diff --git a/packages/app/src/hooks/use-desktop-app-updater.ts b/packages/app/src/hooks/use-desktop-app-updater.ts new file mode 100644 index 000000000..9628e9eb3 --- /dev/null +++ b/packages/app/src/hooks/use-desktop-app-updater.ts @@ -0,0 +1,182 @@ +import { useCallback, useRef, useState } from 'react' +import { + checkDesktopAppUpdate, + installDesktopAppUpdate, + shouldShowDesktopUpdateSection, + type DesktopAppUpdateCheckResult, + type DesktopAppUpdateInstallResult, +} from '@/utils/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: ${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..703f4f878 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback } from "react"; +import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import type { MutableRefObject } from "react"; import { View, @@ -8,7 +8,9 @@ import { Alert, Platform, } from "react-native"; +import * as Clipboard from "expo-clipboard"; import { router, useLocalSearchParams } from "expo-router"; +import { useFocusEffect } from "@react-navigation/native"; import Constants from "expo-constants"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; @@ -37,6 +39,15 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; +import { useDesktopAppUpdater } from "@/hooks/use-desktop-app-updater"; +import { + buildDaemonUpdateDiagnostics, + findLikelyLocalDaemonHost, + formatVersionWithPrefix, + isVersionMismatch, + runLocalDaemonUpdate, + shouldShowDesktopUpdateSection, +} from "@/utils/desktop-updates"; import { getDesktopPermissionSnapshot, requestDesktopPermission, @@ -373,6 +384,60 @@ const styles = StyleSheet.create((theme) => ({ 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: { + alignItems: "flex-end", + gap: theme.spacing[2], + }, + updateWarningCard: { + marginTop: theme.spacing[3], + borderRadius: theme.borderRadius.md, + 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.md, + borderWidth: 1, + borderColor: theme.colors.border, + backgroundColor: theme.colors.surface1, + padding: theme.spacing[3], + gap: theme.spacing[2], + }, + updateDiagnosticsHeader: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing[2], + }, + updateDiagnosticsTitle: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + }, + updateDiagnosticsText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + fontFamily: Platform.select({ + web: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", + default: "Courier", + }), + }, // Empty state emptyCard: { backgroundColor: theme.colors.surface2, @@ -474,11 +539,37 @@ export default function SettingsScreen() { useState(false); const [requestingDesktopPermission, setRequestingDesktopPermission] = useState(null); + const [isUpdatingLocalDaemon, setIsUpdatingLocalDaemon] = useState(false); + const [localDaemonUpdateMessage, setLocalDaemonUpdateMessage] = useState(null); + const [localDaemonUpdateDiagnostics, setLocalDaemonUpdateDiagnostics] = useState(null); const isLoading = settingsLoading || daemonLoading; const showDesktopPermissionSection = shouldShowDesktopPermissionSection(); + const showDesktopUpdatesSection = shouldShowDesktopUpdateSection(); + const { + isDesktop: isDesktopUpdaterAvailable, + statusText: appUpdateStatusText, + availableUpdate, + errorMessage: appUpdateError, + isChecking: isCheckingAppUpdate, + isInstalling: isInstallingAppUpdate, + checkForUpdates, + installUpdate, + } = useDesktopAppUpdater(); const isMountedRef = useRef(true); const lastHandledEditHostRef = useRef(null); const appVersion = resolveAppVersion(); + const appVersionText = formatVersionWithPrefix(appVersion); + const localDaemonHost = useMemo(() => findLikelyLocalDaemonHost(daemons), [daemons]); + const localDaemonServerId = localDaemonHost?.serverId ?? null; + const localDaemonVersion = useSessionStore( + useCallback( + (state) => + localDaemonServerId ? (state.sessions[localDaemonServerId]?.serverInfo?.version ?? null) : null, + [localDaemonServerId] + ) + ); + const localDaemonVersionText = formatVersionWithPrefix(localDaemonVersion); + const daemonVersionMismatch = isVersionMismatch(appVersion, localDaemonVersion); const editingServerId = editingDaemon?.serverId ?? null; const editingDaemonLive = editingServerId ? daemons.find((daemon) => daemon.serverId === editingServerId) ?? null @@ -640,6 +731,132 @@ export default function SettingsScreen() { void refreshDesktopPermissions(); }, [refreshDesktopPermissions, showDesktopPermissionSection]); + useFocusEffect( + useCallback(() => { + if (!showDesktopUpdatesSection || !isDesktopUpdaterAvailable) { + return undefined; + } + + void checkForUpdates({ silent: true }); + return undefined; + }, [checkForUpdates, isDesktopUpdaterAvailable, showDesktopUpdatesSection]) + ); + + const handleCheckForAppUpdates = useCallback(() => { + if (!showDesktopUpdatesSection || !isDesktopUpdaterAvailable) { + return; + } + void checkForUpdates(); + }, [checkForUpdates, isDesktopUpdaterAvailable, showDesktopUpdatesSection]); + + const handleInstallAppUpdate = useCallback(() => { + if (!showDesktopUpdatesSection || !isDesktopUpdaterAvailable) { + return; + } + + void confirmDialog({ + title: "Install desktop update", + message: "This updates Paseo on this computer.", + confirmLabel: "Install update", + cancelLabel: "Cancel", + }) + .then((confirmed) => { + if (!confirmed) { + return; + } + void installUpdate(); + }) + .catch((error) => { + console.error("[Settings] Failed to open app update confirmation", error); + Alert.alert("Error", "Unable to open the update confirmation dialog."); + }); + }, [installUpdate, isDesktopUpdaterAvailable, showDesktopUpdatesSection]); + + const handleUpdateLocalDaemon = useCallback(() => { + if (!showDesktopUpdatesSection || !isDesktopUpdaterAvailable) { + return; + } + if (isUpdatingLocalDaemon) { + return; + } + + void confirmDialog({ + title: "Update local daemon", + message: + "This updates the Paseo daemon on this computer only. A daemon restart is required after the update.", + confirmLabel: "Update daemon", + cancelLabel: "Cancel", + }) + .then((confirmed) => { + if (!confirmed) { + return; + } + + setIsUpdatingLocalDaemon(true); + setLocalDaemonUpdateMessage(null); + setLocalDaemonUpdateDiagnostics(null); + + void runLocalDaemonUpdate() + .then((result) => { + const diagnostics = buildDaemonUpdateDiagnostics(result); + if (result.exitCode !== 0) { + setLocalDaemonUpdateMessage( + `Local daemon update failed (exit code ${result.exitCode}). Copy diagnostics below to troubleshoot.` + ); + setLocalDaemonUpdateDiagnostics(diagnostics); + return; + } + + setLocalDaemonUpdateMessage( + "Local daemon update finished. Restart is required: run `paseo daemon restart` on this computer." + ); + if (result.stdout.trim().length > 0 || result.stderr.trim().length > 0) { + setLocalDaemonUpdateDiagnostics(diagnostics); + } + }) + .catch((error) => { + console.error("[Settings] Failed to update local daemon", error); + const message = error instanceof Error ? error.message : String(error); + setLocalDaemonUpdateMessage( + `Local daemon update failed before completion. Copy diagnostics below to troubleshoot.` + ); + setLocalDaemonUpdateDiagnostics( + buildDaemonUpdateDiagnostics({ + exitCode: -1, + stdout: "", + stderr: message, + }) + ); + }) + .finally(() => { + setIsUpdatingLocalDaemon(false); + }); + }) + .catch((error) => { + console.error("[Settings] Failed to open daemon update confirmation", error); + Alert.alert("Error", "Unable to open the daemon update confirmation dialog."); + }); + }, [ + isDesktopUpdaterAvailable, + isUpdatingLocalDaemon, + showDesktopUpdatesSection, + ]); + + const handleCopyDaemonDiagnostics = useCallback(() => { + if (!localDaemonUpdateDiagnostics) { + return; + } + + void Clipboard.setStringAsync(localDaemonUpdateDiagnostics) + .then(() => { + Alert.alert("Copied", "Daemon update diagnostics copied."); + }) + .catch((error) => { + console.error("[Settings] Failed to copy daemon update diagnostics", error); + Alert.alert("Error", "Unable to copy diagnostics."); + }); + }, [localDaemonUpdateDiagnostics]); + const handleSaveEditDaemon = useCallback(async (nextLabelRaw: string) => { if (!editingServerId) return; if (isSavingEdit) return; @@ -952,6 +1169,119 @@ export default function SettingsScreen() { ) : null} + {showDesktopUpdatesSection ? ( + + Updates + + + + Desktop app version + + Paseo installed on this computer. + + + {appVersionText} + + + + Local daemon version + + {localDaemonHost + ? `Connected host: ${localDaemonHost.label}` + : "No local daemon detected in Settings yet."} + + + {localDaemonVersionText} + + + + App update status + {appUpdateStatusText} + {availableUpdate?.latestVersion ? ( + + New version available: {formatVersionWithPrefix(availableUpdate.latestVersion)} + + ) : null} + {appUpdateError ? ( + + {appUpdateError} + + ) : null} + + + + + + + + + Update local daemon + + This updates the daemon on THIS computer only and requires a restart. + + {localDaemonUpdateMessage ? ( + {localDaemonUpdateMessage} + ) : null} + + + + + + {daemonVersionMismatch ? ( + + + Desktop app and local daemon versions differ. Keep both on the same version to avoid + stability issues or breaking changes. + + + ) : null} + + {localDaemonUpdateDiagnostics ? ( + + + Daemon update diagnostics + + + + {localDaemonUpdateDiagnostics} + + + ) : null} + + ) : null} + {/* About */} About @@ -960,7 +1290,7 @@ export default function SettingsScreen() { Version - {appVersion ?? "Unavailable"} + {appVersionText} diff --git a/packages/app/src/utils/desktop-updates.test.ts b/packages/app/src/utils/desktop-updates.test.ts new file mode 100644 index 000000000..337d737eb --- /dev/null +++ b/packages/app/src/utils/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('Unavailable') + }) + + 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/utils/desktop-updates.ts b/packages/app/src/utils/desktop-updates.ts new file mode 100644 index 000000000..0a8c39929 --- /dev/null +++ b/packages/app/src/utils/desktop-updates.ts @@ -0,0 +1,174 @@ +import { Platform } from 'react-native' +import type { HostProfile } from '@/contexts/daemon-registry-context' +import { getTauri } from '@/utils/tauri' + +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 +} + +async function invokeDesktopCommand(command: string): Promise { + const invoke = getTauri()?.core?.invoke + if (typeof invoke !== 'function') { + throw new Error('Tauri invoke() is unavailable in this environment.') + } + + return (await invoke(command)) as T +} + +function isLikelyLocalDirectEndpoint(endpoint: string): boolean { + const normalized = endpoint.trim().toLowerCase() + return ( + 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 'Unavailable' + } + + 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) => + daemon.connections.some( + (connection) => + connection.type === 'direct' && isLikelyLocalDirectEndpoint(connection.endpoint) + ) + ) + + return localhostDaemon ?? daemons[0] ?? null +} + +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/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 7deb58512..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,14 +2622,14 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link 0.2.1", ] [[package]] name = "paseo" -version = "0.1.14" +version = "0.1.15" dependencies = [ "log", "serde", @@ -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" + } } }