Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .github/workflows/portable-native-build.yml
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions docs/process-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
42 changes: 33 additions & 9 deletions forge.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,33 @@ 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];
if (!value) throw new Error(`${name} is required for signed packaging`);
return value;
}

const distributionSigning = packageMode.kind === "signed"
const distributionSigning = buildingDarwin && packageMode.kind === "signed"
? (() => {
const { channel } = packageMode;
const identity = requiredSigningEnvironment("APPLE_SIGNING_IDENTITY");
Expand All @@ -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"),
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -137,21 +158,21 @@ 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`,
{ mode: 0o644 },
);
}
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,
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading