From 37c089f6bde11b6628c6df9a7a194bdf6ecfe6c9 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 18 Sep 2026 22:21:37 +0800 Subject: [PATCH 01/44] fix(runtime-host): stop progress views from inheriting attention fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handoff progress projection spread the previous attention view, so stale fields like reason "retry_required" surfaced during an active update — exactly the misleading staging + retry_required combination reported in the diagnostics of issue 5476. Split HostHandoffView into a discriminated union (attention carries reason, progress carries phase) and build progress views explicitly instead of spreading. Refs #5476 #5488 Generated-by: Devin --- .../src/__tests__/host-handoff.test.ts | 36 +++++++++++---- .../src/client/host-handoff-copy.ts | 46 +++++++++++-------- .../runtime-host/src/client/host-handoff.ts | 41 +++++++++++------ 3 files changed, 81 insertions(+), 42 deletions(-) diff --git a/packages/runtime-host/src/__tests__/host-handoff.test.ts b/packages/runtime-host/src/__tests__/host-handoff.test.ts index 6b61174fce..4863f39c38 100644 --- a/packages/runtime-host/src/__tests__/host-handoff.test.ts +++ b/packages/runtime-host/src/__tests__/host-handoff.test.ts @@ -26,6 +26,8 @@ import { runHostHandoff, type HostHandoffAction, type HostHandoffBlocker, + type HostHandoffAttentionView, + type HostHandoffProgressView, type HostHandoffView, type OpenHostHandoffSurface, } from '../client/host-handoff.js'; @@ -81,9 +83,24 @@ function surfaceHarness() { choose(view: HostHandoffView, action: HostHandoffAction) { submit(view.revision, action); }, - view(predicate: (view: HostHandoffView) => boolean = () => true): Promise { - if (latest && predicate(latest)) return Promise.resolve(latest); - return new Promise((resolve) => waiters.add({ predicate, resolve })); + view( + predicate: + | ((view: HostHandoffView) => view is V) + | ((view: HostHandoffView) => boolean) = () => true, + ): Promise { + if (latest && predicate(latest)) return Promise.resolve(latest as V); + return new Promise((resolve) => + waiters.add({ + predicate: predicate as (view: HostHandoffView) => boolean, + resolve: resolve as (view: HostHandoffView) => void, + }), + ); + }, + attention(): Promise { + return this.view((view): view is HostHandoffAttentionView => view.state === 'attention'); + }, + progress(): Promise { + return this.view((view): view is HostHandoffProgressView => view.state === 'progress'); }, }; } @@ -274,7 +291,7 @@ test('progress Cancel aborts cooperative convergence but awaits safe transaction const rejection = assert.rejects(running, HostHandoffCancelledError).then(() => { finished = true; }); - const view = await ui.view((candidate) => candidate.state === 'progress'); + const view = await ui.progress(); assert.equal(view.phase, 'pausing'); assert.deepEqual(view.actions, ['cancel']); ui.choose(view, 'cancel'); @@ -309,7 +326,7 @@ test('unknown work requires explicit non-default consent', async () => { }, }), }); - const view = await ui.view(); + const view = await ui.attention(); assert.equal(view.reason, 'activity_unknown'); assert.equal(view.defaultAction, 'cancel'); ui.choose(view, 'interrupt'); @@ -414,7 +431,7 @@ test('a live blocking surface resolves automatically when work finishes', async }, }), }); - assert.equal((await ui.view()).reason, 'busy'); + assert.equal((await ui.attention()).reason, 'busy'); active = false; assert.equal((await running).value, 'done'); assert.equal(ui.closed, true); @@ -586,7 +603,7 @@ test('managed handoff rechecks without mutation and requests interruption only a }); }, }); - const initial = await ui.view(); + const initial = await ui.attention(); assert.equal(initial.reason, 'replacement_required'); assert.match(formatHostHandoff(initial, 'zh-CN').detail, /0\.2\.0 → 0\.3\.0/u); assert.deepEqual(initial.actions, ['cancel', 'retry', 'replace']); @@ -596,7 +613,10 @@ test('managed handoff rechecks without mutation and requests interruption only a const checked = await ui.view((view) => view.revision !== initial.revision); assert.deepEqual(policies, []); ui.choose(checked, 'replace'); - const busy = await ui.view((view) => view.state === 'attention' && view.reason === 'busy'); + const busy = await ui.view( + (view): view is HostHandoffAttentionView => + view.state === 'attention' && view.reason === 'busy', + ); assert.deepEqual(policies, ['refuse_active_work']); assert.ok(busy.actions.includes('interrupt')); ui.choose(busy, 'interrupt'); diff --git a/packages/runtime-host/src/client/host-handoff-copy.ts b/packages/runtime-host/src/client/host-handoff-copy.ts index ccc91f78cd..16935caaf2 100644 --- a/packages/runtime-host/src/client/host-handoff-copy.ts +++ b/packages/runtime-host/src/client/host-handoff-copy.ts @@ -25,7 +25,7 @@ import type { HostHandoffView, } from './host-handoff.js'; -type HostHandoffReason = HostHandoffView['reason']; +type HostHandoffReason = Extract['reason']; interface HostHandoffCopy { titles: Record; @@ -261,6 +261,25 @@ export function formatHostHandoff( actions: readonly { action: HostHandoffAction; label: string }[]; } { const copy = COPY[locale]; + const labels: Record = { + cancel: copy.labels.cancel, + retry: view.manualRecheck ? copy.labels.recheck : copy.labels.retry, + replace: copy.labels.replace, + interrupt: view.manualRecheck + ? copy.labels.interruptRecheck + : view.operation === 'repair' + ? copy.labels.interruptRepair + : copy.labels.interrupt, + }; + const actions = view.actions.map((action) => ({ action, label: labels[action] })); + if (view.state === 'progress') { + return { + title: copy.progressTitle, + description: copy.phases[view.phase], + detail: copy.progressDetail, + actions, + }; + } let title = copy.titles[view.reason]; let description = copy.descriptions(view.target.name)[view.reason]; if (view.recoveryBlocker && view.reason === 'operator_required') { @@ -290,25 +309,12 @@ export function formatHostHandoff( ? copy.waiting.natural : copy.waiting.blocked; const repairNotice = view.operation === 'repair' ? copy.repairNotice : ''; - const labels: Record = { - cancel: copy.labels.cancel, - retry: view.manualRecheck ? copy.labels.recheck : copy.labels.retry, - replace: copy.labels.replace, - interrupt: view.manualRecheck - ? copy.labels.interruptRecheck - : view.operation === 'repair' - ? copy.labels.interruptRepair - : copy.labels.interrupt, - }; return { - title: view.state === 'progress' ? copy.progressTitle : title, - description: view.state === 'progress' ? copy.phases[view.phase ?? 'checking'] : description, - detail: - view.state === 'progress' - ? copy.progressDetail - : [packageChange, facts, backgroundFacts, waiting, repairNotice, view.operatorStep] - .filter(Boolean) - .join('\n'), - actions: view.actions.map((action) => ({ action, label: labels[action] })), + title, + description, + detail: [packageChange, facts, backgroundFacts, waiting, repairNotice, view.operatorStep] + .filter(Boolean) + .join('\n'), + actions, }; } diff --git a/packages/runtime-host/src/client/host-handoff.ts b/packages/runtime-host/src/client/host-handoff.ts index bf54946457..203a0df1d3 100644 --- a/packages/runtime-host/src/client/host-handoff.ts +++ b/packages/runtime-host/src/client/host-handoff.ts @@ -83,24 +83,22 @@ export type HostHandoffObservation = | { readonly kind: 'ready'; readonly value: T } | { readonly kind: 'blocked'; readonly blocker: HostHandoffBlocker }; -/** Data-only projection. A Surface cannot supply policy, a target, or a transaction. */ -export interface HostHandoffView { +export type HostHandoffReason = + | 'replacement_required' + | 'busy' + | 'activity_unknown' + | 'operator_required' + | 'repair_required' + | 'retry_required'; + +interface HostHandoffViewBase { readonly revision: string; readonly target: HostHandoffTarget; - readonly state: 'attention' | 'progress'; - readonly reason: - | 'replacement_required' - | 'busy' - | 'activity_unknown' - | 'operator_required' - | 'repair_required' - | 'retry_required'; readonly activity?: HostActivitySnapshot; readonly mayExitNaturally: boolean; readonly manualRecheck?: boolean; readonly packageChange?: { readonly current: string; readonly target: string }; readonly operation?: 'replace' | 'repair'; - readonly phase?: HostHandoffPhase; readonly actions: readonly HostHandoffAction[]; readonly defaultAction: 'cancel'; readonly recoveryBlocker?: HostHandoffRecoveryBlocker; @@ -108,6 +106,19 @@ export interface HostHandoffView { readonly diagnostic?: string; } +export interface HostHandoffAttentionView extends HostHandoffViewBase { + readonly state: 'attention'; + readonly reason: HostHandoffReason; +} + +export interface HostHandoffProgressView extends HostHandoffViewBase { + readonly state: 'progress'; + readonly phase: HostHandoffPhase; +} + +/** Data-only projection. A Surface cannot supply policy, a target, or a transaction. */ +export type HostHandoffView = HostHandoffAttentionView | HostHandoffProgressView; + export interface HostHandoffSurface { update(view: HostHandoffView): void; close(): void; @@ -128,7 +139,7 @@ export class HostHandoffCancelledError extends RuntimeHostPermanentReconnectErro /** Noninteractive callers receive the same decision instead of prompting or silently stopping work. */ export class HostHandoffRequiredError extends RuntimeHostPermanentReconnectError { readonly code = 'runtime_host_handoff_required'; - constructor(readonly view: HostHandoffView) { + constructor(readonly view: HostHandoffAttentionView) { const copy = formatHostHandoff(view, 'en'); super([copy.title, copy.description, copy.detail, view.diagnostic].filter(Boolean).join('\n')); this.name = 'HostHandoffRequiredError'; @@ -277,11 +288,13 @@ export async function runHostHandoff }>(input : attemptAbort.signal; const progress = (phase: HostHandoffPhase) => publish({ - ...current, revision: randomUUID(), + target: current.target, state: 'progress', phase: cooperative && !interrupt && phase === 'retiring' ? 'pausing' : phase, actions: cancelled ? [] : ['cancel'], + defaultAction: 'cancel', + mayExitNaturally: false, }); if (input.openSurface) surface ??= input.openSurface(submit); progress(cooperative && !interrupt ? 'pausing' : 'checking'); @@ -353,7 +366,7 @@ function projectBlocker( revision: string, recovery?: string, activeWorkRefused = false, -): HostHandoffView { +): HostHandoffAttentionView { const replacement = blocker.replacement; return { revision, From 9def15d945393c3a48dbb527c84b8a82724f4f89 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 18 Sep 2026 22:47:54 +0800 Subject: [PATCH 02/44] feat(desktop): create the main window before Local Host reconciliation Desktop startup awaited the Local Runtime Host connect/handoff chain before building the window or registering IPC, so first paint was gated on connect, generation checks, and on a version mismatch the full managed update. The manager already registers its reconnecting IPC router on construction, so construct it synchronously, create the main window and local caches immediately, and run start() in the background. A failed first connect now degrades the Local target to unavailable, the same semantics post-start fatals already used, instead of closing the router and quitting; retryLocalStart re-drives it in place with a fresh epoch so the profile entry and local session cache survive, and profile enablement routes the recovery dialog retry to it. The boot no longer opens a launch progress window; the handoff surface still opens on demand until the in-window surface replaces it. Refs #5488 Generated-by: Devin --- .../runtime-host-desktop-manager.test.ts | 15 +- .../__tests__/startup-progress-window.test.ts | 2 +- apps/desktop/src/main/main.ts | 25 +-- apps/desktop/src/main/runtime-host-boot.ts | 153 +++++++++--------- .../src/main/runtime-host-desktop-manager.ts | 142 +++++++++++----- .../src/main/runtime-host-profile-service.ts | 2 + 6 files changed, 190 insertions(+), 149 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts index 896a44da9f..bc061bbd15 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts @@ -30,6 +30,7 @@ import { RuntimeHostRequestInterruptedError, type RuntimeHostSpawnedProcess, type HostHandoffView, + type HostHandoffAttentionView, type HostHandoffAction, type OpenHostHandoffSurface, HostHandoffRequiredError, @@ -49,6 +50,7 @@ import { RuntimeHostPairingFinalizationInterruptedError, RuntimeHostUpgradeCancelledError, startRuntimeHostDesktopManager, + type RuntimeHostDesktopTargetState, } from '../runtime-host-desktop-manager.js'; test('replaces a disconnected Runtime Host generation', { timeout: 10_000 }, async () => { @@ -1729,7 +1731,8 @@ test('cancelling a live handoff does not authorize any replacement', async () => const observed = upgradeRequired(false); const conflict = { ...observed, registration: { ...observed.registration, lifecycleMode: 'service' as const } }; - await assert.rejects(startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { + let state: RuntimeHostDesktopTargetState | undefined; + const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { startCandidate: async () => conflict, handoffSurface: decideHandoff(() => 'cancel'), resolveLocalHostReplacement: async () => ({ @@ -1737,7 +1740,13 @@ test('cancelling a live handoff does not authorize any replacement', async () => replace: async () => assert.fail('cancel must not mutate the service'), }), onFatalError: () => undefined, - }), RuntimeHostUpgradeCancelledError); + onTargetStateChanged: (next) => { state = next; }, + }); + assert.equal(state?.readiness, 'unavailable'); + if (state?.readiness === 'unavailable') { + assert.ok(state.error instanceof RuntimeHostUpgradeCancelledError); + } + await owner.close(); }); test('keeps a known repair actionable when its first authority inspection fails', async () => { @@ -1777,7 +1786,7 @@ test('keeps a known repair actionable when its first authority inspection fails' }); function decideHandoff( - choose: (view: HostHandoffView) => HostHandoffAction, + choose: (view: HostHandoffAttentionView) => HostHandoffAction, ): OpenHostHandoffSurface { return (submit) => ({ update(view) { if (view.state === 'attention') submit(view.revision, choose(view)); }, diff --git a/apps/desktop/src/main/__tests__/startup-progress-window.test.ts b/apps/desktop/src/main/__tests__/startup-progress-window.test.ts index 3c1511776b..594e896498 100644 --- a/apps/desktop/src/main/__tests__/startup-progress-window.test.ts +++ b/apps/desktop/src/main/__tests__/startup-progress-window.test.ts @@ -223,7 +223,7 @@ test('an automated run never lets a handoff pull the app to the front', async () const h = harness(mode); h.progress.handoff(attention, () => {}, 'en'); h.resolveLoad(); await flush(); - h.progress.handoff({ ...attention, revision: 'second', state: 'progress' }, () => {}, 'en'); + h.progress.handoff({ revision: 'second', target: attention.target, state: 'progress', phase: 'staging', mayExitNaturally: false, actions: ['cancel'], defaultAction: 'cancel' }, () => {}, 'en'); h.progress.handoff({ ...attention, revision: 'third' }, () => {}, 'en'); h.progress.focus(); assert.deepEqual(h.reveals, expected[mode], mode); diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 0b1bea078b..aa5938f290 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -34,7 +34,6 @@ import { createDesktopPreviousMainProcessDiagnosticInput, installMainProcessLogCapture, formatDesktopDiagnosticReport, - createDesktopStartupDiagnosticInput, mainProcessLogBuffer, } from './main-process-diagnostics.js'; import { @@ -47,11 +46,7 @@ import { isIsolatedE2e, revealMode } from './startup-context.js'; import { reportDevelopmentLaunchResult } from './dev-single-instance-result.js'; import { registerPreviousMainProcessDiagnosticsIpc } from './desktop-diagnostics-ipc-main.js'; import { showBrowserMessageBox } from './browser-message-box.js'; -import { - showDesktopStartupProgress, - updateDesktopStartupProgress, - desktopStartupProgressWindow, -} from './startup-presentation.js'; +import { desktopStartupProgressWindow } from './startup-presentation.js'; let recoveryJournal: MainProcessRecoveryJournal | undefined; installMainProcessLogCapture(mainProcessLogBuffer, () => recoveryJournal?.markDirty()); @@ -204,28 +199,10 @@ if (!app.requestSingleInstanceLock()) { .whenReady() .then(() => { console.log('[startup] app ready'); - showDesktopStartupProgress((phase) => { - clipboard.writeText(formatDesktopDiagnosticReport( - createDesktopStartupDiagnosticInput({ - title: 'Desktop startup', description: 'Startup phase: ' + phase, - }), - captureDesktopDiagnosticEnvironment({ - appVersion: app.getVersion(), buildMode: buildInfo.mode, - updateChannel: desktopDiagnosticUpdateChannel({ - isPackaged: app.isPackaged, appPath: app.getAppPath(), - }), - buildCommit: buildInfo.commit, locale: app.getLocale(), - workspacePath: join(app.getPath('userData'), 'workspaces', 'default'), - }), - mainProcessLogBuffer.snapshot(), - { ok: false, error: 'Runtime Host is not yet available during startup' }, - )); - }); return import('./runtime-host-boot.js'); }) .catch(async (error: unknown) => { console.error('[startup] fatal:', error); - updateDesktopStartupProgress('attention'); try { // E2E runs must not hang on a modal error box (same reasoning as the // fixture-fatal path in runtime-host-boot.ts: print a parseable line and exit fast). diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 32e7a08bc6..cd15d573b2 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -201,8 +201,7 @@ import type { DesktopRuntimeHostTargetPolicy, } from "./runtime-host-desktop-candidate.js"; import { - RuntimeHostUpgradeCancelledError, - startRuntimeHostDesktopManager, + createRuntimeHostDesktopManager, type RuntimeHostDesktopManager, type RuntimeHostDesktopTargetState, } from "./runtime-host-desktop-manager.js"; @@ -666,6 +665,10 @@ const runtimeHostProfileService = createDesktopRuntimeHostProfileService({ if (!runtimeHostManager) throw new Error("Runtime Host manager is unavailable"); await runtimeHostManager.disable(profileId); }, + retryLocal: async () => { + if (!runtimeHostManager) throw new Error("Runtime Host manager is unavailable"); + await runtimeHostManager.retryLocalStart(); + }, finalizePairing: async (profileId) => { if (!runtimeHostManager) throw new Error("Runtime Host manager is unavailable"); await runtimeHostManager.finalizePairing(profileId); @@ -1124,7 +1127,7 @@ registerNotificationsIpc({ }); const sessionCopyOwnerProcessId = randomUUID(); -const startLocalRuntimeHostManager = () => startRuntimeHostDesktopManager( +const createLocalRuntimeHostManager = () => createRuntimeHostDesktopManager( { rootPath: workspaceRoot, clientInstanceId: runtimeHostClientInstanceId, @@ -1407,23 +1410,17 @@ const startLocalRuntimeHostManager = () => startRuntimeHostDesktopManager( }), resolveLocalHostReplacement: (registration, signal) => localRuntimeHostRemoteAccess.resolveConflictingHostReplacement(registration, signal), - onFatalError: (error, target) => { - // Initial failure is handled after manager.start() has closed its own - // observations. Do not quit before startup-owned resources are drained. - if (!runtimeHostManager) return; - if (error instanceof RuntimeHostUpgradeCancelledError) { - if (target.profile.kind === "local") app.quit(); - return; - } + onFatalError: (error) => { + // The target was already marked unavailable by the state machine; the + // app stays up and the recovery affordance offers the retry. console.error("[runtime-host] fatal:", error); - if (target.profile.kind === "local") app.quit(); }, }, ); let workBoardIpc: ReturnType | undefined; let runtimeHostDesktopShutdown: Promise | undefined; -// The first Host handoff can be cancelled before the main window exists. -// Install the same cleanup owner used by normal quit before that handoff. +// The quit coordinator owns cleanup for every later stage, including a Host +// handoff cancelled while the main window is still loading. const quitCoordinator = createAppQuitCoordinator({ prepareToQuit: prepareRuntimeHostDesktopQuit, cleanup: closeRuntimeHostDesktop, @@ -1442,75 +1439,73 @@ const quitCoordinator = createAppQuitCoordinator({ resumeQuit: () => app.quit(), }); app.on("before-quit", quitCoordinator.handleBeforeQuit); -updateDesktopStartupProgress('connect'); -runtimeHostManager = await startLocalRuntimeHostManager().catch(async (error: unknown) => { - await closeRuntimeHostDesktop(); - if (error instanceof RuntimeHostUpgradeCancelledError) { - app.quit(); - return new Promise(() => undefined); - } - throw error; -}); -// Runtime Host is the only schema-migration authority for its State Root. -// Work Board remains a Desktop-owned table, but it opens only after the Host is -// ready and verifies the schema instead of changing it behind a resident Host. -workBoardIpc = registerWorkBoardIpc({ - ipcMain, - workspaceRoot, - mainWindowController, - store: createWorkBoardStore(workspaceRoot, { schemaMigration: 'require_current' }), - validateLinkedSession: async (value, expectedProjectId) => { - const normalized = normalizeWorkBoardLinkedSession(value); - if (!normalized.ok) return false; - try { - const current = runtimeHostManager?.current(normalized.value.profileId); - if (!current?.candidate || current.hostId !== normalized.value.hostId) return false; - const sessions = await current.candidate.client.listSessions(); - const session = sessions.find((candidate) => candidate.id === normalized.value.sessionId); - if (!session) return false; - if (expectedProjectId !== undefined) { - return ( - session.workspace.target.kind === 'project' && - session.workspace.target.projectId === expectedProjectId - ); - } - return true; - } catch { - return false; - } - }, -}); -updateDesktopStartupProgress('renderer'); -wireLifecycle(); +// The manager registers its IPC router on construction; starting the Local +// Host is a background reconciliation, not a prerequisite for the window. +runtimeHostManager = createLocalRuntimeHostManager(); runtimeHostManager.setDefaultProfile(runtimeHostStartup.preferences.defaultProfileId); +wireLifecycle(); sessionLocal.wake(); windowsAppTray.start(); -await guestSessionMountService.start().catch((error: unknown) => { - console.error('[runtime-host] shared Sessions could not be restored:', error); -}); -await localRuntimeHostRemoteAccess.recover().catch((error: unknown) => { - console.error('[runtime-host] interrupted Local Host setup could not be recovered:', error); -}); -void runtimeHostProfileService.startEnabledProfiles(); -const unavailableDefault = runtimeHostStartup.unavailable.get( - runtimeHostStartup.preferences.defaultProfileId, +void (async () => { + await runtimeHostManager?.start(); + // Runtime Host is the only schema-migration authority for its State Root. + // Work Board remains a Desktop-owned table, but it opens only after the Host is + // ready and verifies the schema instead of changing it behind a resident Host. + workBoardIpc = registerWorkBoardIpc({ + ipcMain, + workspaceRoot, + mainWindowController, + store: createWorkBoardStore(workspaceRoot, { schemaMigration: 'require_current' }), + validateLinkedSession: async (value, expectedProjectId) => { + const normalized = normalizeWorkBoardLinkedSession(value); + if (!normalized.ok) return false; + try { + const current = runtimeHostManager?.current(normalized.value.profileId); + if (!current?.candidate || current.hostId !== normalized.value.hostId) return false; + const sessions = await current.candidate.client.listSessions(); + const session = sessions.find((candidate) => candidate.id === normalized.value.sessionId); + if (!session) return false; + if (expectedProjectId !== undefined) { + return ( + session.workspace.target.kind === 'project' && + session.workspace.target.projectId === expectedProjectId + ); + } + return true; + } catch { + return false; + } + }, + }); + await guestSessionMountService.start().catch((error: unknown) => { + console.error('[runtime-host] shared Sessions could not be restored:', error); + }); + await localRuntimeHostRemoteAccess.recover().catch((error: unknown) => { + console.error('[runtime-host] interrupted Local Host setup could not be recovered:', error); + }); + void runtimeHostProfileService.startEnabledProfiles(); + const unavailableDefault = runtimeHostStartup.unavailable.get( + runtimeHostStartup.preferences.defaultProfileId, + ); + if (unavailableDefault) { + void runtimeHostProfileService + .getSnapshot() + .then((snapshot) => { + const entry = snapshot.entries.find((candidate) => candidate.isDefault); + defaultRuntimeHostRecovery.offer({ + profileId: runtimeHostStartup.preferences.defaultProfileId, + profileName: + entry?.profile.name ?? runtimeHostStartup.preferences.defaultProfileId, + error: unavailableDefault, + }); + }) + .catch((error) => + console.error("[runtime-host] failed to resolve unavailable default Host:", error), + ); + } +})().catch((error: unknown) => + console.error("[runtime-host] background startup failed:", error), ); -if (unavailableDefault) { - void runtimeHostProfileService - .getSnapshot() - .then((snapshot) => { - const entry = snapshot.entries.find((candidate) => candidate.isDefault); - defaultRuntimeHostRecovery.offer({ - profileId: runtimeHostStartup.preferences.defaultProfileId, - profileName: - entry?.profile.name ?? runtimeHostStartup.preferences.defaultProfileId, - error: unavailableDefault, - }); - }) - .catch((error) => - console.error("[runtime-host] failed to resolve unavailable default Host:", error), - ); -} const stopComputerUseSession = (sessionId: string): void => { const ref = parseDesktopSessionResourceKey(sessionId); void runtimeHostManager diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts index ffa9837eef..a45f6b9793 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -82,6 +82,8 @@ export interface RuntimeHostDesktopManager { targetId: number, ): void; unobserveSession(observerId: string): Promise; + start(): Promise; + retryLocalStart(): Promise; enable( profileTarget: DesktopRuntimeHostCandidateStartInput['profileTarget'], onHostStatus?: (status: HostStatusResult) => void, @@ -250,33 +252,35 @@ interface DesktopOwnedProcessEvidence { state: 'running' | 'exited' | 'unknown'; } -export async function startRuntimeHostDesktopManager( +export interface RuntimeHostDesktopManagerOptions { + startCandidate?: ( + input: DesktopRuntimeHostCandidateStartInput, + observationRegistry: RuntimeHostSessionObservationRegistry, + ) => Promise; + onFatalError?: (error: Error, target: ResolvedRuntimeHostProfile) => void; + handoffSurface?: OpenHostHandoffSurface; + waitForHostExit?: (pid: number) => Promise; + forceTerminateObservedHost?: typeof forceTerminateObservedRegisteredRuntimeHost; + resolveLocalHostReplacement?: ( + registration: HostRegistration, + signal: AbortSignal, + ) => Promise; + recoverLocalHost?: (signal: AbortSignal) => Promise; + resolveStartupRepair?: (error: Error, signal: AbortSignal) => Promise; + resolveWslHostHandoff?: (profile: Extract, error: RuntimeHostRemoteCompatibilityError, signal: AbortSignal) => Promise; + reconnectBackoff?: RuntimeHostReconnectBackoff; + pairingFinalizationTimeoutMs?: number; + onTargetStateChanged?: (state: RuntimeHostDesktopTargetState) => void; + onTargetRemoved?: (state: RuntimeHostDesktopTargetState) => void; + onDefaultProfileChanged?: (profileId: string) => void; +} + +export function createRuntimeHostDesktopManager( input: DesktopRuntimeHostCandidateStartInput, - options: { - startCandidate?: ( - input: DesktopRuntimeHostCandidateStartInput, - observationRegistry: RuntimeHostSessionObservationRegistry, - ) => Promise; - onFatalError?: (error: Error, target: ResolvedRuntimeHostProfile) => void; - handoffSurface?: OpenHostHandoffSurface; - waitForHostExit?: (pid: number) => Promise; - forceTerminateObservedHost?: typeof forceTerminateObservedRegisteredRuntimeHost; - resolveLocalHostReplacement?: ( - registration: HostRegistration, - signal: AbortSignal, - ) => Promise; - recoverLocalHost?: (signal: AbortSignal) => Promise; - resolveStartupRepair?: (error: Error, signal: AbortSignal) => Promise; - resolveWslHostHandoff?: (profile: Extract, error: RuntimeHostRemoteCompatibilityError, signal: AbortSignal) => Promise; - reconnectBackoff?: RuntimeHostReconnectBackoff; - pairingFinalizationTimeoutMs?: number; - onTargetStateChanged?: (state: RuntimeHostDesktopTargetState) => void; - onTargetRemoved?: (state: RuntimeHostDesktopTargetState) => void; - onDefaultProfileChanged?: (profileId: string) => void; - } = {}, -): Promise { + options: RuntimeHostDesktopManagerOptions = {}, +): RuntimeHostDesktopManager { if (input.profileTarget) throw new Error('Desktop Runtime Host manager must start with Local'); - const manager = new RuntimeHostDesktopManagerImpl( + return new RuntimeHostDesktopManagerImpl( input, options.startCandidate ?? startDesktopRuntimeHostCandidate, options.onFatalError ?? ((error) => console.error('[runtime-host] reconnect failed:', error)), @@ -293,6 +297,13 @@ export async function startRuntimeHostDesktopManager( options.onTargetRemoved, options.onDefaultProfileChanged, ); +} + +export async function startRuntimeHostDesktopManager( + input: DesktopRuntimeHostCandidateStartInput, + options: RuntimeHostDesktopManagerOptions = {}, +): Promise { + const manager = createRuntimeHostDesktopManager(input, options); await manager.start(); return manager; } @@ -368,13 +379,53 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { local.lifecycle = await this.#startLifecycle(local, true); this.#activate(local); } catch (error) { - local.valid = false; - await this.#closeObservations(local.observations); - this.#ipcMain.close(); - throw error; + // A failed first connect degrades the Local target instead of taking the + // manager down: the renderer and IPC stay up, and retryLocalStart can + // drive a fresh attempt. + await this.#markUnavailable(local, error); } } + retryLocalStart(): Promise { + return this.#mutateTarget(LOCAL_RUNTIME_HOST_PROFILE.id, async (connectionSignal) => { + const existing = this.#requireTarget(LOCAL_RUNTIME_HOST_PROFILE.id); + if (existing.valid) return; + this.#targets.delete(LOCAL_RUNTIME_HOST_PROFILE.id); + existing.unsubscribeLifecycle?.(); + existing.unsubscribeRoutes?.(); + this.#ipcMain.deactivate(existing.epoch); + try { + await existing.lifecycle?.close(); + } finally { + await this.#closeObservations(existing.observations); + } + const local = this.#createTarget(this.#baseInput); + this.#targets.set(LOCAL_RUNTIME_HOST_PROFILE.id, local); + this.#publishState(local, { + epoch: local.epoch, + target: local.target, + readiness: 'connecting', + }); + try { + local.lifecycle = await this.#startLifecycle(local, false, connectionSignal); + if (this.#closed) { + await local.lifecycle.close(); + throw new Error('Desktop Runtime Host manager is closed'); + } + if (!local.valid) { + await local.lifecycle.close(); + throw local.state.readiness === 'unavailable' + ? local.state.error + : new Error('Desktop Runtime Host target became unavailable during startup'); + } + this.#activate(local); + } catch (error) { + await this.#markUnavailable(local, error); + throw error; + } + }); + } + async handleBotIncomingMessage(message: BotIncomingMessage): Promise { const target = this.#requireTarget(this.#defaultProfileId); if (target.state.readiness === 'unavailable') throw target.state.error; @@ -665,23 +716,30 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { } this.#activate(target); } catch (error) { - const alreadyUnavailable = target.state.readiness === 'unavailable'; - target.valid = false; - this.#ipcMain.deactivate(target.epoch); - await this.#closeObservations(target.observations); - if (!alreadyUnavailable) { - this.#publishState(target, { - epoch: target.epoch, - target: target.target, - readiness: 'unavailable', - ...(target.hostId ? { hostId: target.hostId } : {}), - error: error instanceof Error ? error : new Error(String(error)), - }); - } + await this.#markUnavailable(target, error); throw error; } } + async #markUnavailable( + target: DesktopRuntimeHostTargetGeneration, + error: unknown, + ): Promise { + const alreadyUnavailable = target.state.readiness === 'unavailable'; + target.valid = false; + this.#ipcMain.deactivate(target.epoch); + await this.#closeObservations(target.observations); + if (!alreadyUnavailable) { + this.#publishState(target, { + epoch: target.epoch, + target: target.target, + readiness: 'unavailable', + ...(target.hostId ? { hostId: target.hostId } : {}), + error: error instanceof Error ? error : new Error(String(error)), + }); + } + } + async disable(profileId: string): Promise { if (profileId === LOCAL_RUNTIME_HOST_PROFILE.id) { throw new Error('Local Runtime Host cannot be disabled'); diff --git a/apps/desktop/src/main/runtime-host-profile-service.ts b/apps/desktop/src/main/runtime-host-profile-service.ts index abb5552f02..c07d931aa2 100644 --- a/apps/desktop/src/main/runtime-host-profile-service.ts +++ b/apps/desktop/src/main/runtime-host-profile-service.ts @@ -303,6 +303,7 @@ export function createDesktopRuntimeHostProfileService(input: { onPeerEndpoint?: (endpoint: HostPeerEndpoint) => void, ) => Promise; readonly disable: (profileId: string) => Promise; + readonly retryLocal?: () => Promise; readonly finalizePairing: (profileId: string) => Promise; readonly setDefault: (profileId: string) => void; readonly catalog?: RuntimeHostProfileCatalog; @@ -1266,6 +1267,7 @@ export function createDesktopRuntimeHostProfileService(input: { return mutateProfiles(async () => { if (profileId === LOCAL_RUNTIME_HOST_PROFILE.id) { if (!isEnabled) throw new Error("Local Runtime Host cannot be disabled"); + await input.retryLocal?.(); return snapshot(); } if (isEnabled) { From dbb4b3c5a0da2dbdfaf4b059555f7b7d1a26aea8 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 18 Sep 2026 23:14:50 +0800 Subject: [PATCH 03/44] refactor(desktop): mount the renderer without prefetching the onboarding snapshot The pre-mount prefetch (retry + 2.5s timeout + prop plumbing + the workHub bypass) existed only to skip a transient loading frame. Mount immediately instead: the .maka-preload skeleton already covers the load gap, useOnboardingSnapshot pulls after mount and re-pulls on sessions:changed / connections:event, and a failed pull now falls back to the empty-chat surface instead of suppressing it forever. Generated-by: Devin --- apps/desktop/src/main/main-window.ts | 10 ++-- apps/desktop/src/renderer/README.md | 2 +- apps/desktop/src/renderer/app-shell.tsx | 43 +++-------------- apps/desktop/src/renderer/app.tsx | 11 +---- apps/desktop/src/renderer/main.tsx | 46 ++----------------- .../src/renderer/use-onboarding-snapshot.ts | 17 +++---- 6 files changed, 26 insertions(+), 103 deletions(-) diff --git a/apps/desktop/src/main/main-window.ts b/apps/desktop/src/main/main-window.ts index 98c0a2a451..935202ef83 100644 --- a/apps/desktop/src/main/main-window.ts +++ b/apps/desktop/src/main/main-window.ts @@ -160,11 +160,11 @@ const MAIN_WINDOW_TRAFFIC_LIGHT_POSITION = { x: 17, y: 14 } as const; const HIDDEN_TRAFFIC_LIGHT_POSITION = { x: -100, y: -100 } as const; // PR-SHOW-AFTER-FIRST-COMMIT: fallback reveal delay for a renderer that never -// signals its first painted frame (window:notifyRendererReady). main.tsx's -// onboarding prefetch bails at 2500ms; the remainder is headroom for React + -// first paint. The timer is armed only after loadURL/loadFile resolves, so -// Vite compilation and document loading do not consume this budget, while a -// wedged renderer still cannot leave the window invisible forever. +// signals its first painted frame (window:notifyRendererReady). The budget +// covers React mount + first paint headroom. The timer is armed only after +// loadURL/loadFile resolves, so Vite compilation and document loading do not +// consume this budget, while a wedged renderer still cannot leave the window +// invisible forever. const SHOW_FALLBACK_TIMEOUT_MS = 4000; // PR-WINDOW-TITLEBAR-0: the titleBarOverlay height matches the renderer diff --git a/apps/desktop/src/renderer/README.md b/apps/desktop/src/renderer/README.md index 5254847879..4d0c8b46c8 100644 --- a/apps/desktop/src/renderer/README.md +++ b/apps/desktop/src/renderer/README.md @@ -25,7 +25,7 @@ For the main/preload/renderer split and the IPC contract, see `apps/desktop/READ ## Entry -`main.tsx` → `app.tsx` → `AppShell` (`app-shell.tsx`). `index.html` is the Vite HTML shell. `main.tsx` prefetches the onboarding snapshot before mounting React so the normal-path first commit paints the real surface (if the prefetch times out it mounts with `null` and a fail-soft loading state); `app.tsx` wraps `AppShell` in `ToastProvider` + `ErrorBoundary`. +`main.tsx` → `app.tsx` → `AppShell` (`app-shell.tsx`). `index.html` is the Vite HTML shell. `main.tsx` mounts React immediately — the `.maka-preload` skeleton covers the load gap and each surface hydrates its own data after mount; `app.tsx` wraps `AppShell` in `ToastProvider` + `ErrorBoundary`. `styles.css` is the **only** bundled style entry: it imports Astryx, fonts, `maka-tokens.css`, `reference-shell.css`, and every `styles/*.css`. It contains only top-level orchestration; real selector rules go in `styles/*.css`. One contract-pinned exception: `index.html` carries an inline `.maka-preload` skeleton with hardcoded colors (no CSS variables — `maka-tokens.css` hasn't loaded yet) so there's no blank window during the CSS + JS load gap; `createRoot` replaces it on mount. diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index c9b074a387..931a064ca3 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -108,7 +108,6 @@ import { import { getOnboardingActivationCandidate, useOnboardingSnapshot } from './use-onboarding-snapshot'; import type { DesktopSessionSummary, - OnboardingSnapshot, } from '../preload/bridge-contract.js'; import { ProviderLogo } from './settings/provider-display'; import { ProviderBrandMark } from './settings/provider-brand-marks'; @@ -209,12 +208,7 @@ type ComposerImportOwner = { */ const SETTLE_FALLBACK_GRACE_MS = 1000; const { useSessionCollaborationDialog } = SessionCollaboration; -type AppShellProps = { - /** Pre-mount snapshot prefetched by main.tsx — see prefetchOnboardingSnapshot. */ - initialOnboardingSnapshot?: OnboardingSnapshot | null; -}; - -export function AppShell({ initialOnboardingSnapshot = null }: AppShellProps = {}) { +export function AppShell() { const [uiLocalePreference, setUiLocalePreference] = useState('auto'); const [uiLocaleOverride, setUiLocaleOverride] = useState(null); const systemUiLocale = useSystemUiLocale(); @@ -251,7 +245,7 @@ export function AppShell({ initialOnboardingSnapshot = null }: AppShellProps = { {(overlays) => ( )} @@ -277,7 +271,6 @@ export function AppShell({ initialOnboardingSnapshot = null }: AppShellProps = { const SESSION_RAIL = ; function AppShellContent({ - initialOnboardingSnapshot = null, taskEntry, overlays, uiLocale, @@ -285,7 +278,6 @@ function AppShellContent({ setUiLocaleOverride, setUiLocalePreference, }: { - initialOnboardingSnapshot?: OnboardingSnapshot | null; taskEntry: TaskEntryShellProjection; overlays: OverlaysShellProjection; uiLocale: UiLocale; @@ -302,7 +294,6 @@ function AppShellContent({ authoritativeSessionIds, sessionsRef, refreshSessions, - seedSessions, activeId, activeIdRef, requestedSessionId, @@ -360,7 +351,7 @@ function AppShellContent({ const { searchScrollTarget } = overlays.selectors; const settingsOpen = overlays.selectors.settings.open; - const onboarding = useOnboardingSnapshot(initialOnboardingSnapshot); + const onboarding = useOnboardingSnapshot(); // The owner bridge keeps commands stable while TaskEntryRoot swaps the // current feature-owned implementation below the shell. const { selectLocalProject, resolveWorkBoardTarget, prepareWorkBoardDraft } = taskEntry.commands; @@ -1031,27 +1022,6 @@ function AppShellContent({ // Re-entrancy lock only — a ref, not state, because nothing renders // from it (#1433 removed its last reader with the first-run hero). const sessionStartPendingRef = useRef(false); - // Seed a snapshot captured before React mounted so the sidebar can paint - // immediately. The subscription bootstrap reconciles once through the live - // Session catalog on the next frame; later onboarding pulls still own - // readiness and connection data, but never overwrite that catalog. - const initialSnapshotSeededRef = useRef(false); - // useLayoutEffect, NOT useEffect: the snapshot render flips - // `isOnboardingLoading` off while `sessions` is still []. A passive - // effect seeds sessions AFTER the browser paints that frame, so users - // with history saw a one-frame flash of the empty-state hero (the - // "配置页闪了一下" startup flash). Layout effects run before paint, - // so the seeded sessions and the un-gated frame commit together. - useLayoutEffect(() => { - if (initialSnapshotSeededRef.current || !initialOnboardingSnapshot) return; - initialSnapshotSeededRef.current = true; - // This prop settled before React mounted, so it is the only onboarding - // value allowed to seed the catalog. Later snapshots must go through the - // authoritative refresher or they can overwrite a newer Guest-inclusive - // catalog with an older point-in-time view. - const next = seedSessions(initialOnboardingSnapshot.sessions); - bootstrapSelectionLease.reconcile(collapseSessionRevisions(next)); - }, [initialOnboardingSnapshot]); useEffect(() => { const snapshot = onboarding.snapshot; if (snapshot) { @@ -1060,17 +1030,18 @@ function AppShellContent({ defaultConnection: snapshot.defaultSlug, chatModelChoices: snapshot.chatModelChoices, }); - } else if (onboarding.error && !initialOnboardingSnapshot) { + } else if (onboarding.error) { // Session bootstrap is independent above. If onboarding itself failed, // retain the previous connection-specific recovery path as well. void defaultHostConnections.refreshConnections(); } - }, [initialOnboardingSnapshot, onboarding.error, onboarding.snapshot]); + }, [onboarding.error, onboarding.snapshot]); // PR110c (@kenji review): suppress hero AND the fallback EmptyChatHero // while the initial snapshot is in flight. Otherwise sessions.length===0 // + snapshot===null flashes the prompt-suggestion EmptyChatHero before // the state-routed OnboardingHero mounts. - const isOnboardingLoading = sessions.length === 0 && onboardingState === undefined && !onboardingSettled; + const isOnboardingLoading = + sessions.length === 0 && onboardingState === undefined && !onboardingSettled && !onboarding.error; // Only unfinished setup takes the chat surface over. A configured user with // no sessions is not onboarding: they land on the normal empty chat and use // the one real Composer, which creates the session on its first send. diff --git a/apps/desktop/src/renderer/app.tsx b/apps/desktop/src/renderer/app.tsx index 3214b6074b..7ed3b66678 100644 --- a/apps/desktop/src/renderer/app.tsx +++ b/apps/desktop/src/renderer/app.tsx @@ -22,14 +22,7 @@ import { Theme } from '@astryxdesign/core/theme'; import { makaTheme } from './astryx-theme/maka'; import { AppShell } from './composition/legacy-desktop-region'; import { useAstryxThemeMode } from './astryx-theme-mode'; -import type { OnboardingSnapshot } from '../preload/bridge-contract.js'; - -export function App({ - initialOnboardingSnapshot = null, -}: { - /** Pre-mount snapshot prefetched by main.tsx — see prefetchOnboardingSnapshot. */ - initialOnboardingSnapshot?: OnboardingSnapshot | null; -}) { +export function App() { // PR-SHOW-AFTER-FIRST-COMMIT: the BrowserWindow is created hidden // (main-window.ts show: false) so the OS never flashes the index.html // `.maka-preload` skeleton before React paints. A layout effect is too early @@ -61,7 +54,7 @@ export function App({ return ( - + ); diff --git a/apps/desktop/src/renderer/main.tsx b/apps/desktop/src/renderer/main.tsx index 78d2d1224e..e2d5acc750 100644 --- a/apps/desktop/src/renderer/main.tsx +++ b/apps/desktop/src/renderer/main.tsx @@ -28,48 +28,12 @@ import { DesktopFeatureServicesProvider, } from './composition/desktop-feature-services'; -const ONBOARDING_SNAPSHOT_RETRY_DELAY_MS = 150; -const ONBOARDING_SNAPSHOT_TIMEOUT_MS = 2_500; - syncUiLocaleDocument(readSystemUiLocale()); applyCachedThemeBeforeMount(); const desktopFeatureServices = createDesktopFeatureServices(); -/** - * Prefetch the onboarding snapshot BEFORE mounting React. The preload - * skeleton (index.html) stays on screen while this resolves, so the first - * React commit already has sessions + connections and paints the real - * chat surface directly — no intermediate loading card, no layout jump - * (the "配置页闪了一下" startup flash). - * - * Fail-open: one quick retry (the IPC handler may not be registered in - * the first milliseconds), then a hard timeout so a wedged main process - * can never block the renderer from mounting. On timeout/failure React - * mounts with `null` and the classic in-app loading path takes over. - */ -async function prefetchOnboardingSnapshot() { - // WorkHub owns its session readiness and never consumes Desktop onboarding. - if (desktopFeatureServices.workHub.surface === 'workhub') return null; - const attempt = async () => { - try { - return await window.maka.onboarding.getSnapshot(); - } catch { - await new Promise((resolve) => setTimeout(resolve, ONBOARDING_SNAPSHOT_RETRY_DELAY_MS)); - try { - return await window.maka.onboarding.getSnapshot(); - } catch { - return null; - } - } - }; - const timeout = new Promise((resolve) => setTimeout(() => resolve(null), ONBOARDING_SNAPSHOT_TIMEOUT_MS)); - return Promise.race([attempt(), timeout]); -} - -void prefetchOnboardingSnapshot().then((initialOnboardingSnapshot) => { - createRoot(document.getElementById('root')!).render( - - - , - ); -}); +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/apps/desktop/src/renderer/use-onboarding-snapshot.ts b/apps/desktop/src/renderer/use-onboarding-snapshot.ts index 8e00c44f20..d3b1a33543 100644 --- a/apps/desktop/src/renderer/use-onboarding-snapshot.ts +++ b/apps/desktop/src/renderer/use-onboarding-snapshot.ts @@ -107,16 +107,15 @@ export function getOnboardingActivationCandidate( */ export function useOnboardingSnapshotImpl( deps: UseOnboardingSnapshotDeps, - initialSnapshot: OnboardingSnapshot | null = null, ): UseOnboardingSnapshotResult { const locale = useUiLocale(); const localeRef = useRef(locale); localeRef.current = locale; - const [snapshot, setSnapshot] = useState(initialSnapshot); + const [snapshot, setSnapshot] = useState(null); const [error, setError] = useState(null); - const sessionsRef = useRef(initialSnapshot?.sessions ?? null); - const connectionsRef = useRef(initialSnapshot?.connections ?? null); - const defaultSlugRef = useRef(initialSnapshot?.defaultSlug ?? null); + const sessionsRef = useRef(null); + const connectionsRef = useRef(null); + const defaultSlugRef = useRef(null); const pollerRef = useRef(null); if (pollerRef.current === null) { @@ -247,14 +246,10 @@ export function onboardingSnapshotErrorMessage(error: unknown, locale: UiLocale) * Callers that need a re-pull on a specific UI action (e.g. modal * close) should call `refresh()` from the returned object. */ -export function useOnboardingSnapshot(initialSnapshot: OnboardingSnapshot | null = null): UseOnboardingSnapshotResult { +export function useOnboardingSnapshot(): UseOnboardingSnapshotResult { // Bind to the live IPC bridge. `deps` is memoized as a module-level // object so the effect deps stay stable across re-renders. - // `initialSnapshot` comes from main.tsx's pre-mount prefetch: with it, - // the very first commit already has sessions + connections, so the - // startup path never shows the intermediate loading card ("配置页 - // 闪了一下"). The mount effect still pulls a fresh snapshot. - return useOnboardingSnapshotImpl(LIVE_DEPS, initialSnapshot); + return useOnboardingSnapshotImpl(LIVE_DEPS); } const LIVE_DEPS: UseOnboardingSnapshotDeps = { From e2e9e79104918aee0057600f9473a6e7ff1a061c Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 18 Sep 2026 23:16:03 +0800 Subject: [PATCH 04/44] feat(desktop): surface Runtime Host handoff attention inside the main window Replaces the standalone startup/progress window with an in-window attention surface. Host reconciliation now runs silently in the background; progress stays invisible and only views that need a user decision (attention state) are pushed to the renderer, which renders them as a required dialog with the existing copy-diagnostics action. - New runtime-host-handoff-surface publishes the current handoff view over IPC and routes renderer decisions back to the handoff submit. - New preload bridge (current/subscribe/decide) plus a renderer overlay mounted above AppShell; localized copy follows the existing UiCatalog pattern. - Deletes startup-progress-window.ts, startup-presentation.ts, their test, the onShow hook, the duplicated onUpdateProgress option (the onProgress callback already carries the same phases), and the renderer-architecture allowlist entry. - Exports the formatted handoff presentation type so the main-process surface and renderer share one contract. Generated-by: Devin --- .../scripts/check-renderer-architecture.mjs | 2 - .../__tests__/main-startup-lifetime.test.ts | 36 +-- .../runtime-host-local-remote-access.test.ts | 6 +- .../__tests__/startup-progress-window.test.ts | 232 -------------- .../__tests__/startup-storage-repair.test.ts | 2 - apps/desktop/src/main/main-window.ts | 2 - apps/desktop/src/main/main.ts | 5 +- apps/desktop/src/main/runtime-host-boot.ts | 34 +-- .../src/main/runtime-host-handoff-surface.ts | 110 +++++++ .../main/runtime-host-local-remote-access.ts | 3 - apps/desktop/src/main/startup-presentation.ts | 135 -------- .../src/main/startup-progress-window.ts | 288 ------------------ apps/desktop/src/preload/bridge-contract.d.ts | 14 + apps/desktop/src/preload/preload.ts | 19 ++ apps/desktop/src/renderer/app.tsx | 3 + .../locales/runtime-host-handoff-copy.ts | 40 +++ .../renderer/runtime-host-handoff-overlay.tsx | 102 +++++++ .../src/client/host-handoff-copy.ts | 14 +- packages/runtime-host/src/client/index.ts | 2 +- 19 files changed, 327 insertions(+), 722 deletions(-) delete mode 100644 apps/desktop/src/main/__tests__/startup-progress-window.test.ts create mode 100644 apps/desktop/src/main/runtime-host-handoff-surface.ts delete mode 100644 apps/desktop/src/main/startup-presentation.ts delete mode 100644 apps/desktop/src/main/startup-progress-window.ts create mode 100644 apps/desktop/src/renderer/locales/runtime-host-handoff-copy.ts create mode 100644 apps/desktop/src/renderer/runtime-host-handoff-overlay.tsx diff --git a/apps/desktop/scripts/check-renderer-architecture.mjs b/apps/desktop/scripts/check-renderer-architecture.mjs index 378e706d5e..b767889894 100644 --- a/apps/desktop/scripts/check-renderer-architecture.mjs +++ b/apps/desktop/scripts/check-renderer-architecture.mjs @@ -2769,8 +2769,6 @@ function validateMainWindowEntryContract(desktopRoot, violations) { const allowedNavigationFiles = new Set([ 'src/main/browser-message-box.ts', - // Self-contained, sandboxed startup status document without the app preload. - 'src/main/startup-progress-window.ts', 'src/main/browser/controller.ts', 'src/main/computer-use/cursor-overlay-window.ts', 'src/main/computer-use/pip-electron.ts', diff --git a/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts b/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts index 463f68891e..64132ea0f5 100644 --- a/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts +++ b/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts @@ -60,12 +60,12 @@ test('retains process lifetime before a standalone startup dialog can close', () ); assert.match( windowAllClosed, - /process\.platform !== "darwin" && !windowsAppTray\.hasTray\(\) && !isBrowserMessageBoxPresentationActive\(\) &&\s*!isDesktopStartupInProgress\(\)/u, + /process\.platform !== "darwin" && !windowsAppTray\.hasTray\(\) && !isBrowserMessageBoxPresentationActive\(\)/u, ); }); test('registers one shared quit cleanup before the initial Host handoff', () => { - const hostStart = bootSource.indexOf('runtimeHostManager = await startLocalRuntimeHostManager'); + const hostStart = bootSource.indexOf('await runtimeHostManager?.start()'); const quitRegistration = bootSource.indexOf('app.on("before-quit", quitCoordinator.handleBeforeQuit)'); const workBoardDeclaration = bootSource.indexOf('let workBoardIpc:'); assert.ok(workBoardDeclaration >= 0 && workBoardDeclaration < quitRegistration); @@ -77,27 +77,13 @@ test('registers one shared quit cleanup before the initial Host handoff', () => assert.match(bootSource, /workBoardIpc\?\.close\(\)/u); }); -test('drains startup resources before cancellation quit or fatal presentation', () => { - const callbackStart = bootSource.indexOf('onFatalError: (error, target) => {'); - const callback = bootSource.slice(callbackStart, bootSource.indexOf('\n);', callbackStart)); - assert.ok(callback.indexOf('if (!runtimeHostManager) return;') < callback.indexOf('app.quit()')); - - const hostStart = bootSource.indexOf('runtimeHostManager = await startLocalRuntimeHostManager'); - const failure = bootSource.slice(hostStart, bootSource.indexOf('// Runtime Host is the only', hostStart)); - const cleanup = failure.indexOf('await closeRuntimeHostDesktop()'); - assert.ok(cleanup >= 0 && cleanup < failure.indexOf('app.quit()')); - assert.ok(cleanup < failure.indexOf('throw error')); - assert.doesNotMatch(failure, /retireOwnedLocalHost|forceTerminate/u); - assert.match(bootSource, /await runtimeHostPeerMeshComponent\?\.close\(\)[\s\S]*await runtimeHostPeerEndpointOwner\?\.close\(\)/u); -}); - -test('presents startup before Host boot and hands off only when the main window is shown', () => { - const ready = mainSource.indexOf("console.log('[startup] app ready')"); - const presentation = mainSource.indexOf('showDesktopStartupProgress(', ready); - const hostBoot = mainSource.indexOf("import('./runtime-host-boot.js')", ready); - assert.ok(ready >= 0 && presentation > ready && hostBoot > presentation); - assert.match(bootSource, /onShow: closeDesktopStartupProgress/u); - assert.match(mainWindowSource, /mainWindow\.once\('show', \(\) => deps\.onShow\?\.\(\)\)/u); +test('creates the main window before starting Local Host reconciliation', () => { + const managerCreate = bootSource.indexOf('runtimeHostManager = createLocalRuntimeHostManager()'); + const lifecycleWire = bootSource.indexOf('wireLifecycle();', managerCreate); + const hostStart = bootSource.indexOf('await runtimeHostManager?.start()', managerCreate); + assert.ok(managerCreate >= 0); + assert.ok(lifecycleWire > managerCreate && hostStart > lifecycleWire); + assert.doesNotMatch(mainSource, /startup-presentation/u); }); test('resolves persisted locale before first post-settings recovery prompt', () => { @@ -112,7 +98,7 @@ test('resolves persisted locale before first post-settings recovery prompt', () const defaultHostRecovery = bootSource.slice(defaultHostRecoveryStart); assert.match(rendererRecovery, /const locale = await desktopLocale\.resolve\(\)/u); - assert.match(bootSource, /handoffSurface: createDesktopHostHandoffSurface\(\(\) => desktopLocale\.resolve\(\)\)/u); + assert.match(bootSource, /resolveLocale: \(\) => desktopLocale\.resolve\(\)/u); assert.match(defaultHostRecovery, /const locale = await desktopLocale\.resolve\(\)/u); assert.doesNotMatch(rendererRecovery, /desktopLocale\.current\(\)/u); assert.doesNotMatch(defaultHostRecovery, /resolveSystemUiLocale/u); @@ -120,7 +106,7 @@ test('resolves persisted locale before first post-settings recovery prompt', () test('lets the Runtime Host migrate its State Root before Desktop opens shared tables', () => { const hostStart = bootSource.indexOf( - 'runtimeHostManager = await startLocalRuntimeHostManager', + 'await runtimeHostManager?.start()', ); const workBoardOpen = bootSource.indexOf( 'store: createWorkBoardStore(workspaceRoot', diff --git a/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts b/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts index 3c788c7f50..65f58c2f2e 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts @@ -344,7 +344,6 @@ test('repairs an existing managed Host with the current setup package and restar directPeerAvailable: true, manager: () => undefined, resolveSetupPackage: async () => setupPackage, - onUpdateProgress: (phase) => phases.push(phase), operator: { async runUpdate(input: { readonly setupPackage: unknown; @@ -382,7 +381,10 @@ test('repairs an existing managed Host with the current setup package and restar }); t.after(() => service.close()); - assert.deepEqual(await service.repairManagedStartup({ allowManualUpdate: true }), { + assert.deepEqual(await service.repairManagedStartup({ + allowManualUpdate: true, + onProgress: (phase) => phases.push(phase), + }), { kind: 'repaired', }); assert.deepEqual(actions, ['update', 'restart']); diff --git a/apps/desktop/src/main/__tests__/startup-progress-window.test.ts b/apps/desktop/src/main/__tests__/startup-progress-window.test.ts deleted file mode 100644 index 594e896498..0000000000 --- a/apps/desktop/src/main/__tests__/startup-progress-window.test.ts +++ /dev/null @@ -1,232 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import assert from 'node:assert/strict'; -import { EventEmitter } from 'node:events'; -import { test } from 'node:test'; -import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron'; -import type { HostHandoffView } from '@maka/runtime-host/client'; -import { createStartupProgressWindow, renderStartupProgressHtml } from '../startup-progress-window.js'; -import type { WindowRevealMode } from '../window-reveal.js'; - -function harness(revealMode: WindowRevealMode = 'active') { - let resolveLoad!: () => void; - let rejectLoad!: (error: Error) => void; - let destroyed = false; - let minimized = false; - let visible = false; - let shown = 0; - let shownInactive = 0; - let focused = 0; - let copied = 0; - let copiedHandoff: HostHandoffView | undefined; - let documentUrl = ''; - let options: BrowserWindowConstructorOptions | undefined; - let openWindow!: () => { action: string }; - let contentSize = [520, 350]; - let measuredHeight = 350; - const scripts: string[] = []; - const errors: unknown[] = []; - const contents = Object.assign(new EventEmitter(), { - setWindowOpenHandler(handler: typeof openWindow) { openWindow = handler; }, - async executeJavaScript(source: string) { scripts.push(source); return measuredHeight; }, - }); - const window = Object.assign(new EventEmitter(), { - webContents: contents, - setMenuBarVisibility() {}, - getContentSize() { return contentSize; }, - setContentSize(width: number, height: number) { contentSize = [width, height]; }, - isDestroyed: () => destroyed, - isMinimized: () => minimized, - destroy() { destroyed = true; }, - minimize() { minimized = true; }, - restore() { minimized = false; }, - isVisible: () => visible, - showInactive() { shownInactive += 1; visible = true; }, - show() { shown += 1; visible = true; }, - focus() { focused += 1; }, - loadURL: (url: string) => { - documentUrl = url; - return new Promise((resolve, reject) => { - resolveLoad = resolve; - rejectLoad = reject; - }); - }, - }); - const progress = createStartupProgressWindow({ - locale: 'en', dark: false, icon: '/test/icon.png', revealMode, - createWindow(input) { options = input; return window as unknown as BrowserWindow; }, - copyDiagnostics(_phase, handoff) { copied += 1; copiedHandoff = handoff; }, - onError(error) { errors.push(error); }, - }); - return { - progress, window, contents, scripts, errors, resolveLoad, rejectLoad, - get options() { return options; }, - get copied() { return copied; }, - get copiedHandoff() { return copiedHandoff; }, - get documentUrl() { return documentUrl; }, - get destroyed() { return destroyed; }, - get minimized() { return minimized; }, - get visible() { return visible; }, - get reveals() { return { shown, shownInactive, focused }; }, - get openWindow() { return openWindow; }, - get contentSize() { return contentSize; }, - setMeasuredHeight(height: number) { measuredHeight = height; }, - }; -} -const flush = () => new Promise((resolve) => setImmediate(resolve)); - -test('fits content instead of reserving an empty handoff panel and bounds long diagnoses', async () => { - const h = harness(); - h.resolveLoad(); - await flush(); - const view: HostHandoffView = { revision: 'sized', state: 'attention', - reason: 'busy', mayExitNaturally: false, defaultAction: 'cancel', - actions: ['cancel'], target: { name: 'Local', location: 'local' } }; - h.setMeasuredHeight(368); - h.progress.handoff(view, () => {}, 'en'); - await flush(); - assert.deepEqual(h.contentSize, [560, 368]); - h.setMeasuredHeight(900); - h.progress.handoff({ ...view, revision: 'long' }, () => {}, 'en'); - await flush(); - assert.deepEqual(h.contentSize, [560, 640]); - h.setMeasuredHeight(350); - h.progress.clearHandoff(); - await flush(); - assert.deepEqual(h.contentSize, [520, 350]); - h.progress.close(); -}); - -test('shows the latest real phase after loading and minimizes without terminating startup', async () => { - const h = harness(); - h.progress.update('staging'); - assert.equal(h.visible, false); - h.resolveLoad(); - await flush(); - assert.equal(h.visible, true); - assert.match(h.scripts.at(-1) ?? '', /Installing the update/); - let prevented = false; - h.window.emit('close', { preventDefault() { prevented = true; } }); - assert.equal(prevented, true); - assert.equal(h.minimized, true); - assert.equal(h.destroyed, false); - h.progress.focus(); - assert.equal(h.minimized, false); - h.progress.close(); - assert.equal(h.destroyed, true); - assert.equal(h.progress.window(), undefined); -}); - -test('handoff before HTML finishes loading cannot reveal an orphan startup window', async () => { - const h = harness(); - h.progress.close(); - h.resolveLoad(); - await flush(); - h.progress.update('renderer'); - h.progress.focus(); - assert.equal(h.visible, false); - assert.equal(h.scripts.length, 0); - assert.equal(h.destroyed, true); -}); - -test('presentation failure is contained and does not reject the startup operation', async () => { - const h = harness(); - const error = new Error('renderer could not load'); - h.rejectLoad(error); - await flush(); - assert.deepEqual(h.errors, [error]); - assert.equal(h.destroyed, true); - h.progress.close(); -}); - -test('diagnostics is the only permitted navigation action; the window has no app bridge', async () => { - const h = harness(); - h.resolveLoad(); - await flush(); - assert.equal(h.options?.webPreferences?.nodeIntegration, false); - assert.equal(h.options?.webPreferences?.sandbox, true); - assert.equal(h.options?.webPreferences?.preload, undefined); - assert.ok(h.documentUrl.startsWith('data:text/html;charset=utf-8,')); - assert.deepEqual(h.openWindow(), { action: 'deny' }); - for (const url of ['https://example.test', 'maka-startup://copy/anything', 'maka-startup://copy']) { - let prevented = false; - h.contents.emit('will-navigate', { preventDefault() { prevented = true; } }, url); - assert.equal(prevented, true); - } - await flush(); - assert.equal(h.copied, 1); - assert.match(h.scripts.at(-1) ?? '', /Diagnostics copied/); - h.progress.close(); -}); - -test('localized progress stays self-contained, accessible and has no fabricated percentage', () => { - for (const locale of ['en', 'zh-CN', 'zh-TW'] as const) { - for (const dark of [false, true]) { - const html = renderStartupProgressHtml(locale, dark); - assert.ok(html.includes('lang="' + locale + '"')); - assert.match(html, /default-src 'none'/); - assert.match(html, /aria-live="polite"/); - assert.match(html, /prefers-reduced-motion/); - assert.doesNotMatch(html, /aria-valuenow| { - const h = harness(); - const actions: string[] = []; - const view: HostHandoffView = { revision: 'first', target: { name: 'local', location: 'local' }, - state: 'attention', reason: 'busy', mayExitNaturally: false, - actions: ['cancel', 'retry', 'interrupt'], defaultAction: 'cancel', diagnostic: 'current host is busy' }; - const submit = (revision: string, action: string) => { actions.push(`${revision}:${action}`); }; - h.progress.handoff(view, submit, 'en'); - h.resolveLoad(); await flush(); - h.progress.handoff({ ...view, revision: 'second', state: 'progress', phase: 'pausing', actions: ['cancel'] }, submit, 'en'); - for (const url of ['maka-startup://handoff/first/interrupt', 'maka-startup://handoff/second/interrupt', - 'maka-startup://handoff/second/cancel', 'maka-startup://copy']) { - h.contents.emit('will-navigate', { preventDefault() {} }, url); - } - await flush(); - assert.deepEqual(actions, ['second:cancel']); - assert.equal(h.copiedHandoff?.revision, 'second'); - assert.equal(h.copiedHandoff?.diagnostic, view.diagnostic); - h.progress.close(); -}); - -test('an automated run never lets a handoff pull the app to the front', async () => { - const attention: HostHandoffView = { revision: 'first', target: { name: 'local', location: 'local' }, - state: 'attention', reason: 'busy', mayExitNaturally: false, actions: ['cancel'], defaultAction: 'cancel' }; - const expected = { - hidden: { shown: 0, shownInactive: 0, focused: 0 }, - inactive: { shown: 0, shownInactive: 1, focused: 0 }, - active: { shown: 3, shownInactive: 0, focused: 3 }, - } as const; - for (const mode of ['hidden', 'inactive', 'active'] as const) { - const h = harness(mode); - h.progress.handoff(attention, () => {}, 'en'); - h.resolveLoad(); await flush(); - h.progress.handoff({ revision: 'second', target: attention.target, state: 'progress', phase: 'staging', mayExitNaturally: false, actions: ['cancel'], defaultAction: 'cancel' }, () => {}, 'en'); - h.progress.handoff({ ...attention, revision: 'third' }, () => {}, 'en'); - h.progress.focus(); - assert.deepEqual(h.reveals, expected[mode], mode); - h.progress.close(); - } -}); diff --git a/apps/desktop/src/main/__tests__/startup-storage-repair.test.ts b/apps/desktop/src/main/__tests__/startup-storage-repair.test.ts index 9b458a8716..66847495c1 100644 --- a/apps/desktop/src/main/__tests__/startup-storage-repair.test.ts +++ b/apps/desktop/src/main/__tests__/startup-storage-repair.test.ts @@ -100,8 +100,6 @@ for (const accept of [false, true]) { createClientRuntimeHostProfileCatalog: () => ({}), resolveDesktopRuntimeHostStartup: async () => ({}), resolveE2eFixture: () => undefined, - desktopStartupProgressWindow: () => undefined, - updateDesktopStartupProgress: () => {}, resolveDesktopStorageRoot, startupStep, getNativeDiagnosticDialogCopy, diff --git a/apps/desktop/src/main/main-window.ts b/apps/desktop/src/main/main-window.ts index 935202ef83..88a32f8ee2 100644 --- a/apps/desktop/src/main/main-window.ts +++ b/apps/desktop/src/main/main-window.ts @@ -107,7 +107,6 @@ interface MainWindowControllerDeps { revealMode: WindowRevealMode; onClose?: () => void; onClosed?: () => void; - onShow?: () => void; onRendererProcessGone: (details: Electron.RenderProcessGoneDetails) => void | Promise; } @@ -462,7 +461,6 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main // // Both are gated on the URL using `http(s):` or `mailto:` — everything else // (file://, electron internal, etc.) is allowed/denied per Electron defaults. - mainWindow.once('show', () => deps.onShow?.()); mainWindow.webContents.setWindowOpenHandler(({ url }) => { if (isExternalUrl(url)) { void shell.openExternal(url); diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index aa5938f290..110bc4c3d5 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -46,7 +46,7 @@ import { isIsolatedE2e, revealMode } from './startup-context.js'; import { reportDevelopmentLaunchResult } from './dev-single-instance-result.js'; import { registerPreviousMainProcessDiagnosticsIpc } from './desktop-diagnostics-ipc-main.js'; import { showBrowserMessageBox } from './browser-message-box.js'; -import { desktopStartupProgressWindow } from './startup-presentation.js'; +import { installDesktopStartupBranding } from './desktop-shell-presentation.js'; let recoveryJournal: MainProcessRecoveryJournal | undefined; installMainProcessLogCapture(mainProcessLogBuffer, () => recoveryJournal?.markDirty()); @@ -199,6 +199,7 @@ if (!app.requestSingleInstanceLock()) { .whenReady() .then(() => { console.log('[startup] app ready'); + installDesktopStartupBranding(revealMode); return import('./runtime-host-boot.js'); }) .catch(async (error: unknown) => { @@ -226,7 +227,7 @@ if (!app.requestSingleInstanceLock()) { mainLogs: () => mainProcessLogBuffer.snapshot(), writeClipboard: (report) => clipboard.writeText(report), showMessageBox: (options) => - showBrowserMessageBox(options, desktopStartupProgressWindow(), { + showBrowserMessageBox(options, undefined, { locale, revealMode, }), diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index cd15d573b2..7ea87919dc 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -209,7 +209,7 @@ import { buildRuntimeHostActiveQuitDialog, } from "./runtime-host-quit-copy.js"; import { prepareRuntimeHostQuit } from "./runtime-host-quit.js"; -import { createDesktopHostHandoffSurface } from './startup-presentation.js'; +import { createDesktopHostHandoffSurface } from './runtime-host-handoff-surface.js'; import { registerRuntimeHostMemoryIpc } from "./runtime-host-memory-ipc-main.js"; import { createDesktopRuntimeHostProfileService, @@ -271,12 +271,6 @@ import { } from "./startup-context.js"; import { resolveDesktopStorageRoot } from "./storage-root-startup.js"; import { startupStep } from "./startup-step.js"; -import { - closeDesktopStartupProgress, - desktopStartupProgressWindow, - isDesktopStartupInProgress, - updateDesktopStartupProgress, -} from './startup-presentation.js'; import { registerWorkspaceSearchIpc } from "./workspace-search-ipc-main.js"; import { parseDesktopSessionResourceKey, @@ -386,7 +380,7 @@ const desktopDiagnostics: DesktopDiagnosticsDeps = { resolveRuntimeHost: resolveRuntimeHostDiagnostics, writeClipboard: (report) => clipboard.writeText(report), }; -let resolveBrowserDialogParent = desktopStartupProgressWindow; +let resolveBrowserDialogParent = (): BrowserWindow | undefined => undefined; let resolveBrowserDialogAppearance = async (): Promise => ({ locale: resolveSystemUiLocale(app.getPreferredSystemLanguages()), palette: "default", @@ -434,7 +428,6 @@ const resolveLocalStorageRoot = () => confirmRepair: () => confirmDesktopStorageRootRepair(workspaceRoot), }), ); -updateDesktopStartupProgress('storage'); const startupLocalStorageRoot = await resolveLocalStorageRoot(); if (!startupLocalStorageRoot) { @@ -507,7 +500,6 @@ const mainWindowController = createMainWindowController({ revealMode, onClose: () => onMainWindowClose(), onClosed: () => onMainWindowClosed(), - onShow: closeDesktopStartupProgress, onRendererProcessGone: async (details) => { const diagnosticInput = createDesktopMainRendererDiagnosticInput({ title: "Maka main Renderer process exited unexpectedly", @@ -533,7 +525,7 @@ const mainWindowController = createMainWindowController({ }); resolveBrowserDialogParent = () => { const main = mainWindowController.browserWindow(); - return main?.isVisible() ? main : desktopStartupProgressWindow(); + return main?.isVisible() ? main : undefined; }; const runtimeHostSshTerminal = createDesktopRuntimeHostSshTerminal({ ipcMain, @@ -552,13 +544,8 @@ const localRuntimeHostRemoteAccess = createDesktopLocalRuntimeHostRemoteAccess({ rootId: startupLocalStorageRoot.rootId, directPeerAvailable: runtimeHostDirectPeerAvailable, manager: () => runtimeHostManager, - resolveSetupPackage: async (signal) => { - updateDesktopStartupProgress('package'); - const result = await runtimeHostSetupPackage.resolveForThisDesktop(signal); - updateDesktopStartupProgress('checking'); - return result; - }, - onUpdateProgress: updateDesktopStartupProgress, + resolveSetupPackage: async (signal) => + runtimeHostSetupPackage.resolveForThisDesktop(signal), operator: localRuntimeHostOperator, }); const native = assembleDesktopNativeCapabilities({ @@ -968,7 +955,7 @@ const windowsAppTray = createWindowsAppTray({ onMainWindowClosed = () => { // A hidden WorkHub host window can keep window-all-closed from firing. // Without a tray, use the existing quit flow; cancelling it restores Maka. - if (process.platform !== 'darwin' && !windowsAppTray.hasTray() && !isDesktopStartupInProgress()) app.quit(); + if (process.platform !== 'darwin' && !windowsAppTray.hasTray()) app.quit(); }; const mcpCapabilityPublisher = createCapabilityRevisionPublisher(() => mcpManager.toolSnapshot().revision, @@ -1298,7 +1285,11 @@ const createLocalRuntimeHostManager = () => createRuntimeHostDesktopManager( runtimeHostProfileService.resolveCollaborationConnectionTarget(profile), }, { - handoffSurface: createDesktopHostHandoffSurface(() => desktopLocale.resolve()), + handoffSurface: createDesktopHostHandoffSurface({ + ipcMain, + send: (payload) => mainWindowController.send('runtime-host-handoff:view', payload), + resolveLocale: () => desktopLocale.resolve(), + }), onTargetStateChanged: (state) => { const localTarget = localSessionTarget(state); if (localTarget) { @@ -2156,8 +2147,7 @@ function wireLifecycle(): void { app.on("window-all-closed", () => { native.computerUseOverlay.destroyAll(); native.computerUsePip.destroyAll(); - if (process.platform !== "darwin" && !windowsAppTray.hasTray() && !isBrowserMessageBoxPresentationActive() && - !isDesktopStartupInProgress()) app.quit(); + if (process.platform !== "darwin" && !windowsAppTray.hasTray() && !isBrowserMessageBoxPresentationActive()) app.quit(); }); powerMonitor.on("resume", wakePeerRecoveryAfterResume); quitCoordinator.focusOrCreateWindow(); diff --git a/apps/desktop/src/main/runtime-host-handoff-surface.ts b/apps/desktop/src/main/runtime-host-handoff-surface.ts new file mode 100644 index 0000000000..ecc64a97d7 --- /dev/null +++ b/apps/desktop/src/main/runtime-host-handoff-surface.ts @@ -0,0 +1,110 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { IpcMain } from 'electron'; +import type { UiLocale } from '@maka/core/ui-locale'; +import { + formatHostHandoff, + type HostHandoffAction, + type HostHandoffPresentation, + type HostHandoffView, + type OpenHostHandoffSurface, +} from '@maka/runtime-host/client'; + +export interface DesktopHostHandoffPayload { + readonly view: HostHandoffView; + readonly presentation: HostHandoffPresentation; +} + +interface OpenDesktopHandoff { + readonly submit: (revision: string, action: HostHandoffAction) => void; + readonly view: HostHandoffView; +} + +/** + * Host handoffs render inside the main window: background progress stays + * silent and only attention views reach the renderer, which decides through + * `runtime-host-handoff:decide`. Concurrent handoffs (e.g. Local plus an + * enabled remote) each keep their own submit; the most recently updated one + * owns the visible slot. + */ +export function createDesktopHostHandoffSurface(input: { + ipcMain: IpcMain; + send: (payload: DesktopHostHandoffPayload | null) => void; + resolveLocale: () => Promise; +}): OpenHostHandoffSurface { + const open = new Map(); + let sequence = 0; + let activeId: number | undefined; + const locale = input.resolveLocale().then( + (resolved) => resolved, + () => 'en' as UiLocale, + ); + + const currentEntry = (): OpenDesktopHandoff | undefined => + activeId === undefined ? undefined : open.get(activeId); + const payloadFor = async ( + entry: OpenDesktopHandoff | undefined, + ): Promise => + entry + ? { view: entry.view, presentation: formatHostHandoff(entry.view, await locale) } + : null; + const publish = (): void => { + void payloadFor(currentEntry()).then((payload) => input.send(payload)); + }; + + input.ipcMain.handle('runtime-host-handoff:current', () => + payloadFor(currentEntry()), + ); + input.ipcMain.handle( + 'runtime-host-handoff:decide', + (_event, payload: { revision?: unknown; action?: unknown }) => { + if (typeof payload?.revision !== 'string' || typeof payload?.action !== 'string') return; + for (const entry of open.values()) { + const { view } = entry; + if ( + view.revision === payload.revision && + view.state === 'attention' && + view.actions.includes(payload.action as HostHandoffAction) + ) { + entry.submit(view.revision, payload.action as HostHandoffAction); + return; + } + } + }, + ); + + return (submit) => { + const id = sequence++; + return { + update(view) { + open.set(id, { submit, view }); + activeId = id; + publish(); + }, + close() { + open.delete(id); + if (activeId === id) { + activeId = [...open.keys()].pop(); + publish(); + } + }, + }; + }; +} diff --git a/apps/desktop/src/main/runtime-host-local-remote-access.ts b/apps/desktop/src/main/runtime-host-local-remote-access.ts index 4f9a3fd861..a0cb2c3e11 100644 --- a/apps/desktop/src/main/runtime-host-local-remote-access.ts +++ b/apps/desktop/src/main/runtime-host-local-remote-access.ts @@ -192,7 +192,6 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { signal?: AbortSignal, ) => DesktopRuntimeHostSetupPackage | Promise; readonly operator: DesktopRuntimeHostLocalOperator; - readonly onUpdateProgress?: (phase: RuntimeHostServiceUpdatePhase | 'restart') => void; readonly resolveManagedDeploymentAuthority?: ( rootId: string, ) => Promise; @@ -779,7 +778,6 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { ...(retirementSignal ? { retirementSignal } : {}), }, (phase) => { - input.onUpdateProgress?.(phase); progress?.(phase); }, ); @@ -878,7 +876,6 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { const setupPackage = await input.resolveSetupPackage(signal); const progress = (phase: RuntimeHostServiceUpdatePhase | 'restart') => { - input.onUpdateProgress?.(phase); options.onProgress?.(phase); }; progress('checking'); diff --git a/apps/desktop/src/main/startup-presentation.ts b/apps/desktop/src/main/startup-presentation.ts deleted file mode 100644 index 4b92b42b30..0000000000 --- a/apps/desktop/src/main/startup-presentation.ts +++ /dev/null @@ -1,135 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { app, BrowserWindow, clipboard, nativeTheme } from 'electron'; -import { resolveSystemUiLocale, type UiLocale } from '@maka/core/ui-locale'; -import type { HostHandoffView, OpenHostHandoffSurface } from '@maka/runtime-host/client'; -import { readableAppIconPath } from './app-icon-surface.js'; -import { installApplicationMenu } from './application-menu.js'; -import { installDesktopStartupBranding } from './desktop-shell-presentation.js'; -import { revealMode } from './startup-context.js'; -import { - createStartupProgressWindow, - type StartupPhase, - type StartupProgressWindow, -} from './startup-progress-window.js'; - -let progress: StartupProgressWindow | undefined; -let handoffUsesStartup = false; - -const focus = () => progress?.focus(); - -/** Called after ready, before importing the asynchronous Runtime Host boot. */ -export function showDesktopStartupProgress( - copyDiagnostics: (phase: StartupPhase) => void | Promise, -): void { - installDesktopStartupBranding(revealMode); - // Automated runs retain their one-main-window contract and never steal focus. - if (revealMode !== 'active') return; - try { - installApplicationMenu({ - platform: process.platform, isPackaged: app.isPackaged, dispatch: focus, - }); - progress = createStartupProgressWindow({ - locale: resolveSystemUiLocale(app.getPreferredSystemLanguages()), - dark: nativeTheme.shouldUseDarkColors, - icon: readableAppIconPath('default'), - revealMode, - createWindow: (options) => new BrowserWindow(options), - copyDiagnostics: (phase, handoff) => handoff - ? clipboard.writeText(JSON.stringify(handoff, null, 2)) : copyDiagnostics(phase), - onError: (error) => console.error('[startup] progress presentation failed:', error), - }); - app.on('activate', focus); - app.on('second-instance', focus); - app.once('before-quit', closeDesktopStartupProgress); - } catch (error) { - console.error('[startup] progress presentation failed:', error); - closeDesktopStartupProgress(); - } -} - -export function updateDesktopStartupProgress(phase: StartupPhase): void { - progress?.update(phase); -} - -/** One presentation lifetime per attempt; startup reuses its already visible window. */ -export function createDesktopHostHandoffSurface(resolveLocale: () => Promise): OpenHostHandoffSurface { - return (submit) => { - let latest: HostHandoffView | undefined; - let window: StartupProgressWindow | undefined; - let locale: UiLocale | undefined; - let ownWindow = false; - let closed = false; - void resolveLocale().then((resolved) => { - if (closed) return; - locale = resolved; - if (progress?.window() && !handoffUsesStartup) { - window = progress; - handoffUsesStartup = true; - } else { - ownWindow = true; - window = createStartupProgressWindow({ - locale, dark: nativeTheme.shouldUseDarkColors, icon: readableAppIconPath('default'), - revealMode, - createWindow: (options) => new BrowserWindow(options), - copyDiagnostics: () => clipboard.writeText(JSON.stringify(latest, null, 2)), - onError: (error) => console.error('[runtime-host] handoff presentation failed:', error), - }); - } - if (latest) window.handoff(latest, submit, locale); - window.focus(); - }).catch((error) => { - console.error('[runtime-host] handoff presentation failed:', error); - if (latest) submit(latest.revision, 'cancel'); - }); - return { - update(view) { - latest = view; - if (window && locale) window.handoff(view, submit, locale); - }, - close() { - closed = true; - if (ownWindow) window?.close(); - else if (window) { - window.clearHandoff(); - handoffUsesStartup = false; - } - }, - }; - }; -} - -export function desktopStartupProgressWindow(): BrowserWindow | undefined { - return progress?.window(); -} - -export function isDesktopStartupInProgress(): boolean { - return progress !== undefined; -} - -export function closeDesktopStartupProgress(): void { - app.removeListener('activate', focus); - app.removeListener('second-instance', focus); - app.removeListener('before-quit', closeDesktopStartupProgress); - // Destroy can synchronously emit window-all-closed before the main window - // exists (quit or renderer failure). Keep the startup lifetime guard then. - progress?.close(); - progress = undefined; -} diff --git a/apps/desktop/src/main/startup-progress-window.ts b/apps/desktop/src/main/startup-progress-window.ts deleted file mode 100644 index 448e53b77b..0000000000 --- a/apps/desktop/src/main/startup-progress-window.ts +++ /dev/null @@ -1,288 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { randomUUID } from 'node:crypto'; -import { MAKA_WORDMARK_PATH } from '@maka/core/maka-wordmark'; -import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; -import { formatHostHandoff, type HostHandoffView, type HostHandoffAction } from '@maka/runtime-host/client'; -import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron'; -import { focusWindow, showWindowInactive, type WindowRevealMode } from './window-reveal.js'; - -export type StartupPhase = - | 'prepare' | 'storage' | 'connect' | 'package' - | 'checking' | 'staging' | 'retiring' | 'replacing' | 'restart' - | 'attention' | 'renderer'; - -interface StartupProgressCopy { - readonly title: string; - readonly detail: string; - readonly slow: string; - readonly copy: string; - readonly copied: string; - readonly copyFailed: string; - readonly elapsed: string; - readonly phases: Record; -} - -const COPY = { - en: { - title: 'Opening your workspace', - detail: 'Maka is preparing your local background service.', - slow: 'This is taking longer than usual. Updates may need to download or build a package. You can minimize this window while Maka continues.', - copy: 'Copy diagnostics', copied: 'Diagnostics copied', copyFailed: 'Could not copy diagnostics', - elapsed: 'Elapsed', - phases: { - prepare: 'Preparing Maka', storage: 'Checking local data', connect: 'Connecting to Runtime Host', - package: 'Preparing the Runtime Host package', checking: 'Checking the managed service', - staging: 'Installing the update', retiring: 'Safely stopping the previous service', - replacing: 'Replacing the managed service', restart: 'Restarting Runtime Host', - attention: 'Waiting for your confirmation', renderer: 'Opening your workspace', - }, - }, - 'zh-CN': { - title: '正在打开工作区', detail: 'Maka 正在准备本地后台服务。', - slow: '此次启动耗时较长。更新可能需要下载或构建安装包;你可以最小化此窗口,Maka 会继续处理。', - copy: '复制诊断信息', copied: '已复制诊断信息', copyFailed: '无法复制诊断信息', elapsed: '已用时', - phases: { - prepare: '正在准备 Maka', storage: '正在检查本地数据', connect: '正在连接 Runtime Host', - package: '正在准备 Runtime Host 安装包', checking: '正在检查托管服务', - staging: '正在安装更新', retiring: '正在安全停止旧服务', - replacing: '正在替换托管服务', restart: '正在重启 Runtime Host', - attention: '等待你的确认', renderer: '正在打开工作区', - }, - }, - 'zh-TW': { - title: '正在開啟工作區', detail: 'Maka 正在準備本機背景服務。', - slow: '此次啟動耗時較長。更新可能需要下載或建置安裝套件;你可以最小化此視窗,Maka 會繼續處理。', - copy: '複製診斷資訊', copied: '已複製診斷資訊', copyFailed: '無法複製診斷資訊', elapsed: '已用時', - phases: { - prepare: '正在準備 Maka', storage: '正在檢查本機資料', connect: '正在連線至 Runtime Host', - package: '正在準備 Runtime Host 安裝套件', checking: '正在檢查託管服務', - staging: '正在安裝更新', retiring: '正在安全停止舊服務', - replacing: '正在替換託管服務', restart: '正在重新啟動 Runtime Host', - attention: '等待你的確認', renderer: '正在開啟工作區', - }, - }, -} satisfies UiCatalog; - -export interface StartupProgressWindow { - update(phase: StartupPhase): void; - handoff(view: HostHandoffView, submit: (revision: string, action: HostHandoffAction) => void, locale: UiLocale): void; - clearHandoff(): void; - focus(): void; - close(): void; - window(): BrowserWindow | undefined; -} - -/** Presentation only: no Host client, application preload, or migration authority. */ -export function createStartupProgressWindow(input: { - locale: UiLocale; - dark: boolean; - icon: string; - /** How far this run may go when the window asks for attention. */ - revealMode: WindowRevealMode; - createWindow(options: BrowserWindowConstructorOptions): BrowserWindow; - copyDiagnostics(phase: StartupPhase, handoff?: HostHandoffView): void | Promise; - onError(error: unknown): void; -}): StartupProgressWindow { - const copy = COPY[input.locale]; - const win = input.createWindow({ - width: 520, height: 350, useContentSize: true, title: 'Maka', icon: input.icon, - show: false, resizable: false, maximizable: false, fullscreenable: false, - backgroundColor: input.dark ? '#1c1d21' : '#ffffff', - webPreferences: { - contextIsolation: true, nodeIntegration: false, sandbox: true, - webSecurity: true, allowRunningInsecureContent: false, - }, - }); - let closed = false; - let loaded = false; - let presentationRevision = 0; - let phase: StartupPhase = 'prepare'; - let handoff: { view: HostHandoffView; submit(revision: string, action: HostHandoffAction): void; locale: UiLocale } | undefined; - const execute = (source: string, accept?: (result: unknown) => void) => { - if (!closed && loaded && !win.isDestroyed()) { - void win.webContents.executeJavaScript(source).then(accept).catch(input.onError); - } - }; - const publish = () => { - if (closed || !loaded || win.isDestroyed()) return; - const revision = ++presentationRevision; - const width = handoff ? 560 : 520; - const [currentWidth, currentHeight] = win.getContentSize(); - if (currentWidth !== width) win.setContentSize(width, currentHeight); - const fitContent = (height: unknown) => { - if (closed || win.isDestroyed() || revision !== presentationRevision || - typeof height !== 'number' || !Number.isFinite(height)) return; - const fittedHeight = Math.max(240, Math.min(640, Math.ceil(height))); - if (win.getContentSize()[1] !== fittedHeight) win.setContentSize(width, fittedHeight); - }; - if (handoff) { - const presentation = formatHostHandoff(handoff.view, handoff.locale); - execute('window.renderHandoff(' + JSON.stringify({ ...presentation, revision: handoff.view.revision, - state: handoff.view.state, diagnostic: handoff.view.diagnostic }) + - '); document.body.getBoundingClientRect().height;', fitContent); - } else execute( - 'window.renderHandoff(null); document.getElementById("phase").textContent = ' + JSON.stringify(copy.phases[phase]) + - '; document.body.dataset.phase = ' + JSON.stringify(phase) + - '; document.body.getBoundingClientRect().height;', fitContent, - ); - }; - const close = () => { - if (closed) return; - closed = true; - if (!win.isDestroyed()) win.destroy(); - }; - win.setMenuBarVisibility(false); - win.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); - // Closing the status window minimizes it; it must not terminate an update. - win.on('close', (event) => { - if (closed) return; - event.preventDefault(); - win.minimize(); - }); - win.webContents.on('will-navigate', (event, url) => { - event.preventDefault(); - if (url.startsWith('maka-startup://handoff/')) { - const match = /^maka-startup:\/\/handoff\/([a-z0-9-]+)\/(cancel|retry|replace|interrupt)$/.exec(url); - if (match && handoff?.view.revision === match[1] && handoff.view.actions.includes(match[2] as HostHandoffAction)) { - handoff.submit(match[1], match[2] as HostHandoffAction); - } - return; - } - if (url !== 'maka-startup://copy') return; - void Promise.resolve().then(() => input.copyDiagnostics(phase, handoff?.view)).then( - () => execute('document.getElementById("copy").textContent = ' + JSON.stringify(copy.copied)), - (error) => { - input.onError(error); - execute('document.getElementById("copy").textContent = ' + JSON.stringify(copy.copyFailed)); - }, - ); - }); - win.webContents.on('will-redirect', (event) => event.preventDefault()); - win.webContents.on('render-process-gone', (_event, details) => { - input.onError(new Error('Startup renderer exited: ' + details.reason)); - handoff?.submit(handoff.view.revision, 'cancel'); - close(); - }); - // No await: failure to present progress must never block Host recovery. - void win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent( - renderStartupProgressHtml(input.locale, input.dark), - )).then(() => { - if (closed || win.isDestroyed()) return; - loaded = true; - publish(); - if (handoff?.view.state === 'attention') focusWindow(win, input.revealMode); - else showWindowInactive(win, input.revealMode); - }).catch((error) => { - input.onError(error); - handoff?.submit(handoff.view.revision, 'cancel'); - close(); - }); - return { - update(next) { phase = next; publish(); }, - handoff(view, submit, locale) { - if (closed || win.isDestroyed()) { submit(view.revision, 'cancel'); return; } - const needsAttention = handoff?.view.state !== 'attention' && view.state === 'attention'; - handoff = { view, submit, locale }; - publish(); - if (loaded && needsAttention) focusWindow(win, input.revealMode); - }, - clearHandoff() { - handoff = undefined; - publish(); - }, - focus() { - if (closed || !loaded || win.isDestroyed()) return; - focusWindow(win, input.revealMode); - }, - close, - window: () => closed || win.isDestroyed() ? undefined : win, - }; -} - -export function renderStartupProgressHtml(locale: UiLocale, dark: boolean): string { - const copy = COPY[locale]; - const nonce = randomUUID().replaceAll('-', ''); - return ` - - -Maka - -

