Merge pull request #69 from getpaseo/feat/auto-update-daemon-app

Add desktop app + local daemon update controls in Settings
This commit is contained in:
Mohamed Boudra
2026-02-22 12:05:39 +07:00
committed by GitHub
16 changed files with 1626 additions and 239 deletions
@@ -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 (
<View style={[styles.audioRow, showBorder && styles.audioRowBorder]}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>{title}</Text>
</View>
<View style={styles.permissionRowActions}>
{isGranted ? (
<View style={styles.permissionStatusPill}>
<Check size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.permissionStatusText}>Granted</Text>
</View>
) : (
<Button variant="secondary" size="sm" onPress={onRequest} disabled={isRequesting}>
{isRequesting ? "Requesting..." : "Request"}
</Button>
)}
{shouldShowDetail ? <Text style={styles.permissionDetailText}>{status?.detail}</Text> : null}
</View>
</View>
);
}
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",
},
}));
@@ -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 (
<View style={styles.section}>
<View style={styles.permissionSectionHeader}>
<Text style={styles.sectionTitle}>Desktop Permissions</Text>
<Pressable
style={({ pressed }) => [
styles.permissionRefreshButton,
isBusy && styles.permissionRefreshButtonDisabled,
pressed && { opacity: 0.85 },
]}
onPress={() => {
void refreshPermissions();
}}
disabled={isBusy}
accessibilityRole="button"
accessibilityLabel="Refresh desktop permissions"
>
<RotateCw size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
<View style={styles.audioCard}>
<DesktopPermissionRow
title="Notifications"
status={snapshot?.notifications ?? null}
isRequesting={requestingPermission === "notifications"}
onRequest={() => {
void requestPermission("notifications");
}}
/>
<DesktopPermissionRow
title="Microphone"
showBorder
status={snapshot?.microphone ?? null}
isRequesting={requestingPermission === "microphone"}
onRequest={() => {
void requestPermission("microphone");
}}
/>
</View>
</View>
);
}
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",
},
}));
@@ -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<string | null>(null);
const [localDaemonUpdateDiagnostics, setLocalDaemonUpdateDiagnostics] = useState<string | null>(
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 (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Updates</Text>
<View style={styles.audioCard}>
<View style={styles.audioRow}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>Desktop app version</Text>
<Text style={styles.updateHintText}>Paseo installed on this computer.</Text>
</View>
<Text style={styles.aboutValue}>{appVersionText}</Text>
</View>
<View style={[styles.audioRow, styles.audioRowBorder]}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>{daemonVersionTitle}</Text>
<Text style={styles.updateHintText}>{daemonHostHint}</Text>
</View>
<Text style={styles.aboutValue}>{localDaemonVersionText}</Text>
</View>
<View style={[styles.audioRow, styles.audioRowBorder]}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>App update status</Text>
<Text style={styles.updateStatusText}>{appUpdateStatusText}</Text>
{availableUpdate?.latestVersion ? (
<Text style={styles.updateHintText}>
New version available: {formatVersionWithPrefix(availableUpdate.latestVersion)}
</Text>
) : null}
{appUpdateError ? <Text style={styles.updateErrorText}>{appUpdateError}</Text> : null}
</View>
<View style={styles.updateActions}>
<Button
variant="secondary"
size="sm"
onPress={handleCheckForAppUpdates}
disabled={!isDesktopUpdaterAvailable || isCheckingAppUpdate || isInstallingAppUpdate}
>
{isCheckingAppUpdate ? "Checking..." : "Check for updates"}
</Button>
<Button
variant="default"
size="sm"
onPress={handleInstallAppUpdate}
disabled={
!isDesktopUpdaterAvailable ||
isCheckingAppUpdate ||
isInstallingAppUpdate ||
!availableUpdate
}
>
{isInstallingAppUpdate
? "Installing..."
: availableUpdate?.latestVersion
? `Update to ${formatVersionWithPrefix(availableUpdate.latestVersion)}`
: "Update app"}
</Button>
</View>
</View>
<View style={[styles.audioRow, styles.audioRowBorder]}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>Update local daemon</Text>
<Text style={styles.updateHintText}>
Updates the daemon on this computer only. Requires a restart.
</Text>
{localDaemonUpdateMessage ? (
<Text style={styles.updateStatusText}>{localDaemonUpdateMessage}</Text>
) : null}
</View>
<Button
variant="secondary"
size="sm"
onPress={handleUpdateLocalDaemon}
disabled={!isDesktopUpdaterAvailable || isUpdatingLocalDaemon}
>
{isUpdatingLocalDaemon ? "Updating..." : "Update daemon"}
</Button>
</View>
</View>
{daemonVersionMismatch ? (
<View style={styles.updateWarningCard}>
<Text style={styles.updateWarningText}>
Desktop app and local daemon versions differ. Keep both on the same version to avoid
stability issues or breaking changes.
</Text>
</View>
) : null}
{localDaemonUpdateDiagnostics ? (
<View style={styles.updateDiagnosticsCard}>
<View style={styles.updateDiagnosticsHeader}>
<Text style={styles.updateDiagnosticsTitle}>Daemon update diagnostics</Text>
<Button variant="secondary" size="sm" onPress={handleCopyDaemonDiagnostics}>
Copy output
</Button>
</View>
<Text style={styles.updateDiagnosticsText} selectable>
{localDaemonUpdateDiagnostics}
</Text>
</View>
) : null}
</View>
);
}
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",
},
}));
@@ -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<void>;
requestPermission: (kind: DesktopPermissionKind) => Promise<void>;
}
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<DesktopPermissionSnapshot | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [requestingPermission, setRequestingPermission] = useState<DesktopPermissionKind | null>(
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,
};
}
@@ -0,0 +1,10 @@
import { getTauri } from "@/utils/tauri";
export async function invokeDesktopCommand<T>(command: string): Promise<T> {
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;
}
@@ -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')
})
})
@@ -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<string, unknown> {
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<DesktopAppUpdateCheckResult> {
const result = await invokeDesktopCommand<unknown>('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<DesktopAppUpdateInstallResult> {
const result = await invokeDesktopCommand<unknown>('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<LocalDaemonUpdateResult> {
const result = await invokeDesktopCommand<unknown>('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')
}
@@ -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<DesktopAppUpdateCheckResult | null>
installUpdate: () => Promise<DesktopAppUpdateInstallResult | null>
}
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<DesktopAppUpdateStatus>('idle')
const [availableUpdate, setAvailableUpdate] = useState<DesktopAppUpdateCheckResult | null>(null)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [installMessage, setInstallMessage] = useState<string | null>(null)
const [lastCheckedAt, setLastCheckedAt] = useState<number | null>(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,
}
}
+21 -234
View File
@@ -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<void>((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<HostProfile | null>(null);
const [isSavingEdit, setIsSavingEdit] = useState(false);
const [desktopPermissionSnapshot, setDesktopPermissionSnapshot] =
useState<DesktopPermissionSnapshot | null>(null);
const [isRefreshingDesktopPermissions, setIsRefreshingDesktopPermissions] =
useState(false);
const [requestingDesktopPermission, setRequestingDesktopPermission] =
useState<DesktopPermissionKind | null>(null);
const isLoading = settingsLoading || daemonLoading;
const showDesktopPermissionSection = shouldShowDesktopPermissionSection();
const isMountedRef = useRef(true);
const lastHandledEditHostRef = useRef<string | null>(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() {
</View>
</View>
{showDesktopPermissionSection ? (
<View style={styles.section}>
<View style={styles.permissionSectionHeader}>
<Text style={[styles.sectionTitle, { marginBottom: 0 }]}>
Desktop permissions
</Text>
<Pressable
style={({ pressed }) => [
styles.permissionRefreshButton,
(isRefreshingDesktopPermissions ||
requestingDesktopPermission !== null) &&
styles.permissionRefreshButtonDisabled,
pressed && { opacity: 0.85 },
]}
onPress={() => {
void refreshDesktopPermissions();
}}
disabled={
isRefreshingDesktopPermissions ||
requestingDesktopPermission !== null
}
accessibilityRole="button"
accessibilityLabel="Refresh desktop permissions"
>
<RotateCw size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
<View style={styles.audioCard}>
<DesktopPermissionRow
title="Notifications"
status={desktopPermissionSnapshot?.notifications ?? null}
isRequesting={requestingDesktopPermission === "notifications"}
onRequest={() => {
void handleRequestDesktopPermission("notifications");
}}
/>
<DesktopPermissionRow
title="Microphone"
showBorder
status={desktopPermissionSnapshot?.microphone ?? null}
isRequesting={requestingDesktopPermission === "microphone"}
onRequest={() => {
void handleRequestDesktopPermission("microphone");
}}
/>
</View>
</View>
) : null}
{isDesktop ? <DesktopPermissionsSection /> : null}
{isDesktop ? <DesktopUpdatesSection appVersion={appVersion} /> : null}
{/* About */}
<View style={styles.section}>
@@ -960,7 +801,7 @@ export default function SettingsScreen() {
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>Version</Text>
</View>
<Text style={styles.aboutValue}>{appVersion ?? "Unavailable"}</Text>
<Text style={styles.aboutValue}>{appVersionText}</Text>
</View>
</View>
</View>
@@ -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 (
<View style={[styles.audioRow, showBorder && styles.audioRowBorder]}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>{title}</Text>
</View>
<View style={styles.permissionRowActions}>
{isGranted ? (
<View style={styles.permissionStatusPill}>
<Check size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.permissionStatusText}>Granted</Text>
</View>
) : (
<Button
variant="secondary"
size="sm"
onPress={onRequest}
disabled={isRequesting}
>
{isRequesting ? "Requesting..." : "Request"}
</Button>
)}
{shouldShowDetail ? (
<Text style={styles.permissionDetailText}>{status?.detail}</Text>
) : null}
</View>
</View>
);
}
function ConnectionRow({
connection,
latencyMs,
+246 -2
View File
@@ -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"
+1 -1
View File
@@ -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"
@@ -19,6 +19,7 @@
"dialog:default",
"notification:default",
"opener:default",
"updater:default",
"websocket:default"
]
}
+139 -2
View File
@@ -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<String>,
body: Option<String>,
date: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct AppUpdateInstallResult {
installed: bool,
version: Option<String>,
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<AppUpdateCheckResult, String> {
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<AppUpdateInstallResult, 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}"))?;
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(
@@ -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"
}
}
}