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/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..39016d2415 --- /dev/null +++ b/apps/desktop/src/main/__tests__/windows-maximize-renderer-sync.test.ts @@ -0,0 +1,143 @@ +/* + * 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 { + 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, []); + }); + + 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/main-window.ts b/apps/desktop/src/main/main-window.ts index fe6acb91a4..fd21973268 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,14 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main void writeSavedBounds(workspaceRoot, next); }, 400); }; - mainWindow.on('resize', scheduleSave); + const scheduleMaximizedRendererSync = createWindowsMaximizeRendererSync(mainWindow); + const handleWindowGeometryChange = (): void => { + scheduleSave(); + scheduleMaximizedRendererSync(); + }; + mainWindow.on('resize', handleWindowGeometryChange); mainWindow.on('move', scheduleSave); - mainWindow.on('maximize', scheduleSave); + mainWindow.on('maximize', handleWindowGeometryChange); 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..9934b09585 --- /dev/null +++ b/apps/desktop/src/main/windows-maximize-renderer-sync.ts @@ -0,0 +1,77 @@ +/* + * 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: { + isDestroyed(): boolean; + invalidate(): void; + }; + isDestroyed(): boolean; + isMaximized(): boolean; + setContentView(view: ContentView): void; +} + +interface MaximizedRendererSyncOptions { + platform?: NodeJS.Platform; + defer?: (callback: () => void) => void; + reportError?: (error: unknown) => 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; + const reportError = options.reportError ?? ((error: unknown) => { + console.warn('[desktop] Windows maximize renderer sync failed:', error); + }); + let pending = false; + + return () => { + if (platform !== 'win32' || pending) return; + if (window.isDestroyed() || !window.isMaximized()) return; + pending = true; + + defer(() => { + pending = false; + try { + if (window.isDestroyed() || !window.isMaximized()) return; + if (window.webContents.isDestroyed()) return; + + 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 b6f8049a6b..43c51b85c0 100644 --- a/scripts/verify-packaged-app.mjs +++ b/scripts/verify-packaged-app.mjs @@ -353,6 +353,268 @@ 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 { + devicePixelRatio: window.devicePixelRatio, + 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; +} + +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; + // 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) { + const stateTransition = + nextWindowState === undefined + ? '' + : 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 @' +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; + } + + [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 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); +} +'@ +$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.' +} +${stateTransition} +${resizeRestoredWindow} +$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 readWindowsNativeWindow(processId, nextWindowState, restoredBounds) { + const { stdout } = await runCommand( + 'powershell', + [ + '-NoProfile', + '-NonInteractive', + '-Command', + windowsWindowProbeScript(processId, nextWindowState, restoredBounds), + ], + { 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 { nativeWindow, layout }; +} + +async function waitForWindowLayout( + rendererUrl, + child, + 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(rendererUrl, child.pid); + lastError = undefined; + if ( + observed.nativeWindow?.windowState === expectedWindowState && + rendererViewportMatchesNativeClient(observed.layout, observed.nativeWindow) + ) { + 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(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'); + const maximized = await waitForWindowLayout(rendererUrl, child, 'maximized'); + + 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, + maximized, + restoredAgain, + })}`, + ); +} + export async function stopChild(child) { if (child.exitCode !== null) return; child.kill('SIGTERM'); @@ -428,7 +690,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 +724,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(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..6e94fa9926 100644 --- a/scripts/verify-windows-harness.test.mjs +++ b/scripts/verify-windows-harness.test.mjs @@ -30,6 +30,8 @@ import { validateWindowsUpgradeBaseline } from './prepare-windows-upgrade-baseli import { diffTreeManifests, directoryTreeManifest, + rendererLayoutMatchesViewport, + rendererViewportMatchesNativeClient, runCommand, waitForDevToolsPort, waitForUsableRenderer, @@ -82,6 +84,71 @@ 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 = () => ({ + devicePixelRatio: 1, + 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('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('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(), { + clientWidth: 1920, + clientHeight: 900, + }), + 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'); }