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..7391d0142
--- /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..ea0ebb408
--- /dev/null
+++ b/packages/app/src/desktop/components/desktop-updates-section.tsx
@@ -0,0 +1,398 @@
+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,
+ isLikelyLocalHost,
+ 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 hasLikelyLocalDaemon = isLikelyLocalHost(localDaemonHost);
+ 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 daemonVersionTitle = hasLikelyLocalDaemon
+ ? "Local daemon version"
+ : "Connected daemon version";
+ const daemonHostHint = localDaemonHost
+ ? hasLikelyLocalDaemon
+ ? `Connected host: ${localDaemonHost.label}`
+ : `No local daemon detected. Showing connected host: ${localDaemonHost.label}.`
+ : "No local daemon detected yet.";
+
+ 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}
+
+
+
+ {daemonVersionTitle}
+ {daemonHostHint}
+
+ {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/desktop/updates/desktop-updates.test.ts b/packages/app/src/desktop/updates/desktop-updates.test.ts
new file mode 100644
index 000000000..0ac433a9e
--- /dev/null
+++ b/packages/app/src/desktop/updates/desktop-updates.test.ts
@@ -0,0 +1,110 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { HostProfile } from '@/contexts/daemon-registry-context'
+
+async function loadModuleForPlatform(platform: 'web' | 'ios' | 'android') {
+ vi.resetModules()
+ vi.doMock('react-native', () => ({ Platform: { OS: platform } }))
+ return import('./desktop-updates')
+}
+
+function createHost(input: {
+ serverId: string
+ endpoint: string
+ type?: 'direct' | 'relay'
+}): HostProfile {
+ const now = new Date(0).toISOString()
+
+ if (input.type === 'relay') {
+ return {
+ serverId: input.serverId,
+ label: input.serverId,
+ connections: [
+ {
+ id: `${input.serverId}-relay`,
+ type: 'relay',
+ relayEndpoint: input.endpoint,
+ daemonPublicKeyB64: 'test-key',
+ },
+ ],
+ preferredConnectionId: `${input.serverId}-relay`,
+ createdAt: now,
+ updatedAt: now,
+ }
+ }
+
+ return {
+ serverId: input.serverId,
+ label: input.serverId,
+ connections: [
+ {
+ id: `${input.serverId}-direct`,
+ type: 'direct',
+ endpoint: input.endpoint,
+ },
+ ],
+ preferredConnectionId: `${input.serverId}-direct`,
+ createdAt: now,
+ updatedAt: now,
+ }
+}
+
+describe('desktop-updates helpers', () => {
+ afterEach(() => {
+ vi.doUnmock('react-native')
+ vi.restoreAllMocks()
+ vi.resetModules()
+ })
+
+ it('normalizes versions for app-daemon comparisons', async () => {
+ const { normalizeVersionForComparison } = await loadModuleForPlatform('web')
+
+ expect(normalizeVersionForComparison(' v0.1.15 ')).toBe('0.1.15')
+ expect(normalizeVersionForComparison('0.1.15')).toBe('0.1.15')
+ expect(normalizeVersionForComparison(null)).toBeNull()
+ })
+
+ it('detects version mismatch after normalization', async () => {
+ const { isVersionMismatch } = await loadModuleForPlatform('web')
+
+ expect(isVersionMismatch('v0.1.15', '0.1.15')).toBe(false)
+ expect(isVersionMismatch('0.1.15', '0.1.16')).toBe(true)
+ expect(isVersionMismatch('0.1.15', null)).toBe(false)
+ })
+
+ it('formats display versions with v prefix and unavailable fallback', async () => {
+ const { formatVersionWithPrefix } = await loadModuleForPlatform('web')
+
+ expect(formatVersionWithPrefix('0.2.0')).toBe('v0.2.0')
+ expect(formatVersionWithPrefix('v0.2.0')).toBe('v0.2.0')
+ expect(formatVersionWithPrefix(null)).toBe('\u2014')
+ })
+
+ it('prefers localhost direct host when selecting local daemon', async () => {
+ const { findLikelyLocalDaemonHost } = await loadModuleForPlatform('web')
+ const remote = createHost({ serverId: 'remote', endpoint: '10.0.0.2:6767' })
+ const local = createHost({ serverId: 'local', endpoint: 'localhost:6767' })
+
+ expect(findLikelyLocalDaemonHost([remote, local])?.serverId).toBe('local')
+ })
+
+ it('falls back to first host when no localhost endpoint exists', async () => {
+ const { findLikelyLocalDaemonHost } = await loadModuleForPlatform('web')
+ const relay = createHost({ serverId: 'relay', endpoint: 'relay.paseo.sh:443', type: 'relay' })
+ const remote = createHost({ serverId: 'remote', endpoint: '10.0.0.2:6767' })
+
+ expect(findLikelyLocalDaemonHost([relay, remote])?.serverId).toBe('relay')
+ })
+
+ it('builds copyable daemon update diagnostics', async () => {
+ const { buildDaemonUpdateDiagnostics } = await loadModuleForPlatform('web')
+ const diagnostics = buildDaemonUpdateDiagnostics({
+ exitCode: 1,
+ stdout: 'stdout text',
+ stderr: 'stderr text',
+ })
+
+ expect(diagnostics).toContain('Exit code: 1')
+ expect(diagnostics).toContain('STDOUT:\nstdout text')
+ expect(diagnostics).toContain('STDERR:\nstderr text')
+ })
+})
diff --git a/packages/app/src/desktop/updates/desktop-updates.ts b/packages/app/src/desktop/updates/desktop-updates.ts
new file mode 100644
index 000000000..0610fd672
--- /dev/null
+++ b/packages/app/src/desktop/updates/desktop-updates.ts
@@ -0,0 +1,172 @@
+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
+ currentVersion: string | null
+ latestVersion: string | null
+ body: string | null
+ date: string | null
+}
+
+export interface DesktopAppUpdateInstallResult {
+ installed: boolean
+ version: string | null
+ message: string
+}
+
+export interface LocalDaemonUpdateResult {
+ exitCode: number
+ stdout: string
+ stderr: string
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null
+}
+
+function toStringOrNull(value: unknown): string | null {
+ if (typeof value !== 'string') {
+ return null
+ }
+
+ const trimmed = value.trim()
+ return trimmed.length > 0 ? trimmed : null
+}
+
+function toStringOrEmpty(value: unknown): string {
+ return typeof value === 'string' ? value : ''
+}
+
+function toNumberOr(defaultValue: number, value: unknown): number {
+ return typeof value === 'number' && Number.isFinite(value) ? value : defaultValue
+}
+
+function isLikelyLocalDirectEndpoint(endpoint: string): boolean {
+ const normalized = endpoint.trim().toLowerCase()
+ return (
+ normalized === 'localhost' ||
+ normalized.startsWith('localhost:') ||
+ normalized === '127.0.0.1' ||
+ normalized.startsWith('127.0.0.1:') ||
+ normalized === '::1' ||
+ normalized.startsWith('::1:') ||
+ normalized === '[::1]' ||
+ normalized.startsWith('[::1]:')
+ )
+}
+
+export function shouldShowDesktopUpdateSection(): boolean {
+ return Platform.OS === 'web' && getTauri() !== null
+}
+
+export async function checkDesktopAppUpdate(): Promise {
+ const result = await invokeDesktopCommand('check_app_update')
+ if (!isRecord(result)) {
+ throw new Error('Unexpected response while checking desktop updates.')
+ }
+
+ return {
+ hasUpdate: result.hasUpdate === true,
+ currentVersion: toStringOrNull(result.currentVersion),
+ latestVersion: toStringOrNull(result.latestVersion),
+ body: toStringOrNull(result.body),
+ date: toStringOrNull(result.date),
+ }
+}
+
+export async function installDesktopAppUpdate(): Promise {
+ const result = await invokeDesktopCommand('install_app_update')
+ if (!isRecord(result)) {
+ throw new Error('Unexpected response while installing desktop update.')
+ }
+
+ return {
+ installed: result.installed === true,
+ version: toStringOrNull(result.version),
+ message: toStringOrNull(result.message) ?? 'Update completed.',
+ }
+}
+
+export async function runLocalDaemonUpdate(): Promise {
+ const result = await invokeDesktopCommand('run_local_daemon_update')
+ if (!isRecord(result)) {
+ throw new Error('Unexpected response while updating local daemon.')
+ }
+
+ return {
+ exitCode: toNumberOr(1, result.exitCode),
+ stdout: toStringOrEmpty(result.stdout),
+ stderr: toStringOrEmpty(result.stderr),
+ }
+}
+
+export function normalizeVersionForComparison(version: string | null | undefined): string | null {
+ const value = version?.trim()
+ if (!value) {
+ return null
+ }
+
+ return value.replace(/^v/i, '')
+}
+
+export function isVersionMismatch(
+ appVersion: string | null | undefined,
+ daemonVersion: string | null | undefined
+): boolean {
+ const app = normalizeVersionForComparison(appVersion)
+ const daemon = normalizeVersionForComparison(daemonVersion)
+
+ if (!app || !daemon) {
+ return false
+ }
+
+ return app !== daemon
+}
+
+export function formatVersionWithPrefix(version: string | null | undefined): string {
+ const value = version?.trim()
+ if (!value) {
+ return '\u2014'
+ }
+
+ return value.startsWith('v') ? value : `v${value}`
+}
+
+export function findLikelyLocalDaemonHost(daemons: HostProfile[]): HostProfile | null {
+ if (daemons.length === 0) {
+ return null
+ }
+
+ const localhostDaemon = daemons.find((daemon) => isLikelyLocalHost(daemon))
+
+ return localhostDaemon ?? daemons[0] ?? null
+}
+
+export function isLikelyLocalHost(host: HostProfile | null | undefined): boolean {
+ if (!host) {
+ return false
+ }
+
+ return host.connections.some(
+ (connection) =>
+ connection.type === 'direct' && isLikelyLocalDirectEndpoint(connection.endpoint)
+ )
+}
+
+export function buildDaemonUpdateDiagnostics(result: LocalDaemonUpdateResult): string {
+ const stdout = result.stdout.length > 0 ? result.stdout : '(empty)'
+ const stderr = result.stderr.length > 0 ? result.stderr : '(empty)'
+
+ return [
+ `Exit code: ${result.exitCode}`,
+ '',
+ 'STDOUT:',
+ stdout,
+ '',
+ 'STDERR:',
+ stderr,
+ ].join('\n')
+}
diff --git a/packages/app/src/desktop/updates/use-desktop-app-updater.ts b/packages/app/src/desktop/updates/use-desktop-app-updater.ts
new file mode 100644
index 000000000..da7f949a3
--- /dev/null
+++ b/packages/app/src/desktop/updates/use-desktop-app-updater.ts
@@ -0,0 +1,183 @@
+import { useCallback, useRef, useState } from 'react'
+import {
+ checkDesktopAppUpdate,
+ formatVersionWithPrefix,
+ installDesktopAppUpdate,
+ shouldShowDesktopUpdateSection,
+ type DesktopAppUpdateCheckResult,
+ type DesktopAppUpdateInstallResult,
+} from '@/desktop/updates/desktop-updates'
+
+export type DesktopAppUpdateStatus =
+ | 'idle'
+ | 'checking'
+ | 'up-to-date'
+ | 'available'
+ | 'installing'
+ | 'installed'
+ | 'error'
+
+export interface UseDesktopAppUpdaterReturn {
+ isDesktop: boolean
+ status: DesktopAppUpdateStatus
+ statusText: string
+ availableUpdate: DesktopAppUpdateCheckResult | null
+ errorMessage: string | null
+ lastCheckedAt: number | null
+ isChecking: boolean
+ isInstalling: boolean
+ checkForUpdates: (options?: { silent?: boolean }) => Promise
+ installUpdate: () => Promise
+}
+
+function getErrorMessage(error: unknown): string {
+ if (error instanceof Error && typeof error.message === 'string') {
+ return error.message
+ }
+ return String(error)
+}
+
+function formatStatusText(input: {
+ status: DesktopAppUpdateStatus
+ availableUpdate: DesktopAppUpdateCheckResult | null
+ installMessage: string | null
+}): string {
+ const { status, availableUpdate, installMessage } = input
+
+ if (status === 'checking') {
+ return 'Checking for app updates...'
+ }
+
+ if (status === 'installing') {
+ return 'Installing app update...'
+ }
+
+ if (status === 'up-to-date') {
+ return 'App is up to date.'
+ }
+
+ if (status === 'available') {
+ if (availableUpdate?.latestVersion) {
+ return `Update available: ${formatVersionWithPrefix(availableUpdate.latestVersion)}`
+ }
+ return 'An app update is available.'
+ }
+
+ if (status === 'installed') {
+ return installMessage ?? 'App update installed. Restart required.'
+ }
+
+ if (status === 'error') {
+ return 'Failed to update app.'
+ }
+
+ return 'Update status has not been checked yet.'
+}
+
+export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
+ const isDesktop = shouldShowDesktopUpdateSection()
+ const requestVersionRef = useRef(0)
+ const [status, setStatus] = useState('idle')
+ const [availableUpdate, setAvailableUpdate] = useState(null)
+ const [errorMessage, setErrorMessage] = useState(null)
+ const [installMessage, setInstallMessage] = useState(null)
+ const [lastCheckedAt, setLastCheckedAt] = useState(null)
+
+ const checkForUpdates = useCallback(
+ async (options: { silent?: boolean } = {}) => {
+ if (!isDesktop) {
+ return null
+ }
+
+ const requestVersion = requestVersionRef.current + 1
+ requestVersionRef.current = requestVersion
+
+ if (!options.silent) {
+ setStatus('checking')
+ }
+ setErrorMessage(null)
+
+ try {
+ const result = await checkDesktopAppUpdate()
+ if (requestVersion !== requestVersionRef.current) {
+ return result
+ }
+
+ setInstallMessage(null)
+ setLastCheckedAt(Date.now())
+
+ if (result.hasUpdate) {
+ setAvailableUpdate(result)
+ setStatus('available')
+ } else {
+ setAvailableUpdate(null)
+ setStatus('up-to-date')
+ }
+
+ return result
+ } catch (error) {
+ if (requestVersion !== requestVersionRef.current) {
+ return null
+ }
+
+ const message = getErrorMessage(error)
+ if (options.silent) {
+ console.warn('[DesktopUpdater] Silent update check failed', message)
+ } else {
+ setStatus('error')
+ setErrorMessage(message)
+ }
+ return null
+ }
+ },
+ [isDesktop]
+ )
+
+ const installUpdate = useCallback(async () => {
+ if (!isDesktop) {
+ return null
+ }
+
+ setStatus('installing')
+ setErrorMessage(null)
+
+ try {
+ const result = await installDesktopAppUpdate()
+ setLastCheckedAt(Date.now())
+
+ if (result.installed) {
+ setAvailableUpdate(null)
+ setInstallMessage(result.message)
+ setStatus('installed')
+ } else {
+ setAvailableUpdate(null)
+ setInstallMessage(result.message)
+ setStatus('up-to-date')
+ }
+
+ return result
+ } catch (error) {
+ const message = getErrorMessage(error)
+ setStatus('error')
+ setErrorMessage(message)
+ return null
+ }
+ }, [isDesktop])
+
+ return {
+ isDesktop,
+ status,
+ statusText: formatStatusText({
+ status,
+ availableUpdate,
+ installMessage,
+ }),
+ availableUpdate,
+ errorMessage,
+ lastCheckedAt,
+ isChecking: status === 'checking',
+ isInstalling: status === 'installing',
+ checkForUpdates,
+ installUpdate,
+ }
+}
diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx
index 561761d70..c61a5cae6 100644
--- a/packages/app/src/screens/settings-screen.tsx
+++ b/packages/app/src/screens/settings-screen.tsx
@@ -12,7 +12,7 @@ import { router, useLocalSearchParams } from "expo-router";
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";
@@ -37,14 +37,8 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
-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) => {
@@ -320,54 +314,6 @@ 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,
@@ -445,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();
@@ -468,17 +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 isLoading = settingsLoading || daemonLoading;
- const showDesktopPermissionSection = shouldShowDesktopPermissionSection();
const isMountedRef = useRef(true);
const lastHandledEditHostRef = useRef(null);
+ const isDesktop = Platform.OS === "web";
const appVersion = resolveAppVersion();
+ const appVersionText = formatVersionForDisplay(appVersion);
const editingServerId = editingDaemon?.serverId ?? null;
const editingDaemonLive = editingServerId
? daemons.find((daemon) => daemon.serverId === editingServerId) ?? null
@@ -573,73 +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]);
-
const handleSaveEditDaemon = useCallback(async (nextLabelRaw: string) => {
if (!editingServerId) return;
if (isSavingEdit) return;
@@ -903,54 +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}
+ {isDesktop ? : null}
+ {isDesktop ? : null}
{/* About */}
@@ -960,7 +801,7 @@ export default function SettingsScreen() {
Version
- {appVersion ?? "Unavailable"}
+ {appVersionText}
@@ -1334,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,
diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock
index 32453b001..bfed8a8d8 100644
--- a/packages/desktop/src-tauri/Cargo.lock
+++ b/packages/desktop/src-tauri/Cargo.lock
@@ -75,6 +75,15 @@ version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
+[[package]]
+name = "arbitrary"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
+dependencies = [
+ "derive_arbitrary",
+]
+
[[package]]
name = "arrayvec"
version = "0.7.6"
@@ -756,6 +765,17 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "derive_arbitrary"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
+
[[package]]
name = "derive_more"
version = "0.99.20"
@@ -1027,6 +1047,17 @@ dependencies = [
"rustc_version",
]
+[[package]]
+name = "filetime"
+version = "0.2.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "libredox",
+]
+
[[package]]
name = "find-msvc-tools"
version = "0.1.8"
@@ -1322,8 +1353,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
+ "js-sys",
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
+ "wasm-bindgen",
]
[[package]]
@@ -1333,9 +1366,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
+ "js-sys",
"libc",
"r-efi",
"wasip2",
+ "wasm-bindgen",
]
[[package]]
@@ -1597,6 +1632,23 @@ dependencies = [
"want",
]
+[[package]]
+name = "hyper-rustls"
+version = "0.27.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "rustls-pki-types",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+ "webpki-roots 1.0.5",
+]
+
[[package]]
name = "hyper-util"
version = "0.1.19"
@@ -1990,6 +2042,7 @@ checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
dependencies = [
"bitflags 2.10.0",
"libc",
+ "redox_syscall 0.7.1",
]
[[package]]
@@ -2022,6 +2075,12 @@ dependencies = [
"value-bag",
]
+[[package]]
+name = "lru-slab"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
+
[[package]]
name = "mac"
version = "0.1.1"
@@ -2092,6 +2151,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+[[package]]
+name = "minisign-verify"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e856fdd13623a2f5f2f54676a4ee49502a96a80ef4a62bcedd23d52427c44d43"
+
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2397,6 +2462,18 @@ dependencies = [
"objc2-core-foundation",
]
+[[package]]
+name = "objc2-osa-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
+dependencies = [
+ "bitflags 2.10.0",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-foundation",
+]
+
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
@@ -2482,6 +2559,20 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "osakit"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+ "objc2-osa-kit",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+]
+
[[package]]
name = "pango"
version = "0.18.3"
@@ -2531,7 +2622,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
- "redox_syscall",
+ "redox_syscall 0.5.18",
"smallvec",
"windows-link 0.2.1",
]
@@ -2549,6 +2640,7 @@ dependencies = [
"tauri-plugin-log",
"tauri-plugin-notification",
"tauri-plugin-opener",
+ "tauri-plugin-updater",
"tauri-plugin-websocket",
]
@@ -2903,6 +2995,61 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "quinn"
+version = "0.11.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash",
+ "rustls",
+ "socket2",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
+dependencies = [
+ "bytes",
+ "getrandom 0.3.4",
+ "lru-slab",
+ "rand 0.9.2",
+ "ring",
+ "rustc-hash",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "thiserror 2.0.18",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "once_cell",
+ "socket2",
+ "tracing",
+ "windows-sys 0.60.2",
+]
+
[[package]]
name = "quote"
version = "1.0.43"
@@ -3049,6 +3196,15 @@ dependencies = [
"bitflags 2.10.0",
]
+[[package]]
+name = "redox_syscall"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b"
+dependencies = [
+ "bitflags 2.10.0",
+]
+
[[package]]
name = "redox_users"
version = "0.5.2"
@@ -3132,16 +3288,21 @@ dependencies = [
"http-body",
"http-body-util",
"hyper",
+ "hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
+ "quinn",
+ "rustls",
+ "rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
+ "tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -3151,6 +3312,7 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
+ "webpki-roots 1.0.5",
]
[[package]]
@@ -3236,6 +3398,12 @@ dependencies = [
"serde_json",
]
+[[package]]
+name = "rustc-hash"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
+
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -3278,6 +3446,7 @@ version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
+ "web-time",
"zeroize",
]
@@ -3673,7 +3842,7 @@ dependencies = [
"objc2-foundation",
"objc2-quartz-core",
"raw-window-handle",
- "redox_syscall",
+ "redox_syscall 0.5.18",
"tracing",
"wasm-bindgen",
"web-sys",
@@ -3872,6 +4041,17 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
+[[package]]
+name = "tar"
+version = "0.4.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
+dependencies = [
+ "filetime",
+ "libc",
+ "xattr",
+]
+
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -4112,6 +4292,38 @@ dependencies = [
"zbus",
]
+[[package]]
+name = "tauri-plugin-updater"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27cbc31740f4d507712550694749572ec0e43bdd66992db7599b89fbfd6b167b"
+dependencies = [
+ "base64 0.22.1",
+ "dirs",
+ "flate2",
+ "futures-util",
+ "http",
+ "infer",
+ "log",
+ "minisign-verify",
+ "osakit",
+ "percent-encoding",
+ "reqwest",
+ "semver",
+ "serde",
+ "serde_json",
+ "tar",
+ "tauri",
+ "tauri-plugin",
+ "tempfile",
+ "thiserror 2.0.18",
+ "time",
+ "tokio",
+ "url",
+ "windows-sys 0.60.2",
+ "zip",
+]
+
[[package]]
name = "tauri-plugin-websocket"
version = "2.4.2"
@@ -4936,6 +5148,16 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
[[package]]
name = "webkit2gtk"
version = "2.0.1"
@@ -5583,6 +5805,16 @@ dependencies = [
"pkg-config",
]
+[[package]]
+name = "xattr"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
+dependencies = [
+ "libc",
+ "rustix",
+]
+
[[package]]
name = "yoke"
version = "0.8.1"
@@ -5747,6 +5979,18 @@ dependencies = [
"syn 2.0.114",
]
+[[package]]
+name = "zip"
+version = "4.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
+dependencies = [
+ "arbitrary",
+ "crc32fast",
+ "indexmap 2.13.0",
+ "memchr",
+]
+
[[package]]
name = "zmij"
version = "1.0.15"
diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml
index 81660e055..a62f21c94 100644
--- a/packages/desktop/src-tauri/Cargo.toml
+++ b/packages/desktop/src-tauri/Cargo.toml
@@ -30,7 +30,7 @@ tauri-plugin-dialog = "2"
tauri-plugin-log = "2"
tauri-plugin-notification = "2"
tauri-plugin-opener = "2"
+tauri-plugin-updater = "2"
tauri-plugin-websocket = "2"
-
diff --git a/packages/desktop/src-tauri/capabilities/default.json b/packages/desktop/src-tauri/capabilities/default.json
index 8e304233a..49b7c618c 100644
--- a/packages/desktop/src-tauri/capabilities/default.json
+++ b/packages/desktop/src-tauri/capabilities/default.json
@@ -19,6 +19,7 @@
"dialog:default",
"notification:default",
"opener:default",
+ "updater:default",
"websocket:default"
]
}
diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs
index b10ebbe1b..f583be8e5 100644
--- a/packages/desktop/src-tauri/src/lib.rs
+++ b/packages/desktop/src-tauri/src/lib.rs
@@ -1,9 +1,11 @@
+use serde::Serialize;
+use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, Submenu};
#[cfg(target_os = "macos")]
use tauri::menu::AboutMetadata;
-use tauri::Manager;
-use tauri::WebviewWindow;
+use tauri::{AppHandle, Manager, WebviewWindow};
+use tauri_plugin_updater::UpdaterExt;
// Store zoom as u64 bits (f64 * 100 as integer for atomic ops)
static ZOOM_LEVEL: AtomicU64 = AtomicU64::new(100);
@@ -18,13 +20,148 @@ fn set_zoom_factor(webview: &WebviewWindow, factor: f64) {
let _ = webview.set_zoom(clamped);
}
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct DaemonUpdateCommandResult {
+ exit_code: i32,
+ stdout: String,
+ stderr: String,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct AppUpdateCheckResult {
+ has_update: bool,
+ current_version: String,
+ latest_version: Option,
+ body: Option,
+ date: Option,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct AppUpdateInstallResult {
+ installed: bool,
+ version: Option,
+ message: String,
+}
+
+fn resolve_login_shell() -> String {
+ std::env::var("SHELL")
+ .ok()
+ .map(|value| value.trim().to_string())
+ .filter(|value| !value.is_empty())
+ .unwrap_or_else(|| "/bin/zsh".to_string())
+}
+
+fn execute_local_daemon_update(shell: &str) -> DaemonUpdateCommandResult {
+ let script = r#"if command -v paseo >/dev/null 2>&1; then
+ paseo daemon update
+else
+ echo "paseo command not found in PATH. Ensure Paseo CLI is installed for this user." >&2
+ exit 127
+fi"#;
+
+ match Command::new(shell).arg("-lc").arg(script).output() {
+ Ok(output) => DaemonUpdateCommandResult {
+ exit_code: output.status.code().unwrap_or(1),
+ stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
+ stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
+ },
+ Err(error) => DaemonUpdateCommandResult {
+ exit_code: -1,
+ stdout: String::new(),
+ stderr: format!("Failed to run daemon update command: {error}"),
+ },
+ }
+}
+
+#[tauri::command]
+async fn run_local_daemon_update() -> DaemonUpdateCommandResult {
+ let shell = resolve_login_shell();
+ tauri::async_runtime::spawn_blocking(move || execute_local_daemon_update(&shell))
+ .await
+ .unwrap_or_else(|error| DaemonUpdateCommandResult {
+ exit_code: -1,
+ stdout: String::new(),
+ stderr: format!("Daemon update task failed: {error}"),
+ })
+}
+
+#[tauri::command]
+async fn check_app_update(app: AppHandle) -> Result {
+ let current_version = app.package_info().version.to_string();
+ let updater = app
+ .updater()
+ .map_err(|error| format!("Failed to initialize updater: {error}"))?;
+ let update = updater
+ .check()
+ .await
+ .map_err(|error| format!("Failed to check for updates: {error}"))?;
+
+ if let Some(update) = update {
+ return Ok(AppUpdateCheckResult {
+ has_update: true,
+ current_version,
+ latest_version: Some(update.version.to_string()),
+ body: update.body,
+ date: update.date.map(|date| date.to_string()),
+ });
+ }
+
+ Ok(AppUpdateCheckResult {
+ has_update: false,
+ current_version,
+ latest_version: None,
+ body: None,
+ date: None,
+ })
+}
+
+#[tauri::command]
+async fn install_app_update(app: AppHandle) -> Result {
+ let updater = app
+ .updater()
+ .map_err(|error| format!("Failed to initialize updater: {error}"))?;
+ let update = updater
+ .check()
+ .await
+ .map_err(|error| format!("Failed to check for updates: {error}"))?;
+
+ let Some(update) = update else {
+ return Ok(AppUpdateInstallResult {
+ installed: false,
+ version: None,
+ message: "No update is currently available.".to_string(),
+ });
+ };
+
+ let version = update.version.to_string();
+ update
+ .download_and_install(|_, _| {}, || {})
+ .await
+ .map_err(|error| format!("Failed to download and install update: {error}"))?;
+
+ Ok(AppUpdateInstallResult {
+ installed: true,
+ version: Some(version),
+ message: "Update installed. Restart Paseo to finish applying it.".to_string(),
+ })
+}
+
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_opener::init())
+ .plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_websocket::init())
+ .invoke_handler(tauri::generate_handler![
+ run_local_daemon_update,
+ check_app_update,
+ install_app_update
+ ])
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json
index 7c3d4efd6..746ce22aa 100644
--- a/packages/desktop/src-tauri/tauri.conf.json
+++ b/packages/desktop/src-tauri/tauri.conf.json
@@ -32,6 +32,7 @@
},
"bundle": {
"active": true,
+ "createUpdaterArtifacts": true,
"targets": "all",
"icon": [
"icons/32x32.png",
@@ -44,5 +45,14 @@
"entitlements": "Entitlements.plist",
"infoPlist": "Info.plist"
}
+ },
+ "plugins": {
+ "updater": {
+ "active": true,
+ "endpoints": [
+ "https://github.com/getpaseo/paseo/releases/latest/download/latest.json"
+ ],
+ "pubkey": "REPLACE_WITH_TAURI_UPDATER_PUBLIC_KEY"
+ }
}
}