From a293bc56923560e7ef7e9e5d7e117134fc47c1e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A5=9D=E5=AD=90=E7=A5=BA?= Date: Tue, 7 Jul 2026 11:27:54 +0800 Subject: [PATCH] fix: preserve Linux BrowserWindow focusability --- scripts/lib/upstream-patches.mjs | 294 ++++++++++++++++++++++++++++++- scripts/smoke-artifacts.mjs | 76 ++++++++ test/smoke-artifacts.test.mjs | 49 +++++- test/upstream-patches.test.mjs | 63 ++++++- 4 files changed, 479 insertions(+), 3 deletions(-) diff --git a/scripts/lib/upstream-patches.mjs b/scripts/lib/upstream-patches.mjs index 3a586fe..6577a2e 100644 --- a/scripts/lib/upstream-patches.mjs +++ b/scripts/lib/upstream-patches.mjs @@ -102,6 +102,18 @@ export const upstreamPatchContracts = [ assertBefore: assertLinuxWindowTransparencyBefore, apply: patchLinuxWindowTransparency, assertAfter: assertLinuxWindowTransparencyAfter + }, + // Why: Electron/Linux treats an explicitly undefined BrowserWindow focusable + // option like false. Upstream 26.623 started passing focusable: undefined for + // the primary window, which makes X11 WMs see an unmanaged override-redirect + // surface. Contract: the main window still forwards the createWindow + // focusable option into BrowserWindow. + { + name: "linux-window-focusable-default", + find: findLinuxWindowFocusablePatch, + assertBefore: assertLinuxWindowFocusableBefore, + apply: patchLinuxWindowFocusable, + assertAfter: assertLinuxWindowFocusableAfter } ]; @@ -168,7 +180,11 @@ export function patchLinuxOpenTargetsSource(source) { } export function patchDisableTransparencySource(source) { - return applyUpstreamPatchContracts(source, upstreamPatchContracts.slice(3)); + return applyUpstreamPatchContracts(source, upstreamPatchContracts.slice(3, 5)); +} + +export function patchLinuxWindowFocusableSource(source) { + return applyUpstreamPatchContract(source, upstreamPatchContracts[5]); } export function patchLinuxOwlFeatureBindingSource(source) { @@ -257,6 +273,14 @@ export function hasUnguardedDynamicToolThreadStartBridgeSource(source) { return patch.status === "patch"; } +export function hasLinuxWindowFocusableContractSource(source) { + return Boolean(findLinuxWindowFocusablePatch(source)); +} + +export function hasUnguardedLinuxWindowFocusableSource(source) { + return findLinuxWindowFocusablePatch(source)?.status === "patch"; +} + export function applyUpstreamPatchContracts(source, contracts) { return contracts.reduce( (patched, contract) => applyUpstreamPatchContract(patched, contract), @@ -1216,6 +1240,242 @@ function backgroundPaletteForObject(object, platformVar, prefersDarkVar) { }; } +function patchLinuxWindowFocusable(source) { + const patch = findLinuxWindowFocusablePatch(source); + + if (patch?.status === "patched") { + return source; + } + + if (!patch) { + throw new Error("Unable to apply upstream patch; missing BrowserWindow focusable option"); + } + + return `${source.slice(0, patch.start)}${patch.replacement}${source.slice(patch.end)}`; +} + +function findLinuxWindowFocusablePatch(source) { + const ast = parseJavaScript(source); + const candidates = []; + let patchedCandidates = 0; + + walkAst(ast, node => { + if (!isFunctionNode(node)) { + return; + } + + const bindings = focusableBindingsForFunction(node); + + if (bindings.size === 0 || !node.body) { + return; + } + + walkAstSkippingNested(node.body, child => { + if (child.type !== "NewExpression" || !isMemberPropertyNamed(child.callee, "BrowserWindow")) { + return; + } + + const options = child.arguments[0]; + + if (options?.type !== "ObjectExpression") { + return; + } + + for (const bindingName of bindings) { + const patch = linuxWindowFocusablePatchForObject(options, bindingName); + + if (patch?.status === "patched") { + patchedCandidates++; + return; + } + + if (patch) { + candidates.push(patch); + return; + } + } + }); + }); + + if (candidates.length > 1) { + throw new Error("Unable to apply upstream patch; ambiguous BrowserWindow focusable option"); + } + + if (candidates.length === 1) { + return candidates[0]; + } + + if (patchedCandidates > 0) { + return { status: "patched" }; + } + + return null; +} + +function assertLinuxWindowFocusableBefore(source) { + const patch = findLinuxWindowFocusablePatch(source); + + if (!patch || patch.status !== "patch") { + throw new Error("missing unguarded BrowserWindow focusable option"); + } +} + +function assertLinuxWindowFocusableAfter(source) { + const patch = findLinuxWindowFocusablePatch(source); + + if (!patch || patch.status !== "patched") { + throw new Error("Linux BrowserWindow focusable assertion failed"); + } +} + +function focusableBindingsForFunction(node) { + const bindings = new Set(); + + for (const param of node.params || []) { + const binding = objectPatternBindings(param).focusable; + + if (binding) { + bindings.add(binding); + } + } + + if (!node.body) { + return bindings; + } + + walkAstSkippingNested(node.body, child => { + if (child.type !== "VariableDeclarator") { + return; + } + + const binding = objectPatternBindings(child.id).focusable; + + if (binding) { + bindings.add(binding); + } + }); + + return bindings; +} + +function linuxWindowFocusablePatchForObject(object, bindingName) { + const property = getObjectProperty(object, "focusable"); + + if (property) { + if (isFocusableDefaultPatched(property.value, bindingName)) { + return { status: "patched" }; + } + + if (isIdentifierNamed(property.value, bindingName)) { + return { + status: "patch", + start: property.value.start, + end: property.value.end, + replacement: `${bindingName}??!0` + }; + } + + return null; + } + + if (hasSafeFocusableSpread(object, bindingName)) { + return { status: "patched" }; + } + + return null; +} + +function hasSafeFocusableSpread(object, bindingName) { + return object.properties.some(property => + property.type === "SpreadElement" && + isSafeFocusableConditional(property.argument, bindingName) + ); +} + +function isSafeFocusableConditional(node, bindingName) { + if (node?.type !== "ConditionalExpression") { + return false; + } + + if (isNullishCheck(node.test, bindingName)) { + return isEmptyObjectExpression(node.consequent) && + isFocusableObjectForBinding(node.alternate, bindingName); + } + + if (isNotNullishCheck(node.test, bindingName)) { + return isFocusableObjectForBinding(node.consequent, bindingName) && + isEmptyObjectExpression(node.alternate); + } + + return false; +} + +function isFocusableObjectForBinding(node, bindingName) { + if (node?.type !== "ObjectExpression") { + return false; + } + + const property = getObjectProperty(node, "focusable"); + + return Boolean(property && isIdentifierNamed(property.value, bindingName)); +} + +function isFocusableDefaultPatched(node, bindingName) { + return ( + node?.type === "LogicalExpression" && + node.operator === "??" && + isIdentifierNamed(node.left, bindingName) && + isTrueExpression(node.right) + ); +} + +function isNullishCheck(node, bindingName) { + return isNullishBinaryExpression(node, bindingName, ["==", "==="]); +} + +function isNotNullishCheck(node, bindingName) { + return isNullishBinaryExpression(node, bindingName, ["!=", "!=="]); +} + +function isNullishBinaryExpression(node, bindingName, operators) { + if (node?.type !== "BinaryExpression" || !operators.includes(node.operator)) { + return false; + } + + return ( + (isIdentifierNamed(node.left, bindingName) && isNullishExpression(node.right)) || + (isNullishExpression(node.left) && isIdentifierNamed(node.right, bindingName)) + ); +} + +function isNullishExpression(node) { + return isNullLiteral(node) || isVoidZeroExpression(node); +} + +function isVoidZeroExpression(node) { + return ( + node?.type === "UnaryExpression" && + node.operator === "void" && + node.argument.type === "Literal" && + node.argument.value === 0 + ); +} + +function isEmptyObjectExpression(node) { + return node?.type === "ObjectExpression" && node.properties.length === 0; +} + +function isTrueExpression(node) { + return ( + node?.type === "Literal" && node.value === true + ) || ( + node?.type === "UnaryExpression" && + node.operator === "!" && + node.argument.type === "Literal" && + (node.argument.value === 0 || node.argument.value === false) + ); +} + function parseJavaScript(source) { try { return parse(source, { @@ -1264,6 +1524,38 @@ function walkAst(root, visit) { } } +function walkAstSkippingNested(root, visit) { + const stack = [root]; + + while (stack.length > 0) { + const node = stack.pop(); + + if (!isNode(node) || (node !== root && isFunctionNode(node))) { + continue; + } + + visit(node); + + for (const key of Object.keys(node)) { + if (key === "start" || key === "end" || key === "loc" || key === "range") { + continue; + } + + const value = node[key]; + + if (Array.isArray(value)) { + for (let index = value.length - 1; index >= 0; index--) { + if (isNode(value[index])) { + stack.push(value[index]); + } + } + } else if (isNode(value)) { + stack.push(value); + } + } + } +} + function isNode(value) { return Boolean(value && typeof value.type === "string"); } diff --git a/scripts/smoke-artifacts.mjs b/scripts/smoke-artifacts.mjs index 8675aea..c46e730 100644 --- a/scripts/smoke-artifacts.mjs +++ b/scripts/smoke-artifacts.mjs @@ -7,10 +7,12 @@ import * as asar from "@electron/asar"; import { channelPaths, getChannel, parseArgs, projectRoot } from "./lib/config.mjs"; import { + hasLinuxWindowFocusableContractSource, hasUnguardedDynamicToolSchemaContractSource, hasUnguardedDynamicToolStartResponseSource, hasUnguardedDynamicToolThreadStartBridgeSource, hasUnguardedDynamicToolThreadStartRequestSource, + hasUnguardedLinuxWindowFocusableSource, hasUnguardedOwlFeatureBindingSource } from "./lib/upstream-patches.mjs"; @@ -74,6 +76,9 @@ export async function smokeLinuxArtifacts({ await runCheck(summary, "owl-runtime-contract", () => assertOwlRuntimeContract(resourcesDir) ); + await runCheck(summary, "linux-window-focusable-contract", () => + assertLinuxWindowFocusableContract(resourcesDir) + ); await runCheck(summary, "dynamic-tool-schema-contract", () => assertDynamicToolSchemaContract(resourcesDir) ); @@ -383,6 +388,25 @@ async function assertDynamicToolSchemaContract(resourcesDir) { }; } +async function assertLinuxWindowFocusableContract(resourcesDir) { + const appAsarPath = path.join(resourcesDir, "app.asar"); + const unsafeSources = await findLinuxWindowFocusableContractSources(appAsarPath); + + if (unsafeSources.unsafe.length > 0) { + throw new Error( + `Linux primary BrowserWindow must default undefined focusable to true; ${unsafeSources.unsafe.slice(0, 5).join(", ")} still passes a destructured focusable value directly and can create an unmanageable, unfocusable X11 window` + ); + } + + if (unsafeSources.checked === 0) { + throw new Error("Unable to find Linux BrowserWindow focusable contract in app.asar"); + } + + return { + checked: unsafeSources.checked + }; +} + async function assertDynamicToolStartResponseContract(resourcesDir) { const appAsarPath = path.join(resourcesDir, "app.asar"); const unsafeSources = await findUnguardedDynamicToolStartResponseSources(appAsarPath); @@ -440,6 +464,58 @@ async function assertDynamicToolThreadStartRequestContract(resourcesDir) { }; } +async function findLinuxWindowFocusableContractSources(appAsarPath) { + const files = await asar.listPackage(appAsarPath); + const sources = []; + + for (const file of files) { + if (!/\.(?:js|mjs|cjs)$/i.test(file)) { + continue; + } + + let source; + + try { + source = asar.extractFile(appAsarPath, file.replace(/^\//, "")).toString("utf8"); + } catch { + continue; + } + + if (!source.includes("BrowserWindow") || !source.includes("focusable")) { + continue; + } + + sources.push({ + file: file.replace(/^\//, ""), + source + }); + } + + return evaluateLinuxWindowFocusableContractSources(sources); +} + +export function evaluateLinuxWindowFocusableContractSources(sources) { + const unsafe = []; + let checked = 0; + + for (const { file, source } of sources) { + if (!hasLinuxWindowFocusableContractSource(source)) { + continue; + } + + checked++; + + if (hasUnguardedLinuxWindowFocusableSource(source)) { + unsafe.push(file); + } + } + + return { + checked, + unsafe + }; +} + async function findUnguardedDynamicToolThreadStartBridgeSources(appAsarPath) { const files = await asar.listPackage(appAsarPath); const unsafe = []; diff --git a/test/smoke-artifacts.test.mjs b/test/smoke-artifacts.test.mjs index fc4d820..c1c1d97 100644 --- a/test/smoke-artifacts.test.mjs +++ b/test/smoke-artifacts.test.mjs @@ -3,7 +3,8 @@ import assert from "node:assert/strict"; import { evaluateBundledCodexLauncherSource, - evaluateDesktopBootResult + evaluateDesktopBootResult, + evaluateLinuxWindowFocusableContractSources } from "../scripts/smoke-artifacts.mjs"; test("desktop boot smoke accepts a silent process still alive at timeout", () => { @@ -83,3 +84,49 @@ candidate="$(command -v codex 2>/dev/null || true)" export CODEX_CLI_PATH="$bundled_codex" `)); }); + +test("Linux window focusable smoke reports unguarded BrowserWindow defaults", () => { + const source = [ + "function createWindow(e={}){", + "let{focusable:m}=e;", + "new a.BrowserWindow({title:`Codex`,focusable:m})", + "}" + ].join(""); + + assert.deepEqual( + evaluateLinuxWindowFocusableContractSources([ + { file: ".vite/build/main.js", source } + ]), + { + checked: 1, + unsafe: [".vite/build/main.js"] + } + ); +}); + +test("Linux window focusable smoke accepts patched and legacy-safe defaults", () => { + const patched = [ + "function createWindow(e={}){", + "let{focusable:m}=e;", + "new a.BrowserWindow({title:`Codex`,focusable:m??!0})", + "}" + ].join(""); + const legacy = [ + "function createWindow(e={}){", + "let{focusable:m}=e;", + "new a.BrowserWindow({title:`Codex`,...(m==null?{}:{focusable:m})})", + "}" + ].join(""); + + assert.deepEqual( + evaluateLinuxWindowFocusableContractSources([ + { file: ".vite/build/main.js", source: patched }, + { file: ".vite/build/legacy.js", source: legacy }, + { file: ".vite/build/overlay.js", source: "new a.BrowserWindow({focusable:!1})" } + ]), + { + checked: 2, + unsafe: [] + } + ); +}); diff --git a/test/upstream-patches.test.mjs b/test/upstream-patches.test.mjs index 01722f8..c25dd24 100644 --- a/test/upstream-patches.test.mjs +++ b/test/upstream-patches.test.mjs @@ -8,6 +8,8 @@ import { hasUnguardedDynamicToolStartResponseSource, hasUnguardedDynamicToolThreadStartBridgeSource, hasUnguardedDynamicToolThreadStartRequestSource, + hasLinuxWindowFocusableContractSource, + hasUnguardedLinuxWindowFocusableSource, hasUnguardedOwlFeatureBindingSource, patchDynamicToolSchemaContractSource, patchDynamicToolStartResponseSource, @@ -15,6 +17,7 @@ import { patchDynamicToolThreadStartRequestSource, patchDisableTransparencySource, patchLinuxOwlFeatureBindingSource, + patchLinuxWindowFocusableSource, patchLinuxOpenTargetsSource, dynamicToolThreadStartBridgeContract, dynamicToolStartResponseContract, @@ -182,7 +185,8 @@ test("upstream patch contracts declare required contract surface", () => { "dynamic-tool-thread-start-bridge", "open-target-dispatcher", "linux-window-background", - "linux-window-transparency" + "linux-window-transparency", + "linux-window-focusable-default" ] ); assert.deepEqual( @@ -214,6 +218,63 @@ test("patchLinuxOpenTargetsSource reports contract name on upstream drift", () = ); }); +test("patchLinuxWindowFocusableSource defaults undefined BrowserWindow focusability", () => { + const source = [ + "async function createWindow(e={}){", + "let{show:l=!0,parent:p,focusable:m,lockTitle:h=!1}=e;", + "return new a.BrowserWindow({title:`Codex`,show:l,parent:p,focusable:m,...process.platform===`linux`?{autoHideMenuBar:!0}:{}})", + "}" + ].join(""); + + const patched = patchLinuxWindowFocusableSource(source); + + assert.match(patched, /show:l,parent:p,focusable:m\?\?!0,/); + assert.doesNotMatch(patched, /show:l,parent:p,focusable:m,/); + assert.equal(hasLinuxWindowFocusableContractSource(patched), true); + assert.equal(hasUnguardedLinuxWindowFocusableSource(source), true); + assert.equal(hasUnguardedLinuxWindowFocusableSource(patched), false); +}); + +test("patchLinuxWindowFocusableSource preserves explicit unfocusable overlay windows", () => { + const source = [ + "function createWindow(e={}){", + "let{focusable:m}=e;", + "new a.BrowserWindow({title:`overlay`,focusable:!1});", + "new a.BrowserWindow({title:`Codex`,focusable:m})", + "}" + ].join(""); + + const patched = patchLinuxWindowFocusableSource(source); + + assert.match(patched, /title:`overlay`,focusable:!1/); + assert.match(patched, /title:`Codex`,focusable:m\?\?!0/); +}); + +test("patchLinuxWindowFocusableSource accepts legacy conditional focusable spread", () => { + const source = [ + "function createWindow(e={}){", + "let{focusable:m}=e;", + "new a.BrowserWindow({title:`Codex`,...(m==null?{}:{focusable:m})})", + "}" + ].join(""); + + assert.equal(patchLinuxWindowFocusableSource(source), source); + assert.equal(hasLinuxWindowFocusableContractSource(source), true); + assert.equal(hasUnguardedLinuxWindowFocusableSource(source), false); +}); + +test("patchLinuxWindowFocusableSource is idempotent", () => { + const source = [ + "function createWindow(e={}){", + "let{focusable:m}=e;", + "new a.BrowserWindow({title:`Codex`,focusable:m})", + "}" + ].join(""); + const patched = patchLinuxWindowFocusableSource(source); + + assert.equal(patchLinuxWindowFocusableSource(patched), patched); +}); + test("patchLinuxOpenTargetsSource tolerates targets without platform maps", () => { const source = withOpenTargetResolver([ "var wd=[nd,id],Td=t.kr(`open-in-targets`);function Ed(e){return wd.flatMap(t=>{let n=t.platforms[e];return n?[{id:t.id,...n}]:[]})}",