Make remote daemon update failures actionable (#2120)

* fix(settings): show daemon update failures

React Native web ignores Alert.alert, so failed remote updates reset to an enabled button without any user-visible explanation. Keep updater progress and failure as explicit rendered state, including the daemon's error, and cover the real browser-to-daemon failure path.

* test(settings): harden daemon update regression

* fix(settings): disable Desktop-managed daemon updates

Desktop-bundled daemons advertised npm self-update even though the install-origin checks always rejected it. Report Desktop ownership to clients, render actionable guidance, and refuse stale requests before touching npm.
This commit is contained in:
Mohamed Boudra
2026-07-16 21:04:31 +08:00
committed by GitHub
parent f4509fe044
commit 6aba0370ae
33 changed files with 531 additions and 92 deletions
@@ -951,6 +951,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
serverId: serverInfo.serverId,
hostname: serverInfo.hostname,
version: serverInfo.version,
...(serverInfo.desktopManaged !== undefined
? { desktopManaged: serverInfo.desktopManaged }
: {}),
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
...(serverInfo.features ? { features: serverInfo.features } : {}),
});
@@ -1422,6 +1425,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
serverId: serverInfo.serverId,
hostname: serverInfo.hostname,
version: serverInfo.version,
...(serverInfo.desktopManaged !== undefined
? { desktopManaged: serverInfo.desktopManaged }
: {}),
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
...(serverInfo.features ? { features: serverInfo.features } : {}),
});
@@ -1,5 +1,9 @@
import { describe, expect, test } from "vitest";
import { formatHostRuntimeSection, redactAppDiagnosticReport } from "./app-diagnostic-report";
import {
formatHostRuntimeSection,
formatServerInfoSection,
redactAppDiagnosticReport,
} from "./app-diagnostic-report";
import type { HostRuntimeSnapshot } from "@/runtime/host-runtime";
import type { HostProfile } from "@/types/host-connection";
@@ -41,6 +45,18 @@ function makeHost(): HostProfile {
}
describe("app diagnostics report", () => {
test("reports whether the connected daemon is managed by Paseo Desktop", () => {
const report = formatServerInfoSection({
status: "server_info",
serverId: "srv-desktop-managed",
hostname: "desktop-host.local",
version: "0.1.108",
desktopManaged: true,
});
expect(report).toContain("Desktop managed: yes");
});
test("formats connection rows without raw connection details", () => {
const host = makeHost();
const snapshot: HostRuntimeSnapshot = {
@@ -7,6 +7,11 @@ interface DiagnosticEntry {
value: string;
}
function formatDiagnosticBoolean(value: boolean | undefined): string {
if (value === undefined) return "unknown";
return value ? "yes" : "no";
}
export function formatDiagnosticSection(title: string, entries: DiagnosticEntry[]): string {
return [title, ...entries.map((entry) => ` ${entry.label}: ${entry.value}`)].join("\n");
}
@@ -77,6 +82,10 @@ export function formatServerInfoSection(serverInfo: ServerInfoStatusPayload | nu
{ label: "Server ID", value: serverInfo.serverId },
{ label: "Hostname", value: serverInfo.hostname ?? "unknown" },
{ label: "Version", value: serverInfo.version ?? "unknown" },
{
label: "Desktop managed",
value: formatDiagnosticBoolean(serverInfo.desktopManaged),
},
{ label: "Features", value: features.length > 0 ? features.join(", ") : "none" },
]);
}
+2
View File
@@ -1859,6 +1859,8 @@ export const ar: TranslationResources = {
dialogFailedMessage: "غير قادر على فتح مربع حوار تأكيد إعادة التشغيل.",
},
update: {
desktopManagedHint:
"يدير Paseo Desktop هذا البرنامج الخفي. حدّث Paseo Desktop على المضيف.",
title: "Update daemon",
hint: "Update the daemon to the latest version and restart it",
confirm: "Update",
+2
View File
@@ -1868,6 +1868,8 @@ export const en = {
dialogFailedMessage: "Unable to open the restart confirmation dialog.",
},
update: {
desktopManagedHint:
"This daemon is managed by Paseo Desktop. Update Paseo Desktop on the host.",
title: "Update daemon",
hint: "Update the daemon to the latest version and restart it",
confirm: "Update",
+2
View File
@@ -1906,6 +1906,8 @@ export const es: TranslationResources = {
"No se puede abrir el cuadro de diálogo de confirmación de reinicio.",
},
update: {
desktopManagedHint:
"Este daemon está administrado por Paseo Desktop. Actualiza Paseo Desktop en el host.",
title: "Update daemon",
hint: "Update the daemon to the latest version and restart it",
confirm: "Update",
+2
View File
@@ -1909,6 +1909,8 @@ export const fr: TranslationResources = {
"Impossible d'ouvrir la boîte de dialogue de confirmation de redémarrage.",
},
update: {
desktopManagedHint:
"Ce daemon est géré par Paseo Desktop. Mettez à jour Paseo Desktop sur l’hôte.",
title: "Update daemon",
hint: "Update the daemon to the latest version and restart it",
confirm: "Update",
+2
View File
@@ -1880,6 +1880,8 @@ export const ja: TranslationResources = {
dialogFailedMessage: "再起動確認ダイアログを開けませんでした。",
},
update: {
desktopManagedHint:
"このデーモンはPaseo Desktopによって管理されています。ホスト上のPaseo Desktopを更新してください。",
title: "デーモンを更新",
hint: "デーモンを最新バージョンに更新して再起動します",
confirm: "更新",
+2
View File
@@ -1890,6 +1890,8 @@ export const ptBR: TranslationResources = {
dialogFailedMessage: "Não foi possível abrir o diálogo de confirmação de reinício.",
},
update: {
desktopManagedHint:
"Este daemon é gerenciado pelo Paseo Desktop. Atualize o Paseo Desktop no host.",
title: "Atualizar daemon",
hint: "Atualiza o daemon para a versão mais recente e o reinicia",
confirm: "Atualizar",
+2
View File
@@ -1896,6 +1896,8 @@ export const ru: TranslationResources = {
dialogFailedMessage: "Невозможно открыть диалоговое окно подтверждения перезапуска.",
},
update: {
desktopManagedHint:
"Этот демон управляется Paseo Desktop. Обновите Paseo Desktop на хосте.",
title: "Update daemon",
hint: "Update the daemon to the latest version and restart it",
confirm: "Update",
+1
View File
@@ -1837,6 +1837,7 @@ export const zhCN: TranslationResources = {
dialogFailedMessage: "无法打开重启确认对话框。",
},
update: {
desktopManagedHint: "此 Daemon 由 Paseo Desktop 管理。请在 Host 上更新 Paseo Desktop。",
title: "Update daemon",
hint: "Update the daemon to the latest version and restart it",
confirm: "Update",
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { hasDaemonReconnectedAfter } from "./daemon-reconnect";
describe("daemon update reconnect detection", () => {
it("accepts a reconnect performed by the existing daemon client", () => {
const start = {
clientGeneration: 4,
lastOnlineAt: "2026-07-16T10:00:00.000Z",
};
const reconnected = hasDaemonReconnectedAfter(
{
connectionStatus: "online",
clientGeneration: 4,
lastOnlineAt: "2026-07-16T10:00:05.000Z",
},
start,
);
expect(reconnected).toBe(true);
});
it("does not accept the original connection before the daemon restarts", () => {
const start = {
clientGeneration: 4,
lastOnlineAt: "2026-07-16T10:00:00.000Z",
};
const reconnected = hasDaemonReconnectedAfter(
{
connectionStatus: "online",
...start,
},
start,
);
expect(reconnected).toBe(false);
});
});
@@ -0,0 +1,20 @@
export interface DaemonConnectionMarker {
clientGeneration: number;
lastOnlineAt: string | null;
}
interface DaemonConnectionSnapshot extends DaemonConnectionMarker {
connectionStatus: "idle" | "connecting" | "online" | "offline" | "error";
}
export function hasDaemonReconnectedAfter(
snapshot: DaemonConnectionSnapshot | null,
start: DaemonConnectionMarker | null,
): boolean {
if (snapshot?.connectionStatus !== "online") return false;
if (!start) return true;
return (
snapshot.clientGeneration !== start.clientGeneration ||
snapshot.lastOnlineAt !== start.lastOnlineAt
);
}
+108 -59
View File
@@ -24,6 +24,7 @@ import {
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { SettingsTextAreaCard } from "@/components/settings-textarea";
import { Alert as InlineAlert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
@@ -68,6 +69,7 @@ import { ICON_SIZE } from "@/styles/theme";
import type { Theme } from "@/styles/theme";
import { getProviderIcon } from "@/components/provider-icons";
import { BrowserToolsOptInCard } from "./browser-tools-card";
import { hasDaemonReconnectedAfter, type DaemonConnectionMarker } from "./daemon-reconnect";
import { restartDaemonFromSettings } from "./daemon-restart";
const ThemedArrowUp = withUnistyles(ArrowUp);
@@ -365,7 +367,7 @@ export function HostSettingsPage({
{isLocalDaemon ? <LocalDaemonSection /> : null}
{!isLocalDaemon ? <UpdateDaemonCard host={host} /> : null}
{!isLocalDaemon ? <UpdateDaemonCard key={host.serverId} host={host} /> : null}
<RemoveHostSection host={host} isLocalDaemon={isLocalDaemon} onRemoved={onHostRemoved} />
</View>
@@ -755,14 +757,19 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
</View>
);
}
type DaemonUpdateState =
| { status: "idle" }
| { status: "updating"; phase: string }
| { status: "failed"; title: string; message: string };
function UpdateDaemonCard({ host }: { host: HostProfile }) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const daemonClient = useHostRuntimeClient(host.serverId);
const isConnected = useHostRuntimeIsConnected(host.serverId);
const runtime = getHostRuntimeStore();
const [isUpdating, setIsUpdating] = useState(false);
const [progressPhase, setProgressPhase] = useState<string | null>(null);
const [updateState, setUpdateState] = useState<DaemonUpdateState>({ status: "idle" });
const isMountedRef = useRef(true);
const unsubscribeRef = useRef<(() => void) | null>(null);
@@ -772,6 +779,9 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
const supportsSelfUpdate = useSessionStore(
(state) => state.sessions[host.serverId]?.serverInfo?.features?.daemonSelfUpdate === true,
);
const desktopManaged = useSessionStore(
(state) => state.sessions[host.serverId]?.serverInfo?.desktopManaged === true,
);
const appVersion = resolveAppVersion();
const hasVersionMismatch = isVersionMismatch(appVersion, daemonVersion);
@@ -788,11 +798,8 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
[host.serverId, runtime],
);
const hasReconnectedAfter = useCallback(
(startGeneration: number | null) => {
const snapshot = runtime.getSnapshot(host.serverId);
if (!snapshot || !isHostRuntimeConnected(snapshot)) return false;
return startGeneration === null || snapshot.clientGeneration !== startGeneration;
},
(startMarker: DaemonConnectionMarker | null) =>
hasDaemonReconnectedAfter(runtime.getSnapshot(host.serverId), startMarker),
[host.serverId, runtime],
);
@@ -810,27 +817,30 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
);
const waitForDaemonRestart = useCallback(
async (startGeneration: number | null) => {
async (startMarker: DaemonConnectionMarker | null) => {
const disconnectTimeoutMs = 15000;
const reconnectTimeoutMs = 120000; // 2 minutes — npm update + restart can take a while
if (!hasReconnectedAfter(startGeneration) && isHostConnected()) {
if (!hasReconnectedAfter(startMarker) && isHostConnected()) {
await waitForCondition(
() => !isHostConnected() || hasReconnectedAfter(startGeneration),
() => !isHostConnected() || hasReconnectedAfter(startMarker),
disconnectTimeoutMs,
);
}
const reconnected =
hasReconnectedAfter(startGeneration) ||
(await waitForCondition(() => hasReconnectedAfter(startGeneration), reconnectTimeoutMs));
hasReconnectedAfter(startMarker) ||
(await waitForCondition(() => hasReconnectedAfter(startMarker), reconnectTimeoutMs));
if (isMountedRef.current) {
setIsUpdating(false);
setProgressPhase(null);
if (!reconnected) {
Alert.alert(
t("settings.host.daemon.update.unableToReconnectTitle"),
t("settings.host.daemon.update.unableToReconnectMessage", { name: host.label }),
);
setUpdateState({
status: "failed",
title: t("settings.host.daemon.update.unableToReconnectTitle"),
message: t("settings.host.daemon.update.unableToReconnectMessage", {
name: host.label,
}),
});
return;
}
setUpdateState({ status: "idle" });
}
},
[hasReconnectedAfter, host.label, isHostConnected, t, waitForCondition],
@@ -838,17 +848,19 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
const handleUpdate = useCallback(() => {
if (!daemonClient) {
Alert.alert(
t("settings.host.daemon.update.unavailableTitle"),
t("settings.host.daemon.update.unavailableMessage"),
);
setUpdateState({
status: "failed",
title: t("settings.host.daemon.update.unavailableTitle"),
message: t("settings.host.daemon.update.unavailableMessage"),
});
return;
}
if (!isHostConnected()) {
Alert.alert(
t("settings.host.daemon.update.offlineTitle"),
t("settings.host.daemon.update.offlineMessage"),
);
setUpdateState({
status: "failed",
title: t("settings.host.daemon.update.offlineTitle"),
message: t("settings.host.daemon.update.offlineMessage"),
});
return;
}
@@ -860,10 +872,18 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
destructive: false,
})
.then((confirmed) => {
if (!confirmed) return;
const startGeneration = runtime.getSnapshot(host.serverId)?.clientGeneration ?? null;
setIsUpdating(true);
setProgressPhase(t("settings.host.daemon.update.phaseStarting"));
if (!confirmed || !isMountedRef.current) return;
const startSnapshot = runtime.getSnapshot(host.serverId);
const startMarker = startSnapshot
? {
clientGeneration: startSnapshot.clientGeneration,
lastOnlineAt: startSnapshot.lastOnlineAt,
}
: null;
setUpdateState({
status: "updating",
phase: t("settings.host.daemon.update.phaseStarting"),
});
const requestId = `settings_daemon_update_${host.serverId}`;
const unsubscribe = daemonClient.on("daemon.update.progress", (message) => {
@@ -871,13 +891,25 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
if (!isMountedRef.current) return;
const { phase } = message.payload;
if (phase === "starting")
setProgressPhase(t("settings.host.daemon.update.phaseStarting"));
setUpdateState({
status: "updating",
phase: t("settings.host.daemon.update.phaseStarting"),
});
else if (phase === "downloading")
setProgressPhase(t("settings.host.daemon.update.phaseDownloading"));
setUpdateState({
status: "updating",
phase: t("settings.host.daemon.update.phaseDownloading"),
});
else if (phase === "installing")
setProgressPhase(t("settings.host.daemon.update.phaseInstalling"));
setUpdateState({
status: "updating",
phase: t("settings.host.daemon.update.phaseInstalling"),
});
else if (phase === "complete")
setProgressPhase(t("settings.host.daemon.update.phaseComplete"));
setUpdateState({
status: "updating",
phase: t("settings.host.daemon.update.phaseComplete"),
});
});
unsubscribeRef.current = unsubscribe;
@@ -888,18 +920,17 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
unsubscribe();
if (!response.success) {
if (!isMountedRef.current) return undefined;
setIsUpdating(false);
setProgressPhase(null);
Alert.alert(
t("settings.host.daemon.update.requestFailedTitle"),
t("settings.host.daemon.update.requestFailedMessage", {
setUpdateState({
status: "failed",
title: t("settings.host.daemon.update.requestFailedTitle"),
message: t("settings.host.daemon.update.requestFailedMessage", {
error: response.error ?? "Unknown error",
}),
);
});
return undefined;
}
// Update succeeded — wait for daemon to restart and reconnect
void waitForDaemonRestart(startGeneration);
void waitForDaemonRestart(startMarker);
return undefined;
})
.catch((error) => {
@@ -907,23 +938,24 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
unsubscribe();
console.error(`[HostPage] Failed to update daemon ${host.label}`, error);
if (!isMountedRef.current) return;
setIsUpdating(false);
setProgressPhase(null);
Alert.alert(
t("settings.host.daemon.update.requestFailedTitle"),
t("settings.host.daemon.update.requestFailedMessage", {
setUpdateState({
status: "failed",
title: t("settings.host.daemon.update.requestFailedTitle"),
message: t("settings.host.daemon.update.requestFailedMessage", {
error: error instanceof Error ? error.message : "Unknown error",
}),
);
});
});
return;
})
.catch((error) => {
console.error(`[HostPage] Failed to open update confirmation for ${host.label}`, error);
Alert.alert(
t("settings.host.daemon.update.requestFailedTitle"),
t("settings.host.daemon.update.dialogFailedMessage"),
);
if (!isMountedRef.current) return;
setUpdateState({
status: "failed",
title: t("settings.host.daemon.update.requestFailedTitle"),
message: t("settings.host.daemon.update.dialogFailedMessage"),
});
});
}, [daemonClient, host.label, host.serverId, isHostConnected, runtime, t, waitForDaemonRestart]);
@@ -932,33 +964,46 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
[theme.iconSize.sm, theme.colors.foreground],
);
// Don't show if the daemon doesn't support self-update or versions match
if (!supportsSelfUpdate || !hasVersionMismatch) {
const shouldShowUpdate = hasVersionMismatch && (supportsSelfUpdate || desktopManaged);
if (!shouldShowUpdate) {
return null;
}
const buttonLabel = isUpdating
? (progressPhase ?? t("settings.host.daemon.update.updating"))
: t("settings.host.daemon.update.confirm");
const isUpdating = updateState.status === "updating";
const buttonLabel = isUpdating ? updateState.phase : t("settings.host.daemon.update.confirm");
return (
<View style={settingsStyles.card} testID="host-page-update-card">
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.host.daemon.update.title")}</Text>
<Text style={settingsStyles.rowHint}>{t("settings.host.daemon.update.hint")}</Text>
<Text style={settingsStyles.rowHint}>
{desktopManaged
? t("settings.host.daemon.update.desktopManagedHint")
: t("settings.host.daemon.update.hint")}
</Text>
</View>
<Button
variant="outline"
size="sm"
leftIcon={updateIcon}
onPress={handleUpdate}
disabled={isUpdating || !daemonClient || !isConnected}
disabled={desktopManaged || isUpdating || !daemonClient || !isConnected}
testID="host-page-update-button"
>
{buttonLabel}
</Button>
</View>
{updateState.status === "failed" ? (
<View style={styles.updateFailure}>
<InlineAlert
variant="error"
title={updateState.title}
description={updateState.message}
testID="host-page-update-error"
/>
</View>
) : null}
</View>
);
}
@@ -1798,6 +1843,10 @@ const terminalProfileStyles = StyleSheet.create((theme) => ({
}));
const styles = StyleSheet.create((theme) => ({
updateFailure: {
marginHorizontal: theme.spacing[4],
marginBottom: theme.spacing[4],
},
identityEditButton: {
padding: theme.spacing[1],
borderRadius: theme.borderRadius.md,
+16 -1
View File
@@ -306,6 +306,7 @@ export interface DaemonServerInfo {
serverId: string;
hostname: string | null;
version: string | null;
desktopManaged?: boolean;
capabilities?: ServerCapabilities;
features?: ServerInfoStatusPayload["features"];
}
@@ -591,17 +592,26 @@ function isSessionServerInfoUnchanged(input: {
currentServerInfo: SessionState["serverInfo"] | undefined;
nextHostname: string | null;
nextVersion: string | null;
nextDesktopManaged: boolean | undefined;
nextCapabilities: ServerCapabilities | undefined;
nextFeatures: ServerInfoStatusPayload["features"] | undefined;
nextServerId: string;
}): boolean {
const { currentServerInfo, nextHostname, nextVersion, nextCapabilities, nextFeatures } = input;
const {
currentServerInfo,
nextHostname,
nextVersion,
nextDesktopManaged,
nextCapabilities,
nextFeatures,
} = input;
const prevHostname = currentServerInfo?.hostname?.trim() || null;
const prevVersion = currentServerInfo?.version?.trim() || null;
return (
currentServerInfo?.serverId === input.nextServerId &&
prevHostname === nextHostname &&
prevVersion === nextVersion &&
currentServerInfo?.desktopManaged === nextDesktopManaged &&
areServerCapabilitiesEqual(currentServerInfo?.capabilities, nextCapabilities) &&
areServerInfoFeaturesEqual(currentServerInfo?.features, nextFeatures)
);
@@ -721,6 +731,7 @@ export const useSessionStore = create<SessionStore>()(
const nextHostname = info.hostname?.trim() || null;
const nextVersion = info.version?.trim() || null;
const nextDesktopManaged = info.desktopManaged;
const nextCapabilities = info.capabilities;
const nextFeatures = info.features;
@@ -729,6 +740,7 @@ export const useSessionStore = create<SessionStore>()(
currentServerInfo: session.serverInfo,
nextHostname,
nextVersion,
nextDesktopManaged,
nextCapabilities,
nextFeatures,
nextServerId: info.serverId,
@@ -747,6 +759,9 @@ export const useSessionStore = create<SessionStore>()(
serverId: info.serverId,
hostname: nextHostname,
version: nextVersion,
...(nextDesktopManaged !== undefined
? { desktopManaged: nextDesktopManaged }
: {}),
...(nextCapabilities ? { capabilities: nextCapabilities } : {}),
...(nextFeatures ? { features: nextFeatures } : {}),
},