From b2d4fb9650a3736cfb014a174c6cdf15b8367551 Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Thu, 13 Aug 2026 06:14:52 +0200 Subject: [PATCH 01/22] Define isolated multiple accounts mode --- PRODUCT.md | 10 +++- docs/README.md | 1 + docs/multiple-accounts.md | 113 ++++++++++++++++++++++++++++++++++++++ docs/process-model.md | 46 ++++++++++++---- docs/user-guide.md | 25 +++++++++ 5 files changed, 183 insertions(+), 12 deletions(-) create mode 100644 docs/multiple-accounts.md diff --git a/PRODUCT.md b/PRODUCT.md index 4a322eb..70f7021 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -23,6 +23,10 @@ wants the official game without Windows, Wine, or a browser tab. Some returning players also want a small set of familiar tools. They do not need a plugin platform. +Some players use more than one Guild Wars account. They can explicitly enable +**Multiple Accounts** mode to open independently controlled accounts in +separate windows. The normal Single Account mode stays the default. + ## Product promise - Keep the official game playable after an unknown ArenaNet update. @@ -31,6 +35,8 @@ need a plugin platform. - Keep host-owned Builds and Teams available without live Tools. - Give players clear Stable and Beta application-update behavior. - Keep local data and diagnostics under the player's control. +- Keep Single Account data unchanged when a player enters or leaves Multiple + Accounts mode. - Keep the project understandable for one new contributor. ## Tools @@ -67,7 +73,9 @@ See [Release verification](docs/release-verification.md). - No Windows or Linux version. - No redistribution of ArenaNet game binaries. - No autonomous gameplay. -- No bots, macros, multiboxing support, or trading tools. +- No bots, macros, input broadcasting, synchronized control, or trading tools. +- No cloned application installations or duplicated ArenaNet game downloads + for Multiple Accounts mode. - No generic memory, packet, command, or plugin API. - No port of the Windows plugin ABI. - No gwonmac telemetry from the Mac app. diff --git a/docs/README.md b/docs/README.md index a435006..c0cc639 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ its rules. | --- | --- | | How does a player use or recover the app? | [User guide](user-guide.md) | | Which process owns this work? | [Process model](process-model.md) | +| How do Single and Multiple Accounts mode isolate player data? | [Multiple Accounts](multiple-accounts.md) | | How do ArenaNet client files and game data update? | [Content pipeline](content-pipeline.md) | | How does the official client host and certification work? | [WASM host](wasm-host.md) | | What can diagnostics record and export? | [Diagnostics](diagnostics.md) | diff --git a/docs/multiple-accounts.md b/docs/multiple-accounts.md new file mode 100644 index 0000000..0f62f82 --- /dev/null +++ b/docs/multiple-accounts.md @@ -0,0 +1,113 @@ +# Multiple Accounts + +This document owns the player-data boundary between Single Account mode and +Multiple Accounts mode. + +## Product boundary + +Single Account mode is the default. It starts Guild Wars directly and keeps +the existing saved login, Guild Wars files, builds, settings, and window state. + +Multiple Accounts mode is an explicit opt-in workspace. It starts at the +Account Picker. The player can open one or more independently controlled Guild +Wars accounts. The app does not broadcast input or automate gameplay. + +The active mode is fixed for the lifetime of the app process. A mode change +takes effect after a restart. + +## Canonical data owners + +| Data | Owner | +| --- | --- | +| Verified client, chunks, compatibility artifacts, and skill assets | Shared app infrastructure | +| Application updater and update preferences | Shared app infrastructure | +| Active account mode | Launcher-mode document | +| Single saved login | Existing fixed Keychain items | +| Single Guild Wars files and templates | Default Electron session | +| Single builds and teams | Existing root build library | +| Single window state | Existing root window state | +| Multiple Accounts profile registry | Multiple Accounts workspace | +| Profile saved login | Profile-scoped Keychain items | +| Profile Guild Wars files | Profile persistent Electron session | +| Profile window state | Profile window-state document | +| Shared Multiple Accounts templates | Multiple Accounts shared template library | +| Private Multiple Accounts templates | Profile template library | +| Shared Multiple Accounts builds | Multiple Accounts shared build library | +| Private Multiple Accounts builds | Profile build library | +| Running, queued, failed, and crashed status | Live window registry | + +Single Account mode is not a Multiple Accounts profile. No Multiple Accounts +game window uses the default Electron session or the fixed Single Account +Keychain items. + +## Setup and mode transitions + +Settings shows **Set Up Multiple Accounts…** only in the Advanced pane until +the player enables the mode. + +Setup creates a staged Multiple Accounts workspace and at least one profile. +The player signs in separately for every profile. Setup can copy templates, +builds, and teams from Single Account mode. This import reads a stable snapshot +and writes a new Multiple Accounts destination. It never moves, links, mirrors, +or later synchronizes the Single Account source. + +The app publishes the workspace before it publishes the selected mode. A +cancelled or failed setup leaves Single Account mode selected. An import failure +does not change its source or the previous destination revision. + +Returning to Single Account mode preserves the complete Multiple Accounts +workspace. Re-enabling it restores the profiles and libraries. Neither +transition copies data automatically. + +## Multiple Accounts sharing + +Sharing applies only among Multiple Accounts profiles. Each profile selects +**Shared** or **Private** independently for templates and for builds and teams. + +Build libraries are main-process documents with revisions. A stale renderer +cannot replace a newer shared library without an explicit conflict result. + +Every profile keeps an isolated IDBFS mount. A profile that uses Shared +templates receives a working projection of the canonical Multiple Accounts +template library. The app reconciles that projection before launch and after a +clean close. It does not mutate another running renderer's filesystem. + +Template reconciliation preserves both contents when two different templates +use the same normalized path. A deletion cannot silently discard a concurrent +edit. The canonical library and each profile checkpoint use revisions, so a +projection can be rebuilt. + +## Lifecycle and recovery + +Every cold Multiple Accounts launch opens the Account Picker with no profile +selected. One profile ID maps to at most one live game window. A duplicate +launch request focuses that window. + +The app starts selected profiles in a bounded queue. It confirms a new client +generation with one canary renderer before it starts the remaining profiles. +One profile failure does not close another profile. + +Closing a profile flushes its filesystem and closes only its sockets. Quitting +the app flushes all live profile filesystems in parallel. After an application +update or process crash, Multiple Accounts mode returns to the Account Picker. +It does not reopen profiles automatically. + +Archive is the normal profile-removal action. It preserves the profile session, +private libraries, and Keychain items. Permanent deletion is a separate, +confirmed action. It never removes a shared library or Single Account data. + +Reset actions name their scope. A Single Account saved-files reset clears only +the default session. A profile reset clears only the selected persistent +session. Clearing downloaded game data affects the shared app infrastructure +and does not clear player files or saved login. + +## Security and privacy + +The window registry derives profile authority from the trusted sender. A +renderer cannot choose a profile ID, native path, Electron partition, Keychain +item, or socket owner. + +The Account Picker cannot access game sockets, saved login, player files, or +build writes. Diagnostics use ephemeral window identifiers. They do not record +profile names, stable profile IDs, account identifiers, credentials, template +contents, or game traffic. diff --git a/docs/process-model.md b/docs/process-model.md index 2255e9c..37c64ef 100644 --- a/docs/process-model.md +++ b/docs/process-model.md @@ -14,6 +14,8 @@ those facts. ```text Electron main process application lifecycle + active Single or Multiple Accounts mode + game-window registry ArenaNet client and content updates verified client generations and rollback native chunk storage @@ -30,7 +32,7 @@ Sandboxed preload | frozen window.gwNative capabilities v Chromium renderer - launcher and settings + account picker, launcher, and settings Guild Wars Module host input and presentation required Core features @@ -47,6 +49,23 @@ routes. The preload exposes one frozen `window.gwNative` object. It transports capabilities. It does not own game rules or persistence rules. +## Account modes + +The process captures one account mode at startup. It does not switch storage +owners while it runs. + +Single Account mode uses the existing default Electron session, saved-login +items, build library, and window state. Multiple Accounts mode does not treat +Single Account mode as a profile. Each Multiple Accounts profile uses a +non-default persistent Electron session and profile-scoped native stores. + +Both modes use the same verified client generation, chunk store, derived +client artifacts, and application updater. These stores contain rebuildable +client infrastructure. They do not contain player account state. + +[Multiple Accounts](multiple-accounts.md) owns the complete data and transition +contract. + ## Client generation ownership Three types have different jobs: @@ -163,10 +182,12 @@ The main process owns these native stores: - verified ArenaNet client generations; - the content-addressed chunk store; - bounded diagnostics files; -- two saved-login items in Apple's Data Protection Keychain. +- Single Account and profile-scoped saved-login items in Apple's Data + Protection Keychain. -The renderer owns the Guild Wars IDBFS mount under the `gw://app` origin. This -mount contains game preferences, templates, screenshots, and chat logs. +Each game renderer owns one Guild Wars IDBFS mount under its isolated +`gw://app` session. The mount contains game preferences, templates, +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. @@ -176,9 +197,10 @@ authority. The Release, Preview, and signed Development identities use separate Keychain authority. Each identity can read only its own provisioned items. -One item stores the ArenaNet user name and password. One item stores the Steam -access token and expiry. A read failure does not delete an item. The game can -continue to its login screen when an item is unavailable. +Each account scope has one item for the ArenaNet user name and password and one +item for the Steam access token and expiry. The existing fixed items belong +only to Single Account mode. A read failure does not delete an item. The game +can continue to its login screen when an item is unavailable. Unpackaged and ordinary local builds use volatile storage. They do not claim a provisioned Keychain item. There is no file or `safeStorage` fallback. @@ -209,11 +231,13 @@ rollback procedures. ## Application lifecycle The app acquires a single-instance lock before it reads or cleans profile-owned -files. A second launch focuses the existing window and exits. +files. In Single Account mode, a second launch focuses the game. In Multiple +Accounts mode, it opens or focuses the Account Picker. -Closing the game window quits the application. Quit follows one bounded cleanup -path. It saves the renderer filesystem, closes sockets, stops background work, -flushes diagnostics, and exits. +Closing the Single Account game window quits the application. Closing one +Multiple Accounts game window closes only that profile. Application quit saves +all live renderer filesystems in parallel, closes sockets, stops background +work, flushes diagnostics, and exits through one bounded cleanup path. Main-to-renderer events stop after the window or its `webContents` is destroyed. The app attempts renderer recovery only after unexpected renderer loss. It does diff --git a/docs/user-guide.md b/docs/user-guide.md index f08e172..a3153cc 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -25,6 +25,31 @@ the client files, asks how to store game data, and starts Guild Wars. Later starts use verified cached data when possible. Login and online play still need ArenaNet. +## Single and Multiple Accounts + +Single Account mode is the default. It starts Guild Wars directly and keeps +the login, templates, builds, settings, and window state that you already use. + +Open **Settings → Advanced → Set Up Multiple Accounts…** to create a separate +Multiple Accounts workspace. Every later Multiple Accounts start opens the +Account Picker. Select one or more profiles and choose **Open Accounts**. + +Each profile signs in separately and keeps separate Guild Wars preferences, +screenshots, chat logs, saved login, and window position. Profiles can use the +shared Multiple Accounts template and build libraries or private libraries. + +Setup can copy templates, builds, and teams from Single Account mode. This is a +one-time copy. The originals remain in Single Account mode. Later changes do +not synchronize between the two modes. + +Use **Settings → Accounts → Return to Single Account Mode** to change the next +launch. Your Multiple Accounts profiles stay available if you enable the mode +again. The modes share verified game downloads, so creating a profile does not +download another complete copy of Guild Wars. + +Every account window is independently controlled. The app does not broadcast +keyboard, mouse, or controller input between windows. + A local source build has a temporary identity. It does not share saved-login access with the published Release app. From 00770d6e7204b3333c51ba1942aa24298d7ebdc3 Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Thu, 13 Aug 2026 06:18:15 +0200 Subject: [PATCH 02/22] Add multiple accounts workspace storage --- src/main/core/multiple-accounts.ts | 90 +++++++++++++++++++ src/main/core/paths.ts | 40 +++++++++ src/shared/errors.ts | 2 + src/shared/multiple-accounts.ts | 126 +++++++++++++++++++++++++++ tests/unit/multiple-accounts.test.ts | 117 +++++++++++++++++++++++++ tests/unit/paths.test.ts | 21 +++++ 6 files changed, 396 insertions(+) create mode 100644 src/main/core/multiple-accounts.ts create mode 100644 src/shared/multiple-accounts.ts create mode 100644 tests/unit/multiple-accounts.test.ts diff --git a/src/main/core/multiple-accounts.ts b/src/main/core/multiple-accounts.ts new file mode 100644 index 0000000..2e6a771 --- /dev/null +++ b/src/main/core/multiple-accounts.ts @@ -0,0 +1,90 @@ +/** + * The durable Single/Multiple Accounts selection and Multi profile registry. + * + * A missing launcher-mode document means the legacy Single Account path. An + * existing malformed document fails closed. Writes use the repository's one + * atomic file publisher so setup cannot expose a partial workspace or mode. + */ +import { randomUUID } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { + parseLauncherMode, + parseMultiWorkspace, + parseProfileName, + type AccountMode, + type LibraryScope, + type MultiWorkspace, + type ProfileId, +} from "../../shared/multiple-accounts.js"; +import { AppError } from "../../shared/errors.js"; +import { writeAtomicJson } from "./atomic-file.js"; + +const DOCUMENT_MODE = 0o600; + +async function readDocument(path: string): Promise { + let text: string; + try { + text = await readFile(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } + try { + return JSON.parse(text) as unknown; + } catch (error) { + throw new AppError( + path.endsWith("launcher-mode.json") ? "bad_launcher_mode" : "bad_multi_workspace", + "account-mode data is not valid JSON", + { cause: error }, + ); + } +} + +export async function loadAccountMode(path: string): Promise { + const value = await readDocument(path); + return value === null ? "single" : parseLauncherMode(value).mode; +} + +export async function saveAccountMode( + path: string, + mode: AccountMode, +): Promise { + const parsed = parseLauncherMode({ formatVersion: 1, mode }); + await writeAtomicJson(path, parsed, DOCUMENT_MODE); + return parsed.mode; +} + +export async function loadMultiWorkspace( + path: string, +): Promise { + const value = await readDocument(path); + return value === null ? null : parseMultiWorkspace(value); +} + +export async function saveMultiWorkspace( + path: string, + workspace: MultiWorkspace, +): Promise { + const parsed = parseMultiWorkspace(workspace); + await writeAtomicJson(path, parsed, DOCUMENT_MODE); + return parsed; +} + +export function createMultiWorkspace(options: { + readonly name: string; + readonly templates: LibraryScope; + readonly builds: LibraryScope; + readonly id?: string; +}): MultiWorkspace { + const id = (options.id ?? randomUUID()) as ProfileId; + return parseMultiWorkspace({ + formatVersion: 1, + profiles: [{ + id, + name: parseProfileName(options.name), + archived: false, + templates: options.templates, + builds: options.builds, + }], + }); +} diff --git a/src/main/core/paths.ts b/src/main/core/paths.ts index 356bbb1..58a7c46 100644 --- a/src/main/core/paths.ts +++ b/src/main/core/paths.ts @@ -13,6 +13,7 @@ */ import path from "node:path"; import type { CLIENT_ARTIFACTS } from "./access-key.js"; +import type { ProfileId } from "../../shared/multiple-accounts.js"; import { clientGenerationPaths } from "./client-compatibility.js"; export interface GamePaths { @@ -20,6 +21,13 @@ export interface GamePaths { settings: string; buildLibrary: string; windowState: string; + launcherMode: string; + multiRoot: string; + multiWorkspace: string; + multiHubWindowState: string; + multiSharedBuildLibrary: string; + multiSharedTemplates: string; + multiProfiles: string; diagnostics: string; game: string; artifacts: string; @@ -39,11 +47,19 @@ export interface GamePaths { export function gamePaths(userData: string): GamePaths { const game = path.join(userData, "game"); const artifacts = path.join(game, "artifacts"); + const multiRoot = path.join(userData, "multi"); return { userData, settings: path.join(userData, "settings.json"), buildLibrary: path.join(userData, "build-library.json"), windowState: path.join(userData, "window-state.json"), + launcherMode: path.join(userData, "launcher-mode.json"), + multiRoot, + multiWorkspace: path.join(multiRoot, "workspace.json"), + multiHubWindowState: path.join(multiRoot, "hub-window-state.json"), + multiSharedBuildLibrary: path.join(multiRoot, "shared", "build-library.json"), + multiSharedTemplates: path.join(multiRoot, "shared", "templates.json"), + multiProfiles: path.join(multiRoot, "profiles"), diagnostics: path.join(userData, "diagnostics"), game, artifacts, @@ -64,6 +80,29 @@ export function gamePaths(userData: string): GamePaths { }; } +export interface MultiProfilePaths { + readonly root: string; + readonly buildLibrary: string; + readonly templates: string; + readonly templateSync: string; + readonly windowState: string; +} + +/** Resolve stores only after `parseProfileId` has made traversal impossible. */ +export function multiProfilePaths( + paths: GamePaths, + profileId: ProfileId, +): MultiProfilePaths { + const root = path.join(paths.multiProfiles, profileId); + return { + root, + buildLibrary: path.join(root, "build-library.json"), + templates: path.join(root, "templates.json"), + templateSync: path.join(root, "template-sync.json"), + windowState: path.join(root, "window-state.json"), + }; +} + /** * Stable document roots whose direct atomic-write temporaries need the generic * boot-time sweep. @@ -77,6 +116,7 @@ export function gamePaths(userData: string): GamePaths { export function documentDirectories(paths: GamePaths): string[] { return [ paths.userData, + paths.multiRoot, paths.game, paths.diagnostics, paths.chunks, diff --git a/src/shared/errors.ts b/src/shared/errors.ts index 4a1b912..357467d 100644 --- a/src/shared/errors.ts +++ b/src/shared/errors.ts @@ -19,6 +19,8 @@ export const ERROR_CODES = [ "bad_compression", "bad_digest", "bad_manifest", + "bad_launcher_mode", + "bad_multi_workspace", "bad_range", "bad_settings", "bad_window_state", diff --git a/src/shared/multiple-accounts.ts b/src/shared/multiple-accounts.ts new file mode 100644 index 0000000..7eec835 --- /dev/null +++ b/src/shared/multiple-accounts.ts @@ -0,0 +1,126 @@ +/** + * The durable vocabulary for the opt-in Multiple Accounts workspace. + * + * Stable profile IDs authorize native resources, so this module validates + * them before main derives a path, partition, or Keychain item. Display names + * never carry authority. Runtime launch state is deliberately absent because + * the live window registry is its only owner. + */ +import { AppError } from "./errors.js"; + +export const ACCOUNT_MODES = ["single", "multi"] as const; +export type AccountMode = (typeof ACCOUNT_MODES)[number]; + +export const LIBRARY_SCOPES = ["shared", "private"] as const; +export type LibraryScope = (typeof LIBRARY_SCOPES)[number]; + +declare const PROFILE_ID: unique symbol; +export type ProfileId = string & { readonly [PROFILE_ID]: true }; + +export interface LauncherModeDocument { + readonly formatVersion: 1; + readonly mode: AccountMode; +} + +export interface MultiProfile { + readonly id: ProfileId; + readonly name: string; + readonly archived: boolean; + readonly templates: LibraryScope; + readonly builds: LibraryScope; +} + +export interface MultiWorkspace { + readonly formatVersion: 1; + readonly profiles: readonly MultiProfile[]; +} + +const PROFILE_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; +const CONTROL_CHARACTER = /[\p{Cc}\p{Cf}]/u; +export const PROFILE_NAME_MAX_LENGTH = 48; + +function record(value: unknown, owner: string): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new AppError(owner === "launcher mode" ? "bad_launcher_mode" : "bad_multi_workspace", `${owner} must be an object`); + } + return value as Record; +} + +export function parseProfileId(value: unknown): ProfileId { + if (typeof value !== "string" || !PROFILE_ID_PATTERN.test(value)) { + throw new AppError("bad_multi_workspace", "profile id must be a lowercase UUID v4"); + } + return value as ProfileId; +} + +export function parseProfileName(value: unknown): string { + if (typeof value !== "string") { + throw new AppError("bad_multi_workspace", "profile name must be text"); + } + const name = value.normalize("NFC").trim(); + if ( + name.length === 0 + || name.length > PROFILE_NAME_MAX_LENGTH + || CONTROL_CHARACTER.test(name) + ) { + throw new AppError("bad_multi_workspace", "profile name is empty, too long, or contains a control character"); + } + return name; +} + +/** Duplicate labels would make windows and destructive confirmations unsafe. */ +export function profileNameKey(name: string): string { + return name.normalize("NFKC").trim().toLowerCase(); +} + +function parseLibraryScope(value: unknown, field: string): LibraryScope { + if (value !== "shared" && value !== "private") { + throw new AppError("bad_multi_workspace", `profile ${field} must be shared or private`); + } + return value; +} + +export function parseLauncherMode(value: unknown): LauncherModeDocument { + const source = record(value, "launcher mode"); + if (source.formatVersion !== 1) { + throw new AppError("bad_launcher_mode", "launcher mode format is not supported"); + } + if (source.mode !== "single" && source.mode !== "multi") { + throw new AppError("bad_launcher_mode", "launcher mode must be single or multi"); + } + return { formatVersion: 1, mode: source.mode }; +} + +export function parseMultiWorkspace(value: unknown): MultiWorkspace { + const source = record(value, "Multiple Accounts workspace"); + if (source.formatVersion !== 1 || !Array.isArray(source.profiles)) { + throw new AppError("bad_multi_workspace", "workspace format or profiles are invalid"); + } + const ids = new Set(); + const names = new Set(); + const profiles = source.profiles.map((raw): MultiProfile => { + const profile = record(raw, "Multiple Accounts profile"); + const id = parseProfileId(profile.id); + const name = parseProfileName(profile.name); + if (typeof profile.archived !== "boolean") { + throw new AppError("bad_multi_workspace", "profile archived must be a boolean"); + } + if (ids.has(id) || names.has(profileNameKey(name))) { + throw new AppError("bad_multi_workspace", "profile ids and names must be unique"); + } + ids.add(id); + names.add(profileNameKey(name)); + return { + id, + name, + archived: profile.archived, + templates: parseLibraryScope(profile.templates, "templates"), + builds: parseLibraryScope(profile.builds, "builds"), + }; + }); + if (!profiles.some((profile) => !profile.archived)) { + throw new AppError("bad_multi_workspace", "workspace needs an active profile"); + } + return { formatVersion: 1, profiles }; +} diff --git a/tests/unit/multiple-accounts.test.ts b/tests/unit/multiple-accounts.test.ts new file mode 100644 index 0000000..bd93ae1 --- /dev/null +++ b/tests/unit/multiple-accounts.test.ts @@ -0,0 +1,117 @@ +/** The account-mode documents preserve Single and reject ambiguous profiles. */ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "node:test"; +import { + createMultiWorkspace, + loadAccountMode, + loadMultiWorkspace, + saveAccountMode, + saveMultiWorkspace, +} from "../../src/main/core/multiple-accounts.js"; +import { + parseMultiWorkspace, + parseProfileId, + profileNameKey, +} from "../../src/shared/multiple-accounts.js"; +import { AppError } from "../../src/shared/errors.js"; + +const ID = "2d31e565-9fc8-4dde-9fd4-9d644f8283ae"; + +describe("Multiple Accounts documents", () => { + it("keeps a missing mode and workspace on the legacy Single path", async () => { + const dir = await mkdtemp(join(tmpdir(), "gw-accounts-")); + assert.equal(await loadAccountMode(join(dir, "launcher-mode.json")), "single"); + assert.equal(await loadMultiWorkspace(join(dir, "workspace.json")), null); + }); + + it("publishes a workspace before an explicit Multi selection", async () => { + const dir = await mkdtemp(join(tmpdir(), "gw-accounts-")); + const workspacePath = join(dir, "multi", "workspace.json"); + const modePath = join(dir, "launcher-mode.json"); + const workspace = createMultiWorkspace({ + id: ID, + name: "Main", + templates: "shared", + builds: "private", + }); + await saveMultiWorkspace(workspacePath, workspace); + assert.equal(await loadAccountMode(modePath), "single"); + await saveAccountMode(modePath, "multi"); + assert.equal(await loadAccountMode(modePath), "multi"); + assert.deepEqual(await loadMultiWorkspace(workspacePath), workspace); + assert.deepEqual(JSON.parse(await readFile(modePath, "utf8")), { + formatVersion: 1, + mode: "multi", + }); + }); + + it("fails closed for corrupt or future documents", async () => { + const dir = await mkdtemp(join(tmpdir(), "gw-accounts-")); + const modePath = join(dir, "launcher-mode.json"); + const workspacePath = join(dir, "workspace.json"); + await writeFile(modePath, "{broken"); + await assert.rejects(loadAccountMode(modePath), AppError); + await writeFile(modePath, JSON.stringify({ formatVersion: 2, mode: "single" })); + await assert.rejects(loadAccountMode(modePath), AppError); + await writeFile(workspacePath, JSON.stringify({ formatVersion: 2, profiles: [] })); + await assert.rejects(loadMultiWorkspace(workspacePath), AppError); + }); + + it("accepts only lowercase UUID v4 identifiers", () => { + assert.equal(parseProfileId(ID), ID); + assert.throws(() => parseProfileId("../single"), AppError); + assert.throws(() => parseProfileId(ID.toUpperCase()), AppError); + assert.throws( + () => parseProfileId("2d31e565-9fc8-3dde-9fd4-9d644f8283ae"), + AppError, + ); + }); + + it("rejects duplicate labels after normalization and case folding", () => { + assert.equal(profileNameKey(" MAIN "), "main"); + assert.throws( + () => parseMultiWorkspace({ + formatVersion: 1, + profiles: [ + { id: ID, name: "Main", archived: false, templates: "shared", builds: "shared" }, + { + id: "6038c349-435a-4483-933f-0a792563a370", + name: " main ", + archived: false, + templates: "private", + builds: "private", + }, + ], + }), + AppError, + ); + }); + + it("requires one active profile and valid binary sharing choices", () => { + assert.throws( + () => parseMultiWorkspace({ + formatVersion: 1, + profiles: [{ + id: ID, + name: "Main", + archived: true, + templates: "shared", + builds: "private", + }], + }), + AppError, + ); + assert.throws( + () => createMultiWorkspace({ + id: ID, + name: "Main", + templates: "linked" as never, + builds: "private", + }), + AppError, + ); + }); +}); diff --git a/tests/unit/paths.test.ts b/tests/unit/paths.test.ts index 7f3da07..821b8d7 100644 --- a/tests/unit/paths.test.ts +++ b/tests/unit/paths.test.ts @@ -6,8 +6,10 @@ import { diagnosticFramesPath, documentDirectories, gamePaths, + multiProfilePaths, unpackedPath, } from "../../src/main/core/paths.ts"; +import { parseProfileId } from "../../src/shared/multiple-accounts.ts"; // Every value below is a literal on purpose. A refactor may move where a path // is *constructed*; it may not change what the path *is*. `game/chunks` holds @@ -24,6 +26,13 @@ describe("resolved profile paths", () => { settings: `${root}/settings.json`, buildLibrary: `${root}/build-library.json`, windowState: `${root}/window-state.json`, + launcherMode: `${root}/launcher-mode.json`, + multiRoot: `${root}/multi`, + multiWorkspace: `${root}/multi/workspace.json`, + multiHubWindowState: `${root}/multi/hub-window-state.json`, + multiSharedBuildLibrary: `${root}/multi/shared/build-library.json`, + multiSharedTemplates: `${root}/multi/shared/templates.json`, + multiProfiles: `${root}/multi/profiles`, diagnostics: `${root}/diagnostics`, game: `${root}/game`, artifacts: `${root}/game/artifacts`, @@ -49,6 +58,7 @@ describe("resolved profile paths", () => { // — nothing else collects them. assert.deepEqual(documentDirectories(gamePaths(root)), [ root, + `${root}/multi`, `${root}/game`, `${root}/diagnostics`, `${root}/game/chunks`, @@ -63,6 +73,17 @@ describe("resolved profile paths", () => { ]); }); + it("derives profile paths only beneath the Multi namespace", () => { + const id = parseProfileId("2d31e565-9fc8-4dde-9fd4-9d644f8283ae"); + assert.deepEqual(multiProfilePaths(gamePaths(root), id), { + root: `${root}/multi/profiles/${id}`, + buildLibrary: `${root}/multi/profiles/${id}/build-library.json`, + templates: `${root}/multi/profiles/${id}/templates.json`, + templateSync: `${root}/multi/profiles/${id}/template-sync.json`, + windowState: `${root}/multi/profiles/${id}/window-state.json`, + }); + }); + it("keeps the downloaded chunk cache exactly where the alpha put it", () => { // Called out separately because this is the expensive one: it is the only // path in the table whose relocation costs a full re-download. From f13cf06b54259e171e07b77ac651f19e914e9924 Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Thu, 13 Aug 2026 06:22:22 +0200 Subject: [PATCH 03/22] Scope saved login to account profiles --- src/main/core/credentials.ts | 9 ++-- src/main/core/native-keychain.ts | 20 ++++++--- src/main/core/steam-session.ts | 9 ++-- src/native/keychain/keychain.mm | 72 +++++++++++++++++++++--------- tests/unit/credentials.test.ts | 17 +++++++ tests/unit/native-keychain.test.ts | 12 ++++- 6 files changed, 106 insertions(+), 33 deletions(-) diff --git a/src/main/core/credentials.ts b/src/main/core/credentials.ts index 1586882..6edc35f 100644 --- a/src/main/core/credentials.ts +++ b/src/main/core/credentials.ts @@ -13,7 +13,7 @@ */ import type { StoredCredentials } from "../../shared/contracts.js"; import { AppError } from "../../shared/errors.js"; -import type { NativeKeychain } from "./native-keychain.js"; +import type { NativeKeychain, SecretSlot } from "./native-keychain.js"; import { KeychainJsonStore, type KeychainSecret } from "./keychain-store.js"; /** @@ -48,7 +48,10 @@ const CREDENTIALS: KeychainSecret = { /** The ArenaNet saved login's fixed Data Protection Keychain item. */ export class CredentialsStore extends KeychainJsonStore { - constructor(keychain: NativeKeychain) { - super("arenaNetCredentials", keychain, CREDENTIALS); + constructor( + keychain: NativeKeychain, + slot: SecretSlot = "arenaNetCredentials", + ) { + super(slot, keychain, CREDENTIALS); } } diff --git a/src/main/core/native-keychain.ts b/src/main/core/native-keychain.ts index 3cafb88..eb9a9d4 100644 --- a/src/main/core/native-keychain.ts +++ b/src/main/core/native-keychain.ts @@ -2,16 +2,26 @@ * The interface every persistent secret talks to, and the closed set of slots * it may name. * - * `SecretSlot` is a union rather than a string, so a new persistent secret is a - * deliberate edit here instead of an ad-hoc item appearing in a player's - * Keychain. `VolatileNativeKeychain` is the implementation for builds with no + * `SecretSlot` is a closed grammar rather than an arbitrary string. Single + * keeps its legacy names; Multi adds the same two kinds under a validated + * profile UUID. `VolatileNativeKeychain` is the implementation for builds with no * provisioned signing identity: secrets live in memory and are lost at quit. * It is not a fallback an entitled build may drop to, and no file, encrypted * blob or mock-Keychain implementation stands beside it as one. */ -export const SECRET_SLOTS = ["arenaNetCredentials", "steamSession"] as const; +import type { ProfileId } from "../../shared/multiple-accounts.js"; -export type SecretSlot = (typeof SECRET_SLOTS)[number]; +export const SINGLE_SECRET_SLOTS = ["arenaNetCredentials", "steamSession"] as const; +export type SingleSecretSlot = (typeof SINGLE_SECRET_SLOTS)[number]; +export type MultiSecretSlot = `multi.${ProfileId}.${SingleSecretSlot}`; +export type SecretSlot = SingleSecretSlot | MultiSecretSlot; + +export function multiSecretSlot( + profileId: ProfileId, + kind: SingleSecretSlot, +): MultiSecretSlot { + return `multi.${profileId}.${kind}`; +} export interface NativeKeychain { load(slot: SecretSlot): Promise; diff --git a/src/main/core/steam-session.ts b/src/main/core/steam-session.ts index ee85aee..9320865 100644 --- a/src/main/core/steam-session.ts +++ b/src/main/core/steam-session.ts @@ -13,7 +13,7 @@ */ import type { SteamRefusalReason } from "../../shared/contracts.js"; import { AppError, errorCode, type ErrorCode } from "../../shared/errors.js"; -import type { NativeKeychain } from "./native-keychain.js"; +import type { NativeKeychain, SecretSlot } from "./native-keychain.js"; import { KeychainJsonStore, type KeychainSecret } from "./keychain-store.js"; import { Mutex } from "./mutex.js"; @@ -79,8 +79,11 @@ const STEAM_SESSION: KeychainSecret = { /** The Steam token's one persistent home. */ export class SteamSessionStore extends KeychainJsonStore { - constructor(keychain: NativeKeychain) { - super("steamSession", keychain, STEAM_SESSION); + constructor( + keychain: NativeKeychain, + slot: SecretSlot = "steamSession", + ) { + super(slot, keychain, STEAM_SESSION); } } diff --git a/src/native/keychain/keychain.mm b/src/native/keychain/keychain.mm index eaa3ace..e4584cc 100644 --- a/src/native/keychain/keychain.mm +++ b/src/native/keychain/keychain.mm @@ -16,13 +16,13 @@ constexpr char kCredentialsSlot[] = "arenaNetCredentials"; constexpr char kSteamSlot[] = "steamSession"; +constexpr char kMultiPrefix[] = "multi."; NSString *const kCredentialsAccount = @"arena-net-credentials"; NSString *const kSteamAccount = @"steam-session"; NSString *const kReleaseBundle = @"io.github.mat4m0.gwonmac"; NSString *const kPreviewBundle = @"io.github.mat4m0.gwonmac.preview"; NSString *const kDevelopmentBundle = @"io.github.mat4m0.gwonmac.dev"; -enum class Slot { kCredentials, kSteam }; enum class Operation { kLoad, kSave, kClear }; enum class Result { kSuccess, @@ -37,7 +37,7 @@ napi_async_work async_work = nullptr; napi_deferred deferred = nullptr; Operation operation = Operation::kLoad; - Slot slot = Slot::kCredentials; + std::string slot; Result result = Result::kUnavailable; std::vector input; std::vector output; @@ -50,8 +50,43 @@ void Zero(std::vector &bytes) { bytes.clear(); } -NSString *AccountForSlot(Slot slot) { - return slot == Slot::kCredentials ? kCredentialsAccount : kSteamAccount; +bool IsLowerHex(char value) { + return (value >= '0' && value <= '9') || (value >= 'a' && value <= 'f'); +} + +bool IsUuidV4(const std::string &value) { + if (value.size() != 36 || value[8] != '-' || value[13] != '-' || + value[18] != '-' || value[23] != '-' || value[14] != '4' || + (value[19] != '8' && value[19] != '9' && value[19] != 'a' && + value[19] != 'b')) + return false; + for (size_t i = 0; i < value.size(); ++i) { + if (i == 8 || i == 13 || i == 18 || i == 23) + continue; + if (!IsLowerHex(value[i])) + return false; + } + return true; +} + +NSString *AccountForSlot(const std::string &slot) { + if (slot == kCredentialsSlot) + return kCredentialsAccount; + if (slot == kSteamSlot) + return kSteamAccount; + const std::string prefix = kMultiPrefix; + if (slot.rfind(prefix, 0) != 0) + return nil; + const size_t separator = slot.find('.', prefix.size()); + if (separator == std::string::npos) + return nil; + const std::string profile = slot.substr(prefix.size(), separator - prefix.size()); + const std::string kind = slot.substr(separator + 1); + if (!IsUuidV4(profile) || + (kind != kCredentialsSlot && kind != kSteamSlot)) + return nil; + NSString *base = kind == kCredentialsSlot ? kCredentialsAccount : kSteamAccount; + return [NSString stringWithFormat:@"%@.multi.%s", base, profile.c_str()]; } NSString *ServiceForHostBundle() { @@ -72,16 +107,17 @@ void Zero(std::vector &bytes) { return @"Guild Wars Reforged Dev saved login"; } -NSMutableDictionary *QueryForSlot(Slot slot) { +NSMutableDictionary *QueryForSlot(const std::string &slot) { NSString *service = ServiceForHostBundle(); - if (service == nil) + NSString *account = AccountForSlot(slot); + if (service == nil || account == nil) return nil; LAContext *context = [[LAContext alloc] init]; context.interactionNotAllowed = YES; return [@{ (__bridge id)kSecClass : (__bridge id)kSecClassGenericPassword, (__bridge id)kSecAttrService : service, - (__bridge id)kSecAttrAccount : AccountForSlot(slot), + (__bridge id)kSecAttrAccount : account, (__bridge id)kSecUseDataProtectionKeychain : @YES, (__bridge id)kSecUseAuthenticationContext : context, } mutableCopy]; @@ -251,27 +287,23 @@ void Complete(napi_env env, napi_status status, void *data) { delete work; } -bool ReadSlot(napi_env env, napi_value value, Slot *slot) { +bool ReadSlot(napi_env env, napi_value value, std::string *slot) { size_t length = 0; if (napi_get_value_string_utf8(env, value, nullptr, 0, &length) != napi_ok || - length > sizeof(kCredentialsSlot)) { + length == 0 || length > 96) { return false; } - char text[sizeof(kCredentialsSlot)] = {}; - if (napi_get_value_string_utf8(env, value, text, sizeof(text), &length) != + std::vector text(length + 1, '\0'); + if (napi_get_value_string_utf8(env, value, text.data(), text.size(), &length) != napi_ok) { return false; } - if (strcmp(text, kCredentialsSlot) == 0) { - *slot = Slot::kCredentials; - return true; - } - if (strcmp(text, kSteamSlot) == 0) { - *slot = Slot::kSteam; - return true; - } - return false; + const std::string candidate(text.data(), length); + if (AccountForSlot(candidate) == nil) + return false; + *slot = candidate; + return true; } napi_value Queue(napi_env env, napi_callback_info info, Operation operation) { diff --git a/tests/unit/credentials.test.ts b/tests/unit/credentials.test.ts index 73f8abd..8d6fa53 100644 --- a/tests/unit/credentials.test.ts +++ b/tests/unit/credentials.test.ts @@ -6,6 +6,8 @@ import type { SecretSlot, } from "../../src/main/core/native-keychain.js"; import { AppError } from "../../src/shared/errors.js"; +import { parseProfileId } from "../../src/shared/multiple-accounts.js"; +import { multiSecretSlot } from "../../src/main/core/native-keychain.js"; class FakeKeychain implements NativeKeychain { readonly values = new Map(); @@ -43,6 +45,21 @@ describe("credentials", () => { assert.equal(await store.load(), null); }); + it("keeps a Multi profile out of the fixed Single slot", async () => { + const keychain = new FakeKeychain(); + const slot = multiSecretSlot( + parseProfileId("2d31e565-9fc8-4dde-9fd4-9d644f8283ae"), + "arenaNetCredentials", + ); + const store = new CredentialsStore(keychain, slot); + await store.save({ username: "multi@example.test", password: "secret" }); + assert.equal(keychain.values.has("arenaNetCredentials"), false); + assert.deepEqual( + JSON.parse(keychain.values.get(slot)!.toString("utf8")), + { username: "multi@example.test", password: "secret" }, + ); + }); + it("maps native failure to the credential vocabulary", async () => { const keychain = new FakeKeychain(); keychain.failure = new Error("injected native failure"); diff --git a/tests/unit/native-keychain.test.ts b/tests/unit/native-keychain.test.ts index adba95f..9926fb8 100644 --- a/tests/unit/native-keychain.test.ts +++ b/tests/unit/native-keychain.test.ts @@ -1,7 +1,8 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { - SECRET_SLOTS, + SINGLE_SECRET_SLOTS, + multiSecretSlot, type NativeKeychain, type SecretSlot, } from "../../src/main/core/native-keychain.js"; @@ -26,7 +27,14 @@ class FakeNativeKeychain implements NativeKeychain { describe("native Keychain boundary", () => { it("has exactly the two product-owned slots", () => { - assert.deepEqual(SECRET_SLOTS, ["arenaNetCredentials", "steamSession"]); + assert.deepEqual(SINGLE_SECRET_SLOTS, ["arenaNetCredentials", "steamSession"]); + assert.equal( + multiSecretSlot( + "2d31e565-9fc8-4dde-9fd4-9d644f8283ae" as never, + "arenaNetCredentials", + ), + "multi.2d31e565-9fc8-4dde-9fd4-9d644f8283ae.arenaNetCredentials", + ); }); it("resolves the one development and one packaged binary path", () => { From d66169227d86e3650265e7e77cd8723284000c86 Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Thu, 13 Aug 2026 06:23:59 +0200 Subject: [PATCH 04/22] Add profile-aware window authority --- src/main/window-registry.ts | 80 ++++++++++++++++++++++++++++++ tests/unit/window-registry.test.ts | 69 ++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 src/main/window-registry.ts create mode 100644 tests/unit/window-registry.test.ts diff --git a/src/main/window-registry.ts b/src/main/window-registry.ts new file mode 100644 index 0000000..2c1f723 --- /dev/null +++ b/src/main/window-registry.ts @@ -0,0 +1,80 @@ +/** + * The authority that binds every native window to one immutable app context. + * + * IPC and lifecycle code resolve context from `webContents.id`; renderers do + * not submit profile identifiers. The registry also enforces the one-window + * per Multi profile invariant and keeps transient launch state out of disk. + */ +import type { BrowserWindow } from "electron"; +import type { ProfileId } from "../shared/multiple-accounts.js"; +import { AppError } from "../shared/errors.js"; + +export type WindowContext = + | Readonly<{ mode: "single"; role: "game" }> + | Readonly<{ mode: "multi"; role: "hub" }> + | Readonly<{ mode: "multi"; role: "game"; profileId: ProfileId }>; + +interface RegisteredWindow { + readonly webContents: { readonly id: number }; + isDestroyed(): boolean; +} + +interface Entry { + readonly win: RegisteredWindow; + readonly context: WindowContext; +} + +export class WindowRegistry { + readonly #byWebContents = new Map(); + readonly #profileWindows = new Map(); + + register(win: RegisteredWindow, context: WindowContext): void { + const id = win.webContents.id; + if (this.#byWebContents.has(id)) { + throw new AppError("validation", "window is already registered"); + } + if (context.mode === "multi" && context.role === "game") { + const existing = this.#profileWindows.get(context.profileId); + if (existing && !existing.isDestroyed()) { + throw new AppError("validation", "profile already has a game window"); + } + this.#profileWindows.set(context.profileId, win); + } + this.#byWebContents.set(id, { win, context }); + } + + unregister(win: RegisteredWindow): void { + const entry = this.#byWebContents.get(win.webContents.id); + if (!entry || entry.win !== win) return; + this.#byWebContents.delete(win.webContents.id); + if (entry.context.mode === "multi" && entry.context.role === "game") { + if (this.#profileWindows.get(entry.context.profileId) === win) { + this.#profileWindows.delete(entry.context.profileId); + } + } + } + + contextForWebContents(id: number): WindowContext | null { + const entry = this.#byWebContents.get(id); + return entry && !entry.win.isDestroyed() ? entry.context : null; + } + + profileWindow(profileId: ProfileId): BrowserWindow | null { + const win = this.#profileWindows.get(profileId); + return win && !win.isDestroyed() ? win as BrowserWindow : null; + } + + windows( + predicate: (context: WindowContext) => boolean = () => true, + ): BrowserWindow[] { + const result: BrowserWindow[] = []; + for (const { win, context } of this.#byWebContents.values()) { + if (!win.isDestroyed() && predicate(context)) result.push(win as BrowserWindow); + } + return result; + } + + gameWindows(): BrowserWindow[] { + return this.windows((context) => context.role === "game"); + } +} diff --git a/tests/unit/window-registry.test.ts b/tests/unit/window-registry.test.ts new file mode 100644 index 0000000..9e19a1c --- /dev/null +++ b/tests/unit/window-registry.test.ts @@ -0,0 +1,69 @@ +/** The window registry is the only authority from IPC senders to profiles. */ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { WindowRegistry } from "../../src/main/window-registry.js"; +import { parseProfileId } from "../../src/shared/multiple-accounts.js"; +import { AppError } from "../../src/shared/errors.js"; + +function fake(id: number) { + let destroyed = false; + return { + webContents: { id }, + isDestroyed: () => destroyed, + destroy: () => { destroyed = true; }, + }; +} + +describe("window registry", () => { + it("resolves immutable context from the native sender id", () => { + const registry = new WindowRegistry(); + const win = fake(7); + const profileId = parseProfileId("2d31e565-9fc8-4dde-9fd4-9d644f8283ae"); + registry.register(win, { mode: "multi", role: "game", profileId }); + assert.deepEqual(registry.contextForWebContents(7), { + mode: "multi", + role: "game", + profileId, + }); + assert.equal(registry.contextForWebContents(8), null); + }); + + it("enforces one live game window for a profile", () => { + const registry = new WindowRegistry(); + const profileId = parseProfileId("2d31e565-9fc8-4dde-9fd4-9d644f8283ae"); + const first = fake(1); + registry.register(first, { mode: "multi", role: "game", profileId }); + assert.throws( + () => registry.register(fake(2), { mode: "multi", role: "game", profileId }), + AppError, + ); + first.destroy(); + registry.register(fake(2), { mode: "multi", role: "game", profileId }); + }); + + it("unregisters only the exact native window", () => { + const registry = new WindowRegistry(); + const win = fake(1); + registry.register(win, { mode: "single", role: "game" }); + registry.unregister(fake(1)); + assert.notEqual(registry.contextForWebContents(1), null); + registry.unregister(win); + assert.equal(registry.contextForWebContents(1), null); + }); + + it("does not return destroyed windows", () => { + const registry = new WindowRegistry(); + const hub = fake(1); + const game = fake(2); + registry.register(hub, { mode: "multi", role: "hub" }); + registry.register(game, { + mode: "multi", + role: "game", + profileId: parseProfileId("2d31e565-9fc8-4dde-9fd4-9d644f8283ae"), + }); + assert.equal(registry.gameWindows().length, 1); + game.destroy(); + assert.equal(registry.gameWindows().length, 0); + assert.equal(registry.windows().length, 1); + }); +}); From 0a3c242050357715b5f9da2c992c10555d5d02dc Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Thu, 13 Aug 2026 06:26:00 +0200 Subject: [PATCH 05/22] Bind game IPC to registered windows --- src/main/ipc.ts | 75 ++++++++++++++++++++++++------------- src/main/main.ts | 7 +++- src/main/window-registry.ts | 3 ++ src/main/window.ts | 19 ++++++++-- 4 files changed, 74 insertions(+), 30 deletions(-) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 1993496..7f01533 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -95,7 +95,7 @@ import { gamePaths } from "./paths.js"; import { isCanonicalRendererUrl } from "./core/renderer-trust.js"; import { MAX_QUEUED_BYTES_PER_SOCKET } from "./core/sockets.js"; import { isQuitting } from "./lifecycle.js"; -import { getMainWindow } from "./window.js"; +import { windowRegistry, type WindowRegistry } from "./window-registry.js"; import { applySettingsChange, confirmSettingsReset, @@ -105,8 +105,10 @@ import { export interface IpcContext { sockets: SocketManager; - credentialsStore: CredentialsStore; - steamSessionStore: SteamSessionStore; + windows: WindowRegistry; + credentialsStoreFor: (win: BrowserWindow) => CredentialsStore; + steamSessionStoreFor: (win: BrowserWindow) => SteamSessionStore; + buildLibraryPathFor: (win: BrowserWindow) => string; getProgress: () => DownloadProgress; getChunkStore: () => ChunkStore | null; getSettings: () => Promise; @@ -130,9 +132,13 @@ export interface IpcContext { type SteamInvokeChannel = "steamToken" | "steamStore" | "steamClear"; -function assertSender(event: Electron.IpcMainInvokeEvent): BrowserWindow { +function assertSender( + registry: WindowRegistry, + event: Electron.IpcMainInvokeEvent, +): BrowserWindow { const win = BrowserWindow.fromWebContents(event.sender); - if (!win || win !== getMainWindow()) { + const context = registry.contextForWebContents(event.sender.id); + if (!win || !context || context.role !== "game") { throw new AllowlistError("unowned ipc sender"); } if (!event.senderFrame || event.senderFrame !== event.sender.mainFrame) { @@ -579,7 +585,6 @@ export function registerIpcHandlers(ctx: IpcContext): { drainSecrets(): Promise; } { const paths = gamePaths(); - const credentials = ctx.credentialsStore; const secretOperations = new Set>(); const secretOperation = (operation: () => Promise): Promise => { if (isQuitting()) { @@ -644,16 +649,16 @@ export function registerIpcHandlers(ctx: IpcContext): { await ctx.sockets.close(socketId, win.webContents.id); }), - buildLibraryGet: channel(nothing, async () => { + buildLibraryGet: channel(nothing, async (win) => { let recovered = false; - const library = await loadBuildLibrary(paths.buildLibrary, () => { + const library = await loadBuildLibrary(ctx.buildLibraryPathFor(win), () => { recovered = true; }); return { library, recovered }; }), - buildLibrarySet: channel(one(parseBuildLibrary), async (_win, library) => { - return saveBuildLibrary(paths.buildLibrary, library); + buildLibrarySet: channel(one(parseBuildLibrary), async (win, library) => { + return saveBuildLibrary(ctx.buildLibraryPathFor(win), library); }), settingsGet: channel(nothing, async () => { @@ -679,9 +684,9 @@ export function registerIpcHandlers(ctx: IpcContext): { confirmSettingsReset(win, ctx.resetSettings), ), - credentialsLoad: channel(nothing, async () => { + credentialsLoad: channel(nothing, async (win) => { try { - return await secretOperation(() => credentials.load()); + return await secretOperation(() => ctx.credentialsStoreFor(win).load()); } catch (error) { logEvent({ k: "credentials.loadFailed", code: errorCode(error) }); throw error; @@ -692,9 +697,9 @@ export function registerIpcHandlers(ctx: IpcContext): { // is that rule, so the boundary is validated without a second opinion. credentialsSave: channel( one(parseCredentials), - async (_win, value: StoredCredentials) => { + async (win, value: StoredCredentials) => { try { - await secretOperation(() => credentials.save(value)); + await secretOperation(() => ctx.credentialsStoreFor(win).save(value)); } catch (error) { logEvent({ k: "credentials.saveFailed", code: errorCode(error) }); throw error; @@ -702,9 +707,9 @@ export function registerIpcHandlers(ctx: IpcContext): { }, ), - credentialsClear: channel(nothing, async () => { + credentialsClear: channel(nothing, async (win) => { try { - await secretOperation(() => credentials.clear()); + await secretOperation(() => ctx.credentialsStoreFor(win).clear()); } catch (error) { logEvent({ k: "credentials.clearFailed", code: errorCode(error) }); throw error; @@ -816,10 +821,11 @@ export function registerIpcHandlers(ctx: IpcContext): { ), } satisfies Record, AnyChannelDef>; - registerChannelDefinitions(handlers); + registerChannelDefinitions(ctx.windows, handlers); const steamSettled = registerSteamIpcHandlers( ctx.acquireSteamToken, - ctx.steamSessionStore, + ctx.steamSessionStoreFor, + ctx.windows, ); return { async drainSecrets() { @@ -832,6 +838,7 @@ export function registerIpcHandlers(ctx: IpcContext): { } function registerChannelDefinitions( + windows: WindowRegistry, handlers: Partial>, ): void { // One registration, uniform and total: `assertSender` first, then the @@ -849,7 +856,7 @@ function registerChannelDefinitions( const def = definition as ChannelDef; const name = key as InvokeChannel; ipcMain.handle(IPC[name], async (event, ...args: unknown[]) => { - const win = assertSender(event); + const win = assertSender(windows, event); let input: unknown; try { input = def.parse(args); @@ -864,9 +871,22 @@ function registerChannelDefinitions( export function registerSteamIpcHandlers( acquireSteamToken: IpcContext["acquireSteamToken"], - store: SteamSessionStore, + storeOrResolver: SteamSessionStore | ((win: BrowserWindow) => SteamSessionStore), + windows?: WindowRegistry, ): () => Promise { - const steam = new SteamSessionCoordinator(store); + const storeFor = typeof storeOrResolver === "function" + ? storeOrResolver + : () => storeOrResolver; + const coordinators = new Map(); + const coordinatorFor = (win: BrowserWindow): SteamSessionCoordinator => { + const store = storeFor(win); + let coordinator = coordinators.get(store); + if (!coordinator) { + coordinator = new SteamSessionCoordinator(store); + coordinators.set(store, coordinator); + } + return coordinator; + }; const runSteamSignIn = async ( win: BrowserWindow, @@ -893,6 +913,7 @@ export function registerSteamIpcHandlers( // rebuilds its own login screen from a refused credential and a rejection // here would only turn "no token" into a launch failure. steamToken: channel(asSilentFlag, async (win, silent) => { + const steam = coordinatorFor(win); if (isQuitting()) throw new ValidationError("application is quitting"); const resolution = await steam.resolve({ silent, @@ -920,14 +941,16 @@ export function registerSteamIpcHandlers( } satisfies SteamTokenResult; }), - steamStore: channel(asSteamStoreback, async (_win, { token, expiry }) => { + steamStore: channel(asSteamStoreback, async (win, { token, expiry }) => { if (isQuitting()) throw new ValidationError("application is quitting"); + const steam = coordinatorFor(win); const outcome = await steam.refresh(token, expiry); logEvent({ k: "steam.storeback", outcome }); }), - steamClear: channel(nothing, async () => { + steamClear: channel(nothing, async (win) => { if (isQuitting()) throw new ValidationError("application is quitting"); + const steam = coordinatorFor(win); try { await steam.clear(); logEvent({ k: "steam.tokenCleared" }); @@ -938,8 +961,10 @@ export function registerSteamIpcHandlers( }), } satisfies Record; - registerChannelDefinitions(handlers); - return () => steam.settled(); + registerChannelDefinitions(windows ?? windowRegistry, handlers); + return async () => { + await Promise.all([...coordinators.values()].map((steam) => steam.settled())); + }; } export function emitSocketEvent(ownerId: number, event: SocketEvent): void { diff --git a/src/main/main.ts b/src/main/main.ts index 349dbe2..957cade 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -107,6 +107,7 @@ import { applyPendingCacheClear, applyPendingGameStorageReset, } from "./settings-actions.js"; +import { windowRegistry } from "./window-registry.js"; // The public app name changed after alpha profiles already existed. Keep that // one profile as the canonical home so the rename cannot strand saved login, @@ -473,8 +474,10 @@ if (primaryInstance) void app.whenReady().then(async () => { const ipcCleanup = registerIpcHandlers({ sockets, - credentialsStore, - steamSessionStore, + windows: windowRegistry, + credentialsStoreFor: () => credentialsStore, + steamSessionStoreFor: () => steamSessionStore, + buildLibraryPathFor: () => paths.buildLibrary, getProgress: () => clientRuntime.progress, getChunkStore: () => clientRuntime.active?.store ?? null, getSettings: () => loadSettings(paths.settings), diff --git a/src/main/window-registry.ts b/src/main/window-registry.ts index 2c1f723..2d8b1eb 100644 --- a/src/main/window-registry.ts +++ b/src/main/window-registry.ts @@ -78,3 +78,6 @@ export class WindowRegistry { return this.windows((context) => context.role === "game"); } } + +/** The process has one native-window authority. */ +export const windowRegistry = new WindowRegistry(); diff --git a/src/main/window.ts b/src/main/window.ts index e47be17..355cc93 100644 --- a/src/main/window.ts +++ b/src/main/window.ts @@ -41,6 +41,7 @@ import { isQuitting } from "./lifecycle.js"; import { gamePaths, preloadPath } from "./paths.js"; import { toggleTools } from "./renderer-commands.js"; import { installApplicationMenu } from "./window-menu.js"; +import { windowRegistry, type WindowContext } from "./window-registry.js"; // Tests launch the app dozens of times; without this they steal keyboard focus // on every launch. Focus-dependent specs leave the flag unset. @@ -267,7 +268,15 @@ export function rendererInitArgument(options: { return `${RENDERER_INIT_ARGUMENT}${JSON.stringify(init)}`; } -export function createMainWindow(host: WindowHost): BrowserWindow { +export function createMainWindow( + host: WindowHost, + options: { + readonly context?: WindowContext; + readonly session?: Electron.Session; + readonly title?: string; + } = {}, +): BrowserWindow { + const context = options.context ?? { mode: "single", role: "game" }; const initialState = restoredWindowState ? fitWindowStateToDisplays( restoredWindowState, @@ -280,7 +289,7 @@ export function createMainWindow(host: WindowHost): BrowserWindow { ...(initialState?.bounds ?? { width: 1280, height: 800 }), minWidth: 800, minHeight: 600, - title: "Guild Wars Reforged", + title: options.title ?? "Guild Wars Reforged", show: false, webPreferences: { preload: preloadPath(), @@ -293,10 +302,12 @@ export function createMainWindow(host: WindowHost): BrowserWindow { spellcheck: false, allowRunningInsecureContent: false, experimentalFeatures: false, + ...(options.session ? { session: options.session } : {}), }, }); mainWindow = win; + windowRegistry.register(win, context); updateLongRunningTaskFeedback(host.getProgress(), win); const rendererId = win.webContents.id; @@ -446,7 +457,7 @@ export function createMainWindow(host: WindowHost): BrowserWindow { }) .finally(() => { if (isQuitting() || win.isDestroyed()) return; - createMainWindow(host); + createMainWindow(host, options); win.destroy(); logEvent({ k: "renderer.recovered" }); }); @@ -461,12 +472,14 @@ export function createMainWindow(host: WindowHost): BrowserWindow { win.on("close", (event) => { if (isQuitting()) return; + if (context.mode === "multi") return; event.preventDefault(); logEvent({ k: "window.closeRequested" }); app.quit(); }); win.on("closed", () => { + windowRegistry.unregister(win); if (mainWindow === win) mainWindow = null; }); From 37f9ef35bbddcc048f9ebcfb571dc6464e6cb81b Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Thu, 13 Aug 2026 06:36:44 +0200 Subject: [PATCH 06/22] Isolate window state per game window --- src/main/window.ts | 134 +++++++++++++++++++++++++++------------------ 1 file changed, 81 insertions(+), 53 deletions(-) diff --git a/src/main/window.ts b/src/main/window.ts index 355cc93..687f88a 100644 --- a/src/main/window.ts +++ b/src/main/window.ts @@ -66,13 +66,18 @@ export interface WindowHost { } let mainWindow: BrowserWindow | null = null; -let rendererRecoveryUsed = false; -let restoredWindowState: WindowState | null = null; -let lastNormalBounds: WindowBounds | null = null; -let windowStateTimer: ReturnType | null = null; -let windowStateWrite: Promise = Promise.resolve(); -let windowStateReset: Promise = Promise.resolve(); -let windowStateResetDepth = 0; +const rendererRecoveryUsed = new Set(); +interface WindowStateOwner { + readonly path: string; + restored: WindowState | null; + lastNormalBounds: WindowBounds | null; + timer: ReturnType | null; + write: Promise; + reset: Promise; + resetDepth: number; +} +const preparedWindowStates = new Map(); +const windowStateOwners = new Map(); let downloadPowerBlockerId: number | null = null; export function updateLongRunningTaskFeedback( @@ -105,35 +110,37 @@ function primaryWorkArea(): WindowBounds { return { ...screen.getPrimaryDisplay().workArea }; } -export async function prepareWindowState(): Promise { - const loaded = await loadWindowState(gamePaths().windowState, () => { +export async function prepareWindowState( + statePath = gamePaths().windowState, +): Promise { + const loaded = await loadWindowState(statePath, () => { logEvent({ k: "window.stateCorruptCleared" }); }); - restoredWindowState = loaded + const restored = loaded ? fitWindowStateToDisplays(loaded, workAreas(), primaryWorkArea()) : null; - lastNormalBounds = restoredWindowState?.bounds ?? null; - if (restoredWindowState) { + preparedWindowStates.set(statePath, restored); + if (restored) { logEvent({ k: "window.stateRestored", - mode: restoredWindowState.mode, - width: restoredWindowState.bounds.width, - height: restoredWindowState.bounds.height, + mode: restored.mode, + width: restored.bounds.width, + height: restored.bounds.height, }); } } -function currentWindowState(win: BrowserWindow): WindowState { +function currentWindowState(win: BrowserWindow, owner: WindowStateOwner): WindowState { const mode = win.isFullScreen() ? "fullscreen" : win.isMaximized() ? "maximized" : "normal"; if (mode === "normal") { - lastNormalBounds = { ...win.getBounds() }; + owner.lastNormalBounds = { ...win.getBounds() }; } return { bounds: - lastNormalBounds ?? + owner.lastNormalBounds ?? fitWindowStateToDisplays( defaultWindowState(primaryWorkArea()), workAreas(), @@ -144,49 +151,56 @@ function currentWindowState(win: BrowserWindow): WindowState { } async function persistWindowState(win: BrowserWindow): Promise { - if (win.isDestroyed() || mainWindow !== win) return; - const state = currentWindowState(win); - restoredWindowState = state; - const write = windowStateWrite.then(() => - saveWindowState(gamePaths().windowState, state), + const owner = windowStateOwners.get(win); + if (win.isDestroyed() || !owner) return; + const state = currentWindowState(win, owner); + owner.restored = state; + const write = owner.write.then(() => + saveWindowState(owner.path, state), ); - windowStateWrite = write.catch(() => undefined); + owner.write = write.catch(() => undefined); await write; } function scheduleWindowStateSave(win: BrowserWindow): void { + const owner = windowStateOwners.get(win); + if (!owner) return; // Leaving fullscreen/maximized and applying the default bounds emits several // intermediate events. Persisting one of those after the explicit reset // write can resurrect the old placement. - if (windowStateResetDepth > 0) return; - if (windowStateTimer) clearTimeout(windowStateTimer); - windowStateTimer = setTimeout(() => { - windowStateTimer = null; + if (owner.resetDepth > 0) return; + if (owner.timer) clearTimeout(owner.timer); + owner.timer = setTimeout(() => { + owner.timer = null; void persistWindowState(win).catch(() => { logEvent({ k: "window.stateSaveFailed" }); }); }, 300); } -export async function flushWindowState(): Promise { - await windowStateReset; - if (windowStateTimer) { - clearTimeout(windowStateTimer); - windowStateTimer = null; - } - const win = mainWindow; +export async function flushWindowState(win = mainWindow): Promise { if (!win || win.isDestroyed()) return; + const owner = windowStateOwners.get(win); + if (!owner) return; + await owner.reset; + if (owner.timer) { + clearTimeout(owner.timer); + owner.timer = null; + } await persistWindowState(win); - await windowStateWrite; + await owner.write; } export function resetWindowState(win = mainWindow): Promise { - const reset = windowStateReset.then(async () => { - windowStateResetDepth += 1; + if (!win || win.isDestroyed()) return Promise.resolve(); + const owner = windowStateOwners.get(win); + if (!owner) return Promise.resolve(); + const reset = owner.reset.then(async () => { + owner.resetDepth += 1; try { - if (windowStateTimer) { - clearTimeout(windowStateTimer); - windowStateTimer = null; + if (owner.timer) { + clearTimeout(owner.timer); + owner.timer = null; } const requested = defaultWindowState(primaryWorkArea()); let settled = requested; @@ -222,22 +236,22 @@ export function resetWindowState(win = mainWindow): Promise { win.setBounds(requested.bounds); settled = { bounds: { ...win.getBounds() }, mode: "normal" }; } - restoredWindowState = settled; - lastNormalBounds = settled.bounds; - const write = windowStateWrite.then(() => - saveWindowState(gamePaths().windowState, settled), + owner.restored = settled; + owner.lastNormalBounds = settled.bounds; + const write = owner.write.then(() => + saveWindowState(owner.path, settled), ); - windowStateWrite = write.catch(() => undefined); + owner.write = write.catch(() => undefined); await write; logEvent({ k: "window.stateReset", width: settled.bounds.width, height: settled.bounds.height, }); } finally { - windowStateResetDepth -= 1; + owner.resetDepth -= 1; } }); - windowStateReset = reset.catch(() => undefined); + owner.reset = reset.catch(() => undefined); return reset; } @@ -274,9 +288,12 @@ export function createMainWindow( readonly context?: WindowContext; readonly session?: Electron.Session; readonly title?: string; + readonly windowStatePath?: string; } = {}, ): BrowserWindow { const context = options.context ?? { mode: "single", role: "game" }; + const statePath = options.windowStatePath ?? gamePaths().windowState; + const restoredWindowState = preparedWindowStates.get(statePath) ?? null; const initialState = restoredWindowState ? fitWindowStateToDisplays( restoredWindowState, @@ -307,6 +324,16 @@ export function createMainWindow( }); mainWindow = win; + const stateOwner: WindowStateOwner = { + path: statePath, + restored: initialState, + lastNormalBounds: initialState?.bounds ?? null, + timer: null, + write: Promise.resolve(), + reset: Promise.resolve(), + resetDepth: 0, + }; + windowStateOwners.set(win, stateOwner); windowRegistry.register(win, context); updateLongRunningTaskFeedback(host.getProgress(), win); const rendererId = win.webContents.id; @@ -320,17 +347,17 @@ export function createMainWindow( const rememberNormalBounds = (): void => { if ( - windowStateResetDepth > 0 || + stateOwner.resetDepth > 0 || win.isFullScreen() || win.isMaximized() ) return; - lastNormalBounds = { ...win.getBounds() }; + stateOwner.lastNormalBounds = { ...win.getBounds() }; scheduleWindowStateSave(win); }; win.on("move", rememberNormalBounds); win.on("resize", rememberNormalBounds); const persistMode = (): void => { - if (windowStateResetDepth > 0) return; + if (stateOwner.resetDepth > 0) return; void persistWindowState(win).catch(() => { logEvent({ k: "window.stateSaveFailed" }); }); @@ -439,11 +466,11 @@ export function createMainWindow( host.sockets.closeAll(rendererId); if (isQuitting()) return; if ( - !rendererRecoveryUsed && + !rendererRecoveryUsed.has(statePath) && details.reason !== "clean-exit" && !win.isDestroyed() ) { - rendererRecoveryUsed = true; + rendererRecoveryUsed.add(statePath); logEvent({ k: "renderer.recoveryScheduled" }); setTimeout(() => { if (isQuitting() || win.isDestroyed()) return; @@ -480,6 +507,7 @@ export function createMainWindow( win.on("closed", () => { windowRegistry.unregister(win); + windowStateOwners.delete(win); if (mainWindow === win) mainWindow = null; }); From 07554d410d98da069e5fb56d64eea10d77543541 Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Thu, 13 Aug 2026 06:37:14 +0200 Subject: [PATCH 07/22] Launch isolated multiple account profiles --- src/main/accounts-window.ts | 84 +++++++++ src/main/core/paths.ts | 1 - src/main/core/renderer-trust.ts | 28 ++- src/main/ipc.ts | 86 ++++++++- src/main/main.ts | 297 +++++++++++++++++++++++++----- src/main/protocol.ts | 10 +- src/preload/preload.body.cjs | 6 + src/shared/contracts.ts | 43 +++++ tests/unit/renderer-trust.test.ts | 18 +- 9 files changed, 511 insertions(+), 62 deletions(-) create mode 100644 src/main/accounts-window.ts diff --git a/src/main/accounts-window.ts b/src/main/accounts-window.ts new file mode 100644 index 0000000..244b7fc --- /dev/null +++ b/src/main/accounts-window.ts @@ -0,0 +1,84 @@ +/** + * The Multiple Accounts picker window and its non-game security boundary. + * + * The Hub uses a dedicated session and is registered with role `hub`, so game + * IPC refuses it. Closing it does not close running accounts; a later app + * activation can reveal the same window again. + */ +import { BrowserWindow, session } from "electron"; +import type { ProtocolDeps } from "./protocol.js"; +import { installGwProtocolHandlerForSession } from "./protocol.js"; +import { preloadPath } from "./paths.js"; +import { windowRegistry } from "./window-registry.js"; + +const HUB_URL = "gw://app/accounts.html"; +let hubWindow: BrowserWindow | null = null; +let protocolInstalled = false; + +export function getAccountsWindow(): BrowserWindow | null { + return hubWindow && !hubWindow.isDestroyed() ? hubWindow : null; +} + +export function revealAccountsWindow(): boolean { + const win = getAccountsWindow(); + if (!win) return false; + if (win.isMinimized()) win.restore(); + win.show(); + win.focus(); + return true; +} + +export function createAccountsWindow(deps: ProtocolDeps): BrowserWindow { + const existing = getAccountsWindow(); + if (existing) { + revealAccountsWindow(); + return existing; + } + const owner = session.fromPartition("persist:gw-multi-hub", { cache: false }); + if (!protocolInstalled) { + installGwProtocolHandlerForSession(owner, deps); + protocolInstalled = true; + } + owner.setPermissionRequestHandler((_contents, _permission, callback) => callback(false)); + owner.setPermissionCheckHandler(() => false); + const win = new BrowserWindow({ + width: 700, + height: 620, + minWidth: 560, + minHeight: 480, + title: "Guild Wars Reforged — Accounts", + show: false, + webPreferences: { + session: owner, + preload: preloadPath(), + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + webSecurity: true, + webviewTag: false, + spellcheck: false, + allowRunningInsecureContent: false, + experimentalFeatures: false, + }, + }); + hubWindow = win; + windowRegistry.register(win, { mode: "multi", role: "hub" }); + win.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + win.webContents.on("will-navigate", (event, url) => { + if (url !== HUB_URL) event.preventDefault(); + }); + win.webContents.on("will-attach-webview", (event) => event.preventDefault()); + win.once("ready-to-show", () => win.show()); + win.on("close", (event) => { + if (windowRegistry.gameWindows().length > 0) { + event.preventDefault(); + win.hide(); + } + }); + win.on("closed", () => { + windowRegistry.unregister(win); + if (hubWindow === win) hubWindow = null; + }); + void win.loadURL(HUB_URL); + return win; +} diff --git a/src/main/core/paths.ts b/src/main/core/paths.ts index 58a7c46..6a80acd 100644 --- a/src/main/core/paths.ts +++ b/src/main/core/paths.ts @@ -116,7 +116,6 @@ export function multiProfilePaths( export function documentDirectories(paths: GamePaths): string[] { return [ paths.userData, - paths.multiRoot, paths.game, paths.diagnostics, paths.chunks, diff --git a/src/main/core/renderer-trust.ts b/src/main/core/renderer-trust.ts index 66d0934..f1b741d 100644 --- a/src/main/core/renderer-trust.ts +++ b/src/main/core/renderer-trust.ts @@ -1,5 +1,5 @@ /** - * What counts as the renderer document: two paths under `gw://app`, with no + * What counts as the game renderer document: two paths under `gw://app`, with no * port, credentials, query or fragment. * * Callers decide from this whether a navigation is the application itself, so @@ -9,14 +9,9 @@ * from having to know what any individual setting means. */ const TRUSTED_PATHS = new Set(["/", "/index.html"]); +const ACCOUNTS_PATH = "/accounts.html"; -/** - * The renderer document, and nothing else. There is no query string to - * allow-list: launch configuration reaches the renderer through - * `RENDERER_INIT_ARGUMENT`, so a security boundary no longer has to know what a - * cursor preference is. - */ -export function isCanonicalRendererUrl(raw: string): boolean { +function trustedUrl(raw: string, paths: ReadonlySet): boolean { try { const url = new URL(raw); return ( @@ -27,9 +22,24 @@ export function isCanonicalRendererUrl(raw: string): boolean { && !url.password && !url.hash && !url.search - && TRUSTED_PATHS.has(url.pathname) + && paths.has(url.pathname) ); } catch { return false; } } + +/** + * The renderer document, and nothing else. There is no query string to + * allow-list: launch configuration reaches the renderer through + * `RENDERER_INIT_ARGUMENT`, so a security boundary no longer has to know what a + * cursor preference is. + */ +export function isCanonicalRendererUrl(raw: string): boolean { + return trustedUrl(raw, TRUSTED_PATHS); +} + +/** The Hub document is separate so a game window can never navigate to it. */ +export function isAccountsRendererUrl(raw: string): boolean { + return trustedUrl(raw, new Set([ACCOUNTS_PATH])); +} diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 7f01533..58934dc 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -18,6 +18,8 @@ import { statfs } from "node:fs/promises"; import type { AppSettings, AppSettingsPatch, + AccountsSetupRequest, + AccountsState, AppUpdateState, CacheInfo, ClientHealthToken, @@ -33,6 +35,12 @@ import type { SteamTokenResult, StoredCredentials, } from "../shared/contracts.js"; +import { + parseProfileId, + parseProfileName, + type LibraryScope, + type ProfileId, +} from "../shared/multiple-accounts.js"; import type { RendererFrameBatch, RendererMetrics, @@ -92,7 +100,10 @@ import { } from "./diagnostics.js"; import { isRendererFingerprint } from "./diagnostics/schema-fields.js"; import { gamePaths } from "./paths.js"; -import { isCanonicalRendererUrl } from "./core/renderer-trust.js"; +import { + isAccountsRendererUrl, + isCanonicalRendererUrl, +} from "./core/renderer-trust.js"; import { MAX_QUEUED_BYTES_PER_SOCKET } from "./core/sockets.js"; import { isQuitting } from "./lifecycle.js"; import { windowRegistry, type WindowRegistry } from "./window-registry.js"; @@ -128,6 +139,10 @@ export interface IpcContext { parent: BrowserWindow, record: (event: SteamAcquireEvent) => void, ) => Promise; + getAccountsState: () => AccountsState; + setupAccounts: (request: AccountsSetupRequest) => Promise; + openAccounts: (profileIds: readonly ProfileId[]) => Promise; + useSingleAccountMode: () => Promise; } type SteamInvokeChannel = "steamToken" | "steamStore" | "steamClear"; @@ -135,16 +150,20 @@ type SteamInvokeChannel = "steamToken" | "steamStore" | "steamClear"; function assertSender( registry: WindowRegistry, event: Electron.IpcMainInvokeEvent, + role: "game" | "hub" | "any", ): BrowserWindow { const win = BrowserWindow.fromWebContents(event.sender); const context = registry.contextForWebContents(event.sender.id); - if (!win || !context || context.role !== "game") { + if (!win || !context || (role !== "any" && context.role !== role)) { throw new AllowlistError("unowned ipc sender"); } if (!event.senderFrame || event.senderFrame !== event.sender.mainFrame) { throw new AllowlistError("ipc sender is not the main frame"); } - if (!isCanonicalRendererUrl(event.senderFrame.url)) { + const trusted = context.role === "hub" + ? isAccountsRendererUrl(event.senderFrame.url) + : isCanonicalRendererUrl(event.senderFrame.url); + if (!trusted) { throw new AllowlistError("invalid ipc origin"); } return win; @@ -173,6 +192,7 @@ type Run = (win: BrowserWindow, input: In) => Out | Promise; interface ChannelDef { readonly parse: Parser; readonly run: Run; + readonly role: "game" | "hub" | "any"; } /** @@ -185,14 +205,16 @@ interface ChannelDef { interface AnyChannelDef { readonly parse: Parser; readonly run: Run; + readonly role: "game" | "hub" | "any"; } /** You cannot construct a channel without a parser. That is the point. */ function channel( parse: Parser, run: Run, + role: "game" | "hub" | "any" = "game", ): ChannelDef { - return { parse, run }; + return { parse, run, role }; } /** For the channels that carry nothing. Still a parser, still explicit. */ @@ -372,6 +394,41 @@ const asExternalLinkKind = one((value: unknown): ExternalLinkKind => { return value; }); +function parseLibraryScope(value: unknown, field: string): LibraryScope { + if (value !== "shared" && value !== "private") { + throw new ValidationError(`${field} must be shared or private`); + } + return value; +} + +const asAccountsSetup = one((value: unknown): AccountsSetupRequest => { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new ValidationError("account setup must be an object"); + } + const input = value as Record; + if (typeof input.importTemplates !== "boolean" || typeof input.importBuilds !== "boolean") { + throw new ValidationError("account import choices must be booleans"); + } + return { + name: parseProfileName(input.name), + templates: parseLibraryScope(input.templates, "templates"), + builds: parseLibraryScope(input.builds, "builds"), + importTemplates: input.importTemplates, + importBuilds: input.importBuilds, + }; +}); + +const asProfileIds = one((value: unknown): readonly ProfileId[] => { + if (!Array.isArray(value) || value.length === 0 || value.length > 16) { + throw new ValidationError("select between 1 and 16 account profiles"); + } + const ids = value.map(parseProfileId); + if (new Set(ids).size !== ids.length) { + throw new ValidationError("account profile selection contains duplicates"); + } + return ids; +}); + interface ParsedMilestone { name: RendererMilestone; rendererTimestampUs: number; @@ -819,6 +876,25 @@ export function registerIpcHandlers(ctx: IpcContext): { nothing, (win) => ctx.restartAndInstallUpdate(win), ), + accountsGet: channel( + nothing, + () => ctx.getAccountsState(), + "any", + ), + accountsSetup: channel( + asAccountsSetup, + (_win, request) => ctx.setupAccounts(request), + ), + accountsOpen: channel( + asProfileIds, + (_win, profileIds) => ctx.openAccounts(profileIds), + "hub", + ), + accountsUseSingle: channel( + nothing, + () => ctx.useSingleAccountMode(), + "any", + ), } satisfies Record, AnyChannelDef>; registerChannelDefinitions(ctx.windows, handlers); @@ -856,7 +932,7 @@ function registerChannelDefinitions( const def = definition as ChannelDef; const name = key as InvokeChannel; ipcMain.handle(IPC[name], async (event, ...args: unknown[]) => { - const win = assertSender(windows, event); + const win = assertSender(windows, event, def.role); let input: unknown; try { input = def.parse(args); diff --git a/src/main/main.ts b/src/main/main.ts index 957cade..4825282 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -16,7 +16,7 @@ import { session, } from "electron"; import { readFileSync } from "node:fs"; -import { mkdir, rm } from "node:fs/promises"; +import { copyFile, mkdir, rm } from "node:fs/promises"; import path from "node:path"; import { EXTERNAL_URLS, @@ -24,6 +24,8 @@ import { IPC, type AppSettings, type AppSettingsPatch, + type AccountsSetupRequest, + type AccountsState, type DownloadProgress, type PrefetchProgress, type UpdateTrack, @@ -69,13 +71,18 @@ import { } from "./lifecycle.js"; import { sweepOrphanDirectories } from "./core/atomic-file.js"; import { documentDirectories } from "./core/paths.js"; +import { multiProfilePaths } from "./core/paths.js"; import { gamePaths } from "./paths.js"; import { DEVELOPER_ENHANCEMENT_PROGRAM, ENHANCEMENT_AUTOMATION_ENABLED, enhancementSelectionFor, } from "./certification/enhancement-policy.js"; -import { installGwProtocolHandler, registerGwScheme } from "./protocol.js"; +import { + installGwProtocolHandler, + installGwProtocolHandlerForSession, + registerGwScheme, +} from "./protocol.js"; import { createMainWindow, flushWindowState, @@ -108,6 +115,22 @@ import { applyPendingGameStorageReset, } from "./settings-actions.js"; import { windowRegistry } from "./window-registry.js"; +import { + createMultiWorkspace, + loadAccountMode, + loadMultiWorkspace, + saveAccountMode, + saveMultiWorkspace, +} from "./core/multiple-accounts.js"; +import { + type AccountMode, + type ProfileId, +} from "../shared/multiple-accounts.js"; +import { multiSecretSlot } from "./core/native-keychain.js"; +import { + createAccountsWindow, + revealAccountsWindow, +} from "./accounts-window.js"; // The public app name changed after alpha profiles already existed. Keep that // one profile as the canonical home so the rename cannot strand saved login, @@ -151,10 +174,12 @@ const settingsLock = new Mutex(); let appUpdaterController: AppUpdater | null = null; let updateRestartInFlight: Promise | null = null; let secondInstanceRequested = false; +let activeAccountMode: AccountMode = "single"; const INJECT_STARTUP_FAILURE = !app.isPackaged && process.env.GW_TEST_STARTUP_FAILURE === "1"; function revealMainWindow(): void { + if (activeAccountMode === "multi" && revealAccountsWindow()) return; const win = getMainWindow(); if (!win || win.isDestroyed()) { secondInstanceRequested = true; @@ -234,12 +259,13 @@ function setPrefetch(next: PrefetchProgress): void { } function sendToRenderer(channel: string, value: unknown): void { - const win = getMainWindow(); - if (!win || win.isDestroyed() || win.webContents.isDestroyed()) return; - try { - win.webContents.send(channel, value); - } catch { - // Renderer teardown can race a native progress callback. + for (const win of windowRegistry.gameWindows()) { + if (win.isDestroyed() || win.webContents.isDestroyed()) continue; + try { + win.webContents.send(channel, value); + } catch { + // Renderer teardown can race a native progress callback. + } } } @@ -262,7 +288,7 @@ function packagedDistributionChannel(): DistributionChannel | null { } } -async function ensureDirs(): Promise { +async function ensureDirs(mode: AccountMode): Promise { const paths = gamePaths(); await mkdir(paths.game, { recursive: true }); await mkdir(paths.chunks, { recursive: true }); @@ -271,7 +297,9 @@ async function ensureDirs(): Promise { // writes. A process killed between // write and rename leaves `...tmp` behind, and boot is the // only moment at which every one of those directories is known to be idle. - const removed = await sweepOrphanDirectories(documentDirectories(paths)); + const roots = documentDirectories(paths); + if (mode === "multi") roots.push(paths.multiRoot, paths.multiProfiles); + const removed = await sweepOrphanDirectories(roots); if (removed > 0) logEvent({ k: "orphanTemps.swept", removed }); } @@ -304,6 +332,15 @@ async function clearBrowserNetworkCache(): Promise { } } +async function copyIfPresent(source: string, destination: string): Promise { + try { + await mkdir(path.dirname(destination), { recursive: true }); + await copyFile(source, destination); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } +} + function buildWindowHost( clientRuntime: ClientRuntime, sockets: SocketManager, @@ -351,9 +388,18 @@ if (primaryInstance) void app.whenReady().then(async () => { website: EXTERNAL_URLS.github, }); const paths = gamePaths(); + activeAccountMode = await loadAccountMode(paths.launcherMode); + let multiWorkspace = activeAccountMode === "multi" + ? await loadMultiWorkspace(paths.multiWorkspace) + : null; + if (activeAccountMode === "multi" && !multiWorkspace) { + throw new Error("Multiple Accounts mode has no workspace"); + } await applyPendingCacheClear(paths); - await applyPendingGameStorageReset(paths); - await ensureDirs(); + if (activeAccountMode === "single") { + await applyPendingGameStorageReset(paths); + } + await ensureDirs(activeAccountMode); await startDiagnostics(); const distributionChannel = packagedDistributionChannel(); const distribution = distributionCapabilities(distributionChannel); @@ -366,7 +412,11 @@ if (primaryInstance) void app.whenReady().then(async () => { app.isPackaged && distribution.persistentSecrets && !app.commandLine.hasSwitch("gw-volatile-secrets"); - if (persistentSecrets && distribution.cleanupLegacySecrets) { + if ( + activeAccountMode === "single" + && persistentSecrets + && distribution.cleanupLegacySecrets + ) { const legacySecretFailures = await cleanupLegacySecretFiles( app.getPath("userData"), rm, @@ -375,8 +425,10 @@ if (primaryInstance) void app.whenReady().then(async () => { logEvent({ k: "legacySecrets.cleanupFailed", code: errorCode(failure) }); } } - await clearBrowserCookies("startup"); - await clearBrowserNetworkCache(); + if (activeAccountMode === "single") { + await clearBrowserCookies("startup"); + await clearBrowserNetworkCache(); + } logEvent({ k: "electron.ready" }); const settings = await loadSettings(paths.settings, async () => { logEvent({ k: "settings.corruptRecovered" }); @@ -394,7 +446,7 @@ if (primaryInstance) void app.whenReady().then(async () => { enhancementSelection, enhancementProgram, ); - await prepareWindowState(); + if (activeAccountMode === "single") await prepareWindowState(); const keychain: NativeKeychain = persistentSecrets ? loadNativeKeychain({ packaged: true, @@ -402,8 +454,36 @@ if (primaryInstance) void app.whenReady().then(async () => { resourcesPath: process.resourcesPath, }) : new VolatileNativeKeychain(); - const credentialsStore = new CredentialsStore(keychain); - const steamSessionStore = new SteamSessionStore(keychain); + const credentialsStores = new Map(); + const steamSessionStores = new Map(); + const credentialStoreForProfile = (profileId?: ProfileId): CredentialsStore => { + const key = profileId ?? "single"; + let store = credentialsStores.get(key); + if (!store) { + store = new CredentialsStore( + keychain, + profileId + ? multiSecretSlot(profileId, "arenaNetCredentials") + : "arenaNetCredentials", + ); + credentialsStores.set(key, store); + } + return store; + }; + const steamStoreForProfile = (profileId?: ProfileId): SteamSessionStore => { + const key = profileId ?? "single"; + let store = steamSessionStores.get(key); + if (!store) { + store = new SteamSessionStore( + keychain, + profileId + ? multiSecretSlot(profileId, "steamSession") + : "steamSession", + ); + steamSessionStores.set(key, store); + } + return store; + }; const expectedUserData = process.env.GW_EXPECT_USER_DATA; const profileMatches = !expectedUserData || @@ -467,17 +547,142 @@ if (primaryInstance) void app.whenReady().then(async () => { logEvent({ k: "fullDownload.stoppedForSleep" }); clientRuntime.stopDownload(); }); - installGwProtocolHandler({ + const protocolDeps = { getActiveClient: () => clientRuntime.active, + }; + if (activeAccountMode === "single") { + installGwProtocolHandler(protocolDeps); + logEvent({ k: "protocol.installed" }); + } + + const host = buildWindowHost( + clientRuntime, + sockets, + enhancementSelection, + enhancementProgram, + ); + const profileProtocolSessions = new Set(); + const profileLaunchState = new Map(); + const profileFor = (profileId: ProfileId) => { + const profile = multiWorkspace?.profiles.find( + (candidate) => candidate.id === profileId && !candidate.archived, + ); + if (!profile) throw new Error("Unknown Multiple Accounts profile"); + return profile; + }; + const accountsState = (): AccountsState => ({ + mode: activeAccountMode, + profiles: (multiWorkspace?.profiles ?? []) + .filter((profile) => !profile.archived) + .map((profile) => ({ + id: profile.id, + name: profile.name, + templates: profile.templates, + builds: profile.builds, + state: windowRegistry.profileWindow(profile.id) + ? "running" + : (profileLaunchState.get(profile.id) ?? "ready"), + })), }); - logEvent({ k: "protocol.installed" }); + const openProfile = async (profileId: ProfileId): Promise => { + const profile = profileFor(profileId); + const existing = windowRegistry.profileWindow(profileId); + if (existing) { + if (existing.isMinimized()) existing.restore(); + existing.show(); + existing.focus(); + return; + } + if (profileLaunchState.get(profileId) === "opening") return; + profileLaunchState.set(profileId, "opening"); + try { + const owner = session.fromPartition(`persist:gw-multi-${profileId}`, { + cache: false, + }); + if (!profileProtocolSessions.has(profileId)) { + installGwProtocolHandlerForSession(owner, protocolDeps); + profileProtocolSessions.add(profileId); + } + await Promise.all([ + owner.clearStorageData({ storages: ["cookies"] }), + owner.clearCache(), + ]); + const profilePaths = multiProfilePaths(paths, profileId); + await mkdir(profilePaths.root, { recursive: true }); + await prepareWindowState(profilePaths.windowState); + const win = createMainWindow(host, { + context: { mode: "multi", role: "game", profileId }, + session: owner, + title: `Guild Wars Reforged — ${profile.name}`, + windowStatePath: profilePaths.windowState, + }); + win.on("closed", () => profileLaunchState.delete(profileId)); + profileLaunchState.delete(profileId); + } catch (error) { + profileLaunchState.set(profileId, "failed"); + throw error; + } + }; + + const setupAccounts = async (request: AccountsSetupRequest): Promise => { + if (activeAccountMode !== "single") { + throw new Error("Multiple Accounts mode is already enabled"); + } + multiWorkspace ??= await loadMultiWorkspace(paths.multiWorkspace); + if (!multiWorkspace) { + multiWorkspace = createMultiWorkspace(request); + const profile = multiWorkspace.profiles[0]!; + const profilePaths = multiProfilePaths(paths, profile.id); + await mkdir(profilePaths.root, { recursive: true }); + if (request.importBuilds) { + await copyIfPresent( + paths.buildLibrary, + profile.builds === "shared" + ? paths.multiSharedBuildLibrary + : profilePaths.buildLibrary, + ); + } + if (request.importTemplates) { + throw new Error("Template import is not available in this build"); + } + await saveMultiWorkspace(paths.multiWorkspace, multiWorkspace); + } + await saveAccountMode(paths.launcherMode, "multi"); + app.relaunch(); + app.quit(); + }; + + const useSingleAccountMode = async (): Promise => { + await saveAccountMode(paths.launcherMode, "single"); + app.relaunch(); + app.quit(); + }; const ipcCleanup = registerIpcHandlers({ sockets, windows: windowRegistry, - credentialsStoreFor: () => credentialsStore, - steamSessionStoreFor: () => steamSessionStore, - buildLibraryPathFor: () => paths.buildLibrary, + credentialsStoreFor: (win) => { + const context = windowRegistry.contextForWebContents(win.webContents.id); + return context?.mode === "multi" && context.role === "game" + ? credentialStoreForProfile(context.profileId) + : credentialStoreForProfile(); + }, + steamSessionStoreFor: (win) => { + const context = windowRegistry.contextForWebContents(win.webContents.id); + return context?.mode === "multi" && context.role === "game" + ? steamStoreForProfile(context.profileId) + : steamStoreForProfile(); + }, + buildLibraryPathFor: (win) => { + const context = windowRegistry.contextForWebContents(win.webContents.id); + if (context?.mode !== "multi" || context.role !== "game") { + return paths.buildLibrary; + } + const profile = profileFor(context.profileId); + return profile.builds === "shared" + ? paths.multiSharedBuildLibrary + : multiProfilePaths(paths, profile.id).buildLibrary; + }, getProgress: () => clientRuntime.progress, getChunkStore: () => clientRuntime.active?.store ?? null, getSettings: () => loadSettings(paths.settings), @@ -535,33 +740,38 @@ if (primaryInstance) void app.whenReady().then(async () => { }), acquireSteamToken: (parent, record) => acquireSteamToken(STEAM_OAUTH, { parent, record }), + getAccountsState: accountsState, + setupAccounts, + openAccounts: async (profileIds) => { + for (const profileId of profileIds) await openProfile(profileId); + }, + useSingleAccountMode, }); onAppQuit(async () => { - const win = getMainWindow(); - if (win && !win.isDestroyed()) { - const outcome = await sendRendererCommand(win, { - type: "filesystem.sync", - }); - if (outcome !== "completed") { - logEvent({ k: "quit.rendererSyncIncomplete", outcome }); + const gameWindows = windowRegistry.gameWindows(); + for (const win of gameWindows) { + if (!win.isDestroyed()) { + const outcome = await sendRendererCommand(win, { + type: "filesystem.sync", + }); + if (outcome !== "completed") { + logEvent({ k: "quit.rendererSyncIncomplete", outcome }); + } } } await ipcCleanup.drainSecrets(); - await flushWindowState(); + await Promise.all(gameWindows.map((win) => flushWindowState(win))); sockets.closeAll(); updateLongRunningTaskFeedback(INITIAL_PROGRESS); await clientRuntime.shutdown(); - await clearBrowserCookies("quit"); + if (activeAccountMode === "single") await clearBrowserCookies("quit"); await stopDiagnostics(); }); - const win = createMainWindow(buildWindowHost( - clientRuntime, - sockets, - enhancementSelection, - enhancementProgram, - )); + const win = activeAccountMode === "multi" + ? createAccountsWindow(protocolDeps) + : createMainWindow(host); if (settings.autoCheckUpdates) { void checkForAppUpdates(settings.updateTrack); } @@ -627,13 +837,10 @@ if (primaryInstance) void app.whenReady().then(async () => { } app.on("activate", () => { - if (!getMainWindow()) { - createMainWindow(buildWindowHost( - clientRuntime, - sockets, - enhancementSelection, - enhancementProgram, - )); + if (activeAccountMode === "multi") { + if (!revealAccountsWindow()) createAccountsWindow(protocolDeps); + } else if (!getMainWindow()) { + createMainWindow(host); } }); app.on("child-process-gone", (_event, details) => { diff --git a/src/main/protocol.ts b/src/main/protocol.ts index 7d8e687..5f63c10 100644 --- a/src/main/protocol.ts +++ b/src/main/protocol.ts @@ -13,7 +13,7 @@ * this scheme serves, so no individual handler can serve a document without * them. */ -import { app, protocol, net } from "electron"; +import { app, protocol, net, type Session } from "electron"; import { createReadStream } from "node:fs"; import { stat } from "node:fs/promises"; import path from "node:path"; @@ -155,6 +155,14 @@ export function installGwProtocolHandler(deps: ProtocolDeps): void { protocol.handle("gw", (request) => handleGwRequest(request, deps)); } +/** A custom partition owns its own protocol registry. */ +export function installGwProtocolHandlerForSession( + owner: Session, + deps: ProtocolDeps, +): void { + owner.protocol.handle("gw", (request) => handleGwRequest(request, deps)); +} + function headers(extra: Record = {}): Headers { return new Headers({ "Content-Security-Policy": CSP, diff --git a/src/preload/preload.body.cjs b/src/preload/preload.body.cjs index 77300fc..28ba948 100644 --- a/src/preload/preload.body.cjs +++ b/src/preload/preload.body.cjs @@ -235,6 +235,12 @@ const api = { ipcRenderer.invoke(IPC.appUpdatesRestartAndInstall), onState: (callback) => listen(IPC.appUpdatesState, callback), }, + accounts: { + get: () => ipcRenderer.invoke(IPC.accountsGet), + setup: (value) => ipcRenderer.invoke(IPC.accountsSetup, value), + open: (profileIds) => ipcRenderer.invoke(IPC.accountsOpen, profileIds), + useSingle: () => ipcRenderer.invoke(IPC.accountsUseSingle), + }, }; for (const namespace of Object.values(api)) Object.freeze(namespace); Object.freeze(api); diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 8029317..24408a5 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -24,6 +24,11 @@ import type { } from "./diagnostics.js"; import type { ErrorCode } from "./errors.js"; import type { BuildLibrary } from "./builds/library.js"; +import type { + AccountMode, + LibraryScope, + ProfileId, +} from "./multiple-accounts.js"; import type { EnhancementProgram, EnhancementSelection, @@ -597,6 +602,34 @@ export interface RendererInit { templateFsTrace: boolean; } +export type MultiProfileRuntimeState = + | "ready" + | "queued" + | "opening" + | "running" + | "failed"; + +export interface AccountProfileSummary { + readonly id: ProfileId; + readonly name: string; + readonly templates: LibraryScope; + readonly builds: LibraryScope; + readonly state: MultiProfileRuntimeState; +} + +export interface AccountsState { + readonly mode: AccountMode; + readonly profiles: readonly AccountProfileSummary[]; +} + +export interface AccountsSetupRequest { + readonly name: string; + readonly templates: LibraryScope; + readonly builds: LibraryScope; + readonly importTemplates: boolean; + readonly importBuilds: boolean; +} + /** * Prefix of the single `webPreferences.additionalArguments` entry that carries * a JSON `RendererInit`. The preload is the only reader. @@ -722,6 +755,10 @@ export const IPC = { appUpdatesCheck: "gw:appUpdates:check", appUpdatesRestartAndInstall: "gw:appUpdates:restartAndInstall", appUpdatesState: "gw:appUpdates:state", + accountsGet: "gw:accounts:get", + accountsSetup: "gw:accounts:setup", + accountsOpen: "gw:accounts:open", + accountsUseSingle: "gw:accounts:useSingle", } as const; export type IpcChannel = (typeof IPC)[keyof typeof IPC]; @@ -898,4 +935,10 @@ export interface GwNativeApi { restartAndInstall(): Promise; onState(callback: (state: AppUpdateState) => void): () => void; }; + accounts: { + get(): Promise; + setup(value: AccountsSetupRequest): Promise; + open(profileIds: readonly ProfileId[]): Promise; + useSingle(): Promise; + }; } diff --git a/tests/unit/renderer-trust.test.ts b/tests/unit/renderer-trust.test.ts index d38c7b5..609bd98 100644 --- a/tests/unit/renderer-trust.test.ts +++ b/tests/unit/renderer-trust.test.ts @@ -1,6 +1,9 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { isCanonicalRendererUrl } from "../../src/main/core/renderer-trust.js"; +import { + isAccountsRendererUrl, + isCanonicalRendererUrl, +} from "../../src/main/core/renderer-trust.js"; describe("canonical renderer URL", () => { it("allows the launcher document and nothing else", () => { @@ -30,6 +33,7 @@ describe("canonical renderer URL", () => { for (const url of [ "gw://app/account/login", "gw://app/Gw.jspi.js", + "gw://app/accounts.html", "gw://app/#fragment", "gw://user@app/", "gw://app:443/", @@ -39,4 +43,16 @@ describe("canonical renderer URL", () => { assert.equal(isCanonicalRendererUrl(url), false, url); } }); + + it("gives the accounts Hub its own document boundary", () => { + assert.equal(isAccountsRendererUrl("gw://app/accounts.html"), true); + for (const url of [ + "gw://app/", + "gw://app/index.html", + "gw://app/accounts.html?profile=one", + "gw://app/accounts.html#profile", + ]) { + assert.equal(isAccountsRendererUrl(url), false, url); + } + }); }); From b55d622266ac081ad63dae3ef66a92beea881910 Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Thu, 13 Aug 2026 06:37:59 +0200 Subject: [PATCH 08/22] Add multiple accounts setup and picker --- scripts/copy-renderer.mjs | 2 + src/renderer/accounts.css | 134 +++++++++++++++++++++++++++++++++++++ src/renderer/accounts.html | 43 ++++++++++++ src/renderer/accounts.ts | 110 ++++++++++++++++++++++++++++++ src/renderer/index.html | 40 +++++++++++ src/renderer/settings.ts | 30 +++++++++ 6 files changed, 359 insertions(+) create mode 100644 src/renderer/accounts.css create mode 100644 src/renderer/accounts.html create mode 100644 src/renderer/accounts.ts diff --git a/scripts/copy-renderer.mjs b/scripts/copy-renderer.mjs index 6fdbb4f..ba85b5d 100644 --- a/scripts/copy-renderer.mjs +++ b/scripts/copy-renderer.mjs @@ -12,6 +12,8 @@ import path from "node:path"; // editor and OS files part of the build, so two clean checkouts could package // different applications. A new asset must be reviewed here. const ASSETS = [ + "accounts.css", + "accounts.html", "favicon.ico", "favicon.png", "fonts/COPYING-QUALITYPE", diff --git a/src/renderer/accounts.css b/src/renderer/accounts.css new file mode 100644 index 0000000..59f2820 --- /dev/null +++ b/src/renderer/accounts.css @@ -0,0 +1,134 @@ +/* The Multiple Accounts Hub uses the existing GWonMac material system while + * giving profile choice a quiet, full-window home instead of impersonating an + * in-game overlay. All colour and control decisions resolve through ui tokens. */ + +* { box-sizing: border-box; } + +html, body { min-height: 100%; } + +body { + margin: 0; + background: + radial-gradient(circle at 78% 8%, color-mix(in srgb, var(--ui-accent) 9%, transparent), transparent 34%), + var(--ui-well); + color: var(--ui-text); + font: var(--ui-font-size) / var(--ui-line-height) var(--ui-font); +} + +button, input { font: inherit; } + +.accounts-shell { + width: min(760px, 100%); + min-height: 100vh; + margin: 0 auto; + padding: clamp(32px, 7vw, 68px) clamp(24px, 6vw, 54px) 28px; +} + +.accounts-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--ui-space-5); + padding-bottom: var(--ui-space-5); + border-bottom: 1px solid var(--ui-line-soft); +} + +.accounts-head img { + flex: 0 0 auto; + object-fit: contain; + opacity: .9; +} + +.accounts-kicker { + margin: 0 0 var(--ui-space-2); + color: var(--ui-accent); + font-size: var(--ui-font-size-sm); + font-weight: 700; + letter-spacing: .16em; + text-transform: uppercase; +} + +h1 { + margin: 0; + color: var(--ui-text-bright); + font: 700 clamp(30px, 6vw, 46px) / 1.05 var(--ui-font-display); + letter-spacing: -.025em; +} + +.accounts-intro { + max-width: 590px; + margin: var(--ui-space-3) 0 0; + color: var(--ui-text-muted); + font-size: var(--ui-font-size-lg); +} + +#accounts-form { padding-top: var(--ui-space-5); } + +.accounts-list { + display: grid; + gap: var(--ui-space-2); + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} + +.account-choice { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: var(--ui-space-3); + min-height: 66px; + padding: var(--ui-space-3) var(--ui-space-4); + border: 1px solid var(--ui-line-soft); + border-radius: var(--ui-radius); + background: var(--ui-row-fill); + cursor: pointer; + transition: border-color var(--ui-duration) var(--ui-ease-out), background var(--ui-duration) var(--ui-ease-out); +} + +.account-choice:hover, +.account-choice:has(input:focus-visible) { + border-color: var(--ui-line); + background: var(--ui-hover); +} + +.account-choice:has(input:checked) { + border-color: var(--ui-accent-strong); + background: var(--ui-selected); +} + +.account-choice input { width: 18px; height: 18px; accent-color: var(--ui-accent); } +.account-name { display: block; overflow: hidden; color: var(--ui-text-bright); font-size: var(--ui-font-size-lg); font-weight: 700; text-overflow: ellipsis; white-space: nowrap; } +.account-meta { display: block; margin-top: 2px; color: var(--ui-text-faint); font-size: var(--ui-font-size-sm); } +.account-state { color: var(--ui-text-muted); font-size: var(--ui-font-size-sm); text-transform: capitalize; } +.account-state[data-state="running"] { color: var(--ui-success); } +.account-state[data-state="failed"] { color: var(--ui-danger); } + +.accounts-help, +.accounts-foot p, +.accounts-loading { color: var(--ui-text-muted); } + +.accounts-help { margin: var(--ui-space-3) 0; } +.accounts-actions { display: flex; flex-wrap: wrap; gap: var(--ui-space-2); } +.accounts-actions + .ui-status-line { min-height: 24px; margin: var(--ui-space-3) 0 0; } + +.accounts-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--ui-space-4); + margin-top: clamp(36px, 8vw, 70px); + padding-top: var(--ui-space-4); + border-top: 1px solid var(--ui-line-soft); +} + +.accounts-foot p { max-width: 420px; margin: 0; font-size: var(--ui-font-size-sm); } +.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } + +@media (max-width: 520px) { + .accounts-head img { display: none; } + .accounts-foot { align-items: flex-start; flex-direction: column; } + .account-choice { grid-template-columns: auto minmax(0, 1fr); } + .account-state { grid-column: 2; } +} diff --git a/src/renderer/accounts.html b/src/renderer/accounts.html new file mode 100644 index 0000000..7228246 --- /dev/null +++ b/src/renderer/accounts.html @@ -0,0 +1,43 @@ + + + + + + Guild Wars Reforged — Accounts + + + + + + +
+
+
+

