From 409a3ac5dfb117a35b48c670807ccc37fab948ff Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 22 Feb 2026 11:45:37 +0700 Subject: [PATCH] refactor(app): isolate desktop settings update and permission flows --- .../components/desktop-permission-row.tsx | 102 +++ .../desktop-permissions-section.tsx | 105 ++++ .../components/desktop-updates-section.tsx | 392 ++++++++++++ .../permissions}/desktop-permissions.test.ts | 0 .../permissions}/desktop-permissions.ts | 0 .../permissions/use-desktop-permissions.ts | 128 ++++ .../desktop/tauri/invoke-desktop-command.ts | 10 + .../updates}/desktop-updates.test.ts | 0 .../updates}/desktop-updates.ts | 10 +- .../updates}/use-desktop-app-updater.ts | 2 +- packages/app/src/screens/settings-screen.tsx | 590 +----------------- 11 files changed, 760 insertions(+), 579 deletions(-) create mode 100644 packages/app/src/desktop/components/desktop-permission-row.tsx create mode 100644 packages/app/src/desktop/components/desktop-permissions-section.tsx create mode 100644 packages/app/src/desktop/components/desktop-updates-section.tsx rename packages/app/src/{utils => desktop/permissions}/desktop-permissions.test.ts (100%) rename packages/app/src/{utils => desktop/permissions}/desktop-permissions.ts (100%) create mode 100644 packages/app/src/desktop/permissions/use-desktop-permissions.ts create mode 100644 packages/app/src/desktop/tauri/invoke-desktop-command.ts rename packages/app/src/{utils => desktop/updates}/desktop-updates.test.ts (100%) rename packages/app/src/{utils => desktop/updates}/desktop-updates.ts (94%) rename packages/app/src/{hooks => desktop/updates}/use-desktop-app-updater.ts (99%) diff --git a/packages/app/src/desktop/components/desktop-permission-row.tsx b/packages/app/src/desktop/components/desktop-permission-row.tsx new file mode 100644 index 000000000..bc4240431 --- /dev/null +++ b/packages/app/src/desktop/components/desktop-permission-row.tsx @@ -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 ( + + + {title} + + + {isGranted ? ( + + + Granted + + ) : ( + + )} + {shouldShowDetail ? {status?.detail} : null} + + + ); +} + +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", + }, +})); diff --git a/packages/app/src/desktop/components/desktop-permissions-section.tsx b/packages/app/src/desktop/components/desktop-permissions-section.tsx new file mode 100644 index 000000000..71db9f122 --- /dev/null +++ b/packages/app/src/desktop/components/desktop-permissions-section.tsx @@ -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 ( + + + Desktop permissions + [ + styles.permissionRefreshButton, + isBusy && styles.permissionRefreshButtonDisabled, + pressed && { opacity: 0.85 }, + ]} + onPress={() => { + void refreshPermissions(); + }} + disabled={isBusy} + accessibilityRole="button" + accessibilityLabel="Refresh desktop permissions" + > + + + + + { + void requestPermission("notifications"); + }} + /> + { + void requestPermission("microphone"); + }} + /> + + + ); +} + +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", + }, +})); diff --git a/packages/app/src/desktop/components/desktop-updates-section.tsx b/packages/app/src/desktop/components/desktop-updates-section.tsx new file mode 100644 index 000000000..a86c62573 --- /dev/null +++ b/packages/app/src/desktop/components/desktop-updates-section.tsx @@ -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(null); + const [localDaemonUpdateDiagnostics, setLocalDaemonUpdateDiagnostics] = useState( + 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 ( + + Updates + + + + Desktop app version + Paseo installed on this computer. + + {appVersionText} + + + + Local daemon version + + {localDaemonHost + ? `Connected host: ${localDaemonHost.label}` + : "No local daemon detected yet."} + + + {localDaemonVersionText} + + + + App update status + {appUpdateStatusText} + {availableUpdate?.latestVersion ? ( + + New version available: {formatVersionWithPrefix(availableUpdate.latestVersion)} + + ) : null} + {appUpdateError ? {appUpdateError} : null} + + + + + + + + + Update local daemon + + Updates the daemon on this computer only. Requires a restart. + + {localDaemonUpdateMessage ? ( + {localDaemonUpdateMessage} + ) : null} + + + + + + {daemonVersionMismatch ? ( + + + Desktop app and local daemon versions differ. Keep both on the same version to avoid + stability issues or breaking changes. + + + ) : null} + + {localDaemonUpdateDiagnostics ? ( + + + Daemon update diagnostics + + + + {localDaemonUpdateDiagnostics} + + + ) : null} + + ); +} + +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", + }, +})); diff --git a/packages/app/src/utils/desktop-permissions.test.ts b/packages/app/src/desktop/permissions/desktop-permissions.test.ts similarity index 100% rename from packages/app/src/utils/desktop-permissions.test.ts rename to packages/app/src/desktop/permissions/desktop-permissions.test.ts diff --git a/packages/app/src/utils/desktop-permissions.ts b/packages/app/src/desktop/permissions/desktop-permissions.ts similarity index 100% rename from packages/app/src/utils/desktop-permissions.ts rename to packages/app/src/desktop/permissions/desktop-permissions.ts diff --git a/packages/app/src/desktop/permissions/use-desktop-permissions.ts b/packages/app/src/desktop/permissions/use-desktop-permissions.ts new file mode 100644 index 000000000..f5f7be672 --- /dev/null +++ b/packages/app/src/desktop/permissions/use-desktop-permissions.ts @@ -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; + requestPermission: (kind: DesktopPermissionKind) => Promise; +} + +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(null); + const [isRefreshing, setIsRefreshing] = useState(false); + const [requestingPermission, setRequestingPermission] = useState( + 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, + }; +} diff --git a/packages/app/src/desktop/tauri/invoke-desktop-command.ts b/packages/app/src/desktop/tauri/invoke-desktop-command.ts new file mode 100644 index 000000000..8df181ab1 --- /dev/null +++ b/packages/app/src/desktop/tauri/invoke-desktop-command.ts @@ -0,0 +1,10 @@ +import { getTauri } from "@/utils/tauri"; + +export async function invokeDesktopCommand(command: string): Promise { + 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; +} diff --git a/packages/app/src/utils/desktop-updates.test.ts b/packages/app/src/desktop/updates/desktop-updates.test.ts similarity index 100% rename from packages/app/src/utils/desktop-updates.test.ts rename to packages/app/src/desktop/updates/desktop-updates.test.ts diff --git a/packages/app/src/utils/desktop-updates.ts b/packages/app/src/desktop/updates/desktop-updates.ts similarity index 94% rename from packages/app/src/utils/desktop-updates.ts rename to packages/app/src/desktop/updates/desktop-updates.ts index 24bcf15e1..4887b8a71 100644 --- a/packages/app/src/utils/desktop-updates.ts +++ b/packages/app/src/desktop/updates/desktop-updates.ts @@ -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(command: string): Promise { - 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 ( diff --git a/packages/app/src/hooks/use-desktop-app-updater.ts b/packages/app/src/desktop/updates/use-desktop-app-updater.ts similarity index 99% rename from packages/app/src/hooks/use-desktop-app-updater.ts rename to packages/app/src/desktop/updates/use-desktop-app-updater.ts index 9628e9eb3..1f8454220 100644 --- a/packages/app/src/hooks/use-desktop-app-updater.ts +++ b/packages/app/src/desktop/updates/use-desktop-app-updater.ts @@ -5,7 +5,7 @@ import { shouldShowDesktopUpdateSection, type DesktopAppUpdateCheckResult, type DesktopAppUpdateInstallResult, -} from '@/utils/desktop-updates' +} from '@/desktop/updates/desktop-updates' export type DesktopAppUpdateStatus = | 'idle' diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 1700a4a97..c61a5cae6 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -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((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(null); const [isSavingEdit, setIsSavingEdit] = useState(false); - const [desktopPermissionSnapshot, setDesktopPermissionSnapshot] = - useState(null); - const [isRefreshingDesktopPermissions, setIsRefreshingDesktopPermissions] = - useState(false); - const [requestingDesktopPermission, setRequestingDesktopPermission] = - useState(null); - const [isUpdatingLocalDaemon, setIsUpdatingLocalDaemon] = useState(false); - const [localDaemonUpdateMessage, setLocalDaemonUpdateMessage] = useState(null); - const [localDaemonUpdateDiagnostics, setLocalDaemonUpdateDiagnostics] = useState(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(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() { - {showDesktopPermissionSection ? ( - - - - Desktop permissions - - [ - styles.permissionRefreshButton, - (isRefreshingDesktopPermissions || - requestingDesktopPermission !== null) && - styles.permissionRefreshButtonDisabled, - pressed && { opacity: 0.85 }, - ]} - onPress={() => { - void refreshDesktopPermissions(); - }} - disabled={ - isRefreshingDesktopPermissions || - requestingDesktopPermission !== null - } - accessibilityRole="button" - accessibilityLabel="Refresh desktop permissions" - > - - - - - { - void handleRequestDesktopPermission("notifications"); - }} - /> - { - void handleRequestDesktopPermission("microphone"); - }} - /> - - - ) : null} - - {showDesktopUpdatesSection ? ( - - Updates - - - - Desktop app version - - Paseo installed on this computer. - - - {appVersionText} - - - - Local daemon version - - {localDaemonHost - ? `Connected host: ${localDaemonHost.label}` - : "No local daemon detected yet."} - - - {localDaemonVersionText} - - - - App update status - {appUpdateStatusText} - {availableUpdate?.latestVersion ? ( - - New version available: {formatVersionWithPrefix(availableUpdate.latestVersion)} - - ) : null} - {appUpdateError ? ( - - {appUpdateError} - - ) : null} - - - - - - - - - Update local daemon - - Updates the daemon on this computer only. Requires a restart. - - {localDaemonUpdateMessage ? ( - {localDaemonUpdateMessage} - ) : null} - - - - - - {daemonVersionMismatch ? ( - - - Desktop app and local daemon versions differ. Keep both on the same version to avoid - stability issues or breaking changes. - - - ) : null} - - {localDaemonUpdateDiagnostics ? ( - - - Daemon update diagnostics - - - - {localDaemonUpdateDiagnostics} - - - ) : null} - - ) : null} + {isDesktop ? : null} + {isDesktop ? : null} {/* About */} @@ -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 ( - - - {title} - - - {isGranted ? ( - - - Granted - - ) : ( - - )} - {shouldShowDetail ? ( - {status?.detail} - ) : null} - - - ); -} - function ConnectionRow({ connection, latencyMs,