From 6c1485cdb012489eacecd5a19ea0d478c74ab23d Mon Sep 17 00:00:00 2001 From: Wang Date: Tue, 1 Sep 2026 21:16:57 +0800 Subject: [PATCH 01/28] feat(desktop): replace native recovery dialogs Generated-by: OpenAI Codex --- .../__tests__/browser-message-box.test.ts | 100 +++ .../native-diagnostic-dialog.test.ts | 11 +- .../runtime-host-desktop-manager.test.ts | 85 ++- .../runtime-host-local-remote-access.test.ts | 19 +- .../runtime-host-upgrade-dialog.test.ts | 17 + apps/desktop/src/main/browser-message-box.ts | 574 ++++++++++++++++++ apps/desktop/src/main/main-window.ts | 82 ++- apps/desktop/src/main/main.ts | 58 +- .../src/main/native-diagnostic-dialog.ts | 15 +- apps/desktop/src/main/runtime-host-boot.ts | 27 +- .../src/main/runtime-host-desktop-manager.ts | 24 +- .../main/runtime-host-local-remote-access.ts | 22 +- .../src/main/runtime-host-upgrade-copy.ts | 25 +- .../src/main/runtime-host-upgrade-dialog.ts | 6 +- 14 files changed, 977 insertions(+), 88 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/browser-message-box.test.ts create mode 100644 apps/desktop/src/main/browser-message-box.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..103a060611 --- /dev/null +++ b/apps/desktop/src/main/__tests__/browser-message-box.test.ts @@ -0,0 +1,100 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + buildBrowserMessageBoxHtml, + centeredBounds, + parseBrowserMessageBoxResponse, +} from '../browser-message-box.js'; + +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', + detail: '', + buttons: ['Replace ', 'Cancel', 'Copy & Diagnostics'], + defaultId: 0, + cancelId: 1, + }, + { + buttons: ['Replace ', 'Cancel', 'Copy & Diagnostics'], + defaultId: 0, + cancelId: 1, + dark: true, + }, + ); + + assert.match(html, /data-theme="dark"/u); + assert.match(html, /class="wordmark"/u); + assert.match(html, /color: #71a8fd/u); + assert.match(html, /border-radius: 12px/u); + assert.match(html, /height: 32px/u); + assert.match(html, /<img src=x onerror=alert\(1\)>/u); + assert.match(html, /Maka & Runtime Host/u); + assert.match(html, /<\/div><script>globalThis\.pwned = true<\/script>/u); + assert.match(html, /Replace <Host>/u); + assert.doesNotMatch(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, /class="decision primary"[^>]*data-response="0" autofocus/u); + assert.match(html, /default-src 'none'/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 a5d0d1fa92..70aeaf3d5b 100644 --- a/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts +++ b/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts @@ -47,7 +47,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; @@ -80,7 +80,7 @@ test('copies diagnostics as an auxiliary native-dialog action', async () => { assert.match(shown[1]?.detail ?? '', /Diagnostics copied/); }); -test('fatal startup errors remain copyable without a renderer or BrowserWindow', async () => { +test('fatal startup errors remain copyable before the main Renderer exists', async () => { const shown: MessageBoxOptions[] = []; const responses = [1, 0]; let clipboard = ''; @@ -141,9 +141,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/); 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..57f3fc4dd5 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 @@ -1235,7 +1235,7 @@ 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 () => { +test('silently replaces an idle non-restartable Local Host and retries', async () => { const observed = upgradeRequired(false); const conflict = { ...observed, @@ -1244,6 +1244,7 @@ test('replaces a non-restartable Local Host through the supplied authority and r const replacement = candidateHarness(); let starts = 0; let replaced: typeof observed.registration | undefined; + const policies: string[] = []; const owner = await startRuntimeHostDesktopManager( {} as DesktopRuntimeHostCandidateStartInput, { @@ -1253,23 +1254,95 @@ 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 only after a non-restartable Local Host reports active tasks', 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, actions) => { + prompts += 1; + assert.deepEqual(actions, { + canReplace: true, + canWait: false, + activeTasksDetected: true, + }); + 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, ['refuse_active_work']); +}); + test('lets the user cancel startup when an incompatible Host owns the root', async () => { const conflict = incompatibleHost('blocked_by_residency'); let presented: DesktopRuntimeHostCandidateStartResult | undefined; 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 9e502095c6..8912619845 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 @@ -294,14 +294,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, @@ -331,8 +331,14 @@ 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; + } return { kind: 'result' as const, action: 'update' as const, @@ -349,8 +355,9 @@ 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'); + assert.deepEqual(policies, [undefined, 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..2a25ebbb99 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 @@ -106,3 +106,20 @@ test('does not offer passive waiting for a supervised Host', async () => { 'cancel', ); }); + +test('explains when the safe replacement check found active background work', () => { + 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, + { action: 'replace', canWait: false, activeTasksDetected: true }, + 'zh', + ); + + assert.match(dialog.options.detail ?? '', /仍有后台任务在运行/u); + assert.doesNotMatch(dialog.options.detail ?? '', /无法报告后台活动/u); + assert.equal(dialog.options.defaultId, dialog.options.cancelId); +}); 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..12d3990139 --- /dev/null +++ b/apps/desktop/src/main/browser-message-box.ts @@ -0,0 +1,574 @@ +/* + * 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 type { + BrowserWindow, + MessageBoxOptions, + MessageBoxReturnValue, + Rectangle, +} from 'electron'; + +const RESPONSE_URL_PREFIX = 'maka-dialog://response/'; +const DIALOG_WIDTH = 520; +const INITIAL_HEIGHT = 600; +const MIN_HEIGHT = 280; +const WORK_AREA_MARGIN = 32; +// Same traced brand outline as packages/ui/src/maka-wordmark.tsx. This startup +// surface deliberately cannot depend on the main React renderer bundle. +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'; + +/** + * 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. A truly + * pre-ready caller also falls back because BrowserWindow is unavailable by + * Electron contract; current startup callers wait for ready when they can. + */ +export async function showBrowserMessageBox( + options: MessageBoxOptions, + parent?: BrowserWindow, +): 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'); + const liveParent = parent && !parent.isDestroyed() ? parent : undefined; + if (!electron.app.isReady()) return showNativeMessageBox(electron, options, liveParent); + try { + return await presentBrowserMessageBox(electron, options, liveParent); + } catch (error) { + console.error('[dialog] BrowserWindow presentation failed; using native fallback:', error); + return showNativeMessageBox(electron, options, liveParent); + } +} + +async function showNativeMessageBox( + electron: typeof import('electron'), + options: MessageBoxOptions, + parent: BrowserWindow | undefined, +): Promise { + return parent && !parent.isDestroyed() + ? electron.dialog.showMessageBox(parent, options) + : electron.dialog.showMessageBox(options); +} + +async function presentBrowserMessageBox( + electron: typeof import('electron'), + options: MessageBoxOptions, + parent: BrowserWindow | undefined, +): Promise { + const presentation = normalizePresentation(options); + const workArea = resolveWorkArea(electron, 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 = new electron.BrowserWindow({ + ...initialBounds, + title: options.title || options.message, + 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, + }, + }); + win.setMenuBarVisibility(false); + win.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); + + return await new Promise((resolve, reject) => { + let settled = false; + const finish = (response: number): void => { + if (settled) return; + settled = true; + resolve({ response, checkboxChecked: false }); + if (!win.isDestroyed()) win.destroy(); + }; + const fail = (error: unknown): void => { + if (settled) return; + settled = true; + reject(error instanceof Error ? error : new Error(String(error))); + if (!win.isDestroyed()) win.destroy(); + }; + + win.on('closed', () => finish(presentation.cancelId)); + 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( + buildBrowserMessageBoxHtml(options, { + ...presentation, + dark: electron.nativeTheme.shouldUseDarkColors, + }), + )}`, + ) + .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(); + }) + .catch(fail); + }); +} + +interface BrowserMessageBoxPresentation { + readonly buttons: readonly string[]; + readonly defaultId: number; + readonly cancelId: number; +} + +function normalizePresentation(options: MessageBoxOptions): BrowserMessageBoxPresentation { + const buttons = options.buttons?.length ? options.buttons : ['OK']; + const cancelId = validButtonId(options.cancelId, buttons.length) + ? options.cancelId + : buttons.length - 1; + const defaultId = validButtonId(options.defaultId, buttons.length) + ? options.defaultId + : 0; + return { buttons, defaultId, cancelId }; +} + +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; +} + +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, + input: BrowserMessageBoxPresentation & { readonly dark: boolean }, +): string { + const nonce = randomUUID().replaceAll('-', ''); + const type = messageBoxType(options.type); + const title = options.title || 'Maka'; + const message = options.message || title; + const detail = options.detail ?? ''; + const isChinese = /\p{Script=Han}/u.test(`${title}${message}`); + const closeLabel = isChinese ? '关闭' : '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 = detail + ? `
${escapeHtml(detail)}
` + : ''; + const statusIcon = + type === 'question' + ? '' + : type === 'info' || type === 'none' + ? '' + : ''; + + return ` + + + + + + ${escapeHtml(title)} + + + +
+
+ + ${closeButton} +
+
+
+ +
+

${escapeHtml(title)}

+
${escapeHtml(message)}
+
+
+ ${detailBlock} +
+
${buttons}
+
+ + +`; +} + +function messageBoxType(value: MessageBoxOptions['type']): string { + 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-window.ts b/apps/desktop/src/main/main-window.ts index 730b7fb1b5..e7ea860da5 100644 --- a/apps/desktop/src/main/main-window.ts +++ b/apps/desktop/src/main/main-window.ts @@ -43,6 +43,11 @@ 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(): boolean; 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). @@ -170,12 +175,39 @@ 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 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 +406,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 +519,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,6 +528,32 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main return { createWindow, + reloadMainRenderer() { + const target = mainWindow; + const signal = mainWindowShutdownSignal; + if ( + !target || + target.isDestroyed() || + target.webContents.isDestroyed() || + !signal || + signal.aborted + ) return false; + // The previous one-shot observer was consumed by the crash. Arm the + // replacement before reload so a second crash still reaches recovery. + clearShowFallbackTimer(); + const contents = target.webContents; + const onLoaded = (): void => armShowFallbackTimer(target); + contents.once('did-finish-load', onLoaded); + try { + observeRendererProcess(target, signal); + contents.reload(); + return true; + } catch (error) { + if (!contents.isDestroyed()) contents.off('did-finish-load', onLoaded); + console.error('[renderer] failed to reload main Renderer:', error); + return false; + } + }, send: safeSendToRenderer, notifyRendererReady() { // PR-SHOW-AFTER-FIRST-COMMIT: the renderer finished its first React diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 1780b0c4c2..1a2d63903f 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()); @@ -76,9 +77,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 +88,47 @@ 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'); + // With no surviving window-all-closed listener in this losing process, + // destroying the temporary dialog would let Electron quit with code 0 + // before the promised loser exit code is published. + const keepAliveUntilReported = (): void => {}; + app.on('window-all-closed', keepAliveUntilReported); + void app + .whenReady() + .then(() => { + const isChinese = resolveSystemUiLocale(app.getPreferredSystemLanguages()) === '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, + }); + }) + .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.off('window-all-closed', keepAliveUntilReported); + app.exit(DEV_LOSER_EXIT_CODE); + }); + } else app.exit(DEV_LOSER_EXIT_CODE); } else { app.exit(0); } @@ -182,7 +212,7 @@ if (!app.requestSingleInstanceLock()) { }), mainLogs: () => mainProcessLogBuffer.snapshot(), writeClipboard: (report) => clipboard.writeText(report), - showMessageBox: (options) => dialog.showMessageBox(options), + showMessageBox: (options) => showBrowserMessageBox(options), }); } } finally { diff --git a/apps/desktop/src/main/native-diagnostic-dialog.ts b/apps/desktop/src/main/native-diagnostic-dialog.ts index 9e848805b7..0eb6469ae5 100644 --- a/apps/desktop/src/main/native-diagnostic-dialog.ts +++ b/apps/desktop/src/main/native-diagnostic-dialog.ts @@ -102,7 +102,7 @@ export async function showFatalStartupError( export async function showMainRendererProcessGoneDialog( deps: DiagnosticDialogDeps, -): Promise<'relaunch' | 'exit'> { +): Promise<'recover' | 'exit'> { const copy = MAIN_RENDERER_GONE_COPY[deps.locale]; const result = await showMessageBoxWithDiagnostics( { @@ -110,14 +110,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( @@ -219,15 +219,16 @@ const MAIN_RENDERER_GONE_COPY = { en: { 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', }, zh: { title: 'Maka 需要恢复', message: 'Maka 界面意外停止运行。', - detail: '重新启动 Maka 以继续,或退出后稍后再打开。', - relaunch: '重新启动', + detail: '只恢复界面,不重启 Maka。Runtime Host、正在运行的工作和后台服务都会保留。', + recover: '恢复界面', exit: '退出', }, } as const; diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 21d1726c53..945eccc17e 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,7 @@ import { readFileCapped } 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 { showBrowserMessageBox } 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"; @@ -330,6 +331,11 @@ const desktopDiagnostics: DesktopDiagnosticsDeps = { resolveRuntimeHost: resolveRuntimeHostDiagnostics, writeClipboard: (report) => clipboard.writeText(report), }; +let resolveBrowserDialogParent = (): BrowserWindow | undefined => undefined; + +function showDesktopMessageBox(options: MessageBoxOptions): Promise { + return showBrowserMessageBox(options, resolveBrowserDialogParent()); +} function showStartupDiagnosticDialog( options: MessageBoxOptions, @@ -337,7 +343,7 @@ function showStartupDiagnosticDialog( ): Promise { return showMessageBoxWithDiagnostics(options, { locale, - showMessageBox: (next) => dialog.showMessageBox(next), + showMessageBox: showDesktopMessageBox, copyDiagnostics: () => copyDesktopDiagnosticReport( desktopDiagnostics, @@ -433,12 +439,19 @@ const mainWindowController = createMainWindowController({ locale: desktopLocale.current(), copyDiagnostics: () => copyDesktopDiagnosticReport(desktopDiagnostics, diagnosticInput), - showMessageBox: (options) => dialog.showMessageBox(options), + // The dialog has its own sandboxed renderer, but stays attached to a + // visible main window so Recover can transition directly back into that + // same window. A pre-first-paint crash uses a standalone dialog instead. + showMessageBox: (options) => { + const parent = mainWindowController.browserWindow(); + return showBrowserMessageBox(options, parent?.isVisible() ? parent : undefined); + }, }); - if (decision === "relaunch") app.relaunch(); + if (decision === "recover" && mainWindowController.reloadMainRenderer()) return; app.quit(); }, }); +resolveBrowserDialogParent = () => mainWindowController.browserWindow(); const runtimeHostSshTerminal = createDesktopRuntimeHostSshTerminal({ ipcMain, send: (channel, event) => mainWindowController.send(channel, event), @@ -759,7 +772,7 @@ const clientSettingsTools = buildClientSettingsTools({ }, confirm: async (changes) => { const copy = clientSettingsConfirmation(changes, await desktopLocale.resolve()); - const result = await dialog.showMessageBox({ + const result = await showDesktopMessageBox({ type: "question", message: copy.message, detail: copy.detail, @@ -1127,7 +1140,7 @@ runtimeHostManager = await startDesktopRuntimeHostWithRecovery({ }); return showRuntimeHostStartupRecoveryDialog(input, { locale: desktopLocale.current(), - showMessageBox: (options) => dialog.showMessageBox(options), + showMessageBox: showDesktopMessageBox, copyDiagnostics: () => copyDesktopDiagnosticReport( desktopDiagnostics, @@ -1841,7 +1854,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)); } async function closeRuntimeHostDesktop(): Promise { diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts index 7953e0e1d8..a0bafb1c69 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -150,10 +150,13 @@ export type RuntimeHostNonRestartableDecision = 'replace' | 'wait' | 'cancel'; export interface RuntimeHostNonRestartableActions { readonly canReplace: boolean; readonly canWait: boolean; + readonly activeTasksDetected?: true; } export interface RuntimeHostLocalReplacement { - replace(): Promise; + replace( + activeWorkPolicy: 'refuse_active_work' | 'interrupt_active_work', + ): Promise<'replaced' | 'active_tasks'>; } export class RuntimeHostUpgradeCancelledError extends RuntimeHostPermanentReconnectError { @@ -942,10 +945,22 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { const replacement = target.input.profileTarget ? undefined : await this.resolveLocalHostReplacement?.(result.registration, signal); + if (replacement) { + // The managed-service operator can check active work even when an + // older Host cannot include an activity snapshot in its handshake. + // Let idle upgrades finish without a dialog, but never grant + // interruption authority until the person explicitly confirms it. + const attempt = await replacement.replace('refuse_active_work'); + if (attempt === 'replaced') { + takeoverHostEpoch = undefined; + continue; + } + } const decision = await this.#resolveNonRestartable(result, { canReplace: replacement !== undefined, canWait: replacement === undefined && result.registration.lifecycleMode !== 'service', + ...(replacement ? { activeTasksDetected: true as const } : {}), }); if (decision === 'cancel') throw new RuntimeHostUpgradeCancelledError(); if (decision === 'replace') { @@ -954,7 +969,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; } 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 4ade5c5610..91fe68146f 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; @@ -721,7 +724,7 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { } const target = authority.target; return { - replace: () => + replace: (activeWorkPolicy) => serialize(async () => { signal.throwIfAborted(); const setupPackage = await input.resolveSetupPackage(signal); @@ -733,13 +736,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') { @@ -749,11 +755,9 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { ); } if (frame.update.kind === 'active_tasks') { - throw conflictReplacementError( - registration.pid, - 'the managed service refused to interrupt active work', - ); + return 'active_tasks'; } + 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..dcf5003738 100644 --- a/apps/desktop/src/main/runtime-host-upgrade-copy.ts +++ b/apps/desktop/src/main/runtime-host-upgrade-copy.ts @@ -27,6 +27,12 @@ import type { type Conflict = RuntimeHostRestartableConflict | RuntimeHostWaitConflict; export type RuntimeHostUpgradeDialogDecision = 'restart' | 'replace' | 'wait' | 'cancel'; +interface RuntimeHostUpgradeAvailability { + readonly action: 'restart' | 'replace' | undefined; + readonly canWait: boolean; + readonly activeTasksDetected?: true; +} + export interface RuntimeHostUpgradeDialog { readonly options: MessageBoxOptions; readonly decisions: readonly RuntimeHostUpgradeDialogDecision[]; @@ -42,15 +48,14 @@ 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; + availability.activeTasksDetected === true || + (activity?.activeOperations ?? 0) > 0 || + (activity?.residencies.length ?? 0) > 0; const copy = UPGRADE_COPY[locale]; const choices: { readonly label: string; readonly decision: RuntimeHostUpgradeDialogDecision }[] = []; @@ -85,10 +90,7 @@ export function buildRuntimeHostUpgradeDialog( function formatActivity( conflict: Conflict, - availability: { - readonly action: 'restart' | 'replace' | undefined; - readonly canWait: boolean; - }, + availability: RuntimeHostUpgradeAvailability, locale: UiLocale, ): string { const activity = conflict.handshake?.activity; @@ -103,7 +105,8 @@ function formatActivity( for (const residency of activity.residencies) { lines.push(`${copy.activity[activityKey(residency.label)]}: ${residency.count}`); } - } else lines.push(copy.unknownActivity); + } else if (availability.activeTasksDetected) lines.push(copy.activeTasksDetected); + else lines.push(copy.unknownActivity); if (availability.action === 'replace') { lines.push('', copy.replaceWarning, copy.replaceExplanation); } else if (availability.action === 'restart') { @@ -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`, + activeTasksDetected: 'This Host reported active background work 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} 个操作正在运行`, + activeTasksDetected: '安全替换检查发现此 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..00e0db3bff 100644 --- a/apps/desktop/src/main/runtime-host-upgrade-dialog.ts +++ b/apps/desktop/src/main/runtime-host-upgrade-dialog.ts @@ -56,7 +56,11 @@ export function createRuntimeHostUpgradePrompts( const locale = await resolveLocale(); const dialog = buildRuntimeHostUpgradeDialog( conflict, - { action: actions.canReplace ? 'replace' : undefined, canWait: actions.canWait }, + { + action: actions.canReplace ? 'replace' : undefined, + canWait: actions.canWait, + ...(actions.activeTasksDetected ? { activeTasksDetected: true } : {}), + }, locale, ); const { response } = await showDialog( From b33870159a844492f20ab18fdb766b3fd397abf4 Mon Sep 17 00:00:00 2001 From: Wang Date: Tue, 1 Sep 2026 21:58:23 +0800 Subject: [PATCH 02/28] fix(desktop): harden recovery window lifecycle Generated-by: OpenAI Codex --- .../__tests__/browser-message-box.test.ts | 61 ++++-- .../main-renderer-process-gone.test.ts | 65 +++++- .../runtime-host-desktop-manager.test.ts | 12 +- .../runtime-host-local-remote-access.test.ts | 13 +- .../runtime-host-upgrade-dialog.test.ts | 14 +- apps/desktop/src/main/browser-message-box.ts | 186 ++++++++++-------- .../src/main/main-renderer-process-gone.ts | 105 +++++++++- apps/desktop/src/main/main-window.ts | 34 ++-- apps/desktop/src/main/main.ts | 12 +- apps/desktop/src/main/runtime-host-boot.ts | 26 +-- .../src/main/runtime-host-desktop-manager.ts | 32 ++- .../main/runtime-host-local-remote-access.ts | 6 + .../src/main/runtime-host-upgrade-copy.ts | 41 ++-- .../src/main/runtime-host-upgrade-dialog.ts | 14 +- .../runtime-host-selected-update.test.ts | 47 +++++ .../cli/src/runtime-host-update-command.ts | 69 +++++-- 16 files changed, 517 insertions(+), 220 deletions(-) diff --git a/apps/desktop/src/main/__tests__/browser-message-box.test.ts b/apps/desktop/src/main/__tests__/browser-message-box.test.ts index 103a060611..e9994e0329 100644 --- a/apps/desktop/src/main/__tests__/browser-message-box.test.ts +++ b/apps/desktop/src/main/__tests__/browser-message-box.test.ts @@ -22,6 +22,7 @@ import { test } from 'node:test'; import { buildBrowserMessageBoxHtml, centeredBounds, + normalizeBrowserMessageBoxPresentation, parseBrowserMessageBoxResponse, } from '../browser-message-box.js'; @@ -61,28 +62,21 @@ test('centers against the parent while keeping the whole dialog on-screen', () = test('renders escaped content with Maka dialog tokens and safe action ordering', () => { const html = buildBrowserMessageBoxHtml( - { - type: 'warning', - title: '', - message: 'Maka & Runtime Host', - detail: '', - buttons: ['Replace ', 'Cancel', 'Copy & Diagnostics'], - defaultId: 0, - cancelId: 1, - }, - { - buttons: ['Replace ', 'Cancel', 'Copy & Diagnostics'], - defaultId: 0, - cancelId: 1, - dark: true, - }, + normalizeBrowserMessageBoxPresentation( + { + type: 'warning', + title: '', + message: 'Maka & Runtime Host', + detail: '', + buttons: ['Replace ', 'Cancel', 'Copy & Diagnostics'], + defaultId: 0, + cancelId: 1, + }, + true, + ), ); assert.match(html, /data-theme="dark"/u); - assert.match(html, /class="wordmark"/u); - assert.match(html, /color: #71a8fd/u); - assert.match(html, /border-radius: 12px/u); - assert.match(html, /height: 32px/u); assert.match(html, /<img src=x onerror=alert\(1\)>/u); assert.match(html, /Maka & Runtime Host/u); assert.match(html, /<\/div><script>globalThis\.pwned = true<\/script>/u); @@ -98,3 +92,32 @@ test('renders escaped content with Maka dialog tokens and safe action ordering', assert.match(html, /class="decision primary"[^>]*data-response="0" autofocus/u); assert.match(html, /default-src 'none'/u); }); + +test('normalizes fallback buttons and out-of-range action indexes once', () => { + const presentation = normalizeBrowserMessageBoxPresentation( + { + type: 'none', + title: '', + message: '', + buttons: [], + defaultId: 4, + cancelId: -1, + }, + false, + ); + + assert.deepEqual(presentation, { + type: 'none', + title: 'Maka', + message: 'Maka', + detail: '', + buttons: ['OK'], + defaultId: 0, + cancelId: 0, + dark: false, + isChinese: false, + }); + const html = buildBrowserMessageBoxHtml(presentation); + assert.match(html, /data-response="0" autofocus/u); + assert.match(html, /data-theme="light"/u); +}); 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..1e261ce6b4 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,10 @@ 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 { + 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 +62,63 @@ test('ignores clean exits and app shutdown', () => { assert.equal(observed, false); } }); + +test('reports reload success only after the main document finishes loading', async () => { + const source = reloadSource(); + let observed = false; + const result = reloadMainRendererProcess({ + source, + shutdownSignal: new AbortController().signal, + onLoaded: () => { + observed = true; + }, + }); + + assert.equal(source.reloadCalls, 1); + source.emit('did-fail-load', {}, -3, 'subframe failed', 'https://example.test/frame', false, 1, 2); + source.emit('did-finish-load'); + 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); +}); + +test('keeps recovery active when a Renderer reload fails or exits', 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 }), + ]) { + const source = reloadSource(); + let observed = false; + const result = reloadMainRendererProcess({ + source, + shutdownSignal: new AbortController().signal, + onLoaded: () => { + observed = true; + }, + }); + + fail(source); + assert.equal(await result, false); + assert.equal(observed, false); + assert.equal(source.listenerCount('did-finish-load'), 0); + } +}); + +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__/runtime-host-desktop-manager.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts index 57f3fc4dd5..e6c71b0f15 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 @@ -1290,13 +1290,9 @@ test('prompts only after a non-restartable Local Host reports active tasks', asy }, upgradePrompts: { restartable: async () => assert.fail('non-restartable conflict used restart prompt'), - nonRestartable: async (_conflict, actions) => { + nonRestartable: async (_conflict, action) => { prompts += 1; - assert.deepEqual(actions, { - canReplace: true, - canWait: false, - activeTasksDetected: true, - }); + assert.equal(action, 'replace_active_work'); return 'replace'; }, }, @@ -1351,9 +1347,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 8912619845..7acaf47cf7 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 @@ -339,6 +339,13 @@ test('replaces a conflicting supervised Host with the requested active-work poli 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, @@ -357,7 +364,11 @@ test('replaces a conflicting supervised Host with the requested active-work poli assert.ok(replacement); assert.equal(await replacement.replace('refuse_active_work'), 'active_tasks'); assert.equal(await replacement.replace('interrupt_active_work'), 'replaced'); - assert.deepEqual(policies, [undefined, true]); + 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 2a25ebbb99..ae3590b834 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 @@ -42,12 +42,12 @@ const conflict = { test('localizes upgrade activity without changing decision indexes', () => { const en = buildRuntimeHostUpgradeDialog( conflict, - { action: 'restart', canWait: true }, + 'restart_or_wait', 'en', ).options; const zh = buildRuntimeHostUpgradeDialog( conflict, - { action: 'restart', canWait: true }, + 'restart_or_wait', 'zh', ).options; assert.deepEqual(en.buttons, ['Restart Runtime Host', 'Wait', 'Cancel Startup']); @@ -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,7 +79,7 @@ test('maps the non-default replacement choice to the replace decision', async () restartable: false, registration: { pid: 42 }, } as never, - { canReplace: true, canWait: true }, + 'replace_active_work', ), 'replace', ); @@ -102,7 +102,7 @@ test('does not offer passive waiting for a supervised Host', async () => { }, ); assert.equal( - await prompts.nonRestartable(conflict, { canReplace: true, canWait: false }), + await prompts.nonRestartable(conflict, 'replace_active_work'), 'cancel', ); }); @@ -115,7 +115,7 @@ test('explains when the safe replacement check found active background work', () } as Parameters[0]; const dialog = buildRuntimeHostUpgradeDialog( conflict, - { action: 'replace', canWait: false, activeTasksDetected: true }, + 'replace_active_work', 'zh', ); diff --git a/apps/desktop/src/main/browser-message-box.ts b/apps/desktop/src/main/browser-message-box.ts index 12d3990139..6cfa3eb43e 100644 --- a/apps/desktop/src/main/browser-message-box.ts +++ b/apps/desktop/src/main/browser-message-box.ts @@ -50,13 +50,16 @@ export async function showBrowserMessageBox( // 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'); - const liveParent = parent && !parent.isDestroyed() ? parent : undefined; - if (!electron.app.isReady()) return showNativeMessageBox(electron, options, liveParent); + const visibleParent = + parent && !parent.isDestroyed() && parent.isVisible() && !parent.isMinimized() + ? parent + : undefined; + if (!electron.app.isReady()) return showNativeMessageBox(electron, options, visibleParent); try { - return await presentBrowserMessageBox(electron, options, liveParent); + return await presentBrowserMessageBox(electron, options, visibleParent); } catch (error) { console.error('[dialog] BrowserWindow presentation failed; using native fallback:', error); - return showNativeMessageBox(electron, options, liveParent); + return showNativeMessageBox(electron, options, visibleParent); } } @@ -65,7 +68,10 @@ async function showNativeMessageBox( options: MessageBoxOptions, parent: BrowserWindow | undefined, ): Promise { - return parent && !parent.isDestroyed() + return parent && + !parent.isDestroyed() && + parent.isVisible() && + !parent.isMinimized() ? electron.dialog.showMessageBox(parent, options) : electron.dialog.showMessageBox(options); } @@ -75,7 +81,10 @@ async function presentBrowserMessageBox( options: MessageBoxOptions, parent: BrowserWindow | undefined, ): Promise { - const presentation = normalizePresentation(options); + const presentation = normalizeBrowserMessageBoxPresentation( + options, + electron.nativeTheme.shouldUseDarkColors, + ); const workArea = resolveWorkArea(electron, parent); const width = Math.max(320, Math.min(DIALOG_WIDTH, workArea.width - WORK_AREA_MARGIN * 2)); const initialHeight = Math.max( @@ -85,7 +94,7 @@ async function presentBrowserMessageBox( const initialBounds = centeredBounds(parent?.getBounds(), workArea, width, initialHeight); const win = new electron.BrowserWindow({ ...initialBounds, - title: options.title || options.message, + title: presentation.title, show: false, frame: false, transparent: true, @@ -106,77 +115,98 @@ async function presentBrowserMessageBox( allowRunningInsecureContent: false, }, }); - win.setMenuBarVisibility(false); - win.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); + try { + win.setMenuBarVisibility(false); + win.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); - return await new Promise((resolve, reject) => { - let settled = false; - const finish = (response: number): void => { - if (settled) return; - settled = true; - resolve({ response, checkboxChecked: false }); - if (!win.isDestroyed()) win.destroy(); - }; - const fail = (error: unknown): void => { - if (settled) return; - settled = true; - reject(error instanceof Error ? error : new Error(String(error))); - if (!win.isDestroyed()) win.destroy(); - }; + return await new Promise((resolve, reject) => { + let settled = false; + const finish = (response: number): void => { + if (settled) return; + settled = true; + resolve({ response, checkboxChecked: false }); + }; + const fail = (error: unknown): void => { + if (settled) return; + settled = true; + reject(error instanceof Error ? error : new Error(String(error))); + }; - win.on('closed', () => finish(presentation.cancelId)); - 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); + 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( + buildBrowserMessageBoxHtml(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(); + }) + .catch(fail); }); - void win - .loadURL( - `data:text/html;charset=utf-8,${encodeURIComponent( - buildBrowserMessageBoxHtml(options, { - ...presentation, - dark: electron.nativeTheme.shouldUseDarkColors, - }), - )}`, - ) - .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(); - }) - .catch(fail); - }); + } finally { + if (!win.isDestroyed()) win.destroy(); + } } -interface BrowserMessageBoxPresentation { +export 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 isChinese: boolean; } -function normalizePresentation(options: MessageBoxOptions): BrowserMessageBoxPresentation { - const buttons = options.buttons?.length ? options.buttons : ['OK']; +export function normalizeBrowserMessageBoxPresentation( + options: MessageBoxOptions, + dark: boolean, +): BrowserMessageBoxPresentation { + const buttons = options.buttons?.length ? [...options.buttons] : ['OK']; const cancelId = validButtonId(options.cancelId, buttons.length) ? options.cancelId : buttons.length - 1; const defaultId = validButtonId(options.defaultId, buttons.length) ? options.defaultId : 0; - return { buttons, defaultId, cancelId }; + const title = options.title || 'Maka'; + const message = options.message || title; + return { + type: messageBoxType(options.type), + title, + message, + detail: options.detail ?? '', + buttons, + defaultId, + cancelId, + dark, + isChinese: /\p{Script=Han}/u.test(`${title}${message}`), + }; } function validButtonId(value: number | undefined, count: number): value is number { @@ -237,17 +267,9 @@ export function parseBrowserMessageBoxResponse( : undefined; } -export function buildBrowserMessageBoxHtml( - options: MessageBoxOptions, - input: BrowserMessageBoxPresentation & { readonly dark: boolean }, -): string { +export function buildBrowserMessageBoxHtml(input: BrowserMessageBoxPresentation): string { const nonce = randomUUID().replaceAll('-', ''); - const type = messageBoxType(options.type); - const title = options.title || 'Maka'; - const message = options.message || title; - const detail = options.detail ?? ''; - const isChinese = /\p{Script=Han}/u.test(`${title}${message}`); - const closeLabel = isChinese ? '关闭' : 'Close'; + const closeLabel = input.isChinese ? '关闭' : 'Close'; const closeButton = ``; @@ -274,23 +296,23 @@ export function buildBrowserMessageBoxHtml( }>${escapeHtml(label)}`; }) .join(''); - const detailBlock = detail - ? `
${escapeHtml(detail)}
` + const detailBlock = input.detail + ? `
${escapeHtml(input.detail)}
` : ''; const statusIcon = - type === 'question' + input.type === 'question' ? '' - : type === 'info' || type === 'none' + : input.type === 'info' || input.type === 'none' ? '' : ''; return ` - + - ${escapeHtml(title)} + ${escapeHtml(input.title)} -
+
${closeButton} @@ -526,8 +548,8 @@ export function buildBrowserMessageBoxHtml(
-

${escapeHtml(title)}

-
${escapeHtml(message)}
+

${escapeHtml(input.title)}

+
${escapeHtml(input.message)}
${detailBlock} @@ -554,7 +576,7 @@ export function buildBrowserMessageBoxHtml( `; } -function messageBoxType(value: MessageBoxOptions['type']): string { +function messageBoxType(value: MessageBoxOptions['type']): BrowserMessageBoxPresentation['type'] { return value === 'warning' || value === 'error' || value === 'question' || value === 'info' ? value : 'none'; diff --git a/apps/desktop/src/main/main-renderer-process-gone.ts b/apps/desktop/src/main/main-renderer-process-gone.ts index 4ee6904a5b..3f6f240111 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,47 @@ interface RenderProcessGoneSource { ): void; } +interface MainRendererReloadSource { + once(event: 'did-finish-load', 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: 'did-finish-load', 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 function observeMainRendererProcessGone(deps: { readonly source: RenderProcessGoneSource; readonly shutdownSignal: AbortSignal; @@ -36,3 +77,65 @@ export function observeMainRendererProcessGone(deps: { deps.onUnexpectedExit(details); }); } + +/** + * Waits for a crashed main Renderer to finish loading before recovery is + * reported as successful. The ordinary one-shot crash observer is re-armed + * synchronously by `onLoaded`, leaving no successful-load gap unobserved. + */ +export function reloadMainRendererProcess(deps: { + readonly source: MainRendererReloadSource; + readonly shutdownSignal: AbortSignal; + readonly onLoaded: () => void; +}): Promise { + if (deps.shutdownSignal.aborted || deps.source.isDestroyed()) { + return Promise.resolve(false); + } + return new Promise((resolve) => { + let settled = false; + const cleanup = (): void => { + deps.source.off('did-finish-load', onLoaded); + deps.source.off('did-fail-load', onFailed); + 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 onLoaded = (): void => { + try { + deps.onLoaded(); + settle(true); + } catch { + settle(false); + } + }; + const onFailed = ( + _event: Event, + _errorCode: number, + _errorDescription: string, + _validatedURL: string, + isMainFrame: boolean, + ): void => { + if (isMainFrame) settle(false); + }; + const onGone = (_event: Event, _details: RenderProcessGoneDetails): void => settle(false); + const onDestroyed = (): void => settle(false); + const onAborted = (): void => settle(false); + + deps.source.once('did-finish-load', onLoaded); + deps.source.on('did-fail-load', onFailed); + deps.source.once('render-process-gone', onGone); + deps.source.once('destroyed', onDestroyed); + deps.shutdownSignal.addEventListener('abort', onAborted, { once: true }); + try { + deps.source.reload(); + } catch { + settle(false); + } + }); +} diff --git a/apps/desktop/src/main/main-window.ts b/apps/desktop/src/main/main-window.ts index e7ea860da5..6cc4f38a84 100644 --- a/apps/desktop/src/main/main-window.ts +++ b/apps/desktop/src/main/main-window.ts @@ -29,7 +29,10 @@ 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 { + 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'; @@ -47,7 +50,7 @@ export interface MainWindowController { * Reload only the crashed main Renderer, preserving the Desktop process, * Runtime Host, background services, and current BrowserWindow. */ - reloadMainRenderer(): boolean; + 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). @@ -528,7 +531,7 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main return { createWindow, - reloadMainRenderer() { + async reloadMainRenderer() { const target = mainWindow; const signal = mainWindowShutdownSignal; if ( @@ -538,21 +541,20 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main !signal || signal.aborted ) return false; - // The previous one-shot observer was consumed by the crash. Arm the - // replacement before reload so a second crash still reaches recovery. clearShowFallbackTimer(); const contents = target.webContents; - const onLoaded = (): void => armShowFallbackTimer(target); - contents.once('did-finish-load', onLoaded); - try { - observeRendererProcess(target, signal); - contents.reload(); - return true; - } catch (error) { - if (!contents.isDestroyed()) contents.off('did-finish-load', onLoaded); - console.error('[renderer] failed to reload main Renderer:', error); - return false; - } + const loaded = await reloadMainRendererProcess({ + source: contents, + shutdownSignal: signal, + onLoaded: () => { + // The previous one-shot observer was consumed by the crash. Re-arm + // it before successful recovery is exposed to the caller. + observeRendererProcess(target, signal); + armShowFallbackTimer(target); + }, + }); + if (!loaded) console.error('[renderer] main Renderer reload did not finish'); + return loaded; }, send: safeSendToRenderer, notifyRendererReady() { diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 1a2d63903f..f00334c510 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -58,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, @@ -94,11 +100,6 @@ if (!app.requestSingleInstanceLock()) { const resultReported = reportDevelopmentLaunchResult(process.argv, { status: 'loser' }); if (!resultReported && shouldShowLoserDialog(process.argv)) { const profilePath = app.getPath('userData'); - // With no surviving window-all-closed listener in this losing process, - // destroying the temporary dialog would let Electron quit with code 0 - // before the promised loser exit code is published. - const keepAliveUntilReported = (): void => {}; - app.on('window-all-closed', keepAliveUntilReported); void app .whenReady() .then(() => { @@ -125,7 +126,6 @@ if (!app.requestSingleInstanceLock()) { ); }) .finally(() => { - app.off('window-all-closed', keepAliveUntilReported); app.exit(DEV_LOSER_EXIT_CODE); }); } else app.exit(DEV_LOSER_EXIT_CODE); diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 945eccc17e..352c437d66 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -435,19 +435,19 @@ const mainWindowController = createMainWindowController({ description: `Reason: ${details.reason}`, details: `Exit code: ${details.exitCode}`, }); - const decision = await showMainRendererProcessGoneDialog({ - locale: desktopLocale.current(), - copyDiagnostics: () => - copyDesktopDiagnosticReport(desktopDiagnostics, diagnosticInput), - // The dialog has its own sandboxed renderer, but stays attached to a - // visible main window so Recover can transition directly back into that - // same window. A pre-first-paint crash uses a standalone dialog instead. - showMessageBox: (options) => { - const parent = mainWindowController.browserWindow(); - return showBrowserMessageBox(options, parent?.isVisible() ? parent : undefined); - }, - }); - if (decision === "recover" && mainWindowController.reloadMainRenderer()) return; + for (;;) { + const decision = await showMainRendererProcessGoneDialog({ + locale: desktopLocale.current(), + copyDiagnostics: () => + copyDesktopDiagnosticReport(desktopDiagnostics, diagnosticInput), + // showBrowserMessageBox attaches only to a visible, non-minimized + // parent. A pre-first-paint crash therefore gets a standalone window. + showMessageBox: showDesktopMessageBox, + }); + if (decision !== "recover") break; + if (await mainWindowController.reloadMainRenderer()) return; + if (!mainWindowController.browserWindow()) break; + } app.quit(); }, }); diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts index a0bafb1c69..29fb3b55d2 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -146,17 +146,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; - readonly activeTasksDetected?: true; -} +export type RuntimeHostNonRestartableAction = + | 'replace_active_work' + | 'wait' + | 'cancel_only'; export interface RuntimeHostLocalReplacement { - replace( - activeWorkPolicy: 'refuse_active_work' | 'interrupt_active_work', - ): Promise<'replaced' | 'active_tasks'>; + replace(activeWorkPolicy: RuntimeHostRetirementMode): Promise<'replaced' | 'active_tasks'>; } export class RuntimeHostUpgradeCancelledError extends RuntimeHostPermanentReconnectError { @@ -193,7 +189,7 @@ export interface RuntimeHostUpgradePrompts { ): Promise; nonRestartable( conflict: RuntimeHostWaitConflict, - actions: RuntimeHostNonRestartableActions, + action: RuntimeHostNonRestartableAction, ): Promise; } @@ -956,12 +952,12 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { continue; } } - const decision = await this.#resolveNonRestartable(result, { - canReplace: replacement !== undefined, - canWait: - replacement === undefined && result.registration.lifecycleMode !== 'service', - ...(replacement ? { activeTasksDetected: true as const } : {}), - }); + const action: RuntimeHostNonRestartableAction = replacement + ? 'replace_active_work' + : result.registration.lifecycleMode !== 'service' + ? 'wait' + : 'cancel_only'; + const decision = await this.#resolveNonRestartable(result, action); if (decision === 'cancel') throw new RuntimeHostUpgradeCancelledError(); if (decision === 'replace') { if (!replacement) { @@ -996,9 +992,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 91fe68146f..504d776871 100644 --- a/apps/desktop/src/main/runtime-host-local-remote-access.ts +++ b/apps/desktop/src/main/runtime-host-local-remote-access.ts @@ -757,6 +757,12 @@ 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 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 dcf5003738..209c05d95f 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,11 +28,10 @@ import type { type Conflict = RuntimeHostRestartableConflict | RuntimeHostWaitConflict; export type RuntimeHostUpgradeDialogDecision = 'restart' | 'replace' | 'wait' | 'cancel'; -interface RuntimeHostUpgradeAvailability { - readonly action: 'restart' | 'replace' | undefined; - readonly canWait: boolean; - readonly activeTasksDetected?: true; -} +type RuntimeHostUpgradeAvailability = + | RuntimeHostNonRestartableAction + | 'restart' + | 'restart_or_wait'; export interface RuntimeHostUpgradeDialog { readonly options: MessageBoxOptions; @@ -53,24 +53,31 @@ export function buildRuntimeHostUpgradeDialog( ): RuntimeHostUpgradeDialog { const activity = conflict.handshake?.activity; const hasWork = - availability.activeTasksDetected === true || + availability === 'replace_active_work' || (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' || availability === 'restart_or_wait' + ? 'restart' + : availability === 'replace_active_work' + ? 'replace' + : undefined; + const canWait = availability === 'restart_or_wait' || availability === 'wait'; + 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 + action === 'restart' && !hasWork ? 'restart' - : availability.canWait + : canWait ? 'wait' : 'cancel'; return { @@ -105,17 +112,19 @@ function formatActivity( for (const residency of activity.residencies) { lines.push(`${copy.activity[activityKey(residency.label)]}: ${residency.count}`); } - } else if (availability.activeTasksDetected) lines.push(copy.activeTasksDetected); + } else if (availability === 'replace_active_work') lines.push(copy.activeTasksDetected); else lines.push(copy.unknownActivity); - if (availability.action === 'replace') { + if (availability === 'replace_active_work') { lines.push('', copy.replaceWarning, copy.replaceExplanation); - } else if (availability.action === 'restart') { + } else if (availability === 'restart' || availability === 'restart_or_wait') { 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 (availability === 'restart_or_wait' || availability === 'wait') { + lines.push(copy.waitExplanation); + } return lines.join('\n'); } diff --git a/apps/desktop/src/main/runtime-host-upgrade-dialog.ts b/apps/desktop/src/main/runtime-host-upgrade-dialog.ts index 00e0db3bff..9c4f6fe91a 100644 --- a/apps/desktop/src/main/runtime-host-upgrade-dialog.ts +++ b/apps/desktop/src/main/runtime-host-upgrade-dialog.ts @@ -39,7 +39,7 @@ export function createRuntimeHostUpgradePrompts( const canWait = conflict.registration.lifecycleMode !== 'service'; const dialog = buildRuntimeHostUpgradeDialog( conflict, - { action: 'restart', canWait }, + canWait ? 'restart_or_wait' : 'restart', locale, ); const { response } = await showDialog( @@ -51,18 +51,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, - ...(actions.activeTasksDetected ? { activeTasksDetected: true } : {}), - }, - 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..29638d0a29 100644 --- a/packages/cli/src/__tests__/runtime-host-selected-update.test.ts +++ b/packages/cli/src/__tests__/runtime-host-selected-update.test.ts @@ -21,6 +21,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { decodeRuntimeHostServiceManagementFrame } from '@maka/runtime-host/operator'; import { + runtimeHostPackageUpdateOperation, runManagedRuntimeHostUpdateCli, runManagedRuntimeHostSelectedUpdateCli, type RuntimeHostSelectedUpdateCliOptions, @@ -48,6 +49,31 @@ const OPTIONS: RuntimeHostSelectedUpdateCliOptions = { }; describe('managed Runtime Host selected update', () => { + it('replaces an exact observed Host even when its package is already current', () => { + const current = { + currentVersion: '2.0.0', + currentIntegrity: INTEGRITY, + targetVersion: '2.0.0', + targetIntegrity: INTEGRITY, + }; + assert.equal( + runtimeHostPackageUpdateOperation({ ...current, replaceExpectedHost: false }), + 'already_current', + ); + assert.equal( + runtimeHostPackageUpdateOperation({ ...current, replaceExpectedHost: true }), + 'replace_current', + ); + assert.equal( + runtimeHostPackageUpdateOperation({ + ...current, + targetVersion: '3.0.0', + replaceExpectedHost: true, + }), + 'update', + ); + }); + it('revalidates selection inside the deployment lock before reading service state', async () => { let lockHeld = false; let output = ''; @@ -256,6 +282,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..29bb1561d2 100644 --- a/packages/cli/src/runtime-host-update-command.ts +++ b/packages/cli/src/runtime-host-update-command.ts @@ -157,6 +157,22 @@ export type RuntimeHostUpdateFrame = Extract< export type RuntimeHostUpdateFrameSink = (frame: RuntimeHostUpdateFrame) => void; +export 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; @@ -665,10 +681,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 +701,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, @@ -720,8 +742,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', @@ -756,11 +780,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 +896,7 @@ export async function runManagedRuntimeHostResolvedUpdateCli( if ( selection.outcome.kind === 'manual_action' && !( - ((options.expectedHost && options.allowInterruptActiveTasks) || + (options.expectedHost || (options.allowManualUpdate && options.managedRootId && options.expectedTarget.deploymentId)) && From b98013202f2980376e0f3f12badd6a450ad911d1 Mon Sep 17 00:00:00 2001 From: Wang Date: Tue, 1 Sep 2026 22:02:23 +0800 Subject: [PATCH 03/28] test(desktop): keep dialog assertions behavioral Generated-by: OpenAI Codex --- apps/desktop/src/main/__tests__/browser-message-box.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/main/__tests__/browser-message-box.test.ts b/apps/desktop/src/main/__tests__/browser-message-box.test.ts index e9994e0329..9e2b54b986 100644 --- a/apps/desktop/src/main/__tests__/browser-message-box.test.ts +++ b/apps/desktop/src/main/__tests__/browser-message-box.test.ts @@ -89,7 +89,7 @@ test('renders escaped content with Maka dialog tokens and safe action ordering', const actionPosition = html.indexOf('>Replace <Host>'); assert.ok(cancelPosition >= 0 && cancelPosition < copyPosition); assert.ok(copyPosition < actionPosition); - assert.match(html, /class="decision primary"[^>]*data-response="0" autofocus/u); + assert.match(html, /data-response="0" autofocus/u); assert.match(html, /default-src 'none'/u); }); From bcaf080a6702cfb084519c5532e659c4fc8df916 Mon Sep 17 00:00:00 2001 From: Wang Date: Tue, 1 Sep 2026 22:13:32 +0800 Subject: [PATCH 04/28] fix(desktop): close remaining recovery gaps --- .../main-renderer-process-gone.test.ts | 20 +++++++- .../__tests__/main-startup-lifetime.test.ts | 39 +++++++++++++++ .../runtime-host-desktop-manager.test.ts | 43 +++++++++++++++-- .../runtime-host-upgrade-dialog.test.ts | 47 +++++++++++++------ .../src/main/main-renderer-process-gone.ts | 9 ++++ .../src/main/runtime-host-desktop-manager.ts | 12 +++-- .../src/main/runtime-host-upgrade-copy.ts | 33 ++++++------- .../src/main/runtime-host-upgrade-dialog.ts | 7 +-- 8 files changed, 165 insertions(+), 45 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts 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 1e261ce6b4..44cbc12a0b 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 @@ -83,12 +83,13 @@ test('reports reload success only after the main document finishes loading', asy assert.equal(source.listenerCount('render-process-gone'), 0); }); -test('keeps recovery active when a Renderer reload fails or exits', async () => { +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(); let observed = false; @@ -104,9 +105,26 @@ test('keeps recovery active when a Renderer reload fails or exits', async () => assert.equal(await result, false); assert.equal(observed, false); assert.equal(source.listenerCount('did-finish-load'), 0); + assert.equal(source.listenerCount('unresponsive'), 0); } }); +test('bounds a Renderer reload that emits no terminal event', async () => { + const source = reloadSource(); + const result = reloadMainRendererProcess({ + source, + shutdownSignal: new AbortController().signal, + onLoaded: () => assert.fail('timed-out reload must not report success'), + timeoutMs: 1, + }); + + assert.equal(await result, false); + assert.equal(source.listenerCount('did-finish-load'), 0); + assert.equal(source.listenerCount('did-fail-load'), 0); + assert.equal(source.listenerCount('unresponsive'), 0); + assert.equal(source.listenerCount('render-process-gone'), 0); +}); + function reloadSource(): EventEmitter & { reloadCalls: number; reload(): void; 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..0a26fff7d8 --- /dev/null +++ b/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts @@ -0,0 +1,39 @@ +/* + * 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', +); + +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); +}); 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 e6c71b0f15..9d56a61936 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, @@ -1206,7 +1236,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 +1254,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); @@ -1292,7 +1329,7 @@ test('prompts only after a non-restartable Local Host reports active tasks', asy restartable: async () => assert.fail('non-restartable conflict used restart prompt'), nonRestartable: async (_conflict, action) => { prompts += 1; - assert.equal(action, 'replace_active_work'); + assert.equal(action, 'replace_may_interrupt_work'); return 'replace'; }, }, 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 ae3590b834..7a75eadb71 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,17 +37,17 @@ const conflict = { ], }, }, -} as never; +} as unknown as Parameters[0]; test('localizes upgrade activity without changing decision indexes', () => { const en = buildRuntimeHostUpgradeDialog( conflict, - 'restart_or_wait', + 'restart', 'en', ).options; const zh = buildRuntimeHostUpgradeDialog( conflict, - 'restart_or_wait', + 'restart', 'zh', ).options; assert.deepEqual(en.buttons, ['Restart Runtime Host', 'Wait', 'Cancel Startup']); @@ -79,13 +79,13 @@ test('maps the non-default replacement choice to the replace decision', async () restartable: false, registration: { pid: 42 }, } as never, - 'replace_active_work', + 'replace_may_interrupt_work', ), 'replace', ); }); -test('does not offer passive waiting for a supervised Host', async () => { +test('offers only cancellation when the target cannot wait or replace the Host', async () => { const conflict = { kind: 'upgrade_required', restartable: false, @@ -94,20 +94,39 @@ 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, 'replace_active_work'), + await prompts.nonRestartable(conflict, 'cancel_only'), 'cancel', ); }); -test('explains when the safe replacement check found active background work', () => { +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.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, @@ -115,11 +134,11 @@ test('explains when the safe replacement check found active background work', () } as Parameters[0]; const dialog = buildRuntimeHostUpgradeDialog( conflict, - 'replace_active_work', + 'replace_may_interrupt_work', 'zh', ); - assert.match(dialog.options.detail ?? '', /仍有后台任务在运行/u); + 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/main-renderer-process-gone.ts b/apps/desktop/src/main/main-renderer-process-gone.ts index 3f6f240111..2ddd3eae1f 100644 --- a/apps/desktop/src/main/main-renderer-process-gone.ts +++ b/apps/desktop/src/main/main-renderer-process-gone.ts @@ -28,6 +28,7 @@ interface RenderProcessGoneSource { interface MainRendererReloadSource { once(event: 'did-finish-load', listener: () => void): void; + once(event: 'unresponsive', listener: () => void): void; once( event: 'render-process-gone', listener: (event: Event, details: RenderProcessGoneDetails) => void, @@ -46,6 +47,7 @@ interface MainRendererReloadSource { ) => void, ): void; off(event: 'did-finish-load', listener: () => void): void; + off(event: 'unresponsive', listener: () => void): void; off( event: 'render-process-gone', listener: (event: Event, details: RenderProcessGoneDetails) => void, @@ -87,15 +89,19 @@ export function reloadMainRendererProcess(deps: { readonly source: MainRendererReloadSource; readonly shutdownSignal: AbortSignal; readonly onLoaded: () => 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; const cleanup = (): void => { + if (timeout) clearTimeout(timeout); deps.source.off('did-finish-load', onLoaded); 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); @@ -123,15 +129,18 @@ export function reloadMainRendererProcess(deps: { ): 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); deps.source.once('did-finish-load', onLoaded); 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 { diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts index 29fb3b55d2..a6247756bb 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -147,7 +147,7 @@ export class DesktopLocalHostRetirementError extends Error { export type RuntimeHostRestartDecision = 'restart' | 'wait' | 'cancel'; export type RuntimeHostNonRestartableDecision = 'replace' | 'wait' | 'cancel'; export type RuntimeHostNonRestartableAction = - | 'replace_active_work' + | 'replace_may_interrupt_work' | 'wait' | 'cancel_only'; @@ -952,11 +952,13 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { 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_active_work' - : result.registration.lifecycleMode !== 'service' - ? 'wait' - : 'cancel_only'; + ? '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') { diff --git a/apps/desktop/src/main/runtime-host-upgrade-copy.ts b/apps/desktop/src/main/runtime-host-upgrade-copy.ts index 209c05d95f..ab9654f931 100644 --- a/apps/desktop/src/main/runtime-host-upgrade-copy.ts +++ b/apps/desktop/src/main/runtime-host-upgrade-copy.ts @@ -28,10 +28,7 @@ import type { type Conflict = RuntimeHostRestartableConflict | RuntimeHostWaitConflict; export type RuntimeHostUpgradeDialogDecision = 'restart' | 'replace' | 'wait' | 'cancel'; -type RuntimeHostUpgradeAvailability = - | RuntimeHostNonRestartableAction - | 'restart' - | 'restart_or_wait'; +type RuntimeHostUpgradeAvailability = RuntimeHostNonRestartableAction | 'restart'; export interface RuntimeHostUpgradeDialog { readonly options: MessageBoxOptions; @@ -53,19 +50,21 @@ export function buildRuntimeHostUpgradeDialog( ): RuntimeHostUpgradeDialog { const activity = conflict.handshake?.activity; const hasWork = - availability === 'replace_active_work' || + availability === 'replace_may_interrupt_work' || (activity?.activeOperations ?? 0) > 0 || (activity?.residencies.length ?? 0) > 0; const copy = UPGRADE_COPY[locale]; const choices: { readonly label: string; readonly decision: RuntimeHostUpgradeDialogDecision }[] = []; const action = - availability === 'restart' || availability === 'restart_or_wait' + availability === 'restart' ? 'restart' - : availability === 'replace_active_work' + : availability === 'replace_may_interrupt_work' ? 'replace' : undefined; - const canWait = availability === 'restart_or_wait' || availability === 'wait'; + const canWait = + availability === 'wait' || + (availability === 'restart' && conflict.registration.lifecycleMode !== 'service'); if (action) { choices.push({ label: action === 'restart' ? copy.restart : copy.replace, @@ -85,7 +84,7 @@ export function buildRuntimeHostUpgradeDialog( 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'), @@ -98,6 +97,7 @@ export function buildRuntimeHostUpgradeDialog( function formatActivity( conflict: Conflict, availability: RuntimeHostUpgradeAvailability, + canWait: boolean, locale: UiLocale, ): string { const activity = conflict.handshake?.activity; @@ -112,17 +112,18 @@ function formatActivity( for (const residency of activity.residencies) { lines.push(`${copy.activity[activityKey(residency.label)]}: ${residency.count}`); } - } else if (availability === 'replace_active_work') lines.push(copy.activeTasksDetected); - else lines.push(copy.unknownActivity); - if (availability === 'replace_active_work') { + } else if (availability === 'replace_may_interrupt_work') { + lines.push(copy.idleNotVerified); + } else lines.push(copy.unknownActivity); + if (availability === 'replace_may_interrupt_work') { lines.push('', copy.replaceWarning, copy.replaceExplanation); - } else if (availability === 'restart' || availability === 'restart_or_wait') { + } 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 === 'restart_or_wait' || availability === 'wait') { + if (canWait) { lines.push(copy.waitExplanation); } return lines.join('\n'); @@ -152,7 +153,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`, - activeTasksDetected: 'This Host reported active background work during the safe replacement check.', + 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: @@ -179,7 +180,7 @@ const UPGRADE_COPY = { uptime: (n: number) => `已运行约 ${n} 分钟`, connections: (n: number) => `仍有 ${n} 个其他客户端连接`, operations: (n: number) => `有 ${n} 个操作正在运行`, - activeTasksDetected: '安全替换检查发现此 Host 仍有后台任务在运行。', + 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 9c4f6fe91a..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, - canWait ? 'restart_or_wait' : 'restart', - locale, - ); + const dialog = buildRuntimeHostUpgradeDialog(conflict, 'restart', locale); const { response } = await showDialog( dialog.options, locale, From 04c64a40cb77342c2cae6adb0bada51017e3e09c Mon Sep 17 00:00:00 2001 From: Wang Date: Tue, 1 Sep 2026 22:51:51 +0800 Subject: [PATCH 05/28] fix(desktop): converge recovery dialog behavior --- .../__tests__/browser-message-box.test.ts | 146 +++++++-- .../main-renderer-process-gone.test.ts | 46 ++- .../runtime-host-desktop-manager.test.ts | 41 +++ apps/desktop/src/main/app-ipc-main.ts | 4 +- apps/desktop/src/main/browser-message-box.ts | 278 ++++++++++-------- .../src/main/main-renderer-process-gone.ts | 25 +- apps/desktop/src/main/main-window.ts | 31 +- apps/desktop/src/main/main.ts | 39 ++- apps/desktop/src/main/runtime-host-boot.ts | 74 +++-- .../src/main/runtime-host-desktop-manager.ts | 22 +- .../runtime-host-selected-update.test.ts | 101 +++++-- .../cli/src/runtime-host-update-command.ts | 47 ++- packages/core/package.json | 1 + packages/core/src/maka-wordmark.ts | 25 ++ packages/ui/src/maka-wordmark.tsx | 3 +- scripts/build-cursor-overlay.mjs | 31 +- 16 files changed, 674 insertions(+), 240 deletions(-) create mode 100644 packages/core/src/maka-wordmark.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 index 9e2b54b986..facf758b0c 100644 --- a/apps/desktop/src/main/__tests__/browser-message-box.test.ts +++ b/apps/desktop/src/main/__tests__/browser-message-box.test.ts @@ -19,13 +19,92 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; +import type { BrowserWindow, MessageBoxReturnValue } from 'electron'; import { buildBrowserMessageBoxHtml, centeredBounds, - normalizeBrowserMessageBoxPresentation, + 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, + } as BrowserWindow; + const nativeParents: Array = []; + const showNative = async ( + _options: typeof options, + actualParent: BrowserWindow | undefined, + ): Promise => { + nativeParents.push(actualParent); + return nativeResult; + }; + + assert.equal( + await showBrowserMessageBoxWithRuntime(options, parent, { + ready: false, + showBrowser: async () => assert.fail('pre-ready presentation must stay native'), + showNative, + onBrowserError: () => assert.fail('native presentation must not report a browser error'), + }), + nativeResult, + ); + + parentState.visible = true; + const failure = new Error('renderer failed'); + let reported: unknown; + assert.equal( + await showBrowserMessageBoxWithRuntime(options, parent, { + ready: true, + showBrowser: async (_nextOptions, actualParent) => { + assert.equal(actualParent, parent); + parentState.minimized = true; + throw failure; + }, + showNative, + onBrowserError: (error) => { + reported = error; + }, + }), + nativeResult, + ); + assert.equal(reported, failure); + assert.deepEqual(nativeParents, [undefined, undefined]); +}); + +test('presents a ready dialog without an inaccessible modal parent', async () => { + const result = { response: 0, checkboxChecked: false }; + const parent = { + isVisible: () => true, + isMinimized: () => true, + isDestroyed: () => false, + } as BrowserWindow; + + let finishPresentation!: (value: MessageBoxReturnValue) => void; + const presentation = showBrowserMessageBoxWithRuntime({ message: 'Recover Maka' }, parent, { + ready: true, + showBrowser: async (_options, actualParent) => { + assert.equal(actualParent, undefined); + return new Promise((resolve) => { + finishPresentation = resolve; + }); + }, + showNative: async () => assert.fail('successful browser presentation must not fall back'), + onBrowserError: () => assert.fail('successful browser presentation must not report error'), + }); + await Promise.resolve(); + assert.equal(isBrowserMessageBoxPresentationActive(), true); + finishPresentation(result); + assert.equal(await presentation, result); + assert.equal(isBrowserMessageBoxPresentationActive(), false); +}); + 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 [ @@ -62,21 +141,23 @@ test('centers against the parent while keeping the whole dialog on-screen', () = test('renders escaped content with Maka dialog tokens and safe action ordering', () => { const html = buildBrowserMessageBoxHtml( - normalizeBrowserMessageBoxPresentation( - { - type: 'warning', - title: '', - message: 'Maka & Runtime Host', - detail: '
', - buttons: ['Replace ', 'Cancel', 'Copy & Diagnostics'], - defaultId: 0, - cancelId: 1, - }, - true, - ), + { + type: 'warning', + title: '', + message: 'Maka & Runtime Host', + detail: '', + buttons: ['Replace ', 'Cancel', 'Copy & Diagnostics'], + defaultId: 0, + cancelId: 1, + }, + { dark: true, locale: 'en', palette: 'nord' }, ); assert.match(html, /data-theme="dark"/u); + assert.match(html, /data-maka-theme="nord"/u); + assert.match(html, /--shadow-med:/u); + assert.match(html, /color: var\(--maka-brand\)/u); + assert.doesNotMatch(html, /--control-overlay-hover:/u); assert.match(html, /<img src=x onerror=alert\(1\)>/u); assert.match(html, /Maka & Runtime Host/u); assert.match(html, /<\/div><script>globalThis\.pwned = true<\/script>/u); @@ -94,7 +175,7 @@ test('renders escaped content with Maka dialog tokens and safe action ordering', }); test('normalizes fallback buttons and out-of-range action indexes once', () => { - const presentation = normalizeBrowserMessageBoxPresentation( + const html = buildBrowserMessageBoxHtml( { type: 'none', title: '', @@ -103,21 +184,32 @@ test('normalizes fallback buttons and out-of-range action indexes once', () => { defaultId: 4, cancelId: -1, }, - false, + { dark: false, locale: 'zh' }, ); - assert.deepEqual(presentation, { - type: 'none', - title: 'Maka', - message: 'Maka', - detail: '', - buttons: ['OK'], - defaultId: 0, - cancelId: 0, - dark: false, - isChinese: false, - }); - const html = buildBrowserMessageBoxHtml(presentation); + assert.match(html, /Maka<\/title>/u); + assert.match(html, /

Maka<\/h1>/u); + assert.match(html, />OK<\/button>/u); assert.match(html, /data-response="0" autofocus/u); assert.match(html, /data-theme="light"/u); + assert.match(html, /data-maka-theme="default"/u); +}); + +test('uses the resolved locale instead of guessing from user-controlled text', () => { + const html = buildBrowserMessageBoxHtml( + { + title: 'Maka recovery', + message: 'Host path: /Users/示例', + buttons: ['Continue', 'Cancel'], + defaultId: '0);globalThis.pwned=true;//' as never, + cancelId: 1, + }, + { dark: false, locale: 'en' }, + ); + + assert.match(html, / { } }); -test('reports reload success only after the main document finishes loading', async () => { +test('reports reload success only after the main document loads and the Renderer paints', async () => { const source = reloadSource(); + const readiness = rendererReadiness(); let observed = false; + let settled = false; const result = reloadMainRendererProcess({ source, shutdownSignal: new AbortController().signal, - onLoaded: () => { + subscribeRendererReady: readiness.subscribe, + onReady: () => { observed = true; }, }); + void result.then(() => { + settled = true; + }); assert.equal(source.reloadCalls, 1); source.emit('did-fail-load', {}, -3, 'subframe failed', 'https://example.test/frame', false, 1, 2); source.emit('did-finish-load'); + await Promise.resolve(); + assert.equal(settled, false); + readiness.notify(); 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 () => { @@ -92,11 +102,13 @@ test('keeps recovery active when a Renderer reload fails, exits, or stops respon (source: ReturnType) => source.emit('unresponsive'), ]) { const source = reloadSource(); + const readiness = rendererReadiness(); let observed = false; const result = reloadMainRendererProcess({ source, shutdownSignal: new AbortController().signal, - onLoaded: () => { + subscribeRendererReady: readiness.subscribe, + onReady: () => { observed = true; }, }); @@ -106,15 +118,18 @@ test('keeps recovery active when a Renderer reload fails, exits, or stops respon assert.equal(observed, false); assert.equal(source.listenerCount('did-finish-load'), 0); 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, - onLoaded: () => assert.fail('timed-out reload must not report success'), + subscribeRendererReady: readiness.subscribe, + onReady: () => assert.fail('timed-out reload must not report success'), timeoutMs: 1, }); @@ -123,8 +138,31 @@ test('bounds a Renderer reload that emits no terminal event', async () => { 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: () => void): () => void; + notify(): void; + subscribed(): boolean; +} { + let listener: (() => void) | undefined; + return { + subscribe(next) { + listener = next; + return () => { + if (listener === next) listener = undefined; + }; + }, + notify() { + listener?.(); + }, + subscribed() { + return listener !== undefined; + }, + }; +} + function reloadSource(): EventEmitter & { reloadCalls: number; reload(): void; 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 9d56a61936..3e8583bb26 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 @@ -1308,6 +1308,47 @@ test('silently replaces an idle non-restartable Local Host and retries', async ( await owner.close(); }); +test('prompts before replacing a non-restartable Local Host with observed connections', async () => { + const observed = upgradeRequired(true, 0, [], 1); + 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 'replaced'; + }, + }), + }, + ); + + assert.equal(prompts, 1); + assert.equal(starts, 2); + assert.deepEqual(policies, ['interrupt_active_work']); + await owner.close(); +}); + test('prompts only after a non-restartable Local Host reports active tasks', async () => { const observed = upgradeRequired(false); const conflict = { diff --git a/apps/desktop/src/main/app-ipc-main.ts b/apps/desktop/src/main/app-ipc-main.ts index d43f899999..bd158de339 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); }); 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 index 6cfa3eb43e..486d2514f0 100644 --- a/apps/desktop/src/main/browser-message-box.ts +++ b/apps/desktop/src/main/browser-message-box.ts @@ -18,22 +18,39 @@ */ 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, 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; -// Same traced brand outline as packages/ui/src/maka-wordmark.tsx. This startup -// surface deliberately cannot depend on the main React renderer bundle. -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'; +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; +} + +/** 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. @@ -45,21 +62,55 @@ const MAKA_WORDMARK_PATH = */ export async function showBrowserMessageBox( options: MessageBoxOptions, - parent?: BrowserWindow, + 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'); - const visibleParent = - parent && !parent.isDestroyed() && parent.isVisible() && !parent.isMinimized() - ? parent - : undefined; - if (!electron.app.isReady()) return showNativeMessageBox(electron, options, visibleParent); + return showBrowserMessageBoxWithRuntime(options, parent, { + ready: electron.app.isReady(), + showBrowser: (nextOptions, nextParent) => + presentBrowserMessageBox(electron, nextOptions, nextParent, appearance), + 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, + runtime: { + readonly ready: boolean; + readonly showBrowser: ( + options: MessageBoxOptions, + parent: BrowserWindow | undefined, + ) => Promise; + readonly showNative: ( + options: MessageBoxOptions, + parent: BrowserWindow | undefined, + ) => Promise; + readonly onBrowserError: (error: unknown) => void; + }, +): Promise { + activeBrowserMessageBoxPresentations += 1; try { - return await presentBrowserMessageBox(electron, options, visibleParent); - } catch (error) { - console.error('[dialog] BrowserWindow presentation failed; using native fallback:', error); - return showNativeMessageBox(electron, options, visibleParent); + const visibleParent = (): BrowserWindow | undefined => + parent && !parent.isDestroyed() && parent.isVisible() && !parent.isMinimized() + ? parent + : undefined; + if (!runtime.ready) return await runtime.showNative(options, visibleParent()); + try { + return await runtime.showBrowser(options, visibleParent()); + } catch (error) { + runtime.onBrowserError(error); + return await runtime.showNative(options, visibleParent()); + } + } finally { + activeBrowserMessageBoxPresentations -= 1; } } @@ -68,10 +119,7 @@ async function showNativeMessageBox( options: MessageBoxOptions, parent: BrowserWindow | undefined, ): Promise { - return parent && - !parent.isDestroyed() && - parent.isVisible() && - !parent.isMinimized() + return parent ? electron.dialog.showMessageBox(parent, options) : electron.dialog.showMessageBox(options); } @@ -80,11 +128,12 @@ async function presentBrowserMessageBox( electron: typeof import('electron'), options: MessageBoxOptions, parent: BrowserWindow | undefined, + appearance: BrowserMessageBoxAppearance, ): Promise { - const presentation = normalizeBrowserMessageBoxPresentation( - options, - electron.nativeTheme.shouldUseDarkColors, - ); + const presentation = normalizeBrowserMessageBoxPresentation(options, { + ...appearance, + dark: appearance.dark ?? electron.nativeTheme.shouldUseDarkColors, + }); const workArea = resolveWorkArea(electron, parent); const width = Math.max(320, Math.min(DIALOG_WIDTH, workArea.width - WORK_AREA_MARGIN * 2)); const initialHeight = Math.max( @@ -121,16 +170,28 @@ async function presentBrowserMessageBox( 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')), + DIALOG_PRESENTATION_TIMEOUT_MS, + ); win.on('closed', () => finish(presentation.cancelId)); win.on('unresponsive', () => fail(new Error('Dialog renderer became unresponsive'))); @@ -145,7 +206,7 @@ async function presentBrowserMessageBox( void win .loadURL( `data:text/html;charset=utf-8,${encodeURIComponent( - buildBrowserMessageBoxHtml(presentation), + renderBrowserMessageBoxHtml(presentation), )}`, ) .then(async () => { @@ -163,6 +224,7 @@ async function presentBrowserMessageBox( if (settled || win.isDestroyed()) return; win.show(); win.focus(); + clearPresentationTimeout(); }) .catch(fail); }); @@ -171,7 +233,7 @@ async function presentBrowserMessageBox( } } -export interface BrowserMessageBoxPresentation { +interface BrowserMessageBoxPresentation { readonly type: 'none' | 'info' | 'warning' | 'error' | 'question'; readonly title: string; readonly message: string; @@ -180,17 +242,16 @@ export interface BrowserMessageBoxPresentation { readonly defaultId: number; readonly cancelId: number; readonly dark: boolean; - readonly isChinese: boolean; + readonly locale: UiLocale; + readonly palette: ThemePalette; } -export function normalizeBrowserMessageBoxPresentation( +function normalizeBrowserMessageBoxPresentation( options: MessageBoxOptions, - dark: boolean, + appearance: BrowserMessageBoxAppearance & { readonly dark: boolean }, ): BrowserMessageBoxPresentation { const buttons = options.buttons?.length ? [...options.buttons] : ['OK']; - const cancelId = validButtonId(options.cancelId, buttons.length) - ? options.cancelId - : buttons.length - 1; + const cancelId = validButtonId(options.cancelId, buttons.length) ? options.cancelId : 0; const defaultId = validButtonId(options.defaultId, buttons.length) ? options.defaultId : 0; @@ -204,8 +265,9 @@ export function normalizeBrowserMessageBoxPresentation( buttons, defaultId, cancelId, - dark, - isChinese: /\p{Script=Han}/u.test(`${title}${message}`), + dark: appearance.dark, + locale: appearance.locale === 'zh' ? 'zh' : 'en', + palette: isThemePalette(appearance.palette) ? appearance.palette : 'default', }; } @@ -254,6 +316,14 @@ async function measureDialogHeight(win: BrowserWindow): Promise { : 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, @@ -267,9 +337,18 @@ export function parseBrowserMessageBoxResponse( : undefined; } -export function buildBrowserMessageBoxHtml(input: BrowserMessageBoxPresentation): string { +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.isChinese ? '关闭' : 'Close'; + const closeLabel = input.locale === 'zh' ? '关闭' : 'Close'; const closeButton = ``; @@ -307,54 +386,19 @@ export function buildBrowserMessageBoxHtml(input: BrowserMessageBoxPresentation) : ''; return ` - + ${escapeHtml(input.title)}