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" + } } }