From 44756fe50e40effe4a2b6c5a94dd5c0e6d41ae32 Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 05:26:33 +0200 Subject: [PATCH 01/40] test(windows): build an isolated update baseline --- scripts/windows-update-fixture.ts | 77 +++++++++++++++++++++++ tests/unit/windows-update-fixture.test.ts | 16 +++++ 2 files changed, 93 insertions(+) create mode 100644 scripts/windows-update-fixture.ts create mode 100644 tests/unit/windows-update-fixture.test.ts diff --git a/scripts/windows-update-fixture.ts b/scripts/windows-update-fixture.ts new file mode 100644 index 000000000..ae5d4818e --- /dev/null +++ b/scripts/windows-update-fixture.ts @@ -0,0 +1,77 @@ +/** Build one older signed Squirrel package without leaving source changes. */ +import { spawn } from "node:child_process"; +import { cp, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { parseReleaseVersion } from "../src/shared/release.js"; + +export function windowsQualificationBaseline(version: string): string { + const parsed = parseReleaseVersion(version); + if (!parsed || parsed.channel !== "stable" || parsed.patch === 0) { + throw new Error("Windows update qualification requires a stable version with patch > 0"); + } + return `${parsed.major}.${parsed.minor}.${parsed.patch - 1}`; +} + +function runMake(root: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn( + process.platform === "win32" ? "pnpm.cmd" : "pnpm", + ["make", "--", "--platform=win32", "--arch=x64"], + { cwd: root, env: process.env, stdio: "inherit", shell: false }, + ); + child.once("error", reject); + child.once("exit", (code, signal) => { + if (code === 0) resolve(); + else reject(new Error(`baseline package build failed: ${code ?? signal}`)); + }); + }); +} + +export async function buildWindowsUpdateFixture( + root: string, + destination: string, +): Promise { + if ( + process.platform !== "win32" + || process.arch !== "x64" + || process.env.GITHUB_ACTIONS !== "true" + || process.env.RUNNER_ENVIRONMENT !== "github-hosted" + ) { + throw new Error("Windows update fixtures run only on a fresh hosted Windows x64 runner"); + } + const manifestPath = path.join(root, "package.json"); + const original = await readFile(manifestPath); + const manifest = JSON.parse(original.toString("utf8")) as Record; + if (typeof manifest.version !== "string") { + throw new Error("package.json has no version"); + } + const baseline = windowsQualificationBaseline(manifest.version); + try { + await writeFile( + manifestPath, + `${JSON.stringify({ ...manifest, version: baseline }, null, 2)}\n`, + ); + await runMake(root); + await rm(destination, { recursive: true, force: true }); + await cp( + path.join(root, "out", "make", "squirrel.windows", "x64"), + destination, + { recursive: true }, + ); + } finally { + await writeFile(manifestPath, original); + } + return baseline; +} + +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + const [destination, ...extra] = process.argv.slice(2); + if (!destination || extra.length > 0) { + throw new Error("usage: windows-update-fixture "); + } + globalThis.console.log(await buildWindowsUpdateFixture( + path.resolve(import.meta.dirname, ".."), + path.resolve(destination), + )); +} diff --git a/tests/unit/windows-update-fixture.test.ts b/tests/unit/windows-update-fixture.test.ts new file mode 100644 index 000000000..3853ac5a2 --- /dev/null +++ b/tests/unit/windows-update-fixture.test.ts @@ -0,0 +1,16 @@ +/** The Windows update fixture always builds one immediate older Stable. */ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { windowsQualificationBaseline } from "../../scripts/windows-update-fixture.ts"; + +describe("Windows update fixture version", () => { + it("selects the preceding Stable patch", () => { + assert.equal(windowsQualificationBaseline("2026.8.10"), "2026.8.9"); + }); + + it("refuses prereleases, malformed versions, and a missing prior patch", () => { + for (const version of ["2026.8.10-beta.1", "2026.8.0", "latest"]) { + assert.throws(() => windowsQualificationBaseline(version)); + } + }); +}); From dd9e48d8d7f3f4806ff72e746f6c0005b3800a01 Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 05:29:21 +0200 Subject: [PATCH 02/40] test(windows): prove signed update recovery and rollback --- .../windows-signed-qualification.yml | 9 +- scripts/windows-installed-qualification.ts | 116 ++++++++++++++++-- tests/policy/source-windows-installed.test.ts | 4 + 3 files changed, 115 insertions(+), 14 deletions(-) diff --git a/.github/workflows/windows-signed-qualification.yml b/.github/workflows/windows-signed-qualification.yml index 0d14d8428..3d7457468 100644 --- a/.github/workflows/windows-signed-qualification.yml +++ b/.github/workflows/windows-signed-qualification.yml @@ -41,7 +41,12 @@ jobs: [Convert]::FromBase64String($env:WINDOWS_SIGNING_PFX_BASE64) ) "WINDOWS_CERTIFICATE_FILE=$certificate" | Out-File $env:GITHUB_ENV -Append - - name: Build the signed package + - name: Build the signed update baseline + env: + GW_PACKAGE_INTENT: release + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_SIGNING_PASSWORD }} + run: node --import ./scripts/ts-hook.mjs scripts/windows-update-fixture.ts "$env:RUNNER_TEMP/windows-update-baseline" + - name: Build the signed candidate package env: GW_PACKAGE_INTENT: release WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_SIGNING_PASSWORD }} @@ -58,6 +63,8 @@ jobs: - name: Qualify signed install, replacement, and credentials env: GW_WINDOWS_SIGNED_QUALIFICATION: "1" + GW_WINDOWS_BASELINE_FEED: ${{ runner.temp }}\windows-update-baseline + GW_WINDOWS_CANDIDATE_FEED: ${{ github.workspace }}\out\make\squirrel.windows\x64 run: pnpm test:windows-installed - name: Remove the temporary signing certificate if: always() diff --git a/scripts/windows-installed-qualification.ts b/scripts/windows-installed-qualification.ts index 7943622b2..3dd6bc872 100644 --- a/scripts/windows-installed-qualification.ts +++ b/scripts/windows-installed-qualification.ts @@ -25,6 +25,8 @@ const execFileAsync = promisify(execFile); const root = path.resolve(import.meta.dirname, ".."); const release = DISTRIBUTION_CHANNEL_CONFIG.release; const signedQualification = process.env.GW_WINDOWS_SIGNED_QUALIFICATION === "1"; +const baselineFeed = process.env.GW_WINDOWS_BASELINE_FEED; +const candidateFeed = process.env.GW_WINDOWS_CANDIDATE_FEED; const delay = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds)); @@ -145,6 +147,26 @@ async function oneInstalledExecutable( return candidates[0]!; } +async function oneSetup(feed: string): Promise { + const setups = (await readdir(feed)) + .filter((entry) => entry.endsWith("-Setup.exe")); + assert.equal(setups.length, 1, `expected one Setup executable in ${feed}`); + return path.join(feed, setups[0]!); +} + +function installedExecutableForVersion( + packageRoot: string, + version: string, +): string { + const executable = path.join( + packageRoot, + `app-${version}`, + `${release.productName}.exe`, + ); + assert.equal(existsSync(executable), true, `installed version ${version} is missing`); + return executable; +} + async function waitForRunning( running: RunningPackagedApp, profileId: ProfileId, @@ -182,6 +204,9 @@ const setup = path.join( ), ); assert.equal(existsSync(setup), true, "the Windows Setup executable is missing"); +if (signedQualification && (!baselineFeed || !candidateFeed)) { + throw new Error("signed qualification requires baseline and candidate feeds"); +} for (const candidate of [packageRoot, path.dirname(storage.config)]) { assert.equal( existsSync(candidate), @@ -193,7 +218,8 @@ for (const candidate of [packageRoot, path.dirname(storage.config)]) { let running: RunningPackagedApp | null = null; let installedExecutable: string | null = null; try { - await execFileAsync(setup, ["--silent"], { + const initialSetup = baselineFeed ? await oneSetup(baselineFeed) : setup; + await execFileAsync(initialSetup, ["--silent"], { timeout: 120_000, windowsHide: true, }); @@ -352,12 +378,29 @@ try { await closePackagedApp(running); running = null; - if (signedQualification) { - await execFileAsync(setup, ["--silent"], { + if (signedQualification && baselineFeed && candidateFeed) { + const brokenFeed = path.join(process.env.RUNNER_TEMP!, "windows-broken-update"); + await mkdir(brokenFeed, { recursive: true }); + await assert.rejects(execFileAsync( + updateExecutable, + ["--update", brokenFeed, "--silent"], + { timeout: 120_000, windowsHide: true }, + )); + assert.equal( + existsSync(installedExecutable), + true, + "a refused update removed the installed baseline", + ); + await execFileAsync(updateExecutable, ["--update", candidateFeed, "--silent"], { timeout: 120_000, windowsHide: true, }); - installedExecutable = await oneInstalledExecutable(packageRoot); + const candidateVersion = ( + JSON.parse(await readFile(path.join(root, "package.json"), "utf8")) as { + version: string; + } + ).version; + installedExecutable = installedExecutableForVersion(packageRoot, candidateVersion); } running = await launchPackagedApp({ @@ -396,20 +439,63 @@ try { }, "signed replacement lost the second profile credential", ); - await restartedSecond.evaluate(() => window.gwNative.credentials.clear()); + } + running = { ...running, page: restartedMain }; + await closePackagedApp(running); + running = null; + + if (signedQualification && baselineFeed) { + await uninstall(updateExecutable); + await execFileAsync(await oneSetup(baselineFeed), ["--silent"], { + timeout: 120_000, + windowsHide: true, + }); + installedExecutable = await oneInstalledExecutable(packageRoot); + running = await launchPackagedApp({ + appPath: packageRoot, + executablePath: installedExecutable, + productName: release.productName, + userData: storage.sessions, + useDefaultUserData: true, + }); + const rollbackLauncher = running.launcherPage; + assert.ok(rollbackLauncher); assert.deepEqual( - await restartedMain.evaluate(() => window.gwNative.credentials.load()), + await rollbackLauncher.evaluate(async () => + (await window.launcherNative.state.get()).profiles.map(({ name }) => name) + ), + ["Main account", "Second account"], + "the rollback install could not read the candidate workspace", + ); + const rollbackMain = await openPackagedProfile(running, mainProfile.id); + assert.deepEqual( + await rollbackMain.evaluate(() => window.gwNative.credentials.load()), { username: "main-qualified@example.invalid", password: "synthetic-main-password", }, + "rollback lost the Main credential", + ); + const rollbackSecond = await openPackagedProfile(running, secondProfile.id); + assert.deepEqual( + await rollbackSecond.evaluate(() => window.gwNative.credentials.load()), + { + username: "second-qualified@example.invalid", + password: "synthetic-second-password", + }, + "rollback lost the second credential", + ); + await rollbackSecond.evaluate(() => window.gwNative.credentials.clear()); + assert.notEqual( + await rollbackMain.evaluate(() => window.gwNative.credentials.load()), + null, "clearing the second profile cleared Main", ); - await restartedMain.evaluate(() => window.gwNative.credentials.clear()); + await rollbackMain.evaluate(() => window.gwNative.credentials.clear()); + running = { ...running, page: rollbackMain }; + await closePackagedApp(running); + running = null; } - running = { ...running, page: restartedMain }; - await closePackagedApp(running); - running = null; await uninstall(updateExecutable); assert.equal( @@ -428,15 +514,19 @@ try { profiles: "isolated and restart-stable", tools: "loaded globally", credentials: signedQualification - ? "isolated, replacement-stable, and cleared" + ? "isolated, update- and rollback-stable, and cleared" : "qualified separately through the native synthetic probe", + updates: signedQualification + ? "refused feed preserved baseline; candidate update and rollback passed" + : "signed update round trip runs in the protected qualification", gpuProcess: "reported", uninstall: "application removed; player data preserved", unproven: [ - "automatic update replacement and forward recovery", "native taskbar focus on physical Windows hardware", "hardware GPU performance and long-session memory", - ...(signedQualification ? [] : ["signed publisher identity"]), + ...(signedQualification + ? [] + : ["signed publisher identity", "installed update and rollback"]), ], }, null, 2)); } finally { diff --git a/tests/policy/source-windows-installed.test.ts b/tests/policy/source-windows-installed.test.ts index 4a99be307..4f216830f 100644 --- a/tests/policy/source-windows-installed.test.ts +++ b/tests/policy/source-windows-installed.test.ts @@ -52,4 +52,8 @@ test("signed qualification cannot publish and uses only synthetic credentials", assert.match(script, /main-qualified@example\.invalid/u); assert.match(script, /second-qualified@example\.invalid/u); assert.match(script, /credentials\.clear\(\)/u); + assert.match(script, /GW_WINDOWS_BASELINE_FEED/u); + assert.match(script, /\["--update", brokenFeed/u); + assert.match(script, /\["--update", candidateFeed/u); + assert.match(script, /rollback install could not read the candidate workspace/u); }); From 4ad087a4c668e1b49e1ca49c82c44d6ec3791af7 Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 05:40:01 +0200 Subject: [PATCH 03/40] test(platform): share installed update baselines --- scripts/linux-update-fixture.ts | 68 +++++++++++++++++++++++ scripts/qualification-baseline-version.ts | 12 ++++ scripts/windows-update-fixture.ts | 12 +--- tests/unit/windows-update-fixture.test.ts | 6 +- 4 files changed, 86 insertions(+), 12 deletions(-) create mode 100644 scripts/linux-update-fixture.ts create mode 100644 scripts/qualification-baseline-version.ts diff --git a/scripts/linux-update-fixture.ts b/scripts/linux-update-fixture.ts new file mode 100644 index 000000000..564f398f8 --- /dev/null +++ b/scripts/linux-update-fixture.ts @@ -0,0 +1,68 @@ +/** Build one older Linux package without leaving source changes behind. */ +import { spawn } from "node:child_process"; +import { cp, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { qualificationBaselineVersion } from "./qualification-baseline-version.js"; + +function runPackage(root: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn("pnpm", ["package"], { + cwd: root, + env: { ...process.env, GW_PACKAGE_INTENT: "release" }, + stdio: "inherit", + shell: false, + }); + child.once("error", reject); + child.once("exit", (code, signal) => { + if (code === 0) resolve(); + else reject(new Error(`baseline package build failed: ${code ?? signal}`)); + }); + }); +} + +export async function buildLinuxUpdateFixture( + root: string, + destination: string, +): Promise { + if ( + process.platform !== "linux" + || process.arch !== "x64" + || process.env.GITHUB_ACTIONS !== "true" + || process.env.RUNNER_ENVIRONMENT !== "github-hosted" + ) { + throw new Error("Linux update fixtures run only on a fresh hosted Linux x64 runner"); + } + const manifestPath = path.join(root, "package.json"); + const original = await readFile(manifestPath); + const manifest = JSON.parse(original.toString("utf8")) as Record; + if (typeof manifest.version !== "string") throw new Error("package.json has no version"); + const baseline = qualificationBaselineVersion(manifest.version); + try { + await writeFile( + manifestPath, + `${JSON.stringify({ ...manifest, version: baseline }, null, 2)}\n`, + ); + await runPackage(root); + await rm(destination, { recursive: true, force: true }); + await cp( + path.join(root, "out", "Guild Wars Reforged-linux-x64"), + destination, + { recursive: true }, + ); + } finally { + await writeFile(manifestPath, original); + } + return baseline; +} + +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + const [destination, ...extra] = process.argv.slice(2); + if (!destination || extra.length > 0) { + throw new Error("usage: linux-update-fixture "); + } + globalThis.console.log(await buildLinuxUpdateFixture( + path.resolve(import.meta.dirname, ".."), + path.resolve(destination), + )); +} diff --git a/scripts/qualification-baseline-version.ts b/scripts/qualification-baseline-version.ts new file mode 100644 index 000000000..fdb241130 --- /dev/null +++ b/scripts/qualification-baseline-version.ts @@ -0,0 +1,12 @@ +import { parseReleaseVersion } from "../src/shared/release.js"; + +/** Select the immediate older Stable build used by installed update proofs. */ +export function qualificationBaselineVersion(version: string): string { + const parsed = parseReleaseVersion(version); + if (!parsed || parsed.channel !== "stable" || parsed.patch === 0) { + throw new Error( + "installed update qualification requires a stable version with patch > 0", + ); + } + return `${parsed.major}.${parsed.minor}.${parsed.patch - 1}`; +} diff --git a/scripts/windows-update-fixture.ts b/scripts/windows-update-fixture.ts index ae5d4818e..f98eb5f2b 100644 --- a/scripts/windows-update-fixture.ts +++ b/scripts/windows-update-fixture.ts @@ -3,15 +3,9 @@ import { spawn } from "node:child_process"; import { cp, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { parseReleaseVersion } from "../src/shared/release.js"; +import { qualificationBaselineVersion } from "./qualification-baseline-version.js"; -export function windowsQualificationBaseline(version: string): string { - const parsed = parseReleaseVersion(version); - if (!parsed || parsed.channel !== "stable" || parsed.patch === 0) { - throw new Error("Windows update qualification requires a stable version with patch > 0"); - } - return `${parsed.major}.${parsed.minor}.${parsed.patch - 1}`; -} +export { qualificationBaselineVersion as windowsQualificationBaseline }; function runMake(root: string): Promise { return new Promise((resolve, reject) => { @@ -46,7 +40,7 @@ export async function buildWindowsUpdateFixture( if (typeof manifest.version !== "string") { throw new Error("package.json has no version"); } - const baseline = windowsQualificationBaseline(manifest.version); + const baseline = qualificationBaselineVersion(manifest.version); try { await writeFile( manifestPath, diff --git a/tests/unit/windows-update-fixture.test.ts b/tests/unit/windows-update-fixture.test.ts index 3853ac5a2..5987c19f4 100644 --- a/tests/unit/windows-update-fixture.test.ts +++ b/tests/unit/windows-update-fixture.test.ts @@ -1,16 +1,16 @@ /** The Windows update fixture always builds one immediate older Stable. */ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { windowsQualificationBaseline } from "../../scripts/windows-update-fixture.ts"; +import { qualificationBaselineVersion } from "../../scripts/qualification-baseline-version.ts"; describe("Windows update fixture version", () => { it("selects the preceding Stable patch", () => { - assert.equal(windowsQualificationBaseline("2026.8.10"), "2026.8.9"); + assert.equal(qualificationBaselineVersion("2026.8.10"), "2026.8.9"); }); it("refuses prereleases, malformed versions, and a missing prior patch", () => { for (const version of ["2026.8.10-beta.1", "2026.8.0", "latest"]) { - assert.throws(() => windowsQualificationBaseline(version)); + assert.throws(() => qualificationBaselineVersion(version)); } }); }); From 4bf385d476ac57bb1001aa9e52f72414cf7c23c7 Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 05:40:11 +0200 Subject: [PATCH 04/40] test(linux): prove Flatpak update recovery and rollback --- .github/workflows/linux-flatpak-build.yml | 22 +++++- scripts/linux-installed-qualification.ts | 88 ++++++++++++++++++++++- tests/policy/source-linux-flatpak.test.ts | 11 +++ 3 files changed, 117 insertions(+), 4 deletions(-) diff --git a/.github/workflows/linux-flatpak-build.yml b/.github/workflows/linux-flatpak-build.yml index 797e5ee7b..6136551e9 100644 --- a/.github/workflows/linux-flatpak-build.yml +++ b/.github/workflows/linux-flatpak-build.yml @@ -24,29 +24,45 @@ jobs: - name: Install build and Flatpak dependencies run: | sudo apt-get update - sudo apt-get install -y flatpak flatpak-builder g++ libglib2.0-dev gnupg xvfb + sudo apt-get install -y flatpak flatpak-builder g++ libglib2.0-dev gnupg ostree xvfb flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo flatpak install --user -y flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08 org.electronjs.Electron2.BaseApp//25.08 - name: Install the pinned Rust toolchain run: rustup toolchain install - run: pnpm install --frozen-lockfile - run: pnpm run check + - name: Build the immediate prior package for update qualification + run: node --import ./scripts/ts-hook.mjs scripts/linux-update-fixture.ts "$RUNNER_TEMP/linux-baseline-package" - name: Build the release-shaped Electron package run: pnpm package env: GW_PACKAGE_INTENT: release - - name: Build and install a GPG-verified local repository + - name: Build two GPG-verified repository commits and install the baseline shell: bash run: | export GNUPGHOME="$RUNNER_TEMP/flatpak-ci-gpg" mkdir -m 700 "$GNUPGHOME" gpg --batch --passphrase '' --quick-generate-key 'gwonmac CI qualification ' ed25519 sign 1d key_id="$(gpg --batch --with-colons --list-secret-keys | awk -F: '$1 == "fpr" { print $10; exit }')" + mv "out/Guild Wars Reforged-linux-x64" "$RUNNER_TEMP/linux-candidate-package" + cp -a "$RUNNER_TEMP/linux-baseline-package" "out/Guild Wars Reforged-linux-x64" + flatpak-builder --user --disable-rofiles-fuse --force-clean --repo=flatpak-baseline-repo --gpg-sign="$key_id" --gpg-homedir="$GNUPGHOME" flatpak-build packaging/linux/io.github.mat4m0.gwonmac.yml + flatpak build-update-repo --gpg-sign="$key_id" --gpg-homedir="$GNUPGHOME" flatpak-baseline-repo + baseline_commit="$(ostree --repo=flatpak-baseline-repo rev-parse app/io.github.mat4m0.gwonmac/x86_64/master)" + cp -a flatpak-baseline-repo flatpak-repo + rm -rf "out/Guild Wars Reforged-linux-x64" + mv "$RUNNER_TEMP/linux-candidate-package" "out/Guild Wars Reforged-linux-x64" flatpak-builder --user --disable-rofiles-fuse --force-clean --repo=flatpak-repo --gpg-sign="$key_id" --gpg-homedir="$GNUPGHOME" flatpak-build packaging/linux/io.github.mat4m0.gwonmac.yml flatpak build-update-repo --gpg-sign="$key_id" --gpg-homedir="$GNUPGHOME" flatpak-repo + candidate_commit="$(ostree --repo=flatpak-repo rev-parse app/io.github.mat4m0.gwonmac/x86_64/master)" + test "$baseline_commit" != "$candidate_commit" gpg --batch --export "$key_id" > "$RUNNER_TEMP/flatpak-ci-public.gpg" - flatpak remote-add --user --gpg-import="$RUNNER_TEMP/flatpak-ci-public.gpg" gwonmac-ci "file://$GITHUB_WORKSPACE/flatpak-repo" + flatpak remote-add --user --gpg-import="$RUNNER_TEMP/flatpak-ci-public.gpg" gwonmac-ci "file://$GITHUB_WORKSPACE/flatpak-baseline-repo" flatpak install --user -y gwonmac-ci io.github.mat4m0.gwonmac + echo "GW_LINUX_BASELINE_COMMIT=$baseline_commit" >> "$GITHUB_ENV" + echo "GW_LINUX_CANDIDATE_COMMIT=$candidate_commit" >> "$GITHUB_ENV" + echo "GW_LINUX_QUALIFICATION_REMOTE=gwonmac-ci" >> "$GITHUB_ENV" + echo "GW_LINUX_QUALIFICATION_REMOTE_URL=file://$GITHUB_WORKSPACE/flatpak-repo" >> "$GITHUB_ENV" - name: Qualify the installed Xwayland package # Start the session bus inside the display so portal services activated # by D-Bus inherit DISPLAY instead of starting headless. diff --git a/scripts/linux-installed-qualification.ts b/scripts/linux-installed-qualification.ts index 5d5fa468a..fc97d620f 100644 --- a/scripts/linux-installed-qualification.ts +++ b/scripts/linux-installed-qualification.ts @@ -20,6 +20,21 @@ const execFileAsync = promisify(execFile); const applicationId = DISTRIBUTION_CHANNEL_CONFIG.release.bundleId; const secretQualification = process.env.GW_LINUX_SECRET_QUALIFICATION === "1"; const nativeWayland = process.env.GW_LINUX_NATIVE_WAYLAND === "1"; +const baselineCommit = process.env.GW_LINUX_BASELINE_COMMIT; +const candidateCommit = process.env.GW_LINUX_CANDIDATE_COMMIT; +const qualificationRemote = process.env.GW_LINUX_QUALIFICATION_REMOTE; +const qualificationRemoteUrl = process.env.GW_LINUX_QUALIFICATION_REMOTE_URL; +const updateQualification = baselineCommit !== undefined + || candidateCommit !== undefined + || qualificationRemote !== undefined + || qualificationRemoteUrl !== undefined; + +if ( + updateQualification + && (!baselineCommit || !candidateCommit || !qualificationRemote || !qualificationRemoteUrl) +) { + throw new Error("Linux update qualification requires both commits, remote, and remote URL"); +} if ( process.platform !== "linux" @@ -107,7 +122,57 @@ async function waitForFlatpakExit(): Promise { } } +async function installedCommit(): Promise { + const { stdout } = await execFileAsync( + "flatpak", + ["info", "--user", "--show-commit", applicationId], + { encoding: "utf8" }, + ); + return stdout.trim(); +} + +async function qualifyUpdateRecovery(): Promise { + assert.ok(baselineCommit && candidateCommit && qualificationRemote && qualificationRemoteUrl); + assert.equal(await installedCommit(), baselineCommit); + const brokenUrl = `file://${path.join(os.tmpdir(), "missing-gwonmac-flatpak-repository")}`; + await execFileAsync( + "flatpak", + ["remote-modify", "--user", `--url=${brokenUrl}`, qualificationRemote], + ); + await assert.rejects(execFileAsync( + "flatpak", + ["update", "--user", "-y", applicationId], + { timeout: 120_000 }, + )); + assert.equal( + await installedCommit(), + baselineCommit, + "a failed Flatpak update changed the installed deployment", + ); + await execFileAsync( + "flatpak", + ["remote-modify", "--user", `--url=${qualificationRemoteUrl}`, qualificationRemote], + ); + await execFileAsync( + "flatpak", + ["update", "--user", "-y", applicationId], + { timeout: 120_000 }, + ); + assert.equal(await installedCommit(), candidateCommit); +} + +async function rollBackInstalledPackage(): Promise { + assert.ok(baselineCommit); + await execFileAsync( + "flatpak", + ["update", "--user", "-y", `--commit=${baselineCommit}`, applicationId], + { timeout: 120_000 }, + ); + assert.equal(await installedCommit(), baselineCommit); +} + assert.equal(await installed(), true, "the signed Flatpak is not installed"); +if (updateQualification) assert.equal(await installedCommit(), baselineCommit); assert.equal( existsSync(appRoot), false, @@ -246,6 +311,8 @@ try { running = null; await waitForFlatpakExit(); + if (updateQualification) await qualifyUpdateRecovery(); + running = await launch(); const restartedLauncher = running.launcherPage; assert.ok(restartedLauncher); @@ -268,6 +335,23 @@ try { running = null; await waitForFlatpakExit(); + if (updateQualification) { + await rollBackInstalledPackage(); + running = await launch(); + const rollbackLauncher = running.launcherPage; + assert.ok(rollbackLauncher); + assert.deepEqual( + await rollbackLauncher.evaluate(async () => + (await window.launcherNative.state.get()).profiles.map(({ name }) => name) + ), + ["Main account", "Second account"], + "the prior package could not read the candidate-preserved workspace", + ); + await closePackagedApp(running); + running = null; + await waitForFlatpakExit(); + } + await execFileAsync("flatpak", ["uninstall", "--user", "-y", applicationId]); assert.equal(await installed(), false); assert.equal( @@ -284,7 +368,9 @@ try { credentials: secretQualification ? "portal-encrypted, isolated, and restart-stable" : "volatile in this smoke; portal runs in the signed desktop gate", - updates: "software-center managed", + updates: updateQualification + ? "failed update recovered; upgraded and rolled back with data preserved" + : "software-center managed", uninstall: "application removed; player data preserved", }, null, 2)); } finally { diff --git a/tests/policy/source-linux-flatpak.test.ts b/tests/policy/source-linux-flatpak.test.ts index 28c097668..13c6adf52 100644 --- a/tests/policy/source-linux-flatpak.test.ts +++ b/tests/policy/source-linux-flatpak.test.ts @@ -50,6 +50,17 @@ describe("Linux Flatpak package", () => { } }); + it("qualifies failed update recovery, upgrade, and rollback", () => { + assert.match(buildWorkflow, /linux-update-fixture\.ts/u); + assert.match(buildWorkflow, /file:\/\/\$GITHUB_WORKSPACE\/flatpak-baseline-repo/u); + assert.match(buildWorkflow, /GW_LINUX_QUALIFICATION_REMOTE_URL=file:\/\/\$GITHUB_WORKSPACE\/flatpak-repo/u); + assert.match(buildWorkflow, /GW_LINUX_CANDIDATE_COMMIT/u); + const installed = readFileSync("scripts/linux-installed-qualification.ts", "utf8"); + assert.match(installed, /a failed Flatpak update changed the installed deployment/u); + assert.match(installed, /--commit=\$\{baselineCommit\}/u); + assert.match(installed, /the prior package could not read the candidate-preserved workspace/u); + }); + it("keeps native Wayland out of the default package gate", () => { const defaultGate = buildWorkflow.split("\n native-wayland:", 1)[0] ?? ""; assert.match(defaultGate, /installed-xwayland/u); From c58c40a993081fb203a0b4f278dfea6b57e315cf Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 05:42:02 +0200 Subject: [PATCH 05/40] test(windows): launch Crashpad with desktop handles --- scripts/windows-installed-qualification.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/scripts/windows-installed-qualification.ts b/scripts/windows-installed-qualification.ts index 3dd6bc872..d6abcc8df 100644 --- a/scripts/windows-installed-qualification.ts +++ b/scripts/windows-installed-qualification.ts @@ -41,7 +41,6 @@ async function proveNormalCrashpadStartup( executable: string, arguments_: readonly string[], ): Promise { - let output = ""; const child = spawn(executable, arguments_, { env: { ...process.env, @@ -49,20 +48,19 @@ async function proveNormalCrashpadStartup( GW_REQUIRE_CACHED_CLIENT: "1", GW_BACKGROUND_LAUNCH: "1", }, - stdio: ["ignore", "pipe", "pipe"], + // Match an Explorer or Start-menu launch. Piping a GUI process' standard + // handles through Node changes the handle inheritance seen by Crashpad and + // is not a production-shaped startup boundary. + detached: true, + stdio: "ignore", windowsHide: true, }); - const capture = (chunk: Buffer) => { - output = `${output}${chunk.toString("utf8")}`.slice(-65_536); - }; - child.stdout.on("data", capture); - child.stderr.on("data", capture); try { await delay(5_000); assert.equal( child.exitCode, null, - `the normal installed application exited before qualification\n${output.trim()}`, + "the normal installed application exited before qualification", ); assert.ok(child.pid, "the normal installed application has no process ID"); const { stdout } = await execFileAsync( From 1f6391a85f38f1c3a402743cd37cd8d3cfec65ab Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 05:47:06 +0200 Subject: [PATCH 06/40] test(linux): qualify desktop secret providers --- .github/workflows/linux-flatpak-build.yml | 53 +++++++++++++++++++++++ tests/policy/source-linux-flatpak.test.ts | 8 ++++ 2 files changed, 61 insertions(+) diff --git a/.github/workflows/linux-flatpak-build.yml b/.github/workflows/linux-flatpak-build.yml index 6136551e9..b248b08b9 100644 --- a/.github/workflows/linux-flatpak-build.yml +++ b/.github/workflows/linux-flatpak-build.yml @@ -114,3 +114,56 @@ jobs: done test -S "$XDG_RUNTIME_DIR/wayland-gwonmac" dbus-run-session -- env WAYLAND_DISPLAY=wayland-gwonmac XDG_SESSION_TYPE=wayland GW_LINUX_NATIVE_WAYLAND=1 pnpm test:linux-installed + + desktop-secrets: + needs: installed-xwayland + strategy: + fail-fast: false + matrix: + desktop: [gnome, kde] + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: pnpm/action-setup@d15e628ca66d93ee5f352c71671a7bc6a97af5c9 # v6.0.8 + with: + version: 11.13.1 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24.18.0 + cache: pnpm + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: linux-flatpak-qualification-repo + path: flatpak-repo + - name: Install GNOME portal and secret service + if: matrix.desktop == 'gnome' + run: | + sudo apt-get update + sudo apt-get install -y flatpak xvfb dbus-x11 gnome-keyring xdg-desktop-portal xdg-desktop-portal-gnome + - name: Install KDE portal and secret service + if: matrix.desktop == 'kde' + run: | + sudo apt-get update + sudo apt-get install -y flatpak xvfb dbus-x11 plasma-workspace xdg-desktop-portal xdg-desktop-portal-kde kwalletmanager + - name: Install the exact GPG-verified package + run: | + flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo + flatpak install --user -y flathub org.freedesktop.Platform//25.08 + flatpak remote-add --user --gpg-import=flatpak-repo/qualification-public.gpg gwonmac-desktop "file://$GITHUB_WORKSPACE/flatpak-repo" + flatpak install --user -y gwonmac-desktop io.github.mat4m0.gwonmac + - run: pnpm install --frozen-lockfile + - name: Qualify encrypted profile secrets on the desktop + shell: bash + env: + DESKTOP: ${{ matrix.desktop }} + GW_LINUX_SECRET_QUALIFICATION: "1" + run: | + xvfb-run -a dbus-run-session -- bash -euc ' + export XDG_CURRENT_DESKTOP="${DESKTOP^^}" + export XDG_SESSION_TYPE=x11 + if [ "$DESKTOP" = gnome ]; then + eval "$(printf "\n" | gnome-keyring-daemon --unlock --components=secrets)" + fi + pnpm test:linux-installed + ' diff --git a/tests/policy/source-linux-flatpak.test.ts b/tests/policy/source-linux-flatpak.test.ts index 13c6adf52..8a83c58f4 100644 --- a/tests/policy/source-linux-flatpak.test.ts +++ b/tests/policy/source-linux-flatpak.test.ts @@ -74,4 +74,12 @@ describe("Linux Flatpak package", () => { assert.doesNotMatch(workflow, /dbus-run-session -- xvfb-run/u); } }); + + it("qualifies encrypted profile secrets on GNOME and KDE", () => { + assert.match(buildWorkflow, /desktop-secrets:/u); + assert.match(buildWorkflow, /desktop: \[gnome, kde\]/u); + assert.match(buildWorkflow, /GW_LINUX_SECRET_QUALIFICATION: "1"/u); + assert.match(buildWorkflow, /gnome-keyring-daemon --unlock/u); + assert.match(buildWorkflow, /xdg-desktop-portal-kde/u); + }); }); From efec6ecefc6199019d0c8353a52c7829785cafd4 Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 05:49:02 +0200 Subject: [PATCH 07/40] docs(platform): explain cross-platform installation --- README.md | 55 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 30 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 4fbea8266..1d07f519e 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@

gwonmac

- Guild Wars Reforged for macOS
- Play ArenaNet's official Guild Wars client finally on an Apple Silicon Mac in high resolution and FPS. + Guild Wars Reforged for macOS, Windows, and Linux
+ Run ArenaNet's official Guild Wars client through one profile-based desktop launcher.

@@ -24,21 +24,22 @@ ## What gwonmac does -gwonmac hosts ArenaNet's official WebAssembly client (what is used in the mobile app) in a sandboxed macOS app. -You do not need Windows, Wine, VMWare or Crossover. +gwonmac hosts ArenaNet's official WebAssembly client in a sandboxed desktop +application. Windows and Linux run the client natively; macOS does not need +Wine, a virtual machine, or CrossOver. The app provides: -- native Apple Silicon packaging; -- high FPS on full retina resolutions; -- notarized by Apple (checked for Malware); +- native Apple Silicon, Windows x64, and Linux x86_64 packaging; +- high-resolution rendering; +- platform-native package verification and saved-login storage; - verified downloads from ArenaNet; - ArenaNet and Steam sign-in; -- build and team management; +- optional Build Management, Quick Travel, and Xunlai Storage Tools; ## Requirements -- An Apple Silicon Mac. +- Apple Silicon macOS, Windows x64, or Linux x86_64 with Flatpak. - A Guild Wars account. - An internet connection for the first download and online play. @@ -46,28 +47,30 @@ You can buy Guild Wars from the [official store](https://store.guildwars.com/en- ## Install -1. Open the [Releases page](https://github.com/Mat4m0/gwonmac/releases) or go to https://gwonmac.com/download -2. Download the latest Stable `.dmg` file. -3. Open the file. -4. Move **Guild Wars Reforged.app** to **Applications**. -5. Open the app from **Applications**. +1. Open the [Releases page](https://github.com/Mat4m0/gwonmac/releases) or go to https://gwonmac.com/download. +2. Choose the package published for your platform: macOS DMG, Windows Setup, + or the Linux Flatpak repository instructions. +3. Install it through the normal system installer or Flatpak software center. +4. Open **Guild Wars Reforged**. -Stable releases are signed with Developer ID and notarized by Apple. The -Releases page also provides checksums, an SBOM, and build attestations. See -[Verify a release](docs/release-verification.md) if you want to inspect them. +Published Stable packages use the platform's verification path: Developer ID +and Apple notarization, Windows Authenticode, or a signed Flatpak repository. +Release availability can differ by platform while qualification is in progress. +The Releases page also provides checksums, an SBOM, and build attestations. See +[Verify a release](docs/release-verification.md) for the exact checks. ## Start the game Guild Wars starts as soon as the required data is ready, then downloads the rest of the game in the background while you play. You can see progress or -pause the download in **Settings → Game Data**. The +pause the download in **Settings → Game files**. The [user guide](docs/user-guide.md) explains sign-in, updates, Tools, recovery, and local data. ## Privacy and safety -- The Mac app sends no gwonmac telemetry. -- Diagnostics stay on your Mac until you attach an export to a report. +- The app sends no gwonmac telemetry. +- Diagnostics stay on your device until you attach an export to a report. - The diagnostics system does not record credentials, packet contents, cookies, request bodies, or local paths. - Provisioned builds can store saved login in Apple's device-only Data @@ -85,8 +88,8 @@ Stable is the default update track. You can choose Beta in Settings. Stable, Beta, and release-candidate builds use the same app identity and local profile. Alpha builds are not public update candidates. -An update found during the launch check installs before play unless you choose -**Play Without Updating**. An update downloaded later waits for a restart. +Application updates never block Play. The launcher offers a restart after an +update is ready, so running game windows remain under the player's control. The app never performs an automatic downgrade. Application updates and ArenaNet game updates are separate systems. See the @@ -94,11 +97,13 @@ Application updates and ArenaNet game updates are separate systems. See the ## Build from source -You need macOS on Apple Silicon, Xcode Command Line Tools, Node.js 22.19 or -newer, pnpm 11, and Rust through rustup. +You need Node.js 22.19 or newer, pnpm 11, Rust through rustup, and the native +compiler toolchain for the target platform. macOS builds require Apple Silicon +and Xcode Command Line Tools; Windows builds require x64 MSVC; Linux builds +require the x86_64 GLib development headers. The supported commands and exact +CI toolchains are in the [development workflow](docs/development-workflow.md). ```bash -xcode-select --install corepack enable pnpm install --frozen-lockfile pnpm exec playwright install chromium From 8effe2bbf5f7601296271da37bcc8efbe3be5738 Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 05:51:06 +0200 Subject: [PATCH 08/40] docs(updates): describe explicit launcher restart --- src/main/app-updater.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/app-updater.ts b/src/main/app-updater.ts index cc3676341..4996ddf3a 100644 --- a/src/main/app-updater.ts +++ b/src/main/app-updater.ts @@ -14,9 +14,8 @@ * selected Stable/Beta track is read once per check. Stable admits only * stable releases; Beta additionally admits beta and RC releases. Alpha is * never eligible. Ad-hoc developer builds carry no release marker and cannot - * reach this owner. This owner never chooses when to restart: the launch gate - * may install a ready update before play, while later readiness waits for user - * or ordinary restart orchestration. + * reach this owner. This owner never chooses when to restart: a ready update + * waits for the launcher's explicit Restart and update command. */ import type { AppUpdateErrorCode, From c81e1df10faa253f1a6c24612b5a8ed8029f8b73 Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 05:59:20 +0200 Subject: [PATCH 09/40] test(windows): retain startup failure evidence --- scripts/windows-installed-qualification.ts | 31 +++++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/scripts/windows-installed-qualification.ts b/scripts/windows-installed-qualification.ts index d6abcc8df..f46907f6c 100644 --- a/scripts/windows-installed-qualification.ts +++ b/scripts/windows-installed-qualification.ts @@ -40,7 +40,7 @@ interface WindowsProcess { async function proveNormalCrashpadStartup( executable: string, arguments_: readonly string[], -): Promise { +): Promise { const child = spawn(executable, arguments_, { env: { ...process.env, @@ -57,11 +57,19 @@ async function proveNormalCrashpadStartup( }); try { await delay(5_000); - assert.equal( - child.exitCode, - null, - "the normal installed application exited before qualification", - ); + if (child.exitCode !== null) { + const { stdout } = await execFileAsync( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + "$since=(Get-Date).AddMinutes(-2); Get-WinEvent -FilterHashtable @{LogName='Application'; StartTime=$since} -ErrorAction SilentlyContinue | Where-Object {$_.Id -in 1000,1001,1026} | Select-Object -First 8 TimeCreated,Id,ProviderName,Message | ConvertTo-Json -Compress", + ], + { encoding: "utf8", timeout: 30_000, windowsHide: true }, + ); + return `normal startup exited with ${child.exitCode}; Windows events: ${stdout.trim() || "none"}`; + } assert.ok(child.pid, "the normal installed application has no process ID"); const { stdout } = await execFileAsync( "powershell.exe", @@ -96,6 +104,7 @@ async function proveNormalCrashpadStartup( ), "the normal installed application did not keep a Crashpad handler alive", ); + return null; } finally { if (child.exitCode === null && child.pid) { await execFileAsync("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { @@ -252,7 +261,10 @@ try { "--enable-logging=stderr", ...(signedQualification ? [] : ["--gw-volatile-secrets"]), ]; - await proveNormalCrashpadStartup(installedExecutable, qualificationArguments); + const normalStartupFailure = await proveNormalCrashpadStartup( + installedExecutable, + qualificationArguments, + ); running = await launchPackagedApp({ appPath: packageRoot, @@ -506,6 +518,11 @@ try { true, "uninstall removed player settings", ); + assert.equal( + normalStartupFailure, + null, + normalStartupFailure ?? "normal Windows startup failed", + ); globalThis.console.log(JSON.stringify({ platform: "win32-x64", package: "Squirrel.Windows installed", From 9b1d6e27e3667d6294a446d3e069f05403ee6b5a Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 06:06:54 +0200 Subject: [PATCH 10/40] test(windows): continue after empty event logs --- scripts/windows-installed-qualification.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/scripts/windows-installed-qualification.ts b/scripts/windows-installed-qualification.ts index f46907f6c..6eef63ce1 100644 --- a/scripts/windows-installed-qualification.ts +++ b/scripts/windows-installed-qualification.ts @@ -58,7 +58,7 @@ async function proveNormalCrashpadStartup( try { await delay(5_000); if (child.exitCode !== null) { - const { stdout } = await execFileAsync( + const eventLog = await execFileAsync( "powershell.exe", [ "-NoProfile", @@ -67,8 +67,20 @@ async function proveNormalCrashpadStartup( "$since=(Get-Date).AddMinutes(-2); Get-WinEvent -FilterHashtable @{LogName='Application'; StartTime=$since} -ErrorAction SilentlyContinue | Where-Object {$_.Id -in 1000,1001,1026} | Select-Object -First 8 TimeCreated,Id,ProviderName,Message | ConvertTo-Json -Compress", ], { encoding: "utf8", timeout: 30_000, windowsHide: true }, - ); - return `normal startup exited with ${child.exitCode}; Windows events: ${stdout.trim() || "none"}`; + ).then(({ stdout }) => stdout.trim() || "none") + // Get-WinEvent exits with 1 when its filter has no matching records. + // That absence is useful evidence, not a reason to stop the remaining + // installed-package diagnostics. + .catch((error: unknown) => { + if ( + typeof error === "object" + && error !== null + && "code" in error + && error.code === 1 + ) return "none"; + return `query failed: ${error instanceof Error ? error.message : String(error)}`; + }); + return `normal startup exited with ${child.exitCode}; Windows events: ${eventLog}`; } assert.ok(child.pid, "the normal installed application has no process ID"); const { stdout } = await execFileAsync( From 09cad039eb93b71939a7a3ced57a2afa0ba36199 Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 06:09:03 +0200 Subject: [PATCH 11/40] test(linux): force recovery update fetch --- scripts/linux-installed-qualification.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/linux-installed-qualification.ts b/scripts/linux-installed-qualification.ts index fc97d620f..b5b5746cf 100644 --- a/scripts/linux-installed-qualification.ts +++ b/scripts/linux-installed-qualification.ts @@ -141,7 +141,7 @@ async function qualifyUpdateRecovery(): Promise { ); await assert.rejects(execFileAsync( "flatpak", - ["update", "--user", "-y", applicationId], + ["update", "--user", "-y", `--commit=${candidateCommit}`, applicationId], { timeout: 120_000 }, )); assert.equal( @@ -155,7 +155,7 @@ async function qualifyUpdateRecovery(): Promise { ); await execFileAsync( "flatpak", - ["update", "--user", "-y", applicationId], + ["update", "--user", "-y", `--commit=${candidateCommit}`, applicationId], { timeout: 120_000 }, ); assert.equal(await installedCommit(), candidateCommit); From cf6d935a65b885269f34a0c74354f9033334df5e Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 06:14:15 +0200 Subject: [PATCH 12/40] build(windows): bundle the native C++ runtime --- scripts/build.mjs | 5 +++++ tests/policy/source-native-keychain.test.ts | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/scripts/build.mjs b/scripts/build.mjs index acb69dfb4..46ace26dd 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -146,6 +146,9 @@ export function nativeBuildSteps(platform, architecture) { if (architecture !== "x64") { throw new Error(`unsupported Windows build architecture: ${architecture}`); } + // The installed package must not depend on a separately installed Visual + // C++ Redistributable. Both shipped binaries therefore carry the static + // runtime selected by /MT. return [ [ "lib.exe", @@ -162,6 +165,7 @@ export function nativeBuildSteps(platform, architecture) { "/nologo", "/std:c++20", "/O2", + "/MT", "/EHsc", "/LD", "/DNAPI_VERSION=8", @@ -183,6 +187,7 @@ export function nativeBuildSteps(platform, architecture) { "/nologo", "/std:c++20", "/O2", + "/MT", "/EHsc", "/Isrc/native/gw-dat", "/Fobuild\\native\\", diff --git a/tests/policy/source-native-keychain.test.ts b/tests/policy/source-native-keychain.test.ts index b1cb87693..3253d3860 100644 --- a/tests/policy/source-native-keychain.test.ts +++ b/tests/policy/source-native-keychain.test.ts @@ -121,6 +121,10 @@ test("Windows and Linux build only their target-native boundaries", () => { assert.ok(windows[1]?.[1].includes("build/native/node.lib")); assert.ok(windows[1]?.[1].includes("/Fe:build/native/windows-host.node")); assert.ok(windows[2]?.[1].includes("/Fe:build/native/gw-dat-decode.exe")); + for (const [, args] of windows.slice(1)) { + assert.ok(args.includes("/MT"), "packaged native code must carry its C++ runtime"); + assert.equal(args.includes("/MD"), false); + } assert.deepEqual(linux.map(([command]) => command), [process.execPath, "c++"]); assert.deepEqual(linux[0]?.[1], ["scripts/build-linux-secret-portal.mjs"]); assert.deepEqual(linux[1]?.[1].slice(-2), [ From b969352045af7785e31a504e55286e9e3c47b3f8 Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 06:16:56 +0200 Subject: [PATCH 13/40] test(windows): isolate Squirrel first run --- scripts/windows-installed-qualification.ts | 29 ++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/scripts/windows-installed-qualification.ts b/scripts/windows-installed-qualification.ts index 6eef63ce1..cd7cd9a6e 100644 --- a/scripts/windows-installed-qualification.ts +++ b/scripts/windows-installed-qualification.ts @@ -173,6 +173,34 @@ async function oneSetup(feed: string): Promise { return path.join(feed, setups[0]!); } +async function stopSquirrelFirstRun(executable: string): Promise { + // Squirrel always launches the installed application once after Setup + // finishes. The controlled qualification needs to own the next launch and + // its single-instance lock, so stop only processes whose executable path is + // the exact disposable package we just installed. + await delay(1_000); + const command = + "$target=[IO.Path]::GetFullPath($args[0]); Get-CimInstance Win32_Process | Where-Object {$_.ExecutablePath -and [IO.Path]::GetFullPath($_.ExecutablePath) -eq $target}"; + await execFileAsync( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + `${command} | ForEach-Object {Stop-Process -Id $_.ProcessId -Force}`, + executable, + ], + { timeout: 30_000, windowsHide: true }, + ); + await delay(500); + const { stdout } = await execFileAsync( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-Command", `${command} | Select-Object -ExpandProperty ProcessId`, executable], + { encoding: "utf8", timeout: 30_000, windowsHide: true }, + ); + assert.equal(stdout.trim(), "", "Squirrel's automatic first run is still alive"); +} + function installedExecutableForVersion( packageRoot: string, version: string, @@ -243,6 +271,7 @@ try { windowsHide: true, }); installedExecutable = await oneInstalledExecutable(packageRoot); + await stopSquirrelFirstRun(installedExecutable); const updateExecutable = path.join(packageRoot, "Update.exe"); assert.equal(existsSync(updateExecutable), true, "Squirrel Update.exe is missing"); From a1d3eedf387f2578bc56834ad745458ad514781f Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 06:19:56 +0200 Subject: [PATCH 14/40] test(windows): seed data before installer launch --- scripts/windows-installed-qualification.ts | 34 +++++++++++++--------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/scripts/windows-installed-qualification.ts b/scripts/windows-installed-qualification.ts index cd7cd9a6e..66c1a0aaa 100644 --- a/scripts/windows-installed-qualification.ts +++ b/scripts/windows-installed-qualification.ts @@ -181,16 +181,21 @@ async function stopSquirrelFirstRun(executable: string): Promise { await delay(1_000); const command = "$target=[IO.Path]::GetFullPath($args[0]); Get-CimInstance Win32_Process | Where-Object {$_.ExecutablePath -and [IO.Path]::GetFullPath($_.ExecutablePath) -eq $target}"; - await execFileAsync( + const { stdout: stopped } = await execFileAsync( "powershell.exe", [ "-NoProfile", "-NonInteractive", "-Command", - `${command} | ForEach-Object {Stop-Process -Id $_.ProcessId -Force}`, + `${command} | ForEach-Object {$_.ProcessId; Stop-Process -Id $_.ProcessId -Force}`, executable, ], - { timeout: 30_000, windowsHide: true }, + { encoding: "utf8", timeout: 30_000, windowsHide: true }, + ); + assert.notEqual( + stopped.trim(), + "", + "Squirrel did not keep its automatic first application run alive", ); await delay(500); const { stdout } = await execFileAsync( @@ -265,16 +270,9 @@ for (const candidate of [packageRoot, path.dirname(storage.config)]) { let running: RunningPackagedApp | null = null; let installedExecutable: string | null = null; try { - const initialSetup = baselineFeed ? await oneSetup(baselineFeed) : setup; - await execFileAsync(initialSetup, ["--silent"], { - timeout: 120_000, - windowsHide: true, - }); - installedExecutable = await oneInstalledExecutable(packageRoot); - await stopSquirrelFirstRun(installedExecutable); - const updateExecutable = path.join(packageRoot, "Update.exe"); - assert.equal(existsSync(updateExecutable), true, "Squirrel Update.exe is missing"); - + // Seed the released Single Account shape before Setup. Squirrel launches the + // application automatically, so that first real execution must exercise the + // same adoption and cached-client path as the controlled launch below. await Promise.all([ mkdir(storage.config, { recursive: true }), mkdir(storage.data, { recursive: true }), @@ -297,6 +295,16 @@ try { userData: storage.sessions, }); + const initialSetup = baselineFeed ? await oneSetup(baselineFeed) : setup; + await execFileAsync(initialSetup, ["--silent"], { + timeout: 120_000, + windowsHide: true, + }); + installedExecutable = await oneInstalledExecutable(packageRoot); + await stopSquirrelFirstRun(installedExecutable); + const updateExecutable = path.join(packageRoot, "Update.exe"); + assert.equal(existsSync(updateExecutable), true, "Squirrel Update.exe is missing"); + const qualificationArguments = [ "--disable-gpu", "--enable-logging=stderr", From 708095b6a283a91bc893406caf496ce2fde81048 Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 06:22:35 +0200 Subject: [PATCH 15/40] test(windows): verify embedded ASAR integrity --- scripts/windows-package-probe.ts | 24 +++++++++++++++++++++- tests/unit/windows-package-probe.test.ts | 26 +++++++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/scripts/windows-package-probe.ts b/scripts/windows-package-probe.ts index e4e928bcb..4d0757c2e 100644 --- a/scripts/windows-package-probe.ts +++ b/scripts/windows-package-probe.ts @@ -1,9 +1,11 @@ /** Inspect the exact unsigned Squirrel.Windows package produced by target CI. */ import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; import { readdir, readFile } from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { getRawHeader } from "@electron/asar"; import { DISTRIBUTION_CHANNEL_CONFIG } from "../src/shared/distribution-channel.js"; import { releaseUpdateArtifactName } from "../src/shared/project-identity.js"; @@ -12,6 +14,24 @@ async function peFile(file: string): Promise { assert.equal(bytes.subarray(0, 2).toString("ascii"), "MZ", `${file} is not a PE file`); } +async function embeddedAsarIntegrity( + executable: string, + archive: string, +): Promise { + const { headerString } = getRawHeader(archive); + const hash = createHash("sha256").update(headerString).digest("hex"); + const expected = JSON.stringify([{ + file: "resources\\app.asar", + alg: "SHA256", + value: hash, + }]); + assert.equal( + (await readFile(executable)).includes(Buffer.from(expected, "utf8")), + true, + "the Windows executable does not embed the exact app.asar header hash", + ); +} + export async function probeWindowsPackage(root: string): Promise { @@ -12,22 +14,44 @@ describe("Windows package probe", () => { const product = "Guild Wars Reforged"; const resources = path.join(root, "out", `${product}-win32-x64`, "resources"); const native = path.join(resources, "app.asar.unpacked", "build", "native"); + const appSource = path.join(root, "app-source"); const make = path.join(root, "out", "make", "squirrel.windows", "x64"); const setup = "Guild-Wars-Reforged-2026.8.10-Windows-x64-Setup.exe"; const packageName = "GuildWarsReforged-2026.8.10-full.nupkg"; try { await mkdir(native, { recursive: true }); + await mkdir(appSource, { recursive: true }); await mkdir(make, { recursive: true }); await writeFile(path.join(root, "package.json"), JSON.stringify({ version: "2026.8.10" })); + await writeFile(path.join(appSource, "main.js"), "export {};\n"); + const archive = path.join(resources, "app.asar"); + await createPackage(appSource, archive); + const hash = createHash("sha256") + .update(getRawHeader(archive).headerString) + .digest("hex"); + const integrity = JSON.stringify([{ + file: "resources\\app.asar", + alg: "SHA256", + value: hash, + }]); for (const file of [ - path.join(root, "out", `${product}-win32-x64`, `${product}.exe`), path.join(native, "windows-host.node"), path.join(native, "gw-dat-decode.exe"), path.join(make, setup), ]) await writeFile(file, "MZfixture"); + await writeFile( + path.join(root, "out", `${product}-win32-x64`, `${product}.exe`), + `MZfixture${integrity}`, + ); await writeFile(path.join(make, packageName), "package"); await writeFile(path.join(make, "RELEASES"), `${"a".repeat(40)} ${packageName} 7\n`); assert.equal((await probeWindowsPackage(root)).setup, setup); + await writeFile(path.join(appSource, "main.js"), "export const changed = true;\n"); + await createPackage(appSource, archive); + await assert.rejects(() => probeWindowsPackage(root)); + await rm(archive); + await writeFile(path.join(appSource, "main.js"), "export {};\n"); + await createPackage(appSource, archive); await writeFile(path.join(resources, "distribution-channel.json"), "{}"); await assert.rejects(() => probeWindowsPackage(root)); } finally { From c88f4dabe9689bfe5d5e99e6e889b41b1f44aed4 Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 06:24:56 +0200 Subject: [PATCH 16/40] test(windows): pass first-run path without shell parsing --- scripts/windows-installed-qualification.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/scripts/windows-installed-qualification.ts b/scripts/windows-installed-qualification.ts index 66c1a0aaa..4c41ef09c 100644 --- a/scripts/windows-installed-qualification.ts +++ b/scripts/windows-installed-qualification.ts @@ -180,7 +180,11 @@ async function stopSquirrelFirstRun(executable: string): Promise { // the exact disposable package we just installed. await delay(1_000); const command = - "$target=[IO.Path]::GetFullPath($args[0]); Get-CimInstance Win32_Process | Where-Object {$_.ExecutablePath -and [IO.Path]::GetFullPath($_.ExecutablePath) -eq $target}"; + "$target=[IO.Path]::GetFullPath($env:GW_QUALIFICATION_EXECUTABLE); Get-CimInstance Win32_Process | Where-Object {$_.ExecutablePath -and [IO.Path]::GetFullPath($_.ExecutablePath) -eq $target}"; + const environment = { + ...process.env, + GW_QUALIFICATION_EXECUTABLE: executable, + }; const { stdout: stopped } = await execFileAsync( "powershell.exe", [ @@ -188,9 +192,8 @@ async function stopSquirrelFirstRun(executable: string): Promise { "-NonInteractive", "-Command", `${command} | ForEach-Object {$_.ProcessId; Stop-Process -Id $_.ProcessId -Force}`, - executable, ], - { encoding: "utf8", timeout: 30_000, windowsHide: true }, + { encoding: "utf8", env: environment, timeout: 30_000, windowsHide: true }, ); assert.notEqual( stopped.trim(), @@ -200,8 +203,8 @@ async function stopSquirrelFirstRun(executable: string): Promise { await delay(500); const { stdout } = await execFileAsync( "powershell.exe", - ["-NoProfile", "-NonInteractive", "-Command", `${command} | Select-Object -ExpandProperty ProcessId`, executable], - { encoding: "utf8", timeout: 30_000, windowsHide: true }, + ["-NoProfile", "-NonInteractive", "-Command", `${command} | Select-Object -ExpandProperty ProcessId`], + { encoding: "utf8", env: environment, timeout: 30_000, windowsHide: true }, ); assert.equal(stdout.trim(), "", "Squirrel's automatic first run is still alive"); } From 1b1dbaf30e892214361dbedb61d97c4ad4e2b8fa Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 06:27:41 +0200 Subject: [PATCH 17/40] test(windows): isolate rollback first run --- scripts/windows-installed-qualification.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/windows-installed-qualification.ts b/scripts/windows-installed-qualification.ts index 4c41ef09c..72b16dccd 100644 --- a/scripts/windows-installed-qualification.ts +++ b/scripts/windows-installed-qualification.ts @@ -513,6 +513,7 @@ try { windowsHide: true, }); installedExecutable = await oneInstalledExecutable(packageRoot); + await stopSquirrelFirstRun(installedExecutable); running = await launchPackagedApp({ appPath: packageRoot, executablePath: installedExecutable, From 0d52d4462b63d3c1fb5f3c0b805b7c4a54d0f409 Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 06:35:57 +0200 Subject: [PATCH 18/40] fix(windows): remove local crash reporting --- scripts/windows-installed-qualification.ts | 74 ++++--------------- src/main/main.ts | 6 -- .../policy/source-windows-credentials.test.ts | 5 +- tests/policy/source-windows-installed.test.ts | 5 +- 4 files changed, 17 insertions(+), 73 deletions(-) diff --git a/scripts/windows-installed-qualification.ts b/scripts/windows-installed-qualification.ts index 72b16dccd..4f347989f 100644 --- a/scripts/windows-installed-qualification.ts +++ b/scripts/windows-installed-qualification.ts @@ -31,13 +31,7 @@ const candidateFeed = process.env.GW_WINDOWS_CANDIDATE_FEED; const delay = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds)); -interface WindowsProcess { - readonly ProcessId: number; - readonly ParentProcessId: number; - readonly CommandLine: string | null; -} - -async function proveNormalCrashpadStartup( +async function proveNormalWindowsStartup( executable: string, arguments_: readonly string[], ): Promise { @@ -48,9 +42,8 @@ async function proveNormalCrashpadStartup( GW_REQUIRE_CACHED_CLIENT: "1", GW_BACKGROUND_LAUNCH: "1", }, - // Match an Explorer or Start-menu launch. Piping a GUI process' standard - // handles through Node changes the handle inheritance seen by Crashpad and - // is not a production-shaped startup boundary. + // Match an Explorer or Start-menu launch, without CDP instrumentation or + // inherited console handles. detached: true, stdio: "ignore", windowsHide: true, @@ -82,40 +75,6 @@ async function proveNormalCrashpadStartup( }); return `normal startup exited with ${child.exitCode}; Windows events: ${eventLog}`; } - assert.ok(child.pid, "the normal installed application has no process ID"); - const { stdout } = await execFileAsync( - "powershell.exe", - [ - "-NoProfile", - "-NonInteractive", - "-Command", - "Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Json -Compress", - ], - { encoding: "utf8", timeout: 30_000, windowsHide: true }, - ); - const parsed = JSON.parse(stdout) as WindowsProcess | WindowsProcess[]; - const processes = Array.isArray(parsed) ? parsed : [parsed]; - const descendants = new Set([child.pid]); - let changed = true; - while (changed) { - changed = false; - for (const candidate of processes) { - if ( - descendants.has(candidate.ParentProcessId) - && !descendants.has(candidate.ProcessId) - ) { - descendants.add(candidate.ProcessId); - changed = true; - } - } - } - assert.ok( - processes.some((candidate) => - descendants.has(candidate.ProcessId) - && candidate.CommandLine?.includes("--type=crashpad-handler") - ), - "the normal installed application did not keep a Crashpad handler alive", - ); return null; } finally { if (child.exitCode === null && child.pid) { @@ -174,10 +133,11 @@ async function oneSetup(feed: string): Promise { } async function stopSquirrelFirstRun(executable: string): Promise { - // Squirrel always launches the installed application once after Setup - // finishes. The controlled qualification needs to own the next launch and - // its single-instance lock, so stop only processes whose executable path is - // the exact disposable package we just installed. + // Squirrel can launch the installed application once after Setup finishes. + // The controlled qualification must own the next launch and its + // single-instance lock, so stop only a process whose executable path is the + // exact disposable package we just installed. A silent install may leave no + // first-run process, which is also safe. await delay(1_000); const command = "$target=[IO.Path]::GetFullPath($env:GW_QUALIFICATION_EXECUTABLE); Get-CimInstance Win32_Process | Where-Object {$_.ExecutablePath -and [IO.Path]::GetFullPath($_.ExecutablePath) -eq $target}"; @@ -185,7 +145,7 @@ async function stopSquirrelFirstRun(executable: string): Promise { ...process.env, GW_QUALIFICATION_EXECUTABLE: executable, }; - const { stdout: stopped } = await execFileAsync( + await execFileAsync( "powershell.exe", [ "-NoProfile", @@ -195,11 +155,6 @@ async function stopSquirrelFirstRun(executable: string): Promise { ], { encoding: "utf8", env: environment, timeout: 30_000, windowsHide: true }, ); - assert.notEqual( - stopped.trim(), - "", - "Squirrel did not keep its automatic first application run alive", - ); await delay(500); const { stdout } = await execFileAsync( "powershell.exe", @@ -313,7 +268,7 @@ try { "--enable-logging=stderr", ...(signedQualification ? [] : ["--gw-volatile-secrets"]), ]; - const normalStartupFailure = await proveNormalCrashpadStartup( + const normalStartupFailure = await proveNormalWindowsStartup( installedExecutable, qualificationArguments, ); @@ -327,12 +282,9 @@ try { // graphics context. Keep the Chromium sandbox enabled, but render this // package qualification in software so a runner-only GPU crash cannot // mask launcher, profile, storage, and uninstall behavior. The preceding - // normal launch proves production Crashpad. CDP is test instrumentation - // that starts before application JavaScript can connect that handler. - arguments: [ - ...qualificationArguments, - "--disable-crash-reporter", - ], + // launch already proves ordinary desktop startup; CDP is test-only + // renderer instrumentation. + arguments: qualificationArguments, environment: { ELECTRON_ENABLE_LOGGING: "1" }, useDefaultUserData: true, }); diff --git a/src/main/main.ts b/src/main/main.ts index a8af56ec1..cf3bd86c8 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -11,7 +11,6 @@ import { app, autoUpdater, type BrowserWindow, - crashReporter, dialog, Notification, powerMonitor, @@ -228,11 +227,6 @@ if (!explicitUserData && (process.platform === "win32" || linuxFlatpak)) { app.setPath("sessionData", applicationStorageRoots.sessions); } if (process.platform === "win32") { - // Chromium can create utility processes while Electron is still entering - // application startup. Connect their local Crashpad handler before any - // shell integration or single-instance work can trigger that process work. - // Reports remain on this device; no upload endpoint is configured. - crashReporter.start({ uploadToServer: false }); app.setAppUserModelId(windowsAppUserModelId(app.getName())); } diff --git a/tests/policy/source-windows-credentials.test.ts b/tests/policy/source-windows-credentials.test.ts index f4cf431d7..4ceb32eb3 100644 --- a/tests/policy/source-windows-credentials.test.ts +++ b/tests/policy/source-windows-credentials.test.ts @@ -18,9 +18,8 @@ test("Windows storage starts from the native LocalAppData known folder", () => { assert.match(main, /explicitUserData\s*\? colocatedStorageRoots/u); }); -test("Windows starts the local Crashpad handler before renderer creation", () => { - assert.match(main, /process\.platform === "win32"[\s\S]*crashReporter\.start\(\{ uploadToServer: false \}\)/u); - assert.doesNotMatch(main, /crashReporter\.start\(\{[^}]*submitURL/u); +test("Windows does not retain process-memory crash reports", () => { + assert.doesNotMatch(main, /crashReporter|crashDumps|\.dmp/u); }); test("Credential Manager owns only closed application and profile slots", () => { diff --git a/tests/policy/source-windows-installed.test.ts b/tests/policy/source-windows-installed.test.ts index 4f216830f..dabd0abfc 100644 --- a/tests/policy/source-windows-installed.test.ts +++ b/tests/policy/source-windows-installed.test.ts @@ -26,9 +26,8 @@ test("installed qualification is restricted to a disposable hosted runner", () = assert.match(script, /refusing to replace a pre-existing Windows fixture root/u); assert.match(script, /useDefaultUserData: true/u); assert.match(script, /"--disable-gpu"/u); - assert.match(script, /proveNormalCrashpadStartup/u); - assert.match(script, /--type=crashpad-handler/u); - assert.match(script, /"--disable-crash-reporter"/u); + assert.match(script, /proveNormalWindowsStartup/u); + assert.doesNotMatch(script, /crashpad-handler|disable-crash-reporter/u); assert.doesNotMatch(script, /--no-sandbox|--disable-setuid-sandbox/u); }); From 7e3bfbbb447f90aeaea047e32378202747c37aa6 Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 06:43:39 +0200 Subject: [PATCH 19/40] test(windows): isolate instrumented crash reporting --- scripts/windows-installed-qualification.ts | 10 +++++++++- tests/policy/source-windows-installed.test.ts | 6 +++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/scripts/windows-installed-qualification.ts b/scripts/windows-installed-qualification.ts index 4f347989f..5ce832a2b 100644 --- a/scripts/windows-installed-qualification.ts +++ b/scripts/windows-installed-qualification.ts @@ -272,6 +272,14 @@ try { installedExecutable, qualificationArguments, ); + const instrumentedArguments = [ + ...qualificationArguments, + // CDP qualification pipes the GUI process' output into this harness. + // Electron's built-in Windows Crashpad process cannot inherit that + // test-only process shape reliably. The detached launch above proves the + // ordinary production path before renderer instrumentation begins. + "--disable-crash-reporter", + ]; running = await launchPackagedApp({ appPath: packageRoot, @@ -284,7 +292,7 @@ try { // mask launcher, profile, storage, and uninstall behavior. The preceding // launch already proves ordinary desktop startup; CDP is test-only // renderer instrumentation. - arguments: qualificationArguments, + arguments: instrumentedArguments, environment: { ELECTRON_ENABLE_LOGGING: "1" }, useDefaultUserData: true, }); diff --git a/tests/policy/source-windows-installed.test.ts b/tests/policy/source-windows-installed.test.ts index dabd0abfc..bfd74ca46 100644 --- a/tests/policy/source-windows-installed.test.ts +++ b/tests/policy/source-windows-installed.test.ts @@ -27,7 +27,11 @@ test("installed qualification is restricted to a disposable hosted runner", () = assert.match(script, /useDefaultUserData: true/u); assert.match(script, /"--disable-gpu"/u); assert.match(script, /proveNormalWindowsStartup/u); - assert.doesNotMatch(script, /crashpad-handler|disable-crash-reporter/u); + assert.match( + script, + /const instrumentedArguments = \[[\s\S]*"--disable-crash-reporter"[\s\S]*arguments: instrumentedArguments/u, + ); + assert.doesNotMatch(script, /crashpad-handler/u); assert.doesNotMatch(script, /--no-sandbox|--disable-setuid-sandbox/u); }); From cec2d5d3f93019fa68d064345d624580d88e43e7 Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 06:51:35 +0200 Subject: [PATCH 20/40] test(windows): preserve desktop process shape --- scripts/windows-installed-qualification.ts | 17 +++++++---------- tests/helpers/packaged-app.ts | 8 +++++++- tests/policy/source-windows-installed.test.ts | 13 ++++++++----- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/scripts/windows-installed-qualification.ts b/scripts/windows-installed-qualification.ts index 5ce832a2b..6689a9204 100644 --- a/scripts/windows-installed-qualification.ts +++ b/scripts/windows-installed-qualification.ts @@ -272,15 +272,6 @@ try { installedExecutable, qualificationArguments, ); - const instrumentedArguments = [ - ...qualificationArguments, - // CDP qualification pipes the GUI process' output into this harness. - // Electron's built-in Windows Crashpad process cannot inherit that - // test-only process shape reliably. The detached launch above proves the - // ordinary production path before renderer instrumentation begins. - "--disable-crash-reporter", - ]; - running = await launchPackagedApp({ appPath: packageRoot, executablePath: installedExecutable, @@ -292,7 +283,11 @@ try { // mask launcher, profile, storage, and uninstall behavior. The preceding // launch already proves ordinary desktop startup; CDP is test-only // renderer instrumentation. - arguments: instrumentedArguments, + arguments: qualificationArguments, + // Keep the installed GUI process in the same detached, pipe-free shape as + // an Explorer launch. CDP connects through DevToolsActivePort and does not + // need to change the process' Windows console or Crashpad inheritance. + desktopProcessShape: true, environment: { ELECTRON_ENABLE_LOGGING: "1" }, useDefaultUserData: true, }); @@ -431,6 +426,7 @@ try { productName: release.productName, userData: storage.sessions, arguments: signedQualification ? [] : ["--gw-volatile-secrets"], + desktopProcessShape: true, useDefaultUserData: true, }); const restartedLauncher = running.launcherPage; @@ -479,6 +475,7 @@ try { executablePath: installedExecutable, productName: release.productName, userData: storage.sessions, + desktopProcessShape: true, useDefaultUserData: true, }); const rollbackLauncher = running.launcherPage; diff --git a/tests/helpers/packaged-app.ts b/tests/helpers/packaged-app.ts index 1d8731f21..e78d4e011 100644 --- a/tests/helpers/packaged-app.ts +++ b/tests/helpers/packaged-app.ts @@ -26,6 +26,8 @@ export interface PackagedAppLaunch { readonly arguments?: readonly string[]; /** Use the package's native platform roots instead of a user-data override. */ readonly useDefaultUserData?: boolean; + /** Launch as an OS desktop process while CDP connects through its port file. */ + readonly desktopProcessShape?: boolean; /** Open the first active profile when the packaged app starts on the launcher. */ readonly openFirstProfile?: boolean; } @@ -160,7 +162,11 @@ export async function launchPackagedApp( GW_BACKGROUND_LAUNCH: "1", ...options.environment, }, - stdio: ["ignore", "pipe", "pipe"], + detached: options.desktopProcessShape === true, + stdio: options.desktopProcessShape === true + ? "ignore" + : ["ignore", "pipe", "pipe"], + windowsHide: options.desktopProcessShape === true, }, ); child.stdout?.on("data", capture); diff --git a/tests/policy/source-windows-installed.test.ts b/tests/policy/source-windows-installed.test.ts index bfd74ca46..578cc86d2 100644 --- a/tests/policy/source-windows-installed.test.ts +++ b/tests/policy/source-windows-installed.test.ts @@ -9,6 +9,10 @@ const script = readFileSync( path.join(root, "scripts/windows-installed-qualification.ts"), "utf8", ); +const packagedApp = readFileSync( + path.join(root, "tests/helpers/packaged-app.ts"), + "utf8", +); const workflow = readFileSync( path.join(root, ".github/workflows/portable-native-build.yml"), "utf8", @@ -27,11 +31,10 @@ test("installed qualification is restricted to a disposable hosted runner", () = assert.match(script, /useDefaultUserData: true/u); assert.match(script, /"--disable-gpu"/u); assert.match(script, /proveNormalWindowsStartup/u); - assert.match( - script, - /const instrumentedArguments = \[[\s\S]*"--disable-crash-reporter"[\s\S]*arguments: instrumentedArguments/u, - ); - assert.doesNotMatch(script, /crashpad-handler/u); + assert.match(script, /desktopProcessShape: true/u); + assert.match(packagedApp, /detached: options\.desktopProcessShape === true/u); + assert.match(packagedApp, /\? "ignore"[\s\S]*windowsHide: options\.desktopProcessShape === true/u); + assert.doesNotMatch(script, /crashpad-handler|disable-crash-reporter/u); assert.doesNotMatch(script, /--no-sandbox|--disable-setuid-sandbox/u); }); From 91b927bed8a4158bc1c16ecc27ecc3b7c9ff0e81 Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 06:59:14 +0200 Subject: [PATCH 21/40] test(windows): run desktop probe after UI checks --- scripts/windows-installed-qualification.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/scripts/windows-installed-qualification.ts b/scripts/windows-installed-qualification.ts index 6689a9204..9bed07e73 100644 --- a/scripts/windows-installed-qualification.ts +++ b/scripts/windows-installed-qualification.ts @@ -268,10 +268,6 @@ try { "--enable-logging=stderr", ...(signedQualification ? [] : ["--gw-volatile-secrets"]), ]; - const normalStartupFailure = await proveNormalWindowsStartup( - installedExecutable, - qualificationArguments, - ); running = await launchPackagedApp({ appPath: packageRoot, executablePath: installedExecutable, @@ -280,9 +276,9 @@ try { // GitHub's hosted Windows service session has no stable accelerated // graphics context. Keep the Chromium sandbox enabled, but render this // package qualification in software so a runner-only GPU crash cannot - // mask launcher, profile, storage, and uninstall behavior. The preceding - // launch already proves ordinary desktop startup; CDP is test-only - // renderer instrumentation. + // mask launcher, profile, storage, and uninstall behavior. CDP is + // test-only renderer instrumentation; the final detached launch proves + // ordinary desktop startup after these checks close gracefully. arguments: qualificationArguments, // Keep the installed GUI process in the same detached, pipe-free shape as // an Explorer launch. CDP connects through DevToolsActivePort and does not @@ -517,6 +513,13 @@ try { running = null; } + // Run the uninstrumented desktop-start proof last. Its bounded cleanup is a + // forced process-tree stop, so it must not poison a later Electron startup + // before the installed profile and rollback checks have run. + const normalStartupFailure = await proveNormalWindowsStartup( + installedExecutable, + qualificationArguments, + ); await uninstall(updateExecutable); assert.equal( existsSync(installedExecutable), From cd225eec60b3b15bfd630f813874cf48c8c9b9ee Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 07:11:12 +0200 Subject: [PATCH 22/40] test(windows): retain installed failure evidence --- .github/workflows/portable-native-build.yml | 8 +++ scripts/windows-installed-qualification.ts | 70 ++++++++++++------- tests/policy/source-windows-installed.test.ts | 3 + 3 files changed, 55 insertions(+), 26 deletions(-) diff --git a/.github/workflows/portable-native-build.yml b/.github/workflows/portable-native-build.yml index 779f43a20..08a121d50 100644 --- a/.github/workflows/portable-native-build.yml +++ b/.github/workflows/portable-native-build.yml @@ -88,3 +88,11 @@ jobs: - name: Qualify the installed Windows package if: runner.os == 'Windows' run: pnpm test:windows-installed + - name: Retain installed Windows failure evidence + if: failure() && runner.os == 'Windows' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: windows-installed-failure + path: ~/AppData/Local/Guild Wars Reforged/logs + if-no-files-found: warn + retention-days: 3 diff --git a/scripts/windows-installed-qualification.ts b/scripts/windows-installed-qualification.ts index 9bed07e73..a544cc8b1 100644 --- a/scripts/windows-installed-qualification.ts +++ b/scripts/windows-installed-qualification.ts @@ -31,6 +31,29 @@ const candidateFeed = process.env.GW_WINDOWS_CANDIDATE_FEED; const delay = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds)); +async function recentWindowsApplicationEvents(): Promise { + return execFileAsync( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + "$since=(Get-Date).AddMinutes(-2); Get-WinEvent -FilterHashtable @{LogName='Application'; StartTime=$since} -ErrorAction SilentlyContinue | Where-Object {$_.Id -in 1000,1001,1026} | Select-Object -First 8 TimeCreated,Id,ProviderName,Message | ConvertTo-Json -Compress", + ], + { encoding: "utf8", timeout: 30_000, windowsHide: true }, + ).then(({ stdout }) => stdout.trim() || "none") + // Get-WinEvent exits with 1 when its filter has no matching records. + .catch((error: unknown) => { + if ( + typeof error === "object" + && error !== null + && "code" in error + && error.code === 1 + ) return "none"; + return `query failed: ${error instanceof Error ? error.message : String(error)}`; + }); +} + async function proveNormalWindowsStartup( executable: string, arguments_: readonly string[], @@ -51,28 +74,7 @@ async function proveNormalWindowsStartup( try { await delay(5_000); if (child.exitCode !== null) { - const eventLog = await execFileAsync( - "powershell.exe", - [ - "-NoProfile", - "-NonInteractive", - "-Command", - "$since=(Get-Date).AddMinutes(-2); Get-WinEvent -FilterHashtable @{LogName='Application'; StartTime=$since} -ErrorAction SilentlyContinue | Where-Object {$_.Id -in 1000,1001,1026} | Select-Object -First 8 TimeCreated,Id,ProviderName,Message | ConvertTo-Json -Compress", - ], - { encoding: "utf8", timeout: 30_000, windowsHide: true }, - ).then(({ stdout }) => stdout.trim() || "none") - // Get-WinEvent exits with 1 when its filter has no matching records. - // That absence is useful evidence, not a reason to stop the remaining - // installed-package diagnostics. - .catch((error: unknown) => { - if ( - typeof error === "object" - && error !== null - && "code" in error - && error.code === 1 - ) return "none"; - return `query failed: ${error instanceof Error ? error.message : String(error)}`; - }); + const eventLog = await recentWindowsApplicationEvents(); return `normal startup exited with ${child.exitCode}; Windows events: ${eventLog}`; } return null; @@ -227,6 +229,7 @@ for (const candidate of [packageRoot, path.dirname(storage.config)]) { let running: RunningPackagedApp | null = null; let installedExecutable: string | null = null; +let qualificationSucceeded = false; try { // Seed the released Single Account shape before Setup. Squirrel launches the // application automatically, so that first real execution must exercise the @@ -265,9 +268,13 @@ try { const qualificationArguments = [ "--disable-gpu", - "--enable-logging=stderr", ...(signedQualification ? [] : ["--gw-volatile-secrets"]), ]; + const automationArguments = [ + ...qualificationArguments, + "--enable-logging=file", + `--log-file=${path.join(storage.logs, "electron.log")}`, + ]; running = await launchPackagedApp({ appPath: packageRoot, executablePath: installedExecutable, @@ -279,12 +286,11 @@ try { // mask launcher, profile, storage, and uninstall behavior. CDP is // test-only renderer instrumentation; the final detached launch proves // ordinary desktop startup after these checks close gracefully. - arguments: qualificationArguments, + arguments: automationArguments, // Keep the installed GUI process in the same detached, pipe-free shape as // an Explorer launch. CDP connects through DevToolsActivePort and does not // need to change the process' Windows console or Crashpad inheritance. desktopProcessShape: true, - environment: { ELECTRON_ENABLE_LOGGING: "1" }, useDefaultUserData: true, }); const launcher = running.launcherPage; @@ -557,11 +563,23 @@ try { : ["signed publisher identity", "installed update and rollback"]), ], }, null, 2)); + qualificationSucceeded = true; +} catch (error) { + globalThis.console.error( + `Recent Windows Application events: ${await recentWindowsApplicationEvents()}`, + ); + throw error; } finally { if (running) await closePackagedApp(running).catch(() => {}); if (installedExecutable && existsSync(path.join(packageRoot, "Update.exe"))) { await uninstall(path.join(packageRoot, "Update.exe")).catch(() => {}); } - await rm(path.dirname(storage.config), { recursive: true, force: true }); + if (qualificationSucceeded) { + await rm(path.dirname(storage.config), { recursive: true, force: true }); + } else { + globalThis.console.error( + `Retained failed Windows fixture: ${path.dirname(storage.config)}`, + ); + } await rm(packageRoot, { recursive: true, force: true }); } diff --git a/tests/policy/source-windows-installed.test.ts b/tests/policy/source-windows-installed.test.ts index 578cc86d2..1ce43088d 100644 --- a/tests/policy/source-windows-installed.test.ts +++ b/tests/policy/source-windows-installed.test.ts @@ -35,6 +35,9 @@ test("installed qualification is restricted to a disposable hosted runner", () = assert.match(packagedApp, /detached: options\.desktopProcessShape === true/u); assert.match(packagedApp, /\? "ignore"[\s\S]*windowsHide: options\.desktopProcessShape === true/u); assert.doesNotMatch(script, /crashpad-handler|disable-crash-reporter/u); + assert.match(script, /Retained failed Windows fixture/u); + assert.match(workflow, /Retain installed Windows failure evidence/u); + assert.match(workflow, /windows-installed-failure/u); assert.doesNotMatch(script, /--no-sandbox|--disable-setuid-sandbox/u); }); From 826143cb2171f6c88fe44040a45d1cad085711e5 Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 07:21:57 +0200 Subject: [PATCH 23/40] test(windows): initialize qualification debugging after Crashpad --- scripts/windows-installed-qualification.ts | 3 +++ src/main/main.ts | 15 +++++++++++++++ tests/helpers/packaged-app.ts | 10 ++++++++-- tests/policy/source-windows-credentials.test.ts | 8 ++++++-- tests/policy/source-windows-installed.test.ts | 2 ++ 5 files changed, 34 insertions(+), 4 deletions(-) diff --git a/scripts/windows-installed-qualification.ts b/scripts/windows-installed-qualification.ts index a544cc8b1..a33b9b153 100644 --- a/scripts/windows-installed-qualification.ts +++ b/scripts/windows-installed-qualification.ts @@ -287,6 +287,7 @@ try { // test-only renderer instrumentation; the final detached launch proves // ordinary desktop startup after these checks close gracefully. arguments: automationArguments, + appOwnedRemoteDebugging: true, // Keep the installed GUI process in the same detached, pipe-free shape as // an Explorer launch. CDP connects through DevToolsActivePort and does not // need to change the process' Windows console or Crashpad inheritance. @@ -428,6 +429,7 @@ try { productName: release.productName, userData: storage.sessions, arguments: signedQualification ? [] : ["--gw-volatile-secrets"], + appOwnedRemoteDebugging: true, desktopProcessShape: true, useDefaultUserData: true, }); @@ -477,6 +479,7 @@ try { executablePath: installedExecutable, productName: release.productName, userData: storage.sessions, + appOwnedRemoteDebugging: true, desktopProcessShape: true, useDefaultUserData: true, }); diff --git a/src/main/main.ts b/src/main/main.ts index cf3bd86c8..61c7c4ddf 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -11,6 +11,7 @@ import { app, autoUpdater, type BrowserWindow, + crashReporter, dialog, Notification, powerMonitor, @@ -229,6 +230,20 @@ if (!explicitUserData && (process.platform === "win32" || linuxFlatpak)) { if (process.platform === "win32") { app.setAppUserModelId(windowsAppUserModelId(app.getName())); } +if ( + process.platform === "win32" + && app.commandLine.hasSwitch("gw-qualification-debugging") +) { + // The installed-package proof must inspect the exact fused executable. + // Starting Chromium's remote-debugging service from the original command + // line creates a Windows child before Electron's main JavaScript can connect + // Crashpad. Initialize that local, non-uploading handler first, then expose + // only an ephemeral loopback port. Ordinary application startup never + // enters this qualification-only path. + crashReporter.start({ uploadToServer: false }); + app.commandLine.appendSwitch("remote-debugging-address", "127.0.0.1"); + app.commandLine.appendSwitch("remote-debugging-port", "0"); +} const primaryInstance = !windowsSquirrelStartupHandled && app.requestSingleInstanceLock(); diff --git a/tests/helpers/packaged-app.ts b/tests/helpers/packaged-app.ts index e78d4e011..07efefba0 100644 --- a/tests/helpers/packaged-app.ts +++ b/tests/helpers/packaged-app.ts @@ -28,6 +28,8 @@ export interface PackagedAppLaunch { readonly useDefaultUserData?: boolean; /** Launch as an OS desktop process while CDP connects through its port file. */ readonly desktopProcessShape?: boolean; + /** Let the Windows app initialize Crashpad before its qualification CDP port. */ + readonly appOwnedRemoteDebugging?: boolean; /** Open the first active profile when the packaged app starts on the launcher. */ readonly openFirstProfile?: boolean; } @@ -148,8 +150,12 @@ export async function launchPackagedApp( ...(options.useDefaultUserData === true ? [] : [`--user-data-dir=${options.userData}`]), - "--remote-debugging-address=127.0.0.1", - "--remote-debugging-port=0", + ...(options.appOwnedRemoteDebugging === true + ? ["--gw-qualification-debugging"] + : [ + "--remote-debugging-address=127.0.0.1", + "--remote-debugging-port=0", + ]), ...(options.arguments ?? []), ], { diff --git a/tests/policy/source-windows-credentials.test.ts b/tests/policy/source-windows-credentials.test.ts index 4ceb32eb3..cc2a23afe 100644 --- a/tests/policy/source-windows-credentials.test.ts +++ b/tests/policy/source-windows-credentials.test.ts @@ -18,8 +18,12 @@ test("Windows storage starts from the native LocalAppData known folder", () => { assert.match(main, /explicitUserData\s*\? colocatedStorageRoots/u); }); -test("Windows does not retain process-memory crash reports", () => { - assert.doesNotMatch(main, /crashReporter|crashDumps|\.dmp/u); +test("Windows starts local crash reporting only for installed qualification", () => { + assert.match( + main, + /hasSwitch\("gw-qualification-debugging"\)[\s\S]*crashReporter\.start\(\{ uploadToServer: false \}\)/u, + ); + assert.doesNotMatch(main, /crashReporter\.start\(\{[^}]*submitURL/u); }); test("Credential Manager owns only closed application and profile slots", () => { diff --git a/tests/policy/source-windows-installed.test.ts b/tests/policy/source-windows-installed.test.ts index 1ce43088d..b84668f86 100644 --- a/tests/policy/source-windows-installed.test.ts +++ b/tests/policy/source-windows-installed.test.ts @@ -32,6 +32,8 @@ test("installed qualification is restricted to a disposable hosted runner", () = assert.match(script, /"--disable-gpu"/u); assert.match(script, /proveNormalWindowsStartup/u); assert.match(script, /desktopProcessShape: true/u); + assert.match(script, /appOwnedRemoteDebugging: true/u); + assert.match(packagedApp, /"--gw-qualification-debugging"/u); assert.match(packagedApp, /detached: options\.desktopProcessShape === true/u); assert.match(packagedApp, /\? "ignore"[\s\S]*windowsHide: options\.desktopProcessShape === true/u); assert.doesNotMatch(script, /crashpad-handler|disable-crash-reporter/u); From 3659fa1be881a78ca30b64121e5c011aa1189b7e Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 07:34:59 +0200 Subject: [PATCH 24/40] test(windows): close Squirrel first run cleanly --- scripts/windows-installed-qualification.ts | 43 ++++++++++++------- src/main/main.ts | 16 ------- tests/helpers/packaged-app.ts | 10 +---- .../policy/source-windows-credentials.test.ts | 8 +--- tests/policy/source-windows-installed.test.ts | 8 +++- 5 files changed, 37 insertions(+), 48 deletions(-) diff --git a/scripts/windows-installed-qualification.ts b/scripts/windows-installed-qualification.ts index a33b9b153..fbeb131ca 100644 --- a/scripts/windows-installed-qualification.ts +++ b/scripts/windows-installed-qualification.ts @@ -137,12 +137,33 @@ async function oneSetup(feed: string): Promise { async function stopSquirrelFirstRun(executable: string): Promise { // Squirrel can launch the installed application once after Setup finishes. // The controlled qualification must own the next launch and its - // single-instance lock, so stop only a process whose executable path is the - // exact disposable package we just installed. A silent install may leave no - // first-run process, which is also safe. + // single-instance lock. Close its real top-level window and let Electron + // finish its own shutdown; force-killing the process tree can strand + // Crashpad state and make the next otherwise-valid launch terminate before + // JavaScript starts. A silent install may leave no first-run process, which + // is also safe. await delay(1_000); - const command = - "$target=[IO.Path]::GetFullPath($env:GW_QUALIFICATION_EXECUTABLE); Get-CimInstance Win32_Process | Where-Object {$_.ExecutablePath -and [IO.Path]::GetFullPath($_.ExecutablePath) -eq $target}"; + const command = [ + "$target=[IO.Path]::GetFullPath($env:GW_QUALIFICATION_EXECUTABLE)", + "$deadline=(Get-Date).AddSeconds(20)", + "$closeRequested=$false", + "while ((Get-Date) -lt $deadline) {", + " $rows=@(Get-CimInstance Win32_Process | Where-Object {$_.ExecutablePath -and [IO.Path]::GetFullPath($_.ExecutablePath) -eq $target})", + " if ($rows.Count -eq 0) { exit 0 }", + " if (-not $closeRequested) {", + " foreach ($row in $rows) {", + " $candidate=Get-Process -Id $row.ProcessId -ErrorAction SilentlyContinue", + " if ($candidate -and $candidate.MainWindowHandle -ne 0) {", + " if (-not $candidate.CloseMainWindow()) { throw 'Squirrel first-run window refused to close' }", + " $closeRequested=$true", + " break", + " }", + " }", + " }", + " Start-Sleep -Milliseconds 250", + "}", + "throw 'Squirrel first-run processes did not exit cleanly'", + ].join("\n"); const environment = { ...process.env, GW_QUALIFICATION_EXECUTABLE: executable, @@ -153,17 +174,10 @@ async function stopSquirrelFirstRun(executable: string): Promise { "-NoProfile", "-NonInteractive", "-Command", - `${command} | ForEach-Object {$_.ProcessId; Stop-Process -Id $_.ProcessId -Force}`, + command, ], { encoding: "utf8", env: environment, timeout: 30_000, windowsHide: true }, ); - await delay(500); - const { stdout } = await execFileAsync( - "powershell.exe", - ["-NoProfile", "-NonInteractive", "-Command", `${command} | Select-Object -ExpandProperty ProcessId`], - { encoding: "utf8", env: environment, timeout: 30_000, windowsHide: true }, - ); - assert.equal(stdout.trim(), "", "Squirrel's automatic first run is still alive"); } function installedExecutableForVersion( @@ -287,7 +301,6 @@ try { // test-only renderer instrumentation; the final detached launch proves // ordinary desktop startup after these checks close gracefully. arguments: automationArguments, - appOwnedRemoteDebugging: true, // Keep the installed GUI process in the same detached, pipe-free shape as // an Explorer launch. CDP connects through DevToolsActivePort and does not // need to change the process' Windows console or Crashpad inheritance. @@ -429,7 +442,6 @@ try { productName: release.productName, userData: storage.sessions, arguments: signedQualification ? [] : ["--gw-volatile-secrets"], - appOwnedRemoteDebugging: true, desktopProcessShape: true, useDefaultUserData: true, }); @@ -479,7 +491,6 @@ try { executablePath: installedExecutable, productName: release.productName, userData: storage.sessions, - appOwnedRemoteDebugging: true, desktopProcessShape: true, useDefaultUserData: true, }); diff --git a/src/main/main.ts b/src/main/main.ts index 61c7c4ddf..5bb9f5703 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -11,7 +11,6 @@ import { app, autoUpdater, type BrowserWindow, - crashReporter, dialog, Notification, powerMonitor, @@ -230,21 +229,6 @@ if (!explicitUserData && (process.platform === "win32" || linuxFlatpak)) { if (process.platform === "win32") { app.setAppUserModelId(windowsAppUserModelId(app.getName())); } -if ( - process.platform === "win32" - && app.commandLine.hasSwitch("gw-qualification-debugging") -) { - // The installed-package proof must inspect the exact fused executable. - // Starting Chromium's remote-debugging service from the original command - // line creates a Windows child before Electron's main JavaScript can connect - // Crashpad. Initialize that local, non-uploading handler first, then expose - // only an ephemeral loopback port. Ordinary application startup never - // enters this qualification-only path. - crashReporter.start({ uploadToServer: false }); - app.commandLine.appendSwitch("remote-debugging-address", "127.0.0.1"); - app.commandLine.appendSwitch("remote-debugging-port", "0"); -} - const primaryInstance = !windowsSquirrelStartupHandled && app.requestSingleInstanceLock(); if (!primaryInstance) { diff --git a/tests/helpers/packaged-app.ts b/tests/helpers/packaged-app.ts index 07efefba0..e78d4e011 100644 --- a/tests/helpers/packaged-app.ts +++ b/tests/helpers/packaged-app.ts @@ -28,8 +28,6 @@ export interface PackagedAppLaunch { readonly useDefaultUserData?: boolean; /** Launch as an OS desktop process while CDP connects through its port file. */ readonly desktopProcessShape?: boolean; - /** Let the Windows app initialize Crashpad before its qualification CDP port. */ - readonly appOwnedRemoteDebugging?: boolean; /** Open the first active profile when the packaged app starts on the launcher. */ readonly openFirstProfile?: boolean; } @@ -150,12 +148,8 @@ export async function launchPackagedApp( ...(options.useDefaultUserData === true ? [] : [`--user-data-dir=${options.userData}`]), - ...(options.appOwnedRemoteDebugging === true - ? ["--gw-qualification-debugging"] - : [ - "--remote-debugging-address=127.0.0.1", - "--remote-debugging-port=0", - ]), + "--remote-debugging-address=127.0.0.1", + "--remote-debugging-port=0", ...(options.arguments ?? []), ], { diff --git a/tests/policy/source-windows-credentials.test.ts b/tests/policy/source-windows-credentials.test.ts index cc2a23afe..4ceb32eb3 100644 --- a/tests/policy/source-windows-credentials.test.ts +++ b/tests/policy/source-windows-credentials.test.ts @@ -18,12 +18,8 @@ test("Windows storage starts from the native LocalAppData known folder", () => { assert.match(main, /explicitUserData\s*\? colocatedStorageRoots/u); }); -test("Windows starts local crash reporting only for installed qualification", () => { - assert.match( - main, - /hasSwitch\("gw-qualification-debugging"\)[\s\S]*crashReporter\.start\(\{ uploadToServer: false \}\)/u, - ); - assert.doesNotMatch(main, /crashReporter\.start\(\{[^}]*submitURL/u); +test("Windows does not retain process-memory crash reports", () => { + assert.doesNotMatch(main, /crashReporter|crashDumps|\.dmp/u); }); test("Credential Manager owns only closed application and profile slots", () => { diff --git a/tests/policy/source-windows-installed.test.ts b/tests/policy/source-windows-installed.test.ts index b84668f86..1b8aea92b 100644 --- a/tests/policy/source-windows-installed.test.ts +++ b/tests/policy/source-windows-installed.test.ts @@ -23,6 +23,10 @@ const signedWorkflow = readFileSync( ); test("installed qualification is restricted to a disposable hosted runner", () => { + const firstRunShutdown = script.match( + /async function stopSquirrelFirstRun[\s\S]*?^\}/mu, + )?.[0]; + assert.ok(firstRunShutdown); assert.match(script, /process\.platform !== "win32"/u); assert.match(script, /process\.arch !== "x64"/u); assert.match(script, /GITHUB_ACTIONS !== "true"/u); @@ -32,8 +36,8 @@ test("installed qualification is restricted to a disposable hosted runner", () = assert.match(script, /"--disable-gpu"/u); assert.match(script, /proveNormalWindowsStartup/u); assert.match(script, /desktopProcessShape: true/u); - assert.match(script, /appOwnedRemoteDebugging: true/u); - assert.match(packagedApp, /"--gw-qualification-debugging"/u); + assert.match(firstRunShutdown, /CloseMainWindow\(\)/u); + assert.doesNotMatch(firstRunShutdown, /Stop-Process|taskkill\.exe/u); assert.match(packagedApp, /detached: options\.desktopProcessShape === true/u); assert.match(packagedApp, /\? "ignore"[\s\S]*windowsHide: options\.desktopProcessShape === true/u); assert.doesNotMatch(script, /crashpad-handler|disable-crash-reporter/u); From 804bd46f27eb315395e3d922c14fa907eb6113e4 Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 07:42:48 +0200 Subject: [PATCH 25/40] test(windows): use a fixed DevTools port --- tests/helpers/packaged-app.ts | 46 +++++++++++++++++-- tests/policy/source-windows-installed.test.ts | 2 + 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/tests/helpers/packaged-app.ts b/tests/helpers/packaged-app.ts index e78d4e011..401409997 100644 --- a/tests/helpers/packaged-app.ts +++ b/tests/helpers/packaged-app.ts @@ -1,6 +1,7 @@ import { chromium, type Browser, type Page } from "playwright"; import { spawn, type ChildProcess } from "node:child_process"; import { readFile, rm } from "node:fs/promises"; +import { createServer } from "node:net"; import path from "node:path"; import type { ProfileId } from "../../src/shared/multiple-accounts.ts"; @@ -49,6 +50,23 @@ async function waitUntil( throw new Error(`timed out waiting for ${description}`); } +async function availableLoopbackPort(): Promise { + const server = createServer(); + server.unref(); + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen({ host: "127.0.0.1", port: 0, exclusive: true }, () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Windows qualification could not reserve a loopback port")); + return; + } + server.close((error) => error ? reject(error) : resolve(address.port)); + }); + }); +} + async function waitForExit(child: ChildProcess, timeoutMs: number): Promise { if (child.exitCode !== null || child.signalCode !== null) return true; return new Promise((resolve) => { @@ -136,6 +154,12 @@ export async function launchPackagedApp( `Contents/MacOS/${options.productName}`, ); const activePort = path.join(options.userData, "DevToolsActivePort"); + // Chromium's port-zero discovery path can terminate an installed Electron + // process before main JavaScript starts on hosted Windows. Reserve a normal + // loopback port there; macOS and Linux retain the proven port-file path. + const requestedPort = process.platform === "win32" + ? await availableLoopbackPort() + : 0; await rm(activePort, { force: true }); let capturedOutput = ""; const capture = (chunk: Buffer) => { @@ -149,7 +173,7 @@ export async function launchPackagedApp( ? [] : [`--user-data-dir=${options.userData}`]), "--remote-debugging-address=127.0.0.1", - "--remote-debugging-port=0", + `--remote-debugging-port=${requestedPort}`, ...(options.arguments ?? []), ], { @@ -172,7 +196,22 @@ export async function launchPackagedApp( child.stdout?.on("data", capture); child.stderr?.on("data", capture); try { - const port = await waitUntil("the packaged app DevTools port", async () => { + const port = requestedPort === 0 + ? await waitUntil("the packaged app DevTools port", async () => { + if (child.exitCode !== null) { + const detail = capturedOutput.trim(); + throw new Error( + `packaged app exited with code ${child.exitCode}${detail ? `\n${detail}` : ""}`, + ); + } + try { + return (await readFile(activePort, "utf8")).split("\n", 1)[0] ?? null; + } catch { + return null; + } + }) + : String(requestedPort); + await waitUntil("the packaged app DevTools endpoint", async () => { if (child.exitCode !== null) { const detail = capturedOutput.trim(); throw new Error( @@ -180,7 +219,8 @@ export async function launchPackagedApp( ); } try { - return (await readFile(activePort, "utf8")).split("\n", 1)[0] ?? null; + const response = await fetch(`http://127.0.0.1:${port}/json/version`); + return response.ok ? true : null; } catch { return null; } diff --git a/tests/policy/source-windows-installed.test.ts b/tests/policy/source-windows-installed.test.ts index 1b8aea92b..763561e08 100644 --- a/tests/policy/source-windows-installed.test.ts +++ b/tests/policy/source-windows-installed.test.ts @@ -40,6 +40,8 @@ test("installed qualification is restricted to a disposable hosted runner", () = assert.doesNotMatch(firstRunShutdown, /Stop-Process|taskkill\.exe/u); assert.match(packagedApp, /detached: options\.desktopProcessShape === true/u); assert.match(packagedApp, /\? "ignore"[\s\S]*windowsHide: options\.desktopProcessShape === true/u); + assert.match(packagedApp, /process\.platform === "win32"[\s\S]*availableLoopbackPort\(\)/u); + assert.match(packagedApp, /remote-debugging-address=127\.0\.0\.1/u); assert.doesNotMatch(script, /crashpad-handler|disable-crash-reporter/u); assert.match(script, /Retained failed Windows fixture/u); assert.match(workflow, /Retain installed Windows failure evidence/u); From 3360e0e640ef4da9495a9b92e446c42e99dc6595 Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 07:53:50 +0200 Subject: [PATCH 26/40] test(windows): capture installed renderer failures --- src/main/main.ts | 23 +++++++++++++++++ tests/helpers/packaged-app.ts | 25 +++++++++++++++---- .../policy/source-windows-credentials.test.ts | 8 ++++-- tests/policy/source-windows-installed.test.ts | 3 ++- 4 files changed, 51 insertions(+), 8 deletions(-) diff --git a/src/main/main.ts b/src/main/main.ts index 5bb9f5703..6be3d0bbd 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -11,6 +11,7 @@ import { app, autoUpdater, type BrowserWindow, + crashReporter, dialog, Notification, powerMonitor, @@ -181,6 +182,28 @@ import { linuxSecretPortalPath, } from "./linux-portal-keychain.js"; +const windowsQualificationPort = process.platform === "win32" + ? app.commandLine.getSwitchValue("gw-qualification-debugging") + : ""; +const windowsQualificationCrashDumps = + process.env.GW_WINDOWS_QUALIFICATION_CRASH_DUMPS; +if ( + /^\d{4,5}$/u.test(windowsQualificationPort) + && process.env.GITHUB_ACTIONS === "true" + && process.env.RUNNER_ENVIRONMENT === "github-hosted" + && windowsQualificationCrashDumps + && path.win32.isAbsolute(windowsQualificationCrashDumps) +) { + // Hosted installed-package qualification must preserve the original child + // failure rather than Crashpad's secondary "not connected" termination. + // This path is unreachable during ordinary application startup and never + // uploads a report. + app.setPath("crashDumps", windowsQualificationCrashDumps); + crashReporter.start({ uploadToServer: false }); + app.commandLine.appendSwitch("remote-debugging-address", "127.0.0.1"); + app.commandLine.appendSwitch("remote-debugging-port", windowsQualificationPort); +} + const windowsSquirrelStartupHandled = process.platform === "win32" && handleWindowsSquirrelStartup({ argv: process.argv, diff --git a/tests/helpers/packaged-app.ts b/tests/helpers/packaged-app.ts index 401409997..dac81a91e 100644 --- a/tests/helpers/packaged-app.ts +++ b/tests/helpers/packaged-app.ts @@ -154,9 +154,9 @@ export async function launchPackagedApp( `Contents/MacOS/${options.productName}`, ); const activePort = path.join(options.userData, "DevToolsActivePort"); - // Chromium's port-zero discovery path can terminate an installed Electron - // process before main JavaScript starts on hosted Windows. Reserve a normal - // loopback port there; macOS and Linux retain the proven port-file path. + // Windows qualification lets the app initialize local crash capture before + // it creates the DevTools child. Reserve a normal loopback port there; + // macOS and Linux retain the proven port-file path. const requestedPort = process.platform === "win32" ? await availableLoopbackPort() : 0; @@ -172,8 +172,12 @@ export async function launchPackagedApp( ...(options.useDefaultUserData === true ? [] : [`--user-data-dir=${options.userData}`]), - "--remote-debugging-address=127.0.0.1", - `--remote-debugging-port=${requestedPort}`, + ...(process.platform === "win32" + ? [`--gw-qualification-debugging=${requestedPort}`] + : [ + "--remote-debugging-address=127.0.0.1", + "--remote-debugging-port=0", + ]), ...(options.arguments ?? []), ], { @@ -184,6 +188,17 @@ export async function launchPackagedApp( // error state instead of granting a test-only ready lifecycle. GW_REQUIRE_CACHED_CLIENT: "1", GW_BACKGROUND_LAUNCH: "1", + ...(process.platform === "win32" + ? { + GW_WINDOWS_QUALIFICATION_CRASH_DUMPS: path.join( + options.userData, + "..", + "..", + "logs", + "crashpad", + ), + } + : {}), ...options.environment, }, detached: options.desktopProcessShape === true, diff --git a/tests/policy/source-windows-credentials.test.ts b/tests/policy/source-windows-credentials.test.ts index 4ceb32eb3..9f226dde7 100644 --- a/tests/policy/source-windows-credentials.test.ts +++ b/tests/policy/source-windows-credentials.test.ts @@ -18,8 +18,12 @@ test("Windows storage starts from the native LocalAppData known folder", () => { assert.match(main, /explicitUserData\s*\? colocatedStorageRoots/u); }); -test("Windows does not retain process-memory crash reports", () => { - assert.doesNotMatch(main, /crashReporter|crashDumps|\.dmp/u); +test("Windows crash capture is restricted to hosted installed qualification", () => { + assert.match( + main, + /getSwitchValue\("gw-qualification-debugging"\)[\s\S]*GITHUB_ACTIONS === "true"[\s\S]*RUNNER_ENVIRONMENT === "github-hosted"[\s\S]*crashReporter\.start\(\{ uploadToServer: false \}\)/u, + ); + assert.doesNotMatch(main, /crashReporter\.start\(\{[^}]*submitURL/u); }); test("Credential Manager owns only closed application and profile slots", () => { diff --git a/tests/policy/source-windows-installed.test.ts b/tests/policy/source-windows-installed.test.ts index 763561e08..70985bba8 100644 --- a/tests/policy/source-windows-installed.test.ts +++ b/tests/policy/source-windows-installed.test.ts @@ -41,7 +41,8 @@ test("installed qualification is restricted to a disposable hosted runner", () = assert.match(packagedApp, /detached: options\.desktopProcessShape === true/u); assert.match(packagedApp, /\? "ignore"[\s\S]*windowsHide: options\.desktopProcessShape === true/u); assert.match(packagedApp, /process\.platform === "win32"[\s\S]*availableLoopbackPort\(\)/u); - assert.match(packagedApp, /remote-debugging-address=127\.0\.0\.1/u); + assert.match(packagedApp, /gw-qualification-debugging=\$\{requestedPort\}/u); + assert.match(packagedApp, /GW_WINDOWS_QUALIFICATION_CRASH_DUMPS/u); assert.doesNotMatch(script, /crashpad-handler|disable-crash-reporter/u); assert.match(script, /Retained failed Windows fixture/u); assert.match(workflow, /Retain installed Windows failure evidence/u); From 3457cef6298c0bda446369ee67a1c6c9cc00c0fe Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 08:10:46 +0200 Subject: [PATCH 27/40] fix(windows): bind native addons to Electron --- scripts/build.mjs | 4 +++ .../windows-host/win-delay-load-hook.cpp | 26 +++++++++++++++++++ tests/policy/source-native-keychain.test.ts | 3 +++ .../policy/source-windows-credentials.test.ts | 11 ++++++++ 4 files changed, 44 insertions(+) create mode 100644 src/native/windows-host/win-delay-load-hook.cpp diff --git a/scripts/build.mjs b/scripts/build.mjs index 46ace26dd..9c9ded999 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -173,12 +173,16 @@ export function nativeBuildSteps(platform, architecture) { "/Fobuild\\native\\", "/Fdbuild/native/windows-host.pdb", "src/native/windows-host/host.cpp", + "src/native/windows-host/win-delay-load-hook.cpp", "Advapi32.lib", "Shell32.lib", "Ole32.lib", "Wintrust.lib", "build/native/node.lib", "/Fe:build/native/windows-host.node", + "/link", + "/DELAYLOAD:NODE.EXE", + "Delayimp.lib", ], ], [ diff --git a/src/native/windows-host/win-delay-load-hook.cpp b/src/native/windows-host/win-delay-load-hook.cpp new file mode 100644 index 000000000..9b2ee7c44 --- /dev/null +++ b/src/native/windows-host/win-delay-load-hook.cpp @@ -0,0 +1,26 @@ +/** + * Resolve Node-API imports from the executable that loaded this addon. + * Electron applications are renamed for distribution, so loading NODE.EXE + * by name would map a second executable into the process instead of using + * Electron's exported Node-API functions. + */ +#define WIN32_LEAN_AND_MEAN + +#include +#include + +#include + +namespace { + +FARPROC WINAPI ResolveNodeHost(unsigned int event, DelayLoadInfo *info) { + if (event != dliNotePreLoadLibrary || + _stricmp(info->szDll, "NODE.EXE") != 0) { + return nullptr; + } + return reinterpret_cast(GetModuleHandleW(nullptr)); +} + +} // namespace + +decltype(__pfnDliNotifyHook2) __pfnDliNotifyHook2 = ResolveNodeHost; diff --git a/tests/policy/source-native-keychain.test.ts b/tests/policy/source-native-keychain.test.ts index 3253d3860..6f5d98440 100644 --- a/tests/policy/source-native-keychain.test.ts +++ b/tests/policy/source-native-keychain.test.ts @@ -119,6 +119,9 @@ test("Windows and Linux build only their target-native boundaries", () => { ]); assert.ok(windows[0]?.[1].includes("/out:build/native/node.lib")); assert.ok(windows[1]?.[1].includes("build/native/node.lib")); + assert.ok(windows[1]?.[1].includes("src/native/windows-host/win-delay-load-hook.cpp")); + assert.ok(windows[1]?.[1].includes("/DELAYLOAD:NODE.EXE")); + assert.ok(windows[1]?.[1].includes("Delayimp.lib")); assert.ok(windows[1]?.[1].includes("/Fe:build/native/windows-host.node")); assert.ok(windows[2]?.[1].includes("/Fe:build/native/gw-dat-decode.exe")); for (const [, args] of windows.slice(1)) { diff --git a/tests/policy/source-windows-credentials.test.ts b/tests/policy/source-windows-credentials.test.ts index 9f226dde7..5d0946c8e 100644 --- a/tests/policy/source-windows-credentials.test.ts +++ b/tests/policy/source-windows-credentials.test.ts @@ -9,6 +9,10 @@ const native = readFileSync( path.join(root, "src/native/windows-host/host.cpp"), "utf8", ); +const delayLoadHook = readFileSync( + path.join(root, "src/native/windows-host/win-delay-load-hook.cpp"), + "utf8", +); const main = readFileSync(path.join(root, "src/main/main.ts"), "utf8"); test("Windows storage starts from the native LocalAppData known folder", () => { @@ -26,6 +30,13 @@ test("Windows crash capture is restricted to hosted installed qualification", () assert.doesNotMatch(main, /crashReporter\.start\(\{[^}]*submitURL/u); }); +test("the renamed Electron executable owns Node-API imports", () => { + assert.match(delayLoadHook, /dliNotePreLoadLibrary/u); + assert.match(delayLoadHook, /"NODE\.EXE"/u); + assert.match(delayLoadHook, /GetModuleHandleW\(nullptr\)/u); + assert.doesNotMatch(delayLoadHook, /\bLoadLibrary(?:A|W)?\s*\(/u); +}); + test("Credential Manager owns only closed application and profile slots", () => { for (const value of [ "io.github.mat4m0.gwonmac", From ee0994fc6acc0fbd6f2319b41ef7b6ce1b894a2f Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 08:14:31 +0200 Subject: [PATCH 28/40] fix(windows): include delay-load types safely --- src/native/windows-host/win-delay-load-hook.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/native/windows-host/win-delay-load-hook.cpp b/src/native/windows-host/win-delay-load-hook.cpp index 9b2ee7c44..b36ada623 100644 --- a/src/native/windows-host/win-delay-load-hook.cpp +++ b/src/native/windows-host/win-delay-load-hook.cpp @@ -6,8 +6,8 @@ */ #define WIN32_LEAN_AND_MEAN -#include #include +#include #include From ea85414e4df49e2a8fc5fdfee4c60ae5b5b30abc Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 08:25:34 +0200 Subject: [PATCH 29/40] test(windows): acknowledge cached client frames --- scripts/windows-installed-qualification.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/windows-installed-qualification.ts b/scripts/windows-installed-qualification.ts index fbeb131ca..87497ac98 100644 --- a/scripts/windows-installed-qualification.ts +++ b/scripts/windows-installed-qualification.ts @@ -338,9 +338,15 @@ try { assert.ok(mainProfile && secondProfile); const mainGame = await openPackagedProfile(running, mainProfile.id); + // The disposable cached client is a one-byte module with no EGL imports, so + // it cannot submit a graphical frame. Acknowledge the synthetic frame here + // just as the focused Electron first-frame tests do; production renderers + // still own this signal through the first real submitted frame. + await mainGame.evaluate(() => window.gwNative.client.readyToPresent()); await waitForRunning(running, mainProfile.id); await mainGame.evaluate(() => localStorage.setItem("profile-proof", "main")); const secondGame = await openPackagedProfile(running, secondProfile.id); + await secondGame.evaluate(() => window.gwNative.client.readyToPresent()); await waitForRunning(running, secondProfile.id); assert.equal( await secondGame.evaluate(() => localStorage.getItem("profile-proof")), From eccb8546d8a598cf0a54198cc7b53242c64756ed Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 08:32:37 +0200 Subject: [PATCH 30/40] test(windows): remove temporary crash capture --- src/main/main.ts | 23 ----------------- tests/helpers/packaged-app.ts | 25 ++++--------------- .../policy/source-windows-credentials.test.ts | 8 ------ tests/policy/source-windows-installed.test.ts | 5 ++-- 4 files changed, 8 insertions(+), 53 deletions(-) diff --git a/src/main/main.ts b/src/main/main.ts index 6be3d0bbd..5bb9f5703 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -11,7 +11,6 @@ import { app, autoUpdater, type BrowserWindow, - crashReporter, dialog, Notification, powerMonitor, @@ -182,28 +181,6 @@ import { linuxSecretPortalPath, } from "./linux-portal-keychain.js"; -const windowsQualificationPort = process.platform === "win32" - ? app.commandLine.getSwitchValue("gw-qualification-debugging") - : ""; -const windowsQualificationCrashDumps = - process.env.GW_WINDOWS_QUALIFICATION_CRASH_DUMPS; -if ( - /^\d{4,5}$/u.test(windowsQualificationPort) - && process.env.GITHUB_ACTIONS === "true" - && process.env.RUNNER_ENVIRONMENT === "github-hosted" - && windowsQualificationCrashDumps - && path.win32.isAbsolute(windowsQualificationCrashDumps) -) { - // Hosted installed-package qualification must preserve the original child - // failure rather than Crashpad's secondary "not connected" termination. - // This path is unreachable during ordinary application startup and never - // uploads a report. - app.setPath("crashDumps", windowsQualificationCrashDumps); - crashReporter.start({ uploadToServer: false }); - app.commandLine.appendSwitch("remote-debugging-address", "127.0.0.1"); - app.commandLine.appendSwitch("remote-debugging-port", windowsQualificationPort); -} - const windowsSquirrelStartupHandled = process.platform === "win32" && handleWindowsSquirrelStartup({ argv: process.argv, diff --git a/tests/helpers/packaged-app.ts b/tests/helpers/packaged-app.ts index dac81a91e..200c935f6 100644 --- a/tests/helpers/packaged-app.ts +++ b/tests/helpers/packaged-app.ts @@ -154,9 +154,9 @@ export async function launchPackagedApp( `Contents/MacOS/${options.productName}`, ); const activePort = path.join(options.userData, "DevToolsActivePort"); - // Windows qualification lets the app initialize local crash capture before - // it creates the DevTools child. Reserve a normal loopback port there; - // macOS and Linux retain the proven port-file path. + // Windows needs a reserved fixed port because Chromium does not publish its + // ephemeral DevTools port reliably for an installed desktop process. macOS + // and Linux retain the proven port-file path. const requestedPort = process.platform === "win32" ? await availableLoopbackPort() : 0; @@ -172,12 +172,8 @@ export async function launchPackagedApp( ...(options.useDefaultUserData === true ? [] : [`--user-data-dir=${options.userData}`]), - ...(process.platform === "win32" - ? [`--gw-qualification-debugging=${requestedPort}`] - : [ - "--remote-debugging-address=127.0.0.1", - "--remote-debugging-port=0", - ]), + "--remote-debugging-address=127.0.0.1", + `--remote-debugging-port=${requestedPort}`, ...(options.arguments ?? []), ], { @@ -188,17 +184,6 @@ export async function launchPackagedApp( // error state instead of granting a test-only ready lifecycle. GW_REQUIRE_CACHED_CLIENT: "1", GW_BACKGROUND_LAUNCH: "1", - ...(process.platform === "win32" - ? { - GW_WINDOWS_QUALIFICATION_CRASH_DUMPS: path.join( - options.userData, - "..", - "..", - "logs", - "crashpad", - ), - } - : {}), ...options.environment, }, detached: options.desktopProcessShape === true, diff --git a/tests/policy/source-windows-credentials.test.ts b/tests/policy/source-windows-credentials.test.ts index 5d0946c8e..23c21ad28 100644 --- a/tests/policy/source-windows-credentials.test.ts +++ b/tests/policy/source-windows-credentials.test.ts @@ -22,14 +22,6 @@ test("Windows storage starts from the native LocalAppData known folder", () => { assert.match(main, /explicitUserData\s*\? colocatedStorageRoots/u); }); -test("Windows crash capture is restricted to hosted installed qualification", () => { - assert.match( - main, - /getSwitchValue\("gw-qualification-debugging"\)[\s\S]*GITHUB_ACTIONS === "true"[\s\S]*RUNNER_ENVIRONMENT === "github-hosted"[\s\S]*crashReporter\.start\(\{ uploadToServer: false \}\)/u, - ); - assert.doesNotMatch(main, /crashReporter\.start\(\{[^}]*submitURL/u); -}); - test("the renamed Electron executable owns Node-API imports", () => { assert.match(delayLoadHook, /dliNotePreLoadLibrary/u); assert.match(delayLoadHook, /"NODE\.EXE"/u); diff --git a/tests/policy/source-windows-installed.test.ts b/tests/policy/source-windows-installed.test.ts index 70985bba8..b6ad4b58a 100644 --- a/tests/policy/source-windows-installed.test.ts +++ b/tests/policy/source-windows-installed.test.ts @@ -41,8 +41,9 @@ test("installed qualification is restricted to a disposable hosted runner", () = assert.match(packagedApp, /detached: options\.desktopProcessShape === true/u); assert.match(packagedApp, /\? "ignore"[\s\S]*windowsHide: options\.desktopProcessShape === true/u); assert.match(packagedApp, /process\.platform === "win32"[\s\S]*availableLoopbackPort\(\)/u); - assert.match(packagedApp, /gw-qualification-debugging=\$\{requestedPort\}/u); - assert.match(packagedApp, /GW_WINDOWS_QUALIFICATION_CRASH_DUMPS/u); + assert.match(packagedApp, /--remote-debugging-address=127\.0\.0\.1/u); + assert.match(packagedApp, /--remote-debugging-port=\$\{requestedPort\}/u); + assert.doesNotMatch(packagedApp, /gw-qualification-debugging|GW_WINDOWS_QUALIFICATION_CRASH_DUMPS/u); assert.doesNotMatch(script, /crashpad-handler|disable-crash-reporter/u); assert.match(script, /Retained failed Windows fixture/u); assert.match(workflow, /Retain installed Windows failure evidence/u); From 0e952cc77375e5ebd9b3ed7bbb6d5466a3404170 Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 08:51:56 +0200 Subject: [PATCH 31/40] test(linux): acknowledge cached client frames --- scripts/linux-installed-qualification.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/linux-installed-qualification.ts b/scripts/linux-installed-qualification.ts index b5b5746cf..1581636ee 100644 --- a/scripts/linux-installed-qualification.ts +++ b/scripts/linux-installed-qualification.ts @@ -243,6 +243,7 @@ try { { cause: error }, ); } + await mainGame.evaluate(() => window.gwNative.client.readyToPresent()); await waitForRunning(running, mainProfile.id); await mainGame.evaluate(() => localStorage.setItem("profile-proof", "main")); let secondGame: Awaited>; @@ -261,6 +262,7 @@ try { { cause: error }, ); } + await secondGame.evaluate(() => window.gwNative.client.readyToPresent()); await waitForRunning(running, secondProfile.id); assert.equal( await secondGame.evaluate(() => localStorage.getItem("profile-proof")), @@ -323,6 +325,7 @@ try { ["Main account", "Second account"], ); const restartedMain = await openPackagedProfile(running, mainProfile.id); + await restartedMain.evaluate(() => window.gwNative.client.readyToPresent()); if (secretQualification) { assert.equal( (await restartedMain.evaluate(() => window.gwNative.credentials.load()))?.username, From 951a110d27b7549c3224dd6965b435b30a752065 Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 08:51:56 +0200 Subject: [PATCH 32/40] test(game): wait for stable crash fallback state --- tests/electron/diagnostics.spec.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/electron/diagnostics.spec.ts b/tests/electron/diagnostics.spec.ts index 3a9a20bd7..e6a3fa8cc 100644 --- a/tests/electron/diagnostics.spec.ts +++ b/tests/electron/diagnostics.spec.ts @@ -636,6 +636,12 @@ test.describe("diagnostics", () => { const fixture = await launchOffline("gw-crash-panel-e2e-"); try { const { app, page } = fixture; + // The default fixture intentionally has no cached client. Let its + // asynchronous preparation reach the stable offline failure before this + // test takes ownership of the fallback label. + await expect(page.locator("#loading-label")).toHaveText( + "Game data could not be prepared.", + ); await page.evaluate(() => window.gwLoading.failCrash(1)); await expect(page.locator("#loading-label")).toBeVisible(); await expect(page.locator("#loading-retry, #loading-report")).toHaveCount(0); From b84d0c56409c300941fbfffd6a125e14aeee15d4 Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 09:08:45 +0200 Subject: [PATCH 33/40] ci(linux): seed desktop portal activation environment --- .github/workflows/linux-flatpak-build.yml | 9 ++++++--- .github/workflows/linux-signed-qualification.yml | 9 ++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/linux-flatpak-build.yml b/.github/workflows/linux-flatpak-build.yml index b248b08b9..31e8fb6aa 100644 --- a/.github/workflows/linux-flatpak-build.yml +++ b/.github/workflows/linux-flatpak-build.yml @@ -159,9 +159,12 @@ jobs: DESKTOP: ${{ matrix.desktop }} GW_LINUX_SECRET_QUALIFICATION: "1" run: | - xvfb-run -a dbus-run-session -- bash -euc ' - export XDG_CURRENT_DESKTOP="${DESKTOP^^}" - export XDG_SESSION_TYPE=x11 + desktop="${DESKTOP^^}" + xvfb-run -a env \ + XDG_CURRENT_DESKTOP="$desktop" \ + XDG_SESSION_DESKTOP="$desktop" \ + XDG_SESSION_TYPE=x11 \ + dbus-run-session -- bash -euc ' if [ "$DESKTOP" = gnome ]; then eval "$(printf "\n" | gnome-keyring-daemon --unlock --components=secrets)" fi diff --git a/.github/workflows/linux-signed-qualification.yml b/.github/workflows/linux-signed-qualification.yml index 8c952cfd4..9b5774488 100644 --- a/.github/workflows/linux-signed-qualification.yml +++ b/.github/workflows/linux-signed-qualification.yml @@ -69,9 +69,12 @@ jobs: DESKTOP: ${{ matrix.desktop }} GW_LINUX_SECRET_QUALIFICATION: "1" run: | - xvfb-run -a dbus-run-session -- bash -euc ' - export XDG_CURRENT_DESKTOP="${DESKTOP^^}" - export XDG_SESSION_TYPE=x11 + desktop="${DESKTOP^^}" + xvfb-run -a env \ + XDG_CURRENT_DESKTOP="$desktop" \ + XDG_SESSION_DESKTOP="$desktop" \ + XDG_SESSION_TYPE=x11 \ + dbus-run-session -- bash -euc ' if [ "$DESKTOP" = gnome ]; then eval "$(printf "\n" | gnome-keyring-daemon --unlock --components=secrets)" fi From 010ac655cb60f02078d20474488543ef1f50236b Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 09:14:41 +0200 Subject: [PATCH 34/40] test(linux): enforce portal activation order --- tests/policy/source-linux-flatpak.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/policy/source-linux-flatpak.test.ts b/tests/policy/source-linux-flatpak.test.ts index 8a83c58f4..060b27a93 100644 --- a/tests/policy/source-linux-flatpak.test.ts +++ b/tests/policy/source-linux-flatpak.test.ts @@ -70,7 +70,15 @@ describe("Linux Flatpak package", () => { it("starts desktop session buses inside their X display", () => { for (const workflow of [buildWorkflow, signedWorkflow]) { - assert.match(workflow, /xvfb-run -a dbus-run-session --/u); + const display = workflow.indexOf("xvfb-run -a env"); + const desktop = workflow.indexOf("XDG_CURRENT_DESKTOP=", display); + const sessionDesktop = workflow.indexOf("XDG_SESSION_DESKTOP=", desktop); + const sessionBus = workflow.indexOf("dbus-run-session --", sessionDesktop); + assert.ok(display >= 0, "the desktop qualification does not start Xvfb"); + assert.ok( + display < desktop && desktop < sessionDesktop && sessionDesktop < sessionBus, + "the D-Bus activation environment does not inherit the selected desktop", + ); assert.doesNotMatch(workflow, /dbus-run-session -- xvfb-run/u); } }); From f5a599a5f74f89252830d9e997cd06123466d2d0 Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 09:31:28 +0200 Subject: [PATCH 35/40] test(input): focus the owned game before pointer lock --- tests/electron/input-camera.spec.ts | 37 +++++++++++++---------------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/tests/electron/input-camera.spec.ts b/tests/electron/input-camera.spec.ts index c6c6cf2d9..3cc43e385 100644 --- a/tests/electron/input-camera.spec.ts +++ b/tests/electron/input-camera.spec.ts @@ -28,6 +28,22 @@ test.describe("renderer camera input", () => { }); try { const { app, page } = fixture; + await app.evaluate(async ({ app: electronApp, BrowserWindow }) => { + const win = BrowserWindow.getAllWindows().find( + (candidate) => candidate.webContents.getURL() === "gw://app/", + ); + if (!win) throw new Error("game window is missing"); + win.show(); + electronApp.focus({ steal: true }); + win.focus(); + const deadline = Date.now() + 5_000; + while (!win.isFocused()) { + if (Date.now() >= deadline) { + throw new Error("game window did not receive focus"); + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + }); await startGameInput(page); await page.evaluate(() => { const canvas = globalThis.document.getElementById("canvas"); @@ -42,27 +58,6 @@ test.describe("renderer camera input", () => { }); const canvas = page.locator("#canvas"); const box = await boxOf(canvas); - await app.evaluate(async ({ app: electronApp, BrowserWindow }) => { - const win = BrowserWindow.getAllWindows()[0]; - if (!win) throw new Error("game window is missing"); - if (!win.isFocused()) { - const focused = new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - win.removeListener("focus", onFocus); - reject(new Error("game window did not receive focus")); - }, 5_000); - const onFocus = () => { - clearTimeout(timeout); - resolve(); - }; - win.once("focus", onFocus); - }); - win.show(); - electronApp.focus({ steal: true }); - win.focus(); - await focused; - } - }); await canvas.focus(); await expect.poll(() => page.evaluate(() => ({ active: document.activeElement?.id, From db643e16305d2702f8d510fef4045490f1b5defa Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 09:31:44 +0200 Subject: [PATCH 36/40] test(linux): qualify portal secrets on KDE --- .github/workflows/linux-flatpak-build.yml | 9 +++++---- .github/workflows/linux-signed-qualification.yml | 9 +++++---- scripts/linux-installed-qualification.ts | 10 +++++++--- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/.github/workflows/linux-flatpak-build.yml b/.github/workflows/linux-flatpak-build.yml index 31e8fb6aa..c1fb47e56 100644 --- a/.github/workflows/linux-flatpak-build.yml +++ b/.github/workflows/linux-flatpak-build.yml @@ -145,7 +145,10 @@ jobs: if: matrix.desktop == 'kde' run: | sudo apt-get update - sudo apt-get install -y flatpak xvfb dbus-x11 plasma-workspace xdg-desktop-portal xdg-desktop-portal-kde kwalletmanager + # Noble's KWallet 5 predates its Secret portal backend. GNOME Keyring + # supplies that standard interface for this KDE integration gate; + # current KWallet 6 supplies the same interface on supported systems. + sudo apt-get install -y flatpak xvfb dbus-x11 plasma-workspace xdg-desktop-portal xdg-desktop-portal-kde kwalletmanager gnome-keyring - name: Install the exact GPG-verified package run: | flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo @@ -165,8 +168,6 @@ jobs: XDG_SESSION_DESKTOP="$desktop" \ XDG_SESSION_TYPE=x11 \ dbus-run-session -- bash -euc ' - if [ "$DESKTOP" = gnome ]; then - eval "$(printf "\n" | gnome-keyring-daemon --unlock --components=secrets)" - fi + eval "$(printf "\n" | gnome-keyring-daemon --unlock --components=secrets)" pnpm test:linux-installed ' diff --git a/.github/workflows/linux-signed-qualification.yml b/.github/workflows/linux-signed-qualification.yml index 9b5774488..cda626920 100644 --- a/.github/workflows/linux-signed-qualification.yml +++ b/.github/workflows/linux-signed-qualification.yml @@ -32,7 +32,10 @@ jobs: if: matrix.desktop == 'kde' run: | sudo apt-get update - sudo apt-get install -y flatpak flatpak-builder g++ libglib2.0-dev gnupg xvfb dbus-x11 plasma-workspace xdg-desktop-portal xdg-desktop-portal-kde kwalletmanager + # Noble's KWallet 5 predates its Secret portal backend. GNOME Keyring + # supplies that standard interface for this KDE integration gate; + # current KWallet 6 supplies the same interface on supported systems. + sudo apt-get install -y flatpak flatpak-builder g++ libglib2.0-dev gnupg xvfb dbus-x11 plasma-workspace xdg-desktop-portal xdg-desktop-portal-kde kwalletmanager gnome-keyring - name: Install Flatpak runtimes run: | flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo @@ -75,8 +78,6 @@ jobs: XDG_SESSION_DESKTOP="$desktop" \ XDG_SESSION_TYPE=x11 \ dbus-run-session -- bash -euc ' - if [ "$DESKTOP" = gnome ]; then - eval "$(printf "\n" | gnome-keyring-daemon --unlock --components=secrets)" - fi + eval "$(printf "\n" | gnome-keyring-daemon --unlock --components=secrets)" pnpm test:linux-installed ' diff --git a/scripts/linux-installed-qualification.ts b/scripts/linux-installed-qualification.ts index 1581636ee..2b5b2205f 100644 --- a/scripts/linux-installed-qualification.ts +++ b/scripts/linux-installed-qualification.ts @@ -287,10 +287,14 @@ try { (await secondGame.evaluate(() => window.gwNative.credentials.load()))?.username, "second-linux-qualified@example.invalid", ); - const encrypted = await readFile( - path.join(storage.data, "secrets", `multi.${mainProfile.id}.arenaNetCredentials.secret`), + const mainEncrypted = await readFile( + path.join(storage.data, "secrets", "arenaNetCredentials.secret"), ); - assert.equal(encrypted.includes(Buffer.from("synthetic-main-password")), false); + const secondEncrypted = await readFile( + path.join(storage.data, "secrets", `multi.${secondProfile.id}.arenaNetCredentials.secret`), + ); + assert.equal(mainEncrypted.includes(Buffer.from("synthetic-main-password")), false); + assert.equal(secondEncrypted.includes(Buffer.from("synthetic-second-password")), false); } const tools = await launcher.evaluate(async () => From fcab017dde1088184eede8d78332a64a870d9e0a Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 10:06:23 +0200 Subject: [PATCH 37/40] test(linux): select the Secret portal on KDE --- .github/workflows/linux-flatpak-build.yml | 2 ++ .github/workflows/linux-signed-qualification.yml | 2 ++ tests/policy/source-linux-flatpak.test.ts | 4 ++++ 3 files changed, 8 insertions(+) diff --git a/.github/workflows/linux-flatpak-build.yml b/.github/workflows/linux-flatpak-build.yml index c1fb47e56..e5ed90873 100644 --- a/.github/workflows/linux-flatpak-build.yml +++ b/.github/workflows/linux-flatpak-build.yml @@ -149,6 +149,8 @@ jobs: # supplies that standard interface for this KDE integration gate; # current KWallet 6 supplies the same interface on supported systems. sudo apt-get install -y flatpak xvfb dbus-x11 plasma-workspace xdg-desktop-portal xdg-desktop-portal-kde kwalletmanager gnome-keyring + mkdir -p "$HOME/.config/xdg-desktop-portal" + printf '[preferred]\ndefault=kde;gtk;\norg.freedesktop.impl.portal.Secret=gnome-keyring;\n' > "$HOME/.config/xdg-desktop-portal/kde-portals.conf" - name: Install the exact GPG-verified package run: | flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo diff --git a/.github/workflows/linux-signed-qualification.yml b/.github/workflows/linux-signed-qualification.yml index cda626920..eca91a78d 100644 --- a/.github/workflows/linux-signed-qualification.yml +++ b/.github/workflows/linux-signed-qualification.yml @@ -36,6 +36,8 @@ jobs: # supplies that standard interface for this KDE integration gate; # current KWallet 6 supplies the same interface on supported systems. sudo apt-get install -y flatpak flatpak-builder g++ libglib2.0-dev gnupg xvfb dbus-x11 plasma-workspace xdg-desktop-portal xdg-desktop-portal-kde kwalletmanager gnome-keyring + mkdir -p "$HOME/.config/xdg-desktop-portal" + printf '[preferred]\ndefault=kde;gtk;\norg.freedesktop.impl.portal.Secret=gnome-keyring;\n' > "$HOME/.config/xdg-desktop-portal/kde-portals.conf" - name: Install Flatpak runtimes run: | flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo diff --git a/tests/policy/source-linux-flatpak.test.ts b/tests/policy/source-linux-flatpak.test.ts index 060b27a93..155efb4ec 100644 --- a/tests/policy/source-linux-flatpak.test.ts +++ b/tests/policy/source-linux-flatpak.test.ts @@ -89,5 +89,9 @@ describe("Linux Flatpak package", () => { assert.match(buildWorkflow, /GW_LINUX_SECRET_QUALIFICATION: "1"/u); assert.match(buildWorkflow, /gnome-keyring-daemon --unlock/u); assert.match(buildWorkflow, /xdg-desktop-portal-kde/u); + for (const workflow of [buildWorkflow, signedWorkflow]) { + assert.match(workflow, /kde-portals\.conf/u); + assert.match(workflow, /org\.freedesktop\.impl\.portal\.Secret=gnome-keyring/u); + } }); }); From b66e0a823dcad24c2f41ac7d7e4827f7e5ea0de6 Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 10:06:34 +0200 Subject: [PATCH 38/40] test(electron): allow loaded runners to finish startup --- tests/electron/input-helpers.ts | 5 +++-- tests/electron/playwright.config.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/electron/input-helpers.ts b/tests/electron/input-helpers.ts index a862d10b6..e96b33667 100644 --- a/tests/electron/input-helpers.ts +++ b/tests/electron/input-helpers.ts @@ -13,8 +13,9 @@ export async function startGameInput(page: Page) { // A full Electron run can have several recently closed renderer processes // draining while the next cached fixture starts. Wait for the explicit // renderer-owned signal instead of inventing a short product startup - // promise. This bound covers loaded CI without weakening the assertion. + // promise. Leave the test's outer budget available for cleanup if a loaded + // hosted runner needs longer than its usual few seconds. await expect(canvas).toHaveAttribute("data-input-ready", "true", { - timeout: 30_000, + timeout: 45_000, }); } diff --git a/tests/electron/playwright.config.ts b/tests/electron/playwright.config.ts index caafae7d6..52251ee61 100644 --- a/tests/electron/playwright.config.ts +++ b/tests/electron/playwright.config.ts @@ -4,7 +4,7 @@ export default defineConfig({ testDir: ".", testMatch: /.*\.spec\.ts$/, globalSetup: "./global-setup.ts", - timeout: 30_000, + timeout: 60_000, workers: 1, ...(process.env.CI ? { maxFailures: 1 } : {}), forbidOnly: !!process.env.CI, From 1dc73ff7313fa524adeb094e669b70153dbb5719 Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 10:18:36 +0200 Subject: [PATCH 39/40] ci(linux): allow manual Flatpak qualification --- .github/workflows/linux-flatpak-build.yml | 1 + tests/policy/source-linux-flatpak.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/linux-flatpak-build.yml b/.github/workflows/linux-flatpak-build.yml index e5ed90873..6c7bdf95e 100644 --- a/.github/workflows/linux-flatpak-build.yml +++ b/.github/workflows/linux-flatpak-build.yml @@ -4,6 +4,7 @@ on: pull_request: push: branches: [main] + workflow_dispatch: permissions: contents: read diff --git a/tests/policy/source-linux-flatpak.test.ts b/tests/policy/source-linux-flatpak.test.ts index 155efb4ec..d8d24bc23 100644 --- a/tests/policy/source-linux-flatpak.test.ts +++ b/tests/policy/source-linux-flatpak.test.ts @@ -41,6 +41,7 @@ describe("Linux Flatpak package", () => { }); it("installs only GPG-verified repository output in qualification", () => { + assert.match(buildWorkflow, /workflow_dispatch:/u); for (const workflow of [buildWorkflow, signedWorkflow]) { assert.match(workflow, /--gpg-sign=/u); assert.match(workflow, /build-update-repo --gpg-sign=/u); From 56bf6424204498d32d1673c9a38c591b471246bb Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 10:38:52 +0200 Subject: [PATCH 40/40] test(input): finish renderer startup before activation --- tests/electron/input-camera.spec.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/electron/input-camera.spec.ts b/tests/electron/input-camera.spec.ts index 3cc43e385..c792f1f69 100644 --- a/tests/electron/input-camera.spec.ts +++ b/tests/electron/input-camera.spec.ts @@ -28,6 +28,11 @@ test.describe("renderer camera input", () => { }); try { const { app, page } = fixture; + // Let the hidden renderer finish installing the game input host before + // changing native window activation. Pointer lock needs focus only for + // the gesture itself; focusing a half-started window adds an unrelated + // AppKit presentation race to this capability test. + await startGameInput(page); await app.evaluate(async ({ app: electronApp, BrowserWindow }) => { const win = BrowserWindow.getAllWindows().find( (candidate) => candidate.webContents.getURL() === "gw://app/", @@ -44,7 +49,6 @@ test.describe("renderer camera input", () => { await new Promise((resolve) => setTimeout(resolve, 25)); } }); - await startGameInput(page); await page.evaluate(() => { const canvas = globalThis.document.getElementById("canvas"); const loading = globalThis.document.getElementById("loading");