From d1b5ee032f183f3f8c7bea705c2d87a676a684d1 Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Mon, 31 Aug 2026 00:22:17 +0200 Subject: [PATCH] build(platform): compile portable native artifacts --- .github/workflows/portable-native-build.yml | 58 +++++ docs/process-model.md | 18 ++ forge.config.ts | 42 ++- package.json | 1 + scripts/build.mjs | 241 ++++++++++++------ scripts/package-ignore.ts | 1 + src/main/core/paths.ts | 8 + src/main/macos-command-key-ups.ts | 4 +- src/main/main.ts | 61 +++-- src/main/native-host.ts | 16 +- src/main/paths.ts | 5 +- tests/integration/gw-dat-dimensions.test.ts | 8 +- tests/policy/fuses.test.ts | 15 +- tests/policy/source-native-keychain.test.ts | 55 +++- tests/policy/source-release-pipeline.test.ts | 5 +- .../policy/source-saved-login-surface.test.ts | 7 +- tests/unit/paths.test.ts | 7 + 17 files changed, 418 insertions(+), 134 deletions(-) create mode 100644 .github/workflows/portable-native-build.yml diff --git a/.github/workflows/portable-native-build.yml b/.github/workflows/portable-native-build.yml new file mode 100644 index 00000000..791720b2 --- /dev/null +++ b/.github/workflows/portable-native-build.yml @@ -0,0 +1,58 @@ +# Compile the native package inputs on their real target toolchains. This is a +# build proof only; installed runtime and distribution qualification stay in +# their platform-specific release gates. +name: Portable native build + +on: + pull_request: + paths: + - ".github/workflows/portable-native-build.yml" + - "apps/**" + - "scripts/build.mjs" + - "src/**" + - "tests/integration/gw-dat-dimensions.test.ts" + - "package.json" + - "pnpm-lock.yaml" + - "rust-toolchain.toml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + strategy: + fail-fast: false + matrix: + os: [windows-2025, ubuntu-24.04] + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + 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 + - name: Enable the x64 MSVC build environment + if: runner.os == 'Windows' + uses: ilammy/msvc-dev-cmd@a102174a2b586eec2ea151a69e6fd14404a8ce7c # v1.13.0 + with: + arch: x64 + - name: Install the Linux native build dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y g++ libglib2.0-dev + - name: Install the pinned Rust toolchain + run: rustup toolchain install + - run: pnpm install --frozen-lockfile + - run: pnpm build + - name: Run the portable source and renderer gate + run: pnpm run check:portable + - name: Exercise the target decoder executable + run: >- + node --import ./scripts/ts-hook.mjs --test + tests/integration/gw-dat-dimensions.test.ts diff --git a/docs/process-model.md b/docs/process-model.md index 15110faa..4f7265d8 100644 --- a/docs/process-model.md +++ b/docs/process-model.md @@ -334,6 +334,24 @@ screenshots, and chat logs. Two renderers do not mount the same browser store. Derived WASM modules and caches are rebuildable. They are never certification authority. +### Native package inputs + +The Guild Wars archive decoder is an isolated executable built for the package +host. Windows x64 uses MSVC and an `.exe` suffix; Linux x86_64 uses the system +C++ compiler; macOS keeps its released Xcode recipe. The decoder never owns a +credential or an Electron process. + +`host.node` remains Darwin-only. It contains the existing AppKit key-release +monitor and Apple Data Protection Keychain implementation. A Windows or Linux +build does not load or package this addon. Until that platform has a qualified +secure provider, persistent saved login fails closed and ordinary development +uses only the in-memory provider. + +Forge applies the complete cross-platform fuse set to every packaged Electron +executable. Embedded ASAR integrity is enabled on macOS and Windows. Electron +does not provide that feature on Linux, where repository signatures, the +Flatpak sandbox, and ASAR-only loading must form the installed package proof. + ## Saved login The Release and signed Development identities use separate Keychain authority. diff --git a/forge.config.ts b/forge.config.ts index 8c24452a..2820ffc5 100644 --- a/forge.config.ts +++ b/forge.config.ts @@ -26,6 +26,25 @@ const packageVersion = ( const macOSVersion = macOSBundleVersions(packageVersion); const packageMode = resolvePackageMode(process.env.GW_PACKAGE_INTENT); const channelConfig = DISTRIBUTION_CHANNEL_CONFIG[packageMode.productChannel]; +const buildingDarwin = process.platform === "darwin"; + +function packagedExecutablePath( + resourcesPath: string, + platform: string, +): string { + if (platform === "darwin") { + return path.resolve(resourcesPath, "../..", "MacOS", "Electron"); + } + if (platform !== "win32" && platform !== "linux") { + throw new Error(`unsupported package platform: ${platform}`); + } + const suffix = platform === "win32" ? ".exe" : ""; + return path.resolve( + resourcesPath, + "..", + `${channelConfig.productName}${suffix}`, + ); +} function requiredSigningEnvironment(name: string): string { const value = process.env[name]; @@ -33,7 +52,7 @@ function requiredSigningEnvironment(name: string): string { return value; } -const distributionSigning = packageMode.kind === "signed" +const distributionSigning = buildingDarwin && packageMode.kind === "signed" ? (() => { const { channel } = packageMode; const identity = requiredSigningEnvironment("APPLE_SIGNING_IDENTITY"); @@ -50,7 +69,7 @@ const distributionSigning = packageMode.kind === "signed" })() : undefined; -const releaseNotarization = packageMode.intent === "release" +const releaseNotarization = buildingDarwin && packageMode.intent === "release" ? { appleApiKey: requiredSigningEnvironment("APPLE_API_KEY_PATH"), appleApiKeyId: requiredSigningEnvironment("APPLE_API_KEY_ID"), @@ -63,7 +82,9 @@ const config: ForgeConfig = { // Both are executable code that cannot run from inside the archive: a // `.node` addon cannot be dlopen'd from it, and a helper cannot be spawned // from it. - asar: { unpack: "**/build/native/{host.node,gw-dat-decode}" }, + asar: { + unpack: "**/build/native/{host.node,gw-dat-decode,gw-dat-decode.exe}", + }, name: channelConfig.productName, executableName: channelConfig.productName, appVersion: macOSVersion.appVersion, @@ -99,7 +120,7 @@ const config: ForgeConfig = { rebuildConfig: {}, makers: [ new MakerZIP({}, ["darwin"]), - ...(packageMode.intent === "release" + ...(buildingDarwin && packageMode.intent === "release" ? [ new MakerDMG({ // appdmg also uses this as the mounted volume name and rejects @@ -137,8 +158,7 @@ const config: ForgeConfig = { platform, arch, ) => { - if (platform !== "darwin") return; - if (packageMode.kind === "signed") { + if (platform === "darwin" && packageMode.kind === "signed") { writeFileSync( path.resolve(resourcesPath, "..", "distribution-channel.json"), `${JSON.stringify(distributionMarker(packageMode.channel))}\n`, @@ -146,12 +166,13 @@ const config: ForgeConfig = { ); } await flipFuses( - path.resolve(resourcesPath, "../..", "MacOS", "Electron"), + packagedExecutablePath(resourcesPath, platform), { version: FuseVersion.V1, // Flipping a fuse edits the binary, which invalidates the signature // the prebuilt Electron carries and Apple Silicon insists on having. - resetAdHocDarwinSignature: arch === "arm64", + resetAdHocDarwinSignature: + platform === "darwin" && arch === "arm64", // A fuse this list has never heard of fails the package rather than // taking whichever default a new Electron shipped it with. strictlyRequireAllFuses: true, @@ -169,7 +190,10 @@ const config: ForgeConfig = { [FuseV1Options.EnableNodeCliInspectArguments]: false, // Gatekeeper checks the bundle seal at first launch; this checks the // archive at every one. - [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true, + // Electron embeds ASAR integrity on macOS and Windows. Linux relies + // on the signed Flatpak repository, sandbox, and ASAR-only loading. + [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: + platform !== "linux", // Otherwise an app/ directory beside the archive is the fallback when // app.asar is missing or unreadable, so removing the archive replaces // it with code the check above never sees. diff --git a/package.json b/package.json index cf2b0763..5783681c 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "typecheck": "tsc --noEmit && tsc -p tsconfig.renderer.json --noEmit && tsc -p tsconfig.tests.json && pnpm --filter @gwonmac/tools-ui typecheck && pnpm --filter @gwonmac/launcher-ui typecheck", "lint": "eslint .", "check:links": "node --import ./scripts/ts-hook.mjs scripts/check-markdown-links.ts", + "check:portable": "pnpm typecheck && pnpm lint && pnpm check:links && pnpm tools:test && pnpm --filter @gwonmac/launcher-ui test", "test:unit": "node --import ./scripts/ts-hook.mjs --test --test-timeout=60000 tests/unit/*.ts", "test:client-artifact": "node --max-old-space-size=8192 --import ./scripts/ts-hook.mjs --test --test-concurrency=1 --test-timeout=60000 tests/client-artifact/*.ts", "test:integration": "node --import ./scripts/ts-hook.mjs --test --test-timeout=60000 tests/integration/*.ts", diff --git a/scripts/build.mjs b/scripts/build.mjs index ccd47a4f..52505d90 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -31,10 +31,155 @@ if ( ); } -const nativeArchitecture = - process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "x86_64" : null; -if (nativeArchitecture === null) { - throw new Error(`unsupported native build architecture: ${process.arch}`); +/** @typedef {readonly [command: string, args: readonly string[]]} BuildStep */ + +/** + * Invoke pnpm's JavaScript entry point through the current Node executable. + * This avoids both shell parsing and Windows command-shim behavior. + * + * @param {NodeJS.Platform} platform + * @param {string | undefined} npmExecPath + * @param {string} nodeExecutable + * @returns {BuildStep} + */ +export function packageManagerInvocation( + platform, + npmExecPath, + nodeExecutable = process.execPath, +) { + if (npmExecPath) return [nodeExecutable, [npmExecPath]]; + if (platform === "win32") { + throw new Error("Run the canonical Windows build through `pnpm build`."); + } + return ["pnpm", []]; +} + +const [pnpmCommand, pnpmPrefixArgs] = packageManagerInvocation( + process.platform, + process.env.npm_execpath, +); + +const DECODER_SOURCES = [ + "src/native/gw-dat/decoder-main.cpp", + "src/native/gw-dat/vendor/gwdat/xentax.cpp", + "src/native/gw-dat/vendor/gwdat/AtexReader.cpp", + "src/native/gw-dat/vendor/gwdat/AtexDecompress.cpp", + "src/native/gw-dat/vendor/gwdat/AtexAsm.cpp", +]; + +/** + * Native recipes selected by the build host. macOS keeps its exact released + * addon and decoder commands. Windows and Linux compile only the portable + * decoder until their credential implementations pass installed tests. + * + * @param {NodeJS.Platform} platform + * @param {NodeJS.Architecture} architecture + * @returns {readonly BuildStep[]} + */ +export function nativeBuildSteps(platform, architecture) { + if (platform === "darwin") { + const targetArchitecture = architecture === "arm64" + ? "arm64" + : architecture === "x64" + ? "x86_64" + : null; + if (targetArchitecture === null) { + throw new Error(`unsupported macOS build architecture: ${architecture}`); + } + return [ + [ + "xcrun", + [ + "clang++", + "-std=c++20", + "-fobjc-arc", + "-bundle", + "-undefined", + "dynamic_lookup", + "-DNAPI_VERSION=8", + "-I", + "node_modules/node-api-headers/include", + "-mmacosx-version-min=12.0", + "-arch", + targetArchitecture, + "-O2", + "-Wall", + "-Wextra", + "-Werror", + "-fvisibility=hidden", + "src/native/host/host.mm", + "-framework", + "AppKit", + "-framework", + "Foundation", + "-framework", + "LocalAuthentication", + "-framework", + "Security", + "-o", + "build/native/host.node", + ], + ], + [ + "xcrun", + [ + "clang++", + "-std=c++20", + "-mmacosx-version-min=12.0", + "-arch", + targetArchitecture, + "-O2", + '-D__int64=long long', + "-Wno-multichar", + "-Wno-constant-logical-operand", + "-Isrc/native/gw-dat", + ...DECODER_SOURCES, + "-o", + "build/native/gw-dat-decode", + ], + ], + ]; + } + + if (platform === "win32") { + if (architecture !== "x64") { + throw new Error(`unsupported Windows build architecture: ${architecture}`); + } + return [[ + "cl.exe", + [ + "/nologo", + "/std:c++20", + "/O2", + "/EHsc", + "/Isrc/native/gw-dat", + ...DECODER_SOURCES, + "/Fe:build/native/gw-dat-decode.exe", + ], + ]]; + } + + if (platform === "linux") { + if (architecture !== "x64") { + throw new Error(`unsupported Linux build architecture: ${architecture}`); + } + return [[ + "c++", + [ + "-std=c++20", + "-O2", + '-D__int64=long long', + "-Wno-multichar", + "-Wno-constant-logical-operand", + "-Isrc/native/gw-dat", + ...DECODER_SOURCES, + "-o", + "build/native/gw-dat-decode", + ], + ]]; + } + + throw new Error(`unsupported native build platform: ${platform}`); } /** @@ -92,84 +237,22 @@ export const BUILD_STEPS = [ [process.execPath, ["node_modules/typescript/bin/tsc"]], // The launcher is a standalone Vue document with its own narrow preload and // protocol subtree. Vite owns only build/renderer/launcher/. - ["pnpm", ["--filter", "@gwonmac/launcher-ui", "build"]], + [ + pnpmCommand, + [...pnpmPrefixArgs, "--filter", "@gwonmac/launcher-ui", "build"], + ], // The Tools application, bundled once for the renderer. It is an independent // Vue workspace with its own tests, so this step only packages what already // passed them. Vite writes build/renderer/tools/ and empties only that // directory, so it cannot disturb the emits above and its position here is // for readability rather than correctness. - ["pnpm", ["--filter", "@gwonmac/tools-ui", "build:embedded"]], - // The only native addon. It uses raw Node-API version 8, whose ABI remains - // stable across the Node and Electron upgrades this project takes. The - // framework APIs resolve at runtime from the Electron host, so the bundle - // deliberately leaves Node-API symbols undefined here. [ - "xcrun", - [ - "clang++", - "-std=c++20", - "-fobjc-arc", - "-bundle", - "-undefined", - "dynamic_lookup", - "-DNAPI_VERSION=8", - "-I", - "node_modules/node-api-headers/include", - "-mmacosx-version-min=12.0", - "-arch", - nativeArchitecture, - "-O2", - "-Wall", - "-Wextra", - "-Werror", - "-fvisibility=hidden", - "src/native/host/host.mm", - "-framework", - "AppKit", - "-framework", - "Foundation", - "-framework", - "LocalAuthentication", - "-framework", - "Security", - "-o", - "build/native/host.node", - ], - ], - // The Guild Wars archive decoder. A separate executable rather than a second - // addon: it is hand-transcribed x86 (see src/native/gw-dat/vendor/README.md) - // parsing the player's own game files, and a malformed record should fail one - // decode rather than take the application down with it. It is spawned once - // per asset and the result is cached on disk, so the process boundary costs - // about four milliseconds, once, per icon that is ever looked at. - // - // -Wall/-Wextra/-Werror are deliberately not applied: this is vendored source - // carried unmodified, and the -D/-Wno flags stand in for MSVC builtins clang - // lacks rather than patching it. The third silences a warning about an `&&` - // that reads like a typo and is not one — src/native/gw-dat/vendor/README.md - // records why changing it would break the decode. - [ - "xcrun", - [ - "clang++", - "-std=c++20", - "-mmacosx-version-min=12.0", - "-arch", - nativeArchitecture, - "-O2", - '-D__int64=long long', - "-Wno-multichar", - "-Wno-constant-logical-operand", - "-Isrc/native/gw-dat", - "src/native/gw-dat/decoder-main.cpp", - "src/native/gw-dat/vendor/gwdat/xentax.cpp", - "src/native/gw-dat/vendor/gwdat/AtexReader.cpp", - "src/native/gw-dat/vendor/gwdat/AtexDecompress.cpp", - "src/native/gw-dat/vendor/gwdat/AtexAsm.cpp", - "-o", - "build/native/gw-dat-decode", - ], + pnpmCommand, + [...pnpmPrefixArgs, "--filter", "@gwonmac/tools-ui", "build:embedded"], ], + // Native compilation remains a direct per-platform recipe. macOS alone has + // the AppKit/Keychain addon; all three targets build the isolated decoder. + ...nativeBuildSteps(process.platform, process.arch), // Reads src/shared/contracts.ts and src/preload/preload.body.cjs and writes // the Core and Tools preload artifacts, which nothing else here produces — so its // position is free. It is TypeScript, so it is spawned the one way this @@ -212,7 +295,13 @@ function build() { ); process.exit(1); } - if (result.status !== 0) process.exit(result.status ?? 1); + if (result.status !== 0) { + console.error( + `Build step failed: ${command}` + + (result.error ? ` (${result.error.message})` : ""), + ); + process.exit(result.status ?? 1); + } } } diff --git a/scripts/package-ignore.ts b/scripts/package-ignore.ts index fe736f09..6caf1efd 100644 --- a/scripts/package-ignore.ts +++ b/scripts/package-ignore.ts @@ -31,6 +31,7 @@ export function ignorePackageFile(file: string): boolean { p === "/build/native" || p === "/build/native/host.node" || p === "/build/native/gw-dat-decode" + || p === "/build/native/gw-dat-decode.exe" ) { return false; } diff --git a/src/main/core/paths.ts b/src/main/core/paths.ts index 83dddad4..361fe230 100644 --- a/src/main/core/paths.ts +++ b/src/main/core/paths.ts @@ -219,3 +219,11 @@ export function unpackedPath(layout: BundleLayout, relative: string): string { ? path.join(layout.resourcesPath, "app.asar.unpacked", relative) : path.join(layout.appPath, relative); } + +/** Name one native helper without leaking the Windows suffix into its owner. */ +export function nativeExecutableName( + name: string, + platform: NodeJS.Platform, +): string { + return platform === "win32" ? `${name}.exe` : name; +} diff --git a/src/main/macos-command-key-ups.ts b/src/main/macos-command-key-ups.ts index a745845f..5469f8c0 100644 --- a/src/main/macos-command-key-ups.ts +++ b/src/main/macos-command-key-ups.ts @@ -4,7 +4,7 @@ * existing renderer command boundary. */ import { physicalCodeForMacKeyCode } from "./core/macos-key-code.js"; -import type { NativeHost } from "./native-host.js"; +import type { NativeInputMonitor } from "./native-host.js"; export interface MacosCommandKeyUpRouting { focusedGameTarget(): T | null; @@ -12,7 +12,7 @@ export interface MacosCommandKeyUpRouting { } export function installMacosCommandKeyUps( - nativeHost: Pick, + nativeHost: NativeInputMonitor, routing: MacosCommandKeyUpRouting, ): () => void { return nativeHost.monitorCommandKeyUps((keyCode) => { diff --git a/src/main/main.ts b/src/main/main.ts index 1985e9d2..e365e6de 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -108,7 +108,7 @@ import { VolatileNativeKeychain, type NativeKeychain, } from "./core/native-keychain.js"; -import { loadNativeHost } from "./native-host.js"; +import { loadDarwinNativeHost } from "./native-host.js"; import { installMacosCommandKeyUps } from "./macos-command-key-ups.js"; import { recordMainInput } from './input-trace.js'; import { cleanupLegacySecretFiles } from "./core/legacy-secret-cleanup.js"; @@ -500,30 +500,35 @@ if (primaryInstance) void app.whenReady().then(async () => { "Mat4m0/gwonmac · App icon artwork © ArenaNet LLC · QT Friz Quad © 1992 QualiType (SIL OFL 1.1) · Not affiliated with ArenaNet or NCSOFT.", website: EXTERNAL_URLS.github, }); - const nativeHost = loadNativeHost({ + const nativeHostLayout = { packaged: app.isPackaged, appPath: app.getAppPath(), resourcesPath: process.resourcesPath, - }); - const stopCommandKeyUps = installMacosCommandKeyUps(nativeHost, { - focusedGameTarget() { - return windowRegistry.focusedGameWindow(); - }, - release(win, code) { - releaseWindowShortcutKey(win, code); - recordMainInput(win, { - source: 'appkit', - kind: 'native-key', - phase: 'up', - key: code.startsWith('Key') || code.startsWith('Digit') - ? 'printable' - : 'other', - repeat: false, - decision: 'normalized-release', - }); - void sendRendererCommand(win, { type: "input.release", code }); - }, - }); + }; + const darwinNativeHost = process.platform === "darwin" + ? loadDarwinNativeHost(nativeHostLayout) + : null; + const stopCommandKeyUps = darwinNativeHost + ? installMacosCommandKeyUps(darwinNativeHost, { + focusedGameTarget() { + return windowRegistry.focusedGameWindow(); + }, + release(win, code) { + releaseWindowShortcutKey(win, code); + recordMainInput(win, { + source: 'appkit', + kind: 'native-key', + phase: 'up', + key: code.startsWith('Key') || code.startsWith('Digit') + ? 'printable' + : 'other', + repeat: false, + decision: 'normalized-release', + }); + void sendRendererCommand(win, { type: "input.release", code }); + }, + }) + : () => {}; app.once("will-quit", () => stopCommandKeyUps()); const paths = gamePaths(); const legacySingleData = await hasReleasedSingleData(paths); @@ -626,9 +631,15 @@ if (primaryInstance) void app.whenReady().then(async () => { if (adoptedStorage) { await prepareWindowState(SINGLE_DIAGNOSTIC_OWNER_ID, adoptedStorage.windowState); } - const keychain: NativeKeychain = persistentSecrets - ? nativeHost - : new VolatileNativeKeychain(); + let keychain: NativeKeychain; + if (persistentSecrets) { + if (darwinNativeHost === null) { + throw new Error("persistent secret provider is unavailable"); + } + keychain = darwinNativeHost; + } else { + keychain = new VolatileNativeKeychain(); + } const expectedUserData = process.env.GW_EXPECT_USER_DATA; const profileMatches = !expectedUserData || diff --git a/src/main/native-host.ts b/src/main/native-host.ts index 4069205c..c88c25fa 100644 --- a/src/main/native-host.ts +++ b/src/main/native-host.ts @@ -1,5 +1,5 @@ /** - * Finding and loading gwonmac's one native macOS host addon. + * Finding and loading gwonmac's Darwin-only native host addon. * * A packaged build resolves it inside `app.asar.unpacked` because a `.node` * binary cannot load from within an archive. The boundary is shape-checked so @@ -9,7 +9,7 @@ import { createRequire } from "node:module"; import type { NativeKeychain } from "./core/native-keychain.js"; import { unpackedPath, type BundleLayout } from "./core/paths.js"; -export interface NativeHost extends NativeKeychain { +export interface NativeInputMonitor { /** * Observe app-local key releases owned by a Command chord. Returning true * consumes that release only while Command remains down, after the renderer @@ -18,15 +18,17 @@ export interface NativeHost extends NativeKeychain { monitorCommandKeyUps(handler: (keyCode: number) => boolean): () => void; } +export type DarwinNativeHost = NativeKeychain & NativeInputMonitor; + export type NativeHostLayout = BundleLayout; export function nativeHostPath(layout: NativeHostLayout): string { return unpackedPath(layout, "build/native/host.node"); } -function isNativeHost(value: unknown): value is NativeHost { +function isDarwinNativeHost(value: unknown): value is DarwinNativeHost { if (typeof value !== "object" || value === null) return false; - const candidate = value as Partial>; + const candidate = value as Partial>; return ( typeof candidate.load === "function" && typeof candidate.save === "function" && @@ -35,9 +37,11 @@ function isNativeHost(value: unknown): value is NativeHost { ); } -export function loadNativeHost(layout: NativeHostLayout): NativeHost { +export function loadDarwinNativeHost( + layout: NativeHostLayout, +): DarwinNativeHost { const loaded: unknown = createRequire(import.meta.url)(nativeHostPath(layout)); - if (!isNativeHost(loaded)) { + if (!isDarwinNativeHost(loaded)) { throw new TypeError("native host module has an invalid shape"); } return loaded; diff --git a/src/main/paths.ts b/src/main/paths.ts index 325470e7..f74590b2 100644 --- a/src/main/paths.ts +++ b/src/main/paths.ts @@ -12,6 +12,7 @@ import path from "node:path"; import { colocatedStorageRoots, gamePaths as resolveGamePaths, + nativeExecutableName, unpackedPath, } from "./core/paths.js"; import type { GamePaths } from "./core/paths.js"; @@ -53,13 +54,13 @@ export function launcherPreloadPath(): string { * The packaging rule itself lives in `./core/paths.ts` beside the keychain * addon's, because it is the same rule and a test can execute it there. */ -export function gwDatDecoderPath(): string { +export function gwDatDecoderPath(platform = process.platform): string { return unpackedPath( { packaged: app.isPackaged, appPath: app.getAppPath(), resourcesPath: process.resourcesPath, }, - "build/native/gw-dat-decode", + `build/native/${nativeExecutableName("gw-dat-decode", platform)}`, ); } diff --git a/tests/integration/gw-dat-dimensions.test.ts b/tests/integration/gw-dat-dimensions.test.ts index 508c9755..3b477ca4 100644 --- a/tests/integration/gw-dat-dimensions.test.ts +++ b/tests/integration/gw-dat-dimensions.test.ts @@ -6,6 +6,7 @@ import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; import { test } from "node:test"; +import { nativeExecutableName } from "../../src/main/core/paths.js"; const run = promisify(execFile); const root = process.cwd(); @@ -13,9 +14,12 @@ const MAX_COMPRESSED_BYTES = 1024 * 1024; test( "the archive decoder refuses oversized stdin without waiting for EOF", - { skip: process.platform !== "darwin" }, async () => { - const decoder = spawn(path.join(root, "build/native/gw-dat-decode"), [], { + const decoder = spawn(path.join( + root, + "build/native", + nativeExecutableName("gw-dat-decode", process.platform), + ), [], { stdio: ["pipe", "ignore", "ignore"], windowsHide: true, }); diff --git a/tests/policy/fuses.test.ts b/tests/policy/fuses.test.ts index 5ebd04be..38aa8c7f 100644 --- a/tests/policy/fuses.test.ts +++ b/tests/policy/fuses.test.ts @@ -18,7 +18,6 @@ const FUSES = { EnableCookieEncryption: false, EnableNodeOptionsEnvironmentVariable: false, EnableNodeCliInspectArguments: false, - EnableEmbeddedAsarIntegrityValidation: true, OnlyLoadAppFromAsar: true, LoadBrowserProcessSpecificV8Snapshot: false, GrantFileProtocolExtraPrivileges: false, @@ -33,6 +32,10 @@ test("release fuses keep Node, inspection and file-protocol privileges disabled" `FuseV1Options.${name} must be ${value}`, ); } + assert.match( + forge, + /\[FuseV1Options\.EnableEmbeddedAsarIntegrityValidation\]:\s+platform !== "linux"/u, + ); assert.match(forge, /strictlyRequireAllFuses: true/); }); @@ -40,5 +43,13 @@ test("no fuse is left to its default", () => { const configured = [...forge.matchAll(/\[FuseV1Options\.(\w+)\]/g)].map( (match) => match[1], ); - assert.deepEqual(configured.sort(), Object.keys(FUSES).sort()); + assert.deepEqual(configured.sort(), [ + ...Object.keys(FUSES), + "EnableEmbeddedAsarIntegrityValidation", + ].sort()); +}); + +test("fuses are applied to every packaged platform executable", () => { + assert.match(forge, /packagedExecutablePath\(resourcesPath, platform\)/u); + assert.doesNotMatch(forge, /if \(platform !== "darwin"\) return/u); }); diff --git a/tests/policy/source-native-keychain.test.ts b/tests/policy/source-native-keychain.test.ts index 0e5dd433..29d5d03c 100644 --- a/tests/policy/source-native-keychain.test.ts +++ b/tests/policy/source-native-keychain.test.ts @@ -3,7 +3,11 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; -import { BUILD_STEPS } from "../../scripts/build.mjs"; +import { + BUILD_STEPS, + nativeBuildSteps, + packageManagerInvocation, +} from "../../scripts/build.mjs"; import { DISTRIBUTION_CHANNEL_CONFIG, DISTRIBUTION_CHANNELS, @@ -105,21 +109,58 @@ test("the canonical build emits one host-only Node-API 8 addon", () => { ); }); -// Two files are unpacked, and both are executable code that cannot run from -// inside the archive: an addon cannot be dlopen'd from it and a helper cannot -// be spawned from it. The pattern stays a literal pair rather than a directory -// glob, so adding a third is an edit here as well as there. -test("Forge unpacks only the two executables from ASAR", () => { +test("Windows and Linux build the decoder without the Darwin host", () => { + const windows = nativeBuildSteps("win32", "x64"); + const linux = nativeBuildSteps("linux", "x64"); + + assert.deepEqual(windows.map(([command]) => command), ["cl.exe"]); + assert.ok(windows[0]?.[1].includes("/Fe:build/native/gw-dat-decode.exe")); + assert.deepEqual(linux.map(([command]) => command), ["c++"]); + assert.deepEqual(linux[0]?.[1].slice(-2), [ + "-o", + "build/native/gw-dat-decode", + ]); + for (const steps of [windows, linux]) { + assert.equal( + steps.some(([, args]) => args.includes("src/native/host/host.mm")), + false, + ); + } +}); + +test("the build invokes pnpm through Node without shell parsing", () => { + assert.deepEqual( + packageManagerInvocation("win32", "C:\\pnpm\\pnpm.cjs", "node.exe"), + ["node.exe", ["C:\\pnpm\\pnpm.cjs"]], + ); + assert.deepEqual( + packageManagerInvocation("linux", "/opt/pnpm/pnpm.cjs", "/usr/bin/node"), + ["/usr/bin/node", ["/opt/pnpm/pnpm.cjs"]], + ); + assert.throws(() => packageManagerInvocation("win32", undefined)); + assert.deepEqual(packageManagerInvocation("darwin", undefined), ["pnpm", []]); +}); + +test("the first target ports refuse unsupported CPU architectures", () => { + assert.throws(() => nativeBuildSteps("win32", "arm64")); + assert.throws(() => nativeBuildSteps("linux", "arm64")); +}); + +// Each package contains the decoder for its own platform. macOS also contains +// the native host. Every listed file is executable code that cannot run inside +// the archive, and the allowlist remains exact rather than opening a directory. +test("Forge unpacks only the platform-native executables from ASAR", () => { const forge = read("forge.config.ts"); const packageIgnore = read("scripts/package-ignore.ts"); assert.match( forge, - /asar: \{ unpack: "\*\*\/build\/native\/\{host\.node,gw-dat-decode\}" \}/u, + /unpack: "\*\*\/build\/native\/\{host\.node,gw-dat-decode,gw-dat-decode\.exe\}"/u, ); for (const kept of [ /p === "\/build\/native"/u, /p === "\/build\/native\/host\.node"/u, /p === "\/build\/native\/gw-dat-decode"/u, + /p === "\/build\/native\/gw-dat-decode\.exe"/u, ]) { assert.match(packageIgnore, kept); } diff --git a/tests/policy/source-release-pipeline.test.ts b/tests/policy/source-release-pipeline.test.ts index 3cfe5167..fa323c99 100644 --- a/tests/policy/source-release-pipeline.test.ts +++ b/tests/policy/source-release-pipeline.test.ts @@ -171,7 +171,10 @@ test("distribution channels use preflighted signing and a scoped marker", () => assert.match(forge, /GW_PACKAGE_INTENT/); assert.doesNotMatch(forge, /GW_PACKAGE_CHANNEL|GW_SIGN_DISTRIBUTION/); assert.match(forge, /distribution-channel\.json/); - assert.match(forge, /if \(packageMode\.kind === "signed"\)/); + assert.match( + forge, + /if \(platform === "darwin" && packageMode\.kind === "signed"\)/, + ); assert.doesNotMatch(forge, /official-update\.json|GW_OFFICIAL_RELEASE/); assert.match(forge, /appBundleId: channelConfig\.bundleId/); assert.match(signing, /provisioningProfile: input\.profile/); diff --git a/tests/policy/source-saved-login-surface.test.ts b/tests/policy/source-saved-login-surface.test.ts index 876c181d..695b8006 100644 --- a/tests/policy/source-saved-login-surface.test.ts +++ b/tests/policy/source-saved-login-surface.test.ts @@ -73,8 +73,11 @@ test("only provisioned distribution channels enable persistent secrets", () => { assert.match(profileStorage, /workspace\.legacyPrimaryProfileId/); assert.doesNotMatch(main, /accountWorkspace\.legacyPrimaryProfileId/); assert.match(main, /capable: distribution\.automaticUpdates/); - assert.match(main, /persistentSecrets\s*\? nativeHost/); - assert.match(main, /: new VolatileNativeKeychain\(\)/); + assert.match( + main, + /if \(persistentSecrets\) \{[\s\S]{0,180}darwinNativeHost === null[\s\S]{0,180}persistent secret provider is unavailable[\s\S]{0,120}keychain = darwinNativeHost/, + ); + assert.match(main, /else \{\s*keychain = new VolatileNativeKeychain\(\)/); assert.doesNotMatch(shippedApplication, /use-mock-keychain/); }); diff --git a/tests/unit/paths.test.ts b/tests/unit/paths.test.ts index 4073d3ae..fd757520 100644 --- a/tests/unit/paths.test.ts +++ b/tests/unit/paths.test.ts @@ -8,6 +8,7 @@ import { documentDirectories, gamePaths, multiProfilePaths, + nativeExecutableName, unpackedPath, } from "../../src/main/core/paths.ts"; import { parseProfileId } from "../../src/shared/multiple-accounts.ts"; @@ -153,6 +154,12 @@ describe("resolved profile paths", () => { } }); + it("adds an executable suffix only on Windows", () => { + assert.equal(nativeExecutableName("gw-dat-decode", "darwin"), "gw-dat-decode"); + assert.equal(nativeExecutableName("gw-dat-decode", "linux"), "gw-dat-decode"); + assert.equal(nativeExecutableName("gw-dat-decode", "win32"), "gw-dat-decode.exe"); + }); + it("pins the diagnostics frame log name", () => { assert.equal( diagnosticFramesPath(`${root}/diagnostics`, "abc-123"),