diff --git a/.github/workflows/linux-flatpak-build.yml b/.github/workflows/linux-flatpak-build.yml index 797e5ee7b..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 @@ -24,29 +25,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. @@ -98,3 +115,62 @@ 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 + # 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 + 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 + 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: | + desktop="${DESKTOP^^}" + xvfb-run -a env \ + XDG_CURRENT_DESKTOP="$desktop" \ + XDG_SESSION_DESKTOP="$desktop" \ + XDG_SESSION_TYPE=x11 \ + dbus-run-session -- bash -euc ' + 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 8c952cfd4..eca91a78d 100644 --- a/.github/workflows/linux-signed-qualification.yml +++ b/.github/workflows/linux-signed-qualification.yml @@ -32,7 +32,12 @@ 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 + 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 @@ -69,11 +74,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 - if [ "$DESKTOP" = gnome ]; then - eval "$(printf "\n" | gnome-keyring-daemon --unlock --components=secrets)" - fi + desktop="${DESKTOP^^}" + xvfb-run -a env \ + XDG_CURRENT_DESKTOP="$desktop" \ + XDG_SESSION_DESKTOP="$desktop" \ + XDG_SESSION_TYPE=x11 \ + dbus-run-session -- bash -euc ' + eval "$(printf "\n" | gnome-keyring-daemon --unlock --components=secrets)" pnpm test:linux-installed ' 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/.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/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 diff --git a/scripts/build.mjs b/scripts/build.mjs index acb69dfb4..9c9ded999 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", @@ -169,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", ], ], [ @@ -183,6 +191,7 @@ export function nativeBuildSteps(platform, architecture) { "/nologo", "/std:c++20", "/O2", + "/MT", "/EHsc", "/Isrc/native/gw-dat", "/Fobuild\\native\\", diff --git a/scripts/linux-installed-qualification.ts b/scripts/linux-installed-qualification.ts index 5d5fa468a..2b5b2205f 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", `--commit=${candidateCommit}`, 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", `--commit=${candidateCommit}`, 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, @@ -178,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>; @@ -196,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")), @@ -220,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 () => @@ -246,6 +317,8 @@ try { running = null; await waitForFlatpakExit(); + if (updateQualification) await qualifyUpdateRecovery(); + running = await launch(); const restartedLauncher = running.launcherPage; assert.ok(restartedLauncher); @@ -256,6 +329,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, @@ -268,6 +342,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 +375,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/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-installed-qualification.ts b/scripts/windows-installed-qualification.ts index 7943622b2..87497ac98 100644 --- a/scripts/windows-installed-qualification.ts +++ b/scripts/windows-installed-qualification.ts @@ -25,21 +25,39 @@ 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)); -interface WindowsProcess { - readonly ProcessId: number; - readonly ParentProcessId: number; - readonly CommandLine: string | null; +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 proveNormalCrashpadStartup( +async function proveNormalWindowsStartup( executable: string, arguments_: readonly string[], -): Promise { - let output = ""; +): Promise { const child = spawn(executable, arguments_, { env: { ...process.env, @@ -47,55 +65,19 @@ async function proveNormalCrashpadStartup( GW_REQUIRE_CACHED_CLIENT: "1", GW_BACKGROUND_LAUNCH: "1", }, - stdio: ["ignore", "pipe", "pipe"], + // Match an Explorer or Start-menu launch, without CDP instrumentation or + // inherited console handles. + 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()}`, - ); - 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; - } - } + if (child.exitCode !== null) { + const eventLog = await recentWindowsApplicationEvents(); + return `normal startup exited with ${child.exitCode}; Windows events: ${eventLog}`; } - 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) { await execFileAsync("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { @@ -145,6 +127,72 @@ 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]!); +} + +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. 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)", + "$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, + }; + await execFileAsync( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + command, + ], + { encoding: "utf8", env: environment, timeout: 30_000, windowsHide: true }, + ); +} + +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 +230,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), @@ -192,15 +243,11 @@ for (const candidate of [packageRoot, path.dirname(storage.config)]) { let running: RunningPackagedApp | null = null; let installedExecutable: string | null = null; +let qualificationSucceeded = false; 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"); - + // 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 }), @@ -223,13 +270,25 @@ 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", ...(signedQualification ? [] : ["--gw-volatile-secrets"]), ]; - await proveNormalCrashpadStartup(installedExecutable, qualificationArguments); - + const automationArguments = [ + ...qualificationArguments, + "--enable-logging=file", + `--log-file=${path.join(storage.logs, "electron.log")}`, + ]; running = await launchPackagedApp({ appPath: packageRoot, executablePath: installedExecutable, @@ -238,14 +297,14 @@ 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 - // normal launch proves production Crashpad. CDP is test instrumentation - // that starts before application JavaScript can connect that handler. - arguments: [ - ...qualificationArguments, - "--disable-crash-reporter", - ], - environment: { ELECTRON_ENABLE_LOGGING: "1" }, + // 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: 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, useDefaultUserData: true, }); const launcher = running.launcherPage; @@ -279,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")), @@ -352,12 +417,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({ @@ -366,6 +448,7 @@ try { productName: release.productName, userData: storage.sessions, arguments: signedQualification ? [] : ["--gw-volatile-secrets"], + desktopProcessShape: true, useDefaultUserData: true, }); const restartedLauncher = running.launcherPage; @@ -396,21 +479,73 @@ 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); + await stopSquirrelFirstRun(installedExecutable); + running = await launchPackagedApp({ + appPath: packageRoot, + executablePath: installedExecutable, + productName: release.productName, + userData: storage.sessions, + desktopProcessShape: true, + 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; + // 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), @@ -422,28 +557,49 @@ 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", 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)); + 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/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 { + 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 = qualificationBaselineVersion(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/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, diff --git a/src/main/main.ts b/src/main/main.ts index a8af56ec1..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, @@ -228,14 +227,8 @@ 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())); } - const primaryInstance = !windowsSquirrelStartupHandled && app.requestSingleInstanceLock(); if (!primaryInstance) { 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..b36ada623 --- /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/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); diff --git a/tests/electron/input-camera.spec.ts b/tests/electron/input-camera.spec.ts index c6c6cf2d9..c792f1f69 100644 --- a/tests/electron/input-camera.spec.ts +++ b/tests/electron/input-camera.spec.ts @@ -28,7 +28,27 @@ 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/", + ); + 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 page.evaluate(() => { const canvas = globalThis.document.getElementById("canvas"); const loading = globalThis.document.getElementById("loading"); @@ -42,27 +62,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, 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, diff --git a/tests/helpers/packaged-app.ts b/tests/helpers/packaged-app.ts index 1d8731f21..200c935f6 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"; @@ -26,6 +27,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; } @@ -47,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) => { @@ -134,6 +154,12 @@ export async function launchPackagedApp( `Contents/MacOS/${options.productName}`, ); const activePort = path.join(options.userData, "DevToolsActivePort"); + // 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; await rm(activePort, { force: true }); let capturedOutput = ""; const capture = (chunk: Buffer) => { @@ -147,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 ?? []), ], { @@ -160,13 +186,32 @@ 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); 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( @@ -174,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-linux-flatpak.test.ts b/tests/policy/source-linux-flatpak.test.ts index 28c097668..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); @@ -50,6 +51,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); @@ -59,8 +71,28 @@ 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); } }); + + 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); + for (const workflow of [buildWorkflow, signedWorkflow]) { + assert.match(workflow, /kde-portals\.conf/u); + assert.match(workflow, /org\.freedesktop\.impl\.portal\.Secret=gnome-keyring/u); + } + }); }); diff --git a/tests/policy/source-native-keychain.test.ts b/tests/policy/source-native-keychain.test.ts index b1cb87693..6f5d98440 100644 --- a/tests/policy/source-native-keychain.test.ts +++ b/tests/policy/source-native-keychain.test.ts @@ -119,8 +119,15 @@ 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)) { + 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), [ diff --git a/tests/policy/source-windows-credentials.test.ts b/tests/policy/source-windows-credentials.test.ts index f4cf431d7..23c21ad28 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", () => { @@ -18,9 +22,11 @@ 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("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", () => { diff --git a/tests/policy/source-windows-installed.test.ts b/tests/policy/source-windows-installed.test.ts index 4a99be307..b6ad4b58a 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", @@ -19,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); @@ -26,9 +34,20 @@ 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.match(script, /desktopProcessShape: true/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.match(packagedApp, /process\.platform === "win32"[\s\S]*availableLoopbackPort\(\)/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); + assert.match(workflow, /windows-installed-failure/u); assert.doesNotMatch(script, /--no-sandbox|--disable-setuid-sandbox/u); }); @@ -52,4 +71,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); }); diff --git a/tests/unit/windows-package-probe.test.ts b/tests/unit/windows-package-probe.test.ts index 6dd0fd5b0..36065240f 100644 --- a/tests/unit/windows-package-probe.test.ts +++ b/tests/unit/windows-package-probe.test.ts @@ -1,9 +1,11 @@ /** The Windows package probe refuses marker, artifact, and feed drift. */ import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { describe, it } from "node:test"; +import { createPackage, getRawHeader } from "@electron/asar"; import { probeWindowsPackage } from "../../scripts/windows-package-probe.ts"; describe("Windows package probe", () => { @@ -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 { diff --git a/tests/unit/windows-update-fixture.test.ts b/tests/unit/windows-update-fixture.test.ts new file mode 100644 index 000000000..5987c19f4 --- /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 { qualificationBaselineVersion } from "../../scripts/qualification-baseline-version.ts"; + +describe("Windows update fixture version", () => { + it("selects the preceding Stable patch", () => { + 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(() => qualificationBaselineVersion(version)); + } + }); +});