Polish desktop settings update/local-daemon sections

This commit is contained in:
Mohamed Boudra
2026-02-22 13:28:14 +07:00
parent 44c3ee670f
commit 8ee9e7274d
5 changed files with 297 additions and 259 deletions
@@ -1,114 +1,53 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useState } from "react";
import { Alert, Text, View } from "react-native"; import { Alert, Text, View } from "react-native";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import { useFocusEffect } from "@react-navigation/native"; import { useFocusEffect } from "@react-navigation/native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { confirmDialog } from "@/utils/confirm-dialog"; import { confirmDialog } from "@/utils/confirm-dialog";
import { useSessionStore } from "@/stores/session-store";
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
import { import {
buildDaemonUpdateDiagnostics, buildDaemonUpdateDiagnostics,
findLikelyLocalDaemonHost,
formatVersionWithPrefix, formatVersionWithPrefix,
isLikelyLocalHost, getLocalDaemonVersion,
isVersionMismatch, isVersionMismatch,
runLocalDaemonUpdate, runLocalDaemonUpdate,
shouldShowDesktopUpdateSection, shouldShowDesktopUpdateSection,
} from "@/desktop/updates/desktop-updates"; } from "@/desktop/updates/desktop-updates";
export interface DesktopUpdatesSectionProps { export interface LocalDaemonSectionProps {
appVersion: string | null; appVersion: string | null;
} }
export function DesktopUpdatesSection({ appVersion }: DesktopUpdatesSectionProps) { export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
const showDesktopUpdatesSection = shouldShowDesktopUpdateSection(); const showSection = shouldShowDesktopUpdateSection();
const { daemons } = useDaemonRegistry(); const [localDaemonVersion, setLocalDaemonVersion] = useState<string | null>(null);
const [localDaemonVersionError, setLocalDaemonVersionError] = useState<string | null>(null);
const [isUpdatingLocalDaemon, setIsUpdatingLocalDaemon] = useState(false); const [isUpdatingLocalDaemon, setIsUpdatingLocalDaemon] = useState(false);
const [localDaemonUpdateMessage, setLocalDaemonUpdateMessage] = useState<string | null>(null); const [localDaemonUpdateMessage, setLocalDaemonUpdateMessage] = useState<string | null>(null);
const [localDaemonUpdateDiagnostics, setLocalDaemonUpdateDiagnostics] = useState<string | null>( const [localDaemonUpdateDiagnostics, setLocalDaemonUpdateDiagnostics] = useState<string | null>(
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( useFocusEffect(
useCallback(() => { useCallback(() => {
if (!showDesktopUpdatesSection || !isDesktopUpdaterAvailable) { if (!showSection) {
return undefined; return undefined;
} }
void checkForUpdates({ silent: true }); void getLocalDaemonVersion().then((result) => {
setLocalDaemonVersion(result.version);
setLocalDaemonVersionError(result.error);
});
return undefined; return undefined;
}, [checkForUpdates, isDesktopUpdaterAvailable, showDesktopUpdatesSection]) }, [showSection])
); );
const handleCheckForAppUpdates = useCallback(() => { const localDaemonVersionText = formatVersionWithPrefix(localDaemonVersion);
if (!showDesktopUpdatesSection || !isDesktopUpdaterAvailable) { const daemonVersionMismatch = isVersionMismatch(appVersion, localDaemonVersion);
return; const daemonVersionHint = localDaemonVersionError ?? "Daemon installed on this computer.";
}
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(() => { const handleUpdateLocalDaemon = useCallback(() => {
if (!showDesktopUpdatesSection || !isDesktopUpdaterAvailable) { if (!showSection) {
return; return;
} }
if (isUpdatingLocalDaemon) { if (isUpdatingLocalDaemon) {
@@ -148,6 +87,11 @@ export function DesktopUpdatesSection({ appVersion }: DesktopUpdatesSectionProps
if (result.stdout.trim().length > 0 || result.stderr.trim().length > 0) { if (result.stdout.trim().length > 0 || result.stderr.trim().length > 0) {
setLocalDaemonUpdateDiagnostics(diagnostics); setLocalDaemonUpdateDiagnostics(diagnostics);
} }
void getLocalDaemonVersion().then((versionResult) => {
setLocalDaemonVersion(versionResult.version);
setLocalDaemonVersionError(versionResult.error);
});
}) })
.catch((error) => { .catch((error) => {
console.error("[Settings] Failed to update local daemon", error); console.error("[Settings] Failed to update local daemon", error);
@@ -171,7 +115,7 @@ export function DesktopUpdatesSection({ appVersion }: DesktopUpdatesSectionProps
console.error("[Settings] Failed to open daemon update confirmation", error); console.error("[Settings] Failed to open daemon update confirmation", error);
Alert.alert("Error", "Unable to open the daemon update confirmation dialog."); Alert.alert("Error", "Unable to open the daemon update confirmation dialog.");
}); });
}, [isDesktopUpdaterAvailable, isUpdatingLocalDaemon, showDesktopUpdatesSection]); }, [isUpdatingLocalDaemon, showSection]);
const handleCopyDaemonDiagnostics = useCallback(() => { const handleCopyDaemonDiagnostics = useCallback(() => {
if (!localDaemonUpdateDiagnostics) { if (!localDaemonUpdateDiagnostics) {
@@ -188,82 +132,36 @@ export function DesktopUpdatesSection({ appVersion }: DesktopUpdatesSectionProps
}); });
}, [localDaemonUpdateDiagnostics]); }, [localDaemonUpdateDiagnostics]);
if (!showDesktopUpdatesSection) { if (!showSection) {
return null; return null;
} }
return ( return (
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>Updates</Text> <Text style={styles.sectionTitle}>Local daemon</Text>
<View style={styles.audioCard}> <View style={styles.card}>
<View style={styles.audioRow}> <View style={styles.row}>
<View style={styles.audioRowContent}> <View style={styles.rowContent}>
<Text style={styles.audioRowTitle}>Desktop app version</Text> <Text style={styles.rowTitle}>Version</Text>
<Text style={styles.updateHintText}>Paseo installed on this computer.</Text> <Text style={styles.hintText}>{daemonVersionHint}</Text>
</View> </View>
<Text style={styles.aboutValue}>{appVersionText}</Text> <Text style={styles.valueText}>{localDaemonVersionText}</Text>
</View> </View>
<View style={[styles.audioRow, styles.audioRowBorder]}> <View style={[styles.row, styles.rowBorder]}>
<View style={styles.audioRowContent}> <View style={styles.rowContent}>
<Text style={styles.audioRowTitle}>{daemonVersionTitle}</Text> <Text style={styles.rowTitle}>Update daemon</Text>
<Text style={styles.updateHintText}>{daemonHostHint}</Text> <Text style={styles.hintText}>
</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. Updates the daemon on this computer only. Requires a restart.
</Text> </Text>
{localDaemonUpdateMessage ? ( {localDaemonUpdateMessage ? (
<Text style={styles.updateStatusText}>{localDaemonUpdateMessage}</Text> <Text style={styles.statusText}>{localDaemonUpdateMessage}</Text>
) : null} ) : null}
</View> </View>
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
onPress={handleUpdateLocalDaemon} onPress={handleUpdateLocalDaemon}
disabled={!isDesktopUpdaterAvailable || isUpdatingLocalDaemon} disabled={isUpdatingLocalDaemon}
> >
{isUpdatingLocalDaemon ? "Updating..." : "Update daemon"} {isUpdatingLocalDaemon ? "Updating..." : "Update daemon"}
</Button> </Button>
@@ -271,8 +169,8 @@ export function DesktopUpdatesSection({ appVersion }: DesktopUpdatesSectionProps
</View> </View>
{daemonVersionMismatch ? ( {daemonVersionMismatch ? (
<View style={styles.updateWarningCard}> <View style={styles.warningCard}>
<Text style={styles.updateWarningText}> <Text style={styles.warningText}>
Desktop app and local daemon versions differ. Keep both on the same version to avoid Desktop app and local daemon versions differ. Keep both on the same version to avoid
stability issues or breaking changes. stability issues or breaking changes.
</Text> </Text>
@@ -280,14 +178,14 @@ export function DesktopUpdatesSection({ appVersion }: DesktopUpdatesSectionProps
) : null} ) : null}
{localDaemonUpdateDiagnostics ? ( {localDaemonUpdateDiagnostics ? (
<View style={styles.updateDiagnosticsCard}> <View style={styles.diagnosticsCard}>
<View style={styles.updateDiagnosticsHeader}> <View style={styles.diagnosticsHeader}>
<Text style={styles.updateDiagnosticsTitle}>Daemon update diagnostics</Text> <Text style={styles.diagnosticsTitle}>Daemon update diagnostics</Text>
<Button variant="secondary" size="sm" onPress={handleCopyDaemonDiagnostics}> <Button variant="secondary" size="sm" onPress={handleCopyDaemonDiagnostics}>
Copy output Copy output
</Button> </Button>
</View> </View>
<Text style={styles.updateDiagnosticsText} selectable> <Text style={styles.diagnosticsText} selectable>
{localDaemonUpdateDiagnostics} {localDaemonUpdateDiagnostics}
</Text> </Text>
</View> </View>
@@ -307,58 +205,48 @@ const styles = StyleSheet.create((theme) => ({
marginBottom: theme.spacing[3], marginBottom: theme.spacing[3],
marginLeft: theme.spacing[1], marginLeft: theme.spacing[1],
}, },
audioCard: { card: {
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
borderWidth: 1, borderWidth: 1,
borderColor: theme.colors.border, borderColor: theme.colors.border,
overflow: "hidden", overflow: "hidden",
}, },
audioRow: { row: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
paddingVertical: theme.spacing[4], paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4], paddingHorizontal: theme.spacing[4],
}, },
audioRowBorder: { rowBorder: {
borderTopWidth: 1, borderTopWidth: 1,
borderTopColor: theme.colors.border, borderTopColor: theme.colors.border,
}, },
audioRowContent: { rowContent: {
flex: 1, flex: 1,
marginRight: theme.spacing[3], marginRight: theme.spacing[3],
}, },
audioRowTitle: { rowTitle: {
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
}, },
aboutValue: { valueText: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
}, },
updateHintText: { hintText: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
marginTop: 2, marginTop: 2,
}, },
updateStatusText: { statusText: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
marginTop: theme.spacing[1], marginTop: theme.spacing[1],
}, },
updateActions: { warningCard: {
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], marginTop: theme.spacing[3],
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
borderWidth: 1, borderWidth: 1,
@@ -367,11 +255,11 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
}, },
updateWarningText: { warningText: {
color: theme.colors.palette.amber[500], color: theme.colors.palette.amber[500],
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
}, },
updateDiagnosticsCard: { diagnosticsCard: {
marginTop: theme.spacing[3], marginTop: theme.spacing[3],
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
borderWidth: 1, borderWidth: 1,
@@ -380,17 +268,17 @@ const styles = StyleSheet.create((theme) => ({
padding: theme.spacing[3], padding: theme.spacing[3],
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
updateDiagnosticsHeader: { diagnosticsHeader: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
updateDiagnosticsTitle: { diagnosticsTitle: {
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
}, },
updateDiagnosticsText: { diagnosticsText: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
@@ -1,5 +1,4 @@
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import type { HostProfile } from '@/contexts/daemon-registry-context'
async function loadModuleForPlatform(platform: 'web' | 'ios' | 'android') { async function loadModuleForPlatform(platform: 'web' | 'ios' | 'android') {
vi.resetModules() vi.resetModules()
@@ -7,47 +6,6 @@ async function loadModuleForPlatform(platform: 'web' | 'ios' | 'android') {
return import('./desktop-updates') 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', () => { describe('desktop-updates helpers', () => {
afterEach(() => { afterEach(() => {
vi.doUnmock('react-native') vi.doUnmock('react-native')
@@ -79,20 +37,47 @@ describe('desktop-updates helpers', () => {
expect(formatVersionWithPrefix(null)).toBe('\u2014') expect(formatVersionWithPrefix(null)).toBe('\u2014')
}) })
it('prefers localhost direct host when selecting local daemon', async () => { it('parses valid local daemon version result', async () => {
const { findLikelyLocalDaemonHost } = await loadModuleForPlatform('web') const { parseLocalDaemonVersionResult } = 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') expect(parseLocalDaemonVersionResult({ version: '0.1.15', error: null })).toEqual({
version: '0.1.15',
error: null,
})
}) })
it('falls back to first host when no localhost endpoint exists', async () => { it('parses local daemon version error result', async () => {
const { findLikelyLocalDaemonHost } = await loadModuleForPlatform('web') const { parseLocalDaemonVersionResult } = 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') expect(
parseLocalDaemonVersionResult({ version: null, error: 'paseo command not found in PATH' })
).toEqual({
version: null,
error: 'paseo command not found in PATH',
})
})
it('parses unexpected local daemon version result', async () => {
const { parseLocalDaemonVersionResult } = await loadModuleForPlatform('web')
expect(parseLocalDaemonVersionResult(null)).toEqual({
version: null,
error: 'Unexpected response from version check.',
})
expect(parseLocalDaemonVersionResult('not an object')).toEqual({
version: null,
error: 'Unexpected response from version check.',
})
})
it('trims whitespace in parsed version', async () => {
const { parseLocalDaemonVersionResult } = await loadModuleForPlatform('web')
expect(parseLocalDaemonVersionResult({ version: ' 0.1.15 ', error: null })).toEqual({
version: '0.1.15',
error: null,
})
}) })
it('builds copyable daemon update diagnostics', async () => { it('builds copyable daemon update diagnostics', async () => {
@@ -1,5 +1,4 @@
import { Platform } from 'react-native' import { Platform } from 'react-native'
import type { HostProfile } from '@/contexts/daemon-registry-context'
import { getTauri } from '@/utils/tauri' import { getTauri } from '@/utils/tauri'
import { invokeDesktopCommand } from '@/desktop/tauri/invoke-desktop-command' import { invokeDesktopCommand } from '@/desktop/tauri/invoke-desktop-command'
@@ -23,6 +22,11 @@ export interface LocalDaemonUpdateResult {
stderr: string stderr: string
} }
export interface LocalDaemonVersionResult {
version: string | null
error: string | null
}
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null return typeof value === 'object' && value !== null
} }
@@ -44,24 +48,26 @@ function toNumberOr(defaultValue: number, value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? value : defaultValue 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 { export function shouldShowDesktopUpdateSection(): boolean {
return Platform.OS === 'web' && getTauri() !== null return Platform.OS === 'web' && getTauri() !== null
} }
export function parseLocalDaemonVersionResult(raw: unknown): LocalDaemonVersionResult {
if (!isRecord(raw)) {
return { version: null, error: 'Unexpected response from version check.' }
}
return {
version: toStringOrNull(raw.version),
error: toStringOrNull(raw.error),
}
}
export async function getLocalDaemonVersion(): Promise<LocalDaemonVersionResult> {
const result = await invokeDesktopCommand<unknown>('get_local_daemon_version')
return parseLocalDaemonVersionResult(result)
}
export async function checkDesktopAppUpdate(): Promise<DesktopAppUpdateCheckResult> { export async function checkDesktopAppUpdate(): Promise<DesktopAppUpdateCheckResult> {
const result = await invokeDesktopCommand<unknown>('check_app_update') const result = await invokeDesktopCommand<unknown>('check_app_update')
if (!isRecord(result)) { if (!isRecord(result)) {
@@ -135,27 +141,6 @@ export function formatVersionWithPrefix(version: string | null | undefined): str
return value.startsWith('v') ? value : `v${value}` 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 { export function buildDaemonUpdateDiagnostics(result: LocalDaemonUpdateResult): string {
const stdout = result.stdout.length > 0 ? result.stdout : '(empty)' const stdout = result.stdout.length > 0 ? result.stdout : '(empty)'
const stderr = result.stderr.length > 0 ? result.stderr : '(empty)' const stderr = result.stderr.length > 0 ? result.stderr : '(empty)'
+121 -2
View File
@@ -9,7 +9,9 @@ import {
Platform, Platform,
} from "react-native"; } from "react-native";
import { router, useLocalSearchParams } from "expo-router"; import { router, useLocalSearchParams } from "expo-router";
import { useFocusEffect } from "@react-navigation/native";
import Constants from "expo-constants"; import Constants from "expo-constants";
import appPackage from "../../package.json";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Sun, Moon, Monitor, Globe, Settings, RotateCw, Trash2 } from "lucide-react-native"; import { Sun, Moon, Monitor, Globe, Settings, RotateCw, Trash2 } from "lucide-react-native";
@@ -38,7 +40,9 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section"; import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
import { DesktopUpdatesSection } from "@/desktop/components/desktop-updates-section"; import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
const delay = (ms: number) => const delay = (ms: number) =>
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
@@ -319,6 +323,21 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
}, },
aboutHintText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
marginTop: 2,
},
aboutErrorText: {
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[1],
},
aboutUpdateActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
// Empty state // Empty state
emptyCard: { emptyCard: {
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
@@ -367,6 +386,11 @@ const styles = StyleSheet.create((theme) => ({
})); }));
function resolveAppVersion(): string | null { function resolveAppVersion(): string | null {
const packageVersion = appPackage?.version;
if (typeof packageVersion === "string" && packageVersion.trim().length > 0) {
return packageVersion.trim();
}
const expoVersion = Constants.expoConfig?.version; const expoVersion = Constants.expoConfig?.version;
if (typeof expoVersion === "string" && expoVersion.trim().length > 0) { if (typeof expoVersion === "string" && expoVersion.trim().length > 0) {
return expoVersion.trim(); return expoVersion.trim();
@@ -404,6 +428,100 @@ function formatVersionForDisplay(version: string | null | undefined): string {
return `v${value}`; return `v${value}`;
} }
function DesktopAppUpdateRow() {
const {
isDesktop,
statusText,
availableUpdate,
errorMessage,
isChecking,
isInstalling,
checkForUpdates,
installUpdate,
} = useDesktopAppUpdater();
useFocusEffect(
useCallback(() => {
if (!isDesktop) {
return undefined;
}
void checkForUpdates({ silent: true });
return undefined;
}, [checkForUpdates, isDesktop])
);
const handleCheckForUpdates = useCallback(() => {
if (!isDesktop) {
return;
}
void checkForUpdates();
}, [checkForUpdates, isDesktop]);
const handleInstallUpdate = useCallback(() => {
if (!isDesktop) {
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, isDesktop]);
if (!isDesktop) {
return null;
}
return (
<View style={[styles.audioRow, styles.audioRowBorder]}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>App updates</Text>
<Text style={styles.aboutHintText}>{statusText}</Text>
{availableUpdate?.latestVersion ? (
<Text style={styles.aboutHintText}>
New version available: {formatVersionWithPrefix(availableUpdate.latestVersion)}
</Text>
) : null}
{errorMessage ? <Text style={styles.aboutErrorText}>{errorMessage}</Text> : null}
</View>
<View style={styles.aboutUpdateActions}>
<Button
variant="secondary"
size="sm"
onPress={handleCheckForUpdates}
disabled={isChecking || isInstalling}
>
{isChecking ? "Checking..." : "Check"}
</Button>
<Button
variant="default"
size="sm"
onPress={handleInstallUpdate}
disabled={isChecking || isInstalling || !availableUpdate}
>
{isInstalling
? "Installing..."
: availableUpdate?.latestVersion
? `Update to ${formatVersionWithPrefix(availableUpdate.latestVersion)}`
: "Update"}
</Button>
</View>
</View>
);
}
export default function SettingsScreen() { export default function SettingsScreen() {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -791,7 +909,7 @@ export default function SettingsScreen() {
</View> </View>
{isDesktop ? <DesktopPermissionsSection /> : null} {isDesktop ? <DesktopPermissionsSection /> : null}
{isDesktop ? <DesktopUpdatesSection appVersion={appVersion} /> : null} {isDesktop ? <LocalDaemonSection appVersion={appVersion} /> : null}
{/* About */} {/* About */}
<View style={styles.section}> <View style={styles.section}>
@@ -803,6 +921,7 @@ export default function SettingsScreen() {
</View> </View>
<Text style={styles.aboutValue}>{appVersionText}</Text> <Text style={styles.aboutValue}>{appVersionText}</Text>
</View> </View>
{isDesktop ? <DesktopAppUpdateRow /> : null}
</View> </View>
</View> </View>
</View> </View>
+61
View File
@@ -46,6 +46,13 @@ struct AppUpdateInstallResult {
message: String, message: String,
} }
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalDaemonVersionResult {
version: Option<String>,
error: Option<String>,
}
fn resolve_login_shell() -> String { fn resolve_login_shell() -> String {
std::env::var("SHELL") std::env::var("SHELL")
.ok() .ok()
@@ -54,6 +61,48 @@ fn resolve_login_shell() -> String {
.unwrap_or_else(|| "/bin/zsh".to_string()) .unwrap_or_else(|| "/bin/zsh".to_string())
} }
fn execute_local_daemon_version(shell: &str) -> LocalDaemonVersionResult {
let script = r#"if command -v paseo >/dev/null 2>&1; then
paseo --version
else
echo "paseo command not found in PATH" >&2
exit 127
fi"#;
match Command::new(shell).arg("-lc").arg(script).output() {
Ok(output) => {
if output.status.success() {
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
if version.is_empty() {
LocalDaemonVersionResult {
version: None,
error: Some("paseo --version returned empty output".to_string()),
}
} else {
LocalDaemonVersionResult {
version: Some(version),
error: None,
}
}
} else {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
LocalDaemonVersionResult {
version: None,
error: Some(if stderr.is_empty() {
format!("paseo --version exited with code {}", output.status.code().unwrap_or(1))
} else {
stderr
}),
}
}
}
Err(error) => LocalDaemonVersionResult {
version: None,
error: Some(format!("Failed to run version check: {error}")),
},
}
}
fn execute_local_daemon_update(shell: &str) -> DaemonUpdateCommandResult { fn execute_local_daemon_update(shell: &str) -> DaemonUpdateCommandResult {
let script = r#"if command -v paseo >/dev/null 2>&1; then let script = r#"if command -v paseo >/dev/null 2>&1; then
paseo daemon update paseo daemon update
@@ -76,6 +125,17 @@ fi"#;
} }
} }
#[tauri::command]
async fn get_local_daemon_version() -> LocalDaemonVersionResult {
let shell = resolve_login_shell();
tauri::async_runtime::spawn_blocking(move || execute_local_daemon_version(&shell))
.await
.unwrap_or_else(|error| LocalDaemonVersionResult {
version: None,
error: Some(format!("Version check task failed: {error}")),
})
}
#[tauri::command] #[tauri::command]
async fn run_local_daemon_update() -> DaemonUpdateCommandResult { async fn run_local_daemon_update() -> DaemonUpdateCommandResult {
let shell = resolve_login_shell(); let shell = resolve_login_shell();
@@ -158,6 +218,7 @@ pub fn run() {
.plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_websocket::init()) .plugin(tauri_plugin_websocket::init())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
get_local_daemon_version,
run_local_daemon_update, run_local_daemon_update,
check_app_update, check_app_update,
install_app_update install_app_update