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:
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user