refactor(app): isolate desktop settings update and permission flows

This commit is contained in:
Mohamed Boudra
2026-02-22 11:45:37 +07:00
parent 1c83e10c6d
commit 409a3ac5df
11 changed files with 760 additions and 579 deletions
@@ -0,0 +1,102 @@
import { View, Text } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Check } from "lucide-react-native";
import { Button } from "@/components/ui/button";
import type { DesktopPermissionStatus } from "@/desktop/permissions/desktop-permissions";
export interface DesktopPermissionRowProps {
title: string;
status: DesktopPermissionStatus | null;
isRequesting: boolean;
showBorder?: boolean;
onRequest: () => void;
}
export function DesktopPermissionRow({
title,
status,
isRequesting,
showBorder,
onRequest,
}: DesktopPermissionRowProps) {
const { theme } = useUnistyles();
const state = status?.state ?? "unknown";
const isGranted = state === "granted";
const shouldShowDetail =
status !== null &&
status.detail.trim().length > 0 &&
state !== "granted" &&
state !== "prompt" &&
state !== "not-granted";
return (
<View style={[styles.audioRow, showBorder && styles.audioRowBorder]}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>{title}</Text>
</View>
<View style={styles.permissionRowActions}>
{isGranted ? (
<View style={styles.permissionStatusPill}>
<Check size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.permissionStatusText}>Granted</Text>
</View>
) : (
<Button variant="secondary" size="sm" onPress={onRequest} disabled={isRequesting}>
{isRequesting ? "Requesting..." : "Request"}
</Button>
)}
{shouldShowDetail ? <Text style={styles.permissionDetailText}>{status?.detail}</Text> : null}
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
audioRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
},
audioRowBorder: {
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
audioRowContent: {
flex: 1,
marginRight: theme.spacing[3],
},
audioRowTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
permissionRowActions: {
alignItems: "flex-end",
gap: theme.spacing[1],
},
permissionStatusPill: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
borderRadius: theme.borderRadius.full,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface3,
paddingHorizontal: theme.spacing[2],
paddingVertical: 4,
minWidth: 88,
justifyContent: "center",
},
permissionStatusText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
permissionDetailText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
maxWidth: 220,
textAlign: "right",
},
}));
@@ -0,0 +1,105 @@
import { View, Text, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { RotateCw } from "lucide-react-native";
import { DesktopPermissionRow } from "@/desktop/components/desktop-permission-row";
import { useDesktopPermissions } from "@/desktop/permissions/use-desktop-permissions";
export function DesktopPermissionsSection() {
const { theme } = useUnistyles();
const {
isDesktop,
snapshot,
isRefreshing,
requestingPermission,
refreshPermissions,
requestPermission,
} = useDesktopPermissions();
if (!isDesktop) {
return null;
}
const isBusy = isRefreshing || requestingPermission !== null;
return (
<View style={styles.section}>
<View style={styles.permissionSectionHeader}>
<Text style={styles.sectionTitle}>Desktop permissions</Text>
<Pressable
style={({ pressed }) => [
styles.permissionRefreshButton,
isBusy && styles.permissionRefreshButtonDisabled,
pressed && { opacity: 0.85 },
]}
onPress={() => {
void refreshPermissions();
}}
disabled={isBusy}
accessibilityRole="button"
accessibilityLabel="Refresh desktop permissions"
>
<RotateCw size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
<View style={styles.audioCard}>
<DesktopPermissionRow
title="Notifications"
status={snapshot?.notifications ?? null}
isRequesting={requestingPermission === "notifications"}
onRequest={() => {
void requestPermission("notifications");
}}
/>
<DesktopPermissionRow
title="Microphone"
showBorder
status={snapshot?.microphone ?? null}
isRequesting={requestingPermission === "microphone"}
onRequest={() => {
void requestPermission("microphone");
}}
/>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
section: {
marginBottom: theme.spacing[6],
},
sectionTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
marginBottom: 0,
marginLeft: theme.spacing[1],
},
permissionSectionHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
marginBottom: theme.spacing[3],
},
permissionRefreshButton: {
width: 34,
height: 34,
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface2,
alignItems: "center",
justifyContent: "center",
},
permissionRefreshButtonDisabled: {
opacity: theme.opacity[50],
},
audioCard: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
overflow: "hidden",
},
}));
@@ -0,0 +1,392 @@
import { useCallback, useMemo, useState } from "react";
import { Alert, Text, View } from "react-native";
import * as Clipboard from "expo-clipboard";
import { useFocusEffect } from "@react-navigation/native";
import { StyleSheet } from "react-native-unistyles";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { Button } from "@/components/ui/button";
import { confirmDialog } from "@/utils/confirm-dialog";
import { useSessionStore } from "@/stores/session-store";
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
import {
buildDaemonUpdateDiagnostics,
findLikelyLocalDaemonHost,
formatVersionWithPrefix,
isVersionMismatch,
runLocalDaemonUpdate,
shouldShowDesktopUpdateSection,
} from "@/desktop/updates/desktop-updates";
export interface DesktopUpdatesSectionProps {
appVersion: string | null;
}
export function DesktopUpdatesSection({ appVersion }: DesktopUpdatesSectionProps) {
const showDesktopUpdatesSection = shouldShowDesktopUpdateSection();
const { daemons } = useDaemonRegistry();
const [isUpdatingLocalDaemon, setIsUpdatingLocalDaemon] = useState(false);
const [localDaemonUpdateMessage, setLocalDaemonUpdateMessage] = useState<string | null>(null);
const [localDaemonUpdateDiagnostics, setLocalDaemonUpdateDiagnostics] = useState<string | null>(
null
);
const appVersionText = formatVersionWithPrefix(appVersion);
const localDaemonHost = useMemo(() => findLikelyLocalDaemonHost(daemons), [daemons]);
const localDaemonServerId = localDaemonHost?.serverId ?? null;
const localDaemonVersion = useSessionStore(
useCallback(
(state) =>
localDaemonServerId
? (state.sessions[localDaemonServerId]?.serverInfo?.version ?? null)
: null,
[localDaemonServerId]
)
);
const localDaemonVersionText = formatVersionWithPrefix(localDaemonVersion);
const daemonVersionMismatch = isVersionMismatch(appVersion, localDaemonVersion);
const {
isDesktop: isDesktopUpdaterAvailable,
statusText: appUpdateStatusText,
availableUpdate,
errorMessage: appUpdateError,
isChecking: isCheckingAppUpdate,
isInstalling: isInstallingAppUpdate,
checkForUpdates,
installUpdate,
} = useDesktopAppUpdater();
useFocusEffect(
useCallback(() => {
if (!showDesktopUpdatesSection || !isDesktopUpdaterAvailable) {
return undefined;
}
void checkForUpdates({ silent: true });
return undefined;
}, [checkForUpdates, isDesktopUpdaterAvailable, showDesktopUpdatesSection])
);
const handleCheckForAppUpdates = useCallback(() => {
if (!showDesktopUpdatesSection || !isDesktopUpdaterAvailable) {
return;
}
void checkForUpdates();
}, [checkForUpdates, isDesktopUpdaterAvailable, showDesktopUpdatesSection]);
const handleInstallAppUpdate = useCallback(() => {
if (!showDesktopUpdatesSection || !isDesktopUpdaterAvailable) {
return;
}
void confirmDialog({
title: "Install desktop update",
message: "This updates Paseo on this computer.",
confirmLabel: "Install update",
cancelLabel: "Cancel",
})
.then((confirmed) => {
if (!confirmed) {
return;
}
void installUpdate();
})
.catch((error) => {
console.error("[Settings] Failed to open app update confirmation", error);
Alert.alert("Error", "Unable to open the update confirmation dialog.");
});
}, [installUpdate, isDesktopUpdaterAvailable, showDesktopUpdatesSection]);
const handleUpdateLocalDaemon = useCallback(() => {
if (!showDesktopUpdatesSection || !isDesktopUpdaterAvailable) {
return;
}
if (isUpdatingLocalDaemon) {
return;
}
void confirmDialog({
title: "Update local daemon",
message:
"This updates the Paseo daemon on this computer. A restart is required afterwards.",
confirmLabel: "Update daemon",
cancelLabel: "Cancel",
})
.then((confirmed) => {
if (!confirmed) {
return;
}
setIsUpdatingLocalDaemon(true);
setLocalDaemonUpdateMessage(null);
setLocalDaemonUpdateDiagnostics(null);
void runLocalDaemonUpdate()
.then((result) => {
const diagnostics = buildDaemonUpdateDiagnostics(result);
if (result.exitCode !== 0) {
setLocalDaemonUpdateMessage(
`Local daemon update failed (exit code ${result.exitCode}). Copy diagnostics below to troubleshoot.`
);
setLocalDaemonUpdateDiagnostics(diagnostics);
return;
}
setLocalDaemonUpdateMessage(
"Local daemon update finished. Restart is required: run `paseo daemon restart` on this computer."
);
if (result.stdout.trim().length > 0 || result.stderr.trim().length > 0) {
setLocalDaemonUpdateDiagnostics(diagnostics);
}
})
.catch((error) => {
console.error("[Settings] Failed to update local daemon", error);
const message = error instanceof Error ? error.message : String(error);
setLocalDaemonUpdateMessage(
"Local daemon update failed before completion. Copy diagnostics below to troubleshoot."
);
setLocalDaemonUpdateDiagnostics(
buildDaemonUpdateDiagnostics({
exitCode: -1,
stdout: "",
stderr: message,
})
);
})
.finally(() => {
setIsUpdatingLocalDaemon(false);
});
})
.catch((error) => {
console.error("[Settings] Failed to open daemon update confirmation", error);
Alert.alert("Error", "Unable to open the daemon update confirmation dialog.");
});
}, [isDesktopUpdaterAvailable, isUpdatingLocalDaemon, showDesktopUpdatesSection]);
const handleCopyDaemonDiagnostics = useCallback(() => {
if (!localDaemonUpdateDiagnostics) {
return;
}
void Clipboard.setStringAsync(localDaemonUpdateDiagnostics)
.then(() => {
Alert.alert("Copied", "Daemon update diagnostics copied.");
})
.catch((error) => {
console.error("[Settings] Failed to copy daemon update diagnostics", error);
Alert.alert("Error", "Unable to copy diagnostics.");
});
}, [localDaemonUpdateDiagnostics]);
if (!showDesktopUpdatesSection) {
return null;
}
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Updates</Text>
<View style={styles.audioCard}>
<View style={styles.audioRow}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>Desktop app version</Text>
<Text style={styles.updateHintText}>Paseo installed on this computer.</Text>
</View>
<Text style={styles.aboutValue}>{appVersionText}</Text>
</View>
<View style={[styles.audioRow, styles.audioRowBorder]}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>Local daemon version</Text>
<Text style={styles.updateHintText}>
{localDaemonHost
? `Connected host: ${localDaemonHost.label}`
: "No local daemon detected yet."}
</Text>
</View>
<Text style={styles.aboutValue}>{localDaemonVersionText}</Text>
</View>
<View style={[styles.audioRow, styles.audioRowBorder]}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>App update status</Text>
<Text style={styles.updateStatusText}>{appUpdateStatusText}</Text>
{availableUpdate?.latestVersion ? (
<Text style={styles.updateHintText}>
New version available: {formatVersionWithPrefix(availableUpdate.latestVersion)}
</Text>
) : null}
{appUpdateError ? <Text style={styles.updateErrorText}>{appUpdateError}</Text> : null}
</View>
<View style={styles.updateActions}>
<Button
variant="secondary"
size="sm"
onPress={handleCheckForAppUpdates}
disabled={!isDesktopUpdaterAvailable || isCheckingAppUpdate || isInstallingAppUpdate}
>
{isCheckingAppUpdate ? "Checking..." : "Check for updates"}
</Button>
<Button
variant="default"
size="sm"
onPress={handleInstallAppUpdate}
disabled={
!isDesktopUpdaterAvailable ||
isCheckingAppUpdate ||
isInstallingAppUpdate ||
!availableUpdate
}
>
{isInstallingAppUpdate
? "Installing..."
: availableUpdate?.latestVersion
? `Update to ${formatVersionWithPrefix(availableUpdate.latestVersion)}`
: "Update app"}
</Button>
</View>
</View>
<View style={[styles.audioRow, styles.audioRowBorder]}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>Update local daemon</Text>
<Text style={styles.updateHintText}>
Updates the daemon on this computer only. Requires a restart.
</Text>
{localDaemonUpdateMessage ? (
<Text style={styles.updateStatusText}>{localDaemonUpdateMessage}</Text>
) : null}
</View>
<Button
variant="secondary"
size="sm"
onPress={handleUpdateLocalDaemon}
disabled={!isDesktopUpdaterAvailable || isUpdatingLocalDaemon}
>
{isUpdatingLocalDaemon ? "Updating..." : "Update daemon"}
</Button>
</View>
</View>
{daemonVersionMismatch ? (
<View style={styles.updateWarningCard}>
<Text style={styles.updateWarningText}>
Desktop app and local daemon versions differ. Keep both on the same version to avoid
stability issues or breaking changes.
</Text>
</View>
) : null}
{localDaemonUpdateDiagnostics ? (
<View style={styles.updateDiagnosticsCard}>
<View style={styles.updateDiagnosticsHeader}>
<Text style={styles.updateDiagnosticsTitle}>Daemon update diagnostics</Text>
<Button variant="secondary" size="sm" onPress={handleCopyDaemonDiagnostics}>
Copy output
</Button>
</View>
<Text style={styles.updateDiagnosticsText} selectable>
{localDaemonUpdateDiagnostics}
</Text>
</View>
) : null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
section: {
marginBottom: theme.spacing[6],
},
sectionTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
marginBottom: theme.spacing[3],
marginLeft: theme.spacing[1],
},
audioCard: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
overflow: "hidden",
},
audioRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
},
audioRowBorder: {
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
audioRowContent: {
flex: 1,
marginRight: theme.spacing[3],
},
audioRowTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
aboutValue: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
updateHintText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
marginTop: 2,
},
updateStatusText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[1],
},
updateActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
updateErrorText: {
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[1],
},
updateWarningCard: {
marginTop: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.palette.amber[500],
backgroundColor: "rgba(245, 158, 11, 0.12)",
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
},
updateWarningText: {
color: theme.colors.palette.amber[500],
fontSize: theme.fontSize.xs,
},
updateDiagnosticsCard: {
marginTop: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
padding: theme.spacing[3],
gap: theme.spacing[2],
},
updateDiagnosticsHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
},
updateDiagnosticsTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
updateDiagnosticsText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
},
}));
@@ -0,0 +1,128 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
getDesktopPermissionSnapshot,
requestDesktopPermission,
shouldShowDesktopPermissionSection,
type DesktopPermissionKind,
type DesktopPermissionSnapshot,
} from "@/desktop/permissions/desktop-permissions";
export interface UseDesktopPermissionsReturn {
isDesktop: boolean;
snapshot: DesktopPermissionSnapshot | null;
isRefreshing: boolean;
requestingPermission: DesktopPermissionKind | null;
refreshPermissions: () => Promise<void>;
requestPermission: (kind: DesktopPermissionKind) => Promise<void>;
}
const EMPTY_NOTIFICATION_STATUS = {
state: "unknown" as const,
detail: "Notification status has not been checked yet.",
};
const EMPTY_MICROPHONE_STATUS = {
state: "unknown" as const,
detail: "Microphone status has not been checked yet.",
};
export function useDesktopPermissions(): UseDesktopPermissionsReturn {
const isDesktop = shouldShowDesktopPermissionSection();
const isMountedRef = useRef(true);
const [snapshot, setSnapshot] = useState<DesktopPermissionSnapshot | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [requestingPermission, setRequestingPermission] = useState<DesktopPermissionKind | null>(
null
);
useEffect(() => {
return () => {
isMountedRef.current = false;
};
}, []);
const refreshPermissions = useCallback(async () => {
if (!isDesktop) {
return;
}
setIsRefreshing(true);
try {
const nextSnapshot = await getDesktopPermissionSnapshot();
if (!isMountedRef.current) {
return;
}
setSnapshot(nextSnapshot);
} catch (error) {
console.error("[Settings] Failed to load desktop permission status", error);
} finally {
if (isMountedRef.current) {
setIsRefreshing(false);
}
}
}, [isDesktop]);
const requestPermission = useCallback(
async (kind: DesktopPermissionKind) => {
if (!isDesktop) {
return;
}
setRequestingPermission(kind);
try {
const status = await requestDesktopPermission({ kind });
if (!isMountedRef.current) {
return;
}
setSnapshot((previous) => {
const base: DesktopPermissionSnapshot =
previous ?? {
checkedAt: Date.now(),
notifications: EMPTY_NOTIFICATION_STATUS,
microphone: EMPTY_MICROPHONE_STATUS,
};
if (kind === "notifications") {
return {
...base,
checkedAt: Date.now(),
notifications: status,
};
}
return {
...base,
checkedAt: Date.now(),
microphone: status,
};
});
} catch (error) {
console.error(`[Settings] Failed to request ${kind} permission`, error);
} finally {
if (isMountedRef.current) {
setRequestingPermission(null);
}
await refreshPermissions();
}
},
[isDesktop, refreshPermissions]
);
useEffect(() => {
if (!isDesktop) {
return;
}
void refreshPermissions();
}, [isDesktop, refreshPermissions]);
return {
isDesktop,
snapshot,
isRefreshing,
requestingPermission,
refreshPermissions,
requestPermission,
};
}
@@ -0,0 +1,10 @@
import { getTauri } from "@/utils/tauri";
export async function invokeDesktopCommand<T>(command: string): Promise<T> {
const invoke = getTauri()?.core?.invoke;
if (typeof invoke !== "function") {
throw new Error("Tauri invoke() is unavailable in this environment.");
}
return (await invoke(command)) as T;
}
@@ -1,6 +1,7 @@
import { Platform } from 'react-native'
import type { HostProfile } from '@/contexts/daemon-registry-context'
import { getTauri } from '@/utils/tauri'
import { invokeDesktopCommand } from '@/desktop/tauri/invoke-desktop-command'
export interface DesktopAppUpdateCheckResult {
hasUpdate: boolean
@@ -43,15 +44,6 @@ function toNumberOr(defaultValue: number, value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? value : defaultValue
}
async function invokeDesktopCommand<T>(command: string): Promise<T> {
const invoke = getTauri()?.core?.invoke
if (typeof invoke !== 'function') {
throw new Error('Tauri invoke() is unavailable in this environment.')
}
return (await invoke(command)) as T
}
function isLikelyLocalDirectEndpoint(endpoint: string): boolean {
const normalized = endpoint.trim().toLowerCase()
return (
@@ -5,7 +5,7 @@ import {
shouldShowDesktopUpdateSection,
type DesktopAppUpdateCheckResult,
type DesktopAppUpdateInstallResult,
} from '@/utils/desktop-updates'
} from '@/desktop/updates/desktop-updates'
export type DesktopAppUpdateStatus =
| 'idle'
+21 -569
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { useState, useEffect, useRef, useCallback } from "react";
import type { MutableRefObject } from "react";
import {
View,
@@ -8,13 +8,11 @@ import {
Alert,
Platform,
} from "react-native";
import * as Clipboard from "expo-clipboard";
import { router, useLocalSearchParams } from "expo-router";
import { useFocusEffect } from "@react-navigation/native";
import Constants from "expo-constants";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Sun, Moon, Monitor, Globe, Settings, RotateCw, Trash2, Check } from "lucide-react-native";
import { Sun, Moon, Monitor, Globe, Settings, RotateCw, Trash2 } from "lucide-react-native";
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
import { useDaemonRegistry, type HostProfile, type HostConnection } from "@/contexts/daemon-registry-context";
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
@@ -39,23 +37,8 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { useDesktopAppUpdater } from "@/hooks/use-desktop-app-updater";
import {
buildDaemonUpdateDiagnostics,
findLikelyLocalDaemonHost,
formatVersionWithPrefix,
isVersionMismatch,
runLocalDaemonUpdate,
shouldShowDesktopUpdateSection,
} from "@/utils/desktop-updates";
import {
getDesktopPermissionSnapshot,
requestDesktopPermission,
shouldShowDesktopPermissionSection,
type DesktopPermissionKind,
type DesktopPermissionSnapshot,
type DesktopPermissionStatus,
} from "@/utils/desktop-permissions";
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
import { DesktopUpdatesSection } from "@/desktop/components/desktop-updates-section";
const delay = (ms: number) =>
new Promise<void>((resolve) => {
@@ -331,119 +314,11 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
permissionSectionHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
marginBottom: theme.spacing[3],
},
permissionRefreshButton: {
width: 34,
height: 34,
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface2,
alignItems: "center",
justifyContent: "center",
},
permissionRefreshButtonDisabled: {
opacity: theme.opacity[50],
},
permissionRowActions: {
alignItems: "flex-end",
gap: theme.spacing[1],
},
permissionStatusPill: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
borderRadius: theme.borderRadius.full,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface3,
paddingHorizontal: theme.spacing[2],
paddingVertical: 4,
minWidth: 88,
justifyContent: "center",
},
permissionStatusText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
permissionDetailText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
maxWidth: 220,
textAlign: "right",
},
aboutValue: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
updateHintText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
marginTop: 2,
},
updateStatusText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[1],
},
updateActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
updateErrorText: {
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[1],
},
updateWarningCard: {
marginTop: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.palette.amber[500],
backgroundColor: "rgba(245, 158, 11, 0.12)",
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
},
updateWarningText: {
color: theme.colors.palette.amber[500],
fontSize: theme.fontSize.xs,
},
updateDiagnosticsCard: {
marginTop: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
padding: theme.spacing[3],
gap: theme.spacing[2],
},
updateDiagnosticsHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
},
updateDiagnosticsTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
updateDiagnosticsText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontFamily: Platform.select({
web: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
default: "Courier",
}),
},
// Empty state
emptyCard: {
backgroundColor: theme.colors.surface2,
@@ -516,6 +391,19 @@ function formatDaemonVersionBadge(version: string | null): string | null {
return `v${daemonVersion}`;
}
function formatVersionForDisplay(version: string | null | undefined): string {
const value = version?.trim();
if (!value) {
return "\u2014";
}
if (value.startsWith("v")) {
return value;
}
return `v${value}`;
}
export default function SettingsScreen() {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
@@ -539,43 +427,12 @@ export default function SettingsScreen() {
const [isRemovingHost, setIsRemovingHost] = useState(false);
const [editingDaemon, setEditingDaemon] = useState<HostProfile | null>(null);
const [isSavingEdit, setIsSavingEdit] = useState(false);
const [desktopPermissionSnapshot, setDesktopPermissionSnapshot] =
useState<DesktopPermissionSnapshot | null>(null);
const [isRefreshingDesktopPermissions, setIsRefreshingDesktopPermissions] =
useState(false);
const [requestingDesktopPermission, setRequestingDesktopPermission] =
useState<DesktopPermissionKind | null>(null);
const [isUpdatingLocalDaemon, setIsUpdatingLocalDaemon] = useState(false);
const [localDaemonUpdateMessage, setLocalDaemonUpdateMessage] = useState<string | null>(null);
const [localDaemonUpdateDiagnostics, setLocalDaemonUpdateDiagnostics] = useState<string | null>(null);
const isLoading = settingsLoading || daemonLoading;
const showDesktopPermissionSection = shouldShowDesktopPermissionSection();
const showDesktopUpdatesSection = shouldShowDesktopUpdateSection();
const {
isDesktop: isDesktopUpdaterAvailable,
statusText: appUpdateStatusText,
availableUpdate,
errorMessage: appUpdateError,
isChecking: isCheckingAppUpdate,
isInstalling: isInstallingAppUpdate,
checkForUpdates,
installUpdate,
} = useDesktopAppUpdater();
const isMountedRef = useRef(true);
const lastHandledEditHostRef = useRef<string | null>(null);
const isDesktop = Platform.OS === "web";
const appVersion = resolveAppVersion();
const appVersionText = formatVersionWithPrefix(appVersion);
const localDaemonHost = useMemo(() => findLikelyLocalDaemonHost(daemons), [daemons]);
const localDaemonServerId = localDaemonHost?.serverId ?? null;
const localDaemonVersion = useSessionStore(
useCallback(
(state) =>
localDaemonServerId ? (state.sessions[localDaemonServerId]?.serverInfo?.version ?? null) : null,
[localDaemonServerId]
)
);
const localDaemonVersionText = formatVersionWithPrefix(localDaemonVersion);
const daemonVersionMismatch = isVersionMismatch(appVersion, localDaemonVersion);
const appVersionText = formatVersionForDisplay(appVersion);
const editingServerId = editingDaemon?.serverId ?? null;
const editingDaemonLive = editingServerId
? daemons.find((daemon) => daemon.serverId === editingServerId) ?? null
@@ -670,198 +527,6 @@ export default function SettingsScreen() {
pendingEditReopenServerId,
]);
const refreshDesktopPermissions = useCallback(async () => {
if (!showDesktopPermissionSection) return;
setIsRefreshingDesktopPermissions(true);
try {
const snapshot = await getDesktopPermissionSnapshot();
if (!isMountedRef.current) return;
setDesktopPermissionSnapshot(snapshot);
} catch (error) {
console.error("[Settings] Failed to load desktop permission status", error);
} finally {
if (isMountedRef.current) {
setIsRefreshingDesktopPermissions(false);
}
}
}, [showDesktopPermissionSection]);
const handleRequestDesktopPermission = useCallback(
async (kind: DesktopPermissionKind) => {
if (!showDesktopPermissionSection) return;
setRequestingDesktopPermission(kind);
try {
const status = await requestDesktopPermission({ kind });
if (!isMountedRef.current) return;
setDesktopPermissionSnapshot((previous) => {
const base: DesktopPermissionSnapshot = previous ?? {
checkedAt: Date.now(),
notifications: {
state: "unknown",
detail: "Notification status has not been checked yet.",
},
microphone: {
state: "unknown",
detail: "Microphone status has not been checked yet.",
},
};
return kind === "notifications"
? {
...base,
checkedAt: Date.now(),
notifications: status,
}
: {
...base,
checkedAt: Date.now(),
microphone: status,
};
});
} catch (error) {
console.error(`[Settings] Failed to request ${kind} permission`, error);
} finally {
if (isMountedRef.current) {
setRequestingDesktopPermission(null);
}
await refreshDesktopPermissions();
}
},
[refreshDesktopPermissions, showDesktopPermissionSection]
);
useEffect(() => {
if (!showDesktopPermissionSection) return;
void refreshDesktopPermissions();
}, [refreshDesktopPermissions, showDesktopPermissionSection]);
useFocusEffect(
useCallback(() => {
if (!showDesktopUpdatesSection || !isDesktopUpdaterAvailable) {
return undefined;
}
void checkForUpdates({ silent: true });
return undefined;
}, [checkForUpdates, isDesktopUpdaterAvailable, showDesktopUpdatesSection])
);
const handleCheckForAppUpdates = useCallback(() => {
if (!showDesktopUpdatesSection || !isDesktopUpdaterAvailable) {
return;
}
void checkForUpdates();
}, [checkForUpdates, isDesktopUpdaterAvailable, showDesktopUpdatesSection]);
const handleInstallAppUpdate = useCallback(() => {
if (!showDesktopUpdatesSection || !isDesktopUpdaterAvailable) {
return;
}
void confirmDialog({
title: "Install desktop update",
message: "This updates Paseo on this computer.",
confirmLabel: "Install update",
cancelLabel: "Cancel",
})
.then((confirmed) => {
if (!confirmed) {
return;
}
void installUpdate();
})
.catch((error) => {
console.error("[Settings] Failed to open app update confirmation", error);
Alert.alert("Error", "Unable to open the update confirmation dialog.");
});
}, [installUpdate, isDesktopUpdaterAvailable, showDesktopUpdatesSection]);
const handleUpdateLocalDaemon = useCallback(() => {
if (!showDesktopUpdatesSection || !isDesktopUpdaterAvailable) {
return;
}
if (isUpdatingLocalDaemon) {
return;
}
void confirmDialog({
title: "Update local daemon",
message: "This updates the Paseo daemon on this computer. A restart is required afterwards.",
confirmLabel: "Update daemon",
cancelLabel: "Cancel",
})
.then((confirmed) => {
if (!confirmed) {
return;
}
setIsUpdatingLocalDaemon(true);
setLocalDaemonUpdateMessage(null);
setLocalDaemonUpdateDiagnostics(null);
void runLocalDaemonUpdate()
.then((result) => {
const diagnostics = buildDaemonUpdateDiagnostics(result);
if (result.exitCode !== 0) {
setLocalDaemonUpdateMessage(
`Local daemon update failed (exit code ${result.exitCode}). Copy diagnostics below to troubleshoot.`
);
setLocalDaemonUpdateDiagnostics(diagnostics);
return;
}
setLocalDaemonUpdateMessage(
"Local daemon update finished. Restart is required: run `paseo daemon restart` on this computer."
);
if (result.stdout.trim().length > 0 || result.stderr.trim().length > 0) {
setLocalDaemonUpdateDiagnostics(diagnostics);
}
})
.catch((error) => {
console.error("[Settings] Failed to update local daemon", error);
const message = error instanceof Error ? error.message : String(error);
setLocalDaemonUpdateMessage(
`Local daemon update failed before completion. Copy diagnostics below to troubleshoot.`
);
setLocalDaemonUpdateDiagnostics(
buildDaemonUpdateDiagnostics({
exitCode: -1,
stdout: "",
stderr: message,
})
);
})
.finally(() => {
setIsUpdatingLocalDaemon(false);
});
})
.catch((error) => {
console.error("[Settings] Failed to open daemon update confirmation", error);
Alert.alert("Error", "Unable to open the daemon update confirmation dialog.");
});
}, [
isDesktopUpdaterAvailable,
isUpdatingLocalDaemon,
showDesktopUpdatesSection,
]);
const handleCopyDaemonDiagnostics = useCallback(() => {
if (!localDaemonUpdateDiagnostics) {
return;
}
void Clipboard.setStringAsync(localDaemonUpdateDiagnostics)
.then(() => {
Alert.alert("Copied", "Daemon update diagnostics copied.");
})
.catch((error) => {
console.error("[Settings] Failed to copy daemon update diagnostics", error);
Alert.alert("Error", "Unable to copy diagnostics.");
});
}, [localDaemonUpdateDiagnostics]);
const handleSaveEditDaemon = useCallback(async (nextLabelRaw: string) => {
if (!editingServerId) return;
if (isSavingEdit) return;
@@ -1125,167 +790,8 @@ export default function SettingsScreen() {
</View>
</View>
{showDesktopPermissionSection ? (
<View style={styles.section}>
<View style={styles.permissionSectionHeader}>
<Text style={[styles.sectionTitle, { marginBottom: 0 }]}>
Desktop permissions
</Text>
<Pressable
style={({ pressed }) => [
styles.permissionRefreshButton,
(isRefreshingDesktopPermissions ||
requestingDesktopPermission !== null) &&
styles.permissionRefreshButtonDisabled,
pressed && { opacity: 0.85 },
]}
onPress={() => {
void refreshDesktopPermissions();
}}
disabled={
isRefreshingDesktopPermissions ||
requestingDesktopPermission !== null
}
accessibilityRole="button"
accessibilityLabel="Refresh desktop permissions"
>
<RotateCw size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
<View style={styles.audioCard}>
<DesktopPermissionRow
title="Notifications"
status={desktopPermissionSnapshot?.notifications ?? null}
isRequesting={requestingDesktopPermission === "notifications"}
onRequest={() => {
void handleRequestDesktopPermission("notifications");
}}
/>
<DesktopPermissionRow
title="Microphone"
showBorder
status={desktopPermissionSnapshot?.microphone ?? null}
isRequesting={requestingDesktopPermission === "microphone"}
onRequest={() => {
void handleRequestDesktopPermission("microphone");
}}
/>
</View>
</View>
) : null}
{showDesktopUpdatesSection ? (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Updates</Text>
<View style={styles.audioCard}>
<View style={styles.audioRow}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>Desktop app version</Text>
<Text style={styles.updateHintText}>
Paseo installed on this computer.
</Text>
</View>
<Text style={styles.aboutValue}>{appVersionText}</Text>
</View>
<View style={[styles.audioRow, styles.audioRowBorder]}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>Local daemon version</Text>
<Text style={styles.updateHintText}>
{localDaemonHost
? `Connected host: ${localDaemonHost.label}`
: "No local daemon detected yet."}
</Text>
</View>
<Text style={styles.aboutValue}>{localDaemonVersionText}</Text>
</View>
<View style={[styles.audioRow, styles.audioRowBorder]}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>App update status</Text>
<Text style={styles.updateStatusText}>{appUpdateStatusText}</Text>
{availableUpdate?.latestVersion ? (
<Text style={styles.updateHintText}>
New version available: {formatVersionWithPrefix(availableUpdate.latestVersion)}
</Text>
) : null}
{appUpdateError ? (
<Text style={styles.updateErrorText}>
{appUpdateError}
</Text>
) : null}
</View>
<View style={styles.updateActions}>
<Button
variant="secondary"
size="sm"
onPress={handleCheckForAppUpdates}
disabled={!isDesktopUpdaterAvailable || isCheckingAppUpdate || isInstallingAppUpdate}
>
{isCheckingAppUpdate ? "Checking..." : "Check for updates"}
</Button>
<Button
variant="default"
size="sm"
onPress={handleInstallAppUpdate}
disabled={
!isDesktopUpdaterAvailable ||
isCheckingAppUpdate ||
isInstallingAppUpdate ||
!availableUpdate
}
>
{isInstallingAppUpdate
? "Installing..."
: availableUpdate?.latestVersion
? `Update to ${formatVersionWithPrefix(availableUpdate.latestVersion)}`
: "Update app"}
</Button>
</View>
</View>
<View style={[styles.audioRow, styles.audioRowBorder]}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>Update local daemon</Text>
<Text style={styles.updateHintText}>
Updates the daemon on this computer only. Requires a restart.
</Text>
{localDaemonUpdateMessage ? (
<Text style={styles.updateStatusText}>{localDaemonUpdateMessage}</Text>
) : null}
</View>
<Button
variant="secondary"
size="sm"
onPress={handleUpdateLocalDaemon}
disabled={!isDesktopUpdaterAvailable || isUpdatingLocalDaemon}
>
{isUpdatingLocalDaemon ? "Updating..." : "Update daemon"}
</Button>
</View>
</View>
{daemonVersionMismatch ? (
<View style={styles.updateWarningCard}>
<Text style={styles.updateWarningText}>
Desktop app and local daemon versions differ. Keep both on the same version to avoid
stability issues or breaking changes.
</Text>
</View>
) : null}
{localDaemonUpdateDiagnostics ? (
<View style={styles.updateDiagnosticsCard}>
<View style={styles.updateDiagnosticsHeader}>
<Text style={styles.updateDiagnosticsTitle}>Daemon update diagnostics</Text>
<Button variant="secondary" size="sm" onPress={handleCopyDaemonDiagnostics}>
Copy output
</Button>
</View>
<Text style={styles.updateDiagnosticsText} selectable>
{localDaemonUpdateDiagnostics}
</Text>
</View>
) : null}
</View>
) : null}
{isDesktop ? <DesktopPermissionsSection /> : null}
{isDesktop ? <DesktopUpdatesSection appVersion={appVersion} /> : null}
{/* About */}
<View style={styles.section}>
@@ -1669,60 +1175,6 @@ function HostDetailModal({
);
}
interface DesktopPermissionRowProps {
title: string;
status: DesktopPermissionStatus | null;
isRequesting: boolean;
showBorder?: boolean;
onRequest: () => void;
}
function DesktopPermissionRow({
title,
status,
isRequesting,
showBorder,
onRequest,
}: DesktopPermissionRowProps) {
const { theme } = useUnistyles();
const state = status?.state ?? "unknown";
const isGranted = state === "granted";
const shouldShowDetail =
status !== null &&
status.detail.trim().length > 0 &&
state !== "granted" &&
state !== "prompt" &&
state !== "not-granted";
return (
<View style={[styles.audioRow, showBorder && styles.audioRowBorder]}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>{title}</Text>
</View>
<View style={styles.permissionRowActions}>
{isGranted ? (
<View style={styles.permissionStatusPill}>
<Check size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.permissionStatusText}>Granted</Text>
</View>
) : (
<Button
variant="secondary"
size="sm"
onPress={onRequest}
disabled={isRequesting}
>
{isRequesting ? "Requesting..." : "Request"}
</Button>
)}
{shouldShowDetail ? (
<Text style={styles.permissionDetailText}>{status?.detail}</Text>
) : null}
</View>
</View>
);
}
function ConnectionRow({
connection,
latencyMs,