diff --git a/apps/desktop/scripts/check-renderer-architecture.mjs b/apps/desktop/scripts/check-renderer-architecture.mjs index 9e09dbd51b..2d1258152f 100644 --- a/apps/desktop/scripts/check-renderer-architecture.mjs +++ b/apps/desktop/scripts/check-renderer-architecture.mjs @@ -2021,6 +2021,7 @@ function validateMainWindowEntryContract(desktopRoot, violations) { } const allowedNavigationFiles = new Set([ + 'src/main/browser-message-box.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__/browser-message-box.test.ts b/apps/desktop/src/main/__tests__/browser-message-box.test.ts new file mode 100644 index 0000000000..488c2c66f8 --- /dev/null +++ b/apps/desktop/src/main/__tests__/browser-message-box.test.ts @@ -0,0 +1,376 @@ +/* + * 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 { runInNewContext } from 'node:vm'; +import type { + BrowserWindow, + BrowserWindowConstructorOptions, + MessageBoxReturnValue, +} from 'electron'; +import { parseHTML } from 'linkedom'; +import { + type BrowserMessageBoxRuntime, + buildBrowserMessageBoxHtml, + centeredBounds, + isBrowserMessageBoxPresentationActive, + parseBrowserMessageBoxResponse, + showBrowserMessageBoxWithRuntime, +} from '../browser-message-box.js'; + +test('falls back natively and never attaches to an inaccessible parent', async () => { + const options = { message: 'Recover Maka' }; + const nativeResult = { response: 0, checkboxChecked: false }; + const parentState = { visible: false, minimized: false, destroyed: false }; + const parent = { + isVisible: () => parentState.visible, + isMinimized: () => parentState.minimized, + isDestroyed: () => parentState.destroyed, + getBounds: () => ({ x: 100, y: 100, width: 900, height: 700 }), + } as BrowserWindow; + const nativeParents: Array = []; + const runtimeBase = { + shouldUseDarkColors: false, + resolveWorkArea: () => ({ x: 0, y: 0, width: 1_200, height: 800 }), + showNative: async ( + _options: typeof options, + actualParent: BrowserWindow | undefined, + ): Promise => { + nativeParents.push(actualParent); + return nativeResult; + }, + onBrowserError: () => assert.fail('native presentation must not report a browser error'), + }; + + parentState.visible = true; + const failure = new Error('renderer failed'); + const failedWindow = fakeBrowserWindow({ loadError: failure }); + let reported: unknown; + assert.equal( + await showBrowserMessageBoxWithRuntime(options, parent, { locale: 'en' }, { + ...runtimeBase, + createWindow: (windowOptions) => { + assert.equal(windowOptions.parent, parent); + assert.equal(windowOptions.modal, true); + parentState.minimized = true; + failedWindow.options = windowOptions; + return failedWindow.window; + }, + onBrowserError: (error) => { + reported = error; + }, + }), + nativeResult, + ); + assert.equal(reported, failure); + assert.deepEqual(nativeParents, [undefined]); + assert.equal(failedWindow.destroyed(), true); +}); + +test('drives the BrowserWindow lifecycle through a safe response URL', async () => { + const parent = { + isVisible: () => true, + isMinimized: () => true, + isDestroyed: () => false, + } as BrowserWindow; + const presented = fakeBrowserWindow(); + let createdOptions: BrowserWindowConstructorOptions | undefined; + + const presentation = showBrowserMessageBoxWithRuntime( + { message: 'Recover Maka', buttons: ['Recover', 'Cancel'], cancelId: 1 }, + parent, + { locale: 'en', dark: true }, + { + shouldUseDarkColors: false, + createWindow: (options) => { + createdOptions = options; + return presented.window; + }, + resolveWorkArea: (actualParent) => { + assert.equal(actualParent, undefined); + return { x: 0, y: 0, width: 1_200, height: 800 }; + }, + showNative: async () => assert.fail('successful browser presentation must not fall back'), + onBrowserError: () => assert.fail('successful browser presentation must not report error'), + }, + ); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(isBrowserMessageBoxPresentationActive(), true); + assert.equal(createdOptions?.parent, undefined); + assert.equal(createdOptions?.modal, undefined); + assert.equal(presented.shown(), true); + assert.equal(presented.focused(), true); + assert.match(presented.loadedUrl(), /^data:text\/html/u); + assert.equal(presented.deniesWindowOpen(), true); + + let prevented = false; + presented.webContents.emit( + 'will-navigate', + { + preventDefault: () => { + prevented = true; + }, + }, + 'maka-dialog://response/0', + ); + assert.deepEqual(await presentation, { response: 0, checkboxChecked: false }); + assert.equal(prevented, true); + assert.equal(presented.destroyed(), true); + assert.equal(isBrowserMessageBoxPresentationActive(), false); +}); + +test('maps close to cancel and falls back after each BrowserWindow presentation failure', async () => { + const closed = fakeBrowserWindow(); + const closeResult = showBrowserMessageBoxWithRuntime( + { message: 'Recover Maka', buttons: ['Recover', 'Cancel'], cancelId: 1 }, + undefined, + { locale: 'en' }, + runtimeForWindow(closed), + ); + closed.window.emit('closed'); + assert.equal(isBrowserMessageBoxPresentationActive(), true); + assert.deepEqual(await closeResult, { response: 1, checkboxChecked: false }); + assert.equal(closed.destroyed(), true); + + for (const scenario of ['unresponsive', 'renderer-gone', 'timeout'] as const) { + const presented = fakeBrowserWindow({ pendingLoad: scenario === 'timeout' }); + const errors: unknown[] = []; + const nativeResult = { response: 0, checkboxChecked: false }; + const result = showBrowserMessageBoxWithRuntime( + { message: 'Recover Maka' }, + undefined, + { locale: 'en' }, + runtimeForWindow(presented, { + presentationTimeoutMs: scenario === 'timeout' ? 1 : undefined, + onBrowserError: (error) => errors.push(error), + nativeResult, + }), + ); + if (scenario === 'unresponsive') presented.window.emit('unresponsive'); + if (scenario === 'renderer-gone') { + presented.webContents.emit('render-process-gone', {}, { reason: 'crashed' }); + } + + assert.equal(await result, nativeResult); + assert.equal(errors.length, 1, scenario); + assert.equal(presented.destroyed(), true, scenario); + assert.equal(isBrowserMessageBoxPresentationActive(), false, scenario); + } +}); + +test('accepts only an in-range response URL produced by the dialog', () => { + assert.equal(parseBrowserMessageBoxResponse('maka-dialog://response/1', 3), 1); + for (const value of [ + 'https://response/1', + 'maka-dialog://other/1', + 'maka-dialog://user@response/1', + 'maka-dialog://response:123/1', + 'maka-dialog://response/01', + 'maka-dialog://response/1?', + 'maka-dialog://response/1#', + 'maka-dialog://response/1?again=true', + 'maka-dialog://response/3', + 'not a url', + ]) { + assert.equal(parseBrowserMessageBoxResponse(value, 3), undefined, value); + } +}); + +test('centers against the parent while keeping the whole dialog on-screen', () => { + assert.deepEqual( + centeredBounds( + { x: 900, y: 700, width: 200, height: 100 }, + { x: 0, y: 0, width: 1_000, height: 800 }, + 520, + 300, + ), + { x: 480, y: 500, width: 520, height: 300 }, + ); + assert.deepEqual( + centeredBounds(undefined, { x: -1_000, y: 40, width: 800, height: 600 }, 400, 280), + { x: -800, y: 200, width: 400, height: 280 }, + ); +}); + +test('renders escaped content with Maka dialog tokens and safe action ordering', () => { + const html = buildBrowserMessageBoxHtml( + { + type: 'warning', + title: '', + message: 'Maka & Runtime Host · /Users/示例', + detail: '', + buttons: ['Replace ', 'Cancel', 'Copy & Diagnostics'], + defaultId: 0, + cancelId: 1, + }, + { dark: true, locale: 'en', palette: 'nord' }, + ); + + assert.match(html, /globalThis\.pwned/u); + + const cancelPosition = html.indexOf('>Cancel'); + const copyPosition = html.indexOf('>Copy & Diagnostics'); + const actionPosition = html.indexOf('>Replace <Host>'); + assert.ok(cancelPosition >= 0 && cancelPosition < copyPosition); + assert.ok(copyPosition < actionPosition); + assert.match(html, /data-response="0" autofocus/u); + assert.match(html, /default-src 'none'/u); +}); + +test('routes button and keyboard decisions through the rendered interaction bridge', () => { + const html = buildBrowserMessageBoxHtml( + { + message: 'Recover Maka', + buttons: ['Recover', 'Cancel'], + defaultId: 0, + cancelId: 1, + }, + { dark: false, locale: 'en' }, + ); + const { document, window } = parseHTML(html); + const script = document.querySelector('script')?.textContent; + assert.ok(script); + const navigations: string[] = []; + runInNewContext(script, { + window: { location: { assign: (url: string) => navigations.push(url) } }, + document, + Element: window.Element, + HTMLButtonElement: window.HTMLButtonElement, + }); + const dispatchKey = (key: string): void => { + const event = new window.Event('keydown', { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'key', { value: key }); + document.body.dispatchEvent(event); + }; + + document + .querySelector('[data-response="0"]') + ?.dispatchEvent(new window.Event('click', { bubbles: true, cancelable: true })); + dispatchKey('Escape'); + dispatchKey('Enter'); + + assert.deepEqual(navigations, [ + 'maka-dialog://response/0', + 'maka-dialog://response/1', + 'maka-dialog://response/0', + ]); +}); + +interface FakeBrowserWindow { + readonly window: BrowserWindow; + readonly webContents: EventEmitter; + options?: BrowserWindowConstructorOptions; + loadedUrl(): string; + shown(): boolean; + focused(): boolean; + destroyed(): boolean; + deniesWindowOpen(): boolean; +} + +function fakeBrowserWindow(input: { + readonly loadError?: Error; + readonly pendingLoad?: boolean; +} = {}): FakeBrowserWindow { + let loadedUrl = ''; + let shown = false; + let focused = false; + let destroyed = false; + let deniesWindowOpen = false; + const webContents = Object.assign(new EventEmitter(), { + setWindowOpenHandler(handler: () => { action: string }) { + deniesWindowOpen = handler().action === 'deny'; + }, + async executeJavaScript(script: string): Promise { + return script.includes('scrollHeight') ? 340 : undefined; + }, + }); + const window = Object.assign(new EventEmitter(), { + webContents, + setMenuBarVisibility() {}, + loadURL(url: string): Promise { + loadedUrl = url; + if (input.loadError) return Promise.reject(input.loadError); + if (input.pendingLoad) return new Promise(() => undefined); + return Promise.resolve(); + }, + isDestroyed: () => destroyed, + destroy: () => { + destroyed = true; + }, + setBounds() {}, + show: () => { + shown = true; + }, + focus: () => { + focused = true; + }, + }) as unknown as BrowserWindow; + return { + window, + webContents, + loadedUrl: () => loadedUrl, + shown: () => shown, + focused: () => focused, + destroyed: () => destroyed, + deniesWindowOpen: () => deniesWindowOpen, + }; +} + +function runtimeForWindow( + presented: FakeBrowserWindow, + options: { + readonly presentationTimeoutMs?: number; + readonly onBrowserError?: (error: unknown) => void; + readonly nativeResult?: MessageBoxReturnValue; + } = {}, +): BrowserMessageBoxRuntime { + return { + shouldUseDarkColors: false, + createWindow: (windowOptions) => { + presented.options = windowOptions; + return presented.window; + }, + resolveWorkArea: () => ({ x: 0, y: 0, width: 1_200, height: 800 }), + showNative: async () => + options.nativeResult ?? assert.fail('successful browser presentation must not fall back'), + onBrowserError: + options.onBrowserError ?? + (() => assert.fail('successful browser presentation must not report an error')), + ...(options.presentationTimeoutMs === undefined + ? {} + : { presentationTimeoutMs: options.presentationTimeoutMs }), + }; +} diff --git a/apps/desktop/src/main/__tests__/main-renderer-process-gone.test.ts b/apps/desktop/src/main/__tests__/main-renderer-process-gone.test.ts index 408ba7c7b4..8161c525b3 100644 --- a/apps/desktop/src/main/__tests__/main-renderer-process-gone.test.ts +++ b/apps/desktop/src/main/__tests__/main-renderer-process-gone.test.ts @@ -21,7 +21,11 @@ import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; import { test } from 'node:test'; import type { RenderProcessGoneDetails } from 'electron'; -import { observeMainRendererProcessGone } from '../main-renderer-process-gone.js'; +import { + type MainRendererFrameIdentity, + observeMainRendererProcessGone, + reloadMainRendererProcess, +} from '../main-renderer-process-gone.js'; test('observes one unexpected main Renderer exit while the app is running', () => { const source = new EventEmitter(); @@ -59,3 +63,135 @@ test('ignores clean exits and app shutdown', () => { assert.equal(observed, false); } }); + +test('accepts first-paint readiness only from the frame committed by this reload', async () => { + const source = reloadSource(); + const readiness = rendererReadiness(); + const previousFrame = frameIdentity(1, 'previous'); + const currentFrame = frameIdentity(2, 'current'); + let observed = false; + const result = reloadMainRendererProcess({ + source, + shutdownSignal: new AbortController().signal, + subscribeMainFrameCommitted: readiness.subscribeCommit, + subscribeRendererReady: readiness.subscribe, + onReady: () => { + observed = true; + }, + }); + + assert.equal(source.reloadCalls, 1); + source.emit('did-fail-load', {}, -3, 'subframe failed', 'https://example.test/frame', false, 1, 2); + assert.equal(readiness.notify(previousFrame), false); + readiness.commit(currentFrame); + assert.equal(readiness.notify(previousFrame), false); + assert.equal(observed, false); + assert.equal(readiness.notify(currentFrame), true); + assert.equal(await result, true); + assert.equal(observed, true); + assert.equal(source.listenerCount('did-fail-load'), 0); + assert.equal(source.listenerCount('render-process-gone'), 0); + assert.equal(readiness.subscribed(), false); +}); + +test('keeps recovery active when a Renderer reload fails, exits, or stops responding', async () => { + for (const fail of [ + (source: ReturnType) => + source.emit('did-fail-load', {}, -105, 'name not resolved', 'https://bad.test', true, 1, 2), + (source: ReturnType) => + source.emit('render-process-gone', {}, { reason: 'crashed', exitCode: 11 }), + (source: ReturnType) => source.emit('unresponsive'), + ]) { + const source = reloadSource(); + const readiness = rendererReadiness(); + let observed = false; + const result = reloadMainRendererProcess({ + source, + shutdownSignal: new AbortController().signal, + subscribeMainFrameCommitted: readiness.subscribeCommit, + subscribeRendererReady: readiness.subscribe, + onReady: () => { + observed = true; + }, + }); + + fail(source); + assert.equal(await result, false); + assert.equal(observed, false); + assert.equal(source.listenerCount('unresponsive'), 0); + assert.equal(readiness.subscribed(), false); + } +}); + +test('bounds a Renderer reload that emits no terminal event', async () => { + const source = reloadSource(); + const readiness = rendererReadiness(); + const result = reloadMainRendererProcess({ + source, + shutdownSignal: new AbortController().signal, + subscribeMainFrameCommitted: readiness.subscribeCommit, + subscribeRendererReady: readiness.subscribe, + onReady: () => assert.fail('timed-out reload must not report success'), + timeoutMs: 1, + }); + + assert.equal(await result, false); + assert.equal(source.listenerCount('did-fail-load'), 0); + assert.equal(source.listenerCount('unresponsive'), 0); + assert.equal(source.listenerCount('render-process-gone'), 0); + assert.equal(readiness.subscribed(), false); +}); + +function rendererReadiness(): { + subscribe(listener: (frame: MainRendererFrameIdentity) => boolean): () => void; + subscribeCommit(listener: (frame: MainRendererFrameIdentity) => void): () => void; + notify(frame: MainRendererFrameIdentity): boolean; + commit(frame: MainRendererFrameIdentity): void; + subscribed(): boolean; +} { + let listener: ((frame: MainRendererFrameIdentity) => boolean) | undefined; + let commitListener: ((frame: MainRendererFrameIdentity) => void) | undefined; + return { + subscribe(next) { + listener = next; + return () => { + if (listener === next) listener = undefined; + }; + }, + subscribeCommit(next) { + commitListener = next; + return () => { + if (commitListener === next) commitListener = undefined; + }; + }, + notify(frame) { + return listener?.(frame) ?? false; + }, + commit(frame) { + commitListener?.(frame); + }, + subscribed() { + return listener !== undefined || commitListener !== undefined; + }, + }; +} + +function frameIdentity(processId: number, frameToken: string): MainRendererFrameIdentity { + return { processId, frameToken }; +} + +function reloadSource(): EventEmitter & { + reloadCalls: number; + reload(): void; + isDestroyed(): boolean; +} { + return Object.assign(new EventEmitter(), { + reloadCalls: 0, + reload() { + this.reloadCalls += 1; + }, + isDestroyed() { + return false; + }, + }); +} diff --git a/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts b/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts new file mode 100644 index 0000000000..cb7b5d0a57 --- /dev/null +++ b/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts @@ -0,0 +1,139 @@ +/* + * 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 { readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const mainSource = readFileSync( + fileURLToPath(new URL('../../../src/main/main.ts', import.meta.url)), + 'utf8', +); +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)), + 'utf8', +); +const mainWindowSource = readFileSync( + fileURLToPath(new URL('../../../src/main/main-window.ts', import.meta.url)), + 'utf8', +); + +test('retains process lifetime before a standalone startup dialog can close', () => { + const retentionPolicy = mainSource.search( + /app\.on\(['"]window-all-closed['"],\s*\(\)\s*=>\s*\{\s*\}\);/u, + ); + const singleInstanceDecision = mainSource.indexOf('app.requestSingleInstanceLock()'); + + assert.notEqual(retentionPolicy, -1); + assert.notEqual(singleInstanceDecision, -1); + assert.ok(retentionPolicy < singleInstanceDecision); + + const lifecycleStart = bootSource.indexOf('function wireLifecycle'); + const windowAllClosedStart = bootSource.indexOf( + 'app.on("window-all-closed"', + lifecycleStart, + ); + const windowAllClosed = bootSource.slice( + windowAllClosedStart, + bootSource.indexOf('app.on("before-quit"', windowAllClosedStart), + ); + assert.match( + windowAllClosed, + /process\.platform !== "darwin" && !isBrowserMessageBoxPresentationActive\(\)/u, + ); +}); + +test('resolves persisted locale before first post-settings recovery prompt', () => { + const rendererRecoveryStart = bootSource.indexOf('onRendererProcessGone: async'); + const rendererRecovery = bootSource.slice( + rendererRecoveryStart, + bootSource.indexOf('resolveBrowserDialogParent =', rendererRecoveryStart), + ); + const hostRecoveryStart = bootSource.indexOf('prompt: async (input)'); + const hostRecovery = bootSource.slice( + hostRecoveryStart, + bootSource.indexOf('}).catch((error: unknown)', hostRecoveryStart), + ); + const defaultHostRecoveryStart = bootSource.indexOf( + 'async function promptForDefaultRuntimeHostRecovery', + ); + const defaultHostRecovery = bootSource.slice(defaultHostRecoveryStart); + + assert.match(rendererRecovery, /const locale = await desktopLocale\.resolve\(\)/u); + assert.match(hostRecovery, /const locale = await desktopLocale\.resolve\(\)/u); + assert.match(defaultHostRecovery, /const locale = await desktopLocale\.resolve\(\)/u); + assert.doesNotMatch(rendererRecovery, /desktopLocale\.current\(\)/u); + assert.doesNotMatch(hostRecovery, /desktopLocale\.current\(\)/u); + assert.doesNotMatch(defaultHostRecovery, /resolveSystemUiLocale/u); +}); + +test('routes the first-paint IPC only to the active Renderer recovery listener', () => { + const ipcHandlerStart = appIpcSource.indexOf( + "targetIpc.handle('window:notifyRendererReady'", + ); + const ipcHandler = appIpcSource.slice( + ipcHandlerStart, + appIpcSource.indexOf("targetIpc.handle('window:setThemeSource'", ipcHandlerStart), + ); + const readyHandlerStart = mainWindowSource.indexOf( + 'notifyRendererReady(sender, senderFrame)', + ); + const readyHandler = mainWindowSource.slice( + readyHandlerStart, + mainWindowSource.indexOf('setTitlebarControlsVisible(sender', readyHandlerStart), + ); + const reloadStart = mainWindowSource.indexOf(' async reloadMainRenderer() {'); + const reloadHandler = mainWindowSource.slice( + reloadStart, + mainWindowSource.indexOf(' send: safeSendToRenderer', reloadStart), + ); + + assert.match( + ipcHandler, + /mainWindowController\.notifyRendererReady\(event\.sender, event\.senderFrame\)/u, + ); + assert.match( + reloadHandler, + /clearShowFallbackTimer\(\);\s*revealGate\.reset\(\);\s*target\.hide\(\);/u, + ); + assert.match(reloadHandler, /reloadMainRendererProcess\(/u); + assert.match(reloadHandler, /subscribeMainFrameCommitted:/u); + assert.match( + reloadHandler, + /if \(!isMainFrame\) return;\s*const frame = webFrameMain\.fromId\(frameProcessId, frameRoutingId\);\s*if \(frame\) listener\(rendererFrameIdentity\(frame\)\);/u, + ); + assert.match( + readyHandler, + /sender !== mainWindow\.webContents\) return;/u, + ); + assert.match( + readyHandler, + /if \(recovery\?\.contents === sender\) \{\s*[^}]*if \(!senderFrame \|\| !recovery\.listener\?\.\(rendererFrameIdentity\(senderFrame\)\)\) return;\s*\}/u, + ); + assert.match( + reloadHandler, + /if \(rendererRecoveryReadiness === readiness\) \{\s*if \(loaded\) rendererRecoveryReadiness = undefined;\s*else readiness\.listener = undefined;\s*\}/u, + ); + assert.match(readyHandler, /revealGate\.markReady\(mainWindow\)/u); +}); 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 80b332e38c..fd5a621e6c 100644 --- a/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts +++ b/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts @@ -49,7 +49,7 @@ const diagnosticEnvironment = () => ({ processUptimeSeconds: 3, }); -test('copies diagnostics as an auxiliary native-dialog action', async () => { +test('copies diagnostics as an auxiliary dialog action', async () => { const shown: MessageBoxOptions[] = []; const responses = [2, 1]; let copies = 0; @@ -182,9 +182,10 @@ test('main Renderer loss keeps Copy Diagnostics auxiliary to recovery', async () }, }); - assert.equal(decision, 'relaunch'); - assert.deepEqual(shown[0]?.buttons, ['Relaunch', 'Exit', 'Copy Diagnostics']); - assert.deepEqual(shown[1]?.buttons, ['Relaunch', 'Exit', 'Copy Again']); + assert.equal(decision, 'recover'); + assert.deepEqual(shown[0]?.buttons, ['Recover Interface', 'Exit', 'Copy Diagnostics']); + assert.deepEqual(shown[1]?.buttons, ['Recover Interface', 'Exit', 'Copy Again']); + assert.match(shown[0]?.detail ?? '', /without restarting Maka/); assert.match(clipboard, /Surface: renderer_process_gone/); assert.match(clipboard, /Reason: oom/); assert.match(clipboard, /Exit code: 137/); @@ -216,9 +217,29 @@ test('managed Host recovery preserves the workspace and confirms active-work int 'Exit', 'Copy Diagnostics', ]); + assert.equal(shown?.defaultId, shown?.cancelId); assert.match(shown?.detail ?? '', /workspace, Host identity, credentials, and settings/); assert.match(shown?.detail ?? '', /automatic update compatibility cannot be confirmed/); assert.match(shown?.detail ?? '', /interrupt that work/); assert.match(shown?.detail ?? '', /Copy diagnostics to inspect the details/); assert.doesNotMatch(shown?.detail ?? '', /service update failed/); + + let unknownShown: MessageBoxOptions | undefined; + const unknownDecision = await showRuntimeHostStartupRecoveryDialog( + { + startupError: new Error('managed service unavailable'), + repairError: new Error('safe repair could not verify Host activity'), + activeTasks: false, + }, + { + locale: 'en', + copyDiagnostics() {}, + showMessageBox: async (options): Promise => { + unknownShown = options; + return { response: 1, checkboxChecked: false }; + }, + }, + ); + assert.equal(unknownDecision, 'exit'); + assert.equal(unknownShown?.defaultId, unknownShown?.cancelId); }); 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 7149705b03..2bbe8a467c 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 @@ -562,6 +562,36 @@ test('keeps Local and remote Hosts active and routes work by owning Host', async await manager.close(); }); +test('does not poll a remote service PID when the Host cannot be replaced', async () => { + const local = candidateHarness({ hostId: 'host-local' }); + const observed = upgradeRequired(false); + const conflict = { + ...observed, + registration: { ...observed.registration, lifecycleMode: 'service' as const }, + }; + const manager = await startRuntimeHostDesktopManager( + {} as DesktopRuntimeHostCandidateStartInput, + { + startCandidate: async (input) => + input.profileTarget ? conflict : ready(local.candidate), + upgradePrompts: { + restartable: async () => assert.fail('non-restartable conflict used restart prompt'), + nonRestartable: async (_conflict, action) => { + assert.equal(action, 'cancel_only'); + return 'cancel'; + }, + }, + waitForHostRetirement: async () => assert.fail('remote PID must not be polled locally'), + }, + ); + + await assert.rejects( + manager.enable(remoteTarget('legacy-service')), + RuntimeHostUpgradeCancelledError, + ); + await manager.close(); +}); + test('keeps independent shared-session credentials active for the same Host', async () => { const candidates = [ candidateHarness({ hostId: 'host-local' }).candidate, @@ -1125,64 +1155,35 @@ test('restarts an idle generation-aware Host without prompting', async () => { await owner.close(); }); -test('prompts before restarting a generation-aware Host with active work', async () => { - const replacement = candidateHarness(); - const conflict = upgradeRequired(true, 1); - let prompts = 0; - const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { - startCandidate: async (input) => - input.takeoverHostEpoch ? ready(replacement.candidate) : conflict, - upgradePrompts: { - restartable: async () => { - prompts += 1; - return 'restart'; - }, - nonRestartable: async () => assert.fail('restartable conflict used non-restartable prompt'), - }, - }); - - assert.equal(prompts, 1); - await owner.close(); -}); - -test('prompts before restarting a generation-aware Host with a residency', async () => { - const replacement = candidateHarness(); - const conflict = upgradeRequired(true, 0, [{ label: 'goal', count: 1 }]); - let prompts = 0; - const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { - startCandidate: async (input) => - input.takeoverHostEpoch ? ready(replacement.candidate) : conflict, - upgradePrompts: { - restartable: async () => { - prompts += 1; - return 'restart'; - }, - nonRestartable: async () => assert.fail('restartable conflict used non-restartable prompt'), - }, - }); - - assert.equal(prompts, 1); - await owner.close(); -}); - -test('prompts before restarting a generation-aware Host with connections', async () => { - const replacement = candidateHarness(); - const conflict = upgradeRequired(true, 0, [], 1); - let prompts = 0; - const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { - startCandidate: async (input) => - input.takeoverHostEpoch ? ready(replacement.candidate) : conflict, - upgradePrompts: { - restartable: async () => { - prompts += 1; - return 'restart'; +test('prompts before restarting a generation-aware Host with observed activity', async () => { + const conflicts = [ + ['operation', upgradeRequired(true, 1)], + ['residency', upgradeRequired(true, 0, [{ label: 'goal', count: 1 }])], + ['connection', upgradeRequired(true, 0, [], 1)], + ] as const; + + for (const [activity, conflict] of conflicts) { + const replacement = candidateHarness(); + let prompts = 0; + const owner = await startRuntimeHostDesktopManager( + {} as DesktopRuntimeHostCandidateStartInput, + { + startCandidate: async (input) => + input.takeoverHostEpoch ? ready(replacement.candidate) : conflict, + upgradePrompts: { + restartable: async () => { + prompts += 1; + return 'restart'; + }, + nonRestartable: async () => + assert.fail('restartable conflict used non-restartable prompt'), + }, }, - nonRestartable: async () => assert.fail('restartable conflict used non-restartable prompt'), - }, - }); + ); - assert.equal(prompts, 1); - await owner.close(); + assert.equal(prompts, 1, activity); + await owner.close(); + } }); test('prompts when a restartable Host has no activity snapshot', async () => { @@ -1206,7 +1207,11 @@ test('prompts when a restartable Host has no activity snapshot', async () => { }); test('waits passively for a Host that cannot be taken over', async () => { - const conflict = upgradeRequired(false); + const observed = upgradeRequired(false); + const conflict = { + ...observed, + registration: { ...observed.registration, lifecycleMode: 'service' as const }, + }; let starts = 0; let finishRetirement!: () => void; const retirement = new Promise((resolve) => { @@ -1220,7 +1225,10 @@ test('waits passively for a Host that cannot be taken over', async () => { }, upgradePrompts: { restartable: async () => assert.fail('wait-only conflict used restart prompt'), - nonRestartable: async () => 'wait', + nonRestartable: async (_conflict, action) => { + assert.equal(action, 'wait'); + return 'wait'; + }, }, waitForHostRetirement: async (registration) => { assert.equal(registration.hostEpoch, conflict.registration.hostEpoch); @@ -1235,15 +1243,17 @@ test('waits passively for a Host that cannot be taken over', async () => { await owner.close(); }); -test('replaces a non-restartable Local Host through the supplied authority and retries', async () => { - const observed = upgradeRequired(false); +test('silently replaces an idle non-restartable Local Host and retries', async () => { + const observed = upgradeRequired(true); const conflict = { ...observed, + restartable: false as const, registration: { ...observed.registration, lifecycleMode: 'service' as const }, }; const replacement = candidateHarness(); let starts = 0; let replaced: typeof observed.registration | undefined; + const policies: string[] = []; const owner = await startRuntimeHostDesktopManager( {} as DesktopRuntimeHostCandidateStartInput, { @@ -1253,23 +1263,132 @@ test('replaces a non-restartable Local Host through the supplied authority and r }, upgradePrompts: { restartable: async () => assert.fail('non-restartable conflict used restart prompt'), - nonRestartable: async (_conflict, actions) => { - assert.deepEqual(actions, { canReplace: true, canWait: false }); - return 'replace'; - }, + nonRestartable: async () => assert.fail('idle replaceable Host must not prompt'), }, resolveLocalHostReplacement: async (registration) => ({ - replace: async () => { + replace: async (policy) => { + policies.push(policy); replaced = registration; + return 'replaced'; }, }), }, ); assert.equal(starts, 2); assert.equal(replaced?.hostEpoch, conflict.registration.hostEpoch); + assert.deepEqual(policies, ['refuse_active_work']); + await owner.close(); +}); + +test('prompts if a non-restartable Local Host omits its activity snapshot', async () => { + const observed = upgradeRequired(false); + const conflict = { + ...observed, + registration: { ...observed.registration, lifecycleMode: 'service' as const }, + }; + const replacement = candidateHarness(); + const policies: string[] = []; + let starts = 0; + let prompts = 0; + const owner = await startRuntimeHostDesktopManager( + {} as DesktopRuntimeHostCandidateStartInput, + { + startCandidate: async () => { + starts += 1; + return starts === 1 ? conflict : ready(replacement.candidate); + }, + upgradePrompts: { + restartable: async () => assert.fail('non-restartable conflict used restart prompt'), + nonRestartable: async (_conflict, action) => { + prompts += 1; + assert.equal(action, 'replace_may_interrupt_work'); + return 'replace'; + }, + }, + resolveLocalHostReplacement: async () => ({ + replace: async (policy) => { + policies.push(policy); + return 'replaced'; + }, + }), + }, + ); + + assert.equal(prompts, 1); + assert.equal(starts, 2); + assert.deepEqual(policies, ['interrupt_active_work']); + await owner.close(); +}); + +test('prompts if an observed-idle Host becomes active before replacement', async () => { + const observed = upgradeRequired(true); + const conflict = { + ...observed, + restartable: false as const, + registration: { ...observed.registration, lifecycleMode: 'service' as const }, + }; + const replacement = candidateHarness(); + const policies: string[] = []; + let starts = 0; + let prompts = 0; + const owner = await startRuntimeHostDesktopManager( + {} as DesktopRuntimeHostCandidateStartInput, + { + startCandidate: async () => { + starts += 1; + return starts === 1 ? conflict : ready(replacement.candidate); + }, + upgradePrompts: { + restartable: async () => assert.fail('non-restartable conflict used restart prompt'), + nonRestartable: async (_conflict, action) => { + prompts += 1; + assert.equal(action, 'replace_may_interrupt_work'); + return 'replace'; + }, + }, + resolveLocalHostReplacement: async () => ({ + replace: async (policy) => { + policies.push(policy); + return policy === 'refuse_active_work' ? 'active_tasks' : 'replaced'; + }, + }), + }, + ); + + assert.equal(prompts, 1); + assert.equal(starts, 2); + assert.deepEqual(policies, ['refuse_active_work', 'interrupt_active_work']); await owner.close(); }); +test('does not authorize active-work interruption when replacement is cancelled', async () => { + const observed = upgradeRequired(false); + const conflict = { + ...observed, + registration: { ...observed.registration, lifecycleMode: 'service' as const }, + }; + const policies: string[] = []; + + await assert.rejects( + startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { + startCandidate: async () => conflict, + upgradePrompts: { + restartable: async () => assert.fail('non-restartable conflict used restart prompt'), + nonRestartable: async () => 'cancel', + }, + resolveLocalHostReplacement: async () => ({ + replace: async (policy) => { + policies.push(policy); + return 'active_tasks'; + }, + }), + onFatalError: () => undefined, + }), + RuntimeHostUpgradeCancelledError, + ); + assert.deepEqual(policies, []); +}); + test('lets the user cancel startup when an incompatible Host owns the root', async () => { const conflict = incompatibleHost('blocked_by_residency'); let presented: DesktopRuntimeHostCandidateStartResult | undefined; @@ -1278,9 +1397,9 @@ test('lets the user cancel startup when an incompatible Host owns the root', asy startCandidate: async () => conflict, upgradePrompts: { restartable: async () => assert.fail('incompatible Host used restart prompt'), - nonRestartable: async (actual, actions) => { + nonRestartable: async (actual, action) => { presented = actual; - assert.deepEqual(actions, { canReplace: false, canWait: true }); + assert.equal(action, 'wait'); return 'cancel'; }, }, 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 c6f2e90813..e3cdf718cb 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 @@ -376,14 +376,14 @@ test('repairs an existing managed Host with the current setup package and restar assert.deepEqual(actions, ['update', 'restart']); }); -test('replaces a conflicting supervised Host through canonical authority without a receipt', async (t) => { +test('replaces a conflicting supervised Host with the requested active-work policy', async (t) => { const base = await mkdtemp(join(tmpdir(), 'maka-local-managed-conflict-')); t.after(() => rm(base, { recursive: true, force: true })); const clientDataRoot = join(base, 'client'); const rootPath = join(clientDataRoot, 'workspaces', 'default'); const rootId = 'a'.repeat(64); await mkdir(rootPath, { recursive: true }); - let updated = false; + const policies: Array = []; const service = createDesktopLocalRuntimeHostRemoteAccess({ ipcMain: { handle() {}, removeHandler() {} }, clientDataRoot, @@ -413,8 +413,21 @@ test('replaces a conflicting supervised Host through canonical authority without assert.equal(input.target.rootId, rootId); assert.equal(input.target.deploymentId, RECOVERY_DEPLOYMENT_ID); assert.deepEqual(input.expectedHost, { hostEpoch: 'older-host', pid: 42 }); - assert.equal(input.allowInterruptActiveTasks, true); - updated = true; + policies.push(input.allowInterruptActiveTasks); + if (!input.allowInterruptActiveTasks) { + return { + kind: 'error' as const, + action: 'update' as const, + error: { code: 'active_tasks', message: 'active work remains' }, + } as never; + } + if (policies.length === 3) { + return { + kind: 'result' as const, + action: 'update' as const, + update: { kind: 'already_current', version: '0.2.0' }, + } as never; + } return { kind: 'result' as const, action: 'update' as const, @@ -431,8 +444,13 @@ test('replaces a conflicting supervised Host through canonical authority without new AbortController().signal, ); assert.ok(replacement); - await replacement.replace(); - assert.equal(updated, true); + assert.equal(await replacement.replace('refuse_active_work'), 'active_tasks'); + assert.equal(await replacement.replace('interrupt_active_work'), 'replaced'); + await assert.rejects( + replacement.replace('interrupt_active_work'), + /did not replace the observed Host/u, + ); + assert.deepEqual(policies, [undefined, true, true]); }); test('does not persist recoverable setup authority before Desktop ownership commits', async (t) => { diff --git a/apps/desktop/src/main/__tests__/runtime-host-upgrade-dialog.test.ts b/apps/desktop/src/main/__tests__/runtime-host-upgrade-dialog.test.ts index 9edb5f5888..fa1da71958 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-upgrade-dialog.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-upgrade-dialog.test.ts @@ -25,7 +25,7 @@ import { createRuntimeHostUpgradePrompts } from '../runtime-host-upgrade-dialog. const conflict = { kind: 'upgrade_required', restartable: true, - registration: {}, + registration: { pid: 42, lifecycleMode: 'ephemeral' }, handshake: { activity: { connections: 2, @@ -37,23 +37,23 @@ const conflict = { ], }, }, -} as never; +} as unknown as Parameters[0]; test('localizes upgrade activity without changing decision indexes', () => { const en = buildRuntimeHostUpgradeDialog( conflict, - { action: 'restart', canWait: true }, + 'restart', 'en', ).options; const zh = buildRuntimeHostUpgradeDialog( conflict, - { action: 'restart', canWait: true }, + 'restart', 'zh', ).options; assert.deepEqual(en.buttons, ['Restart Runtime Host', 'Wait', 'Cancel Startup']); assert.deepEqual(zh.buttons, ['重启 Runtime Host', '等待', '取消启动']); - assert.equal(en.defaultId, 1); - assert.equal(zh.defaultId, 1); + assert.equal(en.defaultId, en.cancelId); + assert.equal(zh.defaultId, zh.cancelId); assert.match(zh.detail ?? '', /仍有 2 个其他客户端连接/); assert.match(zh.detail ?? '', /每日回顾: 1/); assert.match(en.detail ?? '', /Scheduled Task: 2/); @@ -65,9 +65,9 @@ test('maps the non-default replacement choice to the replace decision', async () const prompts = createRuntimeHostUpgradePrompts( async () => 'en', async (options) => { - assert.deepEqual(options.buttons, ['Stop Host and Continue', 'Wait', 'Cancel Startup']); + assert.deepEqual(options.buttons, ['Stop Host and Continue', 'Cancel Startup']); assert.equal(options.defaultId, 1); - assert.equal(options.cancelId, 2); + assert.equal(options.cancelId, 1); assert.match(options.detail ?? '', /Maka will stop this Host/); return { response: 0, checkboxChecked: false }; }, @@ -79,13 +79,13 @@ test('maps the non-default replacement choice to the replace decision', async () restartable: false, registration: { pid: 42 }, } as never, - { canReplace: true, canWait: true }, + 'replace_may_interrupt_work', ), 'replace', ); }); -test('does not offer passive waiting for a supervised Host', async () => { +test('defaults non-restartable prompts to cancellation', async () => { const conflict = { kind: 'upgrade_required', restartable: false, @@ -94,15 +94,64 @@ test('does not offer passive waiting for a supervised Host', async () => { const prompts = createRuntimeHostUpgradePrompts( async () => 'en', async (options) => { - assert.deepEqual(options.buttons, ['Stop Host and Continue', 'Cancel Startup']); - assert.equal(options.defaultId, 1); - assert.equal(options.cancelId, 1); + assert.deepEqual(options.buttons, ['Cancel Startup']); + assert.equal(options.defaultId, 0); + assert.equal(options.cancelId, 0); assert.doesNotMatch(options.detail ?? '', /If you wait/u); - return { response: 1, checkboxChecked: false }; + return { response: 0, checkboxChecked: false }; }, ); assert.equal( - await prompts.nonRestartable(conflict, { canReplace: true, canWait: false }), + await prompts.nonRestartable(conflict, 'cancel_only'), 'cancel', ); + + const waitDialog = buildRuntimeHostUpgradeDialog( + { + kind: 'upgrade_required', + restartable: false, + registration: { pid: 42, lifecycleMode: 'ephemeral' }, + } as never, + 'wait', + 'en', + ).options; + assert.deepEqual(waitDialog.buttons, ['Wait', 'Cancel Startup']); + assert.equal(waitDialog.defaultId, waitDialog.cancelId); +}); + +test('does not offer passive waiting when a supervised Host can restart', async () => { + const prompts = createRuntimeHostUpgradePrompts( + async () => 'en', + async (options) => { + assert.deepEqual(options.buttons, ['Restart Runtime Host', 'Cancel Startup']); + assert.equal(options.defaultId, options.cancelId); + assert.doesNotMatch(options.detail ?? '', /If you wait/u); + return { response: 0, checkboxChecked: false }; + }, + ); + + assert.equal( + await prompts.restartable({ + ...conflict, + registration: { pid: 42, lifecycleMode: 'service' }, + } as never), + 'restart', + ); +}); + +test('explains when the safe replacement check could not verify idle state', () => { + const conflict = { + kind: 'upgrade_required' as const, + restartable: false as const, + registration: { pid: 42, lifecycleMode: 'service' as const }, + } as Parameters[0]; + const dialog = buildRuntimeHostUpgradeDialog( + conflict, + 'replace_may_interrupt_work', + 'zh', + ); + + assert.match(dialog.options.detail ?? '', /无法确认此 Host 是否处于空闲状态/u); + assert.doesNotMatch(dialog.options.detail ?? '', /无法报告后台活动/u); + assert.equal(dialog.options.defaultId, dialog.options.cancelId); }); diff --git a/apps/desktop/src/main/app-ipc-main.ts b/apps/desktop/src/main/app-ipc-main.ts index d43f899999..ceb9ac4dd2 100644 --- a/apps/desktop/src/main/app-ipc-main.ts +++ b/apps/desktop/src/main/app-ipc-main.ts @@ -66,8 +66,8 @@ export function registerAppClientIpc( targetIpc.handle('window:setTitlebarControlsVisible', (event, visible: unknown): void => { mainWindowController.setTitlebarControlsVisible(event.sender, visible); }); - targetIpc.handle('window:notifyRendererReady', (): void => { - mainWindowController.notifyRendererReady(); + targetIpc.handle('window:notifyRendererReady', (event): void => { + mainWindowController.notifyRendererReady(event.sender, event.senderFrame); }); targetIpc.handle('window:setThemeSource', (event, themePref: unknown): void => { mainWindowController.setThemeSource(event.sender, themePref); diff --git a/apps/desktop/src/main/browser-message-box.ts b/apps/desktop/src/main/browser-message-box.ts new file mode 100644 index 0000000000..755ecfb8d4 --- /dev/null +++ b/apps/desktop/src/main/browser-message-box.ts @@ -0,0 +1,640 @@ +/* + * 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 { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { MAKA_WORDMARK_PATH } from '@maka/core/maka-wordmark'; +import { isThemePalette, type ThemePalette } from '@maka/core/settings'; +import type { UiLocale } from '@maka/core/ui-locale'; +import type { + BrowserWindow, + BrowserWindowConstructorOptions, + MessageBoxOptions, + MessageBoxReturnValue, + Rectangle, +} from 'electron'; +import { resolveOverlayAssetDir } from './overlay-assets.js'; + +const RESPONSE_URL_PREFIX = 'maka-dialog://response/'; +const DIALOG_WIDTH = 520; +const INITIAL_HEIGHT = 600; +const MIN_HEIGHT = 280; +const WORK_AREA_MARGIN = 32; +const DIALOG_PRESENTATION_TIMEOUT_MS = 30_000; +const DIALOG_DESIGN_TOKENS_FILE = 'browser-dialog-design-tokens.css'; +let cachedDialogDesignTokens: string | undefined; +let activeBrowserMessageBoxPresentations = 0; + +export interface BrowserMessageBoxAppearance { + readonly locale: UiLocale; + readonly palette?: ThemePalette; + readonly dark?: boolean; +} + +export interface BrowserMessageBoxRuntime { + readonly shouldUseDarkColors: boolean; + readonly createWindow: (options: BrowserWindowConstructorOptions) => BrowserWindow; + readonly resolveWorkArea: (parent: BrowserWindow | undefined) => Rectangle; + readonly showNative: ( + options: MessageBoxOptions, + parent: BrowserWindow | undefined, + ) => Promise; + readonly onBrowserError: (error: unknown) => void; + readonly presentationTimeoutMs?: number; +} + +/** Whether closing a temporary dialog must not be interpreted as app shutdown. */ +export function isBrowserMessageBoxPresentationActive(): boolean { + return activeBrowserMessageBoxPresentations > 0; +} + +/** + * Product-styled replacement for Electron's native MessageBox. + * + * BrowserWindow can fail for exactly the class of failures these dialogs + * report, so the native MessageBox remains the last-resort fallback. + */ +export async function showBrowserMessageBox( + options: MessageBoxOptions, + parent: BrowserWindow | undefined, + appearance: BrowserMessageBoxAppearance, +): Promise { + // Keep the presentation helpers importable under plain `node --test`. + // Electron itself is only required when a dialog is actually presented. + const electron = await import('electron'); + return showBrowserMessageBoxWithRuntime(options, parent, appearance, { + shouldUseDarkColors: electron.nativeTheme.shouldUseDarkColors, + createWindow: (windowOptions) => new electron.BrowserWindow(windowOptions), + resolveWorkArea: (nextParent) => resolveWorkArea(electron, nextParent), + showNative: (nextOptions, nextParent) => + showNativeMessageBox(electron, nextOptions, nextParent), + onBrowserError: (error) => { + console.error('[dialog] BrowserWindow presentation failed; using native fallback:', error); + }, + }); +} + +export async function showBrowserMessageBoxWithRuntime( + options: MessageBoxOptions, + parent: BrowserWindow | undefined, + appearance: BrowserMessageBoxAppearance, + runtime: BrowserMessageBoxRuntime, +): Promise { + activeBrowserMessageBoxPresentations += 1; + try { + const visibleParent = (): BrowserWindow | undefined => + parent && !parent.isDestroyed() && parent.isVisible() && !parent.isMinimized() + ? parent + : undefined; + try { + return await presentBrowserMessageBox(runtime, options, visibleParent(), appearance); + } catch (error) { + runtime.onBrowserError(error); + return await runtime.showNative(options, visibleParent()); + } + } finally { + activeBrowserMessageBoxPresentations -= 1; + } +} + +async function showNativeMessageBox( + electron: typeof import('electron'), + options: MessageBoxOptions, + parent: BrowserWindow | undefined, +): Promise { + return parent + ? electron.dialog.showMessageBox(parent, options) + : electron.dialog.showMessageBox(options); +} + +async function presentBrowserMessageBox( + runtime: BrowserMessageBoxRuntime, + options: MessageBoxOptions, + parent: BrowserWindow | undefined, + appearance: BrowserMessageBoxAppearance, +): Promise { + const presentation = normalizeBrowserMessageBoxPresentation(options, { + ...appearance, + dark: appearance.dark ?? runtime.shouldUseDarkColors, + }); + const workArea = runtime.resolveWorkArea(parent); + const width = Math.max(320, Math.min(DIALOG_WIDTH, workArea.width - WORK_AREA_MARGIN * 2)); + const initialHeight = Math.max( + MIN_HEIGHT, + Math.min(INITIAL_HEIGHT, workArea.height - WORK_AREA_MARGIN * 2), + ); + const initialBounds = centeredBounds(parent?.getBounds(), workArea, width, initialHeight); + const win = runtime.createWindow({ + ...initialBounds, + title: presentation.title, + show: false, + frame: false, + transparent: true, + backgroundColor: '#00000000', + hasShadow: true, + roundedCorners: true, + resizable: false, + movable: true, + minimizable: false, + maximizable: false, + fullscreenable: false, + ...(parent ? { parent, modal: true, skipTaskbar: true } : {}), + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + webSecurity: true, + allowRunningInsecureContent: false, + }, + }); + try { + win.setMenuBarVisibility(false); + win.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); + + return await new Promise((resolve, reject) => { + let settled = false; + let presentationTimeout: ReturnType | undefined; + const clearPresentationTimeout = (): void => { + if (!presentationTimeout) return; + clearTimeout(presentationTimeout); + presentationTimeout = undefined; + }; + const finish = (response: number): void => { + if (settled) return; + settled = true; + clearPresentationTimeout(); + resolve({ response, checkboxChecked: false }); + }; + const fail = (error: unknown): void => { + if (settled) return; + settled = true; + clearPresentationTimeout(); + reject(error instanceof Error ? error : new Error(String(error))); + }; + presentationTimeout = setTimeout( + () => fail(new Error('Dialog renderer did not become interactive in time')), + runtime.presentationTimeoutMs ?? DIALOG_PRESENTATION_TIMEOUT_MS, + ); + + win.on('closed', () => finish(presentation.cancelId)); + win.on('unresponsive', () => fail(new Error('Dialog renderer became unresponsive'))); + win.webContents.on('render-process-gone', (_event, details) => { + fail(new Error(`Dialog renderer exited: ${details.reason}`)); + }); + win.webContents.on('will-navigate', (event, url) => { + const response = parseBrowserMessageBoxResponse(url, presentation.buttons.length); + event.preventDefault(); + if (response !== undefined) finish(response); + }); + void win + .loadURL( + `data:text/html;charset=utf-8,${encodeURIComponent( + renderBrowserMessageBoxHtml(presentation), + )}`, + ) + .then(async () => { + if (settled || win.isDestroyed()) return; + const naturalHeight = await measureDialogHeight(win).catch(() => initialHeight); + const height = Math.max( + MIN_HEIGHT, + Math.min(naturalHeight, workArea.height - WORK_AREA_MARGIN * 2), + ); + win.setBounds(centeredBounds(parent?.getBounds(), workArea, width, height), false); + await win.webContents.executeJavaScript( + "document.body.classList.add('maka-dialog-constrained')", + true, + ); + if (settled || win.isDestroyed()) return; + win.show(); + win.focus(); + clearPresentationTimeout(); + }) + .catch(fail); + }); + } finally { + if (!win.isDestroyed()) win.destroy(); + } +} + +interface BrowserMessageBoxPresentation { + readonly type: 'none' | 'info' | 'warning' | 'error' | 'question'; + readonly title: string; + readonly message: string; + readonly detail: string; + readonly buttons: readonly string[]; + readonly defaultId: number; + readonly cancelId: number; + readonly dark: boolean; + readonly locale: UiLocale; + readonly palette: ThemePalette; +} + +function normalizeBrowserMessageBoxPresentation( + options: MessageBoxOptions, + appearance: BrowserMessageBoxAppearance & { readonly dark: boolean }, +): BrowserMessageBoxPresentation { + const buttons = options.buttons?.length ? [...options.buttons] : ['OK']; + const cancelId = validButtonId(options.cancelId, buttons.length) ? options.cancelId : 0; + const defaultId = validButtonId(options.defaultId, buttons.length) + ? options.defaultId + : 0; + const title = options.title || 'Maka'; + const message = options.message || title; + return { + type: messageBoxType(options.type), + title, + message, + detail: options.detail ?? '', + buttons, + defaultId, + cancelId, + dark: appearance.dark, + locale: appearance.locale === 'zh' ? 'zh' : 'en', + palette: isThemePalette(appearance.palette) ? appearance.palette : 'default', + }; +} + +function validButtonId(value: number | undefined, count: number): value is number { + return Number.isInteger(value) && (value as number) >= 0 && (value as number) < count; +} + +function resolveWorkArea( + electron: typeof import('electron'), + parent: BrowserWindow | undefined, +): Rectangle { + if (parent && !parent.isDestroyed()) { + return electron.screen.getDisplayMatching(parent.getBounds()).workArea; + } + return electron.screen.getPrimaryDisplay().workArea; +} + +export function centeredBounds( + parentBounds: Rectangle | undefined, + workArea: Rectangle, + width: number, + height: number, +): Rectangle { + const anchor = parentBounds ?? workArea; + const preferredX = Math.round(anchor.x + (anchor.width - width) / 2); + const preferredY = Math.round(anchor.y + (anchor.height - height) / 2); + return { + x: clamp(preferredX, workArea.x, workArea.x + workArea.width - width), + y: clamp(preferredY, workArea.y, workArea.y + workArea.height - height), + width, + height, + }; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), Math.max(min, max)); +} + +async function measureDialogHeight(win: BrowserWindow): Promise { + const measured: unknown = await win.webContents.executeJavaScript( + "Math.ceil((document.querySelector('.card')?.scrollHeight ?? 0) + 32)", + true, + ); + return typeof measured === 'number' && Number.isFinite(measured) + ? Math.ceil(measured) + : INITIAL_HEIGHT; +} + +function dialogDesignTokens(): string { + cachedDialogDesignTokens ??= readFileSync( + join(resolveOverlayAssetDir(import.meta.url), DIALOG_DESIGN_TOKENS_FILE), + 'utf8', + ); + return cachedDialogDesignTokens; +} + +export function parseBrowserMessageBoxResponse( + value: string, + buttonCount: number, +): number | undefined { + if (!value.startsWith(RESPONSE_URL_PREFIX)) return undefined; + const encodedResponse = value.slice(RESPONSE_URL_PREFIX.length); + if (!/^(?:0|[1-9]\d*)$/u.test(encodedResponse)) return undefined; + const response = Number(encodedResponse); + return Number.isInteger(response) && response >= 0 && response < buttonCount + ? response + : undefined; +} + +export function buildBrowserMessageBoxHtml( + options: MessageBoxOptions, + appearance: BrowserMessageBoxAppearance & { readonly dark: boolean }, +): string { + return renderBrowserMessageBoxHtml( + normalizeBrowserMessageBoxPresentation(options, appearance), + ); +} + +function renderBrowserMessageBoxHtml(input: BrowserMessageBoxPresentation): string { + const nonce = randomUUID().replaceAll('-', ''); + const closeLabel = input.locale === 'zh' ? '关闭' : 'Close'; + const closeButton = ``; + const buttons = input.buttons + .map((label, index) => ({ label, index })) + .sort((left, right) => { + const rank = (index: number): number => + index === input.defaultId ? 2 : index === input.cancelId ? 0 : 1; + return rank(left.index) - rank(right.index); + }) + .map(({ label, index }) => { + const classes = [ + 'decision', + index === input.defaultId + ? 'primary' + : index === input.cancelId + ? 'ghost' + : 'secondary', + ] + .filter(Boolean) + .join(' '); + return ``; + }) + .join(''); + const detailBlock = input.detail + ? `
${escapeHtml(input.detail)}
` + : ''; + const statusIcon = + input.type === 'question' + ? '' + : input.type === 'info' || input.type === 'none' + ? '' + : ''; + + return ` + + + + + + ${escapeHtml(input.title)} + + + +
+
+ + ${closeButton} +
+
+
+ +
+

${escapeHtml(input.title)}

+
${escapeHtml(input.message)}
+
+
+ ${detailBlock} +
+
${buttons}
+
+ + +`; +} + +function messageBoxType(value: MessageBoxOptions['type']): BrowserMessageBoxPresentation['type'] { + return value === 'warning' || value === 'error' || value === 'question' || value === 'info' + ? value + : 'none'; +} + +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/gu, (character) => { + const entities: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }; + return entities[character] ?? character; + }); +} diff --git a/apps/desktop/src/main/main-renderer-process-gone.ts b/apps/desktop/src/main/main-renderer-process-gone.ts index 4ee6904a5b..a2e410dae0 100644 --- a/apps/desktop/src/main/main-renderer-process-gone.ts +++ b/apps/desktop/src/main/main-renderer-process-gone.ts @@ -17,7 +17,7 @@ * under the License. */ -import type { RenderProcessGoneDetails } from 'electron'; +import type { Event, RenderProcessGoneDetails } from 'electron'; interface RenderProcessGoneSource { once( @@ -26,6 +26,52 @@ interface RenderProcessGoneSource { ): void; } +interface MainRendererReloadSource { + once(event: 'unresponsive', listener: () => void): void; + once( + event: 'render-process-gone', + listener: (event: Event, details: RenderProcessGoneDetails) => void, + ): void; + once(event: 'destroyed', listener: () => void): void; + on( + event: 'did-fail-load', + listener: ( + event: Event, + errorCode: number, + errorDescription: string, + validatedURL: string, + isMainFrame: boolean, + frameProcessId: number, + frameRoutingId: number, + ) => void, + ): void; + off(event: 'unresponsive', listener: () => void): void; + off( + event: 'render-process-gone', + listener: (event: Event, details: RenderProcessGoneDetails) => void, + ): void; + off(event: 'destroyed', listener: () => void): void; + off( + event: 'did-fail-load', + listener: ( + event: Event, + errorCode: number, + errorDescription: string, + validatedURL: string, + isMainFrame: boolean, + frameProcessId: number, + frameRoutingId: number, + ) => void, + ): void; + isDestroyed(): boolean; + reload(): void; +} + +export interface MainRendererFrameIdentity { + readonly processId: number; + readonly frameToken: string; +} + export function observeMainRendererProcessGone(deps: { readonly source: RenderProcessGoneSource; readonly shutdownSignal: AbortSignal; @@ -36,3 +82,99 @@ export function observeMainRendererProcessGone(deps: { deps.onUnexpectedExit(details); }); } + +/** + * Waits for a crashed main Renderer to report its first committed paint before + * recovery is successful. The ordinary one-shot crash observer is re-armed + * synchronously by `onReady`, leaving no successful-recovery gap unobserved. + */ +export function reloadMainRendererProcess(deps: { + readonly source: MainRendererReloadSource; + readonly shutdownSignal: AbortSignal; + readonly subscribeMainFrameCommitted: ( + listener: (frame: MainRendererFrameIdentity) => void, + ) => () => void; + readonly subscribeRendererReady: ( + listener: (frame: MainRendererFrameIdentity) => boolean, + ) => () => void; + readonly onReady: () => void; + readonly timeoutMs?: number; +}): Promise { + if (deps.shutdownSignal.aborted || deps.source.isDestroyed()) { + return Promise.resolve(false); + } + return new Promise((resolve) => { + let settled = false; + let timeout: ReturnType | undefined; + let committedFrame: MainRendererFrameIdentity | undefined; + let unsubscribeMainFrameCommitted = (): void => {}; + let unsubscribeRendererReady = (): void => {}; + const cleanup = (): void => { + if (timeout) clearTimeout(timeout); + unsubscribeMainFrameCommitted(); + unsubscribeRendererReady(); + deps.source.off('did-fail-load', onFailed); + deps.source.off('unresponsive', onUnresponsive); + deps.source.off('render-process-gone', onGone); + deps.source.off('destroyed', onDestroyed); + deps.shutdownSignal.removeEventListener('abort', onAborted); + }; + const settle = (loaded: boolean): void => { + if (settled) return; + settled = true; + cleanup(); + resolve(loaded); + }; + const onRendererReady = (frame: MainRendererFrameIdentity): boolean => { + if (!sameFrame(frame, committedFrame)) return false; + try { + deps.onReady(); + settle(true); + return true; + } catch { + settle(false); + return false; + } + }; + const onFailed = ( + _event: Event, + _errorCode: number, + _errorDescription: string, + _validatedURL: string, + isMainFrame: boolean, + ): void => { + if (isMainFrame) settle(false); + }; + const onUnresponsive = (): void => settle(false); + const onGone = (_event: Event, _details: RenderProcessGoneDetails): void => settle(false); + const onDestroyed = (): void => settle(false); + const onAborted = (): void => settle(false); + + unsubscribeMainFrameCommitted = deps.subscribeMainFrameCommitted((frame) => { + committedFrame = frame; + }); + unsubscribeRendererReady = deps.subscribeRendererReady(onRendererReady); + deps.source.on('did-fail-load', onFailed); + deps.source.once('unresponsive', onUnresponsive); + deps.source.once('render-process-gone', onGone); + deps.source.once('destroyed', onDestroyed); + deps.shutdownSignal.addEventListener('abort', onAborted, { once: true }); + timeout = setTimeout(() => settle(false), deps.timeoutMs ?? 30_000); + try { + deps.source.reload(); + } catch { + settle(false); + } + }); +} + +function sameFrame( + actual: MainRendererFrameIdentity, + expected: MainRendererFrameIdentity | undefined, +): boolean { + if (!expected) return false; + return ( + actual.processId === expected.processId && + actual.frameToken === expected.frameToken + ); +} diff --git a/apps/desktop/src/main/main-window.ts b/apps/desktop/src/main/main-window.ts index 730b7fb1b5..a2c61a5473 100644 --- a/apps/desktop/src/main/main-window.ts +++ b/apps/desktop/src/main/main-window.ts @@ -17,7 +17,7 @@ * under the License. */ -import { app, BrowserWindow, dialog, nativeTheme, screen, shell } from 'electron'; +import { app, BrowserWindow, dialog, nativeTheme, screen, shell, webFrameMain } from 'electron'; import { mkdir } from 'node:fs/promises'; import { join } from 'node:path'; import { appIconForTheme, type AppSettings } from '@maka/core/settings'; @@ -29,7 +29,11 @@ import { BrowserViewManager } from './browser/view-manager.js'; import type { E2eFixture } from './e2e-fixture.js'; import { installMainWindowPermissionPolicy } from './main-window-permission-policy.js'; import { loadMainRenderer, resolveMainRendererEntry } from './main-renderer-loader.js'; -import { observeMainRendererProcessGone } from './main-renderer-process-gone.js'; +import { + type MainRendererFrameIdentity, + observeMainRendererProcessGone, + reloadMainRendererProcess, +} from './main-renderer-process-gone.js'; import { isDarkAppearance, isThemePreference, toNativeThemeSource } from './theme-source.js'; import { createWindowRevealGate } from './window-reveal.js'; import { createWindowsMaximizeRendererSync } from './windows-maximize-renderer-sync.js'; @@ -43,10 +47,18 @@ type SettingsReader = { export interface MainWindowController { createWindow(signal: AbortSignal): Promise; + /** + * Reload only the crashed main Renderer, preserving the Desktop process, + * Runtime Host, background services, and current BrowserWindow. + */ + reloadMainRenderer(): Promise; send(channel: string, ...args: unknown[]): void; // PR-SHOW-AFTER-FIRST-COMMIT: reveal the hidden window after the renderer's // first React commit. Idempotent + e2e-fixture-safe (see notifyRendererReady). - notifyRendererReady(): void; + notifyRendererReady( + sender: Electron.WebContents, + senderFrame: Electron.WebFrameMain | null, + ): void; setTitlebarControlsVisible(sender: Electron.WebContents, visible: unknown): void; setThemeSource(sender: Electron.WebContents, themePref: unknown): void; setTitleBarOverlayTheme(sender: Electron.WebContents, theme: unknown): void; @@ -170,12 +182,45 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main // skeleton anyway. The gate defers those focus requests until markReady. const revealGate = createWindowRevealGate(keepHiddenForE2eFixture); let showFallbackTimer: NodeJS.Timeout | undefined; + let rendererRecoveryReadiness: + | { + readonly contents: Electron.WebContents; + listener?: (frame: MainRendererFrameIdentity) => boolean; + } + | undefined; + let mainWindowShutdownSignal: AbortSignal | undefined; const clearShowFallbackTimer = (): void => { if (showFallbackTimer) { clearTimeout(showFallbackTimer); showFallbackTimer = undefined; } }; + const armShowFallbackTimer = (target: BrowserWindow): void => { + clearShowFallbackTimer(); + if (keepHiddenForE2eFixture || target.isDestroyed() || target.isVisible()) return; + showFallbackTimer = setTimeout(() => { + showFallbackTimer = undefined; + if (!target.isDestroyed()) revealGate.markReady(target); + }, SHOW_FALLBACK_TIMEOUT_MS); + }; + + const observeRendererProcess = (target: BrowserWindow, signal: AbortSignal): void => { + observeMainRendererProcessGone({ + source: target.webContents, + shutdownSignal: signal, + onUnexpectedExit: (details) => { + console.error( + `[renderer] main Renderer process exited unexpectedly: reason=${details.reason} exitCode=${details.exitCode}`, + ); + void Promise.resolve() + .then(() => deps.onRendererProcessGone(details)) + .catch((error) => { + console.error('[renderer] failed to handle main Renderer process exit:', error); + app.quit(); + }); + }, + }); + }; function getBrowserViews(): BrowserViewManager { if (!browserViews) { @@ -374,21 +419,8 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main allowRunningInsecureContent: false, }, }); - observeMainRendererProcessGone({ - source: mainWindow.webContents, - shutdownSignal: signal, - onUnexpectedExit: (details) => { - console.error( - `[renderer] main Renderer process exited unexpectedly: reason=${details.reason} exitCode=${details.exitCode}`, - ); - void Promise.resolve() - .then(() => deps.onRendererProcessGone(details)) - .catch((error) => { - console.error('[renderer] failed to handle main Renderer process exit:', error); - app.quit(); - }); - }, - }); + mainWindowShutdownSignal = signal; + observeRendererProcess(mainWindow, signal); installMainWindowPermissionPolicy(mainWindow.webContents, rendererEntry.url); // Two-layer external-link hygiene: assistant markdown often emits `` @@ -500,12 +532,7 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main // If renderer-ready arrived while loadURL/loadFile was resolving, the // window is already visible and no timer is needed. E2e-fixture windows // remain hidden for their whole lifecycle. - if (!keepHiddenForE2eFixture && !mainWindow.isVisible()) { - showFallbackTimer = setTimeout(() => { - showFallbackTimer = undefined; - revealGate.markReady(mainWindow); - }, SHOW_FALLBACK_TIMEOUT_MS); - } + armShowFallbackTimer(mainWindow); if (process.env.MAKA_REAL_WINDOW_SMOKE === '1') { emitRealWindowSmokeDiagnostic('after-load'); setTimeout(() => emitRealWindowSmokeDiagnostic('settled-1000ms'), 1000); @@ -514,8 +541,77 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main return { createWindow, + async reloadMainRenderer() { + const target = mainWindow; + const signal = mainWindowShutdownSignal; + if ( + !target || + target.isDestroyed() || + target.webContents.isDestroyed() || + !signal || + signal.aborted + ) return false; + clearShowFallbackTimer(); + revealGate.reset(); + target.hide(); + const contents = target.webContents; + const readiness: { + readonly contents: Electron.WebContents; + listener?: (frame: MainRendererFrameIdentity) => boolean; + } = { contents }; + rendererRecoveryReadiness = readiness; + let loaded = false; + try { + loaded = await reloadMainRendererProcess({ + source: contents, + shutdownSignal: signal, + subscribeMainFrameCommitted: (listener) => { + const onFrameNavigated = ( + _event: Electron.Event, + _url: string, + _httpResponseCode: number, + _httpStatusText: string, + isMainFrame: boolean, + frameProcessId: number, + frameRoutingId: number, + ): void => { + if (!isMainFrame) return; + const frame = webFrameMain.fromId(frameProcessId, frameRoutingId); + if (frame) listener(rendererFrameIdentity(frame)); + }; + contents.on('did-frame-navigate', onFrameNavigated); + return () => contents.off('did-frame-navigate', onFrameNavigated); + }, + subscribeRendererReady: (listener) => { + readiness.listener = listener; + return () => { + if (readiness.listener === listener) readiness.listener = undefined; + }; + }, + onReady: () => { + // The previous one-shot observer was consumed by the crash. Re-arm + // it before successful recovery is exposed to the caller. + observeRendererProcess(target, signal); + }, + }); + if (!loaded) console.error('[renderer] main Renderer reload did not become ready'); + return loaded; + } finally { + if (rendererRecoveryReadiness === readiness) { + if (loaded) rendererRecoveryReadiness = undefined; + else readiness.listener = undefined; + } + } + }, send: safeSendToRenderer, - notifyRendererReady() { + notifyRendererReady(sender, senderFrame) { + if (!mainWindow || mainWindow.isDestroyed() || sender !== mainWindow.webContents) return; + const recovery = rendererRecoveryReadiness; + if (recovery?.contents === sender) { + // A failed attempt stays as a tombstone, and a retry accepts ready only + // from the main frame committed by that exact reload navigation. + if (!senderFrame || !recovery.listener?.(rendererFrameIdentity(senderFrame))) return; + } // PR-SHOW-AFTER-FIRST-COMMIT: the renderer finished its first React // commit. Cancel the fallback timer and reveal the window through the // shared gate — idempotent, so an HMR reload re-firing this signal (or a @@ -605,6 +701,13 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main }; } +function rendererFrameIdentity(frame: Electron.WebFrameMain): MainRendererFrameIdentity { + return { + processId: frame.processId, + frameToken: frame.frameToken, + }; +} + function isTitleBarOverlayTheme(value: unknown): value is { isDark: boolean; backgroundColor: string } { if (!value || typeof value !== 'object') return false; const candidate = value as { isDark?: unknown; backgroundColor?: unknown }; diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 1780b0c4c2..8cea0b9d1d 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -43,6 +43,7 @@ import { showFatalStartupError } from './native-diagnostic-dialog.js'; import { isIsolatedE2e } 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'; let recoveryJournal: MainProcessRecoveryJournal | undefined; installMainProcessLogCapture(mainProcessLogBuffer, () => recoveryJournal?.markDirty()); @@ -57,6 +58,12 @@ installMainProcessLogCapture(mainProcessLogBuffer, () => recoveryJournal?.markDi // path logic. See https://github.com/maka-agent/maka-agent/issues/2252. app.setName(app.isPackaged ? 'Maka' : 'Maka Dev'); +// Electron otherwise quits implicitly when the last BrowserWindow closes. +// Startup and fatal-recovery surfaces can be the only window, so keep process +// lifetime explicit; runtime-host-boot installs the normal platform policy +// after startup, and every early terminal path calls app.exit itself. +app.on('window-all-closed', () => {}); + const updateTestUserData = resolveUpdateTestUserDataDirectory({ feedUrl: process.env.MAKA_UPDATE_TEST_FEED, explicitDirectory: process.env.MAKA_UPDATE_TEST_USER_DATA_DIR, @@ -76,9 +83,9 @@ if (isIsolatedE2e && process.env.MAKA_E2E_USER_DATA_DIR) { } // Electron does not enforce single-instance by default. Must run before any -// workspace/store setup below -- a losing second process exits immediately, -// before touching shared state. See the 'second-instance' listener in -// runtime-host-boot.ts for what the surviving process does about it. +// workspace/store setup below -- a losing second process never touches shared +// state. See the 'second-instance' listener in runtime-host-boot.ts for what +// the surviving process does about it. if (!app.requestSingleInstanceLock()) { if (!app.isPackaged) { // Dev: losing the lock must NOT pretend to have started (exit 0 would be @@ -87,18 +94,46 @@ if (!app.requestSingleInstanceLock()) { // one-shot result file. A direct launcher explicitly promises to consume // the exit code; a TCC launcher proves it has a consumer only when the // result write succeeds. Any other entry (Dock, Spotlight, Quit & Reopen) - // gets a native box — fail toward the dialog. Linux pre-ready showErrorBox - // degrades to stderr (no GUI); documented in electron.d.ts. Packaged builds - // keep the existing UX (double-click focuses the first window) — the gate - // is a semantic boundary. + // waits for ready and gets the same product-styled temporary window as + // startup recovery. Packaged builds keep the existing UX (double-click + // focuses the first window) — the gate is a semantic boundary. const resultReported = reportDevelopmentLaunchResult(process.argv, { status: 'loser' }); if (!resultReported && shouldShowLoserDialog(process.argv)) { - dialog.showErrorBox( - 'Maka Dev', - `Another instance holds the Maka Dev profile (${app.getPath('userData')}). Quit it and retry.`, - ); - } - app.exit(DEV_LOSER_EXIT_CODE); + const profilePath = app.getPath('userData'); + void app + .whenReady() + .then(() => { + const locale = resolveSystemUiLocale(app.getPreferredSystemLanguages()); + const isChinese = locale === 'zh'; + return showBrowserMessageBox( + { + type: 'warning', + title: isChinese ? 'Maka Dev 已在运行' : 'Maka Dev is already running', + message: isChinese + ? '另一个 Maka Dev 实例正在使用此开发配置。' + : 'Another Maka Dev instance is using this development profile.', + detail: isChinese + ? `开发配置:${profilePath}\n\n请先退出正在运行的实例,然后重试。` + : `Development profile: ${profilePath}\n\nQuit the running instance, then retry.`, + buttons: [isChinese ? '退出' : 'Exit'], + defaultId: 0, + cancelId: 0, + }, + undefined, + { locale }, + ); + }) + .catch((error) => { + console.error('[dev] styled single-instance dialog failed:', error); + dialog.showErrorBox( + 'Maka Dev', + `Another instance holds the Maka Dev profile (${profilePath}). Quit it and retry.`, + ); + }) + .finally(() => { + app.exit(DEV_LOSER_EXIT_CODE); + }); + } else app.exit(DEV_LOSER_EXIT_CODE); } else { app.exit(0); } @@ -170,8 +205,9 @@ if (!app.requestSingleInstanceLock()) { // fixture-fatal path in runtime-host-boot.ts: print a parseable line and exit fast). if (!isIsolatedE2e) { const buildInfo = resolveBuildInfo(app.isPackaged, app.getAppPath()); + const locale = resolveSystemUiLocale(app.getPreferredSystemLanguages()); await showFatalStartupError(error, { - locale: resolveSystemUiLocale(app.getPreferredSystemLanguages()), + locale, environment: () => captureDesktopDiagnosticEnvironment({ appVersion: app.getVersion(), @@ -182,7 +218,8 @@ if (!app.requestSingleInstanceLock()) { }), mainLogs: () => mainProcessLogBuffer.snapshot(), writeClipboard: (report) => clipboard.writeText(report), - showMessageBox: (options) => dialog.showMessageBox(options), + showMessageBox: (options) => + showBrowserMessageBox(options, undefined, { locale }), }); } } finally { diff --git a/apps/desktop/src/main/native-diagnostic-dialog-copy.ts b/apps/desktop/src/main/native-diagnostic-dialog-copy.ts index db8cb0fcd4..c671249561 100644 --- a/apps/desktop/src/main/native-diagnostic-dialog-copy.ts +++ b/apps/desktop/src/main/native-diagnostic-dialog-copy.ts @@ -36,7 +36,7 @@ interface NativeDiagnosticDialogCopy { readonly title: string; readonly message: string; readonly detail: string; - readonly relaunch: string; + readonly recover: string; readonly exit: string; }; readonly runtimeHostRecovery: { @@ -83,8 +83,9 @@ const COPY = { rendererGone: { title: 'Maka needs to recover', message: "Maka's interface stopped unexpectedly.", - detail: 'Relaunch Maka to continue, or exit and reopen it later.', - relaunch: 'Relaunch', + detail: + 'Recover the interface without restarting Maka. Runtime Host, running work, and background services will stay in place.', + recover: 'Recover Interface', exit: 'Exit', }, runtimeHostRecovery: { @@ -133,8 +134,8 @@ const COPY = { rendererGone: { title: 'Maka 需要恢复', message: 'Maka 界面意外停止运行。', - detail: '重新启动 Maka 以继续,或退出后稍后再打开。', - relaunch: '重新启动', + detail: '只恢复界面,不重启 Maka。Runtime Host、正在运行的工作和后台服务都会保留。', + recover: '恢复界面', exit: '退出', }, runtimeHostRecovery: { diff --git a/apps/desktop/src/main/native-diagnostic-dialog.ts b/apps/desktop/src/main/native-diagnostic-dialog.ts index c3fe2592ec..231c30a2e1 100644 --- a/apps/desktop/src/main/native-diagnostic-dialog.ts +++ b/apps/desktop/src/main/native-diagnostic-dialog.ts @@ -125,7 +125,7 @@ export async function showFatalStartupError( export async function showMainRendererProcessGoneDialog( deps: DiagnosticDialogDeps, -): Promise<'relaunch' | 'exit'> { +): Promise<'recover' | 'exit'> { const copy = getNativeDiagnosticDialogCopy(deps.locale).rendererGone; const result = await showMessageBoxWithDiagnostics( { @@ -133,14 +133,14 @@ export async function showMainRendererProcessGoneDialog( title: copy.title, message: copy.message, detail: copy.detail, - buttons: [copy.relaunch, copy.exit], + buttons: [copy.recover, copy.exit], defaultId: 0, cancelId: 1, noLink: true, }, deps, ); - return result.response === 0 ? 'relaunch' : 'exit'; + return result.response === 0 ? 'recover' : 'exit'; } export async function showRuntimeHostStartupRecoveryDialog( @@ -162,7 +162,7 @@ export async function showRuntimeHostStartupRecoveryDialog( message: copy.message, detail, buttons: [input.activeTasks ? copy.repairAndRestart : copy.repair, copy.exit], - defaultId: 0, + defaultId: input.activeTasks || input.repairError ? 1 : 0, cancelId: 1, noLink: true, }, diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index e1f7ab5898..e57314fa05 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -19,8 +19,8 @@ import { app, + type BrowserWindow, clipboard, - dialog, ipcMain, nativeTheme, powerSaveBlocker, @@ -81,6 +81,11 @@ import { readFileCapped, resolvePickedAttachments } from "./attachment-ingest.js import { registerBrowserIpc } from "./browser-ipc-main.js"; import { browserViewHost } from "./browser/browser-host.js"; import { releaseBrowserSession } from "./browser/session.js"; +import { + isBrowserMessageBoxPresentationActive, + showBrowserMessageBox, + type BrowserMessageBoxAppearance, +} 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"; @@ -99,6 +104,7 @@ import { seedE2eFixture, } from "./e2e-fixture.js"; import { createKeepSystemAwakeController } from "./keep-system-awake.js"; +import { isDarkAppearance } from "./theme-source.js"; import { readWithFallback, type ReconnectableReadIpcMain, @@ -335,6 +341,19 @@ const desktopDiagnostics: DesktopDiagnosticsDeps = { 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 }; + return showBrowserMessageBox(options, resolveBrowserDialogParent(), appearance); +} function showStartupDiagnosticDialog( options: MessageBoxOptions, @@ -343,7 +362,7 @@ function showStartupDiagnosticDialog( ): Promise { return showMessageBoxWithDiagnostics(options, { locale, - showMessageBox: (next) => dialog.showMessageBox(next), + showMessageBox: (nextOptions) => showDesktopMessageBox(nextOptions, { locale }), copyDiagnostics: () => copyDesktopDiagnosticReport( desktopDiagnostics, @@ -382,6 +401,21 @@ 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 workBoardStore = createWorkBoardStore(workspaceRoot); const mcpManager = new McpClientManager({ @@ -435,16 +469,24 @@ const mainWindowController = createMainWindowController({ description: `Reason: ${details.reason}`, details: `Exit code: ${details.exitCode}`, }); - const decision = await showMainRendererProcessGoneDialog({ - locale: desktopLocale.current(), - copyDiagnostics: () => - copyDesktopDiagnosticReport(desktopDiagnostics, diagnosticInput), - showMessageBox: (options) => dialog.showMessageBox(options), - }); - if (decision === "relaunch") app.relaunch(); + 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 = () => mainWindowController.browserWindow(); const runtimeHostSshTerminal = createDesktopRuntimeHostSshTerminal({ ipcMain, send: (channel, event) => mainWindowController.send(channel, event), @@ -767,16 +809,20 @@ const clientSettingsTools = buildClientSettingsTools({ return settings; }, confirm: async (changes) => { - const copy = clientSettingsConfirmation(changes, await desktopLocale.resolve()); - const result = await dialog.showMessageBox({ - type: "question", - message: copy.message, - detail: copy.detail, - buttons: copy.buttons, - defaultId: 0, - cancelId: 1, - noLink: true, - }); + const locale = await desktopLocale.resolve(); + const copy = clientSettingsConfirmation(changes, locale); + const result = await showDesktopMessageBox( + { + type: "question", + message: copy.message, + detail: copy.detail, + buttons: copy.buttons, + defaultId: 0, + cancelId: 1, + noLink: true, + }, + { locale }, + ); return result.response === 0; }, }); @@ -1134,9 +1180,10 @@ runtimeHostManager = await startDesktopRuntimeHostWithRecovery({ repairError: input.repairError, activeTasks: input.activeTasks, }); + const locale = await desktopLocale.resolve(); return showRuntimeHostStartupRecoveryDialog(input, { - locale: desktopLocale.current(), - showMessageBox: (options) => dialog.showMessageBox(options), + locale, + showMessageBox: (options) => showDesktopMessageBox(options, { locale }), copyDiagnostics: () => copyDesktopDiagnosticReport( desktopDiagnostics, @@ -1839,7 +1886,7 @@ function wireLifecycle(): void { app.on("window-all-closed", () => { native.computerUseOverlay.destroyAll(); native.computerUsePip.destroyAll(); - if (process.platform !== "darwin") app.quit(); + if (process.platform !== "darwin" && !isBrowserMessageBoxPresentationActive()) app.quit(); }); app.on("before-quit", quitCoordinator.handleBeforeQuit); quitCoordinator.focusOrCreateWindow(); @@ -1857,7 +1904,7 @@ async function prepareRuntimeHostDesktopQuit(): Promise { async function showRuntimeHostQuitFailure(error: unknown): Promise { const locale = await desktopLocale.resolve(); - await dialog.showMessageBox(buildRuntimeHostQuitFailureDialog(error, locale)); + await showDesktopMessageBox(buildRuntimeHostQuitFailureDialog(error, locale), { locale }); } async function closeRuntimeHostDesktop(): Promise { @@ -1947,7 +1994,7 @@ async function promptForDefaultRuntimeHostRecovery(input: { readonly profileName: string; readonly error: Error; }): Promise<"retry" | "use_local" | "keep_offline"> { - const locale = resolveSystemUiLocale(app.getPreferredSystemLanguages()); + const locale = await desktopLocale.resolve(); const dialogInput = defaultRuntimeHostRecoveryDialog({ ...input, locale }); const { response } = await showStartupDiagnosticDialog( dialogInput.options, diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts index de155eeb53..60dd17f72d 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -36,7 +36,11 @@ import { type RuntimeHostRetirementMode, type RuntimeHostSshInteraction, } from '@maka/runtime-host/client'; -import type { HostRegistration, HostStatusResult } from '@maka/runtime-host/protocol'; +import { + isHostActivityIdle, + type HostRegistration, + type HostStatusResult, +} from '@maka/runtime-host/protocol'; import type { DesktopTargetSessionRef } from '../shared/runtime-host-identity.js'; import { startDesktopRuntimeHostCandidate, @@ -147,14 +151,13 @@ export class DesktopLocalHostRetirementError extends Error { export type RuntimeHostRestartDecision = 'restart' | 'wait' | 'cancel'; export type RuntimeHostNonRestartableDecision = 'replace' | 'wait' | 'cancel'; - -export interface RuntimeHostNonRestartableActions { - readonly canReplace: boolean; - readonly canWait: boolean; -} +export type RuntimeHostNonRestartableAction = + | 'replace_may_interrupt_work' + | 'wait' + | 'cancel_only'; export interface RuntimeHostLocalReplacement { - replace(): Promise; + replace(activeWorkPolicy: RuntimeHostRetirementMode): Promise<'replaced' | 'active_tasks'>; } export class RuntimeHostUpgradeCancelledError extends RuntimeHostPermanentReconnectError { @@ -191,7 +194,7 @@ export interface RuntimeHostUpgradePrompts { ): Promise; nonRestartable( conflict: RuntimeHostWaitConflict, - actions: RuntimeHostNonRestartableActions, + action: RuntimeHostNonRestartableAction, ): Promise; } @@ -922,10 +925,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { if (result.kind === 'upgrade_required' && result.restartable) { const activity = result.handshake?.activity; const decision = - activity && - activity.connections === 0 && - activity.activeOperations === 0 && - activity.residencies.length === 0 + activity && isHostActivityIdle(activity) ? 'restart' : await this.#resolveRestartable(result); if (decision === 'cancel') { @@ -946,11 +946,26 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { const replacement = target.input.profileTarget ? undefined : await this.resolveLocalHostReplacement?.(result.registration, signal); - const decision = await this.#resolveNonRestartable(result, { - canReplace: replacement !== undefined, - canWait: - replacement === undefined && result.registration.lifecycleMode !== 'service', - }); + const activity = result.handshake?.activity; + if (replacement && activity && isHostActivityIdle(activity)) { + // Only a complete, observed snapshot can authorize silent + // replacement. The replacement transaction closes races with new + // operations; an absent snapshot requires explicit consent because + // it cannot prove that other clients are disconnected. + const attempt = await replacement.replace('refuse_active_work'); + if (attempt === 'replaced') { + takeoverHostEpoch = undefined; + continue; + } + } + // The retirement waiter observes a local PID. Remote targets cannot + // use it to prove that a Host on another machine has exited. + const action: RuntimeHostNonRestartableAction = replacement + ? 'replace_may_interrupt_work' + : target.input.profileTarget + ? 'cancel_only' + : 'wait'; + const decision = await this.#resolveNonRestartable(result, action); if (decision === 'cancel') throw new RuntimeHostUpgradeCancelledError(); if (decision === 'replace') { if (!replacement) { @@ -958,7 +973,12 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { 'This Runtime Host cannot be replaced from the current target', ); } - await replacement.replace(); + const replaced = await replacement.replace('interrupt_active_work'); + if (replaced === 'active_tasks') { + throw new RuntimeHostPermanentReconnectError( + 'This Runtime Host still owns work that cannot be interrupted safely', + ); + } takeoverHostEpoch = undefined; continue; } @@ -980,9 +1000,9 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { #resolveNonRestartable( conflict: RuntimeHostWaitConflict, - actions: RuntimeHostNonRestartableActions, + action: RuntimeHostNonRestartableAction, ): Promise { - if (this.upgradePrompts) return this.upgradePrompts.nonRestartable(conflict, actions); + if (this.upgradePrompts) return this.upgradePrompts.nonRestartable(conflict, action); return this.#missingUpgradePrompt(); } 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 3623734975..942e245c82 100644 --- a/apps/desktop/src/main/runtime-host-local-remote-access.ts +++ b/apps/desktop/src/main/runtime-host-local-remote-access.ts @@ -32,7 +32,10 @@ import type { DesktopLocalRuntimeHostRemoteAccessEnableResult, DesktopLocalRuntimeHostRemoteAccessSnapshot, } from '../preload/bridge-contract.js'; -import type { RuntimeHostDesktopManager } from './runtime-host-desktop-manager.js'; +import type { + RuntimeHostDesktopManager, + RuntimeHostLocalReplacement, +} from './runtime-host-desktop-manager.js'; import type { DesktopRuntimeHostClient } from './runtime-host-client.js'; import type { createDesktopRuntimeHostLocalOperator, @@ -121,7 +124,7 @@ export interface DesktopLocalRuntimeHostRemoteAccess { resolveConflictingHostReplacement( registration: HostRegistration, signal: AbortSignal, - ): Promise<{ replace(): Promise } | undefined>; + ): Promise; repairManagedStartup(input?: { readonly allowManualUpdate?: boolean; readonly allowInterruptActiveTasks?: boolean; @@ -725,7 +728,7 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { } const target = authority.target; return { - replace: () => + replace: (activeWorkPolicy) => serialize(async () => { signal.throwIfAborted(); const setupPackage = await input.resolveSetupPackage(signal); @@ -737,13 +740,16 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { hostEpoch: registration.hostEpoch, pid: registration.pid, }, - allowInterruptActiveTasks: true, + ...(activeWorkPolicy === 'interrupt_active_work' + ? { allowInterruptActiveTasks: true } + : {}), signal, }, () => undefined, ); if (frame.kind === 'error') { - if (frame.error.code === 'target_mismatch') return; + if (frame.error.code === 'active_tasks') return 'active_tasks'; + if (frame.error.code === 'target_mismatch') return 'replaced'; throw conflictReplacementError(registration.pid, frame.error.message); } if (frame.kind === 'progress' || frame.action !== 'update') { @@ -753,11 +759,15 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { ); } if (frame.update.kind === 'active_tasks') { + return 'active_tasks'; + } + if (frame.update.kind === 'already_current') { throw conflictReplacementError( registration.pid, - 'the managed service refused to interrupt active work', + 'the managed service did not replace the observed Host', ); } + return 'replaced'; }), }; }, diff --git a/apps/desktop/src/main/runtime-host-upgrade-copy.ts b/apps/desktop/src/main/runtime-host-upgrade-copy.ts index bcaa1a24ef..53fd212d68 100644 --- a/apps/desktop/src/main/runtime-host-upgrade-copy.ts +++ b/apps/desktop/src/main/runtime-host-upgrade-copy.ts @@ -20,6 +20,7 @@ import type { UiLocale } from '@maka/core/ui-locale'; import type { MessageBoxOptions } from 'electron'; import type { + RuntimeHostNonRestartableAction, RuntimeHostRestartableConflict, RuntimeHostWaitConflict, } from './runtime-host-desktop-manager.js'; @@ -27,6 +28,8 @@ import type { type Conflict = RuntimeHostRestartableConflict | RuntimeHostWaitConflict; export type RuntimeHostUpgradeDialogDecision = 'restart' | 'replace' | 'wait' | 'cancel'; +type RuntimeHostUpgradeAvailability = RuntimeHostNonRestartableAction | 'restart'; + export interface RuntimeHostUpgradeDialog { readonly options: MessageBoxOptions; readonly decisions: readonly RuntimeHostUpgradeDialogDecision[]; @@ -42,41 +45,39 @@ type ActivityKey = export function buildRuntimeHostUpgradeDialog( conflict: Conflict, - availability: { - readonly action: 'restart' | 'replace' | undefined; - readonly canWait: boolean; - }, + availability: RuntimeHostUpgradeAvailability, locale: UiLocale, ): RuntimeHostUpgradeDialog { - const activity = conflict.handshake?.activity; - const hasWork = - (activity?.activeOperations ?? 0) > 0 || (activity?.residencies.length ?? 0) > 0; const copy = UPGRADE_COPY[locale]; const choices: { readonly label: string; readonly decision: RuntimeHostUpgradeDialogDecision }[] = []; - if (availability.action) { + const action = + availability === 'restart' + ? 'restart' + : availability === 'replace_may_interrupt_work' + ? 'replace' + : undefined; + const canWait = + availability === 'wait' || + (availability === 'restart' && conflict.registration.lifecycleMode !== 'service'); + if (action) { choices.push({ - label: availability.action === 'restart' ? copy.restart : copy.replace, - decision: availability.action, + label: action === 'restart' ? copy.restart : copy.replace, + decision: action, }); } - if (availability.canWait) choices.push({ label: copy.wait, decision: 'wait' }); + if (canWait) choices.push({ label: copy.wait, decision: 'wait' }); choices.push({ label: copy.cancel, decision: 'cancel' }); - const defaultDecision = - availability.action === 'restart' && !hasWork - ? 'restart' - : availability.canWait - ? 'wait' - : 'cancel'; + const cancelId = choices.length - 1; return { options: { type: 'warning', title: copy.title, message: copy.message, - detail: formatActivity(conflict, availability, locale), + detail: formatActivity(conflict, availability, canWait, locale), buttons: choices.map((choice) => choice.label), - defaultId: choices.findIndex((choice) => choice.decision === defaultDecision), - cancelId: choices.findIndex((choice) => choice.decision === 'cancel'), + defaultId: cancelId, + cancelId, noLink: true, }, decisions: choices.map((choice) => choice.decision), @@ -85,10 +86,8 @@ export function buildRuntimeHostUpgradeDialog( function formatActivity( conflict: Conflict, - availability: { - readonly action: 'restart' | 'replace' | undefined; - readonly canWait: boolean; - }, + availability: RuntimeHostUpgradeAvailability, + canWait: boolean, locale: UiLocale, ): string { const activity = conflict.handshake?.activity; @@ -103,16 +102,20 @@ function formatActivity( for (const residency of activity.residencies) { lines.push(`${copy.activity[activityKey(residency.label)]}: ${residency.count}`); } + } else if (availability === 'replace_may_interrupt_work') { + lines.push(copy.idleNotVerified); } else lines.push(copy.unknownActivity); - if (availability.action === 'replace') { + if (availability === 'replace_may_interrupt_work') { lines.push('', copy.replaceWarning, copy.replaceExplanation); - } else if (availability.action === 'restart') { + } else if (availability === 'restart') { lines.push('', copy.restartWarning); } else if (conflict.kind !== 'upgrade_required' || !conflict.restartable) { lines.push(''); lines.push(copy.exitOwner(conflict.registration.pid)); } - if (availability.canWait) lines.push(copy.waitExplanation); + if (canWait) { + lines.push(copy.waitExplanation); + } return lines.join('\n'); } @@ -140,6 +143,7 @@ const UPGRADE_COPY = { uptime: (n: number) => `Running for about ${n} ${n === 1 ? 'minute' : 'minutes'}`, connections: (n: number) => `${n} other client(s) are still connected`, operations: (n: number) => `${n} operation(s) are running`, + idleNotVerified: 'Maka could not verify that this Host is idle during the safe replacement check.', unknownActivity: 'This Host version cannot report its background activity.', processId: (pid: number) => `Process ID (PID): ${pid}`, restartWarning: @@ -166,6 +170,7 @@ const UPGRADE_COPY = { uptime: (n: number) => `已运行约 ${n} 分钟`, connections: (n: number) => `仍有 ${n} 个其他客户端连接`, operations: (n: number) => `有 ${n} 个操作正在运行`, + idleNotVerified: '安全替换检查无法确认此 Host 是否处于空闲状态。', unknownActivity: '此 Host 版本无法报告后台活动。', processId: (pid: number) => `进程 ID (PID):${pid}`, restartWarning: '重启会保留持久化状态,但可能中断正在进行的外部工作。', diff --git a/apps/desktop/src/main/runtime-host-upgrade-dialog.ts b/apps/desktop/src/main/runtime-host-upgrade-dialog.ts index acda71f26e..488307dc13 100644 --- a/apps/desktop/src/main/runtime-host-upgrade-dialog.ts +++ b/apps/desktop/src/main/runtime-host-upgrade-dialog.ts @@ -36,12 +36,7 @@ export function createRuntimeHostUpgradePrompts( return { restartable: async (conflict): Promise => { const locale = await resolveLocale(); - const canWait = conflict.registration.lifecycleMode !== 'service'; - const dialog = buildRuntimeHostUpgradeDialog( - conflict, - { action: 'restart', canWait }, - locale, - ); + const dialog = buildRuntimeHostUpgradeDialog(conflict, 'restart', locale); const { response } = await showDialog( dialog.options, locale, @@ -51,14 +46,10 @@ export function createRuntimeHostUpgradePrompts( }, nonRestartable: async ( conflict, - actions, + action, ): Promise => { const locale = await resolveLocale(); - const dialog = buildRuntimeHostUpgradeDialog( - conflict, - { action: actions.canReplace ? 'replace' : undefined, canWait: actions.canWait }, - locale, - ); + const dialog = buildRuntimeHostUpgradeDialog(conflict, action, locale); const { response } = await showDialog( dialog.options, locale, diff --git a/packages/cli/src/__tests__/runtime-host-selected-update.test.ts b/packages/cli/src/__tests__/runtime-host-selected-update.test.ts index 3812f2adbf..2f3e59ce54 100644 --- a/packages/cli/src/__tests__/runtime-host-selected-update.test.ts +++ b/packages/cli/src/__tests__/runtime-host-selected-update.test.ts @@ -48,6 +48,95 @@ const OPTIONS: RuntimeHostSelectedUpdateCliOptions = { }; describe('managed Runtime Host selected update', () => { + it('replaces an exact observed Host even when its package is already current', async () => { + const current = { + configRevision: 7, + deploymentRoot: '/managed', + root: { id: TARGET.rootId, path: TARGET.rootPath }, + lifecycle: { mode: 'supervised', provider: 'test' }, + launch: { + package: { kind: 'npm_registry', version: '2.0.0', integrity: INTEGRITY }, + }, + }; + const status = { + schemaVersion: 1, + action: 'status', + service: { + manager: 'systemd_user', + installed: true, + enabled: true, + active: true, + state: 'running', + pid: 42, + lastExitCode: 0, + installedVersion: '2.0.0', + config: { + schemaVersion: 1, + managedDeploymentRoot: '/managed', + rootPath: TARGET.rootPath, + projectDirectoryRoots: [], + websocket: { host: '127.0.0.1', port: 7400, path: '/runtime-host' }, + launch: { nodePath: process.execPath, cliPath: '/managed/current/dist/cli.js' }, + }, + }, + }; + const expectedHost = { hostEpoch: 'older-host', pid: 42 }; + let output = ''; + let managedReads = 0; + let pruned = false; + const exitCode = await runManagedRuntimeHostUpdateCli( + { + ...OPTIONS, + sourcePackageRoot: '/managed/current', + version: '2.0.0', + managedRootId: TARGET.rootId, + expectedHost, + }, + { + withDeploymentLock: async (_root, operation) => operation(), + prepareDeployment: async () => assert.fail('current package must not be staged'), + prunePackages: async (config) => { + assert.equal(config.configRevision, 8); + pruned = true; + }, + canonical: { + createLifecycleDeps: () => ({}) as never, + assertOperatorDeployment: async () => {}, + recoverDeployment: async (_rootId, _deps, options) => { + assert.deepEqual(options?.expectedOwner, expectedHost); + return { kind: 'active', config: current } as never; + }, + verifyProjection: async () => {}, + assertOperatorConfig: () => {}, + manageLifecycle: async () => { + managedReads += 1; + return status as never; + }, + replaceLifecycle: async (input) => { + assert.equal(input.current, current); + assert.deepEqual(input.expectedOwner, expectedHost); + assert.equal(input.allowInterruptActiveTasks, false); + assert.equal(input.desired.configRevision, 8); + return { kind: 'replaced', config: input.desired }; + }, + }, + writeOutput: (value) => { + output += value; + }, + }, + ); + + assert.equal(exitCode, 0); + assert.equal(managedReads, 2); + assert.equal(pruned, true); + assert.doesNotMatch(output, /"phase":"staging"/u); + const result = decodeRuntimeHostServiceManagementFrame(output.trim().split('\n').at(-1) ?? ''); + assert.equal( + result?.kind === 'result' && result.action === 'update' ? result.update.kind : undefined, + 'repaired', + ); + }); + it('revalidates selection inside the deployment lock before reading service state', async () => { let lockHeld = false; let output = ''; @@ -256,6 +345,27 @@ describe('managed Runtime Host selected update', () => { ); assert.deepEqual(updateInput?.expectedHost, { hostEpoch: 'older-host', pid: 42 }); + const safeUpdates: RuntimeHostUpdateCliOptions[] = []; + assert.equal( + await runManagedRuntimeHostSelectedUpdateCli( + { + ...OPTIONS, + expectedHost: { hostEpoch: 'older-host', pid: 42 }, + }, + { + resolveSelection: async () => selection, + withPackage: async (_candidate, use) => use('/verified/package'), + update: async (input) => { + safeUpdates.push(input); + return 0; + }, + }, + ), + 0, + ); + assert.deepEqual(safeUpdates[0]?.expectedHost, { hostEpoch: 'older-host', pid: 42 }); + assert.equal(safeUpdates[0]?.allowInterruptActiveTasks, undefined); + const legacySelection = updateSelection({ kind: 'manual_action', reason: 'current_compatibility_unknown', diff --git a/packages/cli/src/runtime-host-update-command.ts b/packages/cli/src/runtime-host-update-command.ts index c42b7c6d6f..9914640edd 100644 --- a/packages/cli/src/runtime-host-update-command.ts +++ b/packages/cli/src/runtime-host-update-command.ts @@ -134,6 +134,15 @@ interface RuntimeHostUpdateCliDeps { args: readonly string[], invocation?: RuntimeHostOperatorInvocation, ) => Promise; + readonly canonical: { + readonly createLifecycleDeps: (rootId: string) => RuntimeHostLifecycleTransactionDeps; + readonly assertOperatorDeployment: typeof assertRuntimeHostManagedOperatorDeployment; + readonly recoverDeployment: typeof resolveRecoverableRuntimeHostManagedDeployment; + readonly verifyProjection: typeof verifyRuntimeHostLifecycleProjection; + readonly assertOperatorConfig: typeof assertRuntimeHostManagedOperatorConfig; + readonly manageLifecycle: typeof manageRuntimeHostManagedLifecycle; + readonly replaceLifecycle: typeof replaceRuntimeHostLifecycle; + }; readonly writeOutput: (value: string) => unknown; readonly writeError: (value: string) => unknown; } @@ -157,6 +166,22 @@ export type RuntimeHostUpdateFrame = Extract< export type RuntimeHostUpdateFrameSink = (frame: RuntimeHostUpdateFrame) => void; +function runtimeHostPackageUpdateOperation(input: { + readonly currentVersion: string; + readonly currentIntegrity: string; + readonly targetVersion: string; + readonly targetIntegrity: string; + readonly replaceExpectedHost: boolean; +}): 'already_current' | 'replace_current' | 'update' { + if ( + input.currentVersion !== input.targetVersion || + input.currentIntegrity !== input.targetIntegrity + ) { + return 'update'; + } + return input.replaceExpectedHost ? 'replace_current' : 'already_current'; +} + interface RuntimeHostOperatorInvocation { readonly inheritedFds?: readonly number[]; readonly capabilityRequest?: RuntimeHostOperatorCapability; @@ -199,6 +224,20 @@ export async function runManagedRuntimeHostUpdateCli( createBackend: createPlatformRuntimeHostServiceBackend, verifyReady: verifyRuntimeHostManagedServiceReady, runOperator: runManagedRuntimeHostOperator, + canonical: { + createLifecycleDeps: (rootId) => ({ + convergeOperator: (currentConfig, desiredConfig) => + convergeRuntimeHostManagedOperator(currentConfig, desiredConfig), + verifyOperator: verifyRuntimeHostManagedOperator, + resolveProvider: (requested) => resolveRuntimeHostLifecycleProvider(rootId, requested), + }), + assertOperatorDeployment: assertRuntimeHostManagedOperatorDeployment, + recoverDeployment: resolveRecoverableRuntimeHostManagedDeployment, + verifyProjection: verifyRuntimeHostLifecycleProjection, + assertOperatorConfig: assertRuntimeHostManagedOperatorConfig, + manageLifecycle: manageRuntimeHostManagedLifecycle, + replaceLifecycle: replaceRuntimeHostLifecycle, + }, writeOutput: (value) => process.stdout.write(value), writeError: (value) => process.stderr.write(value), ...overrides, @@ -580,7 +619,7 @@ async function runCanonicalRuntimeHostUpdate( return await deps.withDeploymentLock( resolveRuntimeHostManagedControlRoot(options.managedRootId), async () => { - await assertRuntimeHostManagedOperatorDeployment( + await deps.canonical.assertOperatorDeployment( options.managedRootId, options.operatorDeploymentId, process.argv[1] ?? '', @@ -589,14 +628,8 @@ async function runCanonicalRuntimeHostUpdate( if (rejection) { throw new RuntimeHostUpdateSelectionError(rejection.code, rejection.message); } - const lifecycleDeps: RuntimeHostLifecycleTransactionDeps = { - convergeOperator: (currentConfig, desiredConfig) => - convergeRuntimeHostManagedOperator(currentConfig, desiredConfig), - verifyOperator: verifyRuntimeHostManagedOperator, - resolveProvider: (requested) => - resolveRuntimeHostLifecycleProvider(options.managedRootId, requested), - }; - const recovered = await resolveRecoverableRuntimeHostManagedDeployment( + const lifecycleDeps = deps.canonical.createLifecycleDeps(options.managedRootId); + const recovered = await deps.canonical.recoverDeployment( options.managedRootId, lifecycleDeps, { @@ -612,13 +645,13 @@ async function runCanonicalRuntimeHostUpdate( ); } const current = recovered.config; - await verifyRuntimeHostLifecycleProjection(current, lifecycleDeps); - assertRuntimeHostManagedOperatorConfig( + await deps.canonical.verifyProjection(current, lifecycleDeps); + deps.canonical.assertOperatorConfig( current, options.operatorDeploymentId, process.argv[1] ?? '', ); - const currentStatus = await manageRuntimeHostManagedLifecycle( + const currentStatus = await deps.canonical.manageLifecycle( options.managedRootId, { action: 'status', @@ -665,10 +698,14 @@ async function runCanonicalRuntimeHostUpdate( 'The managed Runtime Host changed after its update candidate was selected', ); } - if ( - options.version === current.launch.package.version && - targetIntegrity === current.launch.package.integrity - ) { + const updateOperation = runtimeHostPackageUpdateOperation({ + currentVersion: current.launch.package.version, + currentIntegrity: current.launch.package.integrity, + targetVersion: options.version, + targetIntegrity, + replaceExpectedHost: options.expectedHost !== undefined, + }); + if (updateOperation === 'already_current') { await deps.prunePackages(current); emit({ schemaVersion: 1, @@ -681,15 +718,17 @@ async function runCanonicalRuntimeHostUpdate( return 0; } emit(progress('checking', current.launch.package.version, options.version)); - emit(progress('staging', current.launch.package.version, options.version)); - staged = await deps.prepareDeployment({ - serviceId: options.managedRootId, - clientDataRoot: options.clientDataRoot, - sourcePackageRoot: options.sourcePackageRoot, - version: options.version, - packageIntegrity: targetIntegrity, - deploymentRoot: current.deploymentRoot, - }); + if (updateOperation === 'update') { + emit(progress('staging', current.launch.package.version, options.version)); + staged = await deps.prepareDeployment({ + serviceId: options.managedRootId, + clientDataRoot: options.clientDataRoot, + sourcePackageRoot: options.sourcePackageRoot, + version: options.version, + packageIntegrity: targetIntegrity, + deploymentRoot: current.deploymentRoot, + }); + } const desired = { ...current, configRevision: current.configRevision + 1, @@ -704,7 +743,7 @@ async function runCanonicalRuntimeHostUpdate( }; emit(progress('retiring', current.launch.package.version, options.version)); emit(progress('replacing', current.launch.package.version, options.version)); - const replacement = await replaceRuntimeHostLifecycle({ + const replacement = await deps.canonical.replaceLifecycle({ operation: 'update', current, desired, @@ -720,8 +759,10 @@ async function runCanonicalRuntimeHostUpdate( deps: lifecycleDeps, }); if (replacement.kind === 'active_tasks') { - await staged.rollback(); - staged = undefined; + if (staged) { + await staged.rollback(); + staged = undefined; + } emit({ schemaVersion: 1, kind: 'result', @@ -738,7 +779,7 @@ async function runCanonicalRuntimeHostUpdate( } staged = undefined; await deps.prunePackages(desired); - const updated = await manageRuntimeHostManagedLifecycle( + const updated = await deps.canonical.manageLifecycle( options.managedRootId, { action: 'status', @@ -756,11 +797,14 @@ async function runCanonicalRuntimeHostUpdate( action: 'update', service: runtimeHostServiceSummary(updated), ...operatorCapabilities(), - update: { - kind: 'updated', - previousVersion: current.launch.package.version, - targetVersion: options.version, - }, + update: + updateOperation === 'replace_current' + ? { kind: 'repaired', version: options.version } + : { + kind: 'updated', + previousVersion: current.launch.package.version, + targetVersion: options.version, + }, }); return 0; }, @@ -869,7 +913,7 @@ export async function runManagedRuntimeHostResolvedUpdateCli( if ( selection.outcome.kind === 'manual_action' && !( - ((options.expectedHost && options.allowInterruptActiveTasks) || + (options.expectedHost || (options.allowManualUpdate && options.managedRootId && options.expectedTarget.deploymentId)) && diff --git a/packages/core/package.json b/packages/core/package.json index 6a76b3ed8d..8ef4b2638c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -148,6 +148,7 @@ "./ui-locale": "./dist/ui-locale.js", "./unified-diff": "./dist/unified-diff.js", "./dev-single-instance": "./dist/dev-single-instance.js", + "./maka-wordmark": "./dist/maka-wordmark.js", "./test-only/async-primitives": "./dist/test-only/async-primitives.js" }, "scripts": { diff --git a/packages/core/src/maka-wordmark.ts b/packages/core/src/maka-wordmark.ts new file mode 100644 index 0000000000..c622ac937c --- /dev/null +++ b/packages/core/src/maka-wordmark.ts @@ -0,0 +1,25 @@ +/* + * 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. + */ + +/** + * Traced outline of Maka's product wordmark in its canonical 460×120 view box. + * Renderers choose color through `currentColor`; the brand geometry has one owner. + */ +export const MAKA_WORDMARK_PATH = + 'M2639 1187 c-38 -29 -39 -46 -39 -479 0 -400 1 -425 19 -455 23 -37 68 -50 108 -31 41 20 53 53 53 154 0 83 2 92 25 114 l24 23 143 -138 c79 -75 156 -143 171 -151 57 -30 127 14 127 79 0 32 -39 75 -217 238 l-82 75 130 103 c157 125 173 152 123 210 -21 25 -34 31 -65 31 -35 0 -55 -13 -209 -140 l-170 -139 0 229 0 228 -26 31 c-20 24 -34 31 -62 31 -21 0 -44 -6 -53 -13z M1926 969 c-109 -26 -216 -114 -264 -217 -24 -50 -27 -69 -27 -162 0 -95 3 -111 28 -162 39 -79 104 -143 185 -181 62 -29 75 -32 168 -32 94 0 105 2 164 33 35 18 64 31 64 30 18 -50 63 -74 111 -58 57 19 60 32 57 258 -2 176 -6 211 -23 258 -24 63 -100 151 -163 188 -84 49 -205 67 -300 45z m142 -170 c93 -20 171 -113 172 -205 0 -58 -45 -140 -97 -177 -112 -80 -278 -26 -328 107 -43 112 34 247 158 276 45 11 41 11 95 -1z M547 960 c-105 -18 -200 -90 -248 -187 -23 -45 -24 -60 -27 -268 -2 -120 -1 -229 3 -242 7 -30 58 -56 94 -48 16 3 38 16 49 28 19 20 21 36 24 227 3 226 8 248 69 290 69 50 157 44 215 -14 46 -46 54 -88 54 -297 0 -179 1 -186 23 -207 43 -40 100 -37 134 7 9 11 12 71 13 201 0 102 5 202 10 222 32 114 172 160 264 87 55 -44 60 -66 61 -284 1 -110 5 -208 9 -218 13 -27 63 -49 97 -42 16 4 38 18 49 32 19 24 20 40 20 228 0 224 -8 268 -61 348 -60 90 -158 139 -280 139 -62 1 -88 -4 -135 -26 -33 -15 -71 -38 -85 -52 l-27 -26 -50 36 c-82 60 -179 83 -275 66z M3659 960 c-137 -23 -264 -138 -299 -268 -53 -202 61 -407 260 -466 102 -30 242 -14 304 35 15 12 28 20 29 18 1 -2 7 -13 13 -24 26 -48 94 -55 135 -14 19 19 20 30 17 237 -3 214 -3 218 -31 273 -50 103 -163 186 -282 208 -65 12 -79 12 -146 1z m114 -201 c33 -65 48 -81 107 -112 59 -31 61 -49 10 -72 -52 -24 -93 -70 -115 -131 -10 -27 -24 -56 -32 -64 -11 -12 -15 -12 -26 0 -8 8 -19 35 -26 59 -14 48 -67 110 -121 139 -19 10 -35 25 -35 33 0 7 21 23 46 35 52 25 106 82 115 122 8 34 24 54 38 49 6 -2 23 -28 39 -58z'; diff --git a/packages/runtime-host/protocol-compatible-changes/host-activity-idle-predicate.json b/packages/runtime-host/protocol-compatible-changes/host-activity-idle-predicate.json new file mode 100644 index 0000000000..a289c69e30 --- /dev/null +++ b/packages/runtime-host/protocol-compatible-changes/host-activity-idle-predicate.json @@ -0,0 +1,8 @@ +{ + "epoch": 92, + "files": [ + "packages/runtime-host/src/protocol/host-status.ts", + "packages/runtime-host/src/protocol/operations.ts" + ], + "reason": "Adds a shared non-wire Host activity predicate and export without changing any protocol codec or message shape" +} diff --git a/packages/runtime-host/src/__tests__/host-kernel.test.ts b/packages/runtime-host/src/__tests__/host-kernel.test.ts index 6e9d938775..8d05440a21 100644 --- a/packages/runtime-host/src/__tests__/host-kernel.test.ts +++ b/packages/runtime-host/src/__tests__/host-kernel.test.ts @@ -1064,6 +1064,45 @@ describe('non-serving Runtime Host kernel', () => { }); }); + test('safe retirement refuses a second client that connected after discovery', async () => { + await withHostPaths(async (paths) => { + const capability = await resolveStorageRoot({ path: paths.root, kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + const host = await RuntimeHostKernel.start({ + owner, + lifecycleMode: 'service', + composition: KERNEL_COMPOSITION, + }); + const replacement = await retryConnect(paths, CURRENT_PROTOCOL); + assert.equal(replacement.kind, 'connected'); + if (replacement.kind !== 'connected') return; + const lateClient = await retryConnect(paths, CURRENT_PROTOCOL); + assert.equal(lateClient.kind, 'connected'); + if (lateClient.kind !== 'connected') return; + + assert.deepEqual( + await replacement.connection.request('host.upgrade.prepare', { + expectedHostEpoch: host.hostEpoch, + allowInterruptActiveTasks: false, + }), + { kind: 'active_tasks' }, + ); + assert.equal(host.state, 'ready'); + + await lateClient.connection.close(); + assert.deepEqual( + await replacement.connection.request('host.upgrade.prepare', { + expectedHostEpoch: host.hostEpoch, + allowInterruptActiveTasks: false, + }), + { kind: 'prepared', pid: process.pid }, + ); + await host.closed; + assert.equal(host.shutdownReason, 'retirement'); + }); + }); + test('an explicit generation takeover drains only the exact unobserved ephemeral Host', async () => { await withHostPaths(async (paths) => { const candidate = await startTestRuntimeHostCandidate(paths, { diff --git a/packages/runtime-host/src/client/connection.ts b/packages/runtime-host/src/client/connection.ts index 9788c79f06..9f13a46141 100644 --- a/packages/runtime-host/src/client/connection.ts +++ b/packages/runtime-host/src/client/connection.ts @@ -43,6 +43,7 @@ import { type ConnectionCatalogChangedFrame, type HostOperationErrorCode, type HostIncompatible, + isHostActivityIdle, type HostRegistration, type HostStatusResult, HOST_OPERATION_SPECS, @@ -1303,9 +1304,7 @@ export async function connectResolvedRuntimeHost( return registration.lifecycleMode === 'ephemeral' && result.handshake.state === 'ready' && result.handshake.activity !== undefined && - result.handshake.activity.connections === 0 && - result.handshake.activity.activeOperations === 0 && - result.handshake.activity.residencies.length === 0 + isHostActivityIdle(result.handshake.activity) ? { kind: 'upgrade_required', registration, diff --git a/packages/runtime-host/src/protocol/host-status.ts b/packages/runtime-host/src/protocol/host-status.ts index a28ef97438..2fdc512b62 100644 --- a/packages/runtime-host/src/protocol/host-status.ts +++ b/packages/runtime-host/src/protocol/host-status.ts @@ -39,6 +39,14 @@ export interface HostActivitySnapshot { readonly residencies: readonly { readonly label: string; readonly count: number }[]; } +export function isHostActivityIdle(activity: HostActivitySnapshot): boolean { + return ( + activity.connections === 0 && + activity.activeOperations === 0 && + activity.residencies.length === 0 + ); +} + export interface HostUpgradePrepareInput { readonly expectedHostEpoch: string; readonly allowInterruptActiveTasks: boolean; diff --git a/packages/runtime-host/src/protocol/operations.ts b/packages/runtime-host/src/protocol/operations.ts index f87902936e..f826da5017 100644 --- a/packages/runtime-host/src/protocol/operations.ts +++ b/packages/runtime-host/src/protocol/operations.ts @@ -77,6 +77,7 @@ export type { HostUpgradePrepareInput, HostUpgradePrepareResult, } from './host-status.js'; +export { isHostActivityIdle } from './host-status.js'; export type { HostOperationError, HostOperationErrorCode, diff --git a/packages/runtime-host/src/server/host-kernel.ts b/packages/runtime-host/src/server/host-kernel.ts index 491e663212..bad2dab4cb 100644 --- a/packages/runtime-host/src/server/host-kernel.ts +++ b/packages/runtime-host/src/server/host-kernel.ts @@ -856,6 +856,10 @@ export class RuntimeHostKernel { } #hasUpgradeBlockingActivity(): boolean { + // The request's own accepted transport is expected. Any other live + // connection arrived after discovery or remained attached and therefore + // requires explicit interruption authority before retirement. + if (this.#acceptedTransports.size > 1) return true; if (this.#activeCommandOperations > 1) return true; return this.#residencies.snapshot().some(({ label }) => label !== 'process-retention'); } diff --git a/packages/ui/src/maka-wordmark.tsx b/packages/ui/src/maka-wordmark.tsx index 54f2618063..69d1405e6e 100644 --- a/packages/ui/src/maka-wordmark.tsx +++ b/packages/ui/src/maka-wordmark.tsx @@ -32,6 +32,7 @@ * they want rather than baking a tint in here. */ +import { MAKA_WORDMARK_PATH } from '@maka/core/maka-wordmark'; import type { CSSProperties } from 'react'; export interface MakaWordmarkProps { @@ -60,7 +61,7 @@ export function MakaWordmark({ width = 104, className, style, title }: MakaWordm > {title && {title}} - + ); diff --git a/scripts/build-cursor-overlay.mjs b/scripts/build-cursor-overlay.mjs index 88dcdd01ca..5ccecdae91 100644 --- a/scripts/build-cursor-overlay.mjs +++ b/scripts/build-cursor-overlay.mjs @@ -25,7 +25,7 @@ import * as esbuild from 'esbuild'; import { resolve, dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { mkdir, copyFile } from 'node:fs/promises'; +import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'; const here = dirname(fileURLToPath(import.meta.url)); const desktop = resolve(here, '..', 'apps', 'desktop'); @@ -44,6 +44,7 @@ const jsToTs = { /** Build the overlay renderer bundle + preload + html into dist/overlay. */ export async function buildCursorOverlay({ logLevel = 'info' } = {}) { await mkdir(outDir, { recursive: true }); + await buildBrowserDialogDesignTokens(); await esbuild.build({ entryPoints: [join(srcOverlay, 'cursor-overlay.ts')], bundle: true, @@ -69,6 +70,34 @@ export async function buildCursorOverlay({ logLevel = 'info' } = {}) { return outDir; } +/** + * Browser-backed recovery dialogs run outside the React renderer, but they + * still consume the same generated Astryx scale and Maka palette authority. + * Keep only maka-tokens.css's token/palette prefix: its later base/component + * recipes target the main document and must not leak into a standalone card. + */ +async function buildBrowserDialogDesignTokens() { + const renderer = join(desktop, 'src', 'renderer'); + const [astryxTheme, makaTokens] = await Promise.all([ + readFile(join(renderer, 'astryx-theme', 'maka.css'), 'utf8'), + readFile(join(renderer, 'maka-tokens.css'), 'utf8'), + ]); + const astryxComponentsMarker = '\n .astryx-heading.level-1 {'; + const astryxTokenEnd = astryxTheme.indexOf(astryxComponentsMarker); + const baseStylesMarker = + '/* =============================================================================\n BASE STYLES'; + const tokenEnd = makaTokens.indexOf(baseStylesMarker); + if (astryxTokenEnd < 0 || tokenEnd < 0) { + throw new Error('Unable to locate the dialog design-token boundaries'); + } + const astryxTokens = `${astryxTheme.slice(0, astryxTokenEnd)}\n}\n}\n`; + await writeFile( + join(outDir, 'browser-dialog-design-tokens.css'), + `${astryxTokens}\n${makaTokens.slice(0, tokenEnd)}`, + 'utf8', + ); +} + /** * The Computer Use picture-in-picture mirror. Same shape as the cursor overlay * — module page bundle + CJS preload + verbatim html — sharing `dist/overlay`