Files
openclaw/ui/src/app/device-auth-migration.ts
Peter Steinberger 1e3192c0b4 refactor(ui): model gateway connection state as one closed phase union (#112909)
* refactor(ui): model gateway connection state as one closed phase union

ApplicationGatewaySnapshot carried connected + reconnecting as parallel
booleans whose illegal combination (both true) was representable, and
every consumer re-derived the same flag logic. The snapshot now carries
phase: "stopped" | "connecting" | "connected" | "reconnecting" |
"offline"; offlineStable stays as the store-owned 2s debounced
presentation boolean, and lastError/lastErrorCode are unchanged.

Mapping preserves shipped behavior exactly: never-connected terminal
closes return to "stopped" (login gate), established-connection drops
with retry are "reconnecting" (shell stays mounted), without retry
"offline". Component props stay boolean, derived once at the app-host
boundary; no compat aliases remain on the snapshot.

Closes #112741.

* fix(ui): migrate rebased-in test fixtures to the gateway phase union

* fix(ui): migrate post-rebase gateway snapshot readers to the phase union

* fix(ui): migrate document-title test harness snapshot to the phase union
2026-07-23 07:00:26 -07:00

122 lines
3.9 KiB
TypeScript

import type { GatewayBrowserClient } from "../api/gateway.ts";
import { t } from "../i18n/index.ts";
import { peekStoredDeviceIdentityId } from "../lib/nodes/index.ts";
import type { ApplicationGateway } from "./gateway.ts";
import "../components/device-auth-migration-banner.ts";
export type DeviceAuthMigrationSnapshot = {
requestId: string | null;
busy: boolean;
error: string | null;
};
export type DeviceAuthMigrationController = ReturnType<typeof createDeviceAuthMigrationController>;
const EMPTY_SNAPSHOT: DeviceAuthMigrationSnapshot = {
requestId: null,
busy: false,
error: null,
};
export function createDeviceAuthMigrationController(params: {
gateway: ApplicationGateway;
isCurrent: (client: GatewayBrowserClient, epoch: number) => boolean;
onChange: (snapshot: DeviceAuthMigrationSnapshot) => void;
}) {
let snapshot = EMPTY_SNAPSHOT;
let generation = 0;
let disposed = false;
const update = (patch: Partial<DeviceAuthMigrationSnapshot>) => {
snapshot = { ...snapshot, ...patch };
params.onChange(snapshot);
};
return {
reset() {
generation += 1;
update(EMPTY_SNAPSHOT);
},
async refresh(client: GatewayBrowserClient, epoch: number) {
const migrationPending = params.gateway.snapshot.hello?.deviceAuthMigration?.pending === true;
const deviceId = peekStoredDeviceIdentityId();
if (!migrationPending || !params.isCurrent(client, epoch)) {
generation += 1;
update(EMPTY_SNAPSHOT);
return;
}
if (!deviceId) {
generation += 1;
update({
...EMPTY_SNAPSHOT,
error: t("login.deviceAuthMigration.secureContextRequired"),
});
return;
}
const refreshGeneration = ++generation;
try {
const result = await client.request<{
pending?: Array<{ requestId?: unknown; deviceId?: unknown }>;
}>("device.pair.list", {});
if (disposed || refreshGeneration !== generation || !params.isCurrent(client, epoch)) {
return;
}
const ownRequest = result.pending?.find(
(entry) => entry.deviceId === deviceId && typeof entry.requestId === "string",
);
update({
requestId: typeof ownRequest?.requestId === "string" ? ownRequest.requestId : null,
error: ownRequest ? null : t("login.deviceAuthMigration.pendingUnavailable"),
});
} catch (error) {
if (refreshGeneration === generation && params.isCurrent(client, epoch)) {
const message = error instanceof Error ? error.message : String(error);
update({
error: t("login.deviceAuthMigration.loadFailed", {
error: message,
}),
});
}
}
},
async secure(client: GatewayBrowserClient | null, epoch: number) {
const requestId = snapshot.requestId;
if (
!client ||
!requestId ||
params.gateway.snapshot.phase !== "connected" ||
!params.isCurrent(client, epoch) ||
snapshot.busy ||
disposed
) {
return;
}
update({ busy: true, error: null });
try {
await client.request("device.pair.approve", { requestId });
if (disposed || !params.isCurrent(client, epoch)) {
return;
}
update({ requestId: null, busy: false });
// Reconnect once so the newly approved browser receives and stores its
// device token; shared auth is no longer its baseline.
params.gateway.connect();
} catch (error) {
if (!disposed && params.isCurrent(client, epoch)) {
const message = error instanceof Error ? error.message : String(error);
update({
busy: false,
error: t("login.deviceAuthMigration.approvalFailed", {
error: message,
}),
});
}
}
},
dispose() {
disposed = true;
generation += 1;
},
};
}