diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 00000000..76692d6c --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,38 @@ +# Guild Wars Reforged Interface + +The interface has one visual contract: Guild Wars supplies atmosphere and +macOS supplies interaction. + +## Visual system + +- Use the reviewed static Reforged landscape as environmental key art. Do not + load the launcher video in utility windows. +- Use the existing Reforged logo as the only ornamental brand element. +- Use the macOS system font for application controls, labels, forms, and status. +- Use one ember tint (`#b84618`) for selection and primary action. Reserve red + for destructive actions and green for an already-open account. +- Prefer native hierarchy: system-sized controls, semantic checkboxes, sheets, + menus, concise labels, and visible keyboard focus. +- Use translucent material only where it improves separation from key art. + Reduced-transparency mode replaces it with an opaque warm-dark surface. +- Keep motion short and functional. Reduced-motion mode removes nonessential + transitions and animation. + +## Multiple Accounts Hub + +The Hub uses a 960×700 hidden-inset window with a 640×560 minimum. Its chooser +is content-sized and anchored at bottom-right; compact widths turn it into a +bottom sheet. Account rows scroll within the chooser, so many accounts do not +move the primary action off-screen. + +The launch surface answers one question: which accounts should open? Account +administration uses progressive disclosure: + +1. The chooser shows selection, launch state, Retry, and the primary action. +2. A row's More menu exposes Edit Account and Archive Account. +3. Modal sheets handle creation and editing. +4. Hub Settings handles archived accounts, permanent deletion, and account-mode + switching. + +Player text says **account**. Internal code may retain **profile** for stable +IDs, paths, partitions, and existing domain types. diff --git a/PRODUCT.md b/PRODUCT.md index 4a322ebf..70f7021b 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 a4350062..c0cc639e 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 00000000..6e521d93 --- /dev/null +++ b/docs/multiple-accounts.md @@ -0,0 +1,155 @@ +# 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 | +| Ready, queued, opening, checking, running, and failed status | Main-process runtime store and 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 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. +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 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 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. Private template libraries use the same snapshot +format but never reconcile with another profile. + +## Lifecycle and recovery + +Every cold Multiple Accounts launch opens the Account Picker with no account +selected. One profile ID maps to at most one live game window. The Hub shows +only bounded runtime language: **Ready**, **Waiting**, **Starting**, **Checking +updated client**, **Open**, and **Needs Attention**. It never describes a loaded +renderer as game-ready. + +The app starts selected accounts in a bounded queue and presents every new game +window inactive. It confirms a new client generation with one canary renderer +before it starts the remaining accounts. A canary failure stops the unopened +queue and returns those rows to Ready. An ordinary account failure does not +close or stop another account. + +After complete success, the Hub hides and focuses the first selected account +once. Selecting an already-open account shows its existing window. If any +account fails, the Hub stays visible for recovery and successful accounts stay +open. Brand-new windows cascade by 32 pixels where display space permits; +saved window positions take precedence after the first launch. + +A renderer gets one automatic recovery per deliberate launch. Recovery keeps +the same profile ownership and does not affect other accounts. A second crash +becomes a persistent Needs Attention row with Retry. The Hub remains +recoverable from Dock activation, and Settings is available with Command-,. + +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. + +If the selected mode or profile registry is missing, corrupt, or from an +unsupported future format, startup does not guess at its contents. It offers to +preserve the unreadable document and restart in Single Account mode. This +recovery quarantines the unreadable document and changes the launcher-mode +document only; it does not open, copy, or clear either mode's player data or +Keychain items. + +Archive is the normal account-removal action. It preserves the profile session, +private libraries, and Keychain items. Permanent deletion is a separate, +confirmed action in Hub Settings. It never removes a shared library or Single +Account data. + +## Hub interaction + +The Hub is a focused chooser, not an account dashboard. Rows use native +checkbox semantics and the entire non-action area is clickable. The primary +action reflects the selection: Open, Open _Account_, Show _Account_, Retry +_Account_, or Open _n_ Accounts. Edit and Archive live in each row's More menu. + +New and Edit Account are modal sheets. Player-facing sharing choices are +**Builds and teams** and **In-game templates**, each either **Shared between +Multiple Accounts** or **Separate for this account**. Shared is the default. +The sheet states that login, game settings, screenshots, chat logs, and Single +Account data are never shared. + +Mode switching, archived-account restoration, and permanent deletion live in +Settings rather than the launch surface. Game Settings → Accounts carries the +same mode explanation and Return to Single Account action. + +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 2255e9c8..37c64efb 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 f08e1729..b3e33694 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -25,6 +25,39 @@ 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 → Accounts**, name the first account, choose its Shared or +Private libraries, and select **Enable and Restart…** to create a separate +Multiple Accounts workspace. Every later Multiple Accounts start opens the +Account Picker with nothing preselected. Select one or more accounts and choose +**Open**. If an account is already running, the action changes to **Show**. + +Each account 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 Command-, in the Account Picker to open Settings, then choose **Return to +Single Account…** to change the next launch. Your accounts and saved logins stay +available if you restore Multiple Accounts from **Settings → Accounts** later. The modes +share verified game downloads, so creating an account does not download another +complete copy of Guild Wars. + +Use **New Account…** in the Account Picker to add accounts. The row's More menu +contains **Edit Account…** and **Archive Account**; close its game window before +changing sharing. Archive keeps all account data and saved login. Hub Settings +can restore it or permanently delete it after a native confirmation. + +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. diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 16b27fa0..a5f12118 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -33,6 +33,11 @@ auditConfig: ignoreGhsas: - GHSA-w3rx-r6r6-pgpr - GHSA-5p2g-fcmc-qvqq + # Electron Forge 7 reaches extract-zip only through Electron Packager's + # extraction of the trusted Electron distribution during packaging. The + # application never passes user-controlled archives to this dependency, + # and no patched extract-zip release currently exists. + - GHSA-jmr9-qjv8-65gv # The affected esbuild serve-directory path exists only on Windows. Owned # automation runs on macOS/Linux, and fontless invokes only transform. - GHSA-g7r4-m6w7-qqqr diff --git a/scripts/copy-renderer.mjs b/scripts/copy-renderer.mjs index 6fdbb4f9..4ecb6de2 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", @@ -33,6 +35,9 @@ const ASSETS = [ const SHARED_ASSETS = [ ["src/shared/ui/tokens.css", "ui/tokens.css"], ["src/shared/ui/components.css", "ui/components.css"], + // The website and Hub use one reviewed static painting. Keep it canonical in + // the website package and copy it into the renderer at build time. + ["apps/website/public/bg-reforged.jpg", "images/bg-reforged.jpg"], ]; const dest = path.resolve("build/renderer"); diff --git a/src/main/accounts-window.ts b/src/main/accounts-window.ts new file mode 100644 index 00000000..fab13afc --- /dev/null +++ b/src/main/accounts-window.ts @@ -0,0 +1,128 @@ +/** + * 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 { app, BrowserWindow, Menu, session } from "electron"; +import type { ProtocolDeps } from "./protocol.js"; +import { installGwProtocolHandlerForSession } from "./protocol.js"; +import { preloadPath } from "./paths.js"; +import { sendRendererCommand } from "./renderer-commands.js"; +import { windowRegistry } from "./window-registry.js"; + +const HUB_URL = "gw://app/accounts.html"; +let hubWindow: BrowserWindow | null = null; +let protocolInstalled = false; + +function installAccountsMenu(): void { + Menu.setApplicationMenu(Menu.buildFromTemplate([ + ...(process.platform === "darwin" + ? [{ + label: app.name, + submenu: [ + { role: "about" as const }, + { type: "separator" as const }, + { + id: "accounts-settings-menu", + label: "Settings…", + accelerator: "CommandOrControl+,", + click: () => { + void sendRendererCommand(getAccountsWindow(), { + type: "accounts.settings.open", + }); + }, + }, + { type: "separator" as const }, + { role: "hide" as const }, + { role: "hideOthers" as const }, + { role: "unhide" as const }, + { type: "separator" as const }, + { role: "quit" as const }, + ], + }] + : []), + { + label: "Edit", + submenu: [ + { role: "cut" as const }, + { role: "copy" as const }, + { role: "paste" as const }, + { role: "selectAll" as const }, + ], + }, + ])); +} + +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: 960, + height: 700, + minWidth: 640, + minHeight: 560, + title: "Guild Wars Reforged — Accounts", + titleBarStyle: "hiddenInset", + backgroundColor: "#0a0806", + 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("focus", installAccountsMenu); + installAccountsMenu(); + 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/account-template-library.ts b/src/main/core/account-template-library.ts new file mode 100644 index 00000000..c81c76dc --- /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/credentials.ts b/src/main/core/credentials.ts index 1586882c..6edc35f7 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/multiple-accounts.ts b/src/main/core/multiple-accounts.ts new file mode 100644 index 00000000..e535b3d2 --- /dev/null +++ b/src/main/core/multiple-accounts.ts @@ -0,0 +1,187 @@ +/** + * 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, rename } from "node:fs/promises"; +import { + parseLauncherMode, + parseMultiWorkspace, + parseProfileName, + type AccountMode, + type LibraryScope, + type MultiWorkspace, + type MultiProfile, + 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; +} + +/** Preserve an unreadable account document before a player-approved recovery. */ +export async function quarantineAccountDocument( + filePath: string, +): Promise { + const backupPath = `${filePath}.corrupt-${Date.now()}`; + try { + await rename(filePath, backupPath); + return backupPath; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} + +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, + }], + }); +} + +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, + ), + }); +} + +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/native-keychain.ts b/src/main/core/native-keychain.ts index 3cafb884..eb9a9d48 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/paths.ts b/src/main/core/paths.ts index 356bbb17..40c69d1a 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,12 @@ export interface GamePaths { settings: string; buildLibrary: string; windowState: string; + launcherMode: string; + multiRoot: string; + multiWorkspace: string; + multiSharedBuildLibrary: string; + multiSharedTemplates: string; + multiProfiles: string; diagnostics: string; game: string; artifacts: string; @@ -39,11 +46,18 @@ 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"), + 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 +78,31 @@ export function gamePaths(userData: string): GamePaths { }; } +export interface MultiProfilePaths { + readonly root: string; + readonly buildLibrary: string; + readonly templates: string; + readonly templateSync: string; + readonly windowState: string; + readonly gameStorageClearRequest: 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"), + gameStorageClearRequest: path.join(root, "clear-game-storage-on-start"), + }; +} + /** * Stable document roots whose direct atomic-write temporaries need the generic * boot-time sweep. diff --git a/src/main/core/profile-runtime.ts b/src/main/core/profile-runtime.ts new file mode 100644 index 00000000..ecd553c0 --- /dev/null +++ b/src/main/core/profile-runtime.ts @@ -0,0 +1,60 @@ +/** + * Process-local launch status for Multiple Accounts. This is the one owner of + * the transient state projected into the Hub; nothing here is persisted. + */ +import type { + AccountLaunchIssue, + MultiProfileRuntimeState, +} from "../../shared/contracts.js"; +import type { ProfileId } from "../../shared/multiple-accounts.js"; + +export interface ProfileRuntime { + readonly state: MultiProfileRuntimeState; + readonly launchIssue?: AccountLaunchIssue; +} + +/** The process-local source of truth for every account launch row. */ +export class ProfileRuntimeStore { + readonly #profiles = new Map(); + + get(profileId: ProfileId): ProfileRuntime { + return this.#profiles.get(profileId) ?? { state: "ready" }; + } + + set( + profileId: ProfileId, + state: MultiProfileRuntimeState, + launchIssue?: AccountLaunchIssue, + ): void { + this.#profiles.set(profileId, launchIssue ? { state, launchIssue } : { state }); + } + + queue(profileIds: readonly ProfileId[], isOpen: (id: ProfileId) => boolean): void { + for (const profileId of profileIds) { + if (!isOpen(profileId)) this.set(profileId, "queued"); + } + } + + releaseQueued(profileIds: readonly ProfileId[]): void { + for (const profileId of profileIds) { + if (this.get(profileId).state === "queued") this.set(profileId, "ready"); + } + } +} + +export type ProfileLaunchStage = + | "preparing" + | "starting" + | "validating" + | "crashed" + | "unknown"; + +export function launchIssueForStage(stage: ProfileLaunchStage): AccountLaunchIssue { + switch (stage) { + case "preparing": return "profile-preparation"; + case "starting": return "window-startup"; + case "validating": return "client-validation"; + case "crashed": return "renderer-crash"; + case "unknown": return "unknown"; + } +} diff --git a/src/main/core/renderer-trust.ts b/src/main/core/renderer-trust.ts index 66d09344..f1b741d6 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/core/steam-session.ts b/src/main/core/steam-session.ts index ee85aee4..9320865b 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/main/core/window-state.ts b/src/main/core/window-state.ts index ce3f4628..ddb9cef3 100644 --- a/src/main/core/window-state.ts +++ b/src/main/core/window-state.ts @@ -197,3 +197,28 @@ export function defaultWindowState(primaryWorkArea: WindowBounds): WindowState { mode: "normal", }; } + +/** + * Offset a brand-new account window without ever placing its title bar outside + * the active work area. Persisted bounds bypass this helper entirely. + */ +export function cascadeWindowState( + state: WindowState, + ordinal: number, + workArea: WindowBounds, + step = 32, +): WindowState { + const offset = Math.max(0, Math.trunc(ordinal)) * step; + return fitWindowStateToDisplays( + { + ...state, + bounds: { + ...state.bounds, + x: state.bounds.x + offset, + y: state.bounds.y + offset, + }, + }, + [workArea], + workArea, + ); +} diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 19934963..38253dd3 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -13,11 +13,16 @@ * arguments and either forwards one owner-local capability directly or calls * the workflow owner; it returns codes rather than inventing prose. */ -import { BrowserWindow, clipboard, ipcMain, shell, app } from "electron"; +import { BrowserWindow, clipboard, ipcMain, shell } from "electron"; import { statfs } from "node:fs/promises"; import type { AppSettings, AppSettingsPatch, + AccountsSetupRequest, + AccountProfileRequest, + AccountProfileUpdateRequest, + AccountTemplateLibrary, + AccountsState, AppUpdateState, CacheInfo, ClientHealthToken, @@ -32,7 +37,15 @@ 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"; import type { RendererFrameBatch, RendererMetrics, @@ -77,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, @@ -92,10 +106,13 @@ 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 { getMainWindow } from "./window.js"; +import { windowRegistry, type WindowRegistry } from "./window-registry.js"; import { applySettingsChange, confirmSettingsReset, @@ -105,8 +122,11 @@ import { export interface IpcContext { sockets: SocketManager; - credentialsStore: CredentialsStore; - steamSessionStore: SteamSessionStore; + windows: WindowRegistry; + credentialsStoreFor: (win: BrowserWindow) => CredentialsStore; + steamSessionStoreFor: (win: BrowserWindow) => SteamSessionStore; + buildLibraryPathFor: (win: BrowserWindow) => string; + gameStorageResetMarkerFor: (win: BrowserWindow) => string; getProgress: () => DownloadProgress; getChunkStore: () => ChunkStore | null; getSettings: () => Promise; @@ -126,19 +146,45 @@ export interface IpcContext { parent: BrowserWindow, record: (event: SteamAcquireEvent) => void, ) => Promise; + getAccountsState: () => AccountsState; + setupAccounts: (request: AccountsSetupRequest) => Promise; + openAccounts: (profileIds: readonly ProfileId[]) => Promise; + createAccount: (request: AccountProfileRequest) => Promise; + updateAccount: (request: AccountProfileUpdateRequest) => Promise; + archiveAccount: (profileId: ProfileId) => Promise; + restoreAccount: (profileId: ProfileId) => Promise; + deleteAccount: ( + parent: BrowserWindow, + profileId: ProfileId, + ) => Promise; + useSingleAccountMode: () => Promise; + requestQuit: (win: BrowserWindow) => void; + loadAccountTemplates: (win: BrowserWindow) => Promise; + saveAccountTemplates: ( + win: BrowserWindow, + entries: readonly TemplateExportEntry[], + ) => Promise; } type SteamInvokeChannel = "steamToken" | "steamStore" | "steamClear"; -function assertSender(event: Electron.IpcMainInvokeEvent): BrowserWindow { +function assertSender( + registry: WindowRegistry, + event: Electron.IpcMainInvokeEvent, + role: "game" | "hub" | "any", +): BrowserWindow { const win = BrowserWindow.fromWebContents(event.sender); - if (!win || win !== getMainWindow()) { + const context = registry.contextForWebContents(event.sender.id); + 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; @@ -167,6 +213,7 @@ type Run = (win: BrowserWindow, input: In) => Out | Promise; interface ChannelDef { readonly parse: Parser; readonly run: Run; + readonly role: "game" | "hub" | "any"; } /** @@ -179,14 +226,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. */ @@ -366,6 +415,70 @@ 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, + templateEntries: parseExportEntries(input.templateEntries), + importBuilds: input.importBuilds, + }; +}); + +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 > 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) { + throw new ValidationError("account profile selection contains duplicates"); + } + return ids; +}); + interface ParsedMilestone { name: RendererMilestone; rendererTimestampUs: number; @@ -579,8 +692,29 @@ export function registerIpcHandlers(ctx: IpcContext): { drainSecrets(): Promise; } { const paths = gamePaths(); - const credentials = ctx.credentialsStore; 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")); @@ -644,16 +778,32 @@ export function registerIpcHandlers(ctx: IpcContext): { await ctx.sockets.close(socketId, win.webContents.id); }), - buildLibraryGet: channel(nothing, async () => { - let recovered = false; - const library = await loadBuildLibrary(paths.buildLibrary, () => { - recovered = true; + buildLibraryGet: channel(nothing, async (win) => { + 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(paths.buildLibrary, library); + buildLibrarySet: channel(one(parseBuildLibrary), async (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 () => { @@ -679,9 +829,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 +842,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 +852,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; @@ -729,7 +879,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) => { @@ -775,9 +925,7 @@ export function registerIpcHandlers(ctx: IpcContext): { if (kind === "gameData") shell.showItemInFolder(paths.game); }), - appRequestQuit: channel(nothing, () => { - app.quit(); - }), + appRequestQuit: channel(nothing, (win) => ctx.requestQuit(win)), clipboardWriteText: channel(asClipboardText, (_win, text) => { clipboard.writeText(text); @@ -814,12 +962,65 @@ 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", + ), + 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", + ), + accountsRestore: channel( + asProfileId, + (_win, profileId) => ctx.restoreAccount(profileId), + "hub", + ), + accountsDelete: channel( + asProfileId, + (win, profileId) => ctx.deleteAccount(win, 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(handlers); + registerChannelDefinitions(ctx.windows, handlers); const steamSettled = registerSteamIpcHandlers( ctx.acquireSteamToken, - ctx.steamSessionStore, + ctx.steamSessionStoreFor, + ctx.windows, ); return { async drainSecrets() { @@ -832,6 +1033,7 @@ export function registerIpcHandlers(ctx: IpcContext): { } function registerChannelDefinitions( + windows: WindowRegistry, handlers: Partial>, ): void { // One registration, uniform and total: `assertSender` first, then the @@ -849,7 +1051,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, def.role); let input: unknown; try { input = def.parse(args); @@ -864,9 +1066,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 +1108,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 +1136,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 +1156,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 349dbe28..fb0edb8c 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -15,8 +15,9 @@ import { powerMonitor, session, } from "electron"; +import type { BrowserWindow } from "electron"; import { readFileSync } from "node:fs"; -import { mkdir, rm } from "node:fs/promises"; +import { mkdir, readFile, rm } from "node:fs/promises"; import path from "node:path"; import { EXTERNAL_URLS, @@ -24,6 +25,10 @@ import { IPC, type AppSettings, type AppSettingsPatch, + type AccountsSetupRequest, + type AccountsState, + type AccountProfileRequest, + type AccountProfileUpdateRequest, type DownloadProgress, type PrefetchProgress, type UpdateTrack, @@ -38,6 +43,8 @@ import { INITIAL_PROGRESS } from "../shared/progress.js"; import { AUTOMATION_COMMAND } from "../shared/automation.js"; import { ClientRuntime } from "./client-runtime.js"; import { Mutex } from "./core/mutex.js"; +import { saveBuildLibrary } from "./core/build-library.js"; +import { parseBuildLibrary } from "../shared/builds/parse-library.js"; import { loadSettings, saveSettings } from "./core/settings.js"; import { SocketManager } from "./core/sockets.js"; import { @@ -69,19 +76,27 @@ 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, + closeProfileWindow, flushWindowState, getMainWindow, prepareWindowState, + resetRendererRecovery, RENDERER_URL, + setOwnedWindowTitle, type WindowHost, updateLongRunningTaskFeedback, } from "./window.js"; @@ -106,7 +121,42 @@ import { import { applyPendingCacheClear, applyPendingGameStorageReset, + applyPendingSessionStorageReset, } from "./settings-actions.js"; +import { windowRegistry } from "./window-registry.js"; +import { + createMultiWorkspace, + addMultiProfile, + archiveMultiProfile, + loadAccountMode, + loadMultiWorkspace, + saveAccountMode, + saveMultiWorkspace, + quarantineAccountDocument, + removeArchivedMultiProfile, + restoreMultiProfile, + updateMultiProfile, +} from "./core/multiple-accounts.js"; +import { + type AccountMode, + type MultiWorkspace, + type ProfileId, +} from "../shared/multiple-accounts.js"; +import { multiSecretSlot } from "./core/native-keychain.js"; +import { + launchIssueForStage, + ProfileRuntimeStore, +} from "./core/profile-runtime.js"; +import { + loadAccountTemplateLibrary, + reconcileAccountTemplates, + saveAccountTemplateLibrary, +} from "./core/account-template-library.js"; +import { + createAccountsWindow, + getAccountsWindow, + 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, @@ -147,13 +197,17 @@ 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; +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; @@ -224,7 +278,11 @@ function buildSocketManager(): SocketManager { } function setProgress(next: DownloadProgress): void { - updateLongRunningTaskFeedback(next); + const gameWindows = windowRegistry.gameWindows(); + if (gameWindows.length === 0) updateLongRunningTaskFeedback(next, null); + else { + for (const win of gameWindows) updateLongRunningTaskFeedback(next, win); + } sendToRenderer(IPC.progressEvent, next); } @@ -233,12 +291,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. + } } } @@ -261,7 +320,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 }); @@ -270,7 +329,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 }); } @@ -303,6 +364,21 @@ async function clearBrowserNetworkCache(): Promise { } } +async function importBuildLibraryIfPresent( + source: string, + destination: string, +): Promise { + let bytes: string; + try { + bytes = await readFile(source, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + const library = parseBuildLibrary(JSON.parse(bytes) as unknown); + await saveBuildLibrary(destination, library); +} + function buildWindowHost( clientRuntime: ClientRuntime, sockets: SocketManager, @@ -316,20 +392,23 @@ function buildWindowHost( getProgress: () => clientRuntime.progress, getSettings: () => loadSettings(gamePaths().settings), updateSettings: updateAppSettings, - exportDiagnostics: async () => { - const win = getMainWindow(); - return win ? exportDiagnosticsForWindow(win) : ""; - }, + exportDiagnostics: (win) => exportDiagnosticsForWindow(win), markPerformanceProblem, 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(); + }, }; } @@ -350,9 +429,81 @@ if (primaryInstance) void app.whenReady().then(async () => { website: EXTERNAL_URLS.github, }); const paths = gamePaths(); + try { + activeAccountMode = await loadAccountMode(paths.launcherMode); + } catch { + const { response } = await dialog.showMessageBox({ + type: "warning", + buttons: ["Open Single Account Mode", "Quit"], + defaultId: 0, + cancelId: 1, + message: "Account mode settings are damaged", + detail: + "GWonMac can preserve the damaged file and restart in Single Account mode. Your saved login, templates, builds, and downloaded game data will not be changed.", + }); + if (response !== 0) { + app.quit(); + return; + } + await quarantineAccountDocument(paths.launcherMode); + await saveAccountMode(paths.launcherMode, "single"); + app.relaunch(); + app.quit(); + return; + } + // 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: MultiWorkspace | null; + try { + multiWorkspace = await loadMultiWorkspace(paths.multiWorkspace); + } catch { + if (activeAccountMode === "multi") { + const { response } = await dialog.showMessageBox({ + type: "warning", + buttons: ["Open Single Account Mode", "Quit"], + defaultId: 0, + cancelId: 1, + message: "Multiple Accounts profiles are damaged", + detail: + "GWonMac can preserve the damaged workspace and restart in Single Account mode. Single Account data and profile Keychain items will not be changed.", + }); + if (response !== 0) { + app.quit(); + return; + } + await quarantineAccountDocument(paths.multiWorkspace); + await saveAccountMode(paths.launcherMode, "single"); + app.relaunch(); + app.quit(); + return; + } + await quarantineAccountDocument(paths.multiWorkspace); + multiWorkspace = null; + } + if (activeAccountMode === "multi" && !multiWorkspace) { + const { response } = await dialog.showMessageBox({ + type: "warning", + buttons: ["Open Single Account Mode", "Quit"], + defaultId: 0, + cancelId: 1, + message: "Multiple Accounts profiles are missing", + detail: + "Restart in Single Account mode without changing its saved login, templates, builds, or Guild Wars files.", + }); + if (response !== 0) { + app.quit(); + return; + } + await saveAccountMode(paths.launcherMode, "single"); + app.relaunch(); + app.quit(); + return; + } 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); @@ -365,7 +516,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, @@ -374,8 +529,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" }); @@ -393,7 +550,7 @@ if (primaryInstance) void app.whenReady().then(async () => { enhancementSelection, enhancementProgram, ); - await prepareWindowState(); + if (activeAccountMode === "single") await prepareWindowState(); const keychain: NativeKeychain = persistentSecrets ? loadNativeKeychain({ packaged: true, @@ -401,8 +558,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 || @@ -466,15 +651,227 @@ 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 profileRuntime = new ProfileRuntimeStore(); + 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 ?? []).map((profile) => ({ + id: profile.id, + name: profile.name, + templates: profile.templates, + builds: profile.builds, + archived: profile.archived, + state: profileRuntime.get(profile.id).state, + ...(profileRuntime.get(profile.id).launchIssue + ? { launchIssue: profileRuntime.get(profile.id).launchIssue } + : {}), + })), }); - logEvent({ k: "protocol.installed" }); + const openProfile = async ( + profileId: ProfileId, + newWindowOrdinal: number, + ): Promise<{ readonly win: BrowserWindow; readonly opened: boolean }> => { + const profile = profileFor(profileId); + let existing = windowRegistry.profileWindow(profileId); + const previous = profileRuntime.get(profileId); + const profilePaths = multiProfilePaths(paths, profileId); + if (previous?.state === "failed") { + resetRendererRecovery(profilePaths.windowState); + if (existing && !existing.isDestroyed()) existing.destroy(); + existing = null; + } + if (existing) { + if (existing.isMinimized()) existing.restore(); + return { win: existing, opened: false }; + } + if (previous?.state === "opening" || previous?.state === "checking") { + throw new Error("Account is already opening"); + } + profileRuntime.set(profileId, "opening"); + let failureStage: Parameters[0] = "preparing"; + 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 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, newWindowOrdinal); + failureStage = "starting"; + let hubWasVisibleBeforeRecovery = false; + const win = createMainWindow(host, { + context: { mode: "multi", role: "game", profileId }, + session: owner, + title: `Guild Wars Reforged — ${profile.name}`, + windowStatePath: profilePaths.windowState, + showInactive: true, + onRendererRecoveryStart: () => { + hubWasVisibleBeforeRecovery = getAccountsWindow()?.isVisible() ?? false; + }, + onRendererRecovered: () => { + if (!hubWasVisibleBeforeRecovery) getAccountsWindow()?.hide(); + }, + onRendererFailure: () => { + profileRuntime.set(profileId, "failed", launchIssueForStage("crashed")); + revealAccountsWindow(); + }, + }); + win.on("closed", () => { + const replacement = windowRegistry.profileWindow(profileId); + if (replacement && replacement !== win) return; + if (profileRuntime.get(profileId).state !== "failed") { + profileRuntime.set(profileId, "ready"); + } + }); + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup(); + reject(new Error("profile window did not finish loading")); + }, 30_000); + const cleanup = () => { + clearTimeout(timeout); + win.webContents.removeListener("did-finish-load", loaded); + win.removeListener("closed", closed); + }; + const loaded = () => { + cleanup(); + resolve(); + }; + const closed = () => { + cleanup(); + reject(new Error("profile window closed while loading")); + }; + win.webContents.once("did-finish-load", loaded); + win.once("closed", closed); + }); + profileRuntime.set(profileId, "running"); + return { win, opened: true }; + } catch (error) { + profileRuntime.set(profileId, "failed", launchIssueForStage(failureStage)); + const failedWindow = windowRegistry.profileWindow(profileId); + if (failedWindow && !failedWindow.isDestroyed()) failedWindow.destroy(); + throw error; + } + }; + const waitForCandidateCanary = async (): Promise => { + const candidate = clientRuntime.healthToken; + if (!candidate) return; + const deadline = Date.now() + 60_000; + while (clientRuntime.healthToken === candidate && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + if (clientRuntime.healthToken === candidate) { + throw new Error("first profile did not confirm the new client generation"); + } + }; + + 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) { + const candidate = createMultiWorkspace(request); + const profile = candidate.profiles[0]!; + const profilePaths = multiProfilePaths(paths, profile.id); + await mkdir(profilePaths.root, { recursive: true }); + if (request.importBuilds) { + await importBuildLibraryIfPresent( + paths.buildLibrary, + profile.builds === "shared" + ? paths.multiSharedBuildLibrary + : profilePaths.buildLibrary, + ); + } + if (request.importTemplates) { + const templatePath = profile.templates === "shared" + ? paths.multiSharedTemplates + : profilePaths.templates; + await saveAccountTemplateLibrary(templatePath, { + revision: 1, + entries: request.templateEntries, + }); + } + await saveMultiWorkspace(paths.multiWorkspace, candidate); + multiWorkspace = candidate; + } + 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, - credentialsStore, - steamSessionStore, + windows: windowRegistry, + 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; + }, + 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), @@ -532,33 +929,246 @@ 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) profileFor(profileId); + profileRuntime.queue( + profileIds, + (profileId) => windowRegistry.profileWindow(profileId) !== null, + ); + let canaryChecked = false; + let firstFailure: unknown = null; + let firstSelectedWindow: BrowserWindow | null = null; + for (let index = 0; index < profileIds.length; index += 1) { + const profileId = profileIds[index]!; + let result: { readonly win: BrowserWindow; readonly opened: boolean }; + try { + result = await accountsLock.run(() => openProfile(profileId, index)); + firstSelectedWindow ??= result.win; + } catch (error) { + firstFailure ??= error; + continue; + } + if (result.opened && !canaryChecked) { + if (clientRuntime.healthToken) { + profileRuntime.set(profileId, "checking"); + try { + await waitForCandidateCanary(); + } catch (error) { + profileRuntime.set( + profileId, + "failed", + launchIssueForStage("validating"), + ); + profileRuntime.releaseQueued(profileIds.slice(index + 1)); + revealAccountsWindow(); + throw error; + } + } + profileRuntime.set(profileId, "running"); + canaryChecked = true; + } + } + if (firstFailure) { + revealAccountsWindow(); + throw firstFailure; + } + const hub = getAccountsWindow(); + if (hub && !hub.isDestroyed()) hub.hide(); + if (firstSelectedWindow && !firstSelectedWindow.isDestroyed()) { + if (firstSelectedWindow.isMinimized()) firstSelectedWindow.restore(); + const focused = new Promise((resolve) => { + if (firstSelectedWindow.isFocused()) { + resolve(); + return; + } + const timeout = setTimeout(resolve, 1_000); + firstSelectedWindow.once("focus", () => { + clearTimeout(timeout); + resolve(); + }); + }); + firstSelectedWindow.show(); + app.focus({ steal: true }); + firstSelectedWindow.focus(); + await focused; + } + }, + 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 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; + const profileWindow = windowRegistry.profileWindow(request.id); + if (profileWindow) { + setOwnedWindowTitle( + profileWindow, + `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(); + }), + 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: (parent, profileId: ProfileId) => accountsLock.run(async () => { + if (activeAccountMode !== "multi" || !multiWorkspace) { + throw new Error("Multiple Accounts mode is not active"); + } + const profile = multiWorkspace.profiles.find( + (candidate) => candidate.id === profileId && candidate.archived, + ); + if (!profile) throw new Error("Only an archived profile can be deleted"); + const { response } = await dialog.showMessageBox(parent, { + type: "warning", + buttons: ["Permanently Delete", "Cancel"], + defaultId: 1, + cancelId: 1, + message: `Permanently delete “${profile.name}”?`, + detail: + "Its saved login, Guild Wars files, private templates, builds, and window state cannot be recovered. Shared libraries and Single Account data stay untouched.", + }); + if (response !== 0) return accountsState(); + 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); + }), + requestQuit: (win) => { + const context = windowRegistry.contextForWebContents(win.webContents.id); + if (context?.mode === "multi") void closeProfileWindow(win); + else app.quit(); + }, + 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 }); + if (activeAccountMode === "single") { + const win = getMainWindow(); + if (win && !win.isDestroyed()) { + const outcome = await sendRendererCommand(win, { + type: "filesystem.sync", + }); + if (outcome !== "completed") { + logEvent({ k: "quit.rendererSyncIncomplete", outcome }); + } } + await ipcCleanup.drainSecrets(); + await flushWindowState(); + } else { + const gameWindows = windowRegistry.gameWindows(); + await Promise.all(gameWindows.map(async (win) => { + if (win.isDestroyed()) return; + const outcome = await sendRendererCommand(win, { + type: "filesystem.sync", + }); + if (outcome !== "completed") { + logEvent({ k: "quit.rendererSyncIncomplete", outcome }); + } + })); + await ipcCleanup.drainSecrets(); + await Promise.all(gameWindows.map((win) => flushWindowState(win))); } - await ipcCleanup.drainSecrets(); - await flushWindowState(); 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); } @@ -599,7 +1209,9 @@ if (primaryInstance) void app.whenReady().then(async () => { }); } else { setDiagnosticCaptureStoppedHandler(async () => { - const win = getMainWindow(); + const gameWindows = windowRegistry.gameWindows(); + const win = gameWindows.find((candidate) => candidate.isFocused()) + ?? gameWindows[0]; if (!win || win.isDestroyed()) return; await resetGameInput(win); const { response } = await dialog.showMessageBox(win, { @@ -624,13 +1236,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 7d8e6878..5f63c103 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/main/settings-actions.ts b/src/main/settings-actions.ts index abb18d52..b63bca6d 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 24ada9aa..7ce30af0 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-menu.ts b/src/main/window-menu.ts index d7b6aa7a..8773af3a 100644 --- a/src/main/window-menu.ts +++ b/src/main/window-menu.ts @@ -195,7 +195,7 @@ export function installApplicationMenu({ label: "Export Recent Diagnostics…", click: async () => { await resetGameInput(win); - await exportDiagnosticsReport(host.exportDiagnostics); + await exportDiagnosticsReport(() => host.exportDiagnostics(win)); }, }, { type: "separator" }, diff --git a/src/main/window-registry.ts b/src/main/window-registry.ts new file mode 100644 index 00000000..eb75c54d --- /dev/null +++ b/src/main/window-registry.ts @@ -0,0 +1,88 @@ +/** + * 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(); + readonly #webContentsIds = new WeakMap(); + + 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.#webContentsIds.set(win, id); + this.#byWebContents.set(id, { win, context }); + } + + unregister(win: RegisteredWindow): void { + const id = this.#webContentsIds.get(win); + if (id === undefined) return; + const entry = this.#byWebContents.get(id); + if (!entry || entry.win !== win) return; + this.#byWebContents.delete(id); + this.#webContentsIds.delete(win); + 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"); + } +} + +/** 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 e47be171..beb729aa 100644 --- a/src/main/window.ts +++ b/src/main/window.ts @@ -29,6 +29,7 @@ import { longRunningTaskFeedback } from "../shared/progress.js"; import type { SocketManager } from "./core/sockets.js"; import { defaultWindowState, + cascadeWindowState, fitWindowStateToDisplays, loadWindowState, saveWindowState, @@ -39,8 +40,9 @@ 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"; // Tests launch the app dozens of times; without this they steal keyboard focus // on every launch. Focus-dependent specs leave the flag unset. @@ -56,22 +58,36 @@ export interface WindowHost { getProgress: () => DownloadProgress; getSettings: () => Promise; updateSettings: (value: AppSettingsPatch) => Promise; - exportDiagnostics: () => Promise; + exportDiagnostics: (win: BrowserWindow) => Promise; markPerformanceProblem: () => void; startCapture: (level: 1 | 2) => Promise; stopCapture: () => Promise; reloadGame: (win: BrowserWindow) => void; prepareRendererRecovery: () => Promise; + gameWindowClosed?: () => void; } 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(); + +/** A deliberate player retry gets one fresh automatic renderer recovery. */ +export function resetRendererRecovery(statePath: string): void { + rendererRecoveryUsed.delete(statePath); +} +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(); +const ownedWindowTitles = new WeakMap(); +const profileCloses = new WeakSet(); +const PROFILE_CLOSE_DEADLINE_MS = 6_000; let downloadPowerBlockerId: number | null = null; export function updateLongRunningTaskFeedback( @@ -104,35 +120,48 @@ 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, + newWindowOrdinal?: number, +): 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) { + const prepared = restored ?? ( + newWindowOrdinal === undefined + ? null + : cascadeWindowState( + defaultWindowState(primaryWorkArea()), + newWindowOrdinal, + primaryWorkArea(), + ) + ); + preparedWindowStates.set(statePath, prepared); + 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, }); } + return restored !== null; } -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(), @@ -143,49 +172,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; @@ -221,22 +257,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; } @@ -244,6 +280,33 @@ export function getMainWindow(): BrowserWindow | null { return mainWindow; } +export function setOwnedWindowTitle(win: BrowserWindow, title: string): void { + ownedWindowTitles.set(win, title); + win.setTitle(title); +} + +/** Flush and destroy exactly one Multi game window without quitting the app. */ +export async function closeProfileWindow(win: BrowserWindow): Promise { + if (win.isDestroyed() || profileCloses.has(win)) return; + profileCloses.add(win); + try { + await Promise.race([ + (async () => { + await sendRendererCommand(win, { type: "filesystem.sync" }); + await flushWindowState(win); + })(), + new Promise((resolve) => { + setTimeout(resolve, PROFILE_CLOSE_DEADLINE_MS); + }), + ]); + } catch (error) { + logEvent({ k: "window.stateSaveFailed" }); + console.error("profile close persistence failed", error); + } finally { + if (!win.isDestroyed()) win.destroy(); + } +} + /** The only renderer URL, and it carries no configuration. */ export const RENDERER_URL = "gw://app/"; @@ -267,7 +330,22 @@ 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; + readonly windowStatePath?: string; + readonly showInactive?: boolean; + readonly onRendererRecoveryStart?: () => void; + readonly onRendererRecovered?: () => void; + readonly onRendererFailure?: () => void; + } = {}, +): 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, @@ -280,7 +358,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,33 +371,52 @@ export function createMainWindow(host: WindowHost): BrowserWindow { spellcheck: false, allowRunningInsecureContent: false, experimentalFeatures: false, + ...(options.session ? { session: options.session } : {}), }, }); 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); + if (options.title) { + ownedWindowTitles.set(win, options.title); + win.webContents.on("page-title-updated", (event) => { + event.preventDefault(); + win.setTitle(ownedWindowTitles.get(win) ?? "Guild Wars Reforged"); + }); + } updateLongRunningTaskFeedback(host.getProgress(), win); const rendererId = win.webContents.id; win.once("ready-to-show", () => { if (initialState?.mode === "maximized") win.maximize(); - if (BACKGROUND_LAUNCH) win.showInactive(); + if (BACKGROUND_LAUNCH || options.showInactive) win.showInactive(); else win.show(); if (initialState?.mode === "fullscreen") win.setFullScreen(true); }); 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" }); }); @@ -428,11 +525,12 @@ export function createMainWindow(host: WindowHost): BrowserWindow { host.sockets.closeAll(rendererId); if (isQuitting()) return; if ( - !rendererRecoveryUsed && + !rendererRecoveryUsed.has(statePath) && details.reason !== "clean-exit" && !win.isDestroyed() ) { - rendererRecoveryUsed = true; + rendererRecoveryUsed.add(statePath); + options.onRendererRecoveryStart?.(); logEvent({ k: "renderer.recoveryScheduled" }); setTimeout(() => { if (isQuitting() || win.isDestroyed()) return; @@ -446,12 +544,18 @@ export function createMainWindow(host: WindowHost): BrowserWindow { }) .finally(() => { if (isQuitting() || win.isDestroyed()) return; - createMainWindow(host); + // Release the immutable profile ownership before registering its + // replacement. Destroying afterward keeps the transition local to + // this profile and lets the old closed handler remain idempotent. + windowRegistry.unregister(win); + createMainWindow(host, options); win.destroy(); + options.onRendererRecovered?.(); logEvent({ k: "renderer.recovered" }); }); }, 500); } else if (details.reason !== "clean-exit") { + options.onRendererFailure?.(); dialog.showErrorBox( "Guild Wars stopped unexpectedly", "Use View → Reload Game to try again. If it repeats, choose Help → Report a Bug.", @@ -461,20 +565,34 @@ export function createMainWindow(host: WindowHost): BrowserWindow { win.on("close", (event) => { if (isQuitting()) return; + if (context.mode === "multi") { + event.preventDefault(); + void closeProfileWindow(win); + return; + } event.preventDefault(); logEvent({ k: "window.closeRequested" }); app.quit(); }); win.on("closed", () => { + windowRegistry.unregister(win); + windowStateOwners.delete(win); if (mainWindow === win) mainWindow = null; + if ( + context.mode === "multi" + && context.role === "game" + && !windowRegistry.profileWindow(context.profileId) + ) host.gameWindowClosed?.(); }); - installApplicationMenu({ + const installMenu = () => installApplicationMenu({ host, win, resetWindowState: () => resetWindowState(win), }); + win.on("focus", installMenu); + installMenu(); void win.loadURL(RENDERER_URL); return win; } diff --git a/src/native/keychain/keychain.mm b/src/native/keychain/keychain.mm index eaa3ace4..e4584ccb 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/src/preload/preload.body.cjs b/src/preload/preload.body.cjs index 77300fc6..cc994461 100644 --- a/src/preload/preload.body.cjs +++ b/src/preload/preload.body.cjs @@ -235,6 +235,19 @@ 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), + 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), + }, }; for (const namespace of Object.values(api)) Object.freeze(namespace); Object.freeze(api); diff --git a/src/renderer/accounts.css b/src/renderer/accounts.css new file mode 100644 index 00000000..519cc3ac --- /dev/null +++ b/src/renderer/accounts.css @@ -0,0 +1,132 @@ +:root { + color-scheme: dark; + --ember: #b84618; + --ember-hover: #cc5522; + --focus: #0a84ff; + --danger: #ff6961; + --success: #83d39a; + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif; +} + +* { box-sizing: border-box; } +html, body { width: 100%; height: 100%; } +body { margin: 0; overflow: hidden; background: #0a0806; color: #fff; font-size: 14px; -webkit-font-smoothing: antialiased; user-select: none; } +button, input { font: inherit; } +button { cursor: default; } +button:focus-visible, input:focus-visible { outline: 3px solid color-mix(in srgb, var(--focus) 55%, transparent); outline-offset: 2px; } + +.hub { position: relative; width: 100%; height: 100%; overflow: hidden; -webkit-app-region: drag; } +.scene { position: absolute; inset: 0; background: radial-gradient(ellipse at 34% 40%, transparent 6%, #0706051f 46%, #070605b8 100%), linear-gradient(180deg, #08070612, #08070666), url("images/bg-reforged.jpg") center / cover no-repeat; } +.brand { position: absolute; top: 17%; left: 27%; width: min(43%, 450px); transform: translateX(-50%); filter: drop-shadow(0 5px 20px #000c); } + +.chooser { position: absolute; right: 34px; bottom: 30px; display: flex; flex-direction: column; width: min(430px, 48%); max-height: calc(100% - 96px); padding: 22px; overflow: hidden; border: 1px solid #ffffff26; border-radius: 16px; background: #1c1a18c9; box-shadow: 0 24px 64px #0007, inset 0 1px #ffffff14; backdrop-filter: blur(30px) saturate(145%); -webkit-app-region: no-drag; } +.chooser-header h1 { margin: 0; font-size: 25px; font-weight: 700; line-height: 1.12; letter-spacing: -.026em; } +.chooser-header p { margin: 6px 0 0; color: #ffffffa3; font-size: 13px; } +#accounts-form { display: flex; min-height: 0; flex: 1; flex-direction: column; } +.accounts-list { display: grid; gap: 7px; min-height: 0; margin: 14px -4px 0; padding: 4px; overflow: auto; border: 0; scrollbar-width: thin; scrollbar-color: #ffffff33 transparent; } +.account-row { display: grid; grid-template-columns: 22px minmax(0,1fr) auto auto; align-items: center; gap: 10px; min-height: 58px; padding: 8px 9px 8px 12px; border-radius: 11px; background: #ffffff11; transition: transform 80ms ease-out, background 150ms ease-out; } +.account-row:hover { background: #ffffff1c; } +.account-row:active { transform: scale(.985); } +.account-row:has(input:checked) { background: var(--ember); } +.account-row:has(input:checked):hover { background: var(--ember-hover); } +.account-row:has(input:focus-visible) { outline: 3px solid color-mix(in srgb, var(--focus) 60%, transparent); outline-offset: 1px; } +.account-row > input { width: 18px; height: 18px; margin: 0; accent-color: white; } +.account-copy { min-width: 0; } +.account-copy strong { display: block; overflow: hidden; font-size: 14px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } +.account-copy small { display: block; margin-top: 1px; color: #ffffff8f; font-size: 11px; } +.account-row:has(input:checked) .account-copy small { color: #ffffffc7; } +.open-indicator { display: flex; align-items: center; gap: 5px; color: var(--success); font-size: 11px; } +.open-indicator::before { width: 6px; height: 6px; border-radius: 50%; background: currentColor; content: ""; } +.retry { min-height: 28px; padding: 3px 8px; border: 0; border-radius: 7px; background: #ffffff14; color: #ffd1cd; } +.retry:hover { background: #ffffff24; } +.more { display: grid; width: 30px; height: 30px; padding: 0; place-items: center; border: 0; border-radius: 7px; background: transparent; color: #ffffffa3; font-size: 18px; letter-spacing: 1px; } +.more:hover { background: #ffffff1f; color: white; } + +.loading-row { pointer-events: none; } +.loading-row i { width: 18px; height: 18px; border-radius: 50%; background: #ffffff12; } +.loading-row span b, .loading-row span small { display: block; width: 58%; height: 10px; border-radius: 5px; background: linear-gradient(90deg, #ffffff12, #ffffff2b, #ffffff12); background-size: 220% 100%; animation: shimmer 1.4s ease-in-out infinite; } +.loading-row span small { width: 38%; height: 8px; margin-top: 7px; } +@keyframes shimmer { to { background-position: -220% 0; } } + +.empty-state { min-height: 230px; padding: 28px 12px; text-align: center; } +.empty-state svg { width: 36px; height: 36px; color: #ffffff9e; } +.empty-state h2 { margin: 9px 0 0; font-size: 15px; } +.empty-state p { max-width: 31ch; margin: 5px auto 15px; color: #ffffff94; font-size: 12px; } +.status-banner { margin: 11px 0 0; padding: 9px 11px; border-radius: 9px; background: #ff453a24; color: #ffbab5; font-size: 12px; } +.status-banner:empty { display: none; } +.status-banner[data-tone="progress"] { background: #ffffff12; color: #ffffffad; } +.status-banner[data-tone="success"] { background: #31c55a1f; color: #b6edc5; } +.chooser-actions { display: flex; flex: none; align-items: center; justify-content: space-between; gap: 12px; margin-top: 13px; } +.action-group { display: flex; gap: 8px; } +.quiet, .secondary, .primary { min-height: 34px; padding: 6px 14px; border-radius: 8px; color: white; transition: transform 80ms ease-out, background 150ms ease-out; } +.quiet:active, .secondary:active, .primary:active { transform: scale(.96); } +.quiet { padding-inline: 5px; border: 0; background: transparent; color: #ffffffad; } +.quiet:hover { color: white; } +.secondary { border: 1px solid #ffffff26; background: #ffffff17; } +.secondary:hover { background: #ffffff24; } +.primary { border: 1px solid #ffffff1c; background: var(--ember); font-weight: 600; } +.primary:hover:not(:disabled) { background: var(--ember-hover); } +.primary:disabled { opacity: .42; } +.privacy-note { flex: none; margin: 11px 0 0; color: #ffffff7a; font-size: 11px; } + +.account-menu { position: fixed; z-index: 20; width: 190px; padding: 5px; border: 1px solid #ffffffa6; border-radius: 11px; background: #f2f2f7f2; box-shadow: 0 14px 42px #0007; backdrop-filter: blur(26px) saturate(180%); color: #1c1c1e; -webkit-app-region: no-drag; } +.account-menu button { display: block; width: 100%; min-height: 29px; padding: 4px 9px; border: 0; border-radius: 6px; background: transparent; color: inherit; text-align: left; } +.account-menu button:hover:not(:disabled), .account-menu button:focus-visible { background: var(--focus); color: white; outline: 0; } +.account-menu button:disabled { color: #8e8e93; } +.account-menu hr { height: 1px; margin: 4px 7px; border: 0; background: #3c3c432e; } + +.sheet { width: min(560px, calc(100vw - 32px)); max-height: calc(100vh - 48px); padding: 0; overflow: auto; border: 1px solid #ffffffa6; border-radius: 16px; background: #f6f6f8f7; box-shadow: 0 28px 88px #0007; color: #1b1b1d; } +.sheet::backdrop { background: #0006; backdrop-filter: blur(5px); } +.sheet form { padding: 22px; } +.sheet h2 { margin: 0; font-size: 20px; line-height: 1.15; letter-spacing: -.022em; } +.sheet form > p { margin: 6px 0 17px; color: #6c6c72; font-size: 13px; } +.field { display: block; margin: 14px 0 17px; } +.field > span { display: block; margin: 0 0 6px 2px; color: #515157; font-size: 12px; font-weight: 600; } +.field input { width: 100%; height: 36px; padding: 6px 10px; border: 1px solid #3c3c4347; border-radius: 8px; background: #ffffffc7; color: #171719; } +.sharing-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(210px, 1.25fr); align-items: center; gap: 16px; margin: 0; padding: 12px 2px; border: 0; border-top: 1px solid #3c3c4324; } +.sharing-row legend { min-width: 0; margin: 0; padding: 0; } +.sharing-row strong, .sharing-row small { display: block; } +.sharing-row strong { font-size: 13px; } +.sharing-row small { color: #737379; font-size: 11px; font-weight: 400; } +.segmented { display: grid; grid-template-columns: 1fr; gap: 2px; padding: 2px; border-radius: 8px; background: #7676801f; } +.segmented label { position: relative; } +.segmented input { position: absolute; opacity: 0; } +.segmented span { display: block; min-height: 27px; padding: 5px 8px; border-radius: 6px; color: #49494f; font-size: 11px; text-align: center; } +.segmented input:checked + span { background: white; box-shadow: 0 1px 4px #0003; color: #111; } +.segmented input:focus-visible + span { outline: 3px solid color-mix(in srgb, var(--focus) 50%, transparent); } +.sheet .separation-note { margin: 12px 2px 0; font-size: 11px; } +.sheet-status { min-height: 18px; color: #c52b23 !important; } +.sheet-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; padding-top: 14px; border-top: 1px solid #3c3c4324; } +.sheet-secondary, .sheet-primary { min-height: 32px; padding: 5px 13px; border-radius: 8px; } +.sheet-secondary { border: 1px solid #3c3c4324; background: #76768017; color: #29292d; } +.sheet-primary { border: 0; background: var(--ember); color: white; font-weight: 600; } +.settings-sheet { width: min(620px, calc(100vw - 32px)); } +.settings-section { margin-top: 18px; padding-top: 14px; border-top: 1px solid #3c3c4324; } +.settings-section h3 { margin: 0 0 8px; font-size: 13px; } +.archived-list { display: grid; gap: 6px; } +.archived-account { display: grid; grid-template-columns: minmax(0,1fr) auto auto; align-items: center; gap: 8px; min-height: 38px; padding: 5px 8px; border-radius: 8px; background: #76768012; } +.archived-account strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.archived-account button { min-height: 28px; padding: 3px 8px; border: 0; border-radius: 6px; background: #7676801c; color: #29292d; } +.archived-account .delete { color: #d52d25; } +.settings-empty { margin: 0; color: #737379; font-size: 12px; } +.mode-section { display: flex; align-items: center; justify-content: space-between; gap: 20px; } +.mode-section h3 { margin-bottom: 3px; } +.mode-section p { margin: 0; color: #737379; font-size: 12px; } +.mode-section button { flex: none; } +.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } + +@media (max-width: 720px) { + .brand { top: 10%; left: 50%; width: min(58%, 360px); } + .chooser { right: 16px; bottom: 16px; left: 16px; width: auto; max-height: 72%; } +} +@media (max-width: 560px) { + .chooser { right: 0; bottom: 0; left: 0; max-height: 76%; border-right: 0; border-bottom: 0; border-left: 0; border-radius: 16px 16px 0 0; } + .chooser-actions { align-items: stretch; flex-direction: column-reverse; } + .action-group, .action-group button { width: 100%; } + .action-group .secondary { flex: 1; } + .sharing-row { grid-template-columns: 1fr; gap: 8px; } + .mode-section { align-items: flex-start; flex-direction: column; } +} +@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; } } +@media (prefers-reduced-transparency: reduce) { .chooser, .account-menu, .sheet, .sheet::backdrop { backdrop-filter: none; } .chooser { background: #211e1c; } } +@media (prefers-contrast: more) { .chooser { border-color: #ffffff8c; background: #141211f2; } .account-row { background: #ffffff21; } .privacy-note, .account-copy small { color: #ffffffc4; } } diff --git a/src/renderer/accounts.html b/src/renderer/accounts.html new file mode 100644 index 00000000..1b209e6e --- /dev/null +++ b/src/renderer/accounts.html @@ -0,0 +1,106 @@ + + + + + + Guild Wars Reforged — Accounts + + + + +
+ + + +
+
+

