From 35e543d2e259fad3983cdd336da04406abc3045e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E5=90=89=E6=B5=A9?= <1625567290@qq.com> Date: Sat, 22 Aug 2026 15:42:32 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Windows=20=E6=9C=80?= =?UTF-8?q?=E5=A4=A7=E5=8C=96=E5=90=8E=E6=B8=B2=E6=9F=93=E5=8C=BA=E6=9C=AA?= =?UTF-8?q?=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../windows-maximize-renderer-sync.test.ts | 105 ++++++++++++++++++ apps/desktop/src/main/main-window.ts | 14 ++- .../main/windows-maximize-renderer-sync.ts | 48 ++++++++ 3 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/windows-maximize-renderer-sync.test.ts create mode 100644 apps/desktop/src/main/windows-maximize-renderer-sync.ts diff --git a/apps/desktop/src/main/__tests__/windows-maximize-renderer-sync.test.ts b/apps/desktop/src/main/__tests__/windows-maximize-renderer-sync.test.ts new file mode 100644 index 0000000000..4124ed5da9 --- /dev/null +++ b/apps/desktop/src/main/__tests__/windows-maximize-renderer-sync.test.ts @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + createWindowsMaximizeRendererSync, + type MaximizedRendererSyncWindow, +} from '../windows-maximize-renderer-sync.js'; + +function createFixture() { + const calls: string[] = []; + const deferred: Array<() => void> = []; + let destroyed = false; + let maximized = true; + let webContentsDestroyed = false; + const contentView = {}; + const window: MaximizedRendererSyncWindow = { + contentView, + webContents: { + isDestroyed: () => webContentsDestroyed, + invalidate: () => calls.push('invalidate'), + }, + isDestroyed: () => destroyed, + isMaximized: () => maximized, + setContentView: (view) => { + assert.equal(view, contentView); + calls.push('layout'); + }, + }; + + return { + calls, + deferred, + window, + defer: (callback: () => void) => deferred.push(callback), + setDestroyed: (value: boolean) => { destroyed = value; }, + setMaximized: (value: boolean) => { maximized = value; }, + setWebContentsDestroyed: (value: boolean) => { webContentsDestroyed = value; }, + }; +} + +describe('Windows maximize renderer sync', () => { + it('defers one root layout and repaint for a maximized Windows window', () => { + const fixture = createFixture(); + const schedule = createWindowsMaximizeRendererSync(fixture.window, { + platform: 'win32', + defer: fixture.defer, + }); + + schedule(); + schedule(); + assert.equal(fixture.deferred.length, 1); + assert.deepEqual(fixture.calls, []); + + fixture.deferred.shift()?.(); + assert.deepEqual(fixture.calls, ['layout', 'invalidate']); + }); + + it('does nothing on non-Windows platforms', () => { + const fixture = createFixture(); + const schedule = createWindowsMaximizeRendererSync(fixture.window, { + platform: 'darwin', + defer: fixture.defer, + }); + + schedule(); + assert.equal(fixture.deferred.length, 0); + assert.deepEqual(fixture.calls, []); + }); + + it('drops deferred work when the window leaves maximized state or is destroyed', () => { + const restored = createFixture(); + const scheduleRestored = createWindowsMaximizeRendererSync(restored.window, { + platform: 'win32', + defer: restored.defer, + }); + scheduleRestored(); + restored.setMaximized(false); + restored.deferred.shift()?.(); + + const destroyed = createFixture(); + const scheduleDestroyed = createWindowsMaximizeRendererSync(destroyed.window, { + platform: 'win32', + defer: destroyed.defer, + }); + scheduleDestroyed(); + destroyed.setDestroyed(true); + destroyed.deferred.shift()?.(); + + assert.deepEqual(restored.calls, []); + assert.deepEqual(destroyed.calls, []); + }); + + it('does not touch a destroyed WebContents', () => { + const fixture = createFixture(); + const schedule = createWindowsMaximizeRendererSync(fixture.window, { + platform: 'win32', + defer: fixture.defer, + }); + + schedule(); + fixture.setWebContentsDestroyed(true); + fixture.deferred.shift()?.(); + + assert.deepEqual(fixture.calls, []); + }); +}); diff --git a/apps/desktop/src/main/main-window.ts b/apps/desktop/src/main/main-window.ts index fe6acb91a4..a00bcd150a 100644 --- a/apps/desktop/src/main/main-window.ts +++ b/apps/desktop/src/main/main-window.ts @@ -32,6 +32,7 @@ import { installMainWindowPermissionPolicy } from './main-window-permission-poli import { observeMainRendererProcessGone } from './main-renderer-process-gone.js'; import { isThemePreference, toNativeThemeSource } from './theme-source.js'; import { createWindowRevealGate } from './window-reveal.js'; +import { createWindowsMaximizeRendererSync } from './windows-maximize-renderer-sync.js'; import { parseDesktopSessionResourceKey, } from '../shared/runtime-host-identity.js'; @@ -468,9 +469,18 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main void writeSavedBounds(workspaceRoot, next); }, 400); }; - mainWindow.on('resize', scheduleSave); + const scheduleMaximizedRendererSync = createWindowsMaximizeRendererSync(mainWindow); + const handleResize = (): void => { + scheduleSave(); + scheduleMaximizedRendererSync(); + }; + const handleMaximize = (): void => { + scheduleSave(); + scheduleMaximizedRendererSync(); + }; + mainWindow.on('resize', handleResize); mainWindow.on('move', scheduleSave); - mainWindow.on('maximize', scheduleSave); + mainWindow.on('maximize', handleMaximize); mainWindow.on('unmaximize', scheduleSave); mainWindow.on('close', () => { clearShowFallbackTimer(); diff --git a/apps/desktop/src/main/windows-maximize-renderer-sync.ts b/apps/desktop/src/main/windows-maximize-renderer-sync.ts new file mode 100644 index 0000000000..7c405188d8 --- /dev/null +++ b/apps/desktop/src/main/windows-maximize-renderer-sync.ts @@ -0,0 +1,48 @@ +export interface MaximizedRendererSyncWindow { + readonly contentView: ContentView; + readonly webContents: { + isDestroyed(): boolean; + invalidate(): void; + }; + isDestroyed(): boolean; + isMaximized(): boolean; + setContentView(view: ContentView): void; +} + +interface MaximizedRendererSyncOptions { + platform?: NodeJS.Platform; + defer?: (callback: () => void) => void; +} + +/** + * Re-runs Electron's root view layout after a native Windows maximize. + * + * Electron's BrowserWindow WebContentsView and the public contentView are + * siblings under one default-fill root view. Re-applying the same contentView + * makes Electron invalidate and immediately lay out that root without changing + * the native window bounds or its restored bounds. The repaint then covers the + * newly maximized client area. + */ +export function createWindowsMaximizeRendererSync( + window: MaximizedRendererSyncWindow, + options: MaximizedRendererSyncOptions = {}, +): () => void { + const platform = options.platform ?? process.platform; + const defer = options.defer ?? setImmediate; + let pending = false; + + return () => { + if (platform !== 'win32' || pending) return; + if (window.isDestroyed() || !window.isMaximized()) return; + pending = true; + + defer(() => { + pending = false; + if (window.isDestroyed() || !window.isMaximized()) return; + if (window.webContents.isDestroyed()) return; + + window.setContentView(window.contentView); + window.webContents.invalidate(); + }); + }; +} From 31ac94f964a4b28fc23fb62c6b0f3f5a9ce1b740 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E5=90=89=E6=B5=A9?= <1625567290@qq.com> Date: Sat, 22 Aug 2026 18:27:55 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E8=A1=A5=E5=85=85=20Windows=20=E6=9C=80?= =?UTF-8?q?=E5=A4=A7=E5=8C=96=E6=89=93=E5=8C=85=E7=83=9F=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release-windows-check.yml | 2 + scripts/verify-packaged-app.mjs | 220 +++++++++++++++++++- scripts/verify-windows-harness.test.mjs | 31 +++ scripts/verify-windows-x64.mjs | 5 +- 4 files changed, 256 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-windows-check.yml b/.github/workflows/release-windows-check.yml index d4b58c0b81..c8d0c3ae1f 100644 --- a/.github/workflows/release-windows-check.yml +++ b/.github/workflows/release-windows-check.yml @@ -45,9 +45,11 @@ on: # The packaged updater's feed behavior — and the boot wiring that hands # MAKA_UPDATE_TEST_FEED to it — is only observable on this path. - 'apps/desktop/src/main/app-update-service.ts' + - 'apps/desktop/src/main/main-window.ts' - 'apps/desktop/src/main/runtime-host-boot.ts' - 'packages/runtime-host/src/client/connect-or-spawn.ts' - 'packages/runtime-host/src/client/launcher.ts' + - 'apps/desktop/src/main/windows-maximize-renderer-sync.ts' - 'scripts/prepare-windows-upgrade-baseline.mjs' - 'scripts/windows-upgrade-baseline.json' - 'scripts/verify-packaged-app.mjs' diff --git a/scripts/verify-packaged-app.mjs b/scripts/verify-packaged-app.mjs index b6f8049a6b..543c78d9e3 100644 --- a/scripts/verify-packaged-app.mjs +++ b/scripts/verify-packaged-app.mjs @@ -289,6 +289,66 @@ export async function evaluateInRenderer( } } +/** Send one command to a CDP target and return the command result. */ +export async function sendCdpCommand( + webSocketDebuggerUrl, + method, + params = {}, + { timeoutMs = 10_000 } = {}, +) { + if (typeof WebSocket !== 'function') { + throw new Error('The release verifier requires Node.js WebSocket support.'); + } + const socket = new WebSocket(webSocketDebuggerUrl); + try { + await new Promise((resolvePromise, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`CDP WebSocket did not open within ${timeoutMs}ms.`)); + }, timeoutMs); + socket.addEventListener( + 'open', + () => { + clearTimeout(timeout); + resolvePromise(); + }, + { once: true }, + ); + socket.addEventListener( + 'error', + (event) => { + clearTimeout(timeout); + reject(event.error ?? new Error('CDP WebSocket connection failed.')); + }, + { once: true }, + ); + }); + } catch (error) { + socket.close(); + throw error; + } + + try { + return await new Promise((resolvePromise, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`CDP ${method} timed out.`)); + }, timeoutMs); + socket.addEventListener('message', (event) => { + const message = JSON.parse(String(event.data)); + if (message.id !== 1) return; + clearTimeout(timeout); + if (message.error) { + reject(new Error(`${method}: ${message.error.message}`)); + return; + } + resolvePromise(message.result); + }); + socket.send(JSON.stringify({ id: 1, method, params })); + }); + } finally { + socket.close(); + } +} + export const RENDERER_STATE_EXPRESSION = `({ readyState: document.readyState, hasBridge: Boolean(window.maka), @@ -353,6 +413,158 @@ export async function waitForUsableRenderer( } } +const WINDOW_LAYOUT_EXPRESSION = `(async () => { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + const rect = (selector) => { + const element = document.querySelector(selector); + if (!element) return null; + const bounds = element.getBoundingClientRect(); + return { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + }; + }; + return { + innerWidth: window.innerWidth, + innerHeight: window.innerHeight, + outerWidth: window.outerWidth, + outerHeight: window.outerHeight, + documentWidth: document.documentElement.clientWidth, + documentHeight: document.documentElement.clientHeight, + visualViewportWidth: window.visualViewport?.width ?? null, + visualViewportHeight: window.visualViewport?.height ?? null, + screenAvailWidth: window.screen.availWidth, + screenAvailHeight: window.screen.availHeight, + html: rect('html'), + body: rect('body'), + root: rect('#root'), + appFrame: rect('.appFrame'), + }; +})()`; + +function dimensionsMatch(actual, expected, tolerance = 1) { + return Number.isFinite(actual) && Math.abs(actual - expected) <= tolerance; +} + +export function rendererLayoutMatchesViewport(layout) { + if (!Number.isFinite(layout?.innerWidth) || layout.innerWidth <= 0) return false; + if (!Number.isFinite(layout?.innerHeight) || layout.innerHeight <= 0) return false; + if (!dimensionsMatch(layout.documentWidth, layout.innerWidth)) return false; + if (!dimensionsMatch(layout.documentHeight, layout.innerHeight)) return false; + if (!dimensionsMatch(layout.visualViewportWidth, layout.innerWidth)) return false; + if (!dimensionsMatch(layout.visualViewportHeight, layout.innerHeight)) return false; + for (const bounds of [layout.html, layout.body, layout.root, layout.appFrame]) { + if (!bounds) return false; + if (!dimensionsMatch(bounds.x, 0) || !dimensionsMatch(bounds.y, 0)) return false; + if (!dimensionsMatch(bounds.width, layout.innerWidth)) return false; + if (!dimensionsMatch(bounds.height, layout.innerHeight)) return false; + } + return true; +} + +async function browserDebuggerUrl(port) { + const response = await fetch(`http://127.0.0.1:${port}/json/version`, { + signal: AbortSignal.timeout(2_000), + }); + if (!response.ok) { + throw new Error(`CDP browser target returned HTTP ${response.status}.`); + } + const version = await response.json(); + if (!version.webSocketDebuggerUrl) { + throw new Error('CDP browser target did not expose a WebSocket URL.'); + } + return version.webSocketDebuggerUrl; +} + +async function captureWindowLayout(browserUrl, rendererUrl, windowId) { + const [{ bounds }, layout] = await Promise.all([ + sendCdpCommand(browserUrl, 'Browser.getWindowBounds', { windowId }), + evaluateInRenderer(rendererUrl, WINDOW_LAYOUT_EXPRESSION, { + awaitPromise: true, + timeoutMs: 10_000, + }), + ]); + return { bounds, layout }; +} + +async function waitForWindowLayout( + browserUrl, + rendererUrl, + child, + windowId, + expectedWindowState, + { timeoutMs = 30_000 } = {}, +) { + const deadline = Date.now() + timeoutMs; + let observed; + let lastError; + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error(`Packaged Maka exited during the ${expectedWindowState} transition.`); + } + try { + observed = await captureWindowLayout(browserUrl, rendererUrl, windowId); + lastError = undefined; + if ( + observed.bounds?.windowState === expectedWindowState && + rendererLayoutMatchesViewport(observed.layout) + ) { + return observed; + } + } catch (error) { + lastError = error; + } + await delay(250); + } + throw new Error( + `Packaged renderer did not settle in ${expectedWindowState} state within ${timeoutMs}ms: ${ + lastError ? lastError.message : JSON.stringify(observed) + }`, + ); +} + +export async function exercisePackagedRendererMaximizeRestore(browserUrl, rendererTarget, child) { + const rendererUrl = rendererTarget.webSocketDebuggerUrl; + const { windowId } = await sendCdpCommand(browserUrl, 'Browser.getWindowForTarget', { + targetId: rendererTarget.id, + }); + const restored = await waitForWindowLayout(browserUrl, rendererUrl, child, windowId, 'normal'); + + await sendCdpCommand(browserUrl, 'Browser.setWindowBounds', { + windowId, + bounds: { windowState: 'maximized' }, + }); + const maximized = await waitForWindowLayout( + browserUrl, + rendererUrl, + child, + windowId, + 'maximized', + ); + + await sendCdpCommand(browserUrl, 'Browser.setWindowBounds', { + windowId, + bounds: { windowState: 'normal' }, + }); + const restoredAgain = await waitForWindowLayout( + browserUrl, + rendererUrl, + child, + windowId, + 'normal', + ); + + console.log( + `[packaged-renderer] window transition: ${JSON.stringify({ + restored, + maximized, + restoredAgain, + })}`, + ); +} + export async function stopChild(child) { if (child.exitCode !== null) return; child.kill('SIGTERM'); @@ -428,7 +640,10 @@ export function isolatedUserEnv(homeDirectory, { temporaryDirectory = homeDirect }; } -export async function smokePackagedRenderer(executable, { workingDirectory } = {}) { +export async function smokePackagedRenderer( + executable, + { workingDirectory, verifyMaximizeRestore = false } = {}, +) { const home = join(workingDirectory, 'home'); const userData = join(workingDirectory, 'user-data'); const userEnv = isolatedUserEnv(home); @@ -459,6 +674,9 @@ export async function smokePackagedRenderer(executable, { workingDirectory } = { const port = await waitForDevToolsPort(child); const target = await findRendererTarget(port, child); await waitForUsableRenderer(target.webSocketDebuggerUrl, child); + if (verifyMaximizeRestore) { + await exercisePackagedRendererMaximizeRestore(await browserDebuggerUrl(port), target, child); + } } catch (error) { throw new Error(`${error.message}${stderr.trim() ? `\n${stderr.trim()}` : ''}`); } finally { diff --git a/scripts/verify-windows-harness.test.mjs b/scripts/verify-windows-harness.test.mjs index 2307728c83..86e28a2540 100644 --- a/scripts/verify-windows-harness.test.mjs +++ b/scripts/verify-windows-harness.test.mjs @@ -30,6 +30,7 @@ import { validateWindowsUpgradeBaseline } from './prepare-windows-upgrade-baseli import { diffTreeManifests, directoryTreeManifest, + rendererLayoutMatchesViewport, runCommand, waitForDevToolsPort, waitForUsableRenderer, @@ -82,6 +83,36 @@ it('scopes rollback registration reads and deletion to the fixture uninstaller', assert.match(calls[1].args.at(-1), /Remove-Item -LiteralPath \$_\.Path/u); }); +describe('rendererLayoutMatchesViewport', () => { + const viewportLayout = () => ({ + innerWidth: 1920, + innerHeight: 1040, + outerWidth: 1920, + outerHeight: 1040, + documentWidth: 1920, + documentHeight: 1040, + visualViewportWidth: 1920, + visualViewportHeight: 1040, + screenAvailWidth: 1920, + screenAvailHeight: 1040, + html: { x: 0, y: 0, width: 1920, height: 1040 }, + body: { x: 0, y: 0, width: 1920, height: 1040 }, + root: { x: 0, y: 0, width: 1920, height: 1040 }, + appFrame: { x: 0, y: 0, width: 1920, height: 1040 }, + }); + + it('accepts a renderer tree that covers the maximized viewport', () => { + assert.equal(rendererLayoutMatchesViewport(viewportLayout()), true); + }); + + it('rejects the stale-height band from the maximize regression', () => { + const layout = viewportLayout(); + layout.root.height = 820; + layout.appFrame.height = 820; + assert.equal(rendererLayoutMatchesViewport(layout), false); + }); +}); + it('uses the product SemVer contract throughout Windows release verification', () => { assert.equal(installerVersion('Maka-1.2.3-beta.2-win-x64.exe'), '1.2.3-beta.2'); assert.equal(bumpedAutoupdateVersion('1.2.3-beta.2'), '1.2.3'); diff --git a/scripts/verify-windows-x64.mjs b/scripts/verify-windows-x64.mjs index 877509bc83..6b2bf36265 100644 --- a/scripts/verify-windows-x64.mjs +++ b/scripts/verify-windows-x64.mjs @@ -235,7 +235,10 @@ export async function verifyPackagedWindowsApp( }); step('smoking the packaged renderer'); - await smokeRenderer(executable, { workingDirectory }); + await smokeRenderer(executable, { + workingDirectory, + verifyMaximizeRestore: requiresCurrentContract, + }); step('packaged app verified'); } From 96ea703dd68fc7e2fcaf178e5e0d19f53de148fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E5=90=89=E6=B5=A9?= <1625567290@qq.com> Date: Sat, 22 Aug 2026 19:03:36 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=E6=94=B9=E7=94=A8=E5=8E=9F=E7=94=9F?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E5=8F=A5=E6=9F=84=E9=AA=8C=E8=AF=81=E6=9C=80?= =?UTF-8?q?=E5=A4=A7=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/verify-packaged-app.mjs | 201 +++++++++++------------- scripts/verify-windows-harness.test.mjs | 24 +++ 2 files changed, 117 insertions(+), 108 deletions(-) diff --git a/scripts/verify-packaged-app.mjs b/scripts/verify-packaged-app.mjs index 543c78d9e3..126a886e27 100644 --- a/scripts/verify-packaged-app.mjs +++ b/scripts/verify-packaged-app.mjs @@ -289,66 +289,6 @@ export async function evaluateInRenderer( } } -/** Send one command to a CDP target and return the command result. */ -export async function sendCdpCommand( - webSocketDebuggerUrl, - method, - params = {}, - { timeoutMs = 10_000 } = {}, -) { - if (typeof WebSocket !== 'function') { - throw new Error('The release verifier requires Node.js WebSocket support.'); - } - const socket = new WebSocket(webSocketDebuggerUrl); - try { - await new Promise((resolvePromise, reject) => { - const timeout = setTimeout(() => { - reject(new Error(`CDP WebSocket did not open within ${timeoutMs}ms.`)); - }, timeoutMs); - socket.addEventListener( - 'open', - () => { - clearTimeout(timeout); - resolvePromise(); - }, - { once: true }, - ); - socket.addEventListener( - 'error', - (event) => { - clearTimeout(timeout); - reject(event.error ?? new Error('CDP WebSocket connection failed.')); - }, - { once: true }, - ); - }); - } catch (error) { - socket.close(); - throw error; - } - - try { - return await new Promise((resolvePromise, reject) => { - const timeout = setTimeout(() => { - reject(new Error(`CDP ${method} timed out.`)); - }, timeoutMs); - socket.addEventListener('message', (event) => { - const message = JSON.parse(String(event.data)); - if (message.id !== 1) return; - clearTimeout(timeout); - if (message.error) { - reject(new Error(`${method}: ${message.error.message}`)); - return; - } - resolvePromise(message.result); - }); - socket.send(JSON.stringify({ id: 1, method, params })); - }); - } finally { - socket.close(); - } -} - export const RENDERER_STATE_EXPRESSION = `({ readyState: document.readyState, hasBridge: Boolean(window.maka), @@ -427,6 +367,7 @@ const WINDOW_LAYOUT_EXPRESSION = `(async () => { }; }; return { + devicePixelRatio: window.devicePixelRatio, innerWidth: window.innerWidth, innerHeight: window.innerHeight, outerWidth: window.outerWidth, @@ -464,36 +405,101 @@ export function rendererLayoutMatchesViewport(layout) { return true; } -async function browserDebuggerUrl(port) { - const response = await fetch(`http://127.0.0.1:${port}/json/version`, { - signal: AbortSignal.timeout(2_000), - }); - if (!response.ok) { - throw new Error(`CDP browser target returned HTTP ${response.status}.`); - } - const version = await response.json(); - if (!version.webSocketDebuggerUrl) { - throw new Error('CDP browser target did not expose a WebSocket URL.'); +export function rendererViewportMatchesNativeClient(layout, nativeWindow) { + if (!rendererLayoutMatchesViewport(layout)) return false; + if (!Number.isFinite(layout.devicePixelRatio) || layout.devicePixelRatio <= 0) return false; + if (!Number.isFinite(nativeWindow?.clientWidth) || nativeWindow.clientWidth <= 0) return false; + if (!Number.isFinite(nativeWindow?.clientHeight) || nativeWindow.clientHeight <= 0) return false; + const widthScale = nativeWindow.clientWidth / layout.innerWidth; + const heightScale = nativeWindow.clientHeight / layout.innerHeight; + return ( + dimensionsMatch(widthScale, heightScale, 0.01) && + dimensionsMatch(widthScale, layout.devicePixelRatio, 0.05) + ); +} + +function windowsWindowProbeScript(processId, nextWindowState) { + const showCommand = + nextWindowState === undefined + ? '' + : `[void][MakaNativeWindow]::ShowWindowAsync($handle, ${ + nextWindowState === 'maximized' ? 3 : 9 + })`; + return String.raw` +$ErrorActionPreference = 'Stop' +Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; + +public static class MakaNativeWindow { + [StructLayout(LayoutKind.Sequential)] + public struct RECT { + public int Left; + public int Top; + public int Right; + public int Bottom; } - return version.webSocketDebuggerUrl; + + [DllImport("user32.dll")] + public static extern bool GetClientRect(IntPtr hWnd, out RECT rect); + + [DllImport("user32.dll")] + public static extern bool IsZoomed(IntPtr hWnd); + + [DllImport("user32.dll")] + public static extern bool ShowWindowAsync(IntPtr hWnd, int command); +} +'@ +$process = Get-Process -Id ${processId} -ErrorAction Stop +$process.Refresh() +$handle = $process.MainWindowHandle +if ($handle -eq [IntPtr]::Zero) { + throw 'Packaged Maka has no main window handle.' +} +${showCommand} +$rect = New-Object MakaNativeWindow+RECT +if (-not [MakaNativeWindow]::GetClientRect($handle, [ref]$rect)) { + throw 'GetClientRect failed for packaged Maka.' +} +$windowState = if ([MakaNativeWindow]::IsZoomed($handle)) { 'maximized' } else { 'normal' } +[pscustomobject]@{ + windowState = $windowState + clientWidth = $rect.Right - $rect.Left + clientHeight = $rect.Bottom - $rect.Top +} | ConvertTo-Json -Compress +`; } -async function captureWindowLayout(browserUrl, rendererUrl, windowId) { - const [{ bounds }, layout] = await Promise.all([ - sendCdpCommand(browserUrl, 'Browser.getWindowBounds', { windowId }), +async function readWindowsNativeWindow(processId, nextWindowState) { + const { stdout } = await runCommand( + 'powershell', + [ + '-NoProfile', + '-NonInteractive', + '-Command', + windowsWindowProbeScript(processId, nextWindowState), + ], + { timeoutMs: 10_000 }, + ); + const json = stdout.trim().split(/\r?\n/u).at(-1); + if (!json) throw new Error('Windows native window probe returned no state.'); + return JSON.parse(json); +} + +async function captureWindowLayout(rendererUrl, processId) { + const [nativeWindow, layout] = await Promise.all([ + readWindowsNativeWindow(processId), evaluateInRenderer(rendererUrl, WINDOW_LAYOUT_EXPRESSION, { awaitPromise: true, timeoutMs: 10_000, }), ]); - return { bounds, layout }; + return { nativeWindow, layout }; } async function waitForWindowLayout( - browserUrl, rendererUrl, child, - windowId, expectedWindowState, { timeoutMs = 30_000 } = {}, ) { @@ -505,11 +511,11 @@ async function waitForWindowLayout( throw new Error(`Packaged Maka exited during the ${expectedWindowState} transition.`); } try { - observed = await captureWindowLayout(browserUrl, rendererUrl, windowId); + observed = await captureWindowLayout(rendererUrl, child.pid); lastError = undefined; if ( - observed.bounds?.windowState === expectedWindowState && - rendererLayoutMatchesViewport(observed.layout) + observed.nativeWindow?.windowState === expectedWindowState && + rendererViewportMatchesNativeClient(observed.layout, observed.nativeWindow) ) { return observed; } @@ -525,36 +531,15 @@ async function waitForWindowLayout( ); } -export async function exercisePackagedRendererMaximizeRestore(browserUrl, rendererTarget, child) { +export async function exercisePackagedRendererMaximizeRestore(rendererTarget, child) { const rendererUrl = rendererTarget.webSocketDebuggerUrl; - const { windowId } = await sendCdpCommand(browserUrl, 'Browser.getWindowForTarget', { - targetId: rendererTarget.id, - }); - const restored = await waitForWindowLayout(browserUrl, rendererUrl, child, windowId, 'normal'); + const restored = await waitForWindowLayout(rendererUrl, child, 'normal'); - await sendCdpCommand(browserUrl, 'Browser.setWindowBounds', { - windowId, - bounds: { windowState: 'maximized' }, - }); - const maximized = await waitForWindowLayout( - browserUrl, - rendererUrl, - child, - windowId, - 'maximized', - ); + await readWindowsNativeWindow(child.pid, 'maximized'); + const maximized = await waitForWindowLayout(rendererUrl, child, 'maximized'); - await sendCdpCommand(browserUrl, 'Browser.setWindowBounds', { - windowId, - bounds: { windowState: 'normal' }, - }); - const restoredAgain = await waitForWindowLayout( - browserUrl, - rendererUrl, - child, - windowId, - 'normal', - ); + await readWindowsNativeWindow(child.pid, 'normal'); + const restoredAgain = await waitForWindowLayout(rendererUrl, child, 'normal'); console.log( `[packaged-renderer] window transition: ${JSON.stringify({ @@ -675,7 +660,7 @@ export async function smokePackagedRenderer( const target = await findRendererTarget(port, child); await waitForUsableRenderer(target.webSocketDebuggerUrl, child); if (verifyMaximizeRestore) { - await exercisePackagedRendererMaximizeRestore(await browserDebuggerUrl(port), target, child); + await exercisePackagedRendererMaximizeRestore(target, child); } } catch (error) { throw new Error(`${error.message}${stderr.trim() ? `\n${stderr.trim()}` : ''}`); diff --git a/scripts/verify-windows-harness.test.mjs b/scripts/verify-windows-harness.test.mjs index 86e28a2540..6cd678018c 100644 --- a/scripts/verify-windows-harness.test.mjs +++ b/scripts/verify-windows-harness.test.mjs @@ -31,6 +31,7 @@ import { diffTreeManifests, directoryTreeManifest, rendererLayoutMatchesViewport, + rendererViewportMatchesNativeClient, runCommand, waitForDevToolsPort, waitForUsableRenderer, @@ -85,6 +86,7 @@ it('scopes rollback registration reads and deletion to the fixture uninstaller', describe('rendererLayoutMatchesViewport', () => { const viewportLayout = () => ({ + devicePixelRatio: 1, innerWidth: 1920, innerHeight: 1040, outerWidth: 1920, @@ -111,6 +113,28 @@ describe('rendererLayoutMatchesViewport', () => { layout.appFrame.height = 820; assert.equal(rendererLayoutMatchesViewport(layout), false); }); + + it('matches the renderer viewport to native client pixels at the reported scale', () => { + const layout = viewportLayout(); + layout.devicePixelRatio = 1.25; + assert.equal( + rendererViewportMatchesNativeClient(layout, { + clientWidth: 2400, + clientHeight: 1300, + }), + true, + ); + }); + + it('rejects a renderer viewport that is stale against the native client', () => { + assert.equal( + rendererViewportMatchesNativeClient(viewportLayout(), { + clientWidth: 1920, + clientHeight: 900, + }), + false, + ); + }); }); it('uses the product SemVer contract throughout Windows release verification', () => { From 81cb3a7906a776a3c47fd9cdc960797ec77bdd82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E5=90=89=E6=B5=A9?= <1625567290@qq.com> Date: Sat, 22 Aug 2026 19:22:55 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=E5=BC=BA=E5=8C=96=20Windows=20=E6=9C=80?= =?UTF-8?q?=E5=A4=A7=E5=8C=96=E5=B0=BA=E5=AF=B8=E7=83=9F=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/verify-packaged-app.mjs | 81 +++++++++++++++++++++++++++++---- 1 file changed, 73 insertions(+), 8 deletions(-) diff --git a/scripts/verify-packaged-app.mjs b/scripts/verify-packaged-app.mjs index 126a886e27..81cf5aa768 100644 --- a/scripts/verify-packaged-app.mjs +++ b/scripts/verify-packaged-app.mjs @@ -418,13 +418,33 @@ export function rendererViewportMatchesNativeClient(layout, nativeWindow) { ); } -function windowsWindowProbeScript(processId, nextWindowState) { - const showCommand = +function windowsWindowProbeScript(processId, nextWindowState, restoredBounds) { + const stateTransition = nextWindowState === undefined ? '' - : `[void][MakaNativeWindow]::ShowWindowAsync($handle, ${ - nextWindowState === 'maximized' ? 3 : 9 - })`; + : String.raw` +[void][MakaNativeWindow]::ShowWindowAsync($handle, ${nextWindowState === 'maximized' ? 3 : 9}) +$expectedZoomed = ${nextWindowState === 'maximized' ? '$true' : '$false'} +$stateDeadline = (Get-Date).AddSeconds(2) +while ([MakaNativeWindow]::IsZoomed($handle) -ne $expectedZoomed) { + if ((Get-Date) -ge $stateDeadline) { + throw 'Packaged Maka did not enter the requested native window state.' + } + Start-Sleep -Milliseconds 25 +}`; + const resizeRestoredWindow = restoredBounds + ? String.raw` +if (-not [MakaNativeWindow]::MoveWindow( + $handle, + ${restoredBounds.x}, + ${restoredBounds.y}, + ${restoredBounds.width}, + ${restoredBounds.height}, + $true +)) { + throw 'MoveWindow failed for packaged Maka.' +}` + : ''; return String.raw` $ErrorActionPreference = 'Stop' Add-Type -TypeDefinition @' @@ -446,6 +466,16 @@ public static class MakaNativeWindow { [DllImport("user32.dll")] public static extern bool IsZoomed(IntPtr hWnd); + [DllImport("user32.dll")] + public static extern bool MoveWindow( + IntPtr hWnd, + int x, + int y, + int width, + int height, + bool repaint + ); + [DllImport("user32.dll")] public static extern bool ShowWindowAsync(IntPtr hWnd, int command); } @@ -456,7 +486,8 @@ $handle = $process.MainWindowHandle if ($handle -eq [IntPtr]::Zero) { throw 'Packaged Maka has no main window handle.' } -${showCommand} +${stateTransition} +${resizeRestoredWindow} $rect = New-Object MakaNativeWindow+RECT if (-not [MakaNativeWindow]::GetClientRect($handle, [ref]$rect)) { throw 'GetClientRect failed for packaged Maka.' @@ -470,14 +501,14 @@ $windowState = if ([MakaNativeWindow]::IsZoomed($handle)) { 'maximized' } else { `; } -async function readWindowsNativeWindow(processId, nextWindowState) { +async function readWindowsNativeWindow(processId, nextWindowState, restoredBounds) { const { stdout } = await runCommand( 'powershell', [ '-NoProfile', '-NonInteractive', '-Command', - windowsWindowProbeScript(processId, nextWindowState), + windowsWindowProbeScript(processId, nextWindowState, restoredBounds), ], { timeoutMs: 10_000 }, ); @@ -533,6 +564,12 @@ async function waitForWindowLayout( export async function exercisePackagedRendererMaximizeRestore(rendererTarget, child) { const rendererUrl = rendererTarget.webSocketDebuggerUrl; + await readWindowsNativeWindow(child.pid, 'normal', { + x: 80, + y: 60, + width: 800, + height: 600, + }); const restored = await waitForWindowLayout(rendererUrl, child, 'normal'); await readWindowsNativeWindow(child.pid, 'maximized'); @@ -541,6 +578,34 @@ export async function exercisePackagedRendererMaximizeRestore(rendererTarget, ch await readWindowsNativeWindow(child.pid, 'normal'); const restoredAgain = await waitForWindowLayout(rendererUrl, child, 'normal'); + const restoredClient = restored.nativeWindow; + const maximizedClient = maximized.nativeWindow; + const restoredAgainClient = restoredAgain.nativeWindow; + if ( + maximizedClient.clientWidth < restoredClient.clientWidth || + maximizedClient.clientHeight < restoredClient.clientHeight || + (maximizedClient.clientWidth === restoredClient.clientWidth && + maximizedClient.clientHeight === restoredClient.clientHeight) + ) { + throw new Error( + `Packaged Maka maximize smoke did not grow the native client: ${JSON.stringify({ + restored: restoredClient, + maximized: maximizedClient, + })}`, + ); + } + if ( + !dimensionsMatch(restoredAgainClient.clientWidth, restoredClient.clientWidth, 2) || + !dimensionsMatch(restoredAgainClient.clientHeight, restoredClient.clientHeight, 2) + ) { + throw new Error( + `Packaged Maka did not restore its original native client size: ${JSON.stringify({ + restored: restoredClient, + restoredAgain: restoredAgainClient, + })}`, + ); + } + console.log( `[packaged-renderer] window transition: ${JSON.stringify({ restored, From 3b90c1d580422746fd3c972eb5e56772fc132bfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E5=90=89=E6=B5=A9?= <1625567290@qq.com> Date: Sun, 23 Aug 2026 09:57:35 +0800 Subject: [PATCH 5/7] =?UTF-8?q?=E8=A1=A5=E5=85=85=20Windows=20=E6=9C=80?= =?UTF-8?q?=E5=A4=A7=E5=8C=96=E6=96=87=E4=BB=B6=E8=AE=B8=E5=8F=AF=E5=A4=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../windows-maximize-renderer-sync.test.ts | 19 +++++++++++++++++++ .../main/windows-maximize-renderer-sync.ts | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/apps/desktop/src/main/__tests__/windows-maximize-renderer-sync.test.ts b/apps/desktop/src/main/__tests__/windows-maximize-renderer-sync.test.ts index 4124ed5da9..7cb566b9f8 100644 --- a/apps/desktop/src/main/__tests__/windows-maximize-renderer-sync.test.ts +++ b/apps/desktop/src/main/__tests__/windows-maximize-renderer-sync.test.ts @@ -1,3 +1,22 @@ +/* + * 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 { describe, it } from 'node:test'; import { diff --git a/apps/desktop/src/main/windows-maximize-renderer-sync.ts b/apps/desktop/src/main/windows-maximize-renderer-sync.ts index 7c405188d8..4d5f822d16 100644 --- a/apps/desktop/src/main/windows-maximize-renderer-sync.ts +++ b/apps/desktop/src/main/windows-maximize-renderer-sync.ts @@ -1,3 +1,22 @@ +/* + * 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. + */ + export interface MaximizedRendererSyncWindow { readonly contentView: ContentView; readonly webContents: { From a058b74eb7584c5afd686229ea85c63cdfdbb1dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E5=90=89=E6=B5=A9?= <1625567290@qq.com> Date: Sun, 23 Aug 2026 11:15:16 +0800 Subject: [PATCH 6/7] =?UTF-8?q?=E5=90=88=E5=B9=B6=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E5=B0=BA=E5=AF=B8=E5=90=8C=E6=AD=A5=E4=BA=8B=E4=BB=B6=E5=A4=84?= =?UTF-8?q?=E7=90=86=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/src/main/main-window.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/main/main-window.ts b/apps/desktop/src/main/main-window.ts index a00bcd150a..fd21973268 100644 --- a/apps/desktop/src/main/main-window.ts +++ b/apps/desktop/src/main/main-window.ts @@ -470,17 +470,13 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main }, 400); }; const scheduleMaximizedRendererSync = createWindowsMaximizeRendererSync(mainWindow); - const handleResize = (): void => { + const handleWindowGeometryChange = (): void => { scheduleSave(); scheduleMaximizedRendererSync(); }; - const handleMaximize = (): void => { - scheduleSave(); - scheduleMaximizedRendererSync(); - }; - mainWindow.on('resize', handleResize); + mainWindow.on('resize', handleWindowGeometryChange); mainWindow.on('move', scheduleSave); - mainWindow.on('maximize', handleMaximize); + mainWindow.on('maximize', handleWindowGeometryChange); mainWindow.on('unmaximize', scheduleSave); mainWindow.on('close', () => { clearShowFallbackTimer(); From d95e89c5fd5d4480b998ee0d2f4745dd8feb0a05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E5=90=89=E6=B5=A9?= <1625567290@qq.com> Date: Sun, 23 Aug 2026 14:02:55 +0800 Subject: [PATCH 7/7] =?UTF-8?q?=E6=94=B6=E7=B4=A7=20Windows=20=E6=9C=80?= =?UTF-8?q?=E5=A4=A7=E5=8C=96=E5=90=8C=E6=AD=A5=E5=AE=B9=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../windows-maximize-renderer-sync.test.ts | 19 +++++++++++++++++++ .../main/windows-maximize-renderer-sync.ts | 18 ++++++++++++++---- scripts/verify-packaged-app.mjs | 8 ++++---- scripts/verify-windows-harness.test.mjs | 12 ++++++++++++ 4 files changed, 49 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/main/__tests__/windows-maximize-renderer-sync.test.ts b/apps/desktop/src/main/__tests__/windows-maximize-renderer-sync.test.ts index 7cb566b9f8..39016d2415 100644 --- a/apps/desktop/src/main/__tests__/windows-maximize-renderer-sync.test.ts +++ b/apps/desktop/src/main/__tests__/windows-maximize-renderer-sync.test.ts @@ -121,4 +121,23 @@ describe('Windows maximize renderer sync', () => { assert.deepEqual(fixture.calls, []); }); + + it('reports a native layout failure without stranding future syncs', () => { + const fixture = createFixture(); + const failure = new Error('native layout failed'); + const errors: unknown[] = []; + fixture.window.setContentView = () => { throw failure; }; + const schedule = createWindowsMaximizeRendererSync(fixture.window, { + platform: 'win32', + defer: fixture.defer, + reportError: (error) => errors.push(error), + }); + + schedule(); + assert.doesNotThrow(() => fixture.deferred.shift()?.()); + assert.deepEqual(errors, [failure]); + + schedule(); + assert.equal(fixture.deferred.length, 1); + }); }); diff --git a/apps/desktop/src/main/windows-maximize-renderer-sync.ts b/apps/desktop/src/main/windows-maximize-renderer-sync.ts index 4d5f822d16..9934b09585 100644 --- a/apps/desktop/src/main/windows-maximize-renderer-sync.ts +++ b/apps/desktop/src/main/windows-maximize-renderer-sync.ts @@ -31,6 +31,7 @@ export interface MaximizedRendererSyncWindow { interface MaximizedRendererSyncOptions { platform?: NodeJS.Platform; defer?: (callback: () => void) => void; + reportError?: (error: unknown) => void; } /** @@ -48,6 +49,9 @@ export function createWindowsMaximizeRendererSync( ): () => void { const platform = options.platform ?? process.platform; const defer = options.defer ?? setImmediate; + const reportError = options.reportError ?? ((error: unknown) => { + console.warn('[desktop] Windows maximize renderer sync failed:', error); + }); let pending = false; return () => { @@ -57,11 +61,17 @@ export function createWindowsMaximizeRendererSync( defer(() => { pending = false; - if (window.isDestroyed() || !window.isMaximized()) return; - if (window.webContents.isDestroyed()) return; + try { + if (window.isDestroyed() || !window.isMaximized()) return; + if (window.webContents.isDestroyed()) return; - window.setContentView(window.contentView); - window.webContents.invalidate(); + window.setContentView(window.contentView); + window.webContents.invalidate(); + } catch (error) { + // A best-effort layout correction must not terminate the main process + // if Electron tears down the native window between the guards and call. + reportError(error); + } }); }; } diff --git a/scripts/verify-packaged-app.mjs b/scripts/verify-packaged-app.mjs index 81cf5aa768..43c51b85c0 100644 --- a/scripts/verify-packaged-app.mjs +++ b/scripts/verify-packaged-app.mjs @@ -412,10 +412,10 @@ export function rendererViewportMatchesNativeClient(layout, nativeWindow) { if (!Number.isFinite(nativeWindow?.clientHeight) || nativeWindow.clientHeight <= 0) return false; const widthScale = nativeWindow.clientWidth / layout.innerWidth; const heightScale = nativeWindow.clientHeight / layout.innerHeight; - return ( - dimensionsMatch(widthScale, heightScale, 0.01) && - dimensionsMatch(widthScale, layout.devicePixelRatio, 0.05) - ); + // Electron can report CSS or physical viewport pixels depending on the + // packaged app's DPI-awareness mode. Proportional agreement with the native + // client is the stable contract; equating that scale to DPR is not. + return dimensionsMatch(widthScale, heightScale, 0.01); } function windowsWindowProbeScript(processId, nextWindowState, restoredBounds) { diff --git a/scripts/verify-windows-harness.test.mjs b/scripts/verify-windows-harness.test.mjs index 6cd678018c..6e94fa9926 100644 --- a/scripts/verify-windows-harness.test.mjs +++ b/scripts/verify-windows-harness.test.mjs @@ -126,6 +126,18 @@ describe('rendererLayoutMatchesViewport', () => { ); }); + it('accepts proportional native dimensions independently of reported DPR', () => { + const layout = viewportLayout(); + layout.devicePixelRatio = 1.5; + assert.equal( + rendererViewportMatchesNativeClient(layout, { + clientWidth: 1920, + clientHeight: 1040, + }), + true, + ); + }); + it('rejects a renderer viewport that is stale against the native client', () => { assert.equal( rendererViewportMatchesNativeClient(viewportLayout(), {