${copy.title}

${copy.detail}

- -
${copy.phases.prepare}
-

${copy.slow}

-
-`; -} diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index cd7b5f4cf1..39ced1ee00 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -264,10 +264,16 @@ import type { BundledSkillCatalogEntry, ManagedSkillSourceEntry, ManagedSkillUpd import type { ConfigCategory } from '@maka/storage/config-transfer'; import type { OnboardingMilestone, OnboardingMilestoneId, OnboardingState } from '@maka/core/onboarding'; import type { + HostHandoffPresentation, + HostHandoffView, RemoteRuntimeHostProfile, RuntimeHostProfile, RuntimeHostProfileAccess, } from '@maka/runtime-host/client'; +export interface DesktopHostHandoffPayload { + readonly view: HostHandoffView; + readonly presentation: HostHandoffPresentation; +} export interface OnboardingSnapshot { state: OnboardingState; milestones: OnboardingMilestone[]; @@ -884,6 +890,14 @@ export interface MakaBridge { ): () => void; }; + runtimeHostHandoff: { + current(): Promise; + decide(revision: string, action: string): Promise; + subscribe( + handler: (payload: DesktopHostHandoffPayload | null) => void, + ): () => void; + }; + localRuntimeHostRemoteAccess: { getSnapshot(): Promise; enable(input: { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index ca739b3851..fbfd18242b 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -60,6 +60,7 @@ import type { WindowCommand, PetPackChangedEvent, WorkBoardChangedEvent, + DesktopHostHandoffPayload, DesktopRuntimeHostProfileAddInput, DesktopRuntimeHostProfileChangedEvent, DesktopRuntimeHostProfileSnapshot, @@ -1622,6 +1623,24 @@ const makaBridge = { return () => ipcRenderer.off('runtime-host-profiles:changed', listener); }, }, + runtimeHostHandoff: { + current() { + return ipcRenderer.invoke('runtime-host-handoff:current'); + }, + decide(revision: string, action: string) { + return ipcRenderer.invoke('runtime-host-handoff:decide', { revision, action }); + }, + subscribe(handler: (payload: DesktopHostHandoffPayload | null) => void) { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: DesktopHostHandoffPayload | null, + ) => { + handler(payload); + }; + ipcRenderer.on('runtime-host-handoff:view', listener); + return () => ipcRenderer.off('runtime-host-handoff:view', listener); + }, + }, localRuntimeHostRemoteAccess: { getSnapshot() { return ipcRenderer.invoke('local-runtime-host-remote-access:get-snapshot'); diff --git a/apps/desktop/src/renderer/app.tsx b/apps/desktop/src/renderer/app.tsx index 7ed3b66678..313085f22b 100644 --- a/apps/desktop/src/renderer/app.tsx +++ b/apps/desktop/src/renderer/app.tsx @@ -22,6 +22,8 @@ import { Theme } from '@astryxdesign/core/theme'; import { makaTheme } from './astryx-theme/maka'; import { AppShell } from './composition/legacy-desktop-region'; import { useAstryxThemeMode } from './astryx-theme-mode'; +import { RuntimeHostHandoffOverlay } from './runtime-host-handoff-overlay'; + export function App() { // PR-SHOW-AFTER-FIRST-COMMIT: the BrowserWindow is created hidden // (main-window.ts show: false) so the OS never flashes the index.html @@ -55,6 +57,7 @@ export function App() { + ); diff --git a/apps/desktop/src/renderer/locales/runtime-host-handoff-copy.ts b/apps/desktop/src/renderer/locales/runtime-host-handoff-copy.ts new file mode 100644 index 0000000000..4cc693931a --- /dev/null +++ b/apps/desktop/src/renderer/locales/runtime-host-handoff-copy.ts @@ -0,0 +1,40 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; + +interface RuntimeHostHandoffCopy { + readonly copyDiagnostics: string; +} + +const COPY_BY_LOCALE: UiCatalog = { + 'zh-CN': { + copyDiagnostics: '复制诊断信息', + }, + 'zh-TW': { + copyDiagnostics: '複製診斷資訊', + }, + en: { + copyDiagnostics: 'Copy diagnostics', + }, +}; + +export function getRuntimeHostHandoffCopy(locale: UiLocale): RuntimeHostHandoffCopy { + return COPY_BY_LOCALE[locale]; +} diff --git a/apps/desktop/src/renderer/runtime-host-handoff-overlay.tsx b/apps/desktop/src/renderer/runtime-host-handoff-overlay.tsx new file mode 100644 index 0000000000..97ed13776a --- /dev/null +++ b/apps/desktop/src/renderer/runtime-host-handoff-overlay.tsx @@ -0,0 +1,102 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useEffect, useState } from 'react'; +import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; +import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; +import { Button, Text, useUiLocale } from '@maka/ui'; +import type { DesktopHostHandoffPayload } from '../preload/bridge-contract.js'; +import { getRuntimeHostHandoffCopy } from './locales/runtime-host-handoff-copy.js'; + +/** + * The in-window face of a Runtime Host handoff. Background reconciliation is + * silent — only `attention` views (a decision the Host cannot make alone) + * render here. + */ +export function RuntimeHostHandoffOverlay() { + const locale = useUiLocale(); + const copy = getRuntimeHostHandoffCopy(locale); + const [payload, setPayload] = useState(null); + useEffect(() => { + const bridge = window.maka?.runtimeHostHandoff; + if (!bridge) return; + let mounted = true; + void bridge.current().then((current) => { + if (mounted) setPayload(current); + }); + const unsubscribe = bridge.subscribe(setPayload); + return () => { + mounted = false; + unsubscribe(); + }; + }, []); + + const view = payload?.view; + const presentation = payload?.presentation; + if (view?.state !== 'attention' || !presentation) return null; + const decide = (action: string) => { + void window.maka.runtimeHostHandoff.decide(view.revision, action); + }; + + return ( + {}} purpose="required" width={480}> + + )} + content={( + + {presentation.detail ? ( + + {presentation.detail} + + ) : null} + + ); +} diff --git a/packages/runtime-host/src/client/host-handoff-copy.ts b/packages/runtime-host/src/client/host-handoff-copy.ts index 16935caaf2..fa236e9f0c 100644 --- a/packages/runtime-host/src/client/host-handoff-copy.ts +++ b/packages/runtime-host/src/client/host-handoff-copy.ts @@ -250,16 +250,18 @@ const COPY = { }, } satisfies UiCatalog; -/** Shared consequence-oriented copy, not a second lifecycle policy. */ -export function formatHostHandoff( - view: HostHandoffView, - locale: UiLocale, -): { +export interface HostHandoffPresentation { title: string; description: string; detail: string; actions: readonly { action: HostHandoffAction; label: string }[]; -} { +} + +/** Shared consequence-oriented copy, not a second lifecycle policy. */ +export function formatHostHandoff( + view: HostHandoffView, + locale: UiLocale, +): HostHandoffPresentation { const copy = COPY[locale]; const labels: Record = { cancel: copy.labels.cancel, diff --git a/packages/runtime-host/src/client/index.ts b/packages/runtime-host/src/client/index.ts index 29939a03c3..52788a5303 100644 --- a/packages/runtime-host/src/client/index.ts +++ b/packages/runtime-host/src/client/index.ts @@ -18,7 +18,7 @@ */ export * from './host-handoff.js'; -export { formatHostHandoff } from './host-handoff-copy.js'; +export { formatHostHandoff, type HostHandoffPresentation } from './host-handoff-copy.js'; export { RuntimeHostManagedActivationError, activateRuntimeHostManagedDeployment, From a7942d4437bf8132b1a16cfce7144ec096a91235 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 18 Sep 2026 23:32:04 +0800 Subject: [PATCH 05/44] refactor(runtime-host): drop the pre-dev.9 file-lock compatibility half-layer Managed updates no longer interoperate with Host operators older than dev.9, so the lease machinery that supervised a legacy child's directory lock goes away: - withLegacyFileUpdateLockLease and its .supervised marker producer are deleted; withProcessLifetimeFileUpdateLock keeps recovering stale .supervised + .lock directory pairs left behind by already-shipped builds and still refuses to steal a live legacy directory lock. - withRuntimeHostManagedServiceLegacyOperatorLeases and the update command's lock-protocol probe, inheritedFds stdio plumbing, and RuntimeHostOperatorInvocation are deleted; retire now runs the current operator directly. The process-lifetime-lock-v1 capability stays in the operator echo so older updaters still detect current operators. Trade-off to call out in review: upgrading FROM a pre-dev.9 Host is no longer a supported path; the retire loses the crash-safety umbrella the inherited leases provided for that case. Generated-by: Devin --- .../runtime-host-handoff-surface.test.ts | 11 ++- .../runtime-host-service-manager.test.ts | 29 ------- .../cli/src/runtime-host-service-manager.ts | 24 +----- .../cli/src/runtime-host-update-command.ts | 78 +++++-------------- .../fixtures/file-update-lock-holder.ts | 16 ++-- .../src/process-lifetime-file-update-lock.ts | 47 ----------- 6 files changed, 35 insertions(+), 170 deletions(-) diff --git a/packages/cli/src/__tests__/runtime-host-handoff-surface.test.ts b/packages/cli/src/__tests__/runtime-host-handoff-surface.test.ts index 20bf773d4a..11852491ae 100644 --- a/packages/cli/src/__tests__/runtime-host-handoff-surface.test.ts +++ b/packages/cli/src/__tests__/runtime-host-handoff-surface.test.ts @@ -130,17 +130,20 @@ test('handoff keeps cancellation live during progress and disposes input without defaultAction: 'cancel', }; surface.update(view); - surface.update({ - ...view, + const progress: HostHandoffView = { revision: 'progress', state: 'progress', phase: 'retiring', + target: { name: 'Local', location: 'local' }, + mayExitNaturally: false, actions: ['cancel'], - }); + defaultAction: 'cancel', + }; + surface.update(progress); assert.deepEqual(actions, []); input.write('\n'); assert.deepEqual(actions, ['progress:cancel']); - surface.update({ ...view, revision: 'settling', state: 'progress', actions: [] }); + surface.update({ ...progress, revision: 'settling', actions: [] }); surface.close(); input.end(); output.end(); diff --git a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts index bfacede0a9..c45ce0bcfe 100644 --- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts +++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts @@ -42,7 +42,6 @@ import { RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV, RUNTIME_HOST_OPERATOR_PROCESS_LIFETIME_LOCK_CAPABILITY, RUNTIME_HOST_SERVICE_LOG_MAX_BYTES, - type RuntimeHostOperatorCapability, type RuntimeHostServiceManagementFrame, } from '@maka/runtime-host/operator'; import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority'; @@ -2555,8 +2554,6 @@ describe('managed Runtime Host service', () => { let observedState: 'running' | 'stopped' = 'running'; let observedCliPath: string | undefined; let readyFailure = false; - let operatorSupportsProcessLifetimeLock = false; - let legacyLeaseCalls = 0; let operatorStatusFailure = false; let operatorFailure: Extract | undefined; let replacementPreconditionFailure = false; @@ -2588,7 +2585,6 @@ describe('managed Runtime Host service', () => { activate: async () => { assert.equal(insideLifecycle, true); order.push('activate'); - operatorSupportsProcessLifetimeLock = true; }, cleanup: async () => { order.push('cleanup'); @@ -2624,13 +2620,6 @@ describe('managed Runtime Host service', () => { } }, withDeploymentLock: async (_root: string, operation: () => Promise) => operation(), - withLegacyOperatorLeases: async ( - _root: string, - operation: (fds: readonly number[]) => Promise, - ) => { - legacyLeaseCalls += 1; - return operation([]); - }, openDeployment: async ( input: Parameters[0], ) => deployment(input.version, input.cliPath), @@ -2648,10 +2637,6 @@ describe('managed Runtime Host service', () => { runOperator: async ( operator: import('@maka/runtime-host/operator').RuntimeHostOperatorCommand, args: readonly string[], - invocation?: { - readonly inheritedFds?: readonly number[]; - readonly capabilityRequest?: RuntimeHostOperatorCapability; - }, ) => { assert.deepEqual(operator, { kind: 'legacy_posix_executable', @@ -2661,10 +2646,6 @@ describe('managed Runtime Host service', () => { assert.ok(action === 'status' || action === 'retire'); if (action === 'status') { if (operatorStatusFailure) throw new Error('The active operator is unavailable'); - assert.equal( - invocation?.capabilityRequest, - RUNTIME_HOST_OPERATOR_PROCESS_LIFETIME_LOCK_CAPABILITY, - ); return { schemaVersion: 1 as const, kind: 'result' as const, @@ -2680,13 +2661,6 @@ describe('managed Runtime Host service', () => { stateRoot: expectedTarget.rootPath, projectDirectoryRoots: [], }, - ...(operatorSupportsProcessLifetimeLock - ? { - operatorCapabilities: [ - RUNTIME_HOST_OPERATOR_PROCESS_LIFETIME_LOCK_CAPABILITY, - ] as RuntimeHostOperatorCapability[], - } - : {}), }; } order.push(action); @@ -2751,7 +2725,6 @@ describe('managed Runtime Host service', () => { const exitCode = await runManagedRuntimeHostUpdateCli(options, overrides); assert.equal(exitCode, 0); assert.deepEqual(order, ['retire', 'activate', 'replace', 'cleanup']); - assert.equal(legacyLeaseCalls, 1); const frames = output .trim() .split('\n') @@ -2877,7 +2850,6 @@ describe('managed Runtime Host service', () => { readyFailure = true; assert.equal(await runManagedRuntimeHostUpdateCli(options, overrides), 0); assert.deepEqual(order, ['retire', 'activate', 'replace', 'cleanup']); - assert.equal(legacyLeaseCalls, 1); const activeRecovery = decodeRuntimeHostServiceManagementFrame( output.trim().split('\n').at(-1) ?? '', ); @@ -2925,7 +2897,6 @@ describe('managed Runtime Host service', () => { expectAllowInterruptActiveTasks = true; assert.equal(await runManagedRuntimeHostUpdateCli(options, overrides), 0); assert.deepEqual(order, ['retire', 'force-retire', 'activate', 'replace', 'cleanup']); - assert.equal(legacyLeaseCalls, 1); statusReads = 0; observedVersion = '1.0.0'; diff --git a/packages/cli/src/runtime-host-service-manager.ts b/packages/cli/src/runtime-host-service-manager.ts index 8241cefcf0..598febd393 100644 --- a/packages/cli/src/runtime-host-service-manager.ts +++ b/packages/cli/src/runtime-host-service-manager.ts @@ -53,10 +53,7 @@ import { type RuntimeHostServiceErrorCode, type RuntimeHostSupervisorProvider, } from '@maka/runtime-host/operator'; -import { - withLegacyFileUpdateLockLease, - withProcessLifetimeFileUpdateLock, -} from '@maka/storage/process-lifetime-file-update-lock'; +import { withProcessLifetimeFileUpdateLock } from '@maka/storage/process-lifetime-file-update-lock'; import { discoverMarkedStorageRoot, resolveExistingStorageRoot, @@ -381,25 +378,6 @@ export async function withRuntimeHostManagedServiceDeploymentLock( ); } -export async function withRuntimeHostManagedServiceLegacyOperatorLeases( - clientDataRoot: string, - operation: (inheritedFds: readonly number[]) => Promise, - timeoutMs = SERVICE_OPERATION_LOCK_TIMEOUT_MS, -): Promise { - const configPath = resolveRuntimeHostManagedServiceConfigPath(clientDataRoot); - await mkdir(dirname(configPath), { recursive: true, mode: 0o700 }); - return withLegacyFileUpdateLockLease( - join(clientDataRoot, SERVICE_LIFECYCLE_LOCK_FILE), - (lifecycleFd) => - withLegacyFileUpdateLockLease( - configPath, - (configFd) => operation([lifecycleFd, configFd]), - timeoutMs, - ), - timeoutMs, - ); -} - export async function replaceRuntimeHostManagedService( input: RuntimeHostManagedServiceReplacementInput, backend: RuntimeHostServiceBackend, diff --git a/packages/cli/src/runtime-host-update-command.ts b/packages/cli/src/runtime-host-update-command.ts index 534a3dea08..dde0c2edc6 100644 --- a/packages/cli/src/runtime-host-update-command.ts +++ b/packages/cli/src/runtime-host-update-command.ts @@ -30,9 +30,7 @@ import { RUNTIME_HOST_SERVICE_ERROR_MESSAGE_MAX_BYTES, RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV, - RUNTIME_HOST_OPERATOR_PROCESS_LIFETIME_LOCK_CAPABILITY, runtimeHostOperatorInvocation, - type RuntimeHostOperatorCapability, type RuntimeHostOperatorCommand, type RuntimeHostServiceManagementFrame, type RuntimeHostServiceUpdatePhase, @@ -60,7 +58,6 @@ import { RuntimeHostServiceManagerError, verifyRuntimeHostManagedServiceReady, withRuntimeHostManagedServiceDeploymentLock, - withRuntimeHostManagedServiceLegacyOperatorLeases, withRuntimeHostManagedServiceLifecycleLock, type RuntimeHostManagedServiceResult, type RuntimeHostManagedServiceTarget, @@ -137,13 +134,11 @@ interface RuntimeHostUpdateCliDeps { readonly retireSource: typeof launchRuntimeHostLocalSourceRetirement; readonly withLifecycleLock: typeof withRuntimeHostManagedServiceLifecycleLock; readonly withDeploymentLock: typeof withRuntimeHostManagedServiceDeploymentLock; - readonly withLegacyOperatorLeases: typeof withRuntimeHostManagedServiceLegacyOperatorLeases; readonly createBackend: (serviceId: string, clientDataRoot: string) => RuntimeHostServiceBackend; readonly verifyReady: typeof verifyRuntimeHostManagedServiceReady; readonly runOperator: ( operator: RuntimeHostOperatorCommand, args: readonly string[], - invocation?: RuntimeHostOperatorInvocation, ) => Promise; readonly canonical: { readonly createLifecycleDeps: (rootId: string) => RuntimeHostLifecycleTransactionDeps; @@ -194,11 +189,6 @@ function runtimeHostPackageUpdateOperation(input: { return input.replaceExpectedHost ? 'replace_current' : 'already_current'; } -interface RuntimeHostOperatorInvocation { - readonly inheritedFds?: readonly number[]; - readonly capabilityRequest?: RuntimeHostOperatorCapability; -} - interface RuntimeHostUpdateSelectionRejection { readonly code: string; readonly message: string; @@ -233,7 +223,6 @@ export async function runManagedRuntimeHostUpdateCli( }), withLifecycleLock: withRuntimeHostManagedServiceLifecycleLock, withDeploymentLock: withRuntimeHostManagedServiceDeploymentLock, - withLegacyOperatorLeases: withRuntimeHostManagedServiceLegacyOperatorLeases, createBackend: createPlatformRuntimeHostServiceBackend, verifyReady: verifyRuntimeHostManagedServiceReady, runOperator: runManagedRuntimeHostOperator, @@ -366,19 +355,25 @@ export async function runManagedRuntimeHostUpdateCli( const currentOperator = createRuntimeHostLegacyPosixOperatorCommand( join(serviceConfig.managedDeploymentRoot, 'operator'), ); - let currentOperatorUsesProcessLifetimeLock = false; let currentOperatorUnavailable = false; if (status.service.active) { try { - currentOperatorUsesProcessLifetimeLock = operatorUsesProcessLifetimeLock( - await deps.runOperator( - currentOperator, - ['status', '--framed', ...expectedTargetArgs(options.expectedTarget)], - { - capabilityRequest: RUNTIME_HOST_OPERATOR_PROCESS_LIFETIME_LOCK_CAPABILITY, - }, - ), - ); + const probe = await deps.runOperator(currentOperator, [ + 'status', + '--framed', + ...expectedTargetArgs(options.expectedTarget), + ]); + if (probe.kind === 'error') { + throw new RuntimeHostServiceManagerError( + 'service_manager_operation_failed', + `The current Runtime Host operator could not report its status: ${probe.error.message}`, + ); + } + if (probe.action !== 'status') { + throw new Error( + 'The current Runtime Host operator returned an invalid status result', + ); + } } catch (error) { if (!activeTargetNeedsRepair) throw error; currentOperatorUnavailable = true; @@ -423,14 +418,6 @@ export async function runManagedRuntimeHostUpdateCli( if (status.service.active) { emit(progress('retiring', currentVersion, options.version)); - const runCurrentOperator = (args: readonly string[]) => - currentOperatorUsesProcessLifetimeLock - ? deps.runOperator(currentOperator, args) - : deps.withLegacyOperatorLeases(options.clientDataRoot, (inheritedFds) => - deps.runOperator(currentOperator, args, { - inheritedFds, - }), - ); let retirement: RuntimeHostServiceManagementFrame = currentOperatorUnavailable ? { schemaVersion: 1, @@ -441,7 +428,7 @@ export async function runManagedRuntimeHostUpdateCli( message: 'The active Runtime Host operator is unavailable', }, } - : await runCurrentOperator([ + : await deps.runOperator(currentOperator, [ 'retire', '--framed', ...expectedTargetArgs(options.expectedTarget), @@ -1143,22 +1130,6 @@ function activeTasksRetirementFrame( }; } -function operatorUsesProcessLifetimeLock(frame: RuntimeHostServiceManagementFrame): boolean { - if (frame.kind === 'error') { - throw new RuntimeHostServiceManagerError( - 'service_manager_operation_failed', - `The current Runtime Host operator could not report its lock protocol: ${frame.error.message}`, - ); - } - if (frame.action !== 'status') { - throw new Error('The current Runtime Host operator returned an invalid capability result'); - } - return ( - frame.operatorCapabilities?.includes(RUNTIME_HOST_OPERATOR_PROCESS_LIFETIME_LOCK_CAPABILITY) === - true - ); -} - function operatorCapabilities(): { readonly operatorCapabilities?: (typeof RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY)[]; } { @@ -1173,22 +1144,15 @@ function operatorCapabilities(): { async function runManagedRuntimeHostOperator( operator: RuntimeHostOperatorCommand, args: readonly string[], - invocation: RuntimeHostOperatorInvocation = {}, ): Promise { return new Promise((resolve, reject) => { - const inheritedFds = invocation.inheritedFds ?? []; const command = runtimeHostOperatorInvocation(operator, args); const child = spawn(command.executable, [...command.args], { - // A detached legacy operator keeps the inherited advisory leases alive if - // this updater is interrupted, so an exact retry never steals active work. + // A detached operator can finish a retirement already in progress even + // if this updater exits, so an exact retry never steals active work. detached: process.platform !== 'win32', - env: invocation.capabilityRequest - ? { - ...process.env, - [RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV]: invocation.capabilityRequest, - } - : process.env, - stdio: ['ignore', 'pipe', 'pipe', ...inheritedFds], + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); if (!child.stdout || !child.stderr) { diff --git a/packages/storage/src/__tests__/fixtures/file-update-lock-holder.ts b/packages/storage/src/__tests__/fixtures/file-update-lock-holder.ts index 9ed64f356d..0f687a047c 100644 --- a/packages/storage/src/__tests__/fixtures/file-update-lock-holder.ts +++ b/packages/storage/src/__tests__/fixtures/file-update-lock-holder.ts @@ -18,11 +18,8 @@ */ import { spawn } from 'node:child_process'; -import { mkdir } from 'node:fs/promises'; -import { - withLegacyFileUpdateLockLease, - withProcessLifetimeFileUpdateLock, -} from '../../process-lifetime-file-update-lock.js'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { withProcessLifetimeFileUpdateLock } from '../../process-lifetime-file-update-lock.js'; const targetPath = process.argv[2]; if (!targetPath) throw new Error('Missing file update lock target'); @@ -33,11 +30,10 @@ const hold = async () => { }; if (process.argv[3] === 'legacy') { - await withLegacyFileUpdateLockLease(targetPath, async (inheritedFd) => { - if (inheritedFd <= 2) throw new Error('Legacy lock lease is not inheritable'); - await mkdir(`${targetPath}.lock`); - await hold(); - }); + // The on-disk shape a supervised legacy holder leaves behind when killed. + await writeFile(`${targetPath}.supervised`, ''); + await mkdir(`${targetPath}.lock`); + await hold(); } else if (process.argv[3] === 'inherit') { await withProcessLifetimeFileUpdateLock(targetPath, async (inheritedFd) => { const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 30_000)'], { diff --git a/packages/storage/src/process-lifetime-file-update-lock.ts b/packages/storage/src/process-lifetime-file-update-lock.ts index 8849833378..f91d7ab779 100644 --- a/packages/storage/src/process-lifetime-file-update-lock.ts +++ b/packages/storage/src/process-lifetime-file-update-lock.ts @@ -29,44 +29,6 @@ const LOCK_POLL_MS = 25; const LOCK_TIMEOUT_MS = 10_000; const lockGates = new Map>(); -export async function withLegacyFileUpdateLockLease( - targetPath: string, - operation: (inheritedFd: number) => Promise, - timeoutMs: number = LOCK_TIMEOUT_MS, -): Promise { - const lockPath = `${targetPath}.lock`; - const leasePath = `${targetPath}.lease`; - const supervisionPath = `${targetPath}.supervised`; - const deadline = Date.now() + timeoutMs; - return runWithLockGate(leasePath, deadline, async () => { - const lease = await openStableNativeLockFile(leasePath); - let leased = false; - let supervised = false; - let completed = false; - try { - while (!(leased = tryAcquireNativeFileLock(lease))) { - await waitForLockTurn(lockPath, deadline); - } - // The inherited advisory lease follows the legacy child process. A surviving - // supervision marker therefore proves that its directory lock is ownerless - // once a later process can acquire this lease. - await recoverSupervisedLegacyLock(lockPath, supervisionPath); - await createSupervisionMarker(supervisionPath); - supervised = true; - const result = await operation(lease.fd); - completed = true; - return result; - } finally { - try { - if (supervised && completed) await unlink(supervisionPath).catch(ignoreMissing); - } finally { - if (leased) releaseNativeFileLock(lease); - await lease.close(); - } - } - }); -} - /** * The callback may pass the lease fd as an extra child stdio descriptor. The * advisory lock then survives a parent crash until that exact child exits. @@ -102,15 +64,6 @@ export async function withProcessLifetimeFileUpdateLock( }); } -async function createSupervisionMarker(path: string): Promise { - const marker = await open( - path, - fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY | fsConstants.O_NOFOLLOW, - 0o600, - ); - await marker.close(); -} - async function recoverSupervisedLegacyLock( lockPath: string, supervisionPath: string, From ec4b04fa8cd3f6965da8d70029d97495aa98ac0b Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 18 Sep 2026 23:50:21 +0800 Subject: [PATCH 06/44] test(desktop): cover the Runtime Host handoff overlay in Storybook Exercise the in-window attention surface the same way the bridge drives it in production: replacement consent clicks through to decide(), a retry-exhausted view offers only cancel, and a progress view mounts nothing. Generated-by: Devin --- .../stories/runtime-host-handoff.stories.tsx | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 apps/desktop/stories/runtime-host-handoff.stories.tsx diff --git a/apps/desktop/stories/runtime-host-handoff.stories.tsx b/apps/desktop/stories/runtime-host-handoff.stories.tsx new file mode 100644 index 0000000000..d8426bceb1 --- /dev/null +++ b/apps/desktop/stories/runtime-host-handoff.stories.tsx @@ -0,0 +1,173 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, fn, userEvent, within } from 'storybook/test'; +import { AstryxLocaleProvider, LocaleProvider } from '@maka/ui'; +import type { HostHandoffAttentionView } from '@maka/runtime-host/client'; +import { RuntimeHostHandoffOverlay } from '../src/renderer/runtime-host-handoff-overlay'; +import { withScopedMakaBridge } from './maka-bridge'; +import type { DesktopHostHandoffPayload } from '../src/preload/bridge-contract.js'; + +const meta = { + title: 'Product/Runtime Host Handoff', + parameters: { layout: 'fullscreen' }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const decide = fn(async () => undefined); + +function withHandoffBridge(payload: DesktopHostHandoffPayload) { + return withScopedMakaBridge({ + runtimeHostHandoff: { + current: async () => payload, + subscribe: () => () => {}, + decide, + }, + }); +} + +const replacementView: HostHandoffAttentionView = { + revision: 'handoff-replacement', + state: 'attention', + reason: 'replacement_required', + target: { name: 'Local', location: 'local' }, + mayExitNaturally: false, + packageChange: { current: '0.1.0-dev.38', target: '0.1.0-dev.40' }, + actions: ['cancel', 'replace'], + defaultAction: 'cancel', +}; + +// Mirrors the en formatHostHandoff output for this view — the payload the +// main process sends over the runtimeHostHandoff bridge. +const replacementPayload: DesktopHostHandoffPayload = { + view: replacementView, + presentation: { + title: 'Switch the WSL background service', + description: + 'No update is running. Continue to retire the old service through its installed operator and start the selected version. Interrupting active work requires a separate confirmation.', + detail: + 'Local: 0.1.0-dev.38 → 0.1.0-dev.40\nMaka keeps checking. Waiting or retrying will not resolve this unless the service state changes.', + actions: [ + { action: 'cancel', label: 'Cancel' }, + { action: 'replace', label: 'Stop old service and continue' }, + ], + }, +}; + +// Real path: launch while a managed Runtime Host update needs package-change +// consent — the main window mounts the overlay above the shell. +export const ReplacementConsent: Story = { + decorators: [withHandoffBridge(replacementPayload)], + render: () => ( + + + + + + ), + play: async ({ canvasElement }) => { + decide.mockClear(); + const body = within(canvasElement.ownerDocument.body); + const dialog = await body.findByRole('alertdialog'); + await expect(within(dialog).getByText(/0\.1\.0-dev\.38/u)).toBeTruthy(); + await userEvent.click( + within(dialog).getByRole('button', { name: 'Stop old service and continue' }), + ); + await expect(decide).toHaveBeenCalledWith('handoff-replacement', 'replace'); + }, +}; + +const retryPayload: DesktopHostHandoffPayload = { + view: { + revision: 'handoff-retry', + state: 'attention', + reason: 'retry_required', + target: { name: 'Local', location: 'local' }, + mayExitNaturally: false, + actions: ['cancel'], + defaultAction: 'cancel', + }, + presentation: { + title: 'The handoff could not finish yet', + description: + 'The service changed or the handoff has not finished. A safe retry will not interrupt work by default.', + detail: + 'Maka keeps checking. Waiting or retrying will not resolve this unless the service state changes.', + actions: [{ action: 'cancel', label: 'Cancel' }], + }, +}; + +// Real path: a Local Host update failed after retries were exhausted — the +// only offered action is the one the surface advertised (#5476's stuck view). +export const RetryExhausted: Story = { + decorators: [withHandoffBridge(retryPayload)], + render: () => ( + + + + + + ), + play: async ({ canvasElement }) => { + decide.mockClear(); + const body = within(canvasElement.ownerDocument.body); + const dialog = await body.findByRole('alertdialog'); + await userEvent.click(within(dialog).getByRole('button', { name: 'Cancel' })); + await expect(decide).toHaveBeenCalledWith('handoff-retry', 'cancel'); + }, +}; + +// Real path: the same launch while reconciliation is still in progress — +// progress views stay silent and nothing mounts. +export const ProgressStaysSilent: Story = { + decorators: [ + withHandoffBridge({ + view: { + revision: 'handoff-progress', + state: 'progress', + phase: 'staging', + target: { name: 'Local', location: 'local' }, + mayExitNaturally: false, + actions: ['cancel'], + defaultAction: 'cancel', + }, + presentation: { + title: 'Continuing to your workspace', + description: 'Preparing the update', + detail: 'Finishing the handoff or its safe recovery. Please wait.', + actions: [{ action: 'cancel', label: 'Cancel' }], + }, + }), + ], + render: () => ( + + + + + + ), + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + await new Promise((resolve) => setTimeout(resolve, 50)); + await expect(body.queryByRole('alertdialog')).toBeNull(); + }, +}; From 4bee39ca208828492546f0a5e998d862db2b8297 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 18 Sep 2026 23:59:09 +0800 Subject: [PATCH 07/44] fix(desktop): put the handoff copy-diagnostics action on its own line Text defaults to inline display, which pulled the ghost button onto the last line of the detail paragraph. Generated-by: Devin --- apps/desktop/src/renderer/runtime-host-handoff-overlay.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/runtime-host-handoff-overlay.tsx b/apps/desktop/src/renderer/runtime-host-handoff-overlay.tsx index 97ed13776a..f6f45994c7 100644 --- a/apps/desktop/src/renderer/runtime-host-handoff-overlay.tsx +++ b/apps/desktop/src/renderer/runtime-host-handoff-overlay.tsx @@ -66,7 +66,7 @@ export function RuntimeHostHandoffOverlay() { content={( {presentation.detail ? ( - + {presentation.detail} ) : null} From b5f453a5658316b5f916c8feec966c4fb0f9919d Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 19 Sep 2026 00:42:34 +0800 Subject: [PATCH 08/44] fix(desktop): keep background startup alive when the Work Board schema is unverified MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A degraded Local start resolves instead of rejecting, so the continuation still reaches registerWorkBoardIpc — where require_current throws without a Host-verified schema. The throw aborted the rest of the chain, skipping guest-session restore, interrupted-setup recovery, and remote profile startup. Registration is now isolated and retried when a Local target reaches ready, so a recovered Host completes the board setup too. Generated-by: Devin --- .../__tests__/main-startup-lifetime.test.ts | 7 +- apps/desktop/src/main/runtime-host-boot.ts | 72 +++++++++++-------- 2 files changed, 49 insertions(+), 30 deletions(-) diff --git a/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts b/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts index 64132ea0f5..1b3a6f6005 100644 --- a/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts +++ b/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts @@ -109,6 +109,10 @@ test('lets the Runtime Host migrate its State Root before Desktop opens shared t 'await runtimeHostManager?.start()', ); const workBoardOpen = bootSource.indexOf( + 'registerDesktopWorkBoard();', + hostStart, + ); + const workBoardStore = bootSource.indexOf( 'store: createWorkBoardStore(workspaceRoot', ); const sessionCopyOpen = bootSource.indexOf( @@ -119,8 +123,9 @@ test('lets the Runtime Host migrate its State Root before Desktop opens shared t assert.notEqual(workBoardOpen, -1); assert.notEqual(sessionCopyOpen, -1); assert.ok(hostStart < workBoardOpen); + assert.notEqual(workBoardStore, -1); assert.match( - bootSource.slice(workBoardOpen, bootSource.indexOf('});', workBoardOpen)), + bootSource.slice(workBoardStore, bootSource.indexOf('});', workBoardStore)), /schemaMigration: 'require_current'/u, ); assert.match( diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 7ea87919dc..f326e16a0c 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -1342,6 +1342,9 @@ const createLocalRuntimeHostManager = () => createRuntimeHostDesktopManager( }); } if (state.readiness === "ready") { + if (state.target.profile.id === LOCAL_RUNTIME_HOST_PROFILE.id) { + registerDesktopWorkBoard(); + } const scope = { hostId: state.candidate.client.hostId, targetEpoch: state.epoch }; mainWindowController.send("projects:changed", scope); emitConnectionListChanged(scope); @@ -1437,37 +1440,48 @@ runtimeHostManager.setDefaultProfile(runtimeHostStartup.preferences.defaultProfi wireLifecycle(); sessionLocal.wake(); windowsAppTray.start(); -void (async () => { - await runtimeHostManager?.start(); - // Runtime Host is the only schema-migration authority for its State Root. - // Work Board remains a Desktop-owned table, but it opens only after the Host is - // ready and verifies the schema instead of changing it behind a resident Host. - workBoardIpc = registerWorkBoardIpc({ - ipcMain, - workspaceRoot, - mainWindowController, - store: createWorkBoardStore(workspaceRoot, { schemaMigration: 'require_current' }), - validateLinkedSession: async (value, expectedProjectId) => { - const normalized = normalizeWorkBoardLinkedSession(value); - if (!normalized.ok) return false; - try { - const current = runtimeHostManager?.current(normalized.value.profileId); - if (!current?.candidate || current.hostId !== normalized.value.hostId) return false; - const sessions = await current.candidate.client.listSessions(); - const session = sessions.find((candidate) => candidate.id === normalized.value.sessionId); - if (!session) return false; - if (expectedProjectId !== undefined) { - return ( - session.workspace.target.kind === 'project' && - session.workspace.target.projectId === expectedProjectId +// Runtime Host is the only schema-migration authority for its State Root. +// Work Board remains a Desktop-owned table, but it opens only while a ready +// Host has verified the schema — including a Local Host that only becomes +// ready after a retry. +const registerDesktopWorkBoard = (): void => { + if (workBoardIpc) return; + try { + workBoardIpc = registerWorkBoardIpc({ + ipcMain, + workspaceRoot, + mainWindowController, + store: createWorkBoardStore(workspaceRoot, { schemaMigration: 'require_current' }), + validateLinkedSession: async (value, expectedProjectId) => { + const normalized = normalizeWorkBoardLinkedSession(value); + if (!normalized.ok) return false; + try { + const current = runtimeHostManager?.current(normalized.value.profileId); + if (!current?.candidate || current.hostId !== normalized.value.hostId) return false; + const sessions = await current.candidate.client.listSessions(); + const session = sessions.find( + (candidate) => candidate.id === normalized.value.sessionId, ); + if (!session) return false; + if (expectedProjectId !== undefined) { + return ( + session.workspace.target.kind === 'project' && + session.workspace.target.projectId === expectedProjectId + ); + } + return true; + } catch { + return false; } - return true; - } catch { - return false; - } - }, - }); + }, + }); + } catch (error) { + console.error('[work-board] IPC registration failed:', error); + } +}; +void (async () => { + await runtimeHostManager?.start(); + registerDesktopWorkBoard(); await guestSessionMountService.start().catch((error: unknown) => { console.error('[runtime-host] shared Sessions could not be restored:', error); }); From a17af41556b6e5f6161f1ab1e7b295bfcb9fcd2a Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 19 Sep 2026 00:42:34 +0800 Subject: [PATCH 09/44] fix(desktop): deliver the newest handoff payload when mounting the overlay The snapshot fetch and the push subscription were in flight together; a publish landing between them was overwritten by the older current() response. Subscribe first and let the push win. Generated-by: Devin --- .../src/renderer/runtime-host-handoff-overlay.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/runtime-host-handoff-overlay.tsx b/apps/desktop/src/renderer/runtime-host-handoff-overlay.tsx index f6f45994c7..3a9087e1e0 100644 --- a/apps/desktop/src/renderer/runtime-host-handoff-overlay.tsx +++ b/apps/desktop/src/renderer/runtime-host-handoff-overlay.tsx @@ -37,10 +37,16 @@ export function RuntimeHostHandoffOverlay() { const bridge = window.maka?.runtimeHostHandoff; if (!bridge) return; let mounted = true; + // Subscribe before fetching the snapshot: a push wins over the older + // current() response whenever both are in flight. + let pushed = false; + const unsubscribe = bridge.subscribe((next) => { + pushed = true; + if (mounted) setPayload(next); + }); void bridge.current().then((current) => { - if (mounted) setPayload(current); + if (mounted && !pushed) setPayload(current); }); - const unsubscribe = bridge.subscribe(setPayload); return () => { mounted = false; unsubscribe(); From 7f57bb95f1f79f223f413113f2226e0e8f5e7856 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 19 Sep 2026 00:42:39 +0800 Subject: [PATCH 10/44] refactor(desktop): source the handoff payload type from the bridge contract The surface re-declared the wire shape it sends; the bridge contract is already the single declaration both sides import. Generated-by: Devin --- apps/desktop/src/main/runtime-host-handoff-surface.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/apps/desktop/src/main/runtime-host-handoff-surface.ts b/apps/desktop/src/main/runtime-host-handoff-surface.ts index ecc64a97d7..8b06c29d09 100644 --- a/apps/desktop/src/main/runtime-host-handoff-surface.ts +++ b/apps/desktop/src/main/runtime-host-handoff-surface.ts @@ -22,15 +22,10 @@ import type { UiLocale } from '@maka/core/ui-locale'; import { formatHostHandoff, type HostHandoffAction, - type HostHandoffPresentation, type HostHandoffView, type OpenHostHandoffSurface, } from '@maka/runtime-host/client'; - -export interface DesktopHostHandoffPayload { - readonly view: HostHandoffView; - readonly presentation: HostHandoffPresentation; -} +import type { DesktopHostHandoffPayload } from '../preload/bridge-contract.js'; interface OpenDesktopHandoff { readonly submit: (revision: string, action: HostHandoffAction) => void; From 38497675c40398e72aa39f43be26a491f670e66e Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 19 Sep 2026 00:54:40 +0800 Subject: [PATCH 11/44] fix(desktop): mount the handoff overlay inside the locale providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useUiLocale() throws without LocaleProvider, which lives inside LegacyAppShell — mounting the overlay at Theme level crashed the renderer root on first paint and the window never reported ready. Mounting it in the provider subtree also scopes the dialog to the main surface instead of the floating WorkHub window. Catches added for the snapshot fetch and the clipboard write, which reject when the document is unfocused. Generated-by: Devin --- apps/desktop/src/renderer/app-shell.tsx | 2 ++ apps/desktop/src/renderer/app.tsx | 2 -- .../src/renderer/runtime-host-handoff-overlay.tsx | 13 +++++++++---- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 931a064ca3..0614b69965 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -18,6 +18,7 @@ */ import { WorkHubControlOverlay, WorkHubDock, WorkHubMainNavigation, WorkHubReturnButton } from './features/workhub'; +import { RuntimeHostHandoffOverlay } from './runtime-host-handoff-overlay'; import { useCallback, useEffect, @@ -239,6 +240,7 @@ export function AppShell() { + {(taskEntry) => ( diff --git a/apps/desktop/src/renderer/app.tsx b/apps/desktop/src/renderer/app.tsx index 313085f22b..98aa6cc658 100644 --- a/apps/desktop/src/renderer/app.tsx +++ b/apps/desktop/src/renderer/app.tsx @@ -22,7 +22,6 @@ import { Theme } from '@astryxdesign/core/theme'; import { makaTheme } from './astryx-theme/maka'; import { AppShell } from './composition/legacy-desktop-region'; import { useAstryxThemeMode } from './astryx-theme-mode'; -import { RuntimeHostHandoffOverlay } from './runtime-host-handoff-overlay'; export function App() { // PR-SHOW-AFTER-FIRST-COMMIT: the BrowserWindow is created hidden @@ -57,7 +56,6 @@ export function App() { - ); diff --git a/apps/desktop/src/renderer/runtime-host-handoff-overlay.tsx b/apps/desktop/src/renderer/runtime-host-handoff-overlay.tsx index 3a9087e1e0..52d903b38e 100644 --- a/apps/desktop/src/renderer/runtime-host-handoff-overlay.tsx +++ b/apps/desktop/src/renderer/runtime-host-handoff-overlay.tsx @@ -44,9 +44,12 @@ export function RuntimeHostHandoffOverlay() { pushed = true; if (mounted) setPayload(next); }); - void bridge.current().then((current) => { - if (mounted && !pushed) setPayload(current); - }); + void bridge + .current() + .then((current) => { + if (mounted && !pushed) setPayload(current); + }) + .catch(() => {}); return () => { mounted = false; unsubscribe(); @@ -80,7 +83,9 @@ export function RuntimeHostHandoffOverlay() { variant="ghost" label={copy.copyDiagnostics} onClick={() => - void navigator.clipboard.writeText(JSON.stringify(view, null, 2))} + void navigator.clipboard + .writeText(JSON.stringify(view, null, 2)) + .catch(() => {})} /> )} From f23a7bc155dad9c4e3689b41b64888765b7bff73 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 19 Sep 2026 00:54:45 +0800 Subject: [PATCH 12/44] fix(desktop): keep a pending handoff decision visible behind silent updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every update claimed the visible slot, so a concurrent handoff's progress view could displace a pending attention decision — indefinitely for manualRecheck views that never republish. Only attention views own the slot now, recency-ordered, and the presentation locale resolves per publish instead of once at boot. Unit coverage for the arbitration and the decide fencing, plus a source guard pinning the overlay mount inside the locale providers. Generated-by: Devin --- .../__tests__/main-startup-lifetime.test.ts | 17 +++ .../runtime-host-handoff-surface.test.ts | 109 ++++++++++++++++++ .../src/main/runtime-host-handoff-surface.ts | 34 ++++-- 3 files changed, 148 insertions(+), 12 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/runtime-host-handoff-surface.test.ts diff --git a/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts b/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts index 1b3a6f6005..a340d63273 100644 --- a/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts +++ b/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts @@ -38,6 +38,14 @@ const mainWindowSource = readFileSync( fileURLToPath(new URL('../../../src/main/main-window.ts', import.meta.url)), 'utf8', ); +const appShellSource = readFileSync( + fileURLToPath(new URL('../../../src/renderer/app-shell.tsx', import.meta.url)), + 'utf8', +); +const appSource = readFileSync( + fileURLToPath(new URL('../../../src/renderer/app.tsx', import.meta.url)), + 'utf8', +); test('retains process lifetime before a standalone startup dialog can close', () => { const retentionPolicy = mainSource.search( @@ -77,6 +85,15 @@ test('registers one shared quit cleanup before the initial Host handoff', () => assert.match(bootSource, /workBoardIpc\?\.close\(\)/u); }); +test('mounts the handoff overlay inside the locale providers', () => { + const provider = appShellSource.indexOf('= 0 && overlay > provider); + // Above the providers it crashes the root: useUiLocale() throws without + // the context, and the window never reports renderer-ready. + assert.doesNotMatch(appSource, /RuntimeHostHandoffOverlay/u); +}); + test('creates the main window before starting Local Host reconciliation', () => { const managerCreate = bootSource.indexOf('runtimeHostManager = createLocalRuntimeHostManager()'); const lifecycleWire = bootSource.indexOf('wireLifecycle();', managerCreate); diff --git a/apps/desktop/src/main/__tests__/runtime-host-handoff-surface.test.ts b/apps/desktop/src/main/__tests__/runtime-host-handoff-surface.test.ts new file mode 100644 index 0000000000..573b3ff5dc --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-handoff-surface.test.ts @@ -0,0 +1,109 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { HostHandoffAction, HostHandoffView } from '@maka/runtime-host/client'; +import { createDesktopHostHandoffSurface } from '../runtime-host-handoff-surface.js'; +import type { DesktopHostHandoffPayload } from '../../preload/bridge-contract.js'; + +const attentionView = (revision: string, actions: HostHandoffAction[] = ['cancel']): HostHandoffView => ({ + revision, + state: 'attention', + reason: 'retry_required', + target: { name: 'Local', location: 'local' }, + mayExitNaturally: false, + actions, + defaultAction: 'cancel', +}); + +const progressView = (revision: string): HostHandoffView => ({ + revision, + state: 'progress', + phase: 'staging', + target: { name: 'Local', location: 'local' }, + mayExitNaturally: false, + actions: ['cancel'], + defaultAction: 'cancel', +}); + +function harness() { + const sent: Array = []; + const handlers = new Map unknown>(); + const surface = createDesktopHostHandoffSurface({ + ipcMain: { + handle(channel: string, listener: (...args: unknown[]) => unknown) { + handlers.set(channel, listener); + }, + } as never, + send: (payload) => sent.push(payload), + resolveLocale: async () => 'en', + }); + const last = () => sent.at(-1); + return { surface, sent, handlers, last }; +} + +test('publishes only the newest attention view; progress cannot steal the slot', async () => { + const { surface, last } = harness(); + const first = surface(() => {}); + const second = surface(() => {}); + + first.update(attentionView('r1')); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(last()?.view.revision, 'r1'); + + // A concurrent handoff's progress update must not displace the decision. + second.update(progressView('r2')); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(last()?.view.revision, 'r1'); + + // An attention update from the second handoff claims the slot. + second.update(attentionView('r2')); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(last()?.view.revision, 'r2'); + + // The active handoff transitioning to progress falls back to the other + // pending decision instead of going silent. + second.update(progressView('r2')); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(last()?.view.revision, 'r1'); + + first.close(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(last(), null); +}); + +test('decide routes only a live attention revision and advertised action', async () => { + const { surface, handlers } = harness(); + const decisions: Array<[string, HostHandoffAction]> = []; + const handoff = surface((revision, action) => decisions.push([revision, action])); + handoff.update(attentionView('live', ['cancel', 'retry'])); + const decide = handlers.get('runtime-host-handoff:decide'); + assert.ok(decide); + + decide({}, { revision: 'live', action: 'retry' }); + assert.deepEqual(decisions, [['live', 'retry']]); + + // A stale revision, a non-advertised action, and a progress view all miss. + decide({}, { revision: 'stale', action: 'cancel' }); + decide({}, { revision: 'live', action: 'replace' }); + handoff.update(progressView('live')); + decide({}, { revision: 'live', action: 'cancel' }); + assert.deepEqual(decisions, [['live', 'retry']]); +}); diff --git a/apps/desktop/src/main/runtime-host-handoff-surface.ts b/apps/desktop/src/main/runtime-host-handoff-surface.ts index 8b06c29d09..5154a613ce 100644 --- a/apps/desktop/src/main/runtime-host-handoff-surface.ts +++ b/apps/desktop/src/main/runtime-host-handoff-surface.ts @@ -36,8 +36,9 @@ interface OpenDesktopHandoff { * Host handoffs render inside the main window: background progress stays * silent and only attention views reach the renderer, which decides through * `runtime-host-handoff:decide`. Concurrent handoffs (e.g. Local plus an - * enabled remote) each keep their own submit; the most recently updated one - * owns the visible slot. + * enabled remote) each keep their own submit; the most recently updated + * attention view owns the visible slot — a silent progress update must never + * displace a pending decision. */ export function createDesktopHostHandoffSurface(input: { ipcMain: IpcMain; @@ -47,10 +48,6 @@ export function createDesktopHostHandoffSurface(input: { const open = new Map(); let sequence = 0; let activeId: number | undefined; - const locale = input.resolveLocale().then( - (resolved) => resolved, - () => 'en' as UiLocale, - ); const currentEntry = (): OpenDesktopHandoff | undefined => activeId === undefined ? undefined : open.get(activeId); @@ -58,11 +55,23 @@ export function createDesktopHostHandoffSurface(input: { entry: OpenDesktopHandoff | undefined, ): Promise => entry - ? { view: entry.view, presentation: formatHostHandoff(entry.view, await locale) } + ? { + view: entry.view, + presentation: formatHostHandoff( + entry.view, + await input.resolveLocale().catch(() => 'en' as UiLocale), + ), + } : null; const publish = (): void => { void payloadFor(currentEntry()).then((payload) => input.send(payload)); }; + const refreshActive = (): void => { + activeId = undefined; + for (const [id, entry] of open) { + if (entry.view.state === 'attention') activeId = id; + } + }; input.ipcMain.handle('runtime-host-handoff:current', () => payloadFor(currentEntry()), @@ -89,16 +98,17 @@ export function createDesktopHostHandoffSurface(input: { const id = sequence++; return { update(view) { + // Reinsert so iteration order tracks recency: the newest attention + // view owns the slot and a silent update cannot displace it. + open.delete(id); open.set(id, { submit, view }); - activeId = id; + refreshActive(); publish(); }, close() { open.delete(id); - if (activeId === id) { - activeId = [...open.keys()].pop(); - publish(); - } + refreshActive(); + publish(); }, }; }; From fdd12698a4411973487e293d9df6ec3ea0320270 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 19 Sep 2026 01:17:16 +0800 Subject: [PATCH 13/44] fix(desktop): offer the default-Host recovery prompt for a failed Local start A permanently failed Local Host left the app alive but stranded: the recovery offer skipped the Local profile outright, so retryLocalStart() was unreachable even though the whole retry machinery behind it was already wired. Local failures now enter the same default-Host recovery loop, with a two-button prompt (Retry / Keep Offline) since "Use Local" is meaningless when Local itself is the one that failed. Generated-by: Devin --- .../native-diagnostic-dialog.test.ts | 1 + .../runtime-host-default-recovery.test.ts | 29 ++++++++++++++++ .../runtime-host-desktop-manager.test.ts | 34 +++++++++++++++++++ .../src/main/native-diagnostic-dialog.ts | 10 ++++-- apps/desktop/src/main/runtime-host-boot.ts | 4 +++ .../src/main/runtime-host-default-recovery.ts | 8 +---- 6 files changed, 77 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts b/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts index c105e0f27d..4c8bfb2b1d 100644 --- a/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts +++ b/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts @@ -86,6 +86,7 @@ test('keeps Default Runtime Host errors in diagnostics instead of dialog copy', const error = new Error('Authorization: Bearer very-secret-token'); const recovery = defaultRuntimeHostRecoveryDialog({ locale: 'en', + profileId: 'shared', profileName: 'Shared Host', error, }); diff --git a/apps/desktop/src/main/__tests__/runtime-host-default-recovery.test.ts b/apps/desktop/src/main/__tests__/runtime-host-default-recovery.test.ts index 370686c9cc..851b1fea6d 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-default-recovery.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-default-recovery.test.ts @@ -19,6 +19,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { LOCAL_RUNTIME_HOST_PROFILE } from '@maka/runtime-host/client'; import { createRuntimeHostDefaultRecovery } from '../runtime-host-default-recovery.js'; test('recovers an unavailable default Host without blocking other Hosts', async () => { @@ -62,3 +63,31 @@ test('recovers an unavailable default Host without blocking other Hosts', async assert.equal(retries, 1); assert.deepEqual(prompts, ['offline', 'still offline']); }); + +test('prompts to retry a failed Local default instead of leaving the app degraded', async () => { + let retries = 0; + let resolveRecovered!: () => void; + const recovered = new Promise((resolve) => { + resolveRecovered = resolve; + }); + const recovery = createRuntimeHostDefaultRecovery({ + defaultProfileId: () => LOCAL_RUNTIME_HOST_PROFILE.id, + prompt: async () => 'retry', + retry: async () => { + retries += 1; + resolveRecovered(); + return undefined; + }, + useLocal: async () => assert.fail('use_local is unreachable for the Local profile'), + onError: (error) => assert.fail(error instanceof Error ? error : String(error)), + }); + + recovery.offer({ + profileId: LOCAL_RUNTIME_HOST_PROFILE.id, + profileName: LOCAL_RUNTIME_HOST_PROFILE.name, + error: new Error('Local Host failed'), + }); + await recovered; + + assert.equal(retries, 1); +}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts index bc061bbd15..46bad3fe65 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts @@ -1749,6 +1749,40 @@ test('cancelling a live handoff does not authorize any replacement', async () => await owner.close(); }); +test('recovers a degraded Local start through a fresh target generation', async () => { + const recovered = candidateHarness(); + let starts = 0; + const readiness: string[] = []; + const epochs = new Set(); + const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { + startCandidate: async () => { + starts += 1; + if (starts === 1) throw new Error('connect failed'); + return ready(recovered.candidate); + }, + onFatalError: () => undefined, + onTargetStateChanged: (state) => { + readiness.push(state.readiness); + epochs.add(state.epoch); + }, + }); + const failed = owner.entries().at(-1); + assert.equal(starts, 1); + assert.equal(failed?.readiness, 'unavailable'); + if (failed?.readiness === 'unavailable') { + assert.equal(failed.error.message, 'connect failed'); + } + + await owner.retryLocalStart(); + + assert.equal(starts, 2); + assert.equal(owner.current()?.readiness, 'ready'); + assert.equal(owner.current()?.hostId, 'test-host'); + assert.equal(epochs.size, 2, 'the retry runs on a fresh epoch'); + assert.deepEqual(readiness, ['connecting', 'unavailable', 'connecting', 'ready']); + await owner.close(); +}); + test('keeps a known repair actionable when its first authority inspection fails', async () => { const repaired = candidateHarness({ ownership: 'supervised' }); let inspected = false; diff --git a/apps/desktop/src/main/native-diagnostic-dialog.ts b/apps/desktop/src/main/native-diagnostic-dialog.ts index 7106a45240..04cfb944a3 100644 --- a/apps/desktop/src/main/native-diagnostic-dialog.ts +++ b/apps/desktop/src/main/native-diagnostic-dialog.ts @@ -18,6 +18,7 @@ */ import type { UiLocale } from '@maka/core/ui-locale'; +import { LOCAL_RUNTIME_HOST_PROFILE } from '@maka/runtime-host/client'; import type { MessageBoxOptions, MessageBoxReturnValue, @@ -47,19 +48,24 @@ interface FatalStartupDiagnosticDialogDeps { export function defaultRuntimeHostRecoveryDialog(input: { readonly locale: UiLocale; + readonly profileId: string; readonly profileName: string; readonly error: Error; }): { readonly options: MessageBoxOptions; readonly diagnosticDetails: string } { const copy = getNativeDiagnosticDialogCopy(input.locale).defaultRuntimeHostRecovery; + const isLocal = input.profileId === LOCAL_RUNTIME_HOST_PROFILE.id; return { options: { type: 'warning', title: copy.title, message: copy.connectFailed(input.profileName), detail: copy.detail, - buttons: [copy.retry, copy.useLocal, copy.keepOffline], + // "Use Local" is meaningless when Local itself is the one that failed. + buttons: isLocal + ? [copy.retry, copy.keepOffline] + : [copy.retry, copy.useLocal, copy.keepOffline], defaultId: 0, - cancelId: 2, + cancelId: isLocal ? 1 : 2, noLink: true, }, diagnosticDetails: input.error.stack ?? `${input.error.name}: ${input.error.message}`, diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index f326e16a0c..bce9e4d777 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -2288,6 +2288,7 @@ async function confirmDesktopStorageRootRepair( } async function promptForDefaultRuntimeHostRecovery(input: { + readonly profileId: string; readonly profileName: string; readonly error: Error; }): Promise<"retry" | "use_local" | "keep_offline"> { @@ -2298,5 +2299,8 @@ async function promptForDefaultRuntimeHostRecovery(input: { locale, dialogInput.diagnosticDetails, ); + if (input.profileId === LOCAL_RUNTIME_HOST_PROFILE.id) { + return response === 0 ? "retry" : "keep_offline"; + } return response === 0 ? "retry" : response === 1 ? "use_local" : "keep_offline"; } diff --git a/apps/desktop/src/main/runtime-host-default-recovery.ts b/apps/desktop/src/main/runtime-host-default-recovery.ts index f2df72246f..6cfa7a46e5 100644 --- a/apps/desktop/src/main/runtime-host-default-recovery.ts +++ b/apps/desktop/src/main/runtime-host-default-recovery.ts @@ -17,8 +17,6 @@ * under the License. */ -import { LOCAL_RUNTIME_HOST_PROFILE } from '@maka/runtime-host/client'; - export type RuntimeHostDefaultRecoveryDecision = | 'retry' | 'use_local' @@ -62,11 +60,7 @@ export function createRuntimeHostDefaultRecovery(input: { return { offer(failure) { - if ( - failure.profileId === LOCAL_RUNTIME_HOST_PROFILE.id || - input.defaultProfileId() !== failure.profileId || - pending - ) return; + if (input.defaultProfileId() !== failure.profileId || pending) return; pending = recover(failure) .catch(input.onError) .finally(() => { From d119f6f69299fe473618391da4ddb13c0df3cb8b Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 19 Sep 2026 01:36:12 +0800 Subject: [PATCH 14/44] refactor(desktop): move the handoff overlay into the runtime-host-management zone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new flat renderer file is forbidden growth under the renderer architecture check and --strict-base rejects it outright, so the overlay moves into the existing Runtime Host feature zone: a `handoff` port on RuntimeHostManagementServices carries current/subscribe/decide, the platform adapter owns the only window.maka access, and the component consumes services through context — which also removes the provider-free window.maka?.runtimeHostHandoff probe that silently no-oped whenever the bridge was not up yet. Clipboard goes through the port like every other copy action in the feature, and a copied toast restores the feedback the retired startup window had. The stories wrap the real services provider and adapter around a fake bridge channel instead of stubbing the component's data source, and the architecture ledger is regenerated. Generated-by: Devin --- apps/desktop/renderer-architecture.json | 18 ++-- .../connection-settings-locale-render.test.ts | 6 ++ apps/desktop/src/renderer/app-shell.tsx | 2 +- .../features/runtime-host-management/index.ts | 7 +- .../locales/runtime-host-handoff-copy.ts | 4 + .../features/runtime-host-management/ports.ts | 17 ++++ .../ui}/runtime-host-handoff-overlay.tsx | 26 +++--- ...create-runtime-host-management-services.ts | 8 +- .../stories/runtime-host-handoff.stories.tsx | 91 +++++++++---------- 9 files changed, 106 insertions(+), 73 deletions(-) rename apps/desktop/src/renderer/{ => features/runtime-host-management}/locales/runtime-host-handoff-copy.ts (87%) rename apps/desktop/src/renderer/{ => features/runtime-host-management/ui}/runtime-host-handoff-overlay.tsx (81%) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 5bb7e985b3..f6a3700ae3 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -752,11 +752,11 @@ "useAppShellTurnPresentation": 1, "useComposerAttachments": 1, "useEffect": 7, - "useLayoutEffect": 2, + "useLayoutEffect": 1, "useNewTaskChoice": 1, "useOnboardingSnapshot": 1, "usePlanModeState": 1, - "useRef": 16, + "useRef": 15, "useSessionCollaborationDialog": 1, "useSessionEventHealthPolling": 1, "useSessionNavigationReads": 1, @@ -810,6 +810,7 @@ "./features/goals": 1, "./features/module-hub": 1, "./features/overlays/index.js": 1, + "./features/runtime-host-management/index.js": 1, "./features/session-collaboration": 1, "./features/session-navigation": 1, "./features/session-settings": 1, @@ -862,7 +863,7 @@ "react": 1 }, "importSpecifiers": 100, - "nonTriviaTokens": 13139 + "nonTriviaTokens": 13037 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 2, @@ -4388,16 +4389,13 @@ "react": 1 }, "importSpecifiers": 6, - "nonTriviaTokens": 206 + "nonTriviaTokens": 176 }, "src/renderer/main.tsx": { "importDeclarations": 7, - "bridgePaths": { - "window.maka.onboarding.getSnapshot": 2 - }, + "bridgePaths": {}, "environmentCapabilities": { - "document.getElementById": 1, - "setTimeout": 2 + "document.getElementById": 1 }, "hookCalls": {}, "lifecycleMethods": {}, @@ -4413,7 +4411,7 @@ "react-dom/client": 1 }, "importSpecifiers": 7, - "nonTriviaTokens": 260 + "nonTriviaTokens": 102 } }, "rootDebtClosure": { diff --git a/apps/desktop/src/main/__tests__/connection-settings-locale-render.test.ts b/apps/desktop/src/main/__tests__/connection-settings-locale-render.test.ts index 0fd1c6a4bd..91d522c206 100644 --- a/apps/desktop/src/main/__tests__/connection-settings-locale-render.test.ts +++ b/apps/desktop/src/main/__tests__/connection-settings-locale-render.test.ts @@ -479,6 +479,12 @@ function managementServices(): RuntimeHostManagementServices { readClipboardText: unexpectedCall, writeClipboardText: unexpectedCall, }, resources: { query: unexpectedCall, schedule: unexpectedCall }, + handoff: { + current: async () => null, + subscribe: () => () => {}, + decide: unexpectedCall, + copyText: unexpectedCall, + }, peerMesh: { execute: unexpectedCall, cancel: unexpectedCall, getConnectivityPolicy: unexpectedCall, setConnectivityPolicy: unexpectedCall, diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 0614b69965..9d0706c9d3 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -18,7 +18,7 @@ */ import { WorkHubControlOverlay, WorkHubDock, WorkHubMainNavigation, WorkHubReturnButton } from './features/workhub'; -import { RuntimeHostHandoffOverlay } from './runtime-host-handoff-overlay'; +import { RuntimeHostHandoffOverlay } from './features/runtime-host-management/index.js'; import { useCallback, useEffect, diff --git a/apps/desktop/src/renderer/features/runtime-host-management/index.ts b/apps/desktop/src/renderer/features/runtime-host-management/index.ts index a64ae63f53..f84af651b3 100644 --- a/apps/desktop/src/renderer/features/runtime-host-management/index.ts +++ b/apps/desktop/src/renderer/features/runtime-host-management/index.ts @@ -25,6 +25,7 @@ export { RuntimeHostAddComputerMenu } from './ui/runtime-host-add-computer-menu. export { RuntimeHostConnectionCodeButton } from './ui/runtime-host-connection-code-button.js'; export { RuntimeHostConnectionCodeDialog } from './ui/runtime-host-connection-code-dialog.js'; export { RuntimeHostResourceDialog } from './ui/runtime-host-resource-dialog.js'; +export { RuntimeHostHandoffOverlay } from './ui/runtime-host-handoff-overlay.js'; export { RuntimeHostPairingRecoveryButton, RuntimeHostProfileMoreMenu, @@ -32,4 +33,8 @@ export { export type { RuntimeHostPairingActionCopy } from './ui/runtime-host-profile-pairing-actions.js'; export { RuntimeHostManagementServicesProvider } from './services-context.js'; export { PeerMeshOperationOutcomeUnknownError } from './ports.js'; -export type { RuntimeHostManagementServices } from './ports.js'; +export type { + RuntimeHostHandoffPayload, + RuntimeHostHandoffServices, + RuntimeHostManagementServices, +} from './ports.js'; diff --git a/apps/desktop/src/renderer/locales/runtime-host-handoff-copy.ts b/apps/desktop/src/renderer/features/runtime-host-management/locales/runtime-host-handoff-copy.ts similarity index 87% rename from apps/desktop/src/renderer/locales/runtime-host-handoff-copy.ts rename to apps/desktop/src/renderer/features/runtime-host-management/locales/runtime-host-handoff-copy.ts index 4cc693931a..2ec1f706ef 100644 --- a/apps/desktop/src/renderer/locales/runtime-host-handoff-copy.ts +++ b/apps/desktop/src/renderer/features/runtime-host-management/locales/runtime-host-handoff-copy.ts @@ -21,17 +21,21 @@ import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; interface RuntimeHostHandoffCopy { readonly copyDiagnostics: string; + readonly diagnosticsCopied: string; } const COPY_BY_LOCALE: UiCatalog = { 'zh-CN': { copyDiagnostics: '复制诊断信息', + diagnosticsCopied: '诊断信息已复制', }, 'zh-TW': { copyDiagnostics: '複製診斷資訊', + diagnosticsCopied: '診斷資訊已複製', }, en: { copyDiagnostics: 'Copy diagnostics', + diagnosticsCopied: 'Diagnostics copied', }, }; diff --git a/apps/desktop/src/renderer/features/runtime-host-management/ports.ts b/apps/desktop/src/renderer/features/runtime-host-management/ports.ts index fd121577a0..17a877f144 100644 --- a/apps/desktop/src/renderer/features/runtime-host-management/ports.ts +++ b/apps/desktop/src/renderer/features/runtime-host-management/ports.ts @@ -19,6 +19,10 @@ import type { RuntimeHostPeerMeshManagementAction } from '@maka/runtime-host/operator'; import type { RuntimeHostWebRtcStunPolicy } from '@maka/runtime-host/operator'; +import type { + HostHandoffPresentation, + HostHandoffView, +} from '@maka/runtime-host/client'; import type { HostResourcesResult, PeerMeshInvitationResult, @@ -109,10 +113,23 @@ export interface RuntimeHostResourceServices { schedule(callback: () => void, delayMs: number): () => void; } +export interface RuntimeHostHandoffPayload { + readonly view: HostHandoffView; + readonly presentation: HostHandoffPresentation; +} + +export interface RuntimeHostHandoffServices { + current(): Promise; + subscribe(handler: (payload: RuntimeHostHandoffPayload | null) => void): () => void; + decide(revision: string, action: string): Promise; + copyText(value: string): Promise; +} + export interface RuntimeHostManagementServices { readonly peerMesh: PeerMeshServices; readonly profilePairing: RuntimeHostProfilePairingServices; readonly connectionCodes: RuntimeHostConnectionCodeServices; readonly resources: RuntimeHostResourceServices; + readonly handoff: RuntimeHostHandoffServices; readonly supportsWsl: boolean; } diff --git a/apps/desktop/src/renderer/runtime-host-handoff-overlay.tsx b/apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-handoff-overlay.tsx similarity index 81% rename from apps/desktop/src/renderer/runtime-host-handoff-overlay.tsx rename to apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-handoff-overlay.tsx index 52d903b38e..fbc7e0748b 100644 --- a/apps/desktop/src/renderer/runtime-host-handoff-overlay.tsx +++ b/apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-handoff-overlay.tsx @@ -20,9 +20,10 @@ import { useEffect, useState } from 'react'; import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; -import { Button, Text, useUiLocale } from '@maka/ui'; -import type { DesktopHostHandoffPayload } from '../preload/bridge-contract.js'; -import { getRuntimeHostHandoffCopy } from './locales/runtime-host-handoff-copy.js'; +import { Button, Text, useToast, useUiLocale } from '@maka/ui'; +import type { RuntimeHostHandoffPayload } from '../ports.js'; +import { getRuntimeHostHandoffCopy } from '../locales/runtime-host-handoff-copy.js'; +import { useRuntimeHostManagementServices } from '../services-context.js'; /** * The in-window face of a Runtime Host handoff. Background reconciliation is @@ -32,19 +33,19 @@ import { getRuntimeHostHandoffCopy } from './locales/runtime-host-handoff-copy.j export function RuntimeHostHandoffOverlay() { const locale = useUiLocale(); const copy = getRuntimeHostHandoffCopy(locale); - const [payload, setPayload] = useState(null); + const toast = useToast(); + const handoff = useRuntimeHostManagementServices().handoff; + const [payload, setPayload] = useState(null); useEffect(() => { - const bridge = window.maka?.runtimeHostHandoff; - if (!bridge) return; let mounted = true; // Subscribe before fetching the snapshot: a push wins over the older // current() response whenever both are in flight. let pushed = false; - const unsubscribe = bridge.subscribe((next) => { + const unsubscribe = handoff.subscribe((next) => { pushed = true; if (mounted) setPayload(next); }); - void bridge + void handoff .current() .then((current) => { if (mounted && !pushed) setPayload(current); @@ -54,13 +55,13 @@ export function RuntimeHostHandoffOverlay() { mounted = false; unsubscribe(); }; - }, []); + }, [handoff]); const view = payload?.view; const presentation = payload?.presentation; if (view?.state !== 'attention' || !presentation) return null; const decide = (action: string) => { - void window.maka.runtimeHostHandoff.decide(view.revision, action); + void handoff.decide(view.revision, action); }; return ( @@ -83,8 +84,9 @@ export function RuntimeHostHandoffOverlay() { variant="ghost" label={copy.copyDiagnostics} onClick={() => - void navigator.clipboard - .writeText(JSON.stringify(view, null, 2)) + void handoff + .copyText(JSON.stringify(view, null, 2)) + .then(() => toast.success(copy.diagnosticsCopied)) .catch(() => {})} /> diff --git a/apps/desktop/src/renderer/platform/desktop/create-runtime-host-management-services.ts b/apps/desktop/src/renderer/platform/desktop/create-runtime-host-management-services.ts index df388ab234..cecb9af58e 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-runtime-host-management-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-runtime-host-management-services.ts @@ -25,7 +25,7 @@ import { export type DesktopRuntimeHostManagementBridge = Pick< MakaBridge, - 'runtimeHostManagement' | 'runtimeHostPeerMesh' | 'runtimeHostProfiles' + 'runtimeHostManagement' | 'runtimeHostPeerMesh' | 'runtimeHostProfiles' | 'runtimeHostHandoff' >; export function createDesktopRuntimeHostManagementServices( @@ -79,5 +79,11 @@ export function createDesktopRuntimeHostManagementServices( discard: (profileId) => bridge.runtimeHostProfiles.discardPairing(profileId).then(() => undefined), }, + handoff: { + current: () => bridge.runtimeHostHandoff.current(), + subscribe: (handler) => bridge.runtimeHostHandoff.subscribe(handler), + decide: (revision, action) => bridge.runtimeHostHandoff.decide(revision, action), + copyText: (value) => navigator.clipboard.writeText(value), + }, }; } diff --git a/apps/desktop/stories/runtime-host-handoff.stories.tsx b/apps/desktop/stories/runtime-host-handoff.stories.tsx index d8426bceb1..3d378c129c 100644 --- a/apps/desktop/stories/runtime-host-handoff.stories.tsx +++ b/apps/desktop/stories/runtime-host-handoff.stories.tsx @@ -19,10 +19,14 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { expect, fn, userEvent, within } from 'storybook/test'; -import { AstryxLocaleProvider, LocaleProvider } from '@maka/ui'; +import { AstryxLocaleProvider, LocaleProvider, ToastProvider } from '@maka/ui'; import type { HostHandoffAttentionView } from '@maka/runtime-host/client'; -import { RuntimeHostHandoffOverlay } from '../src/renderer/runtime-host-handoff-overlay'; -import { withScopedMakaBridge } from './maka-bridge'; +import { RuntimeHostHandoffOverlay } from '../src/renderer/features/runtime-host-management/index.js'; +import { RuntimeHostManagementServicesProvider } from '../src/renderer/features/runtime-host-management/index.js'; +import { + createDesktopRuntimeHostManagementServices, + type DesktopRuntimeHostManagementBridge, +} from '../src/renderer/platform/desktop/create-runtime-host-management-services'; import type { DesktopHostHandoffPayload } from '../src/preload/bridge-contract.js'; const meta = { @@ -35,14 +39,28 @@ type Story = StoryObj; const decide = fn(async () => undefined); -function withHandoffBridge(payload: DesktopHostHandoffPayload) { - return withScopedMakaBridge({ +function handoffServices(payload: DesktopHostHandoffPayload) { + return createDesktopRuntimeHostManagementServices({ runtimeHostHandoff: { current: async () => payload, subscribe: () => () => {}, decide, }, - }); + } as unknown as DesktopRuntimeHostManagementBridge); +} + +function renderWithServices(payload: DesktopHostHandoffPayload) { + return () => ( + + + + + + + + + + ); } const replacementView: HostHandoffAttentionView = { @@ -76,14 +94,7 @@ const replacementPayload: DesktopHostHandoffPayload = { // Real path: launch while a managed Runtime Host update needs package-change // consent — the main window mounts the overlay above the shell. export const ReplacementConsent: Story = { - decorators: [withHandoffBridge(replacementPayload)], - render: () => ( - - - - - - ), + render: renderWithServices(replacementPayload), play: async ({ canvasElement }) => { decide.mockClear(); const body = within(canvasElement.ownerDocument.body); @@ -119,14 +130,7 @@ const retryPayload: DesktopHostHandoffPayload = { // Real path: a Local Host update failed after retries were exhausted — the // only offered action is the one the surface advertised (#5476's stuck view). export const RetryExhausted: Story = { - decorators: [withHandoffBridge(retryPayload)], - render: () => ( - - - - - - ), + render: renderWithServices(retryPayload), play: async ({ canvasElement }) => { decide.mockClear(); const body = within(canvasElement.ownerDocument.body); @@ -139,32 +143,23 @@ export const RetryExhausted: Story = { // Real path: the same launch while reconciliation is still in progress — // progress views stay silent and nothing mounts. export const ProgressStaysSilent: Story = { - decorators: [ - withHandoffBridge({ - view: { - revision: 'handoff-progress', - state: 'progress', - phase: 'staging', - target: { name: 'Local', location: 'local' }, - mayExitNaturally: false, - actions: ['cancel'], - defaultAction: 'cancel', - }, - presentation: { - title: 'Continuing to your workspace', - description: 'Preparing the update', - detail: 'Finishing the handoff or its safe recovery. Please wait.', - actions: [{ action: 'cancel', label: 'Cancel' }], - }, - }), - ], - render: () => ( - - - - - - ), + render: renderWithServices({ + view: { + revision: 'handoff-progress', + state: 'progress', + phase: 'staging', + target: { name: 'Local', location: 'local' }, + mayExitNaturally: false, + actions: ['cancel'], + defaultAction: 'cancel', + }, + presentation: { + title: 'Continuing to your workspace', + description: 'Preparing the update', + detail: 'Finishing the handoff or its safe recovery. Please wait.', + actions: [{ action: 'cancel', label: 'Cancel' }], + }, + }), play: async ({ canvasElement }) => { const body = within(canvasElement.ownerDocument.body); await new Promise((resolve) => setTimeout(resolve, 50)); From 39601e1f26cb7fd013b2c6c0540ca2db73dd19e0 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 19 Sep 2026 01:36:25 +0800 Subject: [PATCH 15/44] refactor(renderer): drop the prefetch-era snapshot getters getSessions/getConnections/getDefaultSlug and the refs feeding them were seeding plumbing for the removed onboarding prefetch; nothing calls them any more. Generated-by: Devin --- .../src/renderer/use-onboarding-snapshot.ts | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/apps/desktop/src/renderer/use-onboarding-snapshot.ts b/apps/desktop/src/renderer/use-onboarding-snapshot.ts index d3b1a33543..5b46863a8f 100644 --- a/apps/desktop/src/renderer/use-onboarding-snapshot.ts +++ b/apps/desktop/src/renderer/use-onboarding-snapshot.ts @@ -34,8 +34,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { generalizedErrorMessageForLocale } from '@maka/core/redaction'; -import { type LlmConnection } from '@maka/core/llm-connections'; -import { type SessionSummary } from '@maka/core/session'; import { type UiLocale } from '@maka/core/ui-locale'; import { hasSettledInitialOnboarding } from '@maka/core/onboarding-milestone'; import { useUiLocale } from '@maka/ui'; @@ -54,11 +52,6 @@ export interface UseOnboardingSnapshotResult { snapshot: OnboardingSnapshot | null; error: string | null; refresh: () => void; - /** Sessions from the snapshot — populated on first load, before the separate sessions:list IPC. */ - getSessions(): SessionSummary[] | null; - /** Connections from the snapshot — populated on first load before the live projection refresh. */ - getConnections(): LlmConnection[] | null; - getDefaultSlug(): string | null; } export interface UseOnboardingSnapshotDeps { @@ -113,9 +106,6 @@ export function useOnboardingSnapshotImpl( localeRef.current = locale; const [snapshot, setSnapshot] = useState(null); const [error, setError] = useState(null); - const sessionsRef = useRef(null); - const connectionsRef = useRef(null); - const defaultSlugRef = useRef(null); const pollerRef = useRef(null); if (pollerRef.current === null) { @@ -123,9 +113,6 @@ export function useOnboardingSnapshotImpl( onSnapshot: (next) => { setSnapshot(next); setError(null); - if (next.sessions) sessionsRef.current = next.sessions; - if (next.connections) connectionsRef.current = next.connections; - defaultSlugRef.current = next.defaultSlug; }, onError: (message) => { setError(message); @@ -150,17 +137,10 @@ export function useOnboardingSnapshotImpl( void pollerRef.current?.pull(); }, []); - const getSessions = useCallback((): SessionSummary[] | null => sessionsRef.current, []); - const getConnections = useCallback((): LlmConnection[] | null => connectionsRef.current, []); - const getDefaultSlug = useCallback((): string | null => defaultSlugRef.current, []); - return { snapshot, error, refresh, - getSessions, - getConnections, - getDefaultSlug, }; } From db338b31e9e7cc3fba25e101fa85ab470f543e4b Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 19 Sep 2026 01:36:25 +0800 Subject: [PATCH 16/44] refactor(desktop): activate remote profiles without waiting on Local reconciliation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit startEnabledProfiles only drives remote targets, but it sat behind the Local Host's whole start lifecycle — a handoff parked on a user decision held every enabled remote profile hostage for the session. Generated-by: Devin --- apps/desktop/src/main/runtime-host-boot.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index bce9e4d777..2643881828 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -1440,6 +1440,9 @@ runtimeHostManager.setDefaultProfile(runtimeHostStartup.preferences.defaultProfi wireLifecycle(); sessionLocal.wake(); windowsAppTray.start(); +// Remote profiles do not depend on the Local Host: a handoff parked on a +// user decision must not hold their activation for the whole session. +void runtimeHostProfileService.startEnabledProfiles(); // Runtime Host is the only schema-migration authority for its State Root. // Work Board remains a Desktop-owned table, but it opens only while a ready // Host has verified the schema — including a Local Host that only becomes @@ -1488,7 +1491,6 @@ void (async () => { await localRuntimeHostRemoteAccess.recover().catch((error: unknown) => { console.error('[runtime-host] interrupted Local Host setup could not be recovered:', error); }); - void runtimeHostProfileService.startEnabledProfiles(); const unavailableDefault = runtimeHostStartup.unavailable.get( runtimeHostStartup.preferences.defaultProfileId, ); From 6769c4ebb8443f783939e36c977848e488dfe2e2 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 19 Sep 2026 01:36:41 +0800 Subject: [PATCH 17/44] fix(desktop): raise the main window when a handoff asks for a decision The retired startup window called focus() on every attention view; the in-window surface must keep that pull or a required decision waits silently behind a minimized window. One raise per revision keeps repeat updates of the on-screen decision from stealing focus. Generated-by: Devin --- .../runtime-host-handoff-surface.test.ts | 28 ++++++++++++++++++- apps/desktop/src/main/runtime-host-boot.ts | 1 + .../src/main/runtime-host-handoff-surface.ts | 12 +++++++- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-handoff-surface.test.ts b/apps/desktop/src/main/__tests__/runtime-host-handoff-surface.test.ts index 573b3ff5dc..114173c344 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-handoff-surface.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-handoff-surface.test.ts @@ -46,6 +46,7 @@ const progressView = (revision: string): HostHandoffView => ({ function harness() { const sent: Array = []; const handlers = new Map unknown>(); + let focuses = 0; const surface = createDesktopHostHandoffSurface({ ipcMain: { handle(channel: string, listener: (...args: unknown[]) => unknown) { @@ -53,10 +54,13 @@ function harness() { }, } as never, send: (payload) => sent.push(payload), + focus: () => { + focuses += 1; + }, resolveLocale: async () => 'en', }); const last = () => sent.at(-1); - return { surface, sent, handlers, last }; + return { surface, sent, handlers, last, focuses: () => focuses }; } test('publishes only the newest attention view; progress cannot steal the slot', async () => { @@ -89,6 +93,28 @@ test('publishes only the newest attention view; progress cannot steal the slot', assert.equal(last(), null); }); +test('raises the window once per attention revision, never for progress', async () => { + const { surface, focuses } = harness(); + const handoff = surface(() => {}); + + handoff.update(progressView('p1')); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(focuses(), 0); + + handoff.update(attentionView('a1')); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(focuses(), 1); + + // Repeats of the decision already on screen do not steal focus again. + handoff.update(attentionView('a1')); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(focuses(), 1); + + handoff.update(attentionView('a2')); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(focuses(), 2); +}); + test('decide routes only a live attention revision and advertised action', async () => { const { surface, handlers } = harness(); const decisions: Array<[string, HostHandoffAction]> = []; diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 2643881828..5fc52bb2db 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -1288,6 +1288,7 @@ const createLocalRuntimeHostManager = () => createRuntimeHostDesktopManager( handoffSurface: createDesktopHostHandoffSurface({ ipcMain, send: (payload) => mainWindowController.send('runtime-host-handoff:view', payload), + focus: () => mainWindowController.focus(), resolveLocale: () => desktopLocale.resolve(), }), onTargetStateChanged: (state) => { diff --git a/apps/desktop/src/main/runtime-host-handoff-surface.ts b/apps/desktop/src/main/runtime-host-handoff-surface.ts index 5154a613ce..d2a8ddfa45 100644 --- a/apps/desktop/src/main/runtime-host-handoff-surface.ts +++ b/apps/desktop/src/main/runtime-host-handoff-surface.ts @@ -43,11 +43,15 @@ interface OpenDesktopHandoff { export function createDesktopHostHandoffSurface(input: { ipcMain: IpcMain; send: (payload: DesktopHostHandoffPayload | null) => void; + focus: () => void; resolveLocale: () => Promise; }): OpenHostHandoffSurface { const open = new Map(); let sequence = 0; let activeId: number | undefined; + // An attention view asks for the window once per revision — repeat updates + // of a decision the user is already looking at must not keep stealing focus. + let raisedRevision: string | undefined; const currentEntry = (): OpenDesktopHandoff | undefined => activeId === undefined ? undefined : open.get(activeId); @@ -64,7 +68,13 @@ export function createDesktopHostHandoffSurface(input: { } : null; const publish = (): void => { - void payloadFor(currentEntry()).then((payload) => input.send(payload)); + void payloadFor(currentEntry()).then((payload) => { + input.send(payload); + const revision = + payload?.view.state === 'attention' ? payload.view.revision : undefined; + if (revision !== undefined && revision !== raisedRevision) input.focus(); + raisedRevision = revision; + }); }; const refreshActive = (): void => { activeId = undefined; From cb60310711fe4c5c32be8d23b29c3523c06e7206 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 19 Sep 2026 10:14:45 +0800 Subject: [PATCH 18/44] fix(desktop): treat pre-ready Host reads as pending, not failed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With first paint ahead of Host readiness, mount-time refreshes now race the Host coming up. A getDefault() rejection meant "still connecting", but every background read reported it as a failure: five startup toasts, a stuck memory pill, stale shell settings, and an onboarding snapshot error that cascaded into a bogus connection-refresh error. Gate background refresh reporting on default-Host resolvability and lean on the existing ready-transition re-fire for recovery. Mutations and post-ready failures still surface errors. The onboarding poller defers the same transient rejection via a message check — importing the probe would add a forbidden dependency edge in the architecture ledger. Verified end-to-end on a real Electron boot: first paint ~170ms, renderer mounted ~750ms, Host ready ~1.1s, zero startup error toasts, and the onboarding provider list hydrates on the ready transition. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/desktop/renderer-architecture.json | 4 +- ...ule-hub-scheduled-tasks-controller.test.ts | 46 +++++++++++++++++++ .../module-hub-skills-controller.test.ts | 33 +++++++++++++ .../__tests__/use-onboarding-snapshot.test.ts | 26 +++++++++++ .../desktop/src/renderer/app-shell-effects.ts | 42 ++++++++--------- .../default-runtime-host-operation.ts | 9 ++++ .../controller/default-runtime-host.ts | 11 +++++ .../controller/use-daily-review-controller.ts | 5 +- .../use-scheduled-tasks-controller.ts | 5 +- .../controller/use-skills-controller.ts | 11 ++++- .../src/renderer/use-onboarding-snapshot.ts | 14 +++++- .../src/renderer/use-shell-connections.ts | 9 ++++ .../src/renderer/use-shell-memory-pill.ts | 10 ++++ 13 files changed, 196 insertions(+), 29 deletions(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index f6a3700ae3..ecab7f6975 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -503,7 +503,7 @@ "react": 1 }, "importSpecifiers": 18, - "nonTriviaTokens": 3677 + "nonTriviaTokens": 3675 }, "src/renderer/app-shell-overlays.tsx": { "importDeclarations": 5, @@ -4014,7 +4014,7 @@ "environmentCapabilities": {}, "hookCalls": { "useEffect": 1, - "useRef": 5, + "useRef": 2, "useState": 2, "useUiLocale": 1 }, diff --git a/apps/desktop/src/main/__tests__/module-hub-scheduled-tasks-controller.test.ts b/apps/desktop/src/main/__tests__/module-hub-scheduled-tasks-controller.test.ts index c4c01d3d84..07577f7491 100644 --- a/apps/desktop/src/main/__tests__/module-hub-scheduled-tasks-controller.test.ts +++ b/apps/desktop/src/main/__tests__/module-hub-scheduled-tasks-controller.test.ts @@ -482,3 +482,49 @@ afterEach(() => { latest = undefined; cleanupFakeDom(); }); + +test('Scheduled Tasks refresh failures stay silent while the default Host is unavailable', async () => { + const { root } = installReactRenderer(); + const records: ToastRecord[] = []; + const host = { profileId: 'profile-a', hostId: 'host-a' }; + let defaultHost: typeof host | undefined; + const defaults = createFakeModuleHubServices(); + const services = createFakeModuleHubServices({ + runtimeHosts: { + ...defaults.runtimeHosts, + getDefault: async () => { + if (!defaultHost) throw new Error('identity is unavailable'); + return defaultHost; + }, + }, + scheduledTasks: { + ...defaults.scheduledTasks, + list: async () => [task('ready-task')], + }, + }); + await act(async () => + renderController(root, services, { + selection: activeSelection, + selectModule: () => undefined, + toastApi: toastRecorder(records), + }), + ); + + await act(async () => controller().refresh()); + assert.deepEqual(records, []); + assert.deepEqual(controller().scheduledTasks, []); + + defaultHost = host; + await act(async () => controller().refresh()); + assert.deepEqual( + controller().scheduledTasks.map(({ id }) => id), + ['ready-task'], + ); + assert.deepEqual(records, []); + + services.scheduledTasks.list = async () => { + throw new Error('list failed'); + }; + await act(async () => controller().refresh()); + assert.equal(records.filter(({ kind }) => kind === 'error').length, 1); +}); diff --git a/apps/desktop/src/main/__tests__/module-hub-skills-controller.test.ts b/apps/desktop/src/main/__tests__/module-hub-skills-controller.test.ts index 6a31e8150e..5ee5c147f1 100644 --- a/apps/desktop/src/main/__tests__/module-hub-skills-controller.test.ts +++ b/apps/desktop/src/main/__tests__/module-hub-skills-controller.test.ts @@ -462,3 +462,36 @@ test("stale Skills refresh errors do not outlive a newer successful generation", await act(async () => staleRefresh); assert.deepEqual(records, []); }); + +test("Skills refresh failures stay silent while the default Host is unavailable", async () => { + const { root } = installReactRenderer(); + const records: ToastRecord[] = []; + const host = { profileId: "profile-a", hostId: "host-a" }; + let defaultHost: typeof host | undefined; + const defaults = createFakeModuleHubServices(); + const services = createFakeModuleHubServices({ + runtimeHosts: { + ...defaults.runtimeHosts, + getDefault: async () => { + if (!defaultHost) throw new Error("identity is unavailable"); + return defaultHost; + }, + }, + }); + + await act(async () => renderController(root, services, input(records))); + await act(async () => controller().refreshProjectSkills()); + assert.deepEqual(records, []); + assert.deepEqual(controller().host.skills, []); + + defaultHost = host; + await act(async () => controller().refreshProjectSkills()); + assert.equal(controller().revision, 1); + assert.deepEqual(records, []); + + services.skills.list = async () => { + throw new Error("list failed"); + }; + await act(async () => controller().host.onRefreshSkills()); + assert.equal(records.filter(({ kind }) => kind === "error").length, 1); +}); diff --git a/apps/desktop/src/main/__tests__/use-onboarding-snapshot.test.ts b/apps/desktop/src/main/__tests__/use-onboarding-snapshot.test.ts index 35084cadae..7c977f11cb 100644 --- a/apps/desktop/src/main/__tests__/use-onboarding-snapshot.test.ts +++ b/apps/desktop/src/main/__tests__/use-onboarding-snapshot.test.ts @@ -249,4 +249,30 @@ describe('createOnboardingSnapshotPoller', () => { await poller.pull(); assert.deepEqual(events, [{ type: 'snap', payload: READY_SNAPSHOT }]); }); + + it('shouldDeferError holds backend-pending rejections out of the error slot', async () => { + const events: Array<{ type: 'snap' | 'err'; payload: unknown }> = []; + let backendUp = false; + const poller = createOnboardingSnapshotPoller( + { + getSnapshot: async () => { + if (!backendUp) throw new Error('identity is unavailable'); + return READY_SNAPSHOT; + }, + shouldDeferError: async () => !backendUp, + }, + { + onSnapshot: (s) => events.push({ type: 'snap', payload: s }), + onError: (m) => events.push({ type: 'err', payload: m }), + }, + () => 'zh-CN', + ); + + await poller.pull(); + assert.deepEqual(events, [], 'a not-up backend must not surface an error'); + + backendUp = true; + await poller.pull(); + assert.deepEqual(events, [{ type: 'snap', payload: READY_SNAPSHOT }]); + }); }); diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index e309fa4581..dd747b68dd 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -175,14 +175,12 @@ export function useAppShellBootstrapSubscriptions(options: { options.handleConnectionEvent(event); }); const handleRuntimeHostChange = useEffectEvent((event: DesktopRuntimeHostProfileChangedEvent) => { - void options.refreshSessions().then((sessions) => { - options.retiredSessionIds(sessions).forEach(options.retireSession); - }); - if (event.readiness !== 'ready') return; - if (!event.isDefault) return; + void options.refreshSessions().then((sessions) => options.retiredSessionIds(sessions).forEach(options.retireSession)); + if (event.readiness !== 'ready' || !event.isDefault) return; void options.refreshProjects(); void options.refreshConnections(); void options.refreshMemoryActive('load'); + void options.refreshShellSettings(); }); // PR-2088: the macOS application menu routes New Task / Settings / Keyboard // Shortcuts here through one channel. The renderer already owns these @@ -200,25 +198,23 @@ export function useAppShellBootstrapSubscriptions(options: { (event: SessionChangedEvent) => { const refreshedSessions = options.refreshSessions(); if (event.reason === 'archived' && event.sessionId) options.retireSession(event.sessionId); - if (event.reason === 'created' || event.reason === 'migrated') { - void options.refreshProjects(); + if (event.reason === 'created' || event.reason === 'migrated') void options.refreshProjects(); + if (event.sessionId) { + options.setSessionEventHealthBySession((current) => { + const previous = current[event.sessionId!]; + if (!previous) return current; + return { + ...current, + [event.sessionId!]: recordSessionEventStreamChange(previous, event.ts), + }; + }); + } + if ( + event.sessionId && + (event.reason === 'turn-status-change' || event.reason === 'message-appended' || event.reason === 'deleted') + ) { + options.clearPendingTurnActionsForSession(event.sessionId); } - if (event.sessionId) { - options.setSessionEventHealthBySession((current) => { - const previous = current[event.sessionId!]; - if (!previous) return current; - return { - ...current, - [event.sessionId!]: recordSessionEventStreamChange(previous, event.ts), - }; - }); - } - if ( - event.sessionId && - (event.reason === 'turn-status-change' || event.reason === 'message-appended' || event.reason === 'deleted') - ) { - options.clearPendingTurnActionsForSession(event.sessionId); - } const changedSessionId = event.sessionId; if (event.reason === 'message-appended' && changedSessionId && changedSessionId === options.activeIdRef.current) { void options.refreshMessages(changedSessionId); diff --git a/apps/desktop/src/renderer/default-runtime-host-operation.ts b/apps/desktop/src/renderer/default-runtime-host-operation.ts index 00d0aa6363..1b2df43994 100644 --- a/apps/desktop/src/renderer/default-runtime-host-operation.ts +++ b/apps/desktop/src/renderer/default-runtime-host-operation.ts @@ -47,6 +47,15 @@ export async function runOnDefaultRuntimeHost( } } +export async function isDefaultRuntimeHostResolvable(): Promise { + try { + await runOnDefaultRuntimeHost(async () => undefined); + return true; + } catch { + return false; + } +} + export async function runIfDefaultRuntimeHostCurrent( host: DesktopRuntimeHostRef, operation: () => unknown | Promise, diff --git a/apps/desktop/src/renderer/features/module-hub/controller/default-runtime-host.ts b/apps/desktop/src/renderer/features/module-hub/controller/default-runtime-host.ts index a5af5bde95..193795da30 100644 --- a/apps/desktop/src/renderer/features/module-hub/controller/default-runtime-host.ts +++ b/apps/desktop/src/renderer/features/module-hub/controller/default-runtime-host.ts @@ -76,6 +76,17 @@ export async function isDefaultRuntimeHostCurrent( } } +export async function isDefaultRuntimeHostResolvable( + runtimeHosts: ModuleHubRuntimeHostsService, +): Promise { + try { + await runtimeHosts.getDefault(); + return true; + } catch { + return false; + } +} + export async function runIfDefaultRuntimeHostCurrent( runtimeHosts: ModuleHubRuntimeHostsService, host: ModuleHubRuntimeHostRef, diff --git a/apps/desktop/src/renderer/features/module-hub/controller/use-daily-review-controller.ts b/apps/desktop/src/renderer/features/module-hub/controller/use-daily-review-controller.ts index 64b68812c9..f3cd675358 100644 --- a/apps/desktop/src/renderer/features/module-hub/controller/use-daily-review-controller.ts +++ b/apps/desktop/src/renderer/features/module-hub/controller/use-daily-review-controller.ts @@ -41,6 +41,7 @@ import { defaultRuntimeHostDiagnosticTarget, defaultRuntimeHostOperationHost, isDefaultRuntimeHostCurrent, + isDefaultRuntimeHostResolvable, runOnDefaultRuntimeHost, } from './default-runtime-host.js'; @@ -114,7 +115,9 @@ async function operationFailureIsCurrent( ): Promise { if (error instanceof StaleDailyReviewHostError) return false; const host = defaultRuntimeHostOperationHost(error); - return host ? isDefaultRuntimeHostCurrent(services.runtimeHosts, host) : true; + return host + ? isDefaultRuntimeHostCurrent(services.runtimeHosts, host) + : isDefaultRuntimeHostResolvable(services.runtimeHosts); } /** diff --git a/apps/desktop/src/renderer/features/module-hub/controller/use-scheduled-tasks-controller.ts b/apps/desktop/src/renderer/features/module-hub/controller/use-scheduled-tasks-controller.ts index 7c3c71fbb8..bbb2e28cbb 100644 --- a/apps/desktop/src/renderer/features/module-hub/controller/use-scheduled-tasks-controller.ts +++ b/apps/desktop/src/renderer/features/module-hub/controller/use-scheduled-tasks-controller.ts @@ -33,6 +33,7 @@ import { defaultRuntimeHostDiagnosticTarget, defaultRuntimeHostOperationHost, isDefaultRuntimeHostCurrent, + isDefaultRuntimeHostResolvable, runIfDefaultRuntimeHostCurrent, runOnDefaultRuntimeHost, } from './default-runtime-host.js'; @@ -117,12 +118,14 @@ export function useScheduledTasksController(options: { if (!mountedRef.current || generation !== refreshGenerationRef.current) return; const operationHost = defaultRuntimeHostOperationHost(error); + // A refresh that never reached a Host is pending, not failed — the + // ready transition re-fires it. const hostIsCurrent = operationHost ? await isDefaultRuntimeHostCurrent( services.runtimeHosts, operationHost, ) - : true; + : await isDefaultRuntimeHostResolvable(services.runtimeHosts); if ( !mountedRef.current || generation !== refreshGenerationRef.current || diff --git a/apps/desktop/src/renderer/features/module-hub/controller/use-skills-controller.ts b/apps/desktop/src/renderer/features/module-hub/controller/use-skills-controller.ts index 3871f35609..832a7d139e 100644 --- a/apps/desktop/src/renderer/features/module-hub/controller/use-skills-controller.ts +++ b/apps/desktop/src/renderer/features/module-hub/controller/use-skills-controller.ts @@ -36,6 +36,7 @@ import { defaultRuntimeHostDiagnosticTarget, defaultRuntimeHostOperationHost, isDefaultRuntimeHostCurrent, + isDefaultRuntimeHostResolvable, runIfDefaultRuntimeHostCurrent, runOnDefaultRuntimeHost, } from "./default-runtime-host.js"; @@ -149,10 +150,18 @@ export function useSkillsController( async (options: RefreshOptions, error: unknown): Promise => { const shouldShowError = options.shouldShowError; if (shouldShowError && !shouldShowError()) return false; + // A refresh that never reached a Host is pending, not failed — the + // ready transition re-fires it. + if ( + !defaultRuntimeHostOperationHost(error) && + !(await isDefaultRuntimeHostResolvable(services.runtimeHosts)) + ) { + return false; + } if (!(await isOperationHostCurrent(error))) return false; return shouldShowError?.() ?? true; }, - [isOperationHostCurrent], + [isOperationHostCurrent, services.runtimeHosts], ); const reportRuntimeHostError = useCallback( diff --git a/apps/desktop/src/renderer/use-onboarding-snapshot.ts b/apps/desktop/src/renderer/use-onboarding-snapshot.ts index 5b46863a8f..ad43d2a2c2 100644 --- a/apps/desktop/src/renderer/use-onboarding-snapshot.ts +++ b/apps/desktop/src/renderer/use-onboarding-snapshot.ts @@ -57,6 +57,12 @@ export interface UseOnboardingSnapshotResult { export interface UseOnboardingSnapshotDeps { /** Fetch the current snapshot. */ getSnapshot: () => Promise; + /** + * Optional: a rejection that means "the backend is not up yet" rather than + * a real failure — the pull stays pending (no error surface) until an + * invalidation refires it. + */ + shouldDeferError?: (error: unknown) => Promise; /** * Subscribe to invalidation signals. The handler is fired * (debounced internally by the caller if needed) whenever an @@ -166,7 +172,7 @@ export interface OnboardingSnapshotPoller { } export function createOnboardingSnapshotPoller( - deps: Pick, + deps: Pick, callbacks: OnboardingSnapshotPollerCallbacks, getLocale: () => UiLocale, ): OnboardingSnapshotPoller { @@ -195,6 +201,8 @@ export function createOnboardingSnapshotPoller( if (!active || ticket !== inflightTicket) return; // newer pull won or unmounted emitSnapshot(next); } catch (err) { + if (!active || ticket !== inflightTicket) return; + if (await deps.shouldDeferError?.(err)) return; if (!active || ticket !== inflightTicket) return; emitError(onboardingSnapshotErrorMessage(err, getLocale())); } @@ -234,6 +242,10 @@ export function useOnboardingSnapshot(): UseOnboardingSnapshotResult { const LIVE_DEPS: UseOnboardingSnapshotDeps = { getSnapshot: () => window.maka.onboarding.getSnapshot(), + // The snapshot read goes through the default Host; while it is still + // connecting the pull is pending, not failed. + shouldDeferError: async (error) => + error instanceof Error && error.message.includes('identity is unavailable'), subscribeInvalidations(onInvalidate) { const unsubscribeSessions = window.maka.sessions.subscribeChanges(() => onInvalidate()); const unsubscribeConnections = window.maka.connections.subscribeEvents(() => onInvalidate()); diff --git a/apps/desktop/src/renderer/use-shell-connections.ts b/apps/desktop/src/renderer/use-shell-connections.ts index 1715c8e282..3ab6446b73 100644 --- a/apps/desktop/src/renderer/use-shell-connections.ts +++ b/apps/desktop/src/renderer/use-shell-connections.ts @@ -25,6 +25,7 @@ import type { DesktopNewTaskHostRef } from '../preload/bridge-contract.js'; import { parseDesktopSessionKey } from '../shared/runtime-host-identity.js'; import { defaultRuntimeHostDiagnosticTarget, + isDefaultRuntimeHostResolvable, runOnDefaultRuntimeHost, } from './default-runtime-host-operation.js'; import { getShellRemainingCopy } from './locales/shell-remaining-copy.js'; @@ -192,6 +193,14 @@ export function useShellConnections(options: { refreshSequence.current.get(key) !== sequence || currentKey.current !== key ) return; + // The default read failing with no default Host up is pending, not a + // failure — the ready transition re-fires this refresh. + if ( + target.kind === 'default' && + !(await isDefaultRuntimeHostResolvable()) + ) { + return; + } const diagnosticTarget = target.kind === 'session' && target.sessionId ? { sessionId: target.sessionId } : target.kind === 'new-task' && target.host diff --git a/apps/desktop/src/renderer/use-shell-memory-pill.ts b/apps/desktop/src/renderer/use-shell-memory-pill.ts index 064a972e65..8e0e9f9c65 100644 --- a/apps/desktop/src/renderer/use-shell-memory-pill.ts +++ b/apps/desktop/src/renderer/use-shell-memory-pill.ts @@ -21,6 +21,7 @@ import { useEffect, useRef, useState } from 'react'; import type { UiLocale } from '@maka/core/ui-locale'; import { defaultRuntimeHostDiagnosticTarget, + isDefaultRuntimeHostResolvable, runOnDefaultRuntimeHost, } from './default-runtime-host-operation.js'; import { getShellCopy, localizedShellErrorMessage } from './locales/shell-copy.js'; @@ -77,6 +78,15 @@ export function useShellMemoryPill({ if (refreshSequence.current !== sequence) return; setMemoryActive(next.agentReadEnabled && next.status === 'ok' && next.content.trim().length > 0); } catch (error) { + if (refreshSequence.current !== sequence) return; + // With no active Session the read goes through the default Host — when + // it is simply not up yet the ready transition re-runs this refresh. + if ( + !sessionId && + !(await isDefaultRuntimeHostResolvable()) + ) { + return; + } if (refreshSequence.current !== sequence) return; toastApi.error( failureContext === 'load' ? copy.memoryLoadErrorTitle : copy.memoryRefreshErrorTitle, From 4180f77b21ed9a9044dec8cdee837db624320fa7 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 19 Sep 2026 11:43:02 +0800 Subject: [PATCH 19/44] feat(desktop): reveal the window on the first painted frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The window stayed hidden until the renderer's first React commit, so the designed .maka-preload loading surface — meant to be the loading UI — was never visible and perceived startup was bounded by React mount (~750-940ms). Electron's ready-to-show fires as soon as the skeleton has painted; routing it through the existing reveal gate shows the window ~500ms earlier while keeping inactive/hidden modes, deferred focus, and the notifyRendererReady/fallback backstops intact. Perceived startup now matches the UI-first goal: the window appears with the loading surface while Runtime Host reconciliation continues in the background. Generated-by: Devin --- apps/desktop/src/main/main-window.ts | 27 ++++++++++++++++---------- apps/desktop/src/main/window-reveal.ts | 4 ++-- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/main/main-window.ts b/apps/desktop/src/main/main-window.ts index 88a32f8ee2..88eda08c0b 100644 --- a/apps/desktop/src/main/main-window.ts +++ b/apps/desktop/src/main/main-window.ts @@ -415,12 +415,10 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main minHeight: SAFE_MIN_HEIGHT, backgroundColor: initialBg, // PR-SHOW-AFTER-FIRST-COMMIT: create hidden on every run so the OS never - // flashes the index.html `.maka-preload` skeleton before React paints. - // The renderer signals `window:notifyRendererReady` after its first - // commit (app.tsx) and a fallback timer below reveals the window if that - // signal never arrives; the reveal gate (showWindowOnceReady) keeps the - // window hidden until the first real content can paint, so the app - // never flashes the `.maka-preload` skeleton past it. + // shows an unpainted window; `ready-to-show` reveals it on the first + // painted frame (the `.maka-preload` loading surface), and the reveal + // gate (showWindowOnceReady) routes that plus the renderer-ready IPC, + // the fallback timer, and deferred focus/maximize through the mode. show: false, // Native sidebar vibrancy lets the CSS-side sidebar render // transparent and inherit the system's blurred window material @@ -446,6 +444,15 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main }); mainWindowShutdownSignal = signal; observeRendererProcess(mainWindow, signal); + // The designed `.maka-preload` surface is the loading UI: reveal on the + // first painted frame instead of waiting out the whole React mount. + // markReady is mode-suppressed (hidden/inactive) and idempotent, so the + // later `window:notifyRendererReady` signal and the fallback timer stay + // as no-op backstops. + mainWindow.once('ready-to-show', () => { + clearShowFallbackTimer(); + revealGate.markReady(mainWindow); + }); installMainWindowPermissionPolicy(mainWindow.webContents, rendererEntry.url); // Two-layer external-link hygiene: assistant markdown often emits `` @@ -726,10 +733,10 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main }, focus() { // ChatGPT Pro review P2: second-instance / activate must not show() the - // still-hidden window ahead of the renderer's first commit — that would - // flash the `.maka-preload` skeleton past the reveal gate. The gate - // defers the request and flushes it (restore+show+focus) on markReady; - // after that, focus behaves exactly as before. + // still-hidden window before it has painted — that would flash an + // unpainted frame past the reveal gate. The gate defers the request + // and flushes it (restore+show+focus) on markReady; after that, focus + // behaves exactly as before. revealGate.requestFocus(mainWindow); }, isFocused() { diff --git a/apps/desktop/src/main/window-reveal.ts b/apps/desktop/src/main/window-reveal.ts index 0e1c1f91ec..9e89cf4036 100644 --- a/apps/desktop/src/main/window-reveal.ts +++ b/apps/desktop/src/main/window-reveal.ts @@ -137,9 +137,9 @@ export interface WindowRevealGate { /** * Readiness-aware wrapper around showWindowOnceReady. Focus requests that - * arrive before the renderer's first commit (user re-launches or clicks the + * arrive before the window has painted (user re-launches or clicks the * dock icon while the window is still hidden) must NOT show() the window — - * that would flash the `.maka-preload` skeleton the hidden creation exists to + * that would flash an unpainted frame the hidden creation exists to * suppress. They are remembered and flushed as show()+focus() when markReady * fires, so the user's foreground intent is honored, just not early. * From a01dc0043907243c2b1955f8c24ba3161b012243 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 19 Sep 2026 11:43:14 +0800 Subject: [PATCH 20/44] feat(desktop): fire the window before the Runtime Host module graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runtime-host-boot evaluates ~1100 compiled files before its first statement, so window creation could not start until ~400ms after the app was ready. early-window.ts holds the light slice the window actually needs — storage root, settings, locale, diagnostics, the window controller, the quit coordinator — and fires createWindow as soon as it exists; main.ts imports it first so the remaining Runtime Host graph loads while the renderer is already navigating. Window creation moves from ~400ms to ~210ms on this machine. The login-shell PATH probe now starts at module top and is awaited only where a child process is spawned (Local Host start, remote profiles, MCP) — it no longer sits serially ahead of the window (~100ms on a real launch; e2e fixtures skip it as before). Independent small reads (client instance id, Runtime Host startup) run in parallel. window:notifyRendererReady is not a Host-scoped channel: it is registered on ipcMain by early-window so the renderer's first commit cannot outrun scoped-router registration. The quit coordinator and the window controller keep their exact lifecycle semantics — before-quit aborts in-flight creation, close hooks and diagnostics reach the Runtime Host through boot-context late bindings. Generated-by: Devin --- .../__tests__/main-startup-lifetime.test.ts | 37 ++- .../__tests__/startup-storage-repair.test.ts | 8 +- apps/desktop/src/main/app-ipc-main.ts | 7 +- apps/desktop/src/main/boot-context.ts | 15 + apps/desktop/src/main/early-window.ts | 304 ++++++++++++++++++ apps/desktop/src/main/main.ts | 6 +- apps/desktop/src/main/runtime-host-boot.ts | 289 +++-------------- 7 files changed, 393 insertions(+), 273 deletions(-) create mode 100644 apps/desktop/src/main/boot-context.ts create mode 100644 apps/desktop/src/main/early-window.ts diff --git a/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts b/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts index a340d63273..6090526c88 100644 --- a/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts +++ b/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts @@ -30,8 +30,8 @@ const bootSource = readFileSync( fileURLToPath(new URL('../../../src/main/runtime-host-boot.ts', import.meta.url)), 'utf8', ); -const appIpcSource = readFileSync( - fileURLToPath(new URL('../../../src/main/app-ipc-main.ts', import.meta.url)), +const earlyWindowSource = readFileSync( + fileURLToPath(new URL('../../../src/main/early-window.ts', import.meta.url)), 'utf8', ); const mainWindowSource = readFileSync( @@ -73,14 +73,18 @@ test('retains process lifetime before a standalone startup dialog can close', () }); test('registers one shared quit cleanup before the initial Host handoff', () => { + const earlyWindowImport = mainSource.indexOf("import('./early-window.js')"); + const bootImport = mainSource.indexOf("import('./runtime-host-boot.js')"); const hostStart = bootSource.indexOf('await runtimeHostManager?.start()'); - const quitRegistration = bootSource.indexOf('app.on("before-quit", quitCoordinator.handleBeforeQuit)'); - const workBoardDeclaration = bootSource.indexOf('let workBoardIpc:'); - assert.ok(workBoardDeclaration >= 0 && workBoardDeclaration < quitRegistration); - assert.ok(quitRegistration >= 0 && quitRegistration < hostStart); - assert.equal(bootSource.match(/createAppQuitCoordinator\(\{/gu)?.length, 1); - assert.equal(bootSource.match(/app\.on\("before-quit"/gu)?.length, 1); - assert.match(bootSource, /cleanup: closeRuntimeHostDesktop/u); + const quitRegistration = earlyWindowSource.indexOf( + 'app.on("before-quit", quitCoordinator.handleBeforeQuit)', + ); + assert.ok(earlyWindowImport >= 0 && earlyWindowImport < bootImport); + assert.ok(quitRegistration >= 0); + assert.ok(hostStart >= 0); + assert.equal(earlyWindowSource.match(/createAppQuitCoordinator\(\{/gu)?.length, 1); + assert.equal(earlyWindowSource.match(/app\.on\("before-quit"/gu)?.length, 1); + assert.match(bootSource, /bootContext\.cleanup = closeRuntimeHostDesktop/u); assert.match(bootSource, /return runtimeHostDesktopShutdown \?\?= disposeRuntimeHostDesktop\(\)/u); assert.match(bootSource, /workBoardIpc\?\.close\(\)/u); }); @@ -100,14 +104,15 @@ test('creates the main window before starting Local Host reconciliation', () => const hostStart = bootSource.indexOf('await runtimeHostManager?.start()', managerCreate); assert.ok(managerCreate >= 0); assert.ok(lifecycleWire > managerCreate && hostStart > lifecycleWire); + assert.match(earlyWindowSource, /void quitCoordinator\.focusOrCreateWindow\(\)/u); assert.doesNotMatch(mainSource, /startup-presentation/u); }); test('resolves persisted locale before first post-settings recovery prompt', () => { - const rendererRecoveryStart = bootSource.indexOf('onRendererProcessGone: async'); - const rendererRecovery = bootSource.slice( + const rendererRecoveryStart = earlyWindowSource.indexOf('onRendererProcessGone: async'); + const rendererRecovery = earlyWindowSource.slice( rendererRecoveryStart, - bootSource.indexOf('resolveBrowserDialogParent =', rendererRecoveryStart), + earlyWindowSource.indexOf('mainWindowDelegates.resolveBrowserDialogParent =', rendererRecoveryStart), ); const defaultHostRecoveryStart = bootSource.indexOf( 'async function promptForDefaultRuntimeHostRecovery', @@ -152,12 +157,12 @@ test('lets the Runtime Host migrate its State Root before Desktop opens shared t }); test('routes the first-paint IPC only to the active Renderer recovery listener', () => { - const ipcHandlerStart = appIpcSource.indexOf( - "targetIpc.handle('window:notifyRendererReady'", + const ipcHandlerStart = earlyWindowSource.indexOf( + 'ipcMain.handle("window:notifyRendererReady"', ); - const ipcHandler = appIpcSource.slice( + const ipcHandler = earlyWindowSource.slice( ipcHandlerStart, - appIpcSource.indexOf("targetIpc.handle('window:setThemeSource'", ipcHandlerStart), + earlyWindowSource.indexOf('void quitCoordinator.focusOrCreateWindow()', ipcHandlerStart), ); const readyHandlerStart = mainWindowSource.indexOf( 'notifyRendererReady(sender, senderFrame)', diff --git a/apps/desktop/src/main/__tests__/startup-storage-repair.test.ts b/apps/desktop/src/main/__tests__/startup-storage-repair.test.ts index 66847495c1..e8241fbdd6 100644 --- a/apps/desktop/src/main/__tests__/startup-storage-repair.test.ts +++ b/apps/desktop/src/main/__tests__/startup-storage-repair.test.ts @@ -44,14 +44,18 @@ async function compile(name: string, asynchronous = false): Promise { const imports = parse(source, { sourceType: 'module', plugins: ['typescript'] }).program.body .filter((node) => node.type === 'ImportDeclaration'); const end = imports.at(-1)?.end ?? 0; - source = `${source.slice(0, end)}\nexport default async function() {\n${source.slice(end)}\n}`; + // The wrapped body keeps the module's own `export` keywords (early-window + // exports its products) — they are illegal inside the function wrapper and + // the sandbox reaches the bindings through the deps object anyway. + const body = source.slice(end).replace(/\bexport\s+(?=(?:async\s+)?(?:function|const|let|var|class)\b)/gu, ''); + source = `${source.slice(0, end)}\nexport default async function() {\n${body}\n}`; } return (await transform(source, { loader: 'ts', format: 'cjs', target: 'esnext', define: { 'import.meta': 'importMeta' }, })).code; } -const boot = await compile('runtime-host-boot', true); +const boot = await compile('early-window', true); const context = await compile('startup-context'); for (const accept of [false, true]) { diff --git a/apps/desktop/src/main/app-ipc-main.ts b/apps/desktop/src/main/app-ipc-main.ts index d2dc25ff9c..5893a4e1f1 100644 --- a/apps/desktop/src/main/app-ipc-main.ts +++ b/apps/desktop/src/main/app-ipc-main.ts @@ -76,9 +76,10 @@ export function registerAppClientIpc( targetIpc.handle('window:setTitlebarControlsVisible', (event, visible: unknown): void => { mainWindowController.setTitlebarControlsVisible(event.sender, visible); }); - targetIpc.handle('window:notifyRendererReady', (event): void => { - mainWindowController.notifyRendererReady(event.sender, event.senderFrame); - }); + // `window:notifyRendererReady` is registered directly on ipcMain by + // early-window.js: it is a window-lifecycle signal that must exist before + // this scoped router does, or the first React commit can outrun it. + targetIpc.handle('window:setThemeSource', (event, themePref: unknown): void => { mainWindowController.setThemeSource(event.sender, themePref); }); diff --git a/apps/desktop/src/main/boot-context.ts b/apps/desktop/src/main/boot-context.ts new file mode 100644 index 0000000000..e509aab139 --- /dev/null +++ b/apps/desktop/src/main/boot-context.ts @@ -0,0 +1,15 @@ +import type { DesktopDiagnosticsDeps } from './main-process-diagnostics.js'; +import type { RuntimeHostDesktopManager } from './runtime-host-desktop-manager.js'; +import type { DesktopTargetScope } from '../shared/runtime-host-identity.js'; + +// Cross-module late bindings between the early window path and the Runtime +// Host boot: the window is created while the heavy module graph is still +// evaluating, so pieces the window needs early (diagnostics, quit hooks) read +// the Host-side products through this holder once they exist. +export const bootContext: { + runtimeHostManager?: RuntimeHostDesktopManager; + activeRuntimeHostRef?: () => DesktopTargetScope | undefined; + resolveRuntimeHostDiagnostics?: DesktopDiagnosticsDeps['resolveRuntimeHost']; + prepareToQuit?: () => Promise<'ready' | 'cancelled'>; + cleanup?: () => Promise; +} = {}; diff --git a/apps/desktop/src/main/early-window.ts b/apps/desktop/src/main/early-window.ts new file mode 100644 index 0000000000..2f4008ec2b --- /dev/null +++ b/apps/desktop/src/main/early-window.ts @@ -0,0 +1,304 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// The light startup slice: everything the main window needs — storage root, +// settings, locale, diagnostics, the window controller — and nothing else. +// runtime-host-boot imports this module first so the window loads while the +// heavy Runtime Host module graph is still evaluating. + +import { join } from "node:path"; +import { + app, + type BrowserWindow, + clipboard, + ipcMain, + type MessageBoxOptions, + type MessageBoxReturnValue, + nativeTheme, +} from "electron"; +import { resolveSystemUiLocale } from "@maka/core/ui-locale"; +import { resolveStorageRoot } from "@maka/storage/root-authority"; +import { createSettingsStore } from "@maka/storage/settings-store"; +import { createAppQuitCoordinator } from "./app-quit-coordinator.js"; +import { bootContext } from "./boot-context.js"; +import { showBrowserMessageBox, type BrowserMessageBoxTheme } from "./browser-message-box.js"; +import { resolveBuildInfo } from "./build-info.js"; +import { createDesktopLocaleAuthority } from "./desktop-locale-authority.js"; +import { resolveE2eFixture, seedE2eFixture } from "./e2e-fixture.js"; +import { + captureDesktopDiagnosticEnvironment, + copyDesktopDiagnosticReport, + createDesktopMainRendererDiagnosticInput, + createDesktopStartupDiagnosticInput, + mainProcessLogBuffer, + runtimeHostProcessLogBuffer, + type DesktopDiagnosticsDeps, +} from "./main-process-diagnostics.js"; +import { createMainWindowController } from "./main-window.js"; +import { getNativeDiagnosticDialogCopy } from "./native-diagnostic-dialog-copy.js"; +import { + showMainRendererProcessGoneDialog, + showMessageBoxWithDiagnostics, +} from "./native-diagnostic-dialog.js"; +import { resolveShellEnv } from "./shell-env.js"; +import { revealMode } from "./startup-context.js"; +import { resolveDesktopStorageRoot } from "./storage-root-startup.js"; +import { startupStep } from "./startup-step.js"; +import { isDarkAppearance } from "./theme-source.js"; +import { desktopDiagnosticUpdateChannel } from "./app-update-attestation.js"; + +// The login-shell PATH probe spawns the user's interactive shell — a hundred +// milliseconds of .zshrc on a typical dev machine. Start it now but only +// await it where a child process is actually spawned; window creation and +// storage reads do not need it. +export const shellEnvReady = resolveShellEnv(); +export const buildInfo = resolveBuildInfo(app.isPackaged, app.getAppPath()); +export const userDataDir = app.getPath("userData"); + +export const e2eFixture = resolveDesktopE2eFixture(); +export const workspaceRoot = join( + userDataDir, + "workspaces", + e2eFixture?.workspaceName ?? "default", +); + +// Delegates the Runtime Host boot assigns once its services exist; the window +// controller reads them lazily so the window never waits on that wiring. +export const mainWindowDelegates = { + onMainWindowClose: (): void => {}, + onMainWindowClosed: (): void => {}, +}; + +export const desktopDiagnostics: DesktopDiagnosticsDeps = { + environment: () => + captureDesktopDiagnosticEnvironment({ + appVersion: app.getVersion(), + buildMode: buildInfo.mode, + updateChannel: desktopDiagnosticUpdateChannel({ + isPackaged: app.isPackaged, + appPath: app.getAppPath(), + }), + buildCommit: buildInfo.commit, + locale: app.getLocale(), + workspacePath: workspaceRoot, + }), + mainLogs: () => mainProcessLogBuffer.snapshot(), + runtimeHostProcessLogs: () => runtimeHostProcessLogBuffer.snapshot(), + runtimeHostConnections: () => bootContext.runtimeHostManager?.entries() ?? [], + resolveActiveRuntimeHost: () => { + const scope = bootContext.activeRuntimeHostRef?.(); + return scope ? bootContext.resolveRuntimeHostDiagnostics?.(scope) : undefined; + }, + resolveRuntimeHost: (scope) => { + const resolve = bootContext.resolveRuntimeHostDiagnostics; + if (!resolve) throw new Error("Desktop Runtime Host diagnostics are unavailable"); + return resolve(scope); + }, + writeClipboard: (report) => clipboard.writeText(report), +}; + +// The storage-root repair dialog can fire before settingsStore/desktopLocale +// exist, so both resolvers start at safe defaults and are rebound below once +// the settings-backed versions can actually run. +let resolveBrowserDialogParent = (): BrowserWindow | undefined => undefined; +let resolveBrowserDialogAppearance = async (): Promise => ({ + locale: resolveSystemUiLocale(app.getPreferredSystemLanguages()), + palette: "default", +}); + +export async function showDesktopMessageBox( + options: MessageBoxOptions, + override?: Partial, +): Promise { + const appearance = { + ...(await resolveBrowserDialogAppearance()), + ...override, + revealMode, + }; + return showBrowserMessageBox(options, resolveBrowserDialogParent(), appearance); +} + +export function showStartupDiagnosticDialog( + options: MessageBoxOptions, + locale: ReturnType, + diagnosticDetails = options.detail, +): Promise { + return showMessageBoxWithDiagnostics(options, { + locale, + showMessageBox: (nextOptions) => showDesktopMessageBox(nextOptions, { locale }), + copyDiagnostics: () => + copyDesktopDiagnosticReport( + desktopDiagnostics, + createDesktopStartupDiagnosticInput({ + title: options.title || options.message, + description: options.message, + ...(diagnosticDetails ? { details: diagnosticDetails } : {}), + }), + ), + }); +} + +if (e2eFixture) { + console.log( + `[e2e-fixture] scenario=${e2eFixture.scenario} workspace=${workspaceRoot}`, + ); + await seedE2eFixture({ workspaceRoot, fixture: e2eFixture }); +} +const resolvedLocalStorageRoot = await (e2eFixture + ? resolveStorageRoot({ path: workspaceRoot, kind: "interactive" }) + : startupStep( + "storage root", + resolveDesktopStorageRoot(workspaceRoot, { + confirmRepair: () => confirmDesktopStorageRootRepair(workspaceRoot), + }), + )); +if (!resolvedLocalStorageRoot) { + app.quit(); + await new Promise(() => {}); + throw new Error("Desktop storage root resolution did not complete"); +} +export const startupLocalStorageRoot = resolvedLocalStorageRoot; +export const settingsStore = createSettingsStore(workspaceRoot); +export const desktopLocale = createDesktopLocaleAuthority({ + readSettings: () => settingsStore.get(), + preferredSystemLanguages: () => app.getPreferredSystemLanguages(), +}); +resolveBrowserDialogAppearance = async () => { + try { + const settings = await settingsStore.get(); + return { + locale: desktopLocale.observe(settings), + palette: settings.appearance.palette, + dark: isDarkAppearance( + e2eFixture?.theme ?? settings.appearance.theme, + nativeTheme.shouldUseDarkColors, + ), + }; + } catch { + return { locale: desktopLocale.current(), palette: "default" }; + } +}; + +export const mainWindowController = createMainWindowController({ + workspaceRoot, + e2eFixture, + settingsStore, + revealMode, + onClose: () => mainWindowDelegates.onMainWindowClose(), + onClosed: () => mainWindowDelegates.onMainWindowClosed(), + onRendererProcessGone: async (details) => { + const diagnosticInput = createDesktopMainRendererDiagnosticInput({ + title: "Maka main Renderer process exited unexpectedly", + description: `Reason: ${details.reason}`, + details: `Exit code: ${details.exitCode}`, + }); + for (;;) { + const locale = await desktopLocale.resolve(); + const decision = await showMainRendererProcessGoneDialog({ + locale, + copyDiagnostics: () => + copyDesktopDiagnosticReport(desktopDiagnostics, diagnosticInput), + // showBrowserMessageBox attaches only to a visible, non-minimized + // parent. A pre-first-paint crash therefore gets a standalone window. + showMessageBox: (options) => showDesktopMessageBox(options, { locale }), + }); + if (decision !== "recover") break; + if (await mainWindowController.reloadMainRenderer()) return; + if (!mainWindowController.browserWindow()) break; + } + app.quit(); + }, +}); +resolveBrowserDialogParent = () => { + const main = mainWindowController.browserWindow(); + return main?.isVisible() ? main : undefined; +}; + +export const quitCoordinator = createAppQuitCoordinator({ + // Until the Runtime Host boot wires its quit hooks there is nothing to + // retire — a quit in that gap proceeds straight to cleanup. + prepareToQuit: () => bootContext.prepareToQuit?.() ?? Promise.resolve("ready" as const), + cleanup: () => bootContext.cleanup?.() ?? Promise.resolve(), + focusOrCreateWindow: (signal) => { + if (mainWindowController.hasOpenWindows()) mainWindowController.focus(); + else return mainWindowController.createWindow(signal); + }, + onPreparationError: (error) => { + console.error("[runtime-host] quit retirement failed:", error); + }, + onCleanupError: (error) => + console.error("[runtime-host] shutdown failed:", error), + onWindowCreationError: (error) => + console.error("[window] creation failed:", error), + resumeQuit: () => app.quit(), +}); +app.on("before-quit", quitCoordinator.handleBeforeQuit); +// Renderer-ready is a window-lifecycle signal, not a Runtime Host scope: it +// must be handled before the scoped IPC router exists, or the first React +// commit could race ahead of target wiring and leave the window hidden. +ipcMain.handle("window:notifyRendererReady", (event): void => { + mainWindowController.notifyRendererReady(event.sender, event.senderFrame); +}); +// The window loads the renderer while the Runtime Host services assemble; +// `ready-to-show` reveals the loading surface on the first painted frame. +void quitCoordinator.focusOrCreateWindow(); + +async function confirmDesktopStorageRootRepair( + workspaceRoot: string, +): Promise { + console.log( + "[storage-root] root-identity conflict; parking at repair dialog", + ); + const locale = resolveSystemUiLocale(app.getPreferredSystemLanguages()); + const copy = getNativeDiagnosticDialogCopy(locale).storageRootRepair; + const { response } = await showStartupDiagnosticDialog( + { + type: "warning", + title: copy.title, + message: copy.message, + detail: copy.detail(workspaceRoot), + buttons: [copy.repair, copy.exit], + defaultId: 1, + cancelId: 1, + noLink: true, + }, + locale, + ); + return response === 0; +} + +function resolveDesktopE2eFixture(): ReturnType { + try { + return resolveE2eFixture( + process.env.MAKA_E2E_FIXTURE, + app.isPackaged, + process.env.MAKA_E2E_FIXTURE_REDUCED_MOTION, + process.env.MAKA_E2E_FIXTURE_THEME, + process.env.MAKA_E2E_FIXTURE_LOCALE, + process.env.MAKA_E2E_FIXTURE_TIMEZONE, + process.env.MAKA_E2E_FIXTURE_PLATFORM, + ); + } catch (error) { + if (!process.env.MAKA_E2E_FIXTURE) throw error; + console.error( + `[e2e-fixture] fatal: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); + } +} diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 110bc4c3d5..a8944cb453 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -197,9 +197,13 @@ if (!app.requestSingleInstanceLock()) { // store/db write". app .whenReady() - .then(() => { + .then(async () => { console.log('[startup] app ready'); installDesktopStartupBranding(revealMode); + // early-window holds the light slice (storage root, settings, window + // controller) and fires the renderer load; the heavy Runtime Host + // module graph evaluates while the window is already loading. + await import('./early-window.js'); return import('./runtime-host-boot.js'); }) .catch(async (error: unknown) => { diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 5fc52bb2db..3851e2f715 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -30,8 +30,6 @@ import { powerSaveBlocker, shell, Tray, - type MessageBoxOptions, - type MessageBoxReturnValue, } from "electron"; import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; @@ -39,7 +37,6 @@ import { join } from "node:path"; import { type ConnectionEvent } from '@maka/core/connections'; import { type SessionChangedEvent, type SessionChangedReason } from '@maka/core/session'; import { isBotDeliveryProvider } from '@maka/core/bot-chat-settings'; -import { resolveSystemUiLocale } from '@maka/core/ui-locale'; import { PROVIDER_REGISTRY, providerAuthRequiresSecret, @@ -77,8 +74,6 @@ import { createWorkBoardStore } from "@maka/storage/work-board-store"; import { normalizeWorkBoardLinkedSession } from "@maka/core/work-board"; import { createFileCredentialStore } from "@maka/storage/credential-store"; import { createMcpConfigStore } from "@maka/storage/mcp-config-store"; -import { createSettingsStore } from "@maka/storage/settings-store"; -import { resolveStorageRoot } from "@maka/storage/root-authority"; import { createMcpOAuthController } from "./mcp-oauth-controller.js"; import { CommandCodeBrowserLoginController } from "./commandcode-browser-login.js"; @@ -89,7 +84,23 @@ import { createWorkHubRuntime } from './workhub-runtime.js'; import { createWindowsAppTray } from './windows-app-tray.js'; import { readableAppIconPath } from './app-icon-surface.js'; import { registerAppClientIpc, registerAppIpc } from "./app-ipc-main.js"; -import { createAppQuitCoordinator } from "./app-quit-coordinator.js"; +import { bootContext } from "./boot-context.js"; +import { + buildInfo, + desktopDiagnostics, + desktopLocale, + e2eFixture, + mainWindowController, + mainWindowDelegates, + quitCoordinator, + settingsStore, + shellEnvReady, + showDesktopMessageBox, + showStartupDiagnosticDialog, + startupLocalStorageRoot, + userDataDir, + workspaceRoot, +} from "./early-window.js"; import { desktopDiagnosticUpdateChannel, desktopUpdateChannelFromManifest, @@ -107,51 +118,29 @@ import { browserViewHost } from "./browser/browser-host.js"; import { releaseBrowserSession } from "./browser/session.js"; import { isBrowserMessageBoxPresentationActive, - showBrowserMessageBox, - type BrowserMessageBoxTheme, } from "./browser-message-box.js"; import { createE2eFixtureBotOnboardingAdapters } from "./bot-onboarding-e2e-fixture.js"; -import { resolveBuildInfo } from "./build-info.js"; import { computerUseServiceHealth } from "./computer-use-host.js"; import { registerDesktopDiagnosticsIpc } from "./desktop-diagnostics-ipc-main.js"; import { assembleDesktopNativeCapabilities } from "./desktop-native-capability-assembly.js"; import { clientSettingsConfirmation } from "./client-settings-confirmation-copy.js"; import { nativeFileDialogCopy } from "./native-file-dialog-copy.js"; -import { createDesktopLocaleAuthority } from "./desktop-locale-authority.js"; import { buildRiveWorkflowTool } from "./rive-workflow-tool.js"; import { applyAppIcon } from "./app-icon-surface.js"; import { registerAppIconIpc } from "./app-icon-ipc.js"; import { listAppIconPreviews } from "./app-icon-surface.js"; import { importCustomAppIcon } from "./custom-app-icons.js"; import { installDesktopShellPresentation } from "./desktop-shell-presentation.js"; -import { - resolveE2eFixture, - seedE2eFixture, -} from "./e2e-fixture.js"; import { PARTIAL_HISTORY_TRANSCRIPT_BYTES } from "./e2e-fixture/seed-helpers.js"; import { createKeepSystemAwakeController } from "./keep-system-awake.js"; -import { isDarkAppearance } from "./theme-source.js"; import { readWithFallback, type ReconnectableReadIpcMain, } from "./ipc-reconnect-policy.js"; -import { createMainWindowController } from "./main-window.js"; import type { DesktopRuntimeHostIdentity } from "../preload/bridge-contract.js"; -import { - captureDesktopDiagnosticEnvironment, - copyDesktopDiagnosticReport, - createDesktopMainRendererDiagnosticInput, - createDesktopStartupDiagnosticInput, - mainProcessLogBuffer, - runtimeHostProcessLogBuffer, - type DesktopDiagnosticsDeps, -} from "./main-process-diagnostics.js"; import { defaultRuntimeHostRecoveryDialog, - showMainRendererProcessGoneDialog, - showMessageBoxWithDiagnostics, } from "./native-diagnostic-dialog.js"; -import { getNativeDiagnosticDialogCopy } from "./native-diagnostic-dialog-copy.js"; import { resolveDesktopSessionWorkspace, } from "./new-session-project.js"; @@ -258,7 +247,6 @@ import { import { registerRuntimeHostSkillsIpc } from "./runtime-host-skills-ipc-main.js"; import { registerRuntimeHostUsageIpc } from "./runtime-host-usage-ipc-main.js"; import { registerRuntimeHostWorkspaceIpc } from "./runtime-host-workspace-ipc-main.js"; -import { resolveShellEnv } from "./shell-env.js"; import { registerSettingsBotsIpc, type SettingsBotsIpcHandle, @@ -269,8 +257,6 @@ import { isIsolatedE2e, revealMode, } from "./startup-context.js"; -import { resolveDesktopStorageRoot } from "./storage-root-startup.js"; -import { startupStep } from "./startup-step.js"; import { registerWorkspaceSearchIpc } from "./workspace-search-ipc-main.js"; import { parseDesktopSessionResourceKey, @@ -278,11 +264,11 @@ import { type DesktopTargetScope, } from "../shared/runtime-host-identity.js"; -await resolveShellEnv(); - const MANAGED_UPDATE_RECONNECT_TIMEOUT_MS = 10_000; -const buildInfo = resolveBuildInfo(app.isPackaged, app.getAppPath()); -const userDataDir = app.getPath("userData"); +bootContext.prepareToQuit = prepareRuntimeHostDesktopQuit; +bootContext.cleanup = closeRuntimeHostDesktop; +bootContext.activeRuntimeHostRef = activeRuntimeHostRef; +bootContext.resolveRuntimeHostDiagnostics = resolveRuntimeHostDiagnostics; const runtimeHostPeerConfiguration = await configureDesktopRuntimeHostPeerClient({ isPackaged: app.isPackaged, enableDevelopmentPeer: process.argv.includes('--runtime-host-peer'), @@ -329,19 +315,19 @@ if (runtimeHostPeerConfiguration) { } } const runtimeHostDirectPeerAvailable = runtimeHostPeerClient !== undefined; -const runtimeHostClientInstanceId = await loadOrCreateRuntimeHostClientInstanceId( - join(userDataDir, "runtime-host-client.json"), -); const runtimeHostCandidateLaunchBarrier = createRuntimeHostCandidateLaunchBarrier(); const runtimeHostCredentialStore = createClientRuntimeHostCredentialStore(userDataDir); const runtimeHostProfileCatalog = createClientRuntimeHostProfileCatalog( userDataDir, runtimeHostCredentialStore, ); -const runtimeHostStartup = await resolveDesktopRuntimeHostStartup(userDataDir, { - catalog: runtimeHostProfileCatalog, - credentialStore: runtimeHostCredentialStore, -}); +const [runtimeHostClientInstanceId, runtimeHostStartup] = await Promise.all([ + loadOrCreateRuntimeHostClientInstanceId(join(userDataDir, "runtime-host-client.json")), + resolveDesktopRuntimeHostStartup(userDataDir, { + catalog: runtimeHostProfileCatalog, + credentialStore: runtimeHostCredentialStore, + }), +]); let runtimeHostManager: RuntimeHostDesktopManager | undefined; function activeRuntimeHostRef(): DesktopTargetScope | undefined { const current = runtimeHostManager?.current(); @@ -350,111 +336,7 @@ function activeRuntimeHostRef(): DesktopTargetScope | undefined { : undefined; } const runtimeHostGeneration = app.isPackaged ? app.getVersion() : randomUUID(); -const e2eFixture = resolveDesktopE2eFixture(); const useBotOnboardingFixture = e2eFixture?.scenario === "settings-bots-onboarding"; -const workspaceRoot = join( - userDataDir, - "workspaces", - e2eFixture?.workspaceName ?? "default", -); -const desktopDiagnostics: DesktopDiagnosticsDeps = { - environment: () => - captureDesktopDiagnosticEnvironment({ - appVersion: app.getVersion(), - buildMode: buildInfo.mode, - updateChannel: desktopDiagnosticUpdateChannel({ - isPackaged: app.isPackaged, - appPath: app.getAppPath(), - }), - buildCommit: buildInfo.commit, - locale: app.getLocale(), - workspacePath: workspaceRoot, - }), - mainLogs: () => mainProcessLogBuffer.snapshot(), - runtimeHostProcessLogs: () => runtimeHostProcessLogBuffer.snapshot(), - runtimeHostConnections: () => runtimeHostManager?.entries() ?? [], - resolveActiveRuntimeHost: () => { - const scope = activeRuntimeHostRef(); - return scope ? resolveRuntimeHostDiagnostics(scope) : undefined; - }, - resolveRuntimeHost: resolveRuntimeHostDiagnostics, - writeClipboard: (report) => clipboard.writeText(report), -}; -let resolveBrowserDialogParent = (): BrowserWindow | undefined => undefined; -let resolveBrowserDialogAppearance = async (): Promise => ({ - locale: resolveSystemUiLocale(app.getPreferredSystemLanguages()), - palette: "default", -}); - -async function showDesktopMessageBox( - options: MessageBoxOptions, - override?: Partial, -): Promise { - const appearance = { ...(await resolveBrowserDialogAppearance()), ...override, revealMode }; - return showBrowserMessageBox(options, resolveBrowserDialogParent(), appearance); -} - -function showStartupDiagnosticDialog( - options: MessageBoxOptions, - locale: ReturnType, - diagnosticDetails = options.detail, -): Promise { - return showMessageBoxWithDiagnostics(options, { - locale, - showMessageBox: (nextOptions) => showDesktopMessageBox(nextOptions, { locale }), - copyDiagnostics: () => - copyDesktopDiagnosticReport( - desktopDiagnostics, - createDesktopStartupDiagnosticInput({ - title: options.title || options.message, - description: options.message, - ...(diagnosticDetails ? { details: diagnosticDetails } : {}), - }), - ), - }); -} -if (e2eFixture) { - console.log( - `[e2e-fixture] scenario=${e2eFixture.scenario} workspace=${workspaceRoot}`, - ); - await seedE2eFixture({ workspaceRoot, fixture: e2eFixture }); -} -const resolveLocalStorageRoot = () => - e2eFixture - ? resolveStorageRoot({ path: workspaceRoot, kind: "interactive" }) - : startupStep( - "storage root", - resolveDesktopStorageRoot(workspaceRoot, { - confirmRepair: () => confirmDesktopStorageRootRepair(workspaceRoot), - }), - ); -const startupLocalStorageRoot = - await resolveLocalStorageRoot(); -if (!startupLocalStorageRoot) { - app.quit(); - await new Promise(() => {}); - throw new Error("Desktop storage root resolution did not complete"); -} -const settingsStore = createSettingsStore(workspaceRoot); -const desktopLocale = createDesktopLocaleAuthority({ - readSettings: () => settingsStore.get(), - preferredSystemLanguages: () => app.getPreferredSystemLanguages(), -}); -resolveBrowserDialogAppearance = async () => { - try { - const settings = await settingsStore.get(); - return { - locale: desktopLocale.observe(settings), - palette: settings.appearance.palette, - dark: isDarkAppearance( - e2eFixture?.theme ?? settings.appearance.theme, - nativeTheme.shouldUseDarkColors, - ), - }; - } catch { - return { locale: desktopLocale.current(), palette: "default" }; - } -}; const mcpConfigStore = createMcpConfigStore(workspaceRoot); const mcpManager = new McpClientManager({ clientName: "maka-desktop", @@ -480,9 +362,9 @@ const mcpOAuthController = createMcpOAuthController({ let mcpStartup: Promise | undefined; function ensureMcpReady(): Promise { if (!mcpStartup) { - const startup = mcpConfigStore - .get() - .then((config) => mcpManager.sync(config)); + const startup = shellEnvReady.then(() => + mcpConfigStore.get().then((config) => mcpManager.sync(config)), + ); mcpStartup = startup; void startup.catch(() => { if (mcpStartup === startup) mcpStartup = undefined; @@ -491,42 +373,6 @@ function ensureMcpReady(): Promise { return mcpStartup; } const keepSystemAwake = createKeepSystemAwakeController(powerSaveBlocker); -let onMainWindowClose = (): void => {}; -let onMainWindowClosed = (): void => {}; -const mainWindowController = createMainWindowController({ - workspaceRoot, - e2eFixture, - settingsStore, - revealMode, - onClose: () => onMainWindowClose(), - onClosed: () => onMainWindowClosed(), - onRendererProcessGone: async (details) => { - const diagnosticInput = createDesktopMainRendererDiagnosticInput({ - title: "Maka main Renderer process exited unexpectedly", - description: `Reason: ${details.reason}`, - details: `Exit code: ${details.exitCode}`, - }); - for (;;) { - const locale = await desktopLocale.resolve(); - const decision = await showMainRendererProcessGoneDialog({ - locale, - copyDiagnostics: () => - copyDesktopDiagnosticReport(desktopDiagnostics, diagnosticInput), - // showBrowserMessageBox attaches only to a visible, non-minimized - // parent. A pre-first-paint crash therefore gets a standalone window. - showMessageBox: (options) => showDesktopMessageBox(options, { locale }), - }); - if (decision !== "recover") break; - if (await mainWindowController.reloadMainRenderer()) return; - if (!mainWindowController.browserWindow()) break; - } - app.quit(); - }, -}); -resolveBrowserDialogParent = () => { - const main = mainWindowController.browserWindow(); - return main?.isVisible() ? main : undefined; -}; const runtimeHostSshTerminal = createDesktopRuntimeHostSshTerminal({ ipcMain, send: (channel, event) => mainWindowController.send(channel, event), @@ -574,7 +420,7 @@ const releaseDesktopInteractionSession = (sessionId: string): void => { const permissionOverlay = createPermissionOverlayMain({ resolveLocale: () => desktopLocale.resolve(), }); -onMainWindowClose = () => { +mainWindowDelegates.onMainWindowClose = () => { native.computerUseOverlay.destroyAll(); native.computerUsePip.destroyAll(); }; @@ -952,7 +798,7 @@ const windowsAppTray = createWindowsAppTray({ quit: () => app.quit(), onError: (error) => console.error('[tray]', error), }); -onMainWindowClosed = () => { +mainWindowDelegates.onMainWindowClosed = () => { // A hidden WorkHub host window can keep window-all-closed from firing. // Without a tray, use the existing quit flow; cancelling it restores Maka. if (process.platform !== 'darwin' && !windowsAppTray.hasTray()) app.quit(); @@ -1416,34 +1262,18 @@ let workBoardIpc: ReturnType | undefined; let runtimeHostDesktopShutdown: Promise | undefined; // The quit coordinator owns cleanup for every later stage, including a Host // handoff cancelled while the main window is still loading. -const quitCoordinator = createAppQuitCoordinator({ - prepareToQuit: prepareRuntimeHostDesktopQuit, - cleanup: closeRuntimeHostDesktop, - focusOrCreateWindow: (signal) => { - if (!runtimeHostManager) return; - if (mainWindowController.hasOpenWindows()) mainWindowController.focus(); - else return mainWindowController.createWindow(signal); - }, - onPreparationError: (error) => { - console.error("[runtime-host] quit retirement failed:", error); - }, - onCleanupError: (error) => - console.error("[runtime-host] shutdown failed:", error), - onWindowCreationError: (error) => - console.error("[window] creation failed:", error), - resumeQuit: () => app.quit(), -}); -app.on("before-quit", quitCoordinator.handleBeforeQuit); // The manager registers its IPC router on construction; starting the Local // Host is a background reconciliation, not a prerequisite for the window. runtimeHostManager = createLocalRuntimeHostManager(); +bootContext.runtimeHostManager = runtimeHostManager; runtimeHostManager.setDefaultProfile(runtimeHostStartup.preferences.defaultProfileId); wireLifecycle(); sessionLocal.wake(); windowsAppTray.start(); // Remote profiles do not depend on the Local Host: a handoff parked on a -// user decision must not hold their activation for the whole session. -void runtimeHostProfileService.startEnabledProfiles(); +// user decision must not hold their activation for the whole session. Remote +// transports spawn SSH/relay children, so they still wait on the shell PATH. +void shellEnvReady.then(() => runtimeHostProfileService.startEnabledProfiles()); // Runtime Host is the only schema-migration authority for its State Root. // Work Board remains a Desktop-owned table, but it opens only while a ready // Host has verified the schema — including a Local Host that only becomes @@ -1484,6 +1314,7 @@ const registerDesktopWorkBoard = (): void => { } }; void (async () => { + await shellEnvReady; await runtimeHostManager?.start(); registerDesktopWorkBoard(); await guestSessionMountService.start().catch((error: unknown) => { @@ -2246,50 +2077,6 @@ function wakePeerRecoveryAfterResume(): void { runtimeHostManager?.wakePeerRecovery(); } -function resolveDesktopE2eFixture(): ReturnType { - try { - return resolveE2eFixture( - process.env.MAKA_E2E_FIXTURE, - app.isPackaged, - process.env.MAKA_E2E_FIXTURE_REDUCED_MOTION, - process.env.MAKA_E2E_FIXTURE_THEME, - process.env.MAKA_E2E_FIXTURE_LOCALE, - process.env.MAKA_E2E_FIXTURE_TIMEZONE, - process.env.MAKA_E2E_FIXTURE_PLATFORM, - ); - } catch (error) { - if (!process.env.MAKA_E2E_FIXTURE) throw error; - console.error( - `[e2e-fixture] fatal: ${error instanceof Error ? error.message : String(error)}`, - ); - process.exit(1); - } -} - -async function confirmDesktopStorageRootRepair( - workspaceRoot: string, -): Promise { - console.log( - "[storage-root] root-identity conflict; parking at repair dialog", - ); - const locale = resolveSystemUiLocale(app.getPreferredSystemLanguages()); - const copy = getNativeDiagnosticDialogCopy(locale).storageRootRepair; - const { response } = await showStartupDiagnosticDialog( - { - type: "warning", - title: copy.title, - message: copy.message, - detail: copy.detail(workspaceRoot), - buttons: [copy.repair, copy.exit], - defaultId: 1, - cancelId: 1, - noLink: true, - }, - locale, - ); - return response === 0; -} - async function promptForDefaultRuntimeHostRecovery(input: { readonly profileId: string; readonly profileName: string; From 096da6e3997eb57162e170f171bdf74bd0a36742 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 19 Sep 2026 12:25:54 +0800 Subject: [PATCH 21/44] feat(desktop): show the window at construction in active runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit deferred the Runtime Host module graph until the window existed, but the window still stayed hidden until ready-to-show: the user-visible surface arrived at ~500ms while the OS window itself could have been on screen ~160ms earlier with its theme-matched backgroundColor reading as a launch surface. - `show: revealMode === 'active'` — active runs display the native window at construction; the persisted appearance still picks the right backgroundColor so the first visible frame is theme-correct. Hidden/inactive e2e modes keep show:false and the reveal gate. - Saved-bounds/mkdir/appearance reads run in parallel ahead of the constructor; serialized they cost ~200ms of prelude. - A maximized session restores via maximize() directly in active mode (the window is already shown, so the reveal-gate deferral no longer applies); hidden/inactive runs still defer to markReady. - The firstWindowConstructed boundary moves from construction to the native 'show' event: the heavy module graph may evaluate once the window is on screen without starving the display path, and the launch-settle fallback keeps hidden/failed runs unblocked. Generated-by: Devin --- .../__tests__/main-startup-lifetime.test.ts | 6 +-- apps/desktop/src/main/early-window.ts | 21 +++++++- apps/desktop/src/main/main-window.ts | 52 ++++++++++--------- apps/desktop/src/main/main.ts | 7 ++- 4 files changed, 55 insertions(+), 31 deletions(-) diff --git a/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts b/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts index 6090526c88..83ad0528d3 100644 --- a/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts +++ b/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts @@ -104,7 +104,7 @@ test('creates the main window before starting Local Host reconciliation', () => const hostStart = bootSource.indexOf('await runtimeHostManager?.start()', managerCreate); assert.ok(managerCreate >= 0); assert.ok(lifecycleWire > managerCreate && hostStart > lifecycleWire); - assert.match(earlyWindowSource, /void quitCoordinator\.focusOrCreateWindow\(\)/u); + assert.match(earlyWindowSource, /quitCoordinator\.focusOrCreateWindow\(\)/u); assert.doesNotMatch(mainSource, /startup-presentation/u); }); @@ -112,7 +112,7 @@ test('resolves persisted locale before first post-settings recovery prompt', () const rendererRecoveryStart = earlyWindowSource.indexOf('onRendererProcessGone: async'); const rendererRecovery = earlyWindowSource.slice( rendererRecoveryStart, - earlyWindowSource.indexOf('mainWindowDelegates.resolveBrowserDialogParent =', rendererRecoveryStart), + earlyWindowSource.indexOf('resolveBrowserDialogParent = () =>', rendererRecoveryStart), ); const defaultHostRecoveryStart = bootSource.indexOf( 'async function promptForDefaultRuntimeHostRecovery', @@ -162,7 +162,7 @@ test('routes the first-paint IPC only to the active Renderer recovery listener', ); const ipcHandler = earlyWindowSource.slice( ipcHandlerStart, - earlyWindowSource.indexOf('void quitCoordinator.focusOrCreateWindow()', ipcHandlerStart), + earlyWindowSource.indexOf('const firstWindowLaunch = quitCoordinator.focusOrCreateWindow()', ipcHandlerStart), ); const readyHandlerStart = mainWindowSource.indexOf( 'notifyRendererReady(sender, senderFrame)', diff --git a/apps/desktop/src/main/early-window.ts b/apps/desktop/src/main/early-window.ts index 2f4008ec2b..53e78abefa 100644 --- a/apps/desktop/src/main/early-window.ts +++ b/apps/desktop/src/main/early-window.ts @@ -195,11 +195,29 @@ resolveBrowserDialogAppearance = async () => { } }; +// Resolves when the first window's BrowserWindow exists — main.ts holds the +// heavy Runtime Host module graph until then so its evaluation cannot starve +// the window's async prelude. Also resolves if creation settles without a +// window (abort/failure), so the Host boot is never held hostage by it. +let resolveFirstWindowConstructed!: () => void; +export const firstWindowConstructed = new Promise((resolve) => { + resolveFirstWindowConstructed = resolve; +}); + export const mainWindowController = createMainWindowController({ workspaceRoot, e2eFixture, settingsStore, revealMode, + onWindowConstructed: () => { + // Active runs show at construction: 'show' is the moment the native window + // is on screen — that is the point after which the Runtime Host module + // graph may evaluate without starving the display itself. Hidden/inactive + // windows never emit it, so those runs resolve at construction instead. + const win = mainWindowController.browserWindow(); + if (revealMode === 'active' && win) win.once('show', resolveFirstWindowConstructed); + else resolveFirstWindowConstructed(); + }, onClose: () => mainWindowDelegates.onMainWindowClose(), onClosed: () => mainWindowDelegates.onMainWindowClosed(), onRendererProcessGone: async (details) => { @@ -257,7 +275,8 @@ ipcMain.handle("window:notifyRendererReady", (event): void => { }); // The window loads the renderer while the Runtime Host services assemble; // `ready-to-show` reveals the loading surface on the first painted frame. -void quitCoordinator.focusOrCreateWindow(); +const firstWindowLaunch = quitCoordinator.focusOrCreateWindow(); +void firstWindowLaunch.then(resolveFirstWindowConstructed, resolveFirstWindowConstructed); async function confirmDesktopStorageRootRepair( workspaceRoot: string, diff --git a/apps/desktop/src/main/main-window.ts b/apps/desktop/src/main/main-window.ts index 88eda08c0b..b8e36b3f01 100644 --- a/apps/desktop/src/main/main-window.ts +++ b/apps/desktop/src/main/main-window.ts @@ -108,6 +108,10 @@ interface MainWindowControllerDeps { onClose?: () => void; onClosed?: () => void; onRendererProcessGone: (details: Electron.RenderProcessGoneDetails) => void | Promise; + // Fires right after `new BrowserWindow` — main.ts defers the heavy Runtime + // Host module graph until this point so its evaluation cannot starve the + // window's async prelude (mkdir/bounds/settings) on the shared main thread. + onWindowConstructed?: () => void; } let mainWindow: BrowserWindow | null = null; @@ -298,28 +302,23 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main async function createWindow(signal: AbortSignal): Promise { if (signal.aborted) return; - await mkdir(workspaceRoot, { recursive: true }); // Restore previously-saved bounds when available; first launch and // legacy installs both fall back to the default 1240x820 frame. After // load, validate the saved x/y against the current display layout — if // the previous external monitor is gone, drop x/y so Electron centers // the window on the primary display instead of opening it off-screen. const defaults = e2eFixtureWindowBounds(e2eFixture, { width: 1240, height: 820 }); - const savedBounds = e2eFixture - ? defaults - : await readSavedBounds(workspaceRoot, defaults); + // mkdir, saved-bounds and the persisted appearance are independent reads — + // serialized they cost ~200ms ahead of the window constructor, so run them + // together. The FOUC fix below needs the appearance to pick the right + // backgroundColor (PR103 / PR-IR-01b: e2e-fixture theme wins over the + // persisted pref), which is why it cannot leave the critical path. + const [, savedBounds, persistedAppearance] = await Promise.all([ + mkdir(workspaceRoot, { recursive: true }), + e2eFixture ? Promise.resolve(defaults) : readSavedBounds(workspaceRoot, defaults), + settingsStore.get().then((settings) => settings.appearance), + ]); const bounds = clampBoundsToVisibleDisplay(savedBounds); - - // @kenji PR103 follow-up: complete the FOUC fix at the window-chrome layer. - // The renderer applies `.dark` synchronously before React mounts (PR103), - // but the BrowserWindow's `backgroundColor` shows during the first frame - // before the renderer paints. Pick the right initial bg by reading the - // persisted theme + system preference. - // PR-IR-01b: e2e-fixture theme override wins over the persisted user - // pref. This guarantees the BrowserWindow backgroundColor matches the - // theme variant we're about to screenshot, so the very first frame - // doesn't capture a light-on-dark or dark-on-light flash. - const persistedAppearance = (await settingsStore.get()).appearance; const persistedTheme = persistedAppearance?.theme ?? 'auto'; // Quit cleanup permanently closes process-scoped stores. Re-check after // asynchronous preparation so an in-flight request cannot attach a new @@ -414,12 +413,12 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main // drift apart (locked by app-region-hygiene-contract.test.ts). minHeight: SAFE_MIN_HEIGHT, backgroundColor: initialBg, - // PR-SHOW-AFTER-FIRST-COMMIT: create hidden on every run so the OS never - // shows an unpainted window; `ready-to-show` reveals it on the first - // painted frame (the `.maka-preload` loading surface), and the reveal - // gate (showWindowOnceReady) routes that plus the renderer-ready IPC, - // the fallback timer, and deferred focus/maximize through the mode. - show: false, + // Active runs show the native window immediately: the theme-matched + // backgroundColor reads as a launch surface while the skeleton paints + // (~200ms earlier than waiting for `ready-to-show`). E2E modes stay + // hidden so the reveal gate keeps its inactive/hidden semantics; + // `ready-to-show` still marks ready to flush deferred focus/maximize. + show: revealMode === 'active', // Native sidebar vibrancy lets the CSS-side sidebar render // transparent and inherit the system's blurred window material // (Big Sur+). Renderer CSS gates the transparency on @@ -444,6 +443,7 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main }); mainWindowShutdownSignal = signal; observeRendererProcess(mainWindow, signal); + deps.onWindowConstructed?.(); // The designed `.maka-preload` surface is the loading UI: reveal on the // first painted frame instead of waiting out the whole React mount. // markReady is mode-suppressed (hidden/inactive) and idempotent, so the @@ -513,11 +513,13 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main // Restore maximized state after construction (BrowserWindow constructor // doesn't accept it directly). ChatGPT Pro review P2 (round 2): a direct - // maximize() here reveals the still-hidden window (verified on macOS), - // bypassing the reveal gate — defer it so markReady applies it right - // before the reveal and the first visible frame is already maximized. + // maximize() reveals a still-hidden window (verified on macOS), so hidden + // and inactive runs defer it to markReady and the first visible frame is + // already maximized. An active run is shown at construction, so it + // maximizes now — the window appears already animating to full size. if (bounds.isMaximized) { - revealGate.requestMaximize(mainWindow); + if (revealMode === 'active') mainWindow.maximize(); + else revealGate.requestMaximize(mainWindow); } // Persist bounds across launches. Debounce so a continuous resize drag diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index a8944cb453..df12e976ef 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -202,8 +202,11 @@ if (!app.requestSingleInstanceLock()) { installDesktopStartupBranding(revealMode); // early-window holds the light slice (storage root, settings, window // controller) and fires the renderer load; the heavy Runtime Host - // module graph evaluates while the window is already loading. - await import('./early-window.js'); + // module graph starts only once the window exists — evaluating ~1100 + // files on the shared main thread would otherwise starve the window's + // async prelude and Chromium plumbing. + const earlyWindow = await import('./early-window.js'); + await earlyWindow.firstWindowConstructed; return import('./runtime-host-boot.js'); }) .catch(async (error: unknown) => { From 8a1d55cde822473b0e76fbe64f86f84a9dc7cbb1 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 19 Sep 2026 23:18:54 +0800 Subject: [PATCH 22/44] feat(desktop): replace the preload skeleton with a brand surface The fake app-frame shimmer (a card with two gradient bars) read as a broken half-rendered UI rather than a launch screen. Codex's cold start shows the better pattern: a theme-matched window with a centered mark. - `.maka-preload` becomes the traced wordmark (the same MAKA_WORDMARK_PATH the hero and dock icon use) at --maka-brand on the theme background, with a slow opacity breath and a reduced-motion opt-out. - `body` gets the hardcoded theme background (#ffffff / #1c1d21) so the native window's `backgroundColor` hands off to the first frame with no colour step. - The renderer entry contract allowlists the three presentational tags the inline mark needs (svg/g/path); navigation and execution vectors stay closed via the single-module script check and the on*= scan. Generated-by: Devin --- .../scripts/vite-renderer-entry-contract.ts | 7 ++ apps/desktop/src/renderer/app.tsx | 2 +- apps/desktop/src/renderer/index.html | 69 +++++++------------ .../src/renderer/styles/shell-layout.css | 4 +- 4 files changed, 35 insertions(+), 47 deletions(-) diff --git a/apps/desktop/scripts/vite-renderer-entry-contract.ts b/apps/desktop/scripts/vite-renderer-entry-contract.ts index ca696b0809..f7d08161ea 100644 --- a/apps/desktop/scripts/vite-renderer-entry-contract.ts +++ b/apps/desktop/scripts/vite-renderer-entry-contract.ts @@ -21,15 +21,22 @@ import { resolve, sep } from 'node:path'; import type { Plugin } from 'vite'; const SOURCE_FILE = /\.(?:(?:c|m)?(?:js|ts)x?)$/u; +// svg/g/path exist for the static preload wordmark in index.html — the tag +// whitelist stays closed to anything that can navigate or execute (a, img, +// iframe, foreignObject, svg script/animate): the single-module script check +// and the `on*=` handler scan still apply inside allowed tags. const ALLOWED_HTML_TAGS = new Set([ 'body', 'div', + 'g', 'head', 'html', 'link', 'meta', + 'path', 'script', 'style', + 'svg', 'title', ]); const CONTENT_SECURITY_POLICY = diff --git a/apps/desktop/src/renderer/app.tsx b/apps/desktop/src/renderer/app.tsx index 98aa6cc658..9e9a14bab0 100644 --- a/apps/desktop/src/renderer/app.tsx +++ b/apps/desktop/src/renderer/app.tsx @@ -26,7 +26,7 @@ import { useAstryxThemeMode } from './astryx-theme-mode'; export function App() { // PR-SHOW-AFTER-FIRST-COMMIT: the BrowserWindow is created hidden // (main-window.ts show: false) so the OS never flashes the index.html - // `.maka-preload` skeleton before React paints. A layout effect is too early + // `.maka-preload` surface before React paints. A layout effect is too early // for this signal: it runs after the DOM commit but before Chromium paints, // so the main process can show the BrowserWindow while its last composited // frame is still the preload skeleton. Two animation frames put the signal diff --git a/apps/desktop/src/renderer/index.html b/apps/desktop/src/renderer/index.html index 9f98299921..c13f781dd9 100644 --- a/apps/desktop/src/renderer/index.html +++ b/apps/desktop/src/renderer/index.html @@ -29,75 +29,56 @@ Maka
-
+
+ +
diff --git a/apps/desktop/src/renderer/styles/shell-layout.css b/apps/desktop/src/renderer/styles/shell-layout.css index 084667bb92..998515588d 100644 --- a/apps/desktop/src/renderer/styles/shell-layout.css +++ b/apps/desktop/src/renderer/styles/shell-layout.css @@ -26,7 +26,7 @@ padding: 0; /* PR-APPFRAME-FILL-0: `#root` (index.html) is `display:flex; align-items:center; justify-content:center` so the preload - skeleton (`.maka-preload`, rendered before React mounts) sits + surface (`.maka-preload`, rendered before React mounts) sits centered. Once React mounts, `.appFrame` is that flex container's only child, and without an explicit main-axis size a flex item shrinks to its content width and gets centered — so on the @@ -36,7 +36,7 @@ frame always spans the window regardless of inner content's max-content. The preload centering is unaffected (it's the only child at that point and still centers). */ - /* The backplate under the preload skeleton and behind the shell before it + /* The backplate under the preload surface and behind the shell before it mounts. AppShell paints both columns over this, so it is not a shell surface — it is what the window shows when there is no shell yet. */ background: var(--surface-canvas); From bdf6845f98f99c8d990e681c4550adebd0575f81 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 19 Sep 2026 23:54:57 +0800 Subject: [PATCH 23/44] feat(desktop): hold the launch surface until the app reports a usable frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The launch overlay moves out of #root into a fixed fullscreen layer, so React mounts underneath it instead of replacing it. AppShell drops it once the bootstrap snapshot resolves and no session view or transcript read is still in flight, which removes the bare-vibrancy window, the partial shell, and the skeleton beats from the startup sequence. main.tsx arms an 8s failsafe so a wedged read can never strand the logo. The window returns to show:false — ready-to-show now reveals the first painted frame, which is the launch surface itself, and restoring maximized state defers to markReady in every reveal mode. Generated-by: Devin --- apps/desktop/src/main/early-window.ts | 12 +++--- apps/desktop/src/main/main-window.ts | 27 +++++------- apps/desktop/src/main/window-reveal.ts | 16 ++++--- apps/desktop/src/renderer/app-shell.tsx | 14 ++++++ apps/desktop/src/renderer/app.tsx | 18 +++----- apps/desktop/src/renderer/index.html | 30 +++++++++---- apps/desktop/src/renderer/launch-surface.ts | 43 +++++++++++++++++++ apps/desktop/src/renderer/main.tsx | 2 + .../src/renderer/styles/shell-layout.css | 16 ++----- 9 files changed, 117 insertions(+), 61 deletions(-) create mode 100644 apps/desktop/src/renderer/launch-surface.ts diff --git a/apps/desktop/src/main/early-window.ts b/apps/desktop/src/main/early-window.ts index 53e78abefa..5370bac685 100644 --- a/apps/desktop/src/main/early-window.ts +++ b/apps/desktop/src/main/early-window.ts @@ -210,13 +210,11 @@ export const mainWindowController = createMainWindowController({ settingsStore, revealMode, onWindowConstructed: () => { - // Active runs show at construction: 'show' is the moment the native window - // is on screen — that is the point after which the Runtime Host module - // graph may evaluate without starving the display itself. Hidden/inactive - // windows never emit it, so those runs resolve at construction instead. - const win = mainWindowController.browserWindow(); - if (revealMode === 'active' && win) win.once('show', resolveFirstWindowConstructed); - else resolveFirstWindowConstructed(); + // 'show' is the moment the native window is on screen — the point after + // which the Runtime Host module graph may evaluate without starving the + // display itself. Hidden runs never emit it; the launch-settle fallback + // resolves the boundary for them instead. + mainWindowController.browserWindow()?.once('show', resolveFirstWindowConstructed); }, onClose: () => mainWindowDelegates.onMainWindowClose(), onClosed: () => mainWindowDelegates.onMainWindowClosed(), diff --git a/apps/desktop/src/main/main-window.ts b/apps/desktop/src/main/main-window.ts index b8e36b3f01..bbc6005558 100644 --- a/apps/desktop/src/main/main-window.ts +++ b/apps/desktop/src/main/main-window.ts @@ -206,8 +206,9 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main const revealMode: WindowRevealMode = deps.revealMode; // ChatGPT Pro review P2: focus() (second-instance / activate) used to call // mainWindow.show() directly, bypassing the reveal gate — re-launching or - // clicking the dock icon during the pre-commit window would flash the - // skeleton anyway. The gate defers those focus requests until markReady. + // clicking the dock icon before the first paint would flash the bare + // vibrancy window anyway. The gate defers those focus requests until + // markReady. const revealGate = createWindowRevealGate(revealMode); let showFallbackTimer: NodeJS.Timeout | undefined; let rendererRecoveryReadiness: @@ -413,12 +414,10 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main // drift apart (locked by app-region-hygiene-contract.test.ts). minHeight: SAFE_MIN_HEIGHT, backgroundColor: initialBg, - // Active runs show the native window immediately: the theme-matched - // backgroundColor reads as a launch surface while the skeleton paints - // (~200ms earlier than waiting for `ready-to-show`). E2E modes stay - // hidden so the reveal gate keeps its inactive/hidden semantics; - // `ready-to-show` still marks ready to flush deferred focus/maximize. - show: revealMode === 'active', + // The window stays hidden until `ready-to-show`, so the first visible + // frame is already the launch surface — showing it at construction + // would expose the translucent vibrancy material before the DOM paints. + show: false, // Native sidebar vibrancy lets the CSS-side sidebar render // transparent and inherit the system's blurred window material // (Big Sur+). Renderer CSS gates the transparency on @@ -513,14 +512,10 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main // Restore maximized state after construction (BrowserWindow constructor // doesn't accept it directly). ChatGPT Pro review P2 (round 2): a direct - // maximize() reveals a still-hidden window (verified on macOS), so hidden - // and inactive runs defer it to markReady and the first visible frame is - // already maximized. An active run is shown at construction, so it - // maximizes now — the window appears already animating to full size. - if (bounds.isMaximized) { - if (revealMode === 'active') mainWindow.maximize(); - else revealGate.requestMaximize(mainWindow); - } + // maximize() reveals a still-hidden window (verified on macOS), so all + // reveal modes defer it to markReady and the first visible frame is + // already maximized. + if (bounds.isMaximized) revealGate.requestMaximize(mainWindow); // Persist bounds across launches. Debounce so a continuous resize drag // doesn't write the file on every frame; flush on close. diff --git a/apps/desktop/src/main/window-reveal.ts b/apps/desktop/src/main/window-reveal.ts index 9e89cf4036..e7029bbbd5 100644 --- a/apps/desktop/src/main/window-reveal.ts +++ b/apps/desktop/src/main/window-reveal.ts @@ -20,13 +20,15 @@ /** * PR-SHOW-AFTER-FIRST-COMMIT: shared reveal gate for the hidden main window. * - * The BrowserWindow is created with `show: false` (main-window.ts) so the OS - * never flashes the index.html `.maka-preload` skeleton before React paints. - * Two callers reveal it: the `window:notifyRendererReady` IPC (fired from the - * renderer's first React commit) and a fallback timer for a wedged renderer. - * Both route through here so the show() decision lives in one place — and so - * it stays unit-testable without an Electron runtime (main-window.ts itself - * can't be imported under plain `node --test` because it pulls in `electron`). + * The BrowserWindow is created with `show: false` (main-window.ts); the + * `ready-to-show` event reveals it on the first painted frame, which is the + * index.html launch surface by design. Two further callers exist as + * backstops: the `window:notifyRendererReady` IPC (fired after the + * renderer's first React commit paints) and a fallback timer for a wedged + * renderer. Both route through here so the show() decision lives in one + * place — and so it stays unit-testable without an Electron runtime + * (main-window.ts itself can't be imported under plain `node --test` + * because it pulls in `electron`). */ /** diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 9d0706c9d3..f3c3b4d861 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -93,6 +93,7 @@ import { useNewTaskChoice } from './use-new-task-choice'; import { SessionCollaborationDialog } from './session-collaboration-dialog'; import * as SessionCollaboration from './features/session-collaboration'; import { NEW_TASK_PENDING_KEY } from './pending-items'; +import { dismissLaunchSurface } from './launch-surface'; import { desktopSlashCommandAvailability, parseDesktopSlashCommand, @@ -1075,6 +1076,19 @@ function AppShellContent({ onRetry: () => reloadActiveExecutionBoundary(activeId), } : undefined; + // The index.html launch overlay covers the window until the first usable + // frame exists — suppressing the partial-shell and skeleton beats in + // between. "Usable" means the bootstrap snapshot resolved (or failed into + // its own surface) and no session view or transcript read is still in + // flight. main.tsx arms a failsafe timer so a wedged read can never strand + // the logo. + const launchSurfaceReady = + (onboarding.snapshot !== null || onboarding.error !== null) && + !switchingSession && + !activeMessageLoading; + useEffect(() => { + if (launchSurfaceReady) dismissLaunchSurface(); + }, [launchSurfaceReady]); const desktopSlashCommands = useMemo( () => { const availableCommands = slashCommandsForSurface('desktop').filter( diff --git a/apps/desktop/src/renderer/app.tsx b/apps/desktop/src/renderer/app.tsx index 9e9a14bab0..09ae2ce91a 100644 --- a/apps/desktop/src/renderer/app.tsx +++ b/apps/desktop/src/renderer/app.tsx @@ -24,17 +24,13 @@ import { AppShell } from './composition/legacy-desktop-region'; import { useAstryxThemeMode } from './astryx-theme-mode'; export function App() { - // PR-SHOW-AFTER-FIRST-COMMIT: the BrowserWindow is created hidden - // (main-window.ts show: false) so the OS never flashes the index.html - // `.maka-preload` surface before React paints. A layout effect is too early - // for this signal: it runs after the DOM commit but before Chromium paints, - // so the main process can show the BrowserWindow while its last composited - // frame is still the preload skeleton. Two animation frames put the signal - // after at least one paint of the committed AppShell. This remains - // unconditional: even when the onboarding snapshot is null and AppShell - // mounts its fail-soft loading state, the window should still appear. The - // main-process fallback handles a renderer that never reaches either frame. - // `window.maka` is undefined outside Electron (storybook), so guard it. + // The launch overlay (`#maka-preload` in index.html) stays visible until + // AppShell reports a usable frame; this signal is only a backstop for the + // main-process reveal gate — `ready-to-show` normally beats it. A layout + // effect is too early: it runs after the DOM commit but before Chromium + // paints, so two animation frames put the signal after at least one paint + // of the committed AppShell. `window.maka` is undefined outside Electron + // (storybook), so guard it. useEffect(() => { let secondFrame = 0; const firstFrame = requestAnimationFrame(() => { diff --git a/apps/desktop/src/renderer/index.html b/apps/desktop/src/renderer/index.html index c13f781dd9..bec0e66b2f 100644 --- a/apps/desktop/src/renderer/index.html +++ b/apps/desktop/src/renderer/index.html @@ -29,12 +29,16 @@ Maka -
-
+
+
+