Multiple Accounts

+

Who are you playing?

+

Choose one or more profiles. Each opens in its own window with its own saved login and Guild Wars files.

+
+ +
+ +
+
+ Account profiles +
Loading profiles…
+
+

Already-open profiles are brought to the front. Other selected profiles open independently.

+
+ + +
+

+
+ +
+

Profiles share the downloaded client, never credentials or browser storage.

+ +
+
+ + + diff --git a/src/renderer/accounts.ts b/src/renderer/accounts.ts new file mode 100644 index 0000000..6c23df1 --- /dev/null +++ b/src/renderer/accounts.ts @@ -0,0 +1,110 @@ +import type { AccountProfileSummary } from '../shared/contracts.js'; + +/** + * The Multiple Accounts Hub: a small projection of main-owned profile state. + * Profile IDs originate in main and return only through checked controls; the + * renderer never constructs paths, partitions, or credential names. + */ +(function () { + const required = (id: string): T => { + const value = document.getElementById(id); + if (!value) throw new Error(`missing accounts element: ${id}`); + return value as T; + }; + const form = required('accounts-form'); + const list = required('accounts-list'); + const open = required('accounts-open'); + const selectAll = required('accounts-select-all'); + const status = required('accounts-status'); + const single = required('accounts-single'); + let profiles: readonly AccountProfileSummary[] = []; + + function setStatus(message: string, tone: 'neutral' | 'progress' | 'success' | 'error') { + status.textContent = message; + status.dataset.tone = tone; + } + + function selectedIds() { + return [...list.querySelectorAll('input:checked')] + .map((input) => input.value as AccountProfileSummary['id']); + } + + function syncActions() { + open.disabled = selectedIds().length === 0; + const allSelected = profiles.length > 0 && selectedIds().length === profiles.length; + selectAll.textContent = allSelected ? 'Clear selection' : 'Select all'; + } + + function renderProfile(profile: AccountProfileSummary) { + const label = document.createElement('label'); + label.className = 'account-choice'; + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.name = 'profile'; + checkbox.value = profile.id; + checkbox.checked = profile.state !== 'failed'; + const details = document.createElement('span'); + const name = document.createElement('span'); + name.className = 'account-name'; + name.textContent = profile.name; + const meta = document.createElement('span'); + meta.className = 'account-meta'; + meta.textContent = `${profile.templates} templates · ${profile.builds} builds`; + details.append(name, meta); + const state = document.createElement('span'); + state.className = 'account-state'; + state.dataset.state = profile.state; + state.textContent = profile.state; + label.append(checkbox, details, state); + return label; + } + + async function refresh() { + try { + const state = await window.gwNative.accounts.get(); + if (state.mode !== 'multi') throw new Error('Multiple Accounts is not active'); + profiles = state.profiles; + list.replaceChildren(...profiles.map(renderProfile)); + syncActions(); + } catch { + list.replaceChildren(); + setStatus('Profiles could not be loaded. Restart GWonMac and try again.', 'error'); + } + } + + list.addEventListener('change', syncActions); + selectAll.addEventListener('click', () => { + const inputs = [...list.querySelectorAll('input')]; + const next = !inputs.every((input) => input.checked); + for (const input of inputs) input.checked = next; + syncActions(); + }); + form.addEventListener('submit', async (event) => { + event.preventDefault(); + const ids = selectedIds(); + if (ids.length === 0) return; + open.disabled = true; + setStatus(`Opening ${ids.length === 1 ? 'account' : `${ids.length} accounts`}…`, 'progress'); + try { + await window.gwNative.accounts.open(ids); + setStatus('Selected accounts are open.', 'success'); + await refresh(); + } catch { + setStatus('One or more accounts could not be opened. You can retry them.', 'error'); + await refresh(); + } + }); + single.addEventListener('click', async () => { + if (!window.confirm('Return to Single Account mode? All open game windows will close. Your Multi profiles and saved logins are kept.')) return; + single.disabled = true; + setStatus('Restarting in Single Account mode…', 'progress'); + try { + await window.gwNative.accounts.useSingle(); + } catch { + single.disabled = false; + setStatus('The mode change could not be saved. Nothing changed.', 'error'); + } + }); + + void refresh(); +})(); diff --git a/src/renderer/index.html b/src/renderer/index.html index acf8708..85c843b 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -269,6 +269,9 @@

