diff --git a/.github/workflows/portable-native-build.yml b/.github/workflows/portable-native-build.yml index 97271e460..779f43a20 100644 --- a/.github/workflows/portable-native-build.yml +++ b/.github/workflows/portable-native-build.yml @@ -12,6 +12,7 @@ on: - "scripts/platform-storage-probe.ts" - "scripts/windows-credential-probe.ts" - "scripts/windows-icon.ts" + - "scripts/windows-installed-qualification.ts" - "scripts/windows-package-probe.ts" - "forge.config.ts" - "src/**" @@ -84,3 +85,6 @@ jobs: if ($signature.Status -ne "NotSigned") { throw "Local package unexpectedly has status $($signature.Status)" } + - name: Qualify the installed Windows package + if: runner.os == 'Windows' + run: pnpm test:windows-installed diff --git a/.github/workflows/windows-signed-qualification.yml b/.github/workflows/windows-signed-qualification.yml new file mode 100644 index 000000000..0d14d8428 --- /dev/null +++ b/.github/workflows/windows-signed-qualification.yml @@ -0,0 +1,65 @@ +# Build and exercise a signed package without uploading or publishing it. +name: Windows signed qualification + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + qualify: + runs-on: windows-2025 + timeout-minutes: 45 + environment: windows-release + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: pnpm/action-setup@d15e628ca66d93ee5f352c71671a7bc6a97af5c9 # v6.0.8 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24" + cache: pnpm + - uses: ilammy/msvc-dev-cmd@a102174a2b586eec2ea151a69e6fd14404a8ce7c # v1.13.0 + with: + arch: x64 + - name: Install the pinned Rust toolchain + run: rustup toolchain install + - run: pnpm install --frozen-lockfile + - name: Materialize the temporary signing certificate + shell: pwsh + env: + WINDOWS_SIGNING_PFX_BASE64: ${{ secrets.WINDOWS_SIGNING_PFX_BASE64 }} + run: | + if (-not $env:WINDOWS_SIGNING_PFX_BASE64) { + throw "WINDOWS_SIGNING_PFX_BASE64 is required" + } + $certificate = Join-Path $env:RUNNER_TEMP "gwonmac-windows-signing.pfx" + [IO.File]::WriteAllBytes( + $certificate, + [Convert]::FromBase64String($env:WINDOWS_SIGNING_PFX_BASE64) + ) + "WINDOWS_CERTIFICATE_FILE=$certificate" | Out-File $env:GITHUB_ENV -Append + - name: Build the signed package + env: + GW_PACKAGE_INTENT: release + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_SIGNING_PASSWORD }} + run: pnpm make -- --platform=win32 --arch=x64 + - name: Verify the signed installer + shell: pwsh + run: | + $setup = Get-ChildItem "out/make/squirrel.windows/x64/*Setup.exe" + if ($setup.Count -ne 1) { throw "Expected one Setup executable" } + $signature = Get-AuthenticodeSignature $setup.FullName + if ($signature.Status -ne "Valid") { + throw "Signed Setup has status $($signature.Status)" + } + - name: Qualify signed install, replacement, and credentials + env: + GW_WINDOWS_SIGNED_QUALIFICATION: "1" + run: pnpm test:windows-installed + - name: Remove the temporary signing certificate + if: always() + shell: pwsh + run: Remove-Item "$env:RUNNER_TEMP/gwonmac-windows-signing.pfx" -Force -ErrorAction SilentlyContinue diff --git a/package.json b/package.json index 3fa40970d..b56d54f01 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "launcher:fixture": "pnpm build && node --import ./scripts/ts-hook.mjs scripts/launcher-fixture.ts", "storage:probe": "node --import ./scripts/ts-hook.mjs scripts/platform-storage-probe.ts", "test:windows-credentials": "node --import ./scripts/ts-hook.mjs scripts/windows-credential-probe.ts", + "test:windows-installed": "node --import ./scripts/ts-hook.mjs scripts/windows-installed-qualification.ts", "test:windows-package": "node --import ./scripts/ts-hook.mjs scripts/windows-package-probe.ts", "test:signed-dev": "node --import ./scripts/ts-hook.mjs scripts/run-signed-dev.ts --test-keychain", "tools:dev": "pnpm --filter @gwonmac/tools-ui dev", diff --git a/scripts/windows-installed-qualification.ts b/scripts/windows-installed-qualification.ts new file mode 100644 index 000000000..399b9ea18 --- /dev/null +++ b/scripts/windows-installed-qualification.ts @@ -0,0 +1,361 @@ +/** + * Install the exact unsigned Squirrel.Windows artifact on a fresh hosted + * runner and exercise its real LocalAppData, launcher, game, and uninstall. + */ +import { execFile } from "node:child_process"; +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { promisify } from "node:util"; +import { + closePackagedApp, + launchPackagedApp, + openPackagedProfile, + type RunningPackagedApp, +} from "../tests/helpers/packaged-app.js"; +import { seedCachedClient } from "../tests/helpers/cached-client.js"; +import { windowsStorageRoots } from "../src/main/core/paths.js"; +import { DISTRIBUTION_CHANNEL_CONFIG } from "../src/shared/distribution-channel.js"; +import type { ProfileId } from "../src/shared/multiple-accounts.js"; +import { releaseUpdateArtifactName } from "../src/shared/project-identity.js"; + +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"; + +function hostedWindowsRunner(): string { + if ( + process.platform !== "win32" + || process.arch !== "x64" + || process.env.GITHUB_ACTIONS !== "true" + || process.env.RUNNER_ENVIRONMENT !== "github-hosted" + ) { + throw new Error( + "The installed Windows qualification runs only on a fresh GitHub-hosted Windows x64 runner", + ); + } + const localAppData = process.env.LOCALAPPDATA; + if (!localAppData || !path.win32.isAbsolute(localAppData)) { + throw new Error("LOCALAPPDATA is not an absolute Windows path"); + } + return localAppData; +} + +async function oneInstalledExecutable( + packageRoot: string, +): Promise { + const candidates: string[] = []; + for (const entry of await readdir(packageRoot, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.startsWith("app-")) continue; + const executable = path.join( + packageRoot, + entry.name, + `${release.productName}.exe`, + ); + if (existsSync(executable)) candidates.push(executable); + } + assert.equal( + candidates.length, + 1, + "the first Squirrel install must contain one application version", + ); + return candidates[0]!; +} + +async function waitForRunning( + running: RunningPackagedApp, + profileId: ProfileId, +): Promise { + const launcher = running.launcherPage; + assert.ok(launcher, "the installed app did not expose its launcher"); + await launcher.waitForFunction(async (id) => + (await window.launcherNative.state.get()).profiles.find( + (profile) => profile.id === id, + )?.state === "running" + , profileId, { timeout: 30_000 }); +} + +async function uninstall(updateExecutable: string): Promise { + await execFileAsync(updateExecutable, ["--uninstall", "-s"], { + timeout: 120_000, + windowsHide: true, + }); +} + +const localAppData = hostedWindowsRunner(); +const packageRoot = path.join(localAppData, release.windowsPackageId); +const storage = windowsStorageRoots(localAppData); +const setup = path.join( + root, + "out", + "make", + "squirrel.windows", + "x64", + releaseUpdateArtifactName( + (JSON.parse(await readFile(path.join(root, "package.json"), "utf8")) as { + version: string; + }).version, + "win32-x64", + ), +); +assert.equal(existsSync(setup), true, "the Windows Setup executable is missing"); +for (const candidate of [packageRoot, path.dirname(storage.config)]) { + assert.equal( + existsSync(candidate), + false, + `refusing to replace a pre-existing Windows fixture root: ${candidate}`, + ); +} + +let running: RunningPackagedApp | null = null; +let installedExecutable: string | null = null; +try { + await execFileAsync(setup, ["--silent"], { + timeout: 120_000, + windowsHide: true, + }); + installedExecutable = await oneInstalledExecutable(packageRoot); + const updateExecutable = path.join(packageRoot, "Update.exe"); + assert.equal(existsSync(updateExecutable), true, "Squirrel Update.exe is missing"); + + await Promise.all([ + mkdir(storage.config, { recursive: true }), + mkdir(storage.data, { recursive: true }), + mkdir(storage.cache, { recursive: true }), + mkdir(storage.state, { recursive: true }), + mkdir(storage.logs, { recursive: true }), + mkdir(storage.sessions, { recursive: true }), + ]); + await writeFile( + path.join(storage.config, "launcher-mode.json"), + `${JSON.stringify({ formatVersion: 1, mode: "single" })}\n`, + ); + await writeFile( + path.join(storage.config, "settings.json"), + `${JSON.stringify({ autoCheckUpdates: false, gwonmacTools: true })}\n`, + ); + await seedCachedClient({ + artifacts: path.join(storage.cache, "game", "artifacts"), + userData: storage.sessions, + }); + + running = await launchPackagedApp({ + appPath: packageRoot, + executablePath: installedExecutable, + productName: release.productName, + userData: storage.sessions, + // 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. + arguments: [ + "--disable-gpu", + "--disable-crash-reporter", + "--enable-logging=stderr", + ...(signedQualification ? [] : ["--gw-volatile-secrets"]), + ], + environment: { ELECTRON_ENABLE_LOGGING: "1" }, + useDefaultUserData: true, + }); + const launcher = running.launcherPage; + assert.ok(launcher, "the installed package did not open the Vue launcher"); + await launcher.locator('nav[aria-label="Main navigation"]').waitFor(); + const initial = await launcher.evaluate(async () => { + const snapshot = await window.launcherNative.state.get(); + return { + platform: snapshot.platform, + setup: snapshot.experience.setup, + profiles: snapshot.profiles.map(({ id, name }) => ({ id, name })), + }; + }); + assert.equal(initial.platform, "windows"); + assert.equal(initial.setup, "complete"); + assert.deepEqual(initial.profiles.map(({ name }) => name), ["Main account"]); + + await launcher.evaluate(() => + window.launcherNative.profiles.create({ name: "Second account" }) + ); + const profiles = await launcher.evaluate(async () => + (await window.launcherNative.state.get()).profiles.map( + ({ id, name }) => ({ id, name }), + ) + ); + assert.deepEqual(profiles.map(({ name }) => name), [ + "Main account", + "Second account", + ]); + const [mainProfile, secondProfile] = profiles; + assert.ok(mainProfile && secondProfile); + + const mainGame = await openPackagedProfile(running, mainProfile.id); + await waitForRunning(running, mainProfile.id); + await mainGame.evaluate(() => localStorage.setItem("profile-proof", "main")); + const secondGame = await openPackagedProfile(running, secondProfile.id); + await waitForRunning(running, secondProfile.id); + assert.equal( + await secondGame.evaluate(() => localStorage.getItem("profile-proof")), + null, + "two installed profiles shared browser storage", + ); + await secondGame.evaluate(() => localStorage.setItem("profile-proof", "second")); + assert.equal( + await mainGame.evaluate(() => localStorage.getItem("profile-proof")), + "main", + ); + if (signedQualification) { + await mainGame.evaluate(() => window.gwNative.credentials.save({ + username: "main-qualified@example.invalid", + password: "synthetic-main-password", + })); + await secondGame.evaluate(() => window.gwNative.credentials.save({ + username: "second-qualified@example.invalid", + password: "synthetic-second-password", + })); + assert.deepEqual(await mainGame.evaluate(() => window.gwNative.credentials.load()), { + username: "main-qualified@example.invalid", + password: "synthetic-main-password", + }); + assert.deepEqual(await secondGame.evaluate(() => window.gwNative.credentials.load()), { + username: "second-qualified@example.invalid", + password: "synthetic-second-password", + }); + } + + const beforeShow = running.browser.contexts().flatMap( + (context) => context.pages(), + ).length; + await launcher.evaluate((id) => window.launcherNative.profiles.show(id), mainProfile.id); + assert.equal( + running.browser.contexts().flatMap((context) => context.pages()).length, + beforeShow, + "Show created a duplicate game window", + ); + const tools = await launcher.evaluate(async () => + (await window.launcherNative.state.get()).tools + ); + assert.equal(tools.configured, true); + assert.equal(tools.loaded, true); + assert.deepEqual(Object.keys(tools.features).sort(), [ + "build-management", + "quick-travel", + "xunlai-storage", + ]); + + const cdp = await running.browser.newBrowserCDPSession(); + const gpu = await cdp.send("SystemInfo.getInfo"); + assert.ok(gpu.gpu.devices.length > 0, "the installed renderer reported no GPU device"); + const processes = await cdp.send("SystemInfo.getProcessInfo"); + assert.ok( + processes.processInfo.some((entry) => entry.type === "browser"), + "the installed app reported no browser process", + ); + + await mainGame.close(); + await launcher.waitForFunction(async (id) => + (await window.launcherNative.state.get()).profiles.find( + (profile) => profile.id === id, + )?.state === "ready" + , mainProfile.id); + assert.equal(secondGame.isClosed(), false, "closing one account closed its peer"); + running = { ...running, page: secondGame }; + await closePackagedApp(running); + running = null; + + if (signedQualification) { + await execFileAsync(setup, ["--silent"], { + timeout: 120_000, + windowsHide: true, + }); + installedExecutable = await oneInstalledExecutable(packageRoot); + } + + running = await launchPackagedApp({ + appPath: packageRoot, + executablePath: installedExecutable, + productName: release.productName, + userData: storage.sessions, + arguments: signedQualification ? [] : ["--gw-volatile-secrets"], + useDefaultUserData: true, + }); + const restartedLauncher = running.launcherPage; + assert.ok(restartedLauncher); + assert.deepEqual( + await restartedLauncher.evaluate(async () => + (await window.launcherNative.state.get()).profiles.map(({ name }) => name) + ), + ["Main account", "Second account"], + "the installed workspace did not survive restart", + ); + const restartedMain = await openPackagedProfile(running, mainProfile.id); + if (signedQualification) { + assert.deepEqual( + await restartedMain.evaluate(() => window.gwNative.credentials.load()), + { + username: "main-qualified@example.invalid", + password: "synthetic-main-password", + }, + "signed replacement lost the Main credential", + ); + const restartedSecond = await openPackagedProfile(running, secondProfile.id); + assert.deepEqual( + await restartedSecond.evaluate(() => window.gwNative.credentials.load()), + { + username: "second-qualified@example.invalid", + password: "synthetic-second-password", + }, + "signed replacement lost the second profile credential", + ); + await restartedSecond.evaluate(() => window.gwNative.credentials.clear()); + assert.deepEqual( + await restartedMain.evaluate(() => window.gwNative.credentials.load()), + { + username: "main-qualified@example.invalid", + password: "synthetic-main-password", + }, + "clearing the second profile cleared Main", + ); + await restartedMain.evaluate(() => window.gwNative.credentials.clear()); + } + running = { ...running, page: restartedMain }; + await closePackagedApp(running); + running = null; + + await uninstall(updateExecutable); + assert.equal( + existsSync(installedExecutable), + false, + "uninstall left the installed application executable behind", + ); + assert.equal( + existsSync(path.join(storage.config, "settings.json")), + true, + "uninstall removed player settings", + ); + globalThis.console.log(JSON.stringify({ + platform: "win32-x64", + package: "Squirrel.Windows installed", + profiles: "isolated and restart-stable", + tools: "loaded globally", + credentials: signedQualification + ? "isolated, replacement-stable, and cleared" + : "qualified separately through the native synthetic probe", + 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"]), + ], + }, null, 2)); +} 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 }); + await rm(packageRoot, { recursive: true, force: true }); +} diff --git a/tests/helpers/packaged-app.ts b/tests/helpers/packaged-app.ts index 827ef1284..2cf4b236b 100644 --- a/tests/helpers/packaged-app.ts +++ b/tests/helpers/packaged-app.ts @@ -7,6 +7,8 @@ import type { ProfileId } from "../../src/shared/multiple-accounts.ts"; export interface RunningPackagedApp { readonly browser: Browser; readonly child: ChildProcess; + /** Bounded stdout/stderr retained for installed-package failure evidence. */ + readonly output: () => string; /** Present only for the unified launcher-first application. */ readonly launcherPage: Page | null; readonly page: Page; @@ -15,9 +17,13 @@ export interface RunningPackagedApp { export interface PackagedAppLaunch { readonly appPath: string; readonly productName: string; + /** Exact executable for installed Windows/Linux packages. */ + readonly executablePath?: string; readonly userData: string; readonly environment?: Readonly>; readonly arguments?: readonly string[]; + /** Use the package's native platform roots instead of a user-data override. */ + readonly useDefaultUserData?: boolean; /** Open the first active profile when the packaged app starts on the launcher. */ readonly openFirstProfile?: boolean; } @@ -120,16 +126,22 @@ export async function openPackagedProfile( export async function launchPackagedApp( options: PackagedAppLaunch, ): Promise { - const executablePath = path.join( + const executablePath = options.executablePath ?? path.join( options.appPath, `Contents/MacOS/${options.productName}`, ); const activePort = path.join(options.userData, "DevToolsActivePort"); await rm(activePort, { force: true }); + let capturedOutput = ""; + const capture = (chunk: Buffer) => { + capturedOutput = `${capturedOutput}${chunk.toString("utf8")}`.slice(-65_536); + }; const child = spawn( executablePath, [ - `--user-data-dir=${options.userData}`, + ...(options.useDefaultUserData === true + ? [] + : [`--user-data-dir=${options.userData}`]), "--remote-debugging-address=127.0.0.1", "--remote-debugging-port=0", ...(options.arguments ?? []), @@ -144,13 +156,18 @@ export async function launchPackagedApp( GW_BACKGROUND_LAUNCH: "1", ...options.environment, }, - stdio: "ignore", + stdio: ["ignore", "pipe", "pipe"], }, ); + child.stdout?.on("data", capture); + child.stderr?.on("data", capture); try { const port = await waitUntil("the packaged app DevTools port", async () => { if (child.exitCode !== null) { - throw new Error(`packaged app exited with code ${child.exitCode}`); + 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; @@ -171,6 +188,7 @@ export async function launchPackagedApp( const running: RunningPackagedApp = { browser, child, + output: () => capturedOutput, launcherPage, page: firstPage, }; diff --git a/tests/policy/source-windows-installed.test.ts b/tests/policy/source-windows-installed.test.ts new file mode 100644 index 000000000..84947f42c --- /dev/null +++ b/tests/policy/source-windows-installed.test.ts @@ -0,0 +1,53 @@ +/** Static safety and coverage gates for installed Windows qualification. */ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { test } from "node:test"; + +const root = process.cwd(); +const script = readFileSync( + path.join(root, "scripts/windows-installed-qualification.ts"), + "utf8", +); +const workflow = readFileSync( + path.join(root, ".github/workflows/portable-native-build.yml"), + "utf8", +); +const signedWorkflow = readFileSync( + path.join(root, ".github/workflows/windows-signed-qualification.yml"), + "utf8", +); + +test("installed qualification is restricted to a disposable hosted runner", () => { + assert.match(script, /process\.platform !== "win32"/u); + assert.match(script, /process\.arch !== "x64"/u); + assert.match(script, /GITHUB_ACTIONS !== "true"/u); + assert.match(script, /RUNNER_ENVIRONMENT !== "github-hosted"/u); + 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, /"--disable-crash-reporter"/u); + assert.doesNotMatch(script, /--no-sandbox|--disable-setuid-sandbox/u); +}); + +test("the installed artifact owns the profile, Tool, and uninstall proof", () => { + assert.match(script, /profiles\.create/u); + assert.match(script, /localStorage\.getItem\("profile-proof"\)/u); + assert.match(script, /tools\.loaded/u); + assert.match(script, /SystemInfo\.getInfo/u); + assert.match(script, /uninstall removed player settings/u); + const make = workflow.indexOf("Build the unsigned Windows Squirrel package"); + const installed = workflow.indexOf("Qualify the installed Windows package"); + assert.ok(make >= 0 && installed > make); +}); + +test("signed qualification cannot publish and uses only synthetic credentials", () => { + assert.match(signedWorkflow, /workflow_dispatch:/u); + assert.match(signedWorkflow, /environment: windows-release/u); + assert.match(signedWorkflow, /GW_PACKAGE_INTENT: release/u); + assert.match(signedWorkflow, /GW_WINDOWS_SIGNED_QUALIFICATION: "1"/u); + assert.doesNotMatch(signedWorkflow, /upload-artifact|gh release|contents: write/u); + assert.match(script, /main-qualified@example\.invalid/u); + assert.match(script, /second-qualified@example\.invalid/u); + assert.match(script, /credentials\.clear\(\)/u); +});