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,