diff --git a/Cargo.lock b/Cargo.lock index a8caeb833..b3b64a3e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1222,7 +1222,7 @@ dependencies = [ [[package]] name = "connect-hosted-storage-benchmark" -version = "0.1.0-beta.99" +version = "0.1.0-beta.100" dependencies = [ "aes-gcm", "chrono", @@ -2680,7 +2680,7 @@ dependencies = [ [[package]] name = "mdbase-cli" -version = "0.1.0-beta.99" +version = "0.1.0-beta.100" dependencies = [ "chrono-tz", "clap", @@ -2717,7 +2717,7 @@ dependencies = [ [[package]] name = "mdbase-connect-core" -version = "0.1.0-beta.99" +version = "0.1.0-beta.100" dependencies = [ "chrono", "chrono-tz", @@ -2746,7 +2746,7 @@ dependencies = [ [[package]] name = "mdbase-connect-daemon" -version = "0.1.0-beta.99" +version = "0.1.0-beta.100" dependencies = [ "async-trait", "axum", @@ -2787,7 +2787,7 @@ dependencies = [ [[package]] name = "mdbase-connect-hosted-provider" -version = "0.1.0-beta.99" +version = "0.1.0-beta.100" dependencies = [ "aes-gcm", "async-trait", @@ -2834,7 +2834,7 @@ dependencies = [ [[package]] name = "mdbase-connect-mirror" -version = "0.1.0-beta.99" +version = "0.1.0-beta.100" dependencies = [ "async-trait", "axum", @@ -2861,7 +2861,7 @@ dependencies = [ [[package]] name = "mdbase-connect-protocol" -version = "0.1.0-beta.99" +version = "0.1.0-beta.100" dependencies = [ "aes-gcm", "base64", @@ -2880,7 +2880,7 @@ dependencies = [ [[package]] name = "mdbase-connect-runtime" -version = "0.1.0-beta.99" +version = "0.1.0-beta.100" dependencies = [ "chrono", "mdbase", diff --git a/Cargo.toml b/Cargo.toml index 664689880..73d55a7ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.1.0-beta.99" +version = "0.1.0-beta.100" edition = "2021" license = "MIT" repository = "https://github.com/mdbase-dev/mdbase-connect" diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f54e940d7..a55d66319 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@mdbase/connect-desktop", "productName": "mdbase connect", - "version": "0.1.0-beta.99", + "version": "0.1.0-beta.100", "description": "Connect applications to authorized mdbase collections.", "author": "mdbase", "private": true, diff --git a/apps/desktop/scripts/build-main.mjs b/apps/desktop/scripts/build-main.mjs index e1c8eb766..8b16cc5e6 100644 --- a/apps/desktop/scripts/build-main.mjs +++ b/apps/desktop/scripts/build-main.mjs @@ -7,6 +7,7 @@ await build({ "src/main/main.ts", "src/main/preload.ts", "src/main/agent-startup.ts", + "src/main/boot-gate.ts", "src/main/daemon-lifecycle.ts", "src/main/deep-link.ts", "src/main/editor-url.ts", diff --git a/apps/desktop/src/main/agent-startup.ts b/apps/desktop/src/main/agent-startup.ts index 310f60ce9..aaadb197d 100644 --- a/apps/desktop/src/main/agent-startup.ts +++ b/apps/desktop/src/main/agent-startup.ts @@ -1,6 +1,10 @@ +import { presentReadiness, type AgentReadiness } from "../shared/readiness"; +export { presentReadiness } from "../shared/readiness"; + export interface AgentPing { pong: boolean; ready?: boolean; + readiness?: AgentReadiness; } export interface AgentStartupOptions { @@ -8,10 +12,21 @@ export interface AgentStartupOptions { launch(): Promise; endpointIsUnavailable(error: unknown): boolean; incompatibleDaemon(error: unknown): boolean; + expectedVersion: string; readinessTimeoutMs?: number; pollIntervalMs?: number; } +class ReadinessError extends Error {} + +function isReady(ping: AgentPing, options: AgentStartupOptions): boolean { + const health = presentReadiness(ping.readiness, options.expectedVersion); + if (!ping.pong || health.state === "attention") throw new ReadinessError(health.label); + return health.state === "ready"; +} + +const terminal = (error: unknown, options: AgentStartupOptions) => + error instanceof ReadinessError || options.incompatibleDaemon(error); const delay = (durationMs: number) => new Promise((resolve) => setTimeout(resolve, durationMs)); @@ -20,10 +35,9 @@ export async function waitForAgentReady(options: AgentStartupOptions): Promise { try { - const ping = await options.ping(400); - if (ping.ready !== false) return; + if (isReady(await options.ping(400), options)) return; return waitForAgentReady(options); } catch (error) { - if (options.incompatibleDaemon(error)) throw error; + if (terminal(error, options)) throw error; if (!options.endpointIsUnavailable(error)) return waitForAgentReady(options); } @@ -45,17 +58,13 @@ export async function ensureAgentReady(options: AgentStartupOptions): Promise; + start(): Promise; + blockedReason(): string | null; +} + +/** One owner for update recovery, startup and daemon-backed IPC admission. */ +export class BootGate { + private initialization: Promise | undefined; + private startup: Promise | undefined; + private installing = false; + + constructor(private readonly options: BootGateOptions) {} + + private initialize(): Promise { + // A failed boot stays failed until the application restarts. Ordinary IPC + // must not silently bypass a failed persisted update recovery. + return this.initialization ??= this.options.initialize(); + } + + private assertAdmission(): void { + const reason = this.installing + ? "The application update is in progress." + : this.options.blockedReason(); + if (reason) throw new Error(reason); + } + + async ready(): Promise { + await this.initialize(); + this.assertAdmission(); + this.startup ??= this.options.start().finally(() => { this.startup = undefined; }); + await this.startup; + this.assertAdmission(); + } + + async request(operation: () => Promise): Promise { + await this.ready(); + this.assertAdmission(); + return operation(); + } + + async check(operation: () => Promise): Promise { + await this.initialize(); + this.assertAdmission(); + return operation(); + } + + async install(operation: () => Promise): Promise { + if (this.installing) throw new Error("The application update is in progress."); + this.installing = true; + try { + await this.initialize(); + // Never race the CLI startup already admitted before installation began. + await this.startup; + const reason = this.options.blockedReason(); + if (reason) throw new Error(reason); + return await operation(); + } finally { + this.installing = false; + } + } +} diff --git a/apps/desktop/src/main/control-client.ts b/apps/desktop/src/main/control-client.ts index 7f3c35500..aacfc93e3 100644 --- a/apps/desktop/src/main/control-client.ts +++ b/apps/desktop/src/main/control-client.ts @@ -1,7 +1,7 @@ import { createConnection } from "node:net"; import { randomUUID } from "node:crypto"; -const LOCAL_CONTROL_PROTOCOL_VERSION = 5; +export const LOCAL_CONTROL_PROTOCOL_VERSION = 5; const MAX_LOCAL_CONTROL_RESPONSE_BYTES = 32 * 1024 * 1024; export interface ControlResponse { diff --git a/apps/desktop/src/main/daemon-lifecycle.ts b/apps/desktop/src/main/daemon-lifecycle.ts index e372787d8..2a1700d25 100644 --- a/apps/desktop/src/main/daemon-lifecycle.ts +++ b/apps/desktop/src/main/daemon-lifecycle.ts @@ -1,3 +1,18 @@ +import { execFile as execFileCallback } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir } from "node:fs/promises"; +import { promisify } from "node:util"; + +const execFile = promisify(execFileCallback); + +export async function launchDaemon(binary: string, paths: DaemonPaths, packaged: boolean): Promise { + if (!existsSync(binary)) throw new Error(`Connector runtime is missing: ${binary}`); + await mkdir(paths.stateDir, { recursive: true }); + await execFile(binary, daemonCliArguments(paths.target, paths.stateDir, paths.endpoint, ["start"]), { + env: connectCliEnvironment(packaged), timeout: 30_000, windowsHide: true + }); +} + export function connectCliEnvironment( packaged: boolean, environment: NodeJS.ProcessEnv = process.env @@ -9,15 +24,27 @@ export function connectCliEnvironment( return sanitized; } +export type DaemonTarget = "installed_service" | "isolated_profile"; +export interface DaemonPaths { stateDir: string; endpoint: string; target: DaemonTarget } + +export function parseDaemonPaths(value: unknown): DaemonPaths { + const paths = value as { state_dir?: unknown; endpoint?: unknown; target?: unknown } | null; + if (!paths || typeof paths.state_dir !== "string" || typeof paths.endpoint !== "string" || + (paths.target !== "installed_service" && paths.target !== "isolated_profile")) { + throw new Error("The connector runtime returned invalid path information."); + } + return { stateDir: paths.state_dir, endpoint: paths.endpoint, target: paths.target }; +} + export function daemonCliArguments( - packaged: boolean, + target: DaemonTarget, stateDirectory: string, endpoint: string, command: string[], json = false ): string[] { return [ - ...(packaged ? [] : ["--state-dir", stateDirectory, "--endpoint", endpoint]), + ...(target === "isolated_profile" ? ["--state-dir", stateDirectory, "--endpoint", endpoint] : []), ...(json ? ["--json"] : []), "connect", "daemon", diff --git a/apps/desktop/src/main/electron-update-backend.ts b/apps/desktop/src/main/electron-update-backend.ts index 1f1a86205..765d7c0ed 100644 --- a/apps/desktop/src/main/electron-update-backend.ts +++ b/apps/desktop/src/main/electron-update-backend.ts @@ -1,4 +1,5 @@ import { autoUpdater, shell } from "electron"; +import { presentReadiness, type AgentReadiness } from "../shared/readiness"; import { execFile as execFileCallback } from "node:child_process"; import { createReadStream } from "node:fs"; import { @@ -23,9 +24,10 @@ import { type UpdateManifest, type UpdateTarget } from "./update-policy"; -import type { UpdateTransaction } from "./update-state"; +import type { PersistedUpdateState, UpdateTransaction } from "./update-state"; +import { LOCAL_CONTROL_PROTOCOL_VERSION } from "./control-client"; import { artifactMatches, downloadArtifact, downloadBytes } from "./update-download"; -import { connectCliEnvironment, daemonCliArguments } from "./daemon-lifecycle"; +import { connectCliEnvironment, daemonCliArguments, type DaemonTarget } from "./daemon-lifecycle"; const execFile = promisify(execFileCallback); const AUTO_UPDATER_TIMEOUT_MS = 180_000; @@ -39,6 +41,7 @@ export interface ElectronUpdateBackendOptions { userDataDirectory: string; binaryPath: () => string; stateDirectory: () => string; + target: () => DaemonTarget; endpoint: () => string; } @@ -57,13 +60,25 @@ export class ElectronUpdateBackend implements UpdateBackend { this.packaged = options.packaged; } - async reconcileInstalledRuntime(): Promise { - if (!this.packaged) return null; - const status = await this.daemonStatus(); - const needsReconciliation = runtimeNeedsReconciliation(status, this.currentVersion); - if (!needsReconciliation) return null; - await this.activateRuntime(this.options.binaryPath(), this.currentVersion); - return `Connector runtime ${this.currentVersion} was reconciled with this application.`; + async reconcileInstalledRuntime(rollback?: PersistedUpdateState["last_known_good_runtime"]): Promise { + if (!this.packaged && !rollback) return null; + if (rollback) { + this.assertPrivateRuntime(rollback.path, rollback.version); + if (!(await stat(rollback.path)).isFile()) throw new Error("The saved rollback runtime is missing."); + } + const binary = rollback?.path ?? this.options.binaryPath(); + const version = rollback?.version ?? this.currentVersion; + const status = await this.daemonStatus(binary); + if (runtimeNeedsReconciliation(status, version)) { + await this.activateRuntime(binary, version); + } else if (rollback && !status.ready) { + throw new Error("The saved rollback connector is not ready or uses an incompatible local protocol."); + } else if (!rollback) { + return null; + } + return rollback + ? `Using verified rollback connector ${version} with application ${this.currentVersion}.` + : `Connector runtime ${version} was reconciled with this application.`; } async findLatest(): Promise<{ manifest: UpdateManifest } | null> { @@ -108,7 +123,15 @@ export class ElectronUpdateBackend implements UpdateBackend { await stageMacUpdate(archive, manifest, onProgress); } - async prepareDaemonHandoff(previousVersion: string): Promise { + async prepareDaemonHandoff(previousVersion: string, previousRuntime?: string | null): Promise { + if (previousRuntime) { + this.assertPrivateRuntime(previousRuntime, previousVersion); + if (!(await stat(previousRuntime)).isFile()) throw new Error("The saved rollback runtime is missing."); + const status = await this.daemonStatus(previousRuntime); + // Already preserved: never overwrite the active fallback with the newer bundle. + return { serviceInstalled: status.installed, previousRuntime }; + } + if (previousVersion !== this.currentVersion) throw new Error("The previous runtime binary is missing."); const status = await this.daemonStatus(); const source = this.options.binaryPath(); const directory = join( @@ -153,19 +176,8 @@ export class ElectronUpdateBackend implements UpdateBackend { } async recover(transaction: UpdateTransaction): Promise { - if (transaction.previous_runtime) { - const extension = this.options.platform === "win32" ? ".exe" : ""; - const expected = join( - this.options.userDataDirectory, - "updates", - "runtimes", - transaction.previous_version, - `mdbase${extension}` - ); - if (transaction.previous_runtime !== expected) { - throw new Error("The recorded recovery runtime is outside the private update directory."); - } - } + const previousVersion = transaction.previous_runtime_version ?? transaction.previous_version; + if (transaction.previous_runtime) this.assertPrivateRuntime(transaction.previous_runtime, previousVersion); const runningTarget = this.currentVersion === transaction.target_version; const runningPrevious = this.currentVersion === transaction.previous_version; if (runningTarget) { @@ -183,21 +195,21 @@ export class ElectronUpdateBackend implements UpdateBackend { if (!transaction.previous_runtime) throw error; await this.activateRuntime( transaction.previous_runtime, - transaction.previous_version + previousVersion ); return { healthy: true, rolledBack: true, message: `Version ${transaction.target_version} could not start its connector. ` + - `The last-known-good ${transaction.previous_version} connector was restored.` + `The last-known-good ${previousVersion} connector was restored.` }; } } if (runningPrevious) { await this.activateRuntime( - this.options.binaryPath(), - transaction.previous_version + previousVersion === this.currentVersion ? this.options.binaryPath() : transaction.previous_runtime!, + previousVersion ); return { healthy: true, @@ -213,20 +225,26 @@ export class ElectronUpdateBackend implements UpdateBackend { } await this.activateRuntime( transaction.previous_runtime, - transaction.previous_version + previousVersion ); return { healthy: true, rolledBack: true, - message: `An unexpected application version was detected; connector ${transaction.previous_version} was restored.` + message: `An unexpected application version was detected; connector ${previousVersion} was restored.` }; } + private assertPrivateRuntime(binary: string, version: string): void { + const extension = this.options.platform === "win32" ? ".exe" : ""; + const expected = join(this.options.userDataDirectory, "updates", "runtimes", version, `mdbase${extension}`); + if (binary !== expected) throw new Error("The recorded recovery runtime is outside the private update directory."); + } + private async activateRuntime( binary: string, expectedVersion: string ): Promise { - const current = await this.daemonStatus().catch(() => ({ running: false })); + const current = await this.daemonStatus(binary).catch(() => ({ running: false })); if (current.running) { await this.runCli(binary, ["stop"], 35_000).catch(() => undefined); } @@ -236,7 +254,7 @@ export class ElectronUpdateBackend implements UpdateBackend { while (Date.now() < deadline) { const status = await this.daemonStatus(binary).catch(() => null); lastVersion = status?.binaryVersion; - if (status?.running && status.binaryVersion === expectedVersion) return; + if (status?.running && status.ready && status.binaryVersion === expectedVersion) return; await new Promise((resolve) => setTimeout(resolve, 150)); } throw new Error( @@ -249,12 +267,16 @@ export class ElectronUpdateBackend implements UpdateBackend { private async daemonStatus(binary = this.options.binaryPath()): Promise<{ installed: boolean; running: boolean; + ready: boolean; binaryVersion?: string; }> { const value = await this.runCli(binary, ["status"], 10_000); + const status = value.status as { readiness?: AgentReadiness; protocol_version?: number } | undefined; return { installed: value.installed === true, running: value.running === true, + ready: status?.protocol_version === LOCAL_CONTROL_PROTOCOL_VERSION && + presentReadiness(status.readiness).state === "ready", binaryVersion: value.status && typeof value.status === "object" && @@ -273,7 +295,7 @@ export class ElectronUpdateBackend implements UpdateBackend { const { stdout } = await execFile( binary, daemonCliArguments( - this.packaged, + this.options.target(), this.options.stateDirectory(), this.options.endpoint(), command, diff --git a/apps/desktop/src/main/hosted-snapshot.ts b/apps/desktop/src/main/hosted-snapshot.ts index 3352c9a3a..968d9a2b2 100644 --- a/apps/desktop/src/main/hosted-snapshot.ts +++ b/apps/desktop/src/main/hosted-snapshot.ts @@ -12,24 +12,15 @@ const credentialStoreUnavailable = (error: unknown): boolean => ( && error.code === "credential_store_unavailable" ); -const offlineSnapshot = (): HostedControlSnapshot => ({ - online: false, - hosted_collections_available: false, - hosted_collections: [], - grants: [], - pending_authorizations: [] -}); - interface HostedSnapshotLoaderOptions { retryAfterMs?: number; now?: () => number; } /** - * A hosted snapshot is status data, so a known unavailable credential store is - * represented as an offline snapshot rather than a rejected Electron IPC call. - * Repeated polls are served locally during a short retry cooldown. Other - * failures remain visible to the renderer and preserve its last snapshot. + * Preserve failure as failure, never manufacture an empty success. The renderer + * owns last-known data. A credential error is cached only for a short cooldown, + * replacing repeated keyring pressure without becoming a second data cache. */ export function createHostedSnapshotLoader( request: () => Promise, @@ -38,21 +29,24 @@ export function createHostedSnapshotLoader( const retryAfterMs = options.retryAfterMs ?? 30_000; const now = options.now ?? Date.now; let retryAt = 0; + let credentialError: unknown; let inFlight: Promise | undefined; return () => { - if (now() < retryAt) return Promise.resolve(offlineSnapshot()); + if (now() < retryAt) return Promise.reject(credentialError); if (inFlight) return inFlight; const pending = (async () => { try { const snapshot = await request(); retryAt = 0; + credentialError = undefined; return snapshot; } catch (error) { if (!credentialStoreUnavailable(error)) throw error; retryAt = now() + retryAfterMs; - return offlineSnapshot(); + credentialError = error; + throw error; } })(); const tracked = pending.finally(() => { diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index d6fd2ccd0..5325c5f50 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -17,7 +17,8 @@ import { hostname } from "node:os"; import { promisify } from "node:util"; import { ensureAgentReady, type AgentPing } from "./agent-startup"; import { AgentControlError, requestAgent } from "./control-client"; -import { connectCliEnvironment, daemonCliArguments } from "./daemon-lifecycle"; +import { BootGate } from "./boot-gate"; +import { connectCliEnvironment, launchDaemon, parseDaemonPaths, type DaemonPaths } from "./daemon-lifecycle"; import { routeForDeepLink, shouldRegisterDeepLinks } from "./deep-link"; import { buildEditorUrl } from "./editor-url"; import { ElectronUpdateBackend } from "./electron-update-backend"; @@ -32,8 +33,9 @@ guardDesktopProcessOutput(); let mainWindow: BrowserWindow | null = null; let tray: Tray | null = null; -let agentStartup: Promise | null = null; -let daemonPaths: { stateDir: string; endpoint: string } | null = null; +let bootGate: BootGate; +let localHealthLabel = "Local connector starting"; +let daemonPaths: DaemonPaths | null = null; let updater: UpdateCoordinator | null = null; let quitting = false; const activePairings = new Map(); @@ -77,19 +79,7 @@ async function resolveDaemonPaths(): Promise { timeout: 10_000, windowsHide: true }); - const paths = JSON.parse(stdout) as { state_dir?: unknown; endpoint?: unknown }; - if (typeof paths.state_dir !== "string" || typeof paths.endpoint !== "string") { - throw new Error("The connector runtime returned invalid path information."); - } - daemonPaths = { stateDir: paths.state_dir, endpoint: paths.endpoint }; -} - -async function ensureAgent(): Promise { - if (agentStartup) return agentStartup; - agentStartup = startAgent().finally(() => { - agentStartup = null; - }); - return agentStartup; + daemonPaths = parseDaemonPaths(JSON.parse(stdout)); } async function requestReadyAgent( @@ -97,8 +87,7 @@ async function requestReadyAgent( params?: unknown, timeoutMs = 5_000 ): Promise { - await ensureAgent(); - return requestAgent(controlEndpoint(), method, params, timeoutMs); + return bootGate.request(() => requestAgent(controlEndpoint(), method, params, timeoutMs)); } function endpointIsUnavailable(error: unknown): boolean { @@ -111,33 +100,14 @@ function incompatibleDaemon(error: unknown): boolean { return error instanceof AgentControlError && error.code === "unsupported_local_protocol"; } -async function startAgent(): Promise { +async function startAgent(runtime = updater!.daemonStartupRuntime()): Promise { await ensureAgentReady({ + expectedVersion: runtime.version, ping: (timeoutMs) => requestAgent(controlEndpoint(), "ping", undefined, timeoutMs), endpointIsUnavailable, incompatibleDaemon, - launch: async () => { - const binary = connectBinary(); - if (!existsSync(binary)) { - throw new Error(`Connector runtime is missing: ${binary}`); - } - await mkdir(stateDirectory(), { recursive: true }); - await execFile( - binary, - daemonCliArguments( - app.isPackaged, - stateDirectory(), - controlEndpoint(), - ["start"] - ), - { - env: connectCliEnvironment(app.isPackaged), - timeout: 30_000, - windowsHide: true - } - ); - } + launch: () => launchDaemon(runtime.binary ?? connectBinary(), daemonPaths!, app.isPackaged) }); } @@ -176,12 +146,12 @@ function registerIpc(): void { ipcMain.handle("connect:updates:check", async (event) => { trustedIpc(event); if (!updater) throw new Error("The updater has not been initialized."); - return updater.check(true); + return bootGate.check(() => updater!.check(true)); }); ipcMain.handle("connect:updates:install", async (event) => { trustedIpc(event); if (!updater) throw new Error("The updater has not been initialized."); - return updater.install(); + return bootGate.install(() => updater!.install()); }); ipcMain.handle("connect:collections:list", async (event) => { trustedIpc(event); @@ -889,7 +859,7 @@ function refreshTrayMenu(): void { Menu.buildFromTemplate([ { label: "Show mdbase connect", click: () => mainWindow?.show() }, { type: "separator" }, - { label: "Local connector running", enabled: false }, + { label: localHealthLabel, enabled: false }, { label: updateReady ? update?.phase === "ready" @@ -900,14 +870,14 @@ function refreshTrayMenu(): void { click: () => { if (!updater) return; if (updateReady) { - void updater.install().catch((error) => { + void bootGate.install(() => updater!.install()).catch((error) => { dialog.showErrorBox( "mdbase connect could not install the update", error instanceof Error ? error.message : String(error) ); }); } else { - void updater.check(true); + void bootGate.check(() => updater!.check(true)).catch((error) => dialog.showErrorBox("Could not check for updates", String(error))); } } }, @@ -950,11 +920,33 @@ app.whenReady().then(async () => { userDataDirectory: app.getPath("userData"), binaryPath: connectBinary, stateDirectory, + target: () => daemonPaths!.target, endpoint: controlEndpoint }) ); - const recoveryStatus = await updater.initialize(); + bootGate = new BootGate({ + initialize: async () => { await updater!.initialize(); }, + start: async () => { + localHealthLabel = "Local connector starting"; + refreshTrayMenu(); + try { + await startAgent(); + localHealthLabel = "Local connector ready"; + } catch (error) { + localHealthLabel = error instanceof Error ? error.message : "Local connector needs attention"; + throw error; + } finally { + refreshTrayMenu(); + } + }, + blockedReason: () => updater!.daemonStartupBlock() + }); updater.subscribe((status) => { + if (status.phase === "installing" || status.phase === "recovery") { + localHealthLabel = "Local connector updating"; + } else if (status.phase === "failed" && !status.can_check) { + localHealthLabel = "Local connector needs attention"; + } mainWindow?.webContents.send("connect:update-status", status); refreshTrayMenu(); }); @@ -963,17 +955,18 @@ app.whenReady().then(async () => { createTray(); handleDeepLink(process.argv.find((value) => value.startsWith("mdbase-connect://"))); try { - if (recoveryStatus.phase === "failed") throw new Error(recoveryStatus.message); - await ensureAgent(); + await bootGate.ready(); } catch (error) { + localHealthLabel = "Local connector needs attention"; + refreshTrayMenu(); dialog.showErrorBox( "mdbase connect could not start", error instanceof Error ? error.message : String(error) ); } - const initialUpdateCheck = setTimeout(() => void updater?.check(false), 30_000); + const initialUpdateCheck = setTimeout(() => void bootGate.check(() => updater!.check(false)).catch(() => undefined), 30_000); initialUpdateCheck.unref(); - const updateChecks = setInterval(() => void updater?.check(false), 6 * 60 * 60 * 1000); + const updateChecks = setInterval(() => void bootGate.check(() => updater!.check(false)).catch(() => undefined), 6 * 60 * 60 * 1000); updateChecks.unref(); }); diff --git a/apps/desktop/src/main/update-coordinator.ts b/apps/desktop/src/main/update-coordinator.ts index c5fca2429..904c4f3f9 100644 --- a/apps/desktop/src/main/update-coordinator.ts +++ b/apps/desktop/src/main/update-coordinator.ts @@ -6,7 +6,7 @@ import { type UpdateManifest, type UpdateTarget } from "./update-policy"; -import { UpdateStateStore, type UpdateTransaction } from "./update-state"; +import { UpdateStateStore, type PersistedUpdateState, type UpdateTransaction } from "./update-state"; export type UpdatePhase = | "unavailable" @@ -49,14 +49,14 @@ export interface UpdateBackend { channel: UpdateChannel; platformKey: string; packaged: boolean; - reconcileInstalledRuntime(): Promise; + reconcileInstalledRuntime(rollback?: PersistedUpdateState["last_known_good_runtime"]): Promise; findLatest(): Promise<{ manifest: UpdateManifest } | null>; stageAutomatic( manifest: UpdateManifest, target: UpdateTarget, onProgress: (progress: number) => void ): Promise; - prepareDaemonHandoff(previousVersion: string): Promise; + prepareDaemonHandoff(previousVersion: string, previousRuntime?: string | null): Promise; stopDaemon(): Promise; installAutomatic(): void; openExternal(url: string): Promise; @@ -70,10 +70,12 @@ export class UpdateCoordinator { private statusValue: DesktopUpdateStatus; private candidate: { manifest: UpdateManifest; target: UpdateTarget } | null = null; private operation: Promise | null = null; + private runtime: { version: string; binary: string | null }; constructor(store: UpdateStateStore, backend: UpdateBackend) { this.store = store; this.backend = backend; + this.runtime = { version: backend.currentVersion, binary: null }; this.statusValue = { phase: backend.packaged ? "idle" : "unavailable", current_version: backend.currentVersion, @@ -90,6 +92,17 @@ export class UpdateCoordinator { return structuredClone(this.statusValue); } + daemonStartupRuntime(): { version: string; binary: string | null } { + return { ...this.runtime }; + } + + daemonStartupBlock(): string | null { + const status = this.statusValue; + return status.phase === "installing" || + (["recovery", "failed"].includes(status.phase) && !status.can_check) + ? status.message : null; + } + subscribe(listener: (status: DesktopUpdateStatus) => void): () => void { this.listeners.add(listener); listener(this.status()); @@ -103,7 +116,10 @@ export class UpdateCoordinator { } if (!persisted.transaction) { try { - const message = await this.backend.reconcileInstalledRuntime(); + const rollback = persisted.last_known_good_runtime?.for_app_version === this.backend.currentVersion + ? persisted.last_known_good_runtime : undefined; + const message = await this.backend.reconcileInstalledRuntime(rollback); + if (rollback) this.runtime = { version: rollback.version, binary: rollback.path }; if (message) { this.setStatus({ phase: "idle", @@ -134,23 +150,9 @@ export class UpdateCoordinator { }); try { const result = await this.backend.recover(persisted.transaction); - await this.store.update((state) => { - if (result.healthy && !result.rolledBack) { - state.highest_trusted_version = maxVersion( - state.highest_trusted_version, - persisted.transaction?.target_version - ); - if (persisted.transaction?.previous_runtime) { - state.last_known_good_runtime = { - version: persisted.transaction.previous_version, - path: persisted.transaction.previous_runtime - }; - } - } - delete state.transaction; - }); + await this.completeRecovery(persisted.transaction, result); this.setStatus({ - phase: result.healthy && !result.rolledBack ? "idle" : "recovery", + phase: result.rolledBack ? "recovery" : "idle", message: result.message, can_check: this.backend.packaged, can_install: false @@ -218,18 +220,28 @@ export class UpdateCoordinator { }); this.backend.installAutomatic(); } catch (error) { - const recovered = await this.backend.recover(transaction).catch(() => null); await this.store.update((state) => { - delete state.transaction; + if (state.transaction?.id === transaction.id) state.transaction.phase = "recovering"; + }); + const recovered = await this.backend.recover(transaction).then(async (result) => { + await this.completeRecovery(transaction, result); + return result; + }).catch((recoveryError) => ({ + healthy: false, + rolledBack: false, + message: message(recoveryError) + })); + if (!recovered.healthy) await this.store.update((state) => { + if (state.transaction?.id === transaction.id) state.transaction.error = recovered.message; }); this.candidate = null; this.setStatus({ phase: "failed", target_version: transaction.target_version, - message: recovered?.healthy + message: recovered.healthy ? `The update was not installed; the connector was restored. ${message(error)}` - : `The update was not installed and connector recovery failed: ${message(error)}`, - can_check: this.backend.packaged, + : `The update was not installed: ${message(error)}. Connector recovery needs attention: ${recovered.message}`, + can_check: recovered.healthy && this.backend.packaged, can_install: false }); throw error; @@ -237,6 +249,34 @@ export class UpdateCoordinator { return this.status(); } + private async completeRecovery(transaction: UpdateTransaction, result: RecoveryResult): Promise { + if (!result.healthy) throw new Error(result.message); + const previousVersion = transaction.previous_runtime_version ?? transaction.previous_version; + const version = result.rolledBack ? previousVersion : transaction.target_version; + const binary = result.rolledBack && version !== this.backend.currentVersion ? transaction.previous_runtime : null; + if (version !== this.backend.currentVersion && !binary) { + throw new Error("Verified recovery did not preserve its runtime binary."); + } + await this.store.update((state) => { + if (state.transaction?.id !== transaction.id) throw new Error("Update recovery transaction changed."); + if (!result.rolledBack) { + state.highest_trusted_version = maxVersion(state.highest_trusted_version, transaction.target_version); + } + if (transaction.previous_runtime) { + state.last_known_good_runtime = { + version: previousVersion, + path: transaction.previous_runtime, + ...(result.rolledBack && version !== this.backend.currentVersion + ? { for_app_version: this.backend.currentVersion } : {}) + }; + } else if (state.last_known_good_runtime) { + delete state.last_known_good_runtime.for_app_version; + } + delete state.transaction; + }); + this.runtime = { version, binary }; + } + private async checkExclusive(manual: boolean): Promise { if (!this.backend.packaged) return this.status(); this.setStatus({ @@ -323,7 +363,7 @@ export class UpdateCoordinator { can_check: false, can_install: false }); - const handoff = await this.backend.prepareDaemonHandoff(this.backend.currentVersion); + const handoff = await this.backend.prepareDaemonHandoff(this.runtime.version, this.runtime.binary); const transaction: UpdateTransaction = { id: randomUUID(), phase: "prepared", @@ -331,6 +371,8 @@ export class UpdateCoordinator { previous_version: this.backend.currentVersion, service_installed: handoff.serviceInstalled, previous_runtime: handoff.previousRuntime, + ...(this.runtime.version !== this.backend.currentVersion + ? { previous_runtime_version: this.runtime.version } : {}), started_at: new Date().toISOString() }; await this.store.update((state) => { diff --git a/apps/desktop/src/main/update-state.ts b/apps/desktop/src/main/update-state.ts index 5c4888f85..cc33f58a9 100644 --- a/apps/desktop/src/main/update-state.ts +++ b/apps/desktop/src/main/update-state.ts @@ -10,6 +10,7 @@ export interface UpdateTransaction { previous_version: string; service_installed: boolean; previous_runtime: string | null; + previous_runtime_version?: string; started_at: string; error?: string; } @@ -22,6 +23,7 @@ export interface PersistedUpdateState { last_known_good_runtime?: { version: string; path: string; + for_app_version?: string; }; transaction?: UpdateTransaction; } @@ -55,9 +57,10 @@ export class UpdateStateStore { ): Promise { const current = await this.load(); const next = change(current) ?? current; - this.state = parsePersistedState(next); - await this.write(); - return structuredClone(this.state); + const validated = parsePersistedState(next); + await this.write(validated); + this.state = validated; + return structuredClone(validated); } async remove(): Promise { @@ -65,13 +68,13 @@ export class UpdateStateStore { await rm(this.path, { force: true }); } - private async write(): Promise { - if (!this.state) throw new Error("Update state has not been initialized."); + private async write(state = this.state): Promise { + if (!state) throw new Error("Update state has not been initialized."); const parent = dirname(this.path); await mkdir(parent, { recursive: true, mode: 0o700 }); await chmod(parent, 0o700).catch(() => undefined); const temporary = `${this.path}.tmp-${process.pid}-${randomUUID()}`; - await writeFile(temporary, `${JSON.stringify(this.state, null, 2)}\n`, { + await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" @@ -116,6 +119,11 @@ export function parsePersistedState(value: unknown): PersistedUpdateState { } compareVersions(runtime.version, runtime.version); parsed.last_known_good_runtime = { version: runtime.version, path: runtime.path }; + if (runtime.for_app_version !== undefined) { + if (typeof runtime.for_app_version !== "string") throw new Error("Rollback app version is invalid."); + compareVersions(runtime.for_app_version, runtime.for_app_version); + parsed.last_known_good_runtime.for_app_version = runtime.for_app_version; + } } if (state.transaction !== undefined) parsed.transaction = parseTransaction(state.transaction); return parsed; @@ -143,6 +151,12 @@ function parseTransaction(value: unknown): UpdateTransaction { if (transaction.previous_runtime !== null && typeof transaction.previous_runtime !== "string") { throw new Error("Update transaction runtime path is invalid."); } + if (transaction.previous_runtime_version !== undefined) { + if (typeof transaction.previous_runtime_version !== "string" || !transaction.previous_runtime) { + throw new Error("Previous runtime version is invalid."); + } + compareVersions(transaction.previous_runtime_version, transaction.previous_runtime_version); + } if (transaction.error !== undefined && typeof transaction.error !== "string") { throw new Error("Update transaction error is invalid."); } @@ -153,6 +167,7 @@ function parseTransaction(value: unknown): UpdateTransaction { previous_version: transaction.previous_version as string, service_installed: transaction.service_installed, previous_runtime: transaction.previous_runtime as string | null, + ...(transaction.previous_runtime_version ? { previous_runtime_version: transaction.previous_runtime_version as string } : {}), started_at: new Date(transaction.started_at as string).toISOString(), ...(transaction.error ? { error: transaction.error as string } : {}) }; diff --git a/apps/desktop/src/renderer/connection-state.mts b/apps/desktop/src/renderer/connection-state.mts index 50832531b..69b23c8d9 100644 --- a/apps/desktop/src/renderer/connection-state.mts +++ b/apps/desktop/src/renderer/connection-state.mts @@ -3,6 +3,7 @@ export type ConnectionDotState = "connected" | "connecting" | "paused" | "danger export interface ConnectionStatus { state: "local_only" | "connecting" | "connected" | "offline"; paused: boolean; + relay_problem?: string; } export interface CloudConnection { @@ -28,6 +29,15 @@ export function presentConnection( if (status?.paused) { return { label: "Remote access paused", settingsLabel: "Paused", dot: "paused" }; } + if (status?.relay_problem) { + return { + label: status.relay_problem === "authentication_required" + ? "Account connection needs authorization; reconnect this computer" + : "Relay version incompatible; update the connector", + settingsLabel: "Needs attention", + dot: "danger" + }; + } if (status === null || status.state === "connecting" || status.state === "local_only") { return { label: "Connecting securely…", settingsLabel: "Connecting", dot: "connecting" }; } diff --git a/apps/desktop/src/renderer/global.d.ts b/apps/desktop/src/renderer/global.d.ts index bf4478fd7..8e76706dd 100644 --- a/apps/desktop/src/renderer/global.d.ts +++ b/apps/desktop/src/renderer/global.d.ts @@ -1,4 +1,6 @@ interface AgentStatus { + readiness?: import("../shared/readiness").AgentReadiness; + relay_problem?: string; protocol_version: number; binary_version?: string; state: "local_only" | "connecting" | "connected" | "offline"; @@ -372,7 +374,7 @@ interface Window { renameComputer(name: string): Promise<{ connector: { id: string; name: string } }>; createGrant(input: { applicationId: string; collectionId: string; operations: string[] }): Promise; updateGrant(input: { grantId: string; operations: string[] }): Promise; - revokeGrant(grantId: string): Promise; + revokeGrant(grantId: string): Promise<{ ok: boolean; revocation_status: "revoking" | "revoked" }>; listActivity(limit?: number): Promise; hostedSnapshot(): Promise; createHostedCollection(input: { name: string; timezone: string }): Promise<{ collection: HostedCollectionSummary }>; diff --git a/apps/desktop/src/renderer/main.tsx b/apps/desktop/src/renderer/main.tsx index 9cf05cfa4..226f03397 100644 --- a/apps/desktop/src/renderer/main.tsx +++ b/apps/desktop/src/renderer/main.tsx @@ -33,6 +33,8 @@ import { NotificationAccess, RequestPermissionChoices } from "./authorization-co import { hasSupportedCapabilityDeclaration, requestCapabilityGroups } from "./application-capabilities"; import { ConnectionProgress, Overview } from "./overview-view"; import { singleFlight } from "./single-flight.mjs"; +import { refreshResources, presentResourceFailures, retainOfflineInventory } from "./resource-health.mjs"; +import { presentReadiness } from "../shared/readiness"; import { AccessControl, Empty, @@ -99,6 +101,7 @@ function App() { const [activity, setActivity] = useState([]); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const [resourceFailures, setResourceFailures] = useState>({}); const [notice, setNotice] = useState(null); const [createOpen, setCreateOpen] = useState(false); const [copiedCollectionPath, setCopiedCollectionPath] = useState(null); @@ -112,27 +115,36 @@ function App() { const [initialRefreshComplete, setInitialRefreshComplete] = useState(false); const [navigationOpen, setNavigationOpen] = useState(false); - const runRefresh = useCallback(async (quiet = false) => { - try { - const results = await Promise.allSettled([ - window.mdbaseConnect.status().then(setStatus), - window.mdbaseConnect.updateStatus().then(setUpdateStatus), - window.mdbaseConnect.listCollections().then(setCollections), - window.mdbaseConnect.getLaunchAtLogin().then(setStartup), - window.mdbaseConnect.getCloudConfig().then(setCloud), - window.mdbaseConnect.accessSnapshot().then(setAccess), - window.mdbaseConnect.listActivity(100).then(setActivity), - window.mdbaseConnect.hostedSnapshot().then(setHosted).catch(() => { - setHosted((current) => ({ ...current, online: false })); - }), - window.mdbaseConnect.listMirrors().then(setMirrors) - ]); - const failed = results.find((result): result is PromiseRejectedResult => result.status === "rejected"); - if (failed) throw failed.reason; - setError(null); - } catch (refreshError) { - if (!quiet) setError(message(refreshError)); + const runRefresh = useCallback(async (_quiet = false) => { + let configured: boolean | undefined; + const failures = await refreshResources({ + connector: () => window.mdbaseConnect.status().then((next) => { + setStatus(next); + const health = presentReadiness(next.readiness); + if (health.state !== "ready") throw new Error(health.label); + }), + collections: () => window.mdbaseConnect.listCollections().then(setCollections), + startup: () => window.mdbaseConnect.getLaunchAtLogin().then(setStartup), + account: () => window.mdbaseConnect.getCloudConfig().then((next) => { + configured = next.configured; + setCloud(next); + }), + access: () => window.mdbaseConnect.accessSnapshot().then((next) => { + setAccess((current) => !next.configured ? next : retainOfflineInventory(current, next)); + if (next.configured && !next.online) throw new Error("Application access is offline."); + }), + activity: () => window.mdbaseConnect.listActivity(100).then(setActivity), + hosted: () => window.mdbaseConnect.hostedSnapshot().then((next) => { + setHosted((current) => retainOfflineInventory(current, next)); + if (!next.online) throw new Error("Hosted collections are offline."); + }), + mirrors: () => window.mdbaseConnect.listMirrors().then(setMirrors) + }); + if (configured === false) { + setHosted({ online: false, hosted_collections_available: false, hosted_collections: [], grants: [], pending_authorizations: [] }); + delete failures.hosted; } + setResourceFailures(failures); setInitialRefreshComplete(true); }, []); const refresh = useMemo(() => singleFlight(runRefresh), [runRefresh]); @@ -163,7 +175,15 @@ function App() { setRoute(next as Route); } }); - const removeUpdateStatus = window.mdbaseConnect.onUpdateStatus(setUpdateStatus); + // Updates already have a push subscription; do not poll them with daemon data. + let updatePushed = false; + const removeUpdateStatus = window.mdbaseConnect.onUpdateStatus((next) => { + updatePushed = true; + setUpdateStatus(next); + }); + void window.mdbaseConnect.updateStatus().then((next) => { + if (!updatePushed) setUpdateStatus(next); + }).catch(() => undefined); return () => { window.clearInterval(timer); removeNavigation(); @@ -194,7 +214,6 @@ function App() { async function act(action: () => Promise) { setBusy(true); - setError(null); setNotice(null); try { await action(); @@ -207,7 +226,6 @@ function App() { } async function transferAct(action: () => Promise) { - setError(null); setNotice(null); try { await action(); @@ -344,7 +362,8 @@ function App() {
- {error &&
{error}
} + {error &&
{error}
} + {presentResourceFailures(resourceFailures) &&
{presentResourceFailures(resourceFailures)}
} {notice &&
{notice}
}
@@ -620,11 +639,12 @@ function ApplicationGrantGroup({ group, busy, onAct, onNotice }: { const result = await window.mdbaseConnect.revokeHostedGrant(grant.id); providerConfirmationPending ||= result.revocation_status === "revoking"; } else { - await window.mdbaseConnect.revokeGrant(grant.id); + const result = await window.mdbaseConnect.revokeGrant(grant.id); + providerConfirmationPending ||= result.revocation_status !== "revoked"; } } onNotice(providerConfirmationPending - ? `${group.applicationName} access is disabled here; hosted revocation confirmation is pending.` + ? `${group.applicationName} revocation is pending authority confirmation.` : `${group.applicationName} collection access was revoked.`); }); }}>Revoke all access @@ -649,7 +669,7 @@ function GrantEditor({ grant, busy, onAct, onNotice }: { grant: GrantSummary; bu useEffect(() => setOperations(grant.operations), [grant.operations]); const authority = grant.collection_kind === "hosted" ? "Hosted by mdbase" : "On this computer"; if (grant.revocation_status === "revoking") { - return

Hosted by mdbase

{grant.collection_name}

Access is disabled here. Waiting for the hosted authority to confirm revocation.
Revoking…
; + return

{authority}

{grant.collection_name}

Revocation is pending. Waiting for {grant.collection_kind === "hosted" ? "the hosted authority" : "this computer"} to confirm enforcement.
Revoking…
; } if (permissionDetailsAvailable && (grant.scope.access !== "full_collection" || grant.scope.contracts.length > 0)) { return

{authority}

{grant.collection_name}

Legacy scoped access is revoked. Reauthorize this application for the entire collection.
Reauthorization required
; @@ -676,8 +696,10 @@ function GrantEditor({ grant, busy, onAct, onNotice }: { grant: GrantSummary; bu ? `${grant.application_name} access is disabled here; hosted revocation confirmation is pending.` : `${grant.application_name} access was revoked.`); } else { - await window.mdbaseConnect.revokeGrant(grant.id); - onNotice(`${grant.application_name} access was revoked.`); + const result = await window.mdbaseConnect.revokeGrant(grant.id); + onNotice(result.revocation_status === "revoked" + ? `${grant.application_name} access was revoked.` + : `${grant.application_name} revocation is pending confirmation from this computer.`); } }); }}>Revoke