feat(desktop): add app and local daemon update controls in Settings

This commit is contained in:
Mohamed Boudra
2026-02-22 10:40:12 +07:00
parent d7e2ab4437
commit 231b83dbc2
9 changed files with 1196 additions and 8 deletions
+332 -2
View File
@@ -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>