Settings

+ @@ -564,6 +567,43 @@

Advanced

Restores display, tools, window position, and launcher choices. Downloaded game data and your login are kept.

+ +
+

Multiple Accounts

+

Opt in to an account picker that can open several independent Guild Wars windows.

+

Single Account mode is active.

+

Your current window, saved login, settings, templates, and builds stay in Single Account mode. Multiple Accounts creates a separate workspace and asks you to sign in once for each profile.

+ + +
+ Build library + +
+
+ Build templates + +
+ +

This is a one-time copy. Single Account data is never moved, linked, or written back.

+
+ +
+

+
diff --git a/src/renderer/settings.ts b/src/renderer/settings.ts index dfd05a9..9cc0498 100644 --- a/src/renderer/settings.ts +++ b/src/renderer/settings.ts @@ -43,6 +43,12 @@ const gwonmacTools = form.elements.namedItem('gwonmacTools') as HTMLInputElement; const teamManagement = form.elements.namedItem('teamManagement') as HTMLInputElement; const targetReadout = form.elements.namedItem('targetReadout') as HTMLInputElement; + const accountsName = byId('accounts-first-name') as HTMLInputElement; + const accountsBuilds = form.elements.namedItem('accountsBuilds') as RadioNodeList; + const accountsTemplates = form.elements.namedItem('accountsTemplates') as RadioNodeList; + const accountsImportBuilds = byId('accounts-import-builds') as HTMLInputElement; + const accountsEnable = byId('accounts-enable') as HTMLButtonElement; + const accountsStatus = byId('accounts-setup-status'); /** * The appearance slider beside the `output` that reads it back. * @@ -564,6 +570,30 @@ } }); + accountsEnable.addEventListener('click', async () => { + const name = accountsName.value.trim(); + if (!name) { + accountsName.focus(); + accountsStatus.textContent = 'Give the first profile a name.'; + return; + } + if (!window.confirm('Enable Multiple Accounts and restart GWonMac? Your current Single Account data will stay untouched.')) return; + accountsEnable.disabled = true; + accountsStatus.textContent = 'Creating the separate workspace…'; + try { + await window.gwNative.accounts.setup({ + name, + templates: accountsTemplates.value as 'shared' | 'private', + builds: accountsBuilds.value as 'shared' | 'private', + importTemplates: false, + importBuilds: accountsImportBuilds.checked, + }); + } catch { + accountsEnable.disabled = false; + accountsStatus.textContent = 'Multiple Accounts could not be enabled. Nothing changed.'; + } + }); + window.addEventListener('resize', updateRenderScaleDimensions); window.addEventListener('gw:graphics-resized', updateRenderScaleDimensions); })(); From 3a2606efb7893e37dd3866c909d085b202e9165d Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Thu, 13 Aug 2026 06:48:59 +0200 Subject: [PATCH 09/22] Manage multiple account profiles --- src/main/core/multiple-accounts.ts | 54 +++++++++++++++++ src/main/ipc.ts | 42 ++++++++++++++ src/main/main.ts | 41 +++++++++++++ src/preload/preload.body.cjs | 3 + src/renderer/accounts.css | 20 ++++++- src/renderer/accounts.html | 34 +++++++++++ src/renderer/accounts.ts | 87 ++++++++++++++++++++++++++-- src/renderer/index.html | 4 +- src/renderer/settings.ts | 13 +++++ src/shared/contracts.ts | 16 +++++ tests/unit/multiple-accounts.test.ts | 28 +++++++++ 11 files changed, 334 insertions(+), 8 deletions(-) diff --git a/src/main/core/multiple-accounts.ts b/src/main/core/multiple-accounts.ts index 2e6a771..fd565c8 100644 --- a/src/main/core/multiple-accounts.ts +++ b/src/main/core/multiple-accounts.ts @@ -14,6 +14,7 @@ import { type AccountMode, type LibraryScope, type MultiWorkspace, + type MultiProfile, type ProfileId, } from "../../shared/multiple-accounts.js"; import { AppError } from "../../shared/errors.js"; @@ -88,3 +89,56 @@ export function createMultiWorkspace(options: { }], }); } + +export function addMultiProfile( + workspace: MultiWorkspace, + options: { + readonly name: string; + readonly templates: LibraryScope; + readonly builds: LibraryScope; + readonly id?: string; + }, +): MultiWorkspace { + const profile: MultiProfile = { + id: (options.id ?? randomUUID()) as ProfileId, + name: parseProfileName(options.name), + archived: false, + templates: options.templates, + builds: options.builds, + }; + return parseMultiWorkspace({ + ...workspace, + profiles: [...workspace.profiles, profile], + }); +} + +export function updateMultiProfile( + workspace: MultiWorkspace, + profileId: ProfileId, + changes: Pick, +): MultiWorkspace { + if (!workspace.profiles.some((profile) => profile.id === profileId)) { + throw new AppError("bad_multi_workspace", "profile does not exist"); + } + return parseMultiWorkspace({ + ...workspace, + profiles: workspace.profiles.map((profile) => + profile.id === profileId ? { ...profile, ...changes } : profile, + ), + }); +} + +export function archiveMultiProfile( + workspace: MultiWorkspace, + profileId: ProfileId, +): MultiWorkspace { + if (!workspace.profiles.some((profile) => profile.id === profileId)) { + throw new AppError("bad_multi_workspace", "profile does not exist"); + } + return parseMultiWorkspace({ + ...workspace, + profiles: workspace.profiles.map((profile) => + profile.id === profileId ? { ...profile, archived: true } : profile, + ), + }); +} diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 58934dc..cf88483 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -19,6 +19,8 @@ import type { AppSettings, AppSettingsPatch, AccountsSetupRequest, + AccountProfileRequest, + AccountProfileUpdateRequest, AccountsState, AppUpdateState, CacheInfo, @@ -142,6 +144,9 @@ export interface IpcContext { getAccountsState: () => AccountsState; setupAccounts: (request: AccountsSetupRequest) => Promise; openAccounts: (profileIds: readonly ProfileId[]) => Promise; + createAccount: (request: AccountProfileRequest) => Promise; + updateAccount: (request: AccountProfileUpdateRequest) => Promise; + archiveAccount: (profileId: ProfileId) => Promise; useSingleAccountMode: () => Promise; } @@ -418,6 +423,28 @@ const asAccountsSetup = one((value: unknown): AccountsSetupRequest => { }; }); +function parseAccountProfile(value: unknown): AccountProfileRequest { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new ValidationError("account profile must be an object"); + } + const input = value as Record; + return { + name: parseProfileName(input.name), + templates: parseLibraryScope(input.templates, "templates"), + builds: parseLibraryScope(input.builds, "builds"), + }; +} + +const asAccountProfile = one(parseAccountProfile); +const asAccountProfileUpdate = one((value: unknown): AccountProfileUpdateRequest => { + const profile = parseAccountProfile(value); + return { + id: parseProfileId((value as Record).id), + ...profile, + }; +}); +const asProfileId = one(parseProfileId); + const asProfileIds = one((value: unknown): readonly ProfileId[] => { if (!Array.isArray(value) || value.length === 0 || value.length > 16) { throw new ValidationError("select between 1 and 16 account profiles"); @@ -890,6 +917,21 @@ export function registerIpcHandlers(ctx: IpcContext): { (_win, profileIds) => ctx.openAccounts(profileIds), "hub", ), + accountsCreate: channel( + asAccountProfile, + (_win, request) => ctx.createAccount(request), + "hub", + ), + accountsUpdate: channel( + asAccountProfileUpdate, + (_win, request) => ctx.updateAccount(request), + "hub", + ), + accountsArchive: channel( + asProfileId, + (_win, profileId) => ctx.archiveAccount(profileId), + "hub", + ), accountsUseSingle: channel( nothing, () => ctx.useSingleAccountMode(), diff --git a/src/main/main.ts b/src/main/main.ts index 4825282..53ab277 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -26,6 +26,8 @@ import { type AppSettingsPatch, type AccountsSetupRequest, type AccountsState, + type AccountProfileRequest, + type AccountProfileUpdateRequest, type DownloadProgress, type PrefetchProgress, type UpdateTrack, @@ -117,10 +119,13 @@ import { import { windowRegistry } from "./window-registry.js"; import { createMultiWorkspace, + addMultiProfile, + archiveMultiProfile, loadAccountMode, loadMultiWorkspace, saveAccountMode, saveMultiWorkspace, + updateMultiProfile, } from "./core/multiple-accounts.js"; import { type AccountMode, @@ -171,6 +176,7 @@ const HOST_VERSION = (() => { /** Every settings write is a read-modify-write of one file. */ const settingsLock = new Mutex(); +const accountsLock = new Mutex(); let appUpdaterController: AppUpdater | null = null; let updateRestartInFlight: Promise | null = null; let secondInstanceRequested = false; @@ -745,6 +751,41 @@ if (primaryInstance) void app.whenReady().then(async () => { openAccounts: async (profileIds) => { for (const profileId of profileIds) await openProfile(profileId); }, + createAccount: (request: AccountProfileRequest) => accountsLock.run(async () => { + if (activeAccountMode !== "multi" || !multiWorkspace) { + throw new Error("Multiple Accounts mode is not active"); + } + const next = addMultiProfile(multiWorkspace, request); + const profile = next.profiles.at(-1)!; + await mkdir(multiProfilePaths(paths, profile.id).root, { recursive: true }); + await saveMultiWorkspace(paths.multiWorkspace, next); + multiWorkspace = next; + return accountsState(); + }), + updateAccount: (request: AccountProfileUpdateRequest) => accountsLock.run(async () => { + if (activeAccountMode !== "multi" || !multiWorkspace) { + throw new Error("Multiple Accounts mode is not active"); + } + const next = updateMultiProfile(multiWorkspace, request.id, request); + await saveMultiWorkspace(paths.multiWorkspace, next); + multiWorkspace = next; + windowRegistry.profileWindow(request.id)?.setTitle( + `Guild Wars Reforged — ${request.name}`, + ); + return accountsState(); + }), + archiveAccount: (profileId: ProfileId) => accountsLock.run(async () => { + if (activeAccountMode !== "multi" || !multiWorkspace) { + throw new Error("Multiple Accounts mode is not active"); + } + if (windowRegistry.profileWindow(profileId)) { + throw new Error("Close this account before archiving it"); + } + const next = archiveMultiProfile(multiWorkspace, profileId); + await saveMultiWorkspace(paths.multiWorkspace, next); + multiWorkspace = next; + return accountsState(); + }), useSingleAccountMode, }); diff --git a/src/preload/preload.body.cjs b/src/preload/preload.body.cjs index 28ba948..617bcce 100644 --- a/src/preload/preload.body.cjs +++ b/src/preload/preload.body.cjs @@ -239,6 +239,9 @@ const api = { get: () => ipcRenderer.invoke(IPC.accountsGet), setup: (value) => ipcRenderer.invoke(IPC.accountsSetup, value), open: (profileIds) => ipcRenderer.invoke(IPC.accountsOpen, profileIds), + create: (value) => ipcRenderer.invoke(IPC.accountsCreate, value), + update: (value) => ipcRenderer.invoke(IPC.accountsUpdate, value), + archive: (profileId) => ipcRenderer.invoke(IPC.accountsArchive, profileId), useSingle: () => ipcRenderer.invoke(IPC.accountsUseSingle), }, }; diff --git a/src/renderer/accounts.css b/src/renderer/accounts.css index 59f2820..6d2c7c2 100644 --- a/src/renderer/accounts.css +++ b/src/renderer/accounts.css @@ -75,7 +75,7 @@ h1 { .account-choice { display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-columns: auto minmax(0, 1fr) auto auto; align-items: center; gap: var(--ui-space-3); min-height: 66px; @@ -104,6 +104,7 @@ h1 { .account-state { color: var(--ui-text-muted); font-size: var(--ui-font-size-sm); text-transform: capitalize; } .account-state[data-state="running"] { color: var(--ui-success); } .account-state[data-state="failed"] { color: var(--ui-danger); } +.account-edit { white-space: nowrap; } .accounts-help, .accounts-foot p, @@ -126,9 +127,24 @@ h1 { .accounts-foot p { max-width: 420px; margin: 0; font-size: var(--ui-font-size-sm); } .sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } +.profile-dialog { + width: min(520px, calc(100vw - 32px)); + padding: 0; + border: 0; + color: var(--ui-text); +} +.profile-dialog::backdrop { background: rgb(0 0 0 / 68%); } +.profile-dialog .ui-panel { padding: 9px 10px 11px; } +.profile-fields { display: grid; gap: var(--ui-space-3); } +.profile-fields fieldset { min-width: 0; margin: 0; padding: 0; border: 0; } +.profile-fields legend { margin-bottom: var(--ui-space-2); color: var(--ui-text-muted); } +.profile-fields > label { color: var(--ui-text-muted); } +.profile-actions { flex-wrap: wrap; } +.profile-action-spacer { flex: 1; } + @media (max-width: 520px) { .accounts-head img { display: none; } .accounts-foot { align-items: flex-start; flex-direction: column; } - .account-choice { grid-template-columns: auto minmax(0, 1fr); } + .account-choice { grid-template-columns: auto minmax(0, 1fr) auto; } .account-state { grid-column: 2; } } diff --git a/src/renderer/accounts.html b/src/renderer/accounts.html index 7228246..c4f6c37 100644 --- a/src/renderer/accounts.html +++ b/src/renderer/accounts.html @@ -29,6 +29,7 @@

Who are you playing?

+

@@ -38,6 +39,39 @@

Who are you playing?

+ +
+
+

New profile

+ +
+
+ + +
+ Build library +
+ + +
+
+
+ Build templates +
+ + +
+
+

Sharing is only with other Multiple Accounts profiles. Saved login and Guild Wars files always stay private.

+
+
+ + + + +
+
+
diff --git a/src/renderer/accounts.ts b/src/renderer/accounts.ts index 6c23df1..7a1f0b6 100644 --- a/src/renderer/accounts.ts +++ b/src/renderer/accounts.ts @@ -15,9 +15,17 @@ import type { AccountProfileSummary } from '../shared/contracts.js'; const list = required('accounts-list'); const open = required('accounts-open'); const selectAll = required('accounts-select-all'); + const newProfile = required('accounts-new'); const status = required('accounts-status'); const single = required('accounts-single'); + const profileDialog = required('profile-dialog'); + const profileForm = required('profile-form'); + const profileTitle = required('profile-dialog-title'); + const profileName = required('profile-name'); + const profileSave = required('profile-save'); + const profileArchive = required('profile-archive'); let profiles: readonly AccountProfileSummary[] = []; + let editing: AccountProfileSummary | null = null; function setStatus(message: string, tone: 'neutral' | 'progress' | 'success' | 'error') { status.textContent = message; @@ -36,14 +44,16 @@ import type { AccountProfileSummary } from '../shared/contracts.js'; } function renderProfile(profile: AccountProfileSummary) { - const label = document.createElement('label'); - label.className = 'account-choice'; + const row = document.createElement('div'); + row.className = 'account-choice'; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.name = 'profile'; checkbox.value = profile.id; checkbox.checked = profile.state !== 'failed'; - const details = document.createElement('span'); + checkbox.id = `profile-${profile.id}`; + const details = document.createElement('label'); + details.htmlFor = checkbox.id; const name = document.createElement('span'); name.className = 'account-name'; name.textContent = profile.name; @@ -55,8 +65,35 @@ import type { AccountProfileSummary } from '../shared/contracts.js'; state.className = 'account-state'; state.dataset.state = profile.state; state.textContent = profile.state; - label.append(checkbox, details, state); - return label; + const edit = document.createElement('button'); + edit.type = 'button'; + edit.className = 'account-edit ui-button'; + edit.dataset.variant = 'quiet'; + edit.textContent = 'Edit…'; + edit.addEventListener('click', () => showProfileDialog(profile)); + row.append(checkbox, details, state, edit); + return row; + } + + const choice = (name: string) => + profileForm.elements.namedItem(name) as RadioNodeList; + + function closeProfileDialog() { + profileDialog.close(); + editing = null; + } + + function showProfileDialog(profile: AccountProfileSummary | null) { + editing = profile; + profileTitle.textContent = profile ? `Edit ${profile.name}` : 'New profile'; + profileSave.textContent = profile ? 'Save changes' : 'Create profile'; + profileName.value = profile?.name ?? ''; + choice('profileBuilds').value = profile?.builds ?? 'shared'; + choice('profileTemplates').value = profile?.templates ?? 'shared'; + profileArchive.hidden = !profile || profiles.length < 2; + profileArchive.disabled = profile?.state === 'running'; + profileDialog.showModal(); + profileName.focus(); } async function refresh() { @@ -79,6 +116,46 @@ import type { AccountProfileSummary } from '../shared/contracts.js'; for (const input of inputs) input.checked = next; syncActions(); }); + newProfile.addEventListener('click', () => showProfileDialog(null)); + required('profile-cancel').addEventListener('click', closeProfileDialog); + required('profile-cancel-x').addEventListener('click', closeProfileDialog); + profileForm.addEventListener('submit', async (event) => { + event.preventDefault(); + if (!profileForm.reportValidity()) return; + profileSave.disabled = true; + const request = { + name: profileName.value.trim(), + builds: choice('profileBuilds').value as 'shared' | 'private', + templates: choice('profileTemplates').value as 'shared' | 'private', + }; + const updating = editing !== null; + try { + if (editing) await window.gwNative.accounts.update({ id: editing.id, ...request }); + else await window.gwNative.accounts.create(request); + closeProfileDialog(); + setStatus(updating ? 'Profile updated.' : 'Profile created.', 'success'); + await refresh(); + } catch { + setStatus('The profile could not be saved. Check that its name is unique.', 'error'); + } finally { + profileSave.disabled = false; + } + }); + profileArchive.addEventListener('click', async () => { + const profile = editing; + if (!profile || !window.confirm(`Archive “${profile.name}”? Its saved login and files will be kept.`)) return; + profileArchive.disabled = true; + try { + await window.gwNative.accounts.archive(profile.id); + closeProfileDialog(); + setStatus('Profile archived. Its data was kept.', 'success'); + await refresh(); + } catch { + setStatus('Close the profile before archiving it, then try again.', 'error'); + } finally { + profileArchive.disabled = false; + } + }); form.addEventListener('submit', async (event) => { event.preventDefault(); const ids = selectedIds(); diff --git a/src/renderer/index.html b/src/renderer/index.html index 85c843b..311006c 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -572,8 +572,9 @@

Advanced

aria-labelledby="settings-tab-accounts">

Multiple Accounts

Opt in to an account picker that can open several independent Guild Wars windows.

-

Single Account mode is active.

+

Checking account mode…

Your current window, saved login, settings, templates, and builds stay in Single Account mode. Multiple Accounts creates a separate workspace and asks you to sign in once for each profile.

+

+ diff --git a/src/renderer/settings.ts b/src/renderer/settings.ts index 9cc0498..10d76e1 100644 --- a/src/renderer/settings.ts +++ b/src/renderer/settings.ts @@ -49,6 +49,8 @@ const accountsImportBuilds = byId('accounts-import-builds') as HTMLInputElement; const accountsEnable = byId('accounts-enable') as HTMLButtonElement; const accountsStatus = byId('accounts-setup-status'); + const accountsModeStatus = byId('accounts-mode-status'); + const accountsSingleSetup = byId('accounts-single-setup'); /** * The appearance slider beside the `output` that reads it back. * @@ -594,6 +596,17 @@ } }); + void window.gwNative.accounts.get().then((state) => { + const singleMode = state.mode === 'single'; + accountsModeStatus.textContent = singleMode + ? 'Single Account mode is active.' + : 'Multiple Accounts mode is active. Use the Account Picker to manage profiles or return to Single Account mode.'; + accountsSingleSetup.hidden = !singleMode; + }).catch(() => { + accountsModeStatus.textContent = 'Account mode could not be read.'; + accountsSingleSetup.hidden = true; + }); + window.addEventListener('resize', updateRenderScaleDimensions); window.addEventListener('gw:graphics-resized', updateRenderScaleDimensions); })(); diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 24408a5..5fe3c4c 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -630,6 +630,16 @@ export interface AccountsSetupRequest { readonly importBuilds: boolean; } +export interface AccountProfileRequest { + readonly name: string; + readonly templates: LibraryScope; + readonly builds: LibraryScope; +} + +export interface AccountProfileUpdateRequest extends AccountProfileRequest { + readonly id: ProfileId; +} + /** * Prefix of the single `webPreferences.additionalArguments` entry that carries * a JSON `RendererInit`. The preload is the only reader. @@ -758,6 +768,9 @@ export const IPC = { accountsGet: "gw:accounts:get", accountsSetup: "gw:accounts:setup", accountsOpen: "gw:accounts:open", + accountsCreate: "gw:accounts:create", + accountsUpdate: "gw:accounts:update", + accountsArchive: "gw:accounts:archive", accountsUseSingle: "gw:accounts:useSingle", } as const; @@ -939,6 +952,9 @@ export interface GwNativeApi { get(): Promise; setup(value: AccountsSetupRequest): Promise; open(profileIds: readonly ProfileId[]): Promise; + create(value: AccountProfileRequest): Promise; + update(value: AccountProfileUpdateRequest): Promise; + archive(profileId: ProfileId): Promise; useSingle(): Promise; }; } diff --git a/tests/unit/multiple-accounts.test.ts b/tests/unit/multiple-accounts.test.ts index bd93ae1..feb3963 100644 --- a/tests/unit/multiple-accounts.test.ts +++ b/tests/unit/multiple-accounts.test.ts @@ -5,11 +5,14 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, it } from "node:test"; import { + addMultiProfile, + archiveMultiProfile, createMultiWorkspace, loadAccountMode, loadMultiWorkspace, saveAccountMode, saveMultiWorkspace, + updateMultiProfile, } from "../../src/main/core/multiple-accounts.js"; import { parseMultiWorkspace, @@ -114,4 +117,29 @@ describe("Multiple Accounts documents", () => { AppError, ); }); + + it("adds, updates, and archives profiles without changing stable IDs", () => { + const first = createMultiWorkspace({ + id: "00000000-0000-4000-8000-000000000001", + name: "Primary", + templates: "private", + builds: "private", + }); + const added = addMultiProfile(first, { + id: "00000000-0000-4000-8000-000000000002", + name: "Storage", + templates: "shared", + builds: "shared", + }); + const updated = updateMultiProfile(added, added.profiles[1]!.id, { + name: "Storage Alt", + templates: "private", + builds: "shared", + }); + const archived = archiveMultiProfile(updated, updated.profiles[1]!.id); + assert.equal(archived.profiles[0]!.name, "Primary"); + assert.equal(archived.profiles[1]!.id, added.profiles[1]!.id); + assert.equal(archived.profiles[1]!.name, "Storage Alt"); + assert.equal(archived.profiles[1]!.archived, true); + }); }); From 7002402a4646f2d248dcbf5d0d8620b930cc2365 Mon Sep 17 00:00:00 2001 From: Mat4m0 Date: Thu, 13 Aug 2026 07:02:06 +0200 Subject: [PATCH 10/22] Share and reconcile account resources --- docs/multiple-accounts.md | 12 +- src/main/core/account-template-library.ts | 116 ++++++++++++++++ src/main/core/multiple-accounts.ts | 29 ++++ src/main/core/paths.ts | 2 + src/main/ipc.ts | 91 ++++++++++-- src/main/main.ts | 130 ++++++++++++++++-- src/main/settings-actions.ts | 19 ++- src/main/template-export.ts | 54 +------- src/main/window.ts | 17 ++- src/preload/preload.body.cjs | 4 + src/renderer/accounts.css | 5 + src/renderer/accounts.html | 4 + src/renderer/accounts.ts | 57 +++++++- src/renderer/commands.ts | 10 +- src/renderer/filesystem.ts | 14 +- src/renderer/harness.ts | 6 + src/renderer/index.html | 6 + src/renderer/settings.ts | 25 +++- src/renderer/template-store.ts | 40 ++++++ src/shared/contracts.ts | 15 ++ src/shared/multiple-accounts.ts | 7 +- src/shared/template-entries.ts | 59 ++++++++ tests/unit/account-template-library.test.ts | 42 ++++++ tests/unit/multiple-accounts.test.ts | 10 ++ tests/unit/paths.test.ts | 2 +- tests/unit/template-store.test.ts | 17 +++ ...-kernel-is-compiled-once-per-build.test.ts | 4 + 27 files changed, 706 insertions(+), 91 deletions(-) create mode 100644 src/main/core/account-template-library.ts create mode 100644 src/shared/template-entries.ts create mode 100644 tests/unit/account-template-library.test.ts diff --git a/docs/multiple-accounts.md b/docs/multiple-accounts.md index 0f62f82..0735d14 100644 --- a/docs/multiple-accounts.md +++ b/docs/multiple-accounts.md @@ -42,7 +42,7 @@ Keychain items. ## Setup and mode transitions -Settings shows **Set Up Multiple Accounts…** only in the Advanced pane until +Settings shows Multiple Accounts setup only in the Accounts pane until the player enables the mode. Setup creates a staged Multiple Accounts workspace and at least one profile. @@ -64,18 +64,20 @@ transition copies data automatically. Sharing applies only among Multiple Accounts profiles. Each profile selects **Shared** or **Private** independently for templates and for builds and teams. -Build libraries are main-process documents with revisions. A stale renderer -cannot replace a newer shared library without an explicit conflict result. +Build libraries remain main-process documents. Main serializes writes per +library and refuses a save whose last-read baseline is stale, so one profile +cannot silently replace another profile's newer shared library. Every profile keeps an isolated IDBFS mount. A profile that uses Shared templates receives a working projection of the canonical Multiple Accounts template library. The app reconciles that projection before launch and after a -clean close. It does not mutate another running renderer's filesystem. +clean close or reload. It does not mutate another running renderer's filesystem. Template reconciliation preserves both contents when two different templates use the same normalized path. A deletion cannot silently discard a concurrent edit. The canonical library and each profile checkpoint use revisions, so a -projection can be rebuilt. +projection can be rebuilt. Private template libraries use the same snapshot +format but never reconcile with another profile. ## Lifecycle and recovery diff --git a/src/main/core/account-template-library.ts b/src/main/core/account-template-library.ts new file mode 100644 index 0000000..c81c76d --- /dev/null +++ b/src/main/core/account-template-library.ts @@ -0,0 +1,116 @@ +/** + * The canonical Multiple Accounts template library and its three-way merge. + * + * Each shared profile receives a checkpoint at launch. On close, its current + * projection is compared with that checkpoint and the latest canonical + * library. Unrelated edits combine; a concurrent edit beats a stale deletion; + * and two different edits to one path are both retained under stable conflict + * names. Single Account data never enters this owner except as an explicit + * setup snapshot. + */ +import { readFile } from "node:fs/promises"; +import type { + AccountTemplateLibrary, + TemplateExportEntry, +} from "../../shared/contracts.js"; +import { AppError } from "../../shared/errors.js"; +import { parseTemplateEntries } from "../../shared/template-entries.js"; +import { writeAtomicJson } from "./atomic-file.js"; + +const DOCUMENT_MODE = 0o600; + +function parseLibrary(value: unknown): AccountTemplateLibrary { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new AppError("bad_multi_workspace", "template library must be an object"); + } + const source = value as Record; + if ( + source.formatVersion !== 1 + || !Number.isSafeInteger(source.revision) + || (source.revision as number) < 0 + ) { + throw new AppError("bad_multi_workspace", "template library format is invalid"); + } + return { + revision: source.revision as number, + entries: parseTemplateEntries(source.entries), + }; +} + +export async function loadAccountTemplateLibrary( + filePath: string, +): Promise { + try { + return parseLibrary(JSON.parse(await readFile(filePath, "utf8")) as unknown); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return { revision: 0, entries: [] }; + } + throw error; + } +} + +export async function saveAccountTemplateLibrary( + filePath: string, + library: AccountTemplateLibrary, +): Promise { + const entries = parseTemplateEntries(library.entries); + const value = { formatVersion: 1, revision: library.revision, entries }; + await writeAtomicJson(filePath, value, DOCUMENT_MODE); + return { revision: value.revision, entries: value.entries }; +} + +const pathKey = (filePath: string) => filePath.normalize("NFC").toLowerCase(); +const entriesByPath = (entries: readonly TemplateExportEntry[]) => + new Map(entries.map((entry) => [pathKey(entry.path), entry])); + +function conflictPath(path: string, occupied: ReadonlySet): string { + const suffix = " (conflict)"; + const extension = path.toLowerCase().endsWith(".txt") ? ".txt" : ""; + const cut = path.lastIndexOf("/"); + const directory = path.slice(0, cut + 1); + const file = path.slice(cut + 1, extension ? -extension.length : undefined); + for (let number = 1; number <= 99; number += 1) { + const tag = `${suffix}${number === 1 ? "" : ` ${number}`}`; + const maxStem = 259 - extension.length - tag.length; + const candidate = `${directory}${file.slice(0, maxStem)}${tag}${extension}`; + if (!occupied.has(pathKey(candidate))) return candidate; + } + throw new AppError("bad_multi_workspace", "too many template conflicts"); +} + +/** Merge one profile projection against the checkpoint it was launched with. */ +export function reconcileAccountTemplates( + baseEntries: readonly TemplateExportEntry[], + latestEntries: readonly TemplateExportEntry[], + profileEntries: readonly TemplateExportEntry[], +): TemplateExportEntry[] { + const base = entriesByPath(parseTemplateEntries(baseEntries)); + const latest = entriesByPath(parseTemplateEntries(latestEntries)); + const profile = entriesByPath(parseTemplateEntries(profileEntries)); + const result = new Map(latest); + const paths = new Set([...base.keys(), ...profile.keys()]); + for (const key of paths) { + const before = base.get(key); + const local = profile.get(key); + const current = latest.get(key); + if (local?.contents === before?.contents) continue; + if (local === undefined) { + if (current?.contents === before?.contents) result.delete(key); + continue; + } + if ( + current === undefined + || current.contents === before?.contents + || current.contents === local.contents + ) { + result.set(key, local); + continue; + } + const occupied = new Set(result.keys()); + const conflicted = conflictPath(local.path, occupied); + result.set(pathKey(conflicted), { path: conflicted, contents: local.contents }); + } + return [...result.values()] + .sort((left, right) => left.path.localeCompare(right.path)); +} diff --git a/src/main/core/multiple-accounts.ts b/src/main/core/multiple-accounts.ts index fd565c8..cd81896 100644 --- a/src/main/core/multiple-accounts.ts +++ b/src/main/core/multiple-accounts.ts @@ -142,3 +142,32 @@ export function archiveMultiProfile( ), }); } + +export function restoreMultiProfile( + workspace: MultiWorkspace, + profileId: ProfileId, +): MultiWorkspace { + if (!workspace.profiles.some((profile) => profile.id === profileId)) { + throw new AppError("bad_multi_workspace", "profile does not exist"); + } + return parseMultiWorkspace({ + ...workspace, + profiles: workspace.profiles.map((profile) => + profile.id === profileId ? { ...profile, archived: false } : profile, + ), + }); +} + +export function removeArchivedMultiProfile( + workspace: MultiWorkspace, + profileId: ProfileId, +): MultiWorkspace { + const profile = workspace.profiles.find((candidate) => candidate.id === profileId); + if (!profile?.archived) { + throw new AppError("bad_multi_workspace", "only an archived profile can be deleted"); + } + return parseMultiWorkspace({ + ...workspace, + profiles: workspace.profiles.filter((candidate) => candidate.id !== profileId), + }); +} diff --git a/src/main/core/paths.ts b/src/main/core/paths.ts index 6a80acd..c2d371d 100644 --- a/src/main/core/paths.ts +++ b/src/main/core/paths.ts @@ -86,6 +86,7 @@ export interface MultiProfilePaths { readonly templates: string; readonly templateSync: string; readonly windowState: string; + readonly gameStorageClearRequest: string; } /** Resolve stores only after `parseProfileId` has made traversal impossible. */ @@ -100,6 +101,7 @@ export function multiProfilePaths( templates: path.join(root, "templates.json"), templateSync: path.join(root, "template-sync.json"), windowState: path.join(root, "window-state.json"), + gameStorageClearRequest: path.join(root, "clear-game-storage-on-start"), }; } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index cf88483..02e0301 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -21,6 +21,7 @@ import type { AccountsSetupRequest, AccountProfileRequest, AccountProfileUpdateRequest, + AccountTemplateLibrary, AccountsState, AppUpdateState, CacheInfo, @@ -36,10 +37,12 @@ import type { SteamRefusalReason, SteamTokenResult, StoredCredentials, + TemplateExportEntry, } from "../shared/contracts.js"; import { parseProfileId, parseProfileName, + MULTI_PROFILE_MAX_COUNT, type LibraryScope, type ProfileId, } from "../shared/multiple-accounts.js"; @@ -87,6 +90,7 @@ import type { } from "./steam-acquire.js"; import { parseSettingsPatch } from "./core/settings.js"; import type { SocketManager } from "./core/sockets.js"; +import { Mutex } from "./core/mutex.js"; import { FREE_MARGIN, type ChunkStore } from "./core/chunk-store.js"; import { count, @@ -122,6 +126,7 @@ export interface IpcContext { credentialsStoreFor: (win: BrowserWindow) => CredentialsStore; steamSessionStoreFor: (win: BrowserWindow) => SteamSessionStore; buildLibraryPathFor: (win: BrowserWindow) => string; + gameStorageResetMarkerFor: (win: BrowserWindow) => string; getProgress: () => DownloadProgress; getChunkStore: () => ChunkStore | null; getSettings: () => Promise; @@ -147,7 +152,14 @@ export interface IpcContext { createAccount: (request: AccountProfileRequest) => Promise; updateAccount: (request: AccountProfileUpdateRequest) => Promise; archiveAccount: (profileId: ProfileId) => Promise; + restoreAccount: (profileId: ProfileId) => Promise; + deleteAccount: (profileId: ProfileId) => Promise; useSingleAccountMode: () => Promise; + loadAccountTemplates: (win: BrowserWindow) => Promise; + saveAccountTemplates: ( + win: BrowserWindow, + entries: readonly TemplateExportEntry[], + ) => Promise; } type SteamInvokeChannel = "steamToken" | "steamStore" | "steamClear"; @@ -419,6 +431,7 @@ const asAccountsSetup = one((value: unknown): AccountsSetupRequest => { templates: parseLibraryScope(input.templates, "templates"), builds: parseLibraryScope(input.builds, "builds"), importTemplates: input.importTemplates, + templateEntries: parseExportEntries(input.templateEntries), importBuilds: input.importBuilds, }; }); @@ -446,8 +459,14 @@ const asAccountProfileUpdate = one((value: unknown): AccountProfileUpdateRequest const asProfileId = one(parseProfileId); const asProfileIds = one((value: unknown): readonly ProfileId[] => { - if (!Array.isArray(value) || value.length === 0 || value.length > 16) { - throw new ValidationError("select between 1 and 16 account profiles"); + if ( + !Array.isArray(value) + || value.length === 0 + || value.length > MULTI_PROFILE_MAX_COUNT + ) { + throw new ValidationError( + `select between 1 and ${MULTI_PROFILE_MAX_COUNT} account profiles`, + ); } const ids = value.map(parseProfileId); if (new Set(ids).size !== ids.length) { @@ -670,6 +689,28 @@ export function registerIpcHandlers(ctx: IpcContext): { } { const paths = gamePaths(); const secretOperations = new Set>(); + const buildBaselines = new WeakMap>(); + const buildLocks = new Map(); + const buildLock = (libraryPath: string): Mutex => { + let lock = buildLocks.get(libraryPath); + if (!lock) { + lock = new Mutex(); + buildLocks.set(libraryPath, lock); + } + return lock; + }; + const rememberBuildBaseline = ( + win: BrowserWindow, + libraryPath: string, + library: unknown, + ): void => { + let values = buildBaselines.get(win); + if (!values) { + values = new Map(); + buildBaselines.set(win, values); + } + values.set(libraryPath, JSON.stringify(library)); + }; const secretOperation = (operation: () => Promise): Promise => { if (isQuitting()) { return Promise.reject(new ValidationError("application is quitting")); @@ -734,15 +775,31 @@ export function registerIpcHandlers(ctx: IpcContext): { }), buildLibraryGet: channel(nothing, async (win) => { - let recovered = false; - const library = await loadBuildLibrary(ctx.buildLibraryPathFor(win), () => { - recovered = true; + const libraryPath = ctx.buildLibraryPathFor(win); + return buildLock(libraryPath).run(async () => { + let recovered = false; + const library = await loadBuildLibrary(libraryPath, () => { + recovered = true; + }); + rememberBuildBaseline(win, libraryPath, library); + return { library, recovered }; }); - return { library, recovered }; }), buildLibrarySet: channel(one(parseBuildLibrary), async (win, library) => { - return saveBuildLibrary(ctx.buildLibraryPathFor(win), library); + const libraryPath = ctx.buildLibraryPathFor(win); + return buildLock(libraryPath).run(async () => { + const current = await loadBuildLibrary(libraryPath); + const expected = buildBaselines.get(win)?.get(libraryPath); + if (expected === undefined || expected !== JSON.stringify(current)) { + throw new ValidationError( + "build library changed in another account; reload before saving", + ); + } + const saved = await saveBuildLibrary(libraryPath, library); + rememberBuildBaseline(win, libraryPath, saved); + return saved; + }); }), settingsGet: channel(nothing, async () => { @@ -818,7 +875,7 @@ export function registerIpcHandlers(ctx: IpcContext): { cacheStopDownload: channel(nothing, () => ctx.stopFullDownload()), gameStorageReset: channel(nothing, (win) => - requestGameStorageReset(win, paths.gameStorageClearRequest), + requestGameStorageReset(win, ctx.gameStorageResetMarkerFor(win)), ), diagnosticsGraphics: channel(asGraphics, (_win, value) => { @@ -932,11 +989,29 @@ export function registerIpcHandlers(ctx: IpcContext): { (_win, profileId) => ctx.archiveAccount(profileId), "hub", ), + accountsRestore: channel( + asProfileId, + (_win, profileId) => ctx.restoreAccount(profileId), + "hub", + ), + accountsDelete: channel( + asProfileId, + (_win, profileId) => ctx.deleteAccount(profileId), + "hub", + ), accountsUseSingle: channel( nothing, () => ctx.useSingleAccountMode(), "any", ), + accountsTemplatesLoad: channel( + nothing, + (win) => ctx.loadAccountTemplates(win), + ), + accountsTemplatesSave: channel( + one(parseExportEntries), + (win, entries) => ctx.saveAccountTemplates(win, entries), + ), } satisfies Record, AnyChannelDef>; registerChannelDefinitions(ctx.windows, handlers); diff --git a/src/main/main.ts b/src/main/main.ts index 53ab277..30bc97d 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -115,6 +115,7 @@ import { import { applyPendingCacheClear, applyPendingGameStorageReset, + applyPendingSessionStorageReset, } from "./settings-actions.js"; import { windowRegistry } from "./window-registry.js"; import { @@ -125,6 +126,8 @@ import { loadMultiWorkspace, saveAccountMode, saveMultiWorkspace, + removeArchivedMultiProfile, + restoreMultiProfile, updateMultiProfile, } from "./core/multiple-accounts.js"; import { @@ -132,6 +135,11 @@ import { type ProfileId, } from "../shared/multiple-accounts.js"; import { multiSecretSlot } from "./core/native-keychain.js"; +import { + loadAccountTemplateLibrary, + reconcileAccountTemplates, + saveAccountTemplateLibrary, +} from "./core/account-template-library.js"; import { createAccountsWindow, revealAccountsWindow, @@ -177,6 +185,7 @@ const HOST_VERSION = (() => { /** Every settings write is a read-modify-write of one file. */ const settingsLock = new Mutex(); const accountsLock = new Mutex(); +const templatesLock = new Mutex(); let appUpdaterController: AppUpdater | null = null; let updateRestartInFlight: Promise | null = null; let secondInstanceRequested = false; @@ -368,12 +377,18 @@ function buildWindowHost( startCapture: startDiagnosticCapture, stopCapture: stopDiagnosticCapture, reloadGame: (win) => { - sockets.closeAll(win.webContents.id); - void win.loadURL(RENDERER_URL); + void (async () => { + await sendRendererCommand(win, { type: "filesystem.sync" }); + sockets.closeAll(win.webContents.id); + await win.loadURL(RENDERER_URL); + })(); }, prepareRendererRecovery: async () => { await clientRuntime.recoverRendererCrash(); }, + gameWindowClosed: () => { + if (activeAccountMode === "multi") revealAccountsWindow(); + }, }; } @@ -395,9 +410,9 @@ if (primaryInstance) void app.whenReady().then(async () => { }); const paths = gamePaths(); activeAccountMode = await loadAccountMode(paths.launcherMode); - let multiWorkspace = activeAccountMode === "multi" - ? await loadMultiWorkspace(paths.multiWorkspace) - : null; + // The registry is safe to inspect from Single mode for the explicit Accounts + // settings pane. Its sessions, libraries, and Keychain items remain closed. + let multiWorkspace = await loadMultiWorkspace(paths.multiWorkspace); if (activeAccountMode === "multi" && !multiWorkspace) { throw new Error("Multiple Accounts mode has no workspace"); } @@ -578,14 +593,13 @@ if (primaryInstance) void app.whenReady().then(async () => { }; const accountsState = (): AccountsState => ({ mode: activeAccountMode, - profiles: (multiWorkspace?.profiles ?? []) - .filter((profile) => !profile.archived) - .map((profile) => ({ + profiles: (multiWorkspace?.profiles ?? []).map((profile) => ({ id: profile.id, name: profile.name, templates: profile.templates, builds: profile.builds, - state: windowRegistry.profileWindow(profile.id) + archived: profile.archived, + state: !profile.archived && windowRegistry.profileWindow(profile.id) ? "running" : (profileLaunchState.get(profile.id) ?? "ready"), })), @@ -614,6 +628,14 @@ if (primaryInstance) void app.whenReady().then(async () => { owner.clearCache(), ]); const profilePaths = multiProfilePaths(paths, profileId); + const reset = await applyPendingSessionStorageReset( + owner, + profilePaths.gameStorageClearRequest, + ); + if (reset && profile.templates === "private") { + await rm(profilePaths.templates, { force: true }); + await rm(profilePaths.templateSync, { force: true }); + } await mkdir(profilePaths.root, { recursive: true }); await prepareWindowState(profilePaths.windowState); const win = createMainWindow(host, { @@ -649,7 +671,13 @@ if (primaryInstance) void app.whenReady().then(async () => { ); } if (request.importTemplates) { - throw new Error("Template import is not available in this build"); + const templatePath = profile.templates === "shared" + ? paths.multiSharedTemplates + : profilePaths.templates; + await saveAccountTemplateLibrary(templatePath, { + revision: 1, + entries: request.templateEntries, + }); } await saveMultiWorkspace(paths.multiWorkspace, multiWorkspace); } @@ -689,6 +717,12 @@ if (primaryInstance) void app.whenReady().then(async () => { ? paths.multiSharedBuildLibrary : multiProfilePaths(paths, profile.id).buildLibrary; }, + gameStorageResetMarkerFor: (win) => { + const context = windowRegistry.contextForWebContents(win.webContents.id); + return context?.mode === "multi" && context.role === "game" + ? multiProfilePaths(paths, context.profileId).gameStorageClearRequest + : paths.gameStorageClearRequest; + }, getProgress: () => clientRuntime.progress, getChunkStore: () => clientRuntime.active?.store ?? null, getSettings: () => loadSettings(paths.settings), @@ -766,6 +800,13 @@ if (primaryInstance) void app.whenReady().then(async () => { if (activeAccountMode !== "multi" || !multiWorkspace) { throw new Error("Multiple Accounts mode is not active"); } + const current = profileFor(request.id); + if ( + windowRegistry.profileWindow(request.id) + && (current.builds !== request.builds || current.templates !== request.templates) + ) { + throw new Error("Close this account before changing sharing"); + } const next = updateMultiProfile(multiWorkspace, request.id, request); await saveMultiWorkspace(paths.multiWorkspace, next); multiWorkspace = next; @@ -786,6 +827,75 @@ if (primaryInstance) void app.whenReady().then(async () => { multiWorkspace = next; return accountsState(); }), + restoreAccount: (profileId: ProfileId) => accountsLock.run(async () => { + if (activeAccountMode !== "multi" || !multiWorkspace) { + throw new Error("Multiple Accounts mode is not active"); + } + const next = restoreMultiProfile(multiWorkspace, profileId); + await saveMultiWorkspace(paths.multiWorkspace, next); + multiWorkspace = next; + return accountsState(); + }), + deleteAccount: (profileId: ProfileId) => accountsLock.run(async () => { + if (activeAccountMode !== "multi" || !multiWorkspace) { + throw new Error("Multiple Accounts mode is not active"); + } + const next = removeArchivedMultiProfile(multiWorkspace, profileId); + const owner = session.fromPartition(`persist:gw-multi-${profileId}`, { + cache: false, + }); + await Promise.all([ + credentialStoreForProfile(profileId).clear(), + steamStoreForProfile(profileId).clear(), + owner.clearStorageData(), + owner.clearCache(), + ]); + await rm(multiProfilePaths(paths, profileId).root, { + recursive: true, + force: true, + }); + await saveMultiWorkspace(paths.multiWorkspace, next); + credentialsStores.delete(profileId); + steamSessionStores.delete(profileId); + multiWorkspace = next; + return accountsState(); + }), + loadAccountTemplates: async (win) => { + const context = windowRegistry.contextForWebContents(win.webContents.id); + if (context?.mode !== "multi" || context.role !== "game") return null; + const profile = profileFor(context.profileId); + const profilePaths = multiProfilePaths(paths, profile.id); + const libraryPath = profile.templates === "shared" + ? paths.multiSharedTemplates + : profilePaths.templates; + const library = await loadAccountTemplateLibrary(libraryPath); + await saveAccountTemplateLibrary(profilePaths.templateSync, library); + return library; + }, + saveAccountTemplates: (win, entries) => templatesLock.run(async () => { + const context = windowRegistry.contextForWebContents(win.webContents.id); + if (context?.mode !== "multi" || context.role !== "game") return; + const profile = profileFor(context.profileId); + const profilePaths = multiProfilePaths(paths, profile.id); + if (profile.templates === "private") { + const current = await loadAccountTemplateLibrary(profilePaths.templates); + await saveAccountTemplateLibrary(profilePaths.templates, { + revision: current.revision + 1, + entries, + }); + return; + } + const [base, latest] = await Promise.all([ + loadAccountTemplateLibrary(profilePaths.templateSync), + loadAccountTemplateLibrary(paths.multiSharedTemplates), + ]); + const merged = { + revision: latest.revision + 1, + entries: reconcileAccountTemplates(base.entries, latest.entries, entries), + }; + await saveAccountTemplateLibrary(paths.multiSharedTemplates, merged); + await saveAccountTemplateLibrary(profilePaths.templateSync, merged); + }), useSingleAccountMode, }); diff --git a/src/main/settings-actions.ts b/src/main/settings-actions.ts index abb18d5..b63bca6 100644 --- a/src/main/settings-actions.ts +++ b/src/main/settings-actions.ts @@ -7,7 +7,7 @@ * a settings value or reset marker is durable, a failed relaunch cannot turn * that completed write into a false failure response. */ -import { app, dialog, session } from "electron"; +import { app, dialog, session, type Session } from "electron"; import type { BrowserWindow } from "electron"; import { rm, stat, writeFile } from "node:fs/promises"; import type { @@ -208,13 +208,24 @@ export async function applyPendingCacheClear(paths: GamePaths): Promise { export async function applyPendingGameStorageReset( paths: GamePaths, ): Promise { - if (!(await pendingMarkerExists(paths.gameStorageClearRequest))) return; + await applyPendingSessionStorageReset( + session.defaultSession, + paths.gameStorageClearRequest, + ); +} + +export async function applyPendingSessionStorageReset( + owner: Session, + markerPath: string, +): Promise { + if (!(await pendingMarkerExists(markerPath))) return false; // This runs before a renderer can mount IDBFS. Clearing it later would race // the game's auto-persist and could recreate files before quit. - await session.defaultSession.clearStorageData({ + await owner.clearStorageData({ origin: "gw://app", storages: ["indexdb"], }); - await rm(paths.gameStorageClearRequest, { force: true }); + await rm(markerPath, { force: true }); logEvent({ k: "filesystem.resetCompleted" }); + return true; } diff --git a/src/main/template-export.ts b/src/main/template-export.ts index 24ada9a..7ce30af 100644 --- a/src/main/template-export.ts +++ b/src/main/template-export.ts @@ -16,11 +16,11 @@ import { dialog, type BrowserWindow } from "electron"; import path from "node:path"; import { mkdir, writeFile } from "node:fs/promises"; import { - TEMPLATE_CEILINGS, type TemplateExportEntry, type TemplateExportResult, } from "../shared/contracts.js"; -import { AppError, ValidationError, type ErrorCode } from "../shared/errors.js"; +import { AppError, type ErrorCode } from "../shared/errors.js"; +import { parseTemplateEntries } from "../shared/template-entries.js"; /** The folder an export creates. Numbered rather than merged, so nothing is replaced. */ const DESTINATION_NAME = "Guild Wars Build Templates"; @@ -30,9 +30,6 @@ const MAX_DESTINATIONS = 99; * `Skills/.txt` or `Skills//.txt` — two or three segments, * which is the deepest the client can key a template. */ -const MIN_SEGMENTS = 2; -const MAX_SEGMENTS = 3; - /** * The rule the writer relies on, applied at the boundary rather than trusted. * @@ -42,52 +39,7 @@ const MAX_SEGMENTS = 3; * write the player never asked for. */ export function parseExportEntries(value: unknown): TemplateExportEntry[] { - if (!Array.isArray(value) || value.length > TEMPLATE_CEILINGS.entries) { - throw new ValidationError("invalid template export"); - } - return value.map((entry) => { - if ( - typeof entry !== "object" - || entry === null - || Object.keys(entry).length !== 2 - ) { - throw new ValidationError("invalid template export entry"); - } - const { path: relative, contents } = entry as Record; - if ( - typeof relative !== "string" - || typeof contents !== "string" - || contents.length === 0 - || contents.length > TEMPLATE_CEILINGS.codeLength - ) { - throw new ValidationError("invalid template export entry"); - } - assertRelativePath(relative); - return { path: relative, contents }; - }); -} - -function assertRelativePath(relative: string): void { - const segments = relative.split("/"); - if (segments.length < MIN_SEGMENTS || segments.length > MAX_SEGMENTS) { - throw new ValidationError("invalid template export path"); - } - if (!relative.toLowerCase().endsWith(".txt")) { - throw new ValidationError("invalid template export path"); - } - for (const segment of segments) { - if ( - segment.length === 0 - || segment.length > TEMPLATE_CEILINGS.nameLength + ".txt".length - || segment === "." - || segment === ".." - || segment.includes("\\") - || segment.includes(":") - || /\p{Cc}/u.test(segment) - ) { - throw new ValidationError("invalid template export path"); - } - } + return parseTemplateEntries(value); } /** diff --git a/src/main/window.ts b/src/main/window.ts index 687f88a..188c5da 100644 --- a/src/main/window.ts +++ b/src/main/window.ts @@ -39,7 +39,7 @@ import { logEvent } from "./diagnostics.js"; import { isCanonicalRendererUrl } from "./core/renderer-trust.js"; import { isQuitting } from "./lifecycle.js"; import { gamePaths, preloadPath } from "./paths.js"; -import { toggleTools } from "./renderer-commands.js"; +import { sendRendererCommand, toggleTools } from "./renderer-commands.js"; import { installApplicationMenu } from "./window-menu.js"; import { windowRegistry, type WindowContext } from "./window-registry.js"; @@ -63,6 +63,7 @@ export interface WindowHost { stopCapture: () => Promise; reloadGame: (win: BrowserWindow) => void; prepareRendererRecovery: () => Promise; + gameWindowClosed?: () => void; } let mainWindow: BrowserWindow | null = null; @@ -292,6 +293,7 @@ export function createMainWindow( } = {}, ): BrowserWindow { const context = options.context ?? { mode: "single", role: "game" }; + let profileCloseStarted = false; const statePath = options.windowStatePath ?? gamePaths().windowState; const restoredWindowState = preparedWindowStates.get(statePath) ?? null; const initialState = restoredWindowState @@ -499,7 +501,17 @@ export function createMainWindow( win.on("close", (event) => { if (isQuitting()) return; - if (context.mode === "multi") return; + if (context.mode === "multi") { + if (profileCloseStarted) return; + event.preventDefault(); + profileCloseStarted = true; + void (async () => { + await sendRendererCommand(win, { type: "filesystem.sync" }); + await flushWindowState(win); + if (!win.isDestroyed()) win.destroy(); + })(); + return; + } event.preventDefault(); logEvent({ k: "window.closeRequested" }); app.quit(); @@ -509,6 +521,7 @@ export function createMainWindow( windowRegistry.unregister(win); windowStateOwners.delete(win); if (mainWindow === win) mainWindow = null; + if (context.mode === "multi") host.gameWindowClosed?.(); }); installApplicationMenu({ diff --git a/src/preload/preload.body.cjs b/src/preload/preload.body.cjs index 617bcce..cc99446 100644 --- a/src/preload/preload.body.cjs +++ b/src/preload/preload.body.cjs @@ -242,6 +242,10 @@ const api = { create: (value) => ipcRenderer.invoke(IPC.accountsCreate, value), update: (value) => ipcRenderer.invoke(IPC.accountsUpdate, value), archive: (profileId) => ipcRenderer.invoke(IPC.accountsArchive, profileId), + restore: (profileId) => ipcRenderer.invoke(IPC.accountsRestore, profileId), + delete: (profileId) => ipcRenderer.invoke(IPC.accountsDelete, profileId), + loadTemplates: () => ipcRenderer.invoke(IPC.accountsTemplatesLoad), + saveTemplates: (entries) => ipcRenderer.invoke(IPC.accountsTemplatesSave, entries), useSingle: () => ipcRenderer.invoke(IPC.accountsUseSingle), }, }; diff --git a/src/renderer/accounts.css b/src/renderer/accounts.css index 6d2c7c2..6a6d22a 100644 --- a/src/renderer/accounts.css +++ b/src/renderer/accounts.css @@ -113,6 +113,11 @@ h1 { .accounts-help { margin: var(--ui-space-3) 0; } .accounts-actions { display: flex; flex-wrap: wrap; gap: var(--ui-space-2); } .accounts-actions + .ui-status-line { min-height: 24px; margin: var(--ui-space-3) 0 0; } +.accounts-archived { margin-top: var(--ui-space-4); border-top: 1px solid var(--ui-line-soft); padding-top: var(--ui-space-3); } +.accounts-archived summary { color: var(--ui-text-muted); cursor: pointer; } +.accounts-archived-list { display: grid; gap: var(--ui-space-2); margin-top: var(--ui-space-3); } +.archived-profile { display: flex; align-items: center; gap: var(--ui-space-2); padding: var(--ui-space-2) 0; } +.archived-profile strong { flex: 1; color: var(--ui-text-bright); } .accounts-foot { display: flex; diff --git a/src/renderer/accounts.html b/src/renderer/accounts.html index c4f6c37..7218594 100644 --- a/src/renderer/accounts.html +++ b/src/renderer/accounts.html @@ -32,6 +32,10 @@

Who are you playing?

+