feat(desktop): add app and local daemon update controls in Settings
This commit is contained in:
@@ -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<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: ${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,
|
||||
}
|
||||
}
|
||||
@@ -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<DesktopPermissionKind | null>(null);
|
||||
const [isUpdatingLocalDaemon, setIsUpdatingLocalDaemon] = useState(false);
|
||||
const [localDaemonUpdateMessage, setLocalDaemonUpdateMessage] = useState<string | null>(null);
|
||||
const [localDaemonUpdateDiagnostics, setLocalDaemonUpdateDiagnostics] = useState<string | null>(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<string | null>(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() {
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{showDesktopUpdatesSection ? (
|
||||
<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}>Local daemon version</Text>
|
||||
<Text style={styles.updateHintText}>
|
||||
{localDaemonHost
|
||||
? `Connected host: ${localDaemonHost.label}`
|
||||
: "No local daemon detected in Settings yet."}
|
||||
</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.updateStatusText, { color: theme.colors.palette.red[300] }]}>
|
||||
{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}>
|
||||
This updates the daemon on THIS computer only and 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>
|
||||
) : null}
|
||||
|
||||
{/* About */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>About</Text>
|
||||
@@ -960,7 +1290,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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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<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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 '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')
|
||||
}
|
||||
Reference in New Issue
Block a user