Choose Accounts

+

Select one or more accounts to open.

+
+ +
+
+ Accounts + + + +
+ + + +

+
+ + + + + +
+

Each account keeps its own saved login and game files.

+
+
+
+ + + + +
+

New Account

+

Create an independent Guild Wars account.

+ + + +

Login, game settings, screenshots, chat logs, and Single Account data are never shared.

+

+
+ + +
+
+
+ + +
+

Multiple Accounts Settings

+

Each account has its own login, game settings, screenshots, and chat logs. Shared libraries never connect to Single Account data.

+
+

Archived Accounts

+
+

No archived accounts.

+
+
+

Account Mode

Returning to Single Account restarts GWonMac. Both workspaces are preserved.

+ +
+

+
+
+
+ + + + diff --git a/src/renderer/accounts.ts b/src/renderer/accounts.ts new file mode 100644 index 00000000..ef7229dd --- /dev/null +++ b/src/renderer/accounts.ts @@ -0,0 +1,350 @@ +/** + * The Multiple Accounts Hub interaction layer: it projects main-owned account + * state, manages selection and sheets, and never invents launch status. + */ +import type { AccountProfileSummary } from '../shared/contracts.js'; + +(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 empty = required('accounts-empty'); + const open = required('accounts-open'); + const selectAll = required('accounts-select-all'); + const status = required('accounts-status'); + const menu = required('account-menu'); + const menuEdit = required('account-edit'); + const menuArchive = required('account-archive'); + const profileDialog = required('profile-dialog'); + const profileForm = required('profile-form'); + const profileTitle = required('profile-dialog-title'); + const profileDescription = required('profile-dialog-description'); + const profileName = required('profile-name'); + const profileSave = required('profile-save'); + const profileStatus = required('profile-status'); + const settingsDialog = required('accounts-settings'); + const archivedList = required('accounts-archived-list'); + const noArchived = required('accounts-no-archived'); + const settingsStatus = required('settings-status'); + + let profiles: readonly AccountProfileSummary[] = []; + let selected = new Set(); + let editing: AccountProfileSummary | null = null; + let menuProfile: AccountProfileSummary | null = null; + let refreshInFlight: Promise | null = null; + + const activeProfiles = () => profiles.filter((profile) => !profile.archived); + const selectedProfiles = () => activeProfiles().filter((profile) => selected.has(profile.id)); + const choice = (name: string) => profileForm.elements.namedItem(name) as RadioNodeList; + + function stateLabel(profile: AccountProfileSummary): string { + switch (profile.state) { + case 'ready': return 'Ready'; + case 'queued': return 'Waiting'; + case 'opening': return 'Starting'; + case 'checking': return 'Checking updated client'; + case 'running': return 'Open'; + case 'failed': return 'Needs Attention'; + } + } + + function recoveryText(profile: AccountProfileSummary): string { + switch (profile.launchIssue) { + case 'profile-preparation': return 'Couldn’t prepare this account.'; + case 'window-startup': return 'Its game window couldn’t start.'; + case 'client-validation': return 'The updated client couldn’t be verified.'; + case 'renderer-crash': return 'The game stopped unexpectedly twice.'; + default: return 'This account couldn’t be opened.'; + } + } + + function setStatus(message = '', tone: 'neutral' | 'progress' | 'success' | 'error' = 'neutral') { + status.textContent = message; + status.dataset.tone = tone; + } + + function syncActions() { + const active = activeProfiles(); + const chosen = selectedProfiles(); + open.disabled = chosen.length === 0 || chosen.some((profile) => + profile.state === 'queued' || profile.state === 'opening' || profile.state === 'checking'); + if (chosen.length === 0) open.textContent = 'Open'; + else if (chosen.length > 1) open.textContent = `Open ${chosen.length} Accounts`; + else if (chosen[0]!.state === 'running') open.textContent = `Show ${chosen[0]!.name}`; + else if (chosen[0]!.state === 'failed') open.textContent = `Retry ${chosen[0]!.name}`; + else open.textContent = `Open ${chosen[0]!.name}`; + selectAll.textContent = active.length > 0 && chosen.length === active.length ? 'Clear' : 'Select All'; + selectAll.disabled = active.length === 0; + } + + async function launch(ids: readonly AccountProfileSummary['id'][]) { + if (ids.length === 0) return; + open.disabled = true; + setStatus('Opening selected accounts…', 'progress'); + const poll = window.setInterval(() => { void refresh(); }, 180); + try { + await window.gwNative.accounts.open(ids); + setStatus('Selected accounts are open.', 'success'); + } catch { + setStatus('Some accounts need attention. Open accounts were left running.', 'error'); + } finally { + window.clearInterval(poll); + await refresh(); + } + } + + function closeMenu() { + menu.hidden = true; + menuProfile = null; + } + + function showMenu(profile: AccountProfileSummary, trigger: HTMLElement) { + menuProfile = profile; + const bounds = trigger.getBoundingClientRect(); + menu.style.top = `${Math.min(bounds.bottom + 4, innerHeight - 90)}px`; + menu.style.left = `${Math.max(8, Math.min(bounds.right - 190, innerWidth - 198))}px`; + menuEdit.disabled = profile.state === 'running'; + menuEdit.title = profile.state === 'running' ? 'Close this account before editing it.' : ''; + menuArchive.disabled = profile.state === 'running' || activeProfiles().length < 2; + menuArchive.title = profile.state === 'running' + ? 'Close this account before archiving it.' + : activeProfiles().length < 2 ? 'At least one active account is required.' : ''; + menu.hidden = false; + menuEdit.focus(); + } + + function renderProfile(profile: AccountProfileSummary): HTMLElement { + const row = document.createElement('div'); + row.className = 'account-row'; + row.dataset.profileId = profile.id; + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.name = 'account'; + checkbox.value = profile.id; + checkbox.id = `account-${profile.id}`; + checkbox.checked = selected.has(profile.id); + checkbox.setAttribute('aria-label', `Select ${profile.name}`); + const copy = document.createElement('label'); + copy.className = 'account-copy'; + copy.htmlFor = checkbox.id; + const name = document.createElement('strong'); + name.textContent = profile.name; + name.title = profile.name; + const state = document.createElement('small'); + state.textContent = profile.state === 'failed' + ? `${stateLabel(profile)} — ${recoveryText(profile)}` + : stateLabel(profile); + copy.append(name, state); + const indicator = document.createElement('span'); + if (profile.state === 'running') { + indicator.className = 'open-indicator'; + indicator.textContent = 'Open'; + } else if (profile.state === 'failed') { + const retry = document.createElement('button'); + retry.type = 'button'; + retry.className = 'retry'; + retry.textContent = 'Retry'; + retry.setAttribute('aria-label', `Retry ${profile.name}`); + retry.addEventListener('click', () => { void launch([profile.id]); }); + indicator.append(retry); + } + const trailing = document.createElement('button'); + trailing.type = 'button'; + trailing.className = 'more'; + trailing.textContent = '•••'; + trailing.setAttribute('aria-label', `More options for ${profile.name}`); + trailing.addEventListener('click', () => showMenu(profile, trailing)); + row.append(checkbox, copy, indicator, trailing); + row.addEventListener('click', (event) => { + const target = event.target as Element; + if (target.closest('button, input, label')) return; + checkbox.checked = !checkbox.checked; + checkbox.dispatchEvent(new Event('change', { bubbles: true })); + }); + return row; + } + + function renderArchived() { + const archived = profiles.filter((profile) => profile.archived); + noArchived.hidden = archived.length > 0; + archivedList.replaceChildren(...archived.map((profile) => { + const row = document.createElement('div'); + row.className = 'archived-account'; + const name = document.createElement('strong'); + name.textContent = profile.name; + const restore = document.createElement('button'); + restore.type = 'button'; + restore.textContent = 'Restore'; + restore.addEventListener('click', async () => { + restore.disabled = true; + try { + await window.gwNative.accounts.restore(profile.id); + settingsStatus.textContent = `${profile.name} was restored.`; + await refresh(); + } catch { + restore.disabled = false; + settingsStatus.textContent = 'The account could not be restored.'; + } + }); + const remove = document.createElement('button'); + remove.type = 'button'; + remove.className = 'delete'; + remove.textContent = 'Delete…'; + remove.addEventListener('click', async () => { + remove.disabled = true; + try { + const next = await window.gwNative.accounts.delete(profile.id); + const deleted = !next.profiles.some((candidate) => candidate.id === profile.id); + settingsStatus.textContent = deleted ? `${profile.name} was permanently deleted.` : 'Deletion was cancelled.'; + await refresh(); + } catch { + remove.disabled = false; + settingsStatus.textContent = 'The account could not be deleted.'; + } + }); + row.append(name, restore, remove); + return row; + })); + } + + function render() { + const active = activeProfiles(); + selected = new Set([...selected].filter((id) => active.some((profile) => profile.id === id))); + list.replaceChildren(...active.map(renderProfile)); + list.setAttribute('aria-busy', 'false'); + list.hidden = active.length === 0; + empty.hidden = active.length > 0; + renderArchived(); + syncActions(); + } + + function refresh(): Promise { + if (refreshInFlight) return refreshInFlight; + refreshInFlight = window.gwNative.accounts.get() + .then((state) => { + if (state.mode !== 'multi') throw new Error('Multiple Accounts is not active'); + profiles = state.profiles; + render(); + }) + .catch(() => { + list.replaceChildren(); + list.setAttribute('aria-busy', 'false'); + setStatus('Accounts couldn’t be loaded. Restart GWonMac and try again.', 'error'); + }) + .finally(() => { refreshInFlight = null; }); + return refreshInFlight; + } + + function showProfileDialog(profile: AccountProfileSummary | null) { + editing = profile; + profileTitle.textContent = profile ? 'Edit Account' : 'New Account'; + profileDescription.textContent = profile + ? `Change how ${profile.name} stores builds and templates.` + : 'Create an independent Guild Wars account.'; + profileSave.textContent = profile ? 'Save Changes' : 'Create Account'; + profileName.value = profile?.name ?? ''; + choice('profileBuilds').value = profile?.builds ?? 'shared'; + choice('profileTemplates').value = profile?.templates ?? 'shared'; + profileStatus.textContent = ''; + profileDialog.showModal(); + profileName.focus(); + } + + list.addEventListener('change', (event) => { + const input = event.target; + if (!(input instanceof HTMLInputElement)) return; + if (input.checked) selected.add(input.value as AccountProfileSummary['id']); + else selected.delete(input.value as AccountProfileSummary['id']); + syncActions(); + }); + selectAll.addEventListener('click', () => { + const active = activeProfiles(); + selected = selected.size === active.length + ? new Set() + : new Set(active.map((profile) => profile.id)); + render(); + }); + form.addEventListener('submit', (event) => { + event.preventDefault(); + void launch(selectedProfiles().map((profile) => profile.id)); + }); + required('accounts-new').addEventListener('click', () => showProfileDialog(null)); + required('accounts-empty-new').addEventListener('click', () => showProfileDialog(null)); + required('profile-cancel').addEventListener('click', () => profileDialog.close()); + profileDialog.addEventListener('close', () => { editing = null; profileStatus.textContent = ''; }); + 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 wasEditing = editing !== null; + try { + if (editing) await window.gwNative.accounts.update({ id: editing.id, ...request }); + else await window.gwNative.accounts.create(request); + profileDialog.close(); + setStatus(wasEditing ? 'Account updated.' : 'Account created.', 'success'); + await refresh(); + } catch { + profileStatus.textContent = 'The account could not be saved. Use a unique name and close it before changing sharing.'; + } finally { + profileSave.disabled = false; + } + }); + + menuEdit.addEventListener('click', () => { + const profile = menuProfile; + closeMenu(); + if (profile) showProfileDialog(profile); + }); + menuArchive.addEventListener('click', async () => { + const profile = menuProfile; + closeMenu(); + if (!profile || !window.confirm(`Archive “${profile.name}”? Its login and files will be kept.`)) return; + try { + await window.gwNative.accounts.archive(profile.id); + setStatus(`${profile.name} was archived.`, 'success'); + selected.delete(profile.id); + await refresh(); + } catch { + setStatus('Close the account before archiving it, then try again.', 'error'); + } + }); + document.addEventListener('pointerdown', (event) => { + if (!menu.hidden && !menu.contains(event.target as Node)) closeMenu(); + }); + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && !menu.hidden) closeMenu(); + }); + + const showSettings = () => { + closeMenu(); + settingsStatus.textContent = ''; + renderArchived(); + if (!settingsDialog.open) settingsDialog.showModal(); + }; + required('settings-done').addEventListener('click', () => settingsDialog.close()); + required('accounts-single').addEventListener('click', async () => { + if (!window.confirm('Return to Single Account mode? GWonMac will restart. Multiple Accounts and Single Account data will both be preserved.')) return; + settingsStatus.textContent = 'Restarting in Single Account mode…'; + try { + await window.gwNative.accounts.useSingle(); + } catch { + settingsStatus.textContent = 'The mode change could not be saved. Nothing changed.'; + } + }); + window.gwNative.commands.handle(async (command) => { + if (command.type === 'accounts.settings.open') showSettings(); + }); + + window.addEventListener('focus', () => { void refresh(); }); + void refresh(); +})(); diff --git a/src/renderer/commands.ts b/src/renderer/commands.ts index 48882f71..2986560d 100644 --- a/src/renderer/commands.ts +++ b/src/renderer/commands.ts @@ -83,7 +83,15 @@ } fs.syncfs(false, (error?: unknown) => { if (error) reject(error); - else resolve(); + else { + void import('./template-store.js').then(async ({ exportEntries, templateFilesystem }) => { + const templates = templateFilesystem(); + if (templates) { + await window.gwNative.accounts.saveTemplates(exportEntries(templates)); + } + resolve(); + }).catch(reject); + } }); }); break; diff --git a/src/renderer/filesystem.ts b/src/renderer/filesystem.ts index 1c55d3fd..b4891783 100644 --- a/src/renderer/filesystem.ts +++ b/src/renderer/filesystem.ts @@ -20,7 +20,13 @@ const REQUIRED_DIRECTORIES = [ ]; type EmscriptenFileSystem = { - analyzePath(path: string): { error: number }; + analyzePath(path: string): { error: number; exists: boolean }; + readdir(path: string): string[]; + stat(path: string): { mode: number }; + isDir(mode: number): boolean; + isFile(mode: number): boolean; + readFile(path: string, options: { encoding: 'utf8' }): string; + writeFile(path: string, data: string): void; chdir(path: string): void; lookupPath(path: string, options?: unknown): unknown; open(path: unknown, ...args: unknown[]): unknown; @@ -54,10 +60,12 @@ export const installGameFilesystem = ({ module, failed, log, + restoreTemplates, }: { module: EmscriptenModule; failed(error: unknown): void; log(...values: unknown[]): void; + restoreTemplates?(fs: EmscriptenFileSystem): Promise; }) => { module.preRun = () => { module.addRunDependency(DEPENDENCY); @@ -103,6 +111,7 @@ export const installGameFilesystem = ({ fs.mkdirTree(directory); } fs.chdir(MOUNT); + const persist = () => { // Persist the directory invariant before the game can create a // template, screenshot, chat log, or diagnostic file beneath it. sync(false, (persistError) => { @@ -144,6 +153,9 @@ export const installGameFilesystem = ({ ready(); } }); + }; + if (restoreTemplates) void restoreTemplates(fs).then(persist).catch(stop); + else persist(); } catch (error) { stop(error); } diff --git a/src/renderer/harness.css b/src/renderer/harness.css index 6cfff558..cab3c4f7 100644 --- a/src/renderer/harness.css +++ b/src/renderer/harness.css @@ -294,6 +294,7 @@ html,body { width:100%; height:100%; margin:0; overflow:hidden; .settings-panes[data-active="display"] #settings-pane-display, .settings-panes[data-active="controls"] #settings-pane-controls, .settings-panes[data-active="updates"] #settings-pane-updates, +.settings-panes[data-active="accounts"] #settings-pane-accounts, .settings-panes[data-active="advanced"] #settings-pane-advanced { display: block; } .sr-only { position:absolute; width:1px; height:1px; margin:-1px; padding:0; diff --git a/src/renderer/harness.ts b/src/renderer/harness.ts index 8e9d369c..84639304 100644 --- a/src/renderer/harness.ts +++ b/src/renderer/harness.ts @@ -927,6 +927,12 @@ function mountGameFilesystem() { host.installGameFilesystem({ module: clientRuntime(), log, + async restoreTemplates(fs) { + const library = await native().accounts.loadTemplates(); + if (!library) return; + const { replaceTemplateProjection } = await import('./template-store.js'); + await replaceTemplateProjection(fs, library.entries); + }, failed(error) { window.gwDiagnostics?.event('filesystem.persistenceFailed', error); log( diff --git a/src/renderer/index.html b/src/renderer/index.html index acf8708b..c2745bff 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -269,6 +269,9 @@

Settings

+ @@ -564,6 +567,58 @@

Advanced

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

+ +
+

Multiple Accounts

+

Open several independent Guild Wars accounts, each in its own window.

+

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 account.

+ +
+ +
+ +
+ 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 dfd05a96..eb79ee75 100644 --- a/src/renderer/settings.ts +++ b/src/renderer/settings.ts @@ -43,6 +43,18 @@ 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 accountsImportTemplates = byId('accounts-import-templates') 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'); + const accountsMultiActive = byId('accounts-multi-active'); + const accountsReturnSingle = byId('accounts-return-single') as HTMLButtonElement; + const accountsNewWorkspaceFields = byId('accounts-new-workspace-fields'); /** * The appearance slider beside the `output` that reads it back. * @@ -564,6 +576,72 @@ } }); + 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 { + let templateEntries: import('../shared/contracts.js').TemplateExportEntry[] = []; + if (accountsImportTemplates.checked) { + const { exportEntries, templateFilesystem } = await import('./template-store.js'); + const filesystem = templateFilesystem(); + if (!filesystem) throw new Error('template filesystem is unavailable'); + templateEntries = exportEntries(filesystem); + } + await window.gwNative.accounts.setup({ + name, + templates: accountsTemplates.value as 'shared' | 'private', + builds: accountsBuilds.value as 'shared' | 'private', + importTemplates: accountsImportTemplates.checked, + templateEntries, + importBuilds: accountsImportBuilds.checked, + }); + } catch { + accountsEnable.disabled = false; + accountsStatus.textContent = 'Multiple Accounts could not be enabled. Nothing changed.'; + } + }); + + accountsReturnSingle.addEventListener('click', async () => { + if (!window.confirm('Return to Single Account mode? GWonMac will restart. Multiple Accounts and Single Account data will both be preserved.')) return; + accountsReturnSingle.disabled = true; + accountsModeStatus.textContent = 'Restarting in Single Account mode…'; + try { + await window.gwNative.accounts.useSingle(); + } catch { + accountsReturnSingle.disabled = false; + accountsModeStatus.textContent = 'The mode change could not be saved. Nothing changed.'; + } + }); + + void window.gwNative.accounts.get().then((state) => { + const singleMode = state.mode === 'single'; + const activeProfiles = state.profiles.filter((profile) => !profile.archived); + const existingWorkspace = singleMode && activeProfiles.length > 0; + accountsModeStatus.textContent = existingWorkspace + ? `Single Account mode is active. Your ${activeProfiles.length} Multiple Accounts ${activeProfiles.length === 1 ? 'account is' : 'accounts are'} ready to restore.` + : singleMode + ? 'Single Account mode is active.' + : 'Multiple Accounts mode is active. Use the Account Picker to open and manage accounts.'; + accountsSingleSetup.hidden = !singleMode; + accountsMultiActive.hidden = singleMode; + if (existingWorkspace) { + accountsName.value = activeProfiles[0]?.name ?? 'Account'; + accountsNewWorkspaceFields.hidden = true; + accountsEnable.textContent = 'Restore Multiple Accounts and Restart…'; + } + }).catch(() => { + accountsModeStatus.textContent = 'Account mode could not be read.'; + accountsSingleSetup.hidden = true; + accountsMultiActive.hidden = true; + }); + window.addEventListener('resize', updateRenderScaleDimensions); window.addEventListener('gw:graphics-resized', updateRenderScaleDimensions); })(); diff --git a/src/renderer/template-store.ts b/src/renderer/template-store.ts index 15efa57c..e9c9598d 100644 --- a/src/renderer/template-store.ts +++ b/src/renderer/template-store.ts @@ -324,6 +324,46 @@ export async function applyImport( }); } +/** Replace the isolated profile's working templates with a canonical snapshot. */ +export async function replaceTemplateProjection( + fs: TemplateFileSystem, + entries: readonly TemplateExportEntry[], +): Promise { + const incoming: TemplateCandidate[] = entries.map((entry) => { + const segments = entry.path.split('/'); + const kind: TemplateKind = segments[0] === 'Equipment' ? 'equipment' : 'skills'; + const file = segments.at(-1) ?? ''; + const name = file.replace(/\.txt$/i, ''); + const folder = segments.length === 3 ? (segments[1] ?? null) : null; + const candidate = { kind, folder, name, code: entry.contents }; + if (templatePath(candidate) === null || !isTemplateCode(candidate.code)) { + throw new Error('invalid account template projection'); + } + return candidate; + }); + await mutate(fs, () => { + for (const directory of Object.values(TEMPLATE_DIRECTORIES)) { + for (const item of listing(fs, directory)) { + const itemPath = `${directory}/${item}`; + if (isDirectory(fs, itemPath)) { + for (const child of listing(fs, itemPath)) { + const childPath = `${itemPath}/${child}`; + if (fs.isFile(fs.stat(childPath).mode)) fs.unlink(childPath); + } + try { fs.rmdir(itemPath); } catch { /* Keep unmanaged contents. */ } + } else if (fs.isFile(fs.stat(itemPath).mode)) { + fs.unlink(itemPath); + } + } + } + for (const candidate of incoming) { + const filePath = templatePath(candidate)!; + fs.mkdirTree(filePath.slice(0, filePath.lastIndexOf('/'))); + fs.writeFile(filePath, candidate.code); + } + }); +} + /** * Templates the game has no way to reach. * diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 8029317c..3135ba3c 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,60 @@ export interface RendererInit { templateFsTrace: boolean; } +export type MultiProfileRuntimeState = + | "ready" + | "queued" + | "opening" + | "checking" + | "running" + | "failed"; + +export type AccountLaunchIssue = + | "profile-preparation" + | "window-startup" + | "client-validation" + | "renderer-crash" + | "unknown"; + +export interface AccountProfileSummary { + readonly id: ProfileId; + readonly name: string; + readonly templates: LibraryScope; + readonly builds: LibraryScope; + readonly archived: boolean; + readonly state: MultiProfileRuntimeState; + readonly launchIssue?: AccountLaunchIssue; +} + +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 templateEntries: readonly TemplateExportEntry[]; + readonly importBuilds: boolean; +} + +export interface AccountTemplateLibrary { + readonly revision: number; + readonly entries: readonly TemplateExportEntry[]; +} + +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. @@ -634,6 +693,7 @@ export type WasmBridgeMarkers = typeof WASM_BRIDGE_MARKERS; */ export type RendererCommand = | { type: "input.reset" } + | { type: "accounts.settings.open" } | { type: "tools.toggle" } | { type: "settings.open"; @@ -722,6 +782,17 @@ 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", + accountsCreate: "gw:accounts:create", + accountsUpdate: "gw:accounts:update", + accountsArchive: "gw:accounts:archive", + accountsRestore: "gw:accounts:restore", + accountsDelete: "gw:accounts:delete", + accountsTemplatesLoad: "gw:accounts:templatesLoad", + accountsTemplatesSave: "gw:accounts:templatesSave", + accountsUseSingle: "gw:accounts:useSingle", } as const; export type IpcChannel = (typeof IPC)[keyof typeof IPC]; @@ -898,4 +969,17 @@ export interface GwNativeApi { restartAndInstall(): Promise; onState(callback: (state: AppUpdateState) => void): () => void; }; + accounts: { + get(): Promise; + setup(value: AccountsSetupRequest): Promise; + open(profileIds: readonly ProfileId[]): Promise; + create(value: AccountProfileRequest): Promise; + update(value: AccountProfileUpdateRequest): Promise; + archive(profileId: ProfileId): Promise; + restore(profileId: ProfileId): Promise; + delete(profileId: ProfileId): Promise; + loadTemplates(): Promise; + saveTemplates(entries: readonly TemplateExportEntry[]): Promise; + useSingle(): Promise; + }; } diff --git a/src/shared/errors.ts b/src/shared/errors.ts index 4a1b912b..357467d1 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 00000000..4a72c729 --- /dev/null +++ b/src/shared/multiple-accounts.ts @@ -0,0 +1,131 @@ +/** + * 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; +export const MULTI_PROFILE_MAX_COUNT = 16; + +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) + || source.profiles.length > MULTI_PROFILE_MAX_COUNT + ) { + 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/src/shared/template-entries.ts b/src/shared/template-entries.ts new file mode 100644 index 00000000..a38e1fec --- /dev/null +++ b/src/shared/template-entries.ts @@ -0,0 +1,62 @@ +/** + * The one trust-boundary parser for portable Guild Wars template entries. + * It validates bounded paths and contents before either process may store them. + */ +import { + TEMPLATE_CEILINGS, + type TemplateExportEntry, +} from "./contracts.js"; +import { ValidationError } from "./errors.js"; + +const MIN_SEGMENTS = 2; +const MAX_SEGMENTS = 3; + +export function parseTemplateEntries(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 + || !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"); + } + } +} diff --git a/tests/electron/fixtures.mts b/tests/electron/fixtures.mts index 4a203611..5b53f7cd 100644 --- a/tests/electron/fixtures.mts +++ b/tests/electron/fixtures.mts @@ -148,7 +148,13 @@ async function waitForExit( } async function stopElectron(app: ElectronApplication): Promise { - const child = app.process(); + let child: ReturnType; + try { + child = app.process(); + } catch { + // Playwright drops its process handle after an app has already exited. + return; + } const closed = await new Promise((resolve) => { const finish = (value: boolean) => { clearTimeout(timeout); diff --git a/tests/electron/multiple-accounts.spec.ts b/tests/electron/multiple-accounts.spec.ts new file mode 100644 index 00000000..3821bb04 --- /dev/null +++ b/tests/electron/multiple-accounts.spec.ts @@ -0,0 +1,381 @@ +/** Real Electron coverage for the opt-in Hub and profile isolation boundary. */ +import { expect, test } from "@playwright/test"; +import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; +import type { ProfileId } from "../../src/shared/multiple-accounts.js"; +import { closeOffline, launchOffline } from "./fixtures.mjs"; + +declare global { + var __multiModeRestart: { + quit: boolean; + relaunch: boolean; + originalQuit: Electron.App["quit"]; + originalRelaunch: Electron.App["relaunch"]; + }; +} + +const FIRST = "00000000-0000-4000-8000-000000000001"; +const SECOND = "00000000-0000-4000-8000-000000000002"; + +test("Hub exposes the focused chooser, account sheets, and Settings management", async () => { + const fixture = await launchOffline("gw-multi-hub-ui-e2e-", {}, async (userData) => { + await mkdir(path.join(userData, "multi"), { recursive: true }); + await writeFile( + path.join(userData, "launcher-mode.json"), + JSON.stringify({ formatVersion: 1, mode: "multi" }), + ); + await writeFile( + path.join(userData, "multi", "workspace.json"), + JSON.stringify({ + formatVersion: 1, + profiles: [ + { id: FIRST, name: "Primary Account With A Deliberately Long Name", archived: false, templates: "shared", builds: "shared" }, + { id: SECOND, name: "Alt", archived: false, templates: "private", builds: "private" }, + { id: "00000000-0000-4000-8000-000000000003", name: "Pre-Searing", archived: false, templates: "shared", builds: "shared" }, + { id: "00000000-0000-4000-8000-000000000004", name: "Storage", archived: false, templates: "shared", builds: "shared" }, + { id: "00000000-0000-4000-8000-000000000005", name: "PvP", archived: false, templates: "shared", builds: "shared" }, + { id: "00000000-0000-4000-8000-000000000006", name: "Archived", archived: true, templates: "shared", builds: "shared" }, + ], + }), + ); + }); + try { + await expect(fixture.page.getByRole("heading", { name: "Choose Accounts" })).toBeVisible(); + await expect(fixture.page.getByText("Each account keeps its own saved login and game files.")).toBeVisible(); + await expect(fixture.page.getByRole("checkbox")).toHaveCount(5); + await expect(fixture.page.getByRole("button", { name: "Open", exact: true })).toBeDisabled(); + + const primary = fixture.page.getByRole("checkbox", { name: /Select Primary Account/ }); + await primary.check(); + await expect(fixture.page.getByRole("button", { name: /Open Primary Account/ })).toBeEnabled(); + expect(await fixture.page.locator("#accounts-list").evaluate((element) => + element.scrollHeight >= element.clientHeight)).toBe(true); + await expect(fixture.page.locator(".account-copy strong").first()).toHaveAttribute( + "title", + "Primary Account With A Deliberately Long Name", + ); + + await fixture.page.getByRole("button", { name: "New Account…" }).click(); + await expect(fixture.page.getByRole("dialog", { name: "New Account" })).toBeVisible(); + await expect(fixture.page.getByText("Builds and teams")).toBeVisible(); + await expect(fixture.page.getByText("In-game templates")).toBeVisible(); + await expect(fixture.page.getByText(/Single Account data are never shared/)).toBeVisible(); + await fixture.page.getByRole("button", { name: "Cancel" }).click(); + + await fixture.page.getByRole("button", { name: /More options for Primary/ }).click(); + await expect(fixture.page.getByRole("menuitem", { name: "Edit Account…" })).toBeVisible(); + await expect(fixture.page.getByRole("menuitem", { name: "Archive Account" })).toBeVisible(); + await fixture.page.keyboard.press("Escape"); + + await fixture.app.evaluate(({ Menu }) => { + const item = Menu.getApplicationMenu()?.getMenuItemById("accounts-settings-menu"); + if (!item?.click) throw new Error("Accounts Settings menu item is unavailable"); + item.click(item, undefined, {} as Electron.KeyboardEvent); + }); + await expect(fixture.page.getByRole("dialog", { name: "Multiple Accounts Settings" })).toBeVisible(); + await expect(fixture.page.getByText("Archived", { exact: true })).toBeVisible(); + await expect(fixture.page.getByRole("button", { name: "Restore" })).toBeVisible(); + await expect(fixture.page.getByRole("button", { name: "Delete…" })).toBeVisible(); + await expect(fixture.page.getByRole("button", { name: "Return to Single Account…" })).toBeVisible(); + } finally { + await closeOffline(fixture); + } +}); + +test("renderer recovery stays with its account and a second crash needs attention", async () => { + const fixture = await launchOffline("gw-multi-recovery-e2e-", {}, async (userData) => { + await mkdir(path.join(userData, "multi"), { recursive: true }); + await writeFile(path.join(userData, "launcher-mode.json"), JSON.stringify({ formatVersion: 1, mode: "multi" })); + await writeFile(path.join(userData, "multi", "workspace.json"), JSON.stringify({ + formatVersion: 1, + profiles: [ + { id: FIRST, name: "Primary", archived: false, templates: "shared", builds: "shared" }, + { id: SECOND, name: "Alt", archived: false, templates: "shared", builds: "shared" }, + ], + })); + }); + try { + await fixture.page.evaluate( + ([first, second]) => window.gwNative.accounts.open([first, second] as ProfileId[]), + [FIRST, SECOND] as const, + ); + const firstRenderer = await fixture.app.evaluate(({ BrowserWindow, dialog }) => { + dialog.showErrorBox = () => undefined; + const win = BrowserWindow.getAllWindows().find((candidate) => candidate.getTitle().endsWith("Primary")); + if (!win) throw new Error("Primary window not found"); + const id = win.webContents.id; + win.webContents.forcefullyCrashRenderer(); + return id; + }); + await expect.poll(() => fixture.app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().find((win) => win.getTitle().endsWith("Primary"))?.webContents.id, + )).not.toBe(firstRenderer); + await expect.poll(() => fixture.page.evaluate(() => + window.gwNative.accounts.get().then((state) => state.profiles.find((profile) => profile.name === "Primary")?.state), + )).toBe("running"); + expect(await fixture.app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().find((win) => win.getTitle().endsWith("Accounts"))?.isVisible(), + )).toBe(false); + + await fixture.app.evaluate(({ BrowserWindow }) => { + const win = BrowserWindow.getAllWindows().find((candidate) => candidate.getTitle().endsWith("Primary")); + if (!win) throw new Error("Recovered Primary window not found"); + win.webContents.forcefullyCrashRenderer(); + }); + await expect.poll(() => fixture.page.evaluate(() => + window.gwNative.accounts.get().then((state) => state.profiles.find((profile) => profile.name === "Primary")), + )).toMatchObject({ state: "failed", launchIssue: "renderer-crash" }); + expect(await fixture.app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().find((win) => win.getTitle().endsWith("Accounts"))?.isVisible(), + )).toBe(true); + expect(await fixture.app.evaluate(({ BrowserWindow }) => { + const alt = BrowserWindow.getAllWindows().find((win) => win.getTitle().endsWith("Alt")); + return !!alt && !alt.webContents.isCrashed(); + })).toBe(true); + } finally { + await closeOffline(fixture); + } +}); + +test("Multi starts at the Hub and isolates two profile windows from Single", async () => { + const fixture = await launchOffline("gw-multi-e2e-", { + GW_BACKGROUND_LAUNCH: "0", + }, async (userData) => { + await mkdir(path.join(userData, "multi"), { recursive: true }); + await writeFile( + path.join(userData, "launcher-mode.json"), + JSON.stringify({ formatVersion: 1, mode: "multi" }), + ); + await writeFile( + path.join(userData, "multi", "workspace.json"), + JSON.stringify({ + formatVersion: 1, + profiles: [ + { id: FIRST, name: "Primary", archived: false, templates: "shared", builds: "shared" }, + { id: SECOND, name: "Alt", archived: false, templates: "shared", builds: "shared" }, + ], + }), + ); + await writeFile(path.join(userData, "build-library.json"), "single-sentinel"); + await writeFile(path.join(userData, "clear-game-storage-on-start"), "pending"); + await mkdir(path.join(userData, "multi", "profiles", FIRST), { recursive: true }); + await writeFile( + path.join( + userData, + "multi", + "profiles", + FIRST, + "clear-game-storage-on-start", + ), + "pending", + ); + }); + try { + await expect(fixture.page.locator("h1")).toHaveText("Choose Accounts"); + const state = await fixture.page.evaluate(() => window.gwNative.accounts.get()); + expect(state.mode).toBe("multi"); + expect(state.profiles.map((profile) => profile.name)).toEqual(["Primary", "Alt"]); + + await fixture.page.getByRole("checkbox", { name: "Select Primary" }).check(); + await expect(fixture.page.getByRole("button", { name: "Open Primary" })).toBeVisible(); + await fixture.page.getByRole("checkbox", { name: "Select Alt" }).check(); + await expect(fixture.page.getByRole("button", { name: "Open 2 Accounts" })).toBeVisible(); + await fixture.page.getByRole("button", { name: "Open 2 Accounts" }).click(); + await expect.poll(() => fixture.app.windows().length).toBe(3); + const games = fixture.app.windows().filter((page) => page !== fixture.page); + await Promise.all(games.map((page) => page.waitForLoadState("domcontentloaded"))); + expect(games.map((page) => page.url())).toEqual(["gw://app/", "gw://app/"]); + + const titles = await fixture.app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().map((win) => win.getTitle()).sort(), + ); + expect(titles).toEqual([ + "Guild Wars Reforged — Accounts", + "Guild Wars Reforged — Alt", + "Guild Wars Reforged — Primary", + ]); + const presentation = await fixture.app.evaluate(({ BrowserWindow }) => { + const windows = BrowserWindow.getAllWindows(); + return { + focused: BrowserWindow.getFocusedWindow()?.getTitle(), + hubVisible: windows.find((win) => win.getTitle() === "Guild Wars Reforged — Accounts")?.isVisible(), + bounds: Object.fromEntries(windows + .filter((win) => win.getTitle() !== "Guild Wars Reforged — Accounts") + .map((win) => [win.getTitle(), win.getBounds()])), + }; + }); + expect(presentation.focused).toBe("Guild Wars Reforged — Primary"); + expect(presentation.hubVisible).toBe(false); + const primaryBounds = presentation.bounds["Guild Wars Reforged — Primary"]!; + const altBounds = presentation.bounds["Guild Wars Reforged — Alt"]!; + expect(altBounds.x - primaryBounds.x).toBe(32); + expect(altBounds.y - primaryBounds.y).toBe(32); + const storagePaths = await fixture.app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows() + .filter((win) => win.getTitle() !== "Guild Wars Reforged — Accounts") + .map((win) => win.webContents.session.storagePath), + ); + expect(new Set(storagePaths).size).toBe(2); + expect(storagePaths.every((value) => value?.includes("Partitions/gw-multi-"))).toBe(true); + + await games[0]!.evaluate(() => localStorage.setItem("profile-proof", "first")); + await games[1]!.evaluate(() => localStorage.setItem("profile-proof", "second")); + expect(await games[0]!.evaluate(() => localStorage.getItem("profile-proof"))).toBe("first"); + expect(await games[1]!.evaluate(() => localStorage.getItem("profile-proof"))).toBe("second"); + + await games[0]!.evaluate(() => + window.gwNative.credentials.save({ username: "first@example.test", password: "one" }), + ); + await games[1]!.evaluate(() => + window.gwNative.credentials.save({ username: "second@example.test", password: "two" }), + ); + expect(await games[0]!.evaluate(() => window.gwNative.credentials.load())) + .toEqual({ username: "first@example.test", password: "one" }); + expect(await games[1]!.evaluate(() => window.gwNative.credentials.load())) + .toEqual({ username: "second@example.test", password: "two" }); + await expect( + fixture.page.evaluate(() => window.gwNative.credentials.load()), + ).rejects.toThrow(); + + await Promise.all(games.map((game) => + game.evaluate(() => window.gwNative.accounts.loadTemplates()), + )); + await games[0]!.evaluate(() => window.gwNative.accounts.saveTemplates([{ + path: "Skills/Primary.txt", + contents: "OQCiUyo8AkVwR4KMMGAAAEAA", + }])); + await games[1]!.evaluate(() => window.gwNative.accounts.saveTemplates([{ + path: "Skills/Alt.txt", + contents: "OQCiUyo8AkVwR4KMMGAAAEAB", + }])); + const sharedTemplates = JSON.parse(await readFile( + path.join(fixture.userData, "multi", "shared", "templates.json"), + "utf8", + )) as { entries: Array<{ path: string }> }; + expect(sharedTemplates.entries.map((entry) => entry.path)).toEqual([ + "Skills/Alt.txt", + "Skills/Primary.txt", + ]); + + const libraries = await Promise.all(games.map((game) => + game.evaluate(() => window.gwNative.buildLibrary.get()), + )); + await games[0]!.evaluate( + (library) => window.gwNative.buildLibrary.set({ ...library, tags: ["primary"] }), + libraries[0]!.library, + ); + await expect(games[1]!.evaluate( + (library) => window.gwNative.buildLibrary.set({ ...library, tags: ["alt"] }), + libraries[1]!.library, + )).rejects.toThrow(); + expect(await readFile(path.join(fixture.userData, "build-library.json"), "utf8")) + .toBe("single-sentinel"); + expect(JSON.parse(await readFile( + path.join(fixture.userData, "multi", "shared", "build-library.json"), + "utf8", + ))).toMatchObject({ tags: ["primary"] }); + await stat(path.join(fixture.userData, "clear-game-storage-on-start")); + await expect(stat(path.join( + fixture.userData, + "multi", + "profiles", + FIRST, + "clear-game-storage-on-start", + ))).rejects.toMatchObject({ code: "ENOENT" }); + + const identifiedGames = await Promise.all(games.map(async (game) => ({ + game, + credentials: await game.evaluate(() => window.gwNative.credentials.load()), + }))); + const primaryPage = identifiedGames.find(({ credentials }) => + credentials?.username === "first@example.test")?.game; + const altPage = identifiedGames.find(({ credentials }) => + credentials?.username === "second@example.test")?.game; + if (!primaryPage || !altPage) throw new Error("profile pages not found"); + const primaryClosed = primaryPage.waitForEvent("close", { timeout: 12_000 }); + await primaryPage.evaluate(() => { + void window.gwNative.app.requestQuit(); + }); + await primaryClosed; + expect(await altPage.evaluate(() => window.gwNative.credentials.load())).toEqual({ + username: "second@example.test", + password: "two", + }); + expect(await fixture.page.evaluate(() => document.visibilityState)).toBe("visible"); + } finally { + await closeOffline(fixture); + } +}); + +test("opt-in publishes a separate workspace before requesting restart", async () => { + const singleLibrary = JSON.stringify({ version: 3, builds: [], teams: [], tags: [] }); + const fixture = await launchOffline("gw-multi-setup-e2e-", {}, async (userData) => { + await writeFile(path.join(userData, "build-library.json"), singleLibrary); + }); + try { + await fixture.app.evaluate(({ app }) => { + globalThis.__multiModeRestart = { + quit: false, + relaunch: false, + originalQuit: app.quit.bind(app), + originalRelaunch: app.relaunch.bind(app), + }; + app.quit = () => { globalThis.__multiModeRestart.quit = true; }; + app.relaunch = () => { globalThis.__multiModeRestart.relaunch = true; }; + }); + await fixture.page.evaluate(() => window.gwNative.accounts.setup({ + name: "Primary", + templates: "shared", + builds: "private", + importTemplates: true, + templateEntries: [{ + path: "Skills/Imported.txt", + contents: "OQCiUyo8AkVwR4KMMGAAAEAA", + }], + importBuilds: true, + })); + expect(JSON.parse(await readFile( + path.join(fixture.userData, "launcher-mode.json"), + "utf8", + ))).toEqual({ formatVersion: 1, mode: "multi" }); + const workspace = JSON.parse(await readFile( + path.join(fixture.userData, "multi", "workspace.json"), + "utf8", + )) as { profiles: Array<{ id: string; name: string }> }; + expect(workspace.profiles.map((profile) => profile.name)).toEqual(["Primary"]); + expect(await readFile(path.join(fixture.userData, "build-library.json"), "utf8")) + .toBe(singleLibrary); + expect(JSON.parse(await readFile( + path.join( + fixture.userData, + "multi", + "profiles", + workspace.profiles[0]!.id, + "build-library.json", + ), + "utf8", + ))).toEqual(JSON.parse(singleLibrary)); + expect(JSON.parse(await readFile( + path.join(fixture.userData, "multi", "shared", "templates.json"), + "utf8", + ))).toEqual({ + formatVersion: 1, + revision: 1, + entries: [{ + path: "Skills/Imported.txt", + contents: "OQCiUyo8AkVwR4KMMGAAAEAA", + }], + }); + expect(await fixture.app.evaluate(() => ({ + quit: globalThis.__multiModeRestart.quit, + relaunch: globalThis.__multiModeRestart.relaunch, + }))).toEqual({ quit: true, relaunch: true }); + } finally { + await fixture.app.evaluate(({ app }) => { + if (!globalThis.__multiModeRestart) return; + app.quit = globalThis.__multiModeRestart.originalQuit; + app.relaunch = globalThis.__multiModeRestart.originalRelaunch; + }).catch(() => undefined); + await closeOffline(fixture); + } +}); diff --git a/tests/electron/sandbox.spec.ts b/tests/electron/sandbox.spec.ts index 1f1181ae..e193948f 100644 --- a/tests/electron/sandbox.spec.ts +++ b/tests/electron/sandbox.spec.ts @@ -44,6 +44,7 @@ test.describe("sandbox boundary", () => { // if a member is added there without a call — the two files change // together. keys: [ + "accounts", "app", "appUpdates", "buildLibrary", diff --git a/tests/electron/settings-data-display.spec.ts b/tests/electron/settings-data-display.spec.ts index 9402e148..68679f55 100644 --- a/tests/electron/settings-data-display.spec.ts +++ b/tests/electron/settings-data-display.spec.ts @@ -5,6 +5,30 @@ import { closeOffline, launchOffline } from "./fixtures.mjs"; import "./settings-test-fixture.mjs"; test.describe("data and display settings", () => { + test("shows the Multiple Accounts setup when its tab is selected", async () => { + const fixture = await launchOffline("gw-settings-accounts-e2e-"); + try { + const { page } = fixture; + await page.evaluate(() => + globalThis.dispatchEvent(new globalThis.Event("gw:settings")), + ); + await page.locator("#settings-tab-accounts").click(); + + const pane = page.locator("#settings-pane-accounts"); + await expect(pane).toBeVisible(); + await expect(pane.getByRole("heading", { name: "Multiple Accounts" })) + .toBeVisible(); + await expect(page.locator("#accounts-first-name")).toBeVisible(); + await expect(page.getByRole("group", { name: "Build library" })) + .toBeVisible(); + await expect(page.getByRole("group", { name: "Build templates" })) + .toBeVisible(); + await expect(page.locator("#accounts-enable")).toBeVisible(); + } finally { + await closeOffline(fixture); + } + }); + test("interface style and panel opacity apply live and survive", async () => { const fixture = await launchOffline("gw-settings-appearance-e2e-"); try { diff --git a/tests/policy/forbidden-artifacts.test.ts b/tests/policy/forbidden-artifacts.test.ts index 852f8bac..1b9bae65 100644 --- a/tests/policy/forbidden-artifacts.test.ts +++ b/tests/policy/forbidden-artifacts.test.ts @@ -100,12 +100,21 @@ test("no private key material is tracked", () => { assert.deepEqual(hits, []); }); -test("only the public client access key is UUID-shaped", () => { +test("only public identifiers and explicit profile-id fixtures are UUID-shaped", () => { const uuid = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi; // One entry, the public client access key. The RFC 6455 WebSocket GUID used // to sit beside it; it belonged to the retired Python runtime's relay and // matches nothing in the tree, so it is gone. const allowed = new Set(["2043FE79-F32D-4FD7-8C27-0D47231C4F03"]); + const profileIdFixtures = new Set([ + "tests/electron/multiple-accounts.spec.ts", + "tests/release/preload-behaviour.test.ts", + "tests/unit/credentials.test.ts", + "tests/unit/multiple-accounts.test.ts", + "tests/unit/native-keychain.test.ts", + "tests/unit/paths.test.ts", + "tests/unit/window-registry.test.ts", + ]); const hits = []; for (const file of tracked) { if (file === "tests/policy/forbidden-artifacts.test.ts") continue; @@ -116,7 +125,10 @@ test("only the public client access key is UUID-shaped", () => { continue; } for (const match of text.matchAll(uuid)) { - if (!allowed.has(match[0].toUpperCase())) hits.push(`${file}:${match[0]}`); + if ( + !allowed.has(match[0].toUpperCase()) + && !profileIdFixtures.has(file) + ) hits.push(`${file}:${match[0]}`); } } assert.deepEqual(hits, []); diff --git a/tests/policy/source-saved-login-surface.test.ts b/tests/policy/source-saved-login-surface.test.ts index 39dc804f..08819b33 100644 --- a/tests/policy/source-saved-login-surface.test.ts +++ b/tests/policy/source-saved-login-surface.test.ts @@ -63,7 +63,7 @@ test("only provisioned distribution channels enable persistent secrets", () => { ); assert.match( main, - /if \(persistentSecrets && distribution\.cleanupLegacySecrets\) \{[\s\S]{0,200}cleanupLegacySecretFiles/, + /if \(\s*activeAccountMode === "single"\s*&& persistentSecrets\s*&& distribution\.cleanupLegacySecrets\s*\) \{[\s\S]{0,200}cleanupLegacySecretFiles/, ); assert.match(main, /capable: distribution\.automaticUpdates/); assert.match(main, /persistentSecrets\s*\? loadNativeKeychain/); diff --git a/tests/release/preload-behaviour.test.ts b/tests/release/preload-behaviour.test.ts index bd4f7ecb..297f5dad 100644 --- a/tests/release/preload-behaviour.test.ts +++ b/tests/release/preload-behaviour.test.ts @@ -158,6 +158,7 @@ function call(api: GwNativeApi, dotted: string): Capability { } const PACKET = new Uint8Array([1, 2, 3, 4]); +const PROFILE_ID = "00000000-0000-4000-8000-000000000001"; /** A request/response capability, with the arguments a caller really passes. */ interface Invocation { @@ -179,6 +180,49 @@ const INVOCATIONS: Invocation[] = [ { path: "settings.get", args: [], channel: IPC.settingsGet }, { path: "settings.set", args: [{ renderScale: 2 }], channel: IPC.settingsSet }, { path: "settings.reset", args: [], channel: IPC.settingsReset }, + { path: "accounts.get", args: [], channel: IPC.accountsGet }, + { + path: "accounts.setup", + args: [{ + name: "Primary", + templates: "shared", + builds: "private", + importTemplates: false, + templateEntries: [], + importBuilds: false, + }], + channel: IPC.accountsSetup, + }, + { + path: "accounts.create", + args: [{ name: "Alt", templates: "private", builds: "private" }], + channel: IPC.accountsCreate, + }, + { + path: "accounts.update", + args: [{ + id: PROFILE_ID, + name: "Alt", + templates: "shared", + builds: "private", + }], + channel: IPC.accountsUpdate, + }, + { path: "accounts.archive", args: [PROFILE_ID], channel: IPC.accountsArchive }, + { path: "accounts.restore", args: [PROFILE_ID], channel: IPC.accountsRestore }, + { path: "accounts.delete", args: [PROFILE_ID], channel: IPC.accountsDelete }, + { path: "accounts.open", args: [[PROFILE_ID]], channel: IPC.accountsOpen }, + { path: "accounts.useSingle", args: [], channel: IPC.accountsUseSingle }, + { + path: "accounts.loadTemplates", + args: [], + channel: IPC.accountsTemplatesLoad, + }, + { + path: "accounts.saveTemplates", + args: [[{ path: "Skills/Alt.txt", contents: "OQCiUyo8AkVwR4KMMGAAAEAA" }]], + channel: IPC.accountsTemplatesSave, + }, { path: "buildLibrary.get", args: [], channel: IPC.buildLibraryGet }, { path: "buildLibrary.set", diff --git a/tests/unit/account-template-library.test.ts b/tests/unit/account-template-library.test.ts new file mode 100644 index 00000000..0cb531de --- /dev/null +++ b/tests/unit/account-template-library.test.ts @@ -0,0 +1,42 @@ +/** Shared template reconciliation preserves concurrent work and applies edits. */ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { reconcileAccountTemplates } from "../../src/main/core/account-template-library.js"; + +const A = "OQCiUyo8AkVwR4KMMGAAAEAA"; +const B = "OQCiUyo8AkVwR4KMMGAAAEAB"; +const C = "OQCiUyo8AkVwR4KMMGAAAEAC"; +const entry = (path: string, contents: string) => ({ path, contents }); + +test("merges unrelated shared-template edits", () => { + assert.deepEqual( + reconcileAccountTemplates( + [entry("Skills/A.txt", A)], + [entry("Skills/A.txt", A), entry("Skills/B.txt", B)], + [entry("Skills/A.txt", A), entry("Skills/C.txt", C)], + ), + [entry("Skills/A.txt", A), entry("Skills/B.txt", B), entry("Skills/C.txt", C)], + ); +}); + +test("a stale deletion cannot discard a concurrent edit", () => { + assert.deepEqual( + reconcileAccountTemplates( + [entry("Skills/A.txt", A)], + [entry("Skills/A.txt", B)], + [], + ), + [entry("Skills/A.txt", B)], + ); +}); + +test("two concurrent edits to one path preserve both contents", () => { + assert.deepEqual( + reconcileAccountTemplates( + [entry("Skills/A.txt", A)], + [entry("Skills/A.txt", B)], + [entry("Skills/A.txt", C)], + ), + [entry("Skills/A (conflict).txt", C), entry("Skills/A.txt", B)], + ); +}); diff --git a/tests/unit/credentials.test.ts b/tests/unit/credentials.test.ts index 73f8abdc..8d6fa532 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/multiple-accounts.test.ts b/tests/unit/multiple-accounts.test.ts new file mode 100644 index 00000000..01ffdbb1 --- /dev/null +++ b/tests/unit/multiple-accounts.test.ts @@ -0,0 +1,166 @@ +/** 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 { + addMultiProfile, + archiveMultiProfile, + createMultiWorkspace, + loadAccountMode, + loadMultiWorkspace, + quarantineAccountDocument, + removeArchivedMultiProfile, + restoreMultiProfile, + saveAccountMode, + saveMultiWorkspace, + updateMultiProfile, +} 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("quarantines a damaged document without rewriting its bytes", async () => { + const dir = await mkdtemp(join(tmpdir(), "gw-accounts-")); + const workspacePath = join(dir, "workspace.json"); + await writeFile(workspacePath, "{damaged"); + const backup = await quarantineAccountDocument(workspacePath); + assert.ok(backup); + assert.equal(await readFile(backup, "utf8"), "{damaged"); + assert.equal(await loadMultiWorkspace(workspacePath), null); + }); + + 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, + ); + }); + + 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); + const restored = restoreMultiProfile(archived, archived.profiles[1]!.id); + assert.equal(restored.profiles[1]!.archived, false); + const archivedAgain = archiveMultiProfile(restored, restored.profiles[1]!.id); + const removed = removeArchivedMultiProfile( + archivedAgain, + archivedAgain.profiles[1]!.id, + ); + assert.deepEqual(removed.profiles.map((profile) => profile.name), ["Primary"]); + }); +}); diff --git a/tests/unit/native-keychain.test.ts b/tests/unit/native-keychain.test.ts index adba95f2..9926fb8d 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", () => { diff --git a/tests/unit/paths.test.ts b/tests/unit/paths.test.ts index 7f3da07d..de4364df 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,12 @@ 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`, + 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`, @@ -63,6 +71,18 @@ 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`, + gameStorageClearRequest: `${root}/multi/profiles/${id}/clear-game-storage-on-start`, + }); + }); + 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. diff --git a/tests/unit/profile-runtime.test.ts b/tests/unit/profile-runtime.test.ts new file mode 100644 index 00000000..82aa0fff --- /dev/null +++ b/tests/unit/profile-runtime.test.ts @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + launchIssueForStage, + ProfileRuntimeStore, +} from "../../src/main/core/profile-runtime.js"; +import type { ProfileId } from "../../src/shared/multiple-accounts.js"; + +const id = (value: string) => value as ProfileId; + +describe("Multiple Accounts runtime state", () => { + it("tracks honest batch transitions without changing already-open accounts", () => { + const runtime = new ProfileRuntimeStore(); + const open = id("open"); + const first = id("first"); + const second = id("second"); + runtime.set(open, "running"); + + runtime.queue([open, first, second], (profileId) => profileId === open); + assert.equal(runtime.get(open).state, "running"); + assert.equal(runtime.get(first).state, "queued"); + assert.equal(runtime.get(second).state, "queued"); + + runtime.set(first, "opening"); + runtime.set(first, "checking"); + runtime.set(first, "running"); + assert.equal(runtime.get(first).state, "running"); + }); + + it("releases the untouched queue after canary failure", () => { + const runtime = new ProfileRuntimeStore(); + const canary = id("canary"); + const waiting = id("waiting"); + runtime.queue([canary, waiting], () => false); + runtime.set(canary, "failed", launchIssueForStage("validating")); + runtime.releaseQueued([waiting]); + + assert.deepEqual(runtime.get(canary), { + state: "failed", + launchIssue: "client-validation", + }); + assert.deepEqual(runtime.get(waiting), { state: "ready" }); + }); + + it("maps every failure stage to bounded player-safe vocabulary", () => { + assert.deepEqual( + ["preparing", "starting", "validating", "crashed", "unknown"].map( + (stage) => launchIssueForStage(stage as Parameters[0]), + ), + [ + "profile-preparation", + "window-startup", + "client-validation", + "renderer-crash", + "unknown", + ], + ); + }); +}); diff --git a/tests/unit/renderer-trust.test.ts b/tests/unit/renderer-trust.test.ts index d38c7b56..609bd987 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); + } + }); }); diff --git a/tests/unit/template-store.test.ts b/tests/unit/template-store.test.ts index d6ad2e19..aebed1aa 100644 --- a/tests/unit/template-store.test.ts +++ b/tests/unit/template-store.test.ts @@ -14,6 +14,7 @@ import { mutationInFlight, planImport, readTemplates, + replaceTemplateProjection, rescueStranded, strandedTemplates, templatePath, @@ -149,6 +150,22 @@ test("addresses an export the way an export folder is addressed", () => { ]); }); +test("replaces a profile projection with the canonical snapshot", async () => { + const fs = fakeFilesystem({ + [`${SKILLS_DIR}/Old.txt`]: SKILLS, + [`${SKILLS_DIR}/Folder/Old child.txt`]: OTHER_SKILLS, + }); + await replaceTemplateProjection(fs, [ + { path: "Equipment/PvP Set.txt", contents: EQUIPMENT }, + { path: "Skills/New.txt", contents: OTHER_SKILLS }, + ]); + assert.deepEqual(exportEntries(fs), [ + { path: "Equipment/PvP Set.txt", contents: EQUIPMENT }, + { path: "Skills/New.txt", contents: OTHER_SKILLS }, + ]); + assert.equal(fs.syncs, 1); +}); + test("re-importing the same folder does nothing at all", () => { const fs = fakeFilesystem({ [`${SKILLS_DIR}/Shockaxe.txt`]: SKILLS }); const plan = planImport(fs, [candidate()], "skip"); diff --git a/tests/unit/the-companion-kernel-is-compiled-once-per-build.test.ts b/tests/unit/the-companion-kernel-is-compiled-once-per-build.test.ts index 5ad3398d..fb091cb5 100644 --- a/tests/unit/the-companion-kernel-is-compiled-once-per-build.test.ts +++ b/tests/unit/the-companion-kernel-is-compiled-once-per-build.test.ts @@ -41,6 +41,8 @@ function rendererCheckout(): string { writeFileSync(file, contents); }; write("src/renderer/index.html", "\n"); + write("src/renderer/accounts.html", "\n"); + write("src/renderer/accounts.css", "css"); write("src/renderer/loading.js", "export {};\n"); write("src/renderer/gw-native.d.ts", "export {};\n"); write("src/renderer/favicon.ico", "ico"); @@ -52,6 +54,7 @@ function rendererCheckout(): string { write("src/renderer/images/logo.webp", "webp"); write("src/renderer/images/hero-poster.jpg", "jpeg"); write("src/renderer/images/hero-video.webm", "webm"); + write("apps/website/public/bg-reforged.jpg", "landscape"); write("src/renderer/.DS_Store", "local metadata"); write("src/renderer/images/local-note.txt", "untracked"); // The design system is a package input that does not live under src/renderer: @@ -98,6 +101,10 @@ describe("scripts/copy-renderer.mjs only copies assets", () => { readFileSync(path.join(root, "build/renderer/images/hero-video.webm"), "utf8"), "webm", ); + assert.equal( + readFileSync(path.join(root, "build/renderer/images/bg-reforged.jpg"), "utf8"), + "landscape", + ); // Not the preload: scripts/generate-preload.ts owns that output, splices // the canonical channel constants in and is the only producer of // build/preload/preload.cjs. @@ -116,11 +123,14 @@ describe("scripts/copy-renderer.mjs only copies assets", () => { .map((file) => path.relative(path.join(root, "build/renderer"), file)) .sort(); assert.deepEqual(relative, [ + "accounts.css", + "accounts.html", "favicon.ico", "favicon.png", "fonts/COPYING-QUALITYPE", "fonts/QTFrizQuad.otf", "harness.css", + "images/bg-reforged.jpg", "images/hero-poster.jpg", "images/hero-video.webm", "images/logo.webp", diff --git a/tests/unit/window-registry.test.ts b/tests/unit/window-registry.test.ts new file mode 100644 index 00000000..7ae44b29 --- /dev/null +++ b/tests/unit/window-registry.test.ts @@ -0,0 +1,85 @@ +/** 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("unregisters after Electron has made webContents unreadable", () => { + const registry = new WindowRegistry(); + let readable = true; + const win = { + get webContents() { + if (!readable) throw new Error("destroyed webContents was read"); + return { id: 1 }; + }, + isDestroyed: () => !readable, + }; + registry.register(win, { mode: "single", role: "game" }); + readable = false; + assert.doesNotThrow(() => 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); + }); +}); diff --git a/tests/unit/window-state.test.ts b/tests/unit/window-state.test.ts index 5871d938..7b23b9c9 100644 --- a/tests/unit/window-state.test.ts +++ b/tests/unit/window-state.test.ts @@ -6,6 +6,7 @@ import { join } from "node:path"; import { AppError } from "../../src/shared/errors.js"; import { defaultWindowState, + cascadeWindowState, fitWindowStateToDisplays, loadWindowState, parseWindowState, @@ -124,4 +125,21 @@ describe("window state", () => { mode: "normal", }); }); + + it("cascades new account windows by 32px and clamps them", () => { + const workArea = { x: 0, y: 24, width: 1600, height: 1000 }; + const base = defaultWindowState(workArea); + + assert.deepEqual(cascadeWindowState(base, 1, workArea).bounds, { + ...base.bounds, + x: base.bounds.x + 32, + y: base.bounds.y + 32, + }); + assert.deepEqual(cascadeWindowState(base, 20, workArea).bounds, { + x: workArea.x + workArea.width - base.bounds.width, + y: workArea.y + workArea.height - base.bounds.height, + width: base.bounds.width, + height: base.bounds.height, + }); + }); });