diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 1dea79bf..50a13dae 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -53,7 +53,7 @@ jobs: - name: Compile TypeScript + assets run: npm run build - # Fetch the umu-launcher zipapp into resources/umu (extraResources) for the Proton launcher (Р1). + # Fetch the umu-launcher zipapp into resources/umu (extraResources) for the Proton launcher. - name: Fetch umu-launcher run: npm run build:umu diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index 024f37af..f179af89 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -1,10 +1,10 @@ name: Build macOS # Build the macOS (Apple Silicon) distributable in the cloud, mirroring build-linux.yml. The runner is -# macos-14 — an arm64 image — because the build is arm64-only (Д7): Intel Macs get no artifact, and a +# macos-14 — an arm64 image — because the build is arm64-only: Intel Macs get no artifact, and a # universal build would additionally need a universal native `drivelist`. # -# The dmg is NOT signed with a Developer ID and NOT notarized (Д6 — no Apple Developer account), so it +# The dmg is NOT signed with a Developer ID and NOT notarized (no Apple Developer account), so it # does NOT self-update: the macOS build reports the updater as unsupported and the user re-downloads by # hand. See README (macOS section) for the Gatekeeper steps a first launch needs. # diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 00000000..4e4f3b5d --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,68 @@ +name: Check + +# Gate every pull request and every push to main on typecheck + lint + tests, on all three OSes. The +# build-*.yml workflows run the same steps, but only on a v* tag or by hand — so between releases nothing +# checked main, and rule violations (a bare `path.join` in a *.linux.ts module, which only fails on the +# Windows runner) reached main and sat there until the next release build went red. +# +# Windows is not optional here: it is the only runner where the `path.posix` rule (CLAUDE.md, platform +# layer) fails loudly. + +on: + pull_request: + push: + branches: + - main + +# A new push to the same PR supersedes the run in flight — three OSes × ~5 minutes is too much to spend +# on a commit nobody will look at. +concurrency: + group: check-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + name: ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + # Pinned to windows-2022 for the same reason as build-windows.yml: windows-latest ships Visual + # Studio 18, which the bundled node-gyp cannot recognize, so the native drivelist build fails + # during `npm ci`. + - windows-2022 + - macos-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + + # Node 22.12+ is required by electron@43 and @electron/rebuild@4 (their `engines`). `cache: npm` + # keeps ~/.npm between runs. + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: '22' + cache: 'npm' + + # Deliberately WITHOUT --ignore-scripts: the postinstall (electron-builder install-app-deps) builds + # the native drivelist, and the game-config-* / game-move-transaction tests reach it through + # game-config.ts → drive-watcher.ts. + - name: Install dependencies + run: npm ci + + - name: Type check + run: npm run typecheck + + - name: Lint + run: npm run lint + + - name: Unit tests + run: npm test + + # typecheck proves the types; the esbuild bundle of the renderer and the tsc emit of main are what + # ships, and until now they were only built on a release tag. + - name: Build + run: npm run build diff --git a/.gitignore b/.gitignore index a994f5bb..01db4f82 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ release/ # umu-launcher zipapp is fetched at build time (scripts/fetch-umu.mjs), never committed. resources/umu/ -# Build artifacts must never live next to their .ts sources in src/ (see audit C3). +# Build artifacts must never live next to their .ts sources in src/: a stale .js beside its .ts shadows +# the source for every tool that resolves extensionless imports. src/**/*.js src/**/*.js.map diff --git a/CLAUDE.md b/CLAUDE.md index 249dd21d..086af6db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,9 @@ mandate to rewrite what already works. ## Layers (do not blur) -- **main** owns all game logic (fs, registry, process control, FFI). **renderer** is stateless UI. +- **main** owns all game logic (fs, registry, process control, FFI). **renderer** holds no domain state: + UI state (a manifest draft, a menu stack, the boot sequence) lives inside the screen factories, and + everything about games, cards and settings is pushed from main via the `on*` subscriptions. - They talk **only over IPC**. The renderer never touches fs/registry; main never touches the DOM. - Preload bridges are typed and sandboxed (`contextIsolation: true`, `sandbox: true`). - A **pure** function BOTH sides must compute identically (no fs/electron either way) lives in @@ -30,6 +32,29 @@ mandate to rewrite what already works. browser, so a `node:*` import there breaks the build, not just the convention. See `src/shared/asset-move-names.ts` (move-to-card asset names, computed identically in main and renderer). +## Two state models: the session and per-game activities + +- **`AppState` is the game session only** (`idle | ready | configuringProton | syncing-in | launching | + running | syncing-out | error`): at most one game is launched, played and synced at a time. Its locks + live in `GameSequences` (`game-sequences.ts`). +- **Everything that runs in the background is a per-game activity** (`shared/activity.ts`, + `ActivityMap` keyed by game id), held by `ActivityRegistry` (`activity-registry.ts`) and pushed in full + on `activity:update` (seeded by `activity:request`). Writers: `SteamActivityWatch` + (`steam-activity-watch.ts`, Steam downloads / updates / removals of every available Steam game) and + `GameJobs` (`game-jobs.ts`, copy installs, installer runs, uninstalls, prefix cleanups, with their own + queues and session rules in the pure `startVerdict`). +- **A game never has both.** A game with an activity is not launched; the session's game gets no + activity (Steam updating it before launch must not hide "Launching..."). +- **Actions are addressed by game id** (`action:launch`, `action:uninstall`, `action:cancel-job`). + `GameActions` (`game-actions.ts`) resolves the target with the pure `resolveActionTarget` + (`action-target.ts`): with the session busy, another game may be installed or removed, never launched. +- **The renderer judges the game on screen by itself**: `screenActions` in `state-view.ts` decides Play, + Install, Uninstall, Cancel and Force close from the session, the browse model and that game's activity. + `#app[data-phase]` stays global; `data-busy`, `data-activity` and `data-play-locked` describe the game + on screen. +- **A managed exit goes through `QuitGate`** (`quit-gate.ts`): with jobs running, Quit / Shutdown / Reboot + ask first (`quit:confirm` / `quit:confirm-reply`, a native dialog when the window does not answer). + ## Error-handling convention Pick per situation, matching the existing patterns: @@ -48,11 +73,11 @@ Pick per situation, matching the existing patterns: ## Adding a new service -Follow the **interface-DI** shape of `StatsService` / `UpdaterService` (dependencies passed via a -typed `…Deps` interface), not the bare-primitive-constructor or free-function styles that predate it. -Interface-DI is the most testable: it lets a unit test inject fakes without electron/fs. Bootstrap the -service in `main.ts`; wire IPC through `GameController` (or the service's own `init()`, the way -`GameConfigService` and `MetadataService` register their channels). +Follow the **interface-DI** shape of `UpdaterService` / `NotificationsService` / `MetadataService` +(dependencies passed via a typed `…Deps` interface), not the bare-primitive-constructor or free-function +styles that predate it. Interface-DI is the most testable: it lets a unit test inject fakes without +electron/fs. Bootstrap the service in `main.ts`; wire IPC through `GameController` (or the service's own +`init()`, the way `GameConfigService` and `MetadataService` register their channels). ## Adding a new IPC channel @@ -63,7 +88,10 @@ The channel literal lives in **one** source of truth and is bridged with compile 3. Add the literal to `src/preload/preload.ts`'s `CHANNELS` map. It is `satisfies typeof IPC`, so a wrong value, a typo'd key AND a forgotten channel are all compile errors — there is one window and one preload, so the map has to be complete. -4. Wire the handler in `ipc.ts` (main) and consume it in the renderer. +4. Wire the handler in the owning service's `init()` (`settings-service.ts`, `game-config.ts`, + `file-picker-service.ts`, `metadata/service.ts`, … — `game-controller.ts` only for the card-session + channels) and consume it in the renderer. `test/ipc-channels.test.ts` also reads those `init()`s as + text and fails when a channel is registered twice: Electron refuses the second `handle` at runtime. The `test/ipc-channels.test.ts` suite guards the same invariant from the outside (it reads the preload sources as text) and would still catch it if a second window — and a second preload, back to @@ -99,7 +127,8 @@ build does not self-update. **All OS-specific behaviour lives behind the `Platfo - Add the capability to an interface in `platform/types.ts` (the bundle is `ProcessMonitor`, `SteamLocator`, `SteamShortcuts`, `GameProcessLauncher`, `SavePathResolver`, `PowerBackend`, - `RemovableMounter`, `resolveInstallDir`). + `RemovableMounter`, `resolveInstallDir`). The file is types only and imports nothing from the + implementations — `GameProcess`, `PowerAction` and `InstallDirResolver` live there for that reason. - Implement it in **all three** of `platform/win32.ts`, `platform/linux.ts` and `platform/darwin.ts` (linux Proton helpers live in `platform/*.linux.ts` / `umu.ts`; the macOS ones in `platform/*.darwin.ts`). `createPlatform(process.platform)` selects the bundle once at bootstrap in an explicit three-way branch @@ -133,14 +162,19 @@ build does not self-update. **All OS-specific behaviour lives behind the `Platfo it), a wrong separator does not fail loudly — it produces a wrong value. Quick check before pushing: `grep -rn "path\.\(join\|dirname\|basename\|resolve\)(" src/main/platform/*.{linux,darwin}.ts` + — and `test/platform-posix-paths.test.ts` is that grep with teeth (see "Mechanical guards"). ## Tests - Runner: **vitest** (`npm test`). Tests live in `test/`, run in plain Node with **no electron** - (`test/stubs/electron.ts` is aliased for the `electron` import — see `vitest.config.ts`). -- Testable = **pure / electron-free** modules. Modules that evaluate koffi FFI at import - (`game-launcher.ts`) are not importable in Node — extract pure logic into a util (as `launch-args.ts` - was) and test that. + (`test/stubs/electron.ts` is aliased for the `electron` import — see `vitest.config.ts`). `test/setup.ts` + points the file logger at a temp dir first, so a `log.warn` a test trips never lands in your real + launcher log (logger.ts would otherwise fall back to userData, as it does for the GUI). +- Testable = **pure / electron-free** modules. The koffi-bound win32 modules (`game-launcher.ts`, + `registry.ts`, `window-finder.ts`) DO import under vitest on every OS — the prebuilt addon loads and the + DLLs bind lazily — but their FFI branches cannot be exercised without Windows, so test them through + the pure logic split out beside them (as `launch-args.ts` was) and hand the process waits in through + a seam (`ProcessControl` in `test/game-controller.test.ts`). - Prefer covering the risky, data-touching functions: manifest validation/anti-traversal, stats merge, save-sync retry, argument quoting. - **DOM tests of the renderer's screen controllers live in `test/renderer/**`** and run under @@ -152,6 +186,9 @@ build does not self-update. **All OS-specific behaviour lives behind the `Platfo picker / online picker surfaces), and the translator is the real `createTranslator('en')`. Input is the `NavSurface` primitives called directly — no gamepad polling; the hover/veil branches are reachable through `hoverOver()` (they all sit behind the `mouse-asleep` class the fixture starts with). + **The `mouse-asleep` class on `` is a contract**: `idle.ts` is its only writer, every surface's + hover branch reads it with `classList.contains`, and the fixture starts with it on — do not replace it + with a per-screen `isAsleep()` dep. Four rules that bite: - **The screens that fetch their own data open ASYNCHRONOUSLY** — `filePicker.open()` awaits `listDir`, `gameSettings.open(id)` awaits the manifest read, so assert after `await flushAsync()`. `SettingsScreen` @@ -161,9 +198,14 @@ build does not self-update. **All OS-specific behaviour lives behind the `Platfo - **Load the fixture per test** (`beforeEach`), and create the controller after it: no controller removes its listeners, so a fixture shared across a file collects one live instance per test on the same nodes. - **`app.ts` stays out** — it touches `window.api` at module scope. - Covered so far: `screen-sidebar`, `osk`, `file-picker`, `settings-screen`, `game-settings-screen`. Still - uncovered and next in line for the same base: `controls.ts`, `online-picker.ts`, `library-screen.ts`, - `carousel.ts`. Anything needing real layout (`scrollHeight`, canvas) is still a manual check on the Deck. + Covered so far: `screen-sidebar`, `osk`, `file-picker`, `settings-screen`, `game-settings-screen`, + `controls` (its seams live in `controls-deps.ts`; input is a `keydown` on `window`, since the six + primitives are not on its public surface), `hero` (with `computePalette` mocked — the canvas decode is + the one thing happy-dom cannot do — so a test can decide WHEN a palette lands relative to a swap). + `online-picker` (its stateless nodes and captions live in `online-picker-view.ts`; the artwork api is + answered by hand, so a test decides when a page lands relative to a filter or section change). Still + uncovered and next in line for the same base: `library-screen.ts`, `carousel.ts`. Anything needing real layout (`scrollHeight`, + canvas) is still a manual check on the Deck. Upgrade note: `environmentMatchGlobs` is deprecated in vitest 3 and GONE in vitest 4 — an upgrade must move `test/renderer/**` to `test.projects` (or a per-file `@vitest-environment` docblock) or the suites will quietly run in Node again and fail on `document is not defined`. @@ -174,7 +216,31 @@ build does not self-update. **All OS-specific behaviour lives behind the `Platfo "fix" such a failure by rewriting the expectation with `path.join`: that makes the test assert whatever the code does and stops testing anything at all. -## Tooling (all run in CI before build) +## Mechanical guards + +Rules that live only in this file get broken between releases, so the ones that matter are enforced by +tests and lint, in the same source-as-text style as `test/daemon-imports.test.ts`: + +- **`path.posix` in the platform layer** — `test/platform-posix-paths.test.ts` reads every module under + `src/main/platform/` except `win32.ts` / `*.win32.ts` and fails on a bare `path.join` / `dirname` / + `basename` / `resolve` / `relative` / `normalize` / `isAbsolute` / `path.sep`. +- **`process.platform` outside `platform/`** — ESLint `no-restricted-syntax` over `src/main/**` + (`eslint.config.mjs`), with an explicit allowlist and a reason per file (bootstrap / electron-UI glue, + electron-updater environment detection, the win32-only FFI modules' self-guards). It is an `error`: a + behavioural branch belongs on a `Platform` interface, implemented for all three OSes. A per-line + `eslint-disable` is not the way out: the repo has none. +- **File and factory size ratchet** — `test/file-size-ratchet.test.ts` keeps a baseline of raw `wc -l` + per `src/**` file over 1000 lines and per screen factory (`createControls`, `createGameSettingsScreen`, + …). Nothing may grow past its entry; a file that shrank by more than 50 lines must have its entry + lowered; a file not in the map may not exceed 1000. When you grow one, split it instead. + +All three run on every PR and every push to `main` on Windows, Linux and macOS +(`.github/workflows/check.yml`), so a rule violation no longer waits for the next release build to surface. + +## Tooling + +The first three run in CI on every PR and push to `main` (`check.yml`, followed by `npm run build`) and +again before every release build. Prettier is deliberately NOT a gate. - `npm run typecheck` — strict `tsc`, no `any`. Covers `test/` as well as `src/`. - `npm run lint` — ESLint with type-aware rules (`no-non-null-assertion` — the "no `!`" rule, which @@ -182,5 +248,6 @@ build does not self-update. **All OS-specific behaviour lives behind the `Platfo `strict-boolean-expressions`), over `src` and `test`. Tests switch off `require-await` and `unbound-method` (both only ever fire on test doubles) and allow a `_`-prefixed unused parameter. - `npm test` — vitest. -- `npm run format` / `format:check` — Prettier (available for new code; the existing hand-aligned - files are intentionally not mass-reformatted). +- `npm run format` / `format:check` — Prettier, available for new code and not run in CI: the existing + hand-aligned files are intentionally not mass-reformatted, so `format:check` is red on most of the repo + by design. Format the file you are adding; do not reformat the one you are touching. diff --git a/README.md b/README.md index a65a5418..df3b8ece 100644 --- a/README.md +++ b/README.md @@ -92,11 +92,11 @@ launcher — see [Settings and Customize](#settings-and-customize). ## How it works 1. Playhook starts hidden in the tray. With no game card inserted, there is no window (unless you - enable *Always show the no-card screen* in Settings). + enable *Keep the launcher open without a card* in Settings). 2. Insert a card with a valid `game.json` and a window appears: the hero art as the background and a bottom bar with the **Play** button on the left, the game title in the middle and the **More** (⋯) - button on the right (state `ready`). If the manifest has no `heroImage`, a bundled wallpaper — or - your own, set in Settings — is used. + button on the right (state `ready`). If the manifest has no `heroImage`, a bundled wallpaper is + used. 3. With more than one game to show, the launcher opens on the **history carousel** instead: a row of game cards you flip through with **left/right**. The games on the inserted card come first (each marked with a dot — those you can launch right now), followed by the games you have played on this @@ -266,19 +266,21 @@ the Steam Deck they work in **Game Mode** as well as on the desktop. ### Settings -- **Updates** — automatic mode (*download and install* / *download, install manually* / *off*), the - **pre-release (beta)** channel, and a manual *Check for updates* with the current version. -- **Appearance** — theme (system / light / dark), **language** (system / English / Russian), and a - custom **background for the empty screen** (any image; *Reset* restores the bundled wallpaper). +- **Updates** — the current version and a *Check for updates* button, the automatic mode (*download + and install* / *download, install manually* / *off*) and the **pre-release (beta)** channel. On macOS, + where the app cannot update itself, only the status line is shown. +- **Language** — the interface language (system / English / Russian). - **General** — the gamepad summon hotkey, *keep the screen awake while the launcher is open*, - *always show the no-card screen* (instead of hiding to the tray), *disable silent installer mode* - (see [Install mode](#install-mode-heavy-games-on-slow-media)), and, on the Steam Deck, the Game Mode - auto-launch on card insertion. + *keep the launcher open without a card* (instead of hiding to the tray), *disable silent installer + mode* (see [Install mode](#install-mode-heavy-games-on-slow-media)), and, on the Steam Deck, the Game + Mode auto-launch on card insertion. +- **Game metadata** — an optional **SteamGridDB API key**: with it, *Find online* also offers covers and + backgrounds from SteamGridDB (see [Credits](#credits)). - **Audio** — the **navigation sound set** and the **background ambience** shipped with the app, a volume slider for each, and an *only global ambience* switch that makes the app's ambience win over whatever the card carries in `backgroundMusic`. The navigation sounds always come from the chosen set: a card cannot supply its own. -- **Advanced** — *Open logs*, *Open games folder* (the install-mode directory), *Reset to defaults*. +- **Reset to defaults** and **Close** at the bottom. The logs folder is opened from the tray menu. Settings live in `settings.json` next to the rest of the app state (`%APPDATA%\playhook\` on Windows, `~/.config/playhook/` on Linux, `~/Library/Application Support/playhook/` on macOS); a missing or @@ -444,8 +446,9 @@ How it works: - While the game isn't installed there is **no Play button**: **Install** lives in the **More (⋯)** menu. Pressing it asks for confirmation (the popup also shows the destination path, handy if the installer isn't fully silent), then runs the installer **silently** and shows an **"Installing..."** - indicator. When the executable appears **Play** comes back, and from then on the game launches - **from the install location on the PC**. + indicator. The install runs in the background (see + [Background installs and removals](#background-installs-and-removals)). When the executable appears + **Play** comes back, and from then on the game launches **from the install location on the PC**. - **`type: "copy"` — "move game to PC" without an installer.** `installer` points at the game's own directory on the card and Playhook simply **copies its contents** into the install dir (`executable` is relative to that copy; a leading `/` is stripped, so both spellings work). Nothing is @@ -566,8 +569,13 @@ for a normal game. - **Not installed → no Play button; "Install" sits in the More (⋯) menu.** Pressing it confirms ("Open Steam to install this game?") and opens `steam://install/` — Steam shows its own install dialog. Playhook does **not** block on a wizard: it stays on the screen, shows a non-blocking - **"Installing…"** indicator, and a background poll (~5s) brings **Play** back once Steam reports the - game fully installed. The window stays usable meanwhile — a Steam download can run for hours. + **"Installing…"** indicator, and a background poll of every Steam game (~5s) brings **Play** back once + Steam reports the game fully installed. The window stays usable meanwhile - a Steam download can run for + hours, several at once, each shown on its own game (see + [Background installs and removals](#background-installs-and-removals)). +- **Updates and pre-loads:** Steam updating an installed game reads **"Updating…"** and ends without an + "installed" notification; a pre-load of a game that is not released yet reads **"Pre-load complete"**. + A game Steam updates right before you launch it shows "Launching…", not "Updating…". - **Pause:** if you pause the download in Steam, the indicator becomes **"Installing paused on N%…"** (the percent is only available while paused — see limitations). While a download is in progress the **activity button (the spinning gear) opens Steam's Downloads page** so you can pause/resume there — @@ -584,6 +592,50 @@ If Steam isn't installed on the PC, Install/Play report **"Steam is not installe a URI. Steam's install and uninstall dialogs **cannot be made silent** — there is no `steam://` flag to suppress them (just one confirmation; the rest of the flow is automatic). +### Background installs and removals + +Installing and uninstalling never take the launcher over: every game carries its own **activity**, and +any number of games can be busy at once while the others stay playable. + +- **Per game, side by side.** Copy installs, card installers, uninstalls, prefix cleanups and Steam + downloads each belong to one game. Its card pulses in the carousel and the Library, and its detail + screen shows its own status: "Installing...", "Waiting to install...", "Updating...", + "Pre-load complete" and so on. The status shows from the moment Playhook starts, without opening the game. +- **While a game is running** you can bring Playhook up (Start+Back on Windows, the tray or window + switching in desktop Linux, Steam's window switcher in Game Mode) and install, cancel or uninstall + **other** games. A second game is never launched on top of the first: its Play stays visible but + inactive. A Steam install opens Steam's own dialog, which may appear over the game (not yet checked in + Game Mode). +- **Queues.** At most two copy installs and **one installer** run at once (parallel installers are not + safe on Windows: MSI's global lock fails a second one, and repacks eat the machine); the rest show + "Waiting to install...". An interactive installer (silent mode off), an elevated one (`runAsAdmin`) and a + removal that runs the game's own uninstaller elevated wait until you quit the game you are playing + ("Will install after you quit the game"), so no wizard or UAC prompt pops up over it. Under gamescope + (Game Mode) no installer and no winetricks prefix setup starts while a game runs at all - the game has + the only window there. +- **Cancel:** More (⋯) → **Cancel installation** stops a queued or running install of the game on screen. + A stopped copy leaves nothing behind: the files are staged in `.partial` and moved into place + only once complete. +- **Notifications instead of a popping window.** A finished install or removal no longer raises the + launcher; it is reported in the notifications (a failure too, with its reason - the error popup shows + only while the launcher has focus). +- **Quitting with work in progress** - Quit, Shutdown or Reboot, from the launcher or the tray - asks + first; confirming cancels the running jobs and cleans up after them. If the launcher window does not + answer, a native dialog asks instead. A launcher killed outright (crash, logout) sweeps leftover staging + folders on the next start, and an interrupted installer run never leaves a game that looks installed. +- **Pulling the card** stops the installs that read from it (reported in the notifications); removals + target the PC and finish. + +Tuning (environment variables, read at start): + +| Variable | Default | What it sets | +|---|---|---| +| `PLAYHOOK_STEAM_POLL_MS` | `5000` | How often Steam's `.acf` state of every Steam game is polled | +| `PLAYHOOK_MAX_PARALLEL_COPY_INSTALLS` | `2` | Copy installs running at once (reading a card while a game runs from it competes for it) | +| `PLAYHOOK_MAX_PARALLEL_INSTALLERS` | `1` | Installer runs at once - more is at your own risk on Windows | +| `PLAYHOOK_QUIT_CONFIRM_ACK_MS` | `2000` | How long the launcher window has to show the quit question before the native dialog takes over | +| `PLAYHOOK_QUIT_GRACE_MS` | `3000` | How long a quit waits for running jobs to clean up | + ### Statistics: one card, many PCs Statistics (hours / last played / launch count) are **unified across machines** with the card as @@ -696,6 +748,9 @@ These are ignored on Windows, so a dual-platform card can carry them safely: yanked into Playhook on every entry would be obnoxious. Launch it from the library in that case. An ordinary USB stick or a card without `game.json` never triggers it. The whole behaviour can be turned off in **Settings → General**. +- **Installing beside a running game:** switch to Playhook with Steam's window switcher, start installs or + removals of other games, and switch back. Installers and winetricks prefix setups wait until you quit + the game (gamescope gives the game the only window); a copy whose prefix is already set up runs at once. - **No autostart in Game Mode** — the app lives as a non-Steam game, started by Steam (or by the service above). In Desktop Mode it writes a `~/.config/autostart/playhook.desktop` entry instead. @@ -878,7 +933,9 @@ Release flow: That is the default; **Settings → Updates** lets the user pick *download and install automatically*, *download automatically, install manually*, or *off (check manually)*, opt into the **pre-release -(beta)** channel, and run a check by hand. +(beta)** channel, and run a check by hand. *Restart & install* on a downloaded update runs the +NSIS installer **silently** (no wizard window) and relaunches Playhook when it is done; the first +install still shows the regular wizard. Notes: @@ -897,7 +954,7 @@ On **Windows** and **macOS** the app registers itself via Login Items*). - It always starts hidden in the tray (no flag needed): the window appears only when a valid game - card is detected — unless *Always show the no-card screen* is enabled in Settings. + card is detected — unless *Keep the launcher open without a card* is enabled in Settings. - Guaranteed for the **NSIS installation**; for **portable** it is best-effort (the path to the exe may change). - To disable: *Settings → Apps → Startup* in Windows. @@ -916,7 +973,7 @@ exists while you are in Game Mode; **Remove from Steam** deletes it. The main process writes a timestamped log, split **per calendar day** into `%APPDATA%\playhook\logs\main-YYYY-MM-DD.log` (`~/.config/playhook/logs/` on Linux, `~/Library/Application Support/playhook/logs/` on macOS); files older than -**14 days** are pruned on startup. Open the folder from **Settings → Advanced → Open logs**. +**14 days** are pruned on startup. Open the folder from the tray menu (**Open logs**). It records card insertions, manifest validation, the stats reconcile / card-copy result, and launch/exit — useful when a save or stats copy to the card silently fails. @@ -977,14 +1034,29 @@ launch/exit — useful when a save or stats copy to the card silently fails. ``` src/ main/ # all work with the FS/processes/disks (Electron main) + metadata/ # "Find online": the MetadataProvider seam and one module per source (Steam, GOG, ...) platform/ # everything OS-specific (win32 / linux + Proton, umu / darwin) preload/ # the typed contextBridge bridge renderer/ # UI + gamepad/keyboard input (launcher, settings, customize — no Node) - shared/ # shared contract of types/IPC channels + the i18n dictionaries + shared/ # shared contract of types/IPC channels + i18n/ # the en / ru dictionaries and plural rules test/ # vitest suites (plain Node, no electron) + renderer/ # DOM tests of the screens, run under happy-dom against the real index.html + stubs/ # the inert `electron` stub the node suites are aliased to +scripts/ # build-time helpers, plain ESM (see below) ``` -npm scripts: `typecheck`, `lint`, `test`, `format`, `build`, `start`, `rebuild`, `build:umu`, `dist`. +npm scripts: `typecheck`, `lint`, `test`, `format`, `format:check`, `build` (= `build:renderer`, +`build:app`, `build:main`, `build:assets`), `build:umu`, `start`, `rebuild`, `dist`; `postinstall` runs +`electron-builder install-app-deps`. + +`scripts/*.mjs`: + +- `copy-assets.mjs` — copies html/css/fonts/audio into `dist/` after tsc and writes `dist/audio/index.json`. +- `audio-index.mjs` — the pure, unit-tested helpers `copy-assets.mjs` uses to list sound sets and ambience. +- `fetch-umu.mjs` — downloads the pinned umu-launcher zipapp into `resources/umu/` for the Linux build. +- `after-pack.mjs` — electron-builder `afterPack` hook (Linux): wraps the executable so Game Mode gets `--no-sandbox`. + Contributor conventions (layers, error handling, adding an IPC channel, the daemon's electron-free import graph) live in [`CLAUDE.md`](CLAUDE.md). @@ -994,7 +1066,8 @@ import graph) live in [`CLAUDE.md`](CLAUDE.md). PRs welcome. The codebase is **strict TypeScript** (no `any`, no non-null `!`, explicit return types, functional style). Please run `npm run typecheck`, `npm run lint` and `npm test` before opening a PR — -CI runs all three, on Windows, Linux **and** macOS. +CI runs all three on every PR, on Windows, Linux **and** macOS. Prettier (`npm run format`) is there for +new files, not a gate: the existing hand-aligned sources are intentionally left as they are. --- @@ -1025,8 +1098,8 @@ CI runs all three, on Windows, Linux **and** macOS. client (the card is just a pointer by `appid`). For a **non-Steam** wrapper/launcher `.exe`, point it at the exe and use [`watchProcesses`](#rules-and-security-the-card-is-untrusted-input) so exit detection works. -- **Is there a Russian UI?** Yes — *Settings → Appearance* has English and Russian (plus a light/dark/system - theme), and by default the app follows the system language. +- **Is there a Russian UI?** Yes — *Settings → Language* has English and Russian, and by default the app + follows the system language. --- diff --git a/assets/playhook-wallpaper.jpg b/assets/playhook-wallpaper.jpg index d2f0742b..251d7ee4 100644 Binary files a/assets/playhook-wallpaper.jpg and b/assets/playhook-wallpaper.jpg differ diff --git a/electron-builder.yml b/electron-builder.yml index 4031a3d4..2b9a6c70 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -18,7 +18,7 @@ files: # drivelist is a native module. electron-builder usually detects native code and # unpacks it from asar on its own, but we pin an explicit unpack so the .node binary is -# available in the packaged build (verify on a real nsis/portable build — step 0/11 of the plan). +# available in the packaged build (verify on a real nsis/portable build). asar: true asarUnpack: - "**/node_modules/drivelist/**" @@ -39,7 +39,7 @@ nsis: # any distro, and electron-updater supports AppImage in-place updates (blockmap deltas). `icon.png` is the # 256×256 app icon (electron-builder may warn about size but it works). `category: Game` sets the .desktop # category. The native drivelist/koffi unpack above applies to Linux too (koffi is unused there but stays -# a dependency of the win32 build). The umu-launcher zipapp is bundled via extraResources (added in Э4). +# a dependency of the win32 build). The umu-launcher zipapp is bundled via extraResources. linux: icon: assets/icon.png category: Game @@ -51,30 +51,30 @@ linux: artifactName: ${productName}.${ext} target: - AppImage - # Bundle the umu-launcher zipapp OUTSIDE asar (it is run as `python3 umu-run …` — Р1). Fetched into + # Bundle the umu-launcher zipapp OUTSIDE asar (it is run as `python3 umu-run …`). Fetched into # resources/umu by `npm run build:umu` (scripts/fetch-umu.mjs) before the Linux pack. Lands at # /umu/umu-run in the AppImage. extraResources: - from: resources/umu to: umu -# macOS build (arm64 only — Д7: the runner and the developer machine are both Apple Silicon, and a +# macOS build (arm64 only: the runner and the developer machine are both Apple Silicon, and a # universal build would additionally require a universal `drivelist`). The dmg is what a user downloads; # the zip is there because electron-updater's macOS channel expects one, and it is the easier artifact to # script around. # -# NOT signed with a Developer ID and NOT notarized: there is no Apple Developer account (Д6). `identity: -` +# NOT signed with a Developer ID and NOT notarized: there is no Apple Developer account. `identity: -` # asks for an AD-HOC signature, which is not the same as skipping signing: on Apple Silicon an app whose # signature is broken (which repacking a signed Electron binary does) refuses to launch at all, so the # ad-hoc one is what makes the build runnable once the user clears Gatekeeper. Without a Developer ID the -# first launch still goes through System Settings → Privacy & Security → "Open Anyway" (Р3), and the app +# first launch still goes through System Settings → Privacy & Security → "Open Anyway", and the app # does not auto-update (Squirrel.Mac requires a real signature — see updater.ts). # # `extendInfo` is NOT optional: the default Electron Info.plist declares neither key, and macOS silently # denies the capability when its usage string is missing — Apple Events would fail with -1743 in the # PACKAGED build only (in dev the responsible process is the terminal, which already has consent, so -# leaving these out yields a false green). NSAppleEvents backs the power menu (Д4); NSRemovableVolumes -# backs reading a card under /Volumes (Р7). +# leaving these out yields a false green). NSAppleEvents backs the power menu; NSRemovableVolumes +# backs reading a card under /Volumes. # # koffi stays in the mac bundle on purpose: `gamepad-global.ts` does a top-level `import koffi` on every # OS, so dropping it would break the app at startup. The `asarUnpack` above already covers it — do not diff --git a/eslint.config.mjs b/eslint.config.mjs index 49c0c885..fbbfd2e0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,5 +1,5 @@ -// Flat ESLint config (audit I4). Type-aware linting over src/ with the high-value async-safety rules -// the audit calls out: no-floating-promises / no-misused-promises catch forgotten awaits, and +// Flat ESLint config. Type-aware linting over src/ with the high-value async-safety rules: +// no-floating-promises / no-misused-promises catch forgotten awaits, and // strict-boolean-expressions catches implicit nullable/number truthiness. eslint-config-prettier is // applied last so no lint rule fights the formatter. Tests are linted too (they are part of the // typechecked program), minus two rules that only ever fire on their fakes; build output is not. @@ -41,6 +41,43 @@ export default tseslint.config( ], }, }, + { + // CLAUDE.md's platform-layer rule ("all OS-specific behaviour lives behind the `Platform` bundle, not + // scattered `process.platform` checks"), enforced. The allowlist below is every file where a direct + // check is the RIGHT thing, each with its reason; anything else is a behavioural branch that belongs + // on a `Platform` interface — the win32-only helpers (game-launcher.ts, registry.ts, + // uninstaller.win32.ts) are reached only through the win32 bundle, so they need no guard of their + // own. A per-line `eslint-disable` is not the way out — the repo has none, and that is worth keeping. + files: ['src/main/**/*.ts'], + ignores: [ + // The platform layer itself: this is where the OS is meant to be asked. + 'src/main/platform/**', + // Bootstrap and electron-UI glue that select the bundle or an OS-specific electron behaviour once + // (application menu, dock hide, tray icon format, simple-fullscreen, log/config directories). + 'src/main/main.ts', + 'src/main/window.ts', + 'src/main/tray.ts', + 'src/main/logger.ts', + 'src/main/config-paths.ts', + // electron-updater environment detection (AppImage vs macOS) — about the updater's runtime, not + // about a game. + 'src/main/updater.ts', + // Self-guards of the win32-only FFI modules: they must refuse to bind koffi anywhere but Windows + // BEFORE any Platform object exists. + 'src/main/foreground.ts', + 'src/main/window-finder.ts', + ], + rules: { + 'no-restricted-syntax': [ + 'error', + { + selector: "MemberExpression[object.name='process'][property.name='platform']", + message: + 'OS-specific behaviour belongs on a `Platform` interface (src/main/platform/types.ts), not on a process.platform check here. See CLAUDE.md "Platform layer".', + }, + ], + }, + }, { files: ['test/**/*.ts'], rules: { diff --git a/package-lock.json b/package-lock.json index a2d0ea15..37144d96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "playhook", - "version": "0.8.1", + "version": "0.9.0-alpha.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "playhook", - "version": "0.8.1", + "version": "0.9.0-alpha.11", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 97c997ed..12d04e1f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "playhook", - "version": "0.8.1", + "version": "0.9.0-alpha.11", "description": "Background launcher that detects installed game on external device, syncs saves and tracks playtime. Bring console vibes to your PC.", "private": true, "main": "dist/main/main.js", diff --git a/scripts/audio-index.mjs b/scripts/audio-index.mjs index 16758b05..31994995 100644 --- a/scripts/audio-index.mjs +++ b/scripts/audio-index.mjs @@ -1,5 +1,5 @@ // Pure helpers for building dist/audio/index.json — the bundled sound-set + ambience listing the -// settings window reads at runtime (one readFile) instead of a readdir over the asar, which has no +// Settings screen reads at runtime (one readFile) instead of a readdir over the asar, which has no // precedent in this codebase and whose withFileTypes/isDirectory was historically weak in the asar shim. // Side-effect-free so a unit test can exercise the .DS_Store / incomplete-set / non-audio filtering // without touching the filesystem. diff --git a/scripts/copy-assets.mjs b/scripts/copy-assets.mjs index 18626582..0f69fd34 100644 --- a/scripts/copy-assets.mjs +++ b/scripts/copy-assets.mjs @@ -40,7 +40,7 @@ for (const name of icons) { await cp(resolve(root, 'audio'), resolve(outDist, 'audio'), { recursive: true }); // Enumerate the sound sets + ambience tracks HERE (real FS, where readdir/withFileTypes always works) and -// write dist/audio/index.json, so the settings window reads one file instead of a readdir over the asar — +// write dist/audio/index.json, so the Settings screen reads one file instead of a readdir over the asar — // which has no precedent in this codebase. .DS_Store and incomplete/non-audio entries are filtered out. const uiDir = resolve(root, 'audio/ui'); const ambienceDir = resolve(root, 'audio/ambience'); diff --git a/scripts/fetch-umu.mjs b/scripts/fetch-umu.mjs index cd9c6877..a5608be9 100644 --- a/scripts/fetch-umu.mjs +++ b/scripts/fetch-umu.mjs @@ -1,4 +1,4 @@ -// Fetches the umu-launcher zipapp into resources/umu/ for the Linux (Proton) build (Р1). The zipapp is a +// Fetches the umu-launcher zipapp into resources/umu/ for the Linux (Proton) build. The zipapp is a // ~420 KB python zipapp (umu-run) + umu_run.py; it is NOT committed to the repo — this runs before the // Linux electron-builder pack (see build-linux.yml / `npm run build:umu`). Uses curl + tar (present on // macOS and the ubuntu CI runner). Idempotent: skips the download if resources/umu/umu-run already exists. diff --git a/src/main/action-target.ts b/src/main/action-target.ts new file mode 100644 index 00000000..ce0c2f6e --- /dev/null +++ b/src/main/action-target.ts @@ -0,0 +1,42 @@ +/** What a Play / Install or Uninstall pressed for one game id resolves to. */ +export type ActionTarget = + | 'launch-or-install-selected' + | 'select-then-act' + | 'install-only' + | 'uninstall-only' + | 'resume-session' + | 'refuse'; + +export interface ActionTargetInput { + readonly action: 'launch' | 'uninstall'; + /** The game the action was pressed for. */ + readonly id: string; + /** The game AppState is about (the selection), or null. */ + readonly selectedId: string | null; + /** A session is in flight: launching, running, syncing, or a sequence still unwinding. */ + readonly sessionBusy: boolean; + /** The game that session is about, or null. */ + readonly sessionGameId: string | null; + /** The target already has an activity (an install, a removal, a Steam download). */ + readonly hasActivity: boolean; + /** The target's source is available: a local game always, a card game only with its card in. */ + readonly sourceAvailable: boolean; + readonly reloadInFlight: boolean; +} + +/** + * Where an action for game `id` goes. With the session free it acts on the selected game as it always + * did, selecting the target first when it is another one. With a session in flight the session's own game + * only resumes, and any other game may be installed or removed but never launched: one game runs at a time. + * A game that is busy, whose card is gone, or that is being reloaded is refused. + */ +export function resolveActionTarget(input: ActionTargetInput): ActionTarget { + if (input.hasActivity || !input.sourceAvailable || input.reloadInFlight) return 'refuse'; + if (!input.sessionBusy) { + if (input.id !== input.selectedId) return 'select-then-act'; + return input.action === 'launch' ? 'launch-or-install-selected' : 'uninstall-only'; + } + if (input.id === input.sessionGameId) + return input.action === 'launch' ? 'resume-session' : 'refuse'; + return input.action === 'launch' ? 'install-only' : 'uninstall-only'; +} diff --git a/src/main/activity-registry.ts b/src/main/activity-registry.ts new file mode 100644 index 00000000..3b33cab3 --- /dev/null +++ b/src/main/activity-registry.ts @@ -0,0 +1,60 @@ +import { ipcMain } from 'electron'; +import { IPC } from '../shared/types'; +import { sameActivity, type ActivityMap, type GameActivity } from '../shared/activity'; + +type ActivityListener = (activities: ActivityMap) => void; + +/** Holds the background operation of every game by id and announces each real change as a full snapshot. */ +export class ActivityRegistry { + private activities: ActivityMap = {}; + private readonly listeners = new Set(); + + /** Answers the renderer's seed request and pushes every change to the window as a full snapshot. */ + init(send: (channel: string, activities: ActivityMap) => void): void { + ipcMain.handle(IPC.activityRequest, (): ActivityMap => this.snapshot()); + this.subscribe((activities) => send(IPC.activityUpdate, activities)); + } + + /** The activity of `id`, or undefined when the game is free. */ + get(id: string): GameActivity | undefined { + return this.activities[id]; + } + + /** Whether `id` carries any activity. */ + has(id: string): boolean { + return this.activities[id] !== undefined; + } + + /** Every game's activity right now. */ + snapshot(): ActivityMap { + return this.activities; + } + + /** Sets the activity of `id`; a value equal to the current one changes nothing and announces nothing. */ + set(id: string, activity: GameActivity): void { + if (sameActivity(this.activities[id], activity)) return; + this.activities = { ...this.activities, [id]: activity }; + this.emit(); + } + + /** Frees `id`; a game that carries no activity changes nothing and announces nothing. */ + clear(id: string): void { + if (this.activities[id] === undefined) return; + this.activities = Object.fromEntries( + Object.entries(this.activities).filter(([key]) => key !== id), + ); + this.emit(); + } + + /** Calls `listener` with the full snapshot after every change. Returns the unsubscribe. */ + subscribe(listener: ActivityListener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + private emit(): void { + for (const listener of this.listeners) listener(this.activities); + } +} diff --git a/src/main/app-settings.ts b/src/main/app-settings.ts index dcf5f083..c2f3f217 100644 --- a/src/main/app-settings.ts +++ b/src/main/app-settings.ts @@ -15,13 +15,10 @@ const settingsObject = z.object({ // `autoUpdate` mid-write) still validates instead of failing the WHOLE parse → a full reset to defaults. // The value mirrors DEFAULT_SETTINGS. schemaVersion stays strict on purpose (see the note above the class). autoUpdate: z.enum(['download', 'download-install', 'off']).default('download-install'), - // Kept in the schema so an older settings.json that still carries a chosen theme parses (and so the - // key survives a round trip), but the value is NORMALIZED to 'system' on read: the Settings screen has - // no theme selector any more, and no window left that reads one: the launcher paints itself from the - // card's own palette. - theme: z.enum(['system', 'light', 'dark']).default('system'), - // Language mirrors theme: `.default('system')` so an older settings.json without the field stays valid - // (no schemaVersion bump / migration needed). + // `.default('system')` so an older settings.json without the field stays valid (no schemaVersion bump / + // migration needed). A `theme` key an older file still carries is dropped by z.object's default strip: + // the Settings screen has no theme selector any more, and no window left that reads one — the launcher + // paints itself from the card's own palette. language: z.enum(['system', 'en', 'ru']).default('system'), allowPrerelease: z.boolean().default(false), summonHotkeyEnabled: z.boolean().default(true), @@ -62,7 +59,7 @@ const settingsObject = z.object({ // The user's SteamGridDB key. `.default('')` migrates an older settings.json without the field (no // schemaVersion bump); an empty string is the normal state — the metadata feature just runs Steam-only. steamGridDbApiKey: z.string().default(''), -}); +}) satisfies z.ZodType; /** * `alwaysShowEmptyScreen` was renamed to `keepOpenWithoutCard` when the screen it was named after went @@ -90,7 +87,6 @@ const settingsSchema = z.preprocess((raw) => { export const DEFAULT_SETTINGS: AppSettings = { schemaVersion: 1, autoUpdate: 'download-install', - theme: 'system', language: 'system', allowPrerelease: false, summonHotkeyEnabled: true, @@ -113,6 +109,10 @@ export class AppSettingsStore { // firing a burst of patch()) can't interleave read-modify-write and lose updates, and never race on the // shared `${settingsPath}.tmp` file. Reads stay OFF the queue (a queued op reads directly — see enqueue). private tail: Promise = Promise.resolve(); + // The last parse, keyed by the file's mtime and size: settings are read from a dozen places per browse, + // and a corrupt file would otherwise be re-parsed — and re-warned about — on every one of them. The + // stamp changes on any write, ours or the user's, so a stale snapshot is never served. + private cached: { readonly stamp: string; readonly value: AppSettings } | null = null; /** * @param baseDir where settings.json lives (the GUI passes app.getPath('userData')). @@ -150,11 +150,24 @@ export class AppSettingsStore { /** * Reads settings; returns the default when the file is missing or corrupted (a warn is logged on - * corruption). `theme` is normalized to 'system' regardless of what the file holds — see the schema. + * corruption). */ async read(): Promise { - const parsed = await readJsonValidated(this.settingsPath, settingsSchema, DEFAULT_SETTINGS); - return { ...parsed, theme: 'system' }; + const stamp = await this.stamp(); + if (stamp !== null && this.cached?.stamp === stamp) return this.cached.value; + const value = await readJsonValidated(this.settingsPath, settingsSchema, DEFAULT_SETTINGS); + this.cached = stamp === null ? null : { stamp, value }; + return value; + } + + /** `mtime:size` of settings.json, or null when it cannot be stat'ed (missing, or a racing write). */ + private async stamp(): Promise { + try { + const stat = await fse.stat(this.settingsPath); + return `${stat.mtimeMs}:${stat.size}`; + } catch { + return null; + } } /** The actual atomic write — called ONLY from inside a queued op, so it never enqueues (would deadlock). */ @@ -168,6 +181,7 @@ export class AppSettingsStore { this.onWriteFailed?.(cause); throw cause; } + this.cached = null; this.onChange?.(next); } diff --git a/src/main/asset-file-names.ts b/src/main/asset-file-names.ts index d8d7a8d9..039e94f0 100644 --- a/src/main/asset-file-names.ts +++ b/src/main/asset-file-names.ts @@ -1,6 +1,5 @@ // The file name an asset picked from the user's filesystem gets INSIDE the app's own storage — the PC -// library's `assets/`, and the history staging directory that reuses the same rules (see the -// history-config plan, Р9). +// library's `assets/`, and the history staging directory that reuses the same rules. // // Two properties matter and they pull against each other. The name comes from a foreign filesystem, so // everything outside a conservative ASCII set must go (traversal, leading dots, exFAT-hostile characters diff --git a/src/main/asset-import.ts b/src/main/asset-import.ts index a04df8f6..a022fbc8 100644 --- a/src/main/asset-import.ts +++ b/src/main/asset-import.ts @@ -1,5 +1,5 @@ // The refusals every asset import passes through — the PC library's `assets/`, and the history's staging -// directory that reuses them (see the history-config plan, Р9). +// directory that reuses them. // // They were implicit while the only way in was a native dialog whose filters the OS enforced; the // in-launcher picker names the path from the renderer instead, so the limits are stated here, in the one diff --git a/src/main/asset-reader.ts b/src/main/asset-reader.ts index 51c5b731..38f073c4 100644 --- a/src/main/asset-reader.ts +++ b/src/main/asset-reader.ts @@ -4,7 +4,8 @@ // delivery (setHero/setCardMusic push to the window); this class only reads bytes and encodes them. import path from 'node:path'; import fse from 'fs-extra'; -import { type HeroAssets, type ResolvedManifest, type SfxName, type SfxSet } from '../shared/types'; +import { type HeroAssets, type SfxName, type SfxSet } from '../shared/types'; +import type { ResolvedManifest } from './manifest-types'; import { log } from './logger'; import { describe } from './util'; @@ -31,7 +32,7 @@ const AUDIO_MIME: Readonly> = { /** * Supported image / audio file extensions WITHOUT the leading dot, derived from the MIME maps above so * there is a single source of truth. The manifest editor accepts a picked file against these (see - * game-config.ts acceptPickedPaths) — keeping the "what can be a hero image / a sound" answer in + * pick-rejection.ts) — keeping the "what can be a hero image / a sound" answer in * lockstep with what this reader actually decodes. */ export const IMAGE_EXTENSIONS: readonly string[] = Object.keys(IMAGE_MIME).map((ext) => ext.slice(1)); @@ -44,7 +45,7 @@ export const AUDIO_EXTENSIONS: readonly string[] = Object.keys(AUDIO_MIME).map(( * An extension this reader does not know is REFUSED rather than served as `application/octet-stream`. * That fallback used to be harmless (only manifest-referenced files reached it), but the in-launcher * picker lets the renderer name the path — and "read any file on the machine as base64" is exactly what - * the octet-stream branch would have granted (see the plan, Р5.1). + * the octet-stream branch would have granted. */ export async function readImageDataUrl(filePath: string): Promise { const mime = IMAGE_MIME[path.extname(filePath).toLowerCase()]; diff --git a/src/main/browse-presenter.ts b/src/main/browse-presenter.ts new file mode 100644 index 00000000..149463a7 --- /dev/null +++ b/src/main/browse-presenter.ts @@ -0,0 +1,270 @@ +// What the launcher window is SHOWING, and the pushes that keep it so: the selected card game's hero and +// music, the bundled UI sound set, the default ambience, the carousel row, and the browse cursor with its +// debounced hero/music reads. Split out of GameController, which decides WHAT to show (which game is +// selected, where the cursor lands) and hands the result here; this holds the last value per channel so +// the renderer's `*:request` invokes can be answered, and owns the debounce + ticket that keep a fast flip +// through the carousel from reading the disk once per step. +import { + IPC, + type BrowseInfo, + type GameLibrary, + type HeroAssets, + type SfxSet, +} from '../shared/types'; +import type { ResolvedManifest } from './manifest-types'; +import { type AssetReader } from './asset-reader'; +import { type BrowseAssets } from './library-store'; + +// How long the browsed game's HEAVY assets (hero images, music — megabytes of data URL each) wait before +// being read. The light BrowseInfo goes out immediately, so the title/status/stats track the carousel +// with no lag; only the expensive half is debounced, and a burst of moves reads the disk once. +// +// It must outlast the GAP the renderer leaves between two chained auto-moves — releasing the pad for a +// beat and pressing again (AUTO_CHAIN_MS + NAV_REPEAT_MS in auto-repeat.ts, ~310 ms). Shorter than that +// and every such gap starts a megabyte-sized read plus a base64 encode for a game the user is already +// flipping past, which is what made a rapid press-release-press stutter. The renderer holds the swap for +// the same span (FLIP_SETTLE_MS in app.ts), so the two wait side by side rather than one after the other +// — this costs nothing on a single step. Keep the three in step if any of them changes. +const BROWSE_ASSETS_DEBOUNCE_MS = 320; + +export interface BrowsePresenterDeps { + /** Reads card assets (hero/audio/wallpaper) into data URLs. */ + readonly assets: AssetReader; + /** Pushes one message to the launcher window (a no-op while there is none). */ + readonly send: (channel: string, payload: unknown) => void; + /** The manifest of a game that can be acted on right now, by id — null for a history-only entry. */ + readonly findManifest: (id: string) => ResolvedManifest | null; + /** The history's copy of a game's hero/music (the LibraryStore read). */ + readonly readBrowseAssets: (id: string) => Promise; + /** The "only global ambience" setting, read live. */ + readonly onlyGlobalAmbient: () => Promise; +} + +export class BrowsePresenter { + // The inserted card's background music, sent on its own channel (not on every AppState) — it is the + // card's only audio contribution. Null when there is no card, or when "only global ambience" mutes it. + private currentCardMusic: string | null = null; + // The bundled UI sound set chosen in Settings — the only source of UI sounds there is, on every screen. + // Read once at init (warmSfxSet); null until then. + private currentSfxSet: SfxSet | null = null; + // The default ambience data URL, delivered on its own channel (independent of the card's music). The + // renderer prioritizes a card's own music over this and crossfades between them. Null = no ambience. + private currentAmbient: string | null = null; + // Hero images for the current card, sent on their own channel (not on every AppState) — see HeroAssets. + private currentHero: HeroAssets | null = null; + // The light carousel list ({id,title,active}) — the inserted card's games plus the play history — in + // display order, pushed on every change (insert / removal / finished session / eviction). Null when + // there is nothing at all to show. The artwork travels separately, per card, on library:grid-request. + private currentLibrary: GameLibrary | null = null; + // What is on screen (see BrowseInfo): the truth for the title/stats/hero/music, INDEPENDENT of AppState + // — which describes one game's process and cannot represent "a history game while no card is in", nor + // "browsing game B while game A installs". Null only when there is neither a card nor any history. + private currentBrowse: BrowseInfo | null = null; + // Monotonic ticket for browse-asset reads: only the newest may push (see pushBrowseAssets). + private browseAssetsSeq = 0; + // Pending read of the browsed game's hero/music (see BROWSE_ASSETS_DEBOUNCE_MS). + private browseAssetsTimer: ReturnType | null = null; + + constructor(private readonly deps: BrowsePresenterDeps) {} + + // ── The last value per channel (the `*:request` invokes) ────────────────── + + get cardMusic(): string | null { + return this.currentCardMusic; + } + + get sfxSet(): SfxSet | null { + return this.currentSfxSet; + } + + get ambient(): string | null { + return this.currentAmbient; + } + + get hero(): HeroAssets | null { + return this.currentHero; + } + + get library(): GameLibrary | null { + return this.currentLibrary; + } + + get browse(): BrowseInfo | null { + return this.currentBrowse; + } + + /** Drops the pending browse-asset read (on application exit). */ + dispose(): void { + if (this.browseAssetsTimer !== null) clearTimeout(this.browseAssetsTimer); + } + + // ── Hero images (delivered once per card, rotated in the renderer) ─────── + + /** Stores the current hero images and pushes them to the window (null when no card / on error). */ + setHero(assets: HeroAssets | null): void { + this.currentHero = assets; + this.deps.send(IPC.heroUpdate, assets); + } + + // ── Audio (the card's music + the bundled UI sound set) ────────────────── + + /** + * The music that belongs to the CARD channel — the one a game with no music of its own falls back to + * (see the fallback chain in audio.ts: browsed game → card → ambience). + * + * A LOCAL game never fills it, and that is the whole point of this helper. The fallback says "you are + * looking at a game with no theme, so keep playing the card's" — which is right for a card, whose + * games travel together, and wrong for the PC library, where the selected game is just whichever one + * happens to be highlighted: its theme would then play under every other local game, drowning out the + * ambience the user chose in Settings. A local game's own music still reaches the ear through the + * browse channel, which is what plays the game you are actually looking at. + */ + async cardMusicFor(manifest: ResolvedManifest | null): Promise { + if (manifest === null || manifest.source !== 'card') return null; + return this.deps.assets.readMusicDataUrl(manifest); + } + + /** Stores the current card's music and pushes it to the window (null when no card / on error). */ + setCardMusic(url: string | null): void { + this.currentCardMusic = url; + this.deps.send(IPC.cardMusicUpdate, url); + } + + /** Stores the bundled UI sound set and pushes it (every UI sound the app plays). */ + setSfxSet(set: SfxSet | null): void { + this.storeSfxSet(set); + this.pushSfxSet(); + } + + /** Stores the sound set WITHOUT pushing — for a caller that answers `sfxSet:request` from here while + * it still has work to do before the push (refreshAudio re-reads the card's music in between). */ + storeSfxSet(set: SfxSet | null): void { + this.currentSfxSet = set; + } + + /** Pushes the bundled UI sound set (every UI sound the app plays). */ + pushSfxSet(): void { + this.deps.send(IPC.sfxSetUpdate, this.currentSfxSet); + } + + /** + * Re-sends the browsed game's music after an audio-settings change ("only global ambience", the sound + * set). Music only — re-running the whole browse would re-encode the hero images for nothing. + */ + async refreshBrowseMusic(): Promise { + const browse = this.currentBrowse; + if (browse === null) return; + this.pushBrowseMusic(await this.browseMusicFor(browse.id)); + } + + /** + * The music to play for a browsed game: the card's own file when it is on the inserted card, else the + * copy in the history. "Only global ambience" suppresses BOTH — AssetReader applies it for the card, + * and the history copy is checked here (LibraryStore knows nothing about settings), so a history game + * cannot smuggle its theme past a setting that silenced the card games. + */ + private async browseMusicFor(id: string): Promise { + const manifest = this.deps.findManifest(id); + if (manifest !== null) return this.deps.assets.readMusicDataUrl(manifest); + if (await this.deps.onlyGlobalAmbient()) return null; + return (await this.deps.readBrowseAssets(id)).music; + } + + /** Stores the default-ambience data URL (or null) and pushes it to the game window. */ + setAmbient(url: string | null): void { + this.currentAmbient = url; + this.deps.send(IPC.ambientUpdate, url); + } + + // ── Carousel list (the card's games + the play history) ──────────────────── + + /** Stores the current carousel list and pushes it to the window (null when there is nothing to show). */ + setLibrary(library: GameLibrary | null): void { + this.currentLibrary = library; + this.deps.send(IPC.libraryUpdate, library); + } + + // ── Browse (what is on screen) ───────────────────────────────────────────── + + /** Stores the browsed game and pushes it to the window (null = nothing to show at all). */ + pushBrowse(browse: BrowseInfo | null): void { + this.currentBrowse = browse; + this.deps.send(IPC.browseUpdate, browse); + } + + /** Pushes the browsed game's backgrounds. A SEPARATE channel from hero:update on purpose: that one + * keeps carrying the inserted card's selected game, so browsing can never overwrite (and strand) it. */ + pushBrowseHero(assets: HeroAssets | null): void { + this.deps.send(IPC.browseHero, assets); + } + + /** Pushes the browsed game's music (music only — the SFX set is never rebuilt by browsing). */ + pushBrowseMusic(url: string | null): void { + this.deps.send(IPC.browseMusic, url); + } + + /** Debounced read+push of the browsed game's hero/music; a newer browse cancels the pending one. + * `null` is the launcher-card case: the same debounce, pushing empty assets at the end of it. */ + scheduleBrowseAssets(id: string | null, immediate = false): void { + if (this.browseAssetsTimer !== null) clearTimeout(this.browseAssetsTimer); + this.browseAssetsTimer = null; + // `immediate` is the renderer saying the user has COMMITTED to this game (opened its screen) rather + // than flipped onto it. Waiting out the debounce there means a quarter second of the previous game's + // background and music on a screen that is already the new game's. + if (immediate) { + void this.pushBrowseAssets(id); + return; + } + this.browseAssetsTimer = setTimeout(() => { + this.browseAssetsTimer = null; + void this.pushBrowseAssets(id); + }, BROWSE_ASSETS_DEBOUNCE_MS); + } + + private async pushBrowseAssets(id: string | null): Promise { + const seq = ++this.browseAssetsSeq; + // Checked before EVERY push, not just on entry. Each read below is megabytes off the disk plus a + // base64 encode, and the selection keeps moving while it runs — so a read started for a game the user + // flipped past would otherwise land on the game they stopped on, dragging its background, its colours + // and its music along. The sequence covers the other half: two reads in flight at once (a debounced + // one and an immediate one) can finish out of order, and only the newest may speak. + const current = (): boolean => + seq === this.browseAssetsSeq && + (id === null ? this.currentBrowse === null : this.currentBrowse?.id === id); + if (!current()) return; + // A launcher card: no background and no music of its own. The renderer answers an empty payload with + // the idle wallpaper and the global ambience (see hero.applyIdleBackground / audio.setIdle). + if (id === null) { + this.pushBrowseHero(null); + this.pushBrowseMusic(null); + return; + } + const manifest = this.deps.findManifest(id); + if (manifest !== null) { + const hero = await this.deps.assets.readHeroAssets(manifest); + if (!current()) return; + this.pushBrowseHero(hero); + const music = await this.deps.assets.readMusicDataUrl(manifest); + if (!current()) return; + this.pushBrowseMusic(music); + return; + } + const assets = await this.deps.readBrowseAssets(id); + if (!current()) return; + // A history game with no hero of its own falls back to the wallpaper, exactly like a card game does + // (readHeroAssets). Without it this push carried `null`, the renderer had nothing to paint, and the + // PREVIOUS game's background stayed on screen under the new game's name. + const hero = assets.hero ?? (await this.wallpaperHero()); + if (!current()) return; + this.pushBrowseHero(hero); + const music = await this.browseMusicFor(id); + if (!current()) return; + this.pushBrowseMusic(music); + } + + /** The wallpaper as a one-image hero payload — the per-game fallback shared by both browse paths. */ + private async wallpaperHero(): Promise { + const wallpaper = await this.deps.assets.readWallpaperDataUrl(); + return wallpaper === null ? null : { images: [wallpaper] }; + } +} diff --git a/src/main/config-paths.ts b/src/main/config-paths.ts index 0c1919b5..5959afac 100644 --- a/src/main/config-paths.ts +++ b/src/main/config-paths.ts @@ -1,7 +1,7 @@ // The path decisions behind picking a file for a manifest field, as pure functions: what a field ACCEPTS, // what a picked path becomes in the manifest, and where its picker opens. Electron-free and fs-free (the // caller does the stat and passes what it found), so the rules that used to be enforced by an OS dialog -// are unit-testable now that a renderer-driven picker enforces them instead — see the plan, Р5.1/Р5.2. +// are unit-testable now that a renderer-driven picker enforces them instead. // // These are HOST paths (a card root is `E:\` on Windows and `/run/media/deck/…` on the Deck), so they are // built with the native `path`, not `path.posix`: the posix rule in CLAUDE.md is about paths that describe diff --git a/src/main/controller-deps.ts b/src/main/controller-deps.ts new file mode 100644 index 00000000..41a22f09 --- /dev/null +++ b/src/main/controller-deps.ts @@ -0,0 +1,126 @@ +// The controller's seams: what it needs from each collaborator, as interfaces rather than the classes +// (a unit test stands in a fake per seam — see test/game-controller.test.ts), plus the process waits and +// the koffi-bound helpers it drives. Type-only, next to the controller that consumes them. +import { type ConfigSaveResult, type GameCollisionAnswer } from '../shared/types'; +import { type Translator } from '../shared/i18n/index'; +import { type StateManager } from './state'; +import { type GameWindow } from './window'; +import { type PcStore } from './pc-store'; +import { type StatsService } from './stats'; +import { type LibraryStore } from './library-store'; +import { type PcLibraryStore } from './pc-library'; +import { type HistorySyncLibrary } from './history-sync'; +import { type DriveWatcher } from './drive-watcher'; +import { + type waitForExit, + type waitForStart, + type waitForWatchedExit, + type waitForWatchedStart, + type waitForSteamStart, + type waitForSteamExit, +} from './game-launcher'; +import { type Platform } from './platform'; +import { type AppSettingsStore } from './app-settings'; +import { type NotificationsService } from './notifications'; +import { type focusGameWindow } from './window-finder'; +import { type ActivityRegistry } from './activity-registry'; + +/** + * What the collision answer needs from the Customize backend (GameConfigService). Attached after + * construction — the service is built from this controller, so it cannot also be one of its deps. + */ +export interface CollisionResolver { + /** The card's content signature right now, or null when it cannot be read. */ + signatureFor(root: string): Promise; + /** Puts a local game's name and artwork onto the card that carries the same id. */ + mergeCollision(answer: GameCollisionAnswer): Promise; + /** Drops a local game from the PC library (the draft whose look has just moved onto the card). */ + removeLocalGame(id: string): Promise; +} + +/** + * The process waits and the win32-only FFI helpers the sequences drive. Injected rather than imported: + * the waits poll on second-long cadences (a real exit wait is three misses 2.5 s apart), and + * `focusGameWindow` binds koffi — so a test of the controller hands in fast fakes + * and never touches the native side. + */ +export interface ProcessControl { + readonly waitForStart: typeof waitForStart; + readonly waitForExit: typeof waitForExit; + readonly waitForWatchedStart: typeof waitForWatchedStart; + readonly waitForWatchedExit: typeof waitForWatchedExit; + readonly waitForSteamStart: typeof waitForSteamStart; + readonly waitForSteamExit: typeof waitForSteamExit; + readonly focusGameWindow: typeof focusGameWindow; +} + +// The slices of each collaborator the controller actually calls. Interfaces rather than the classes so a +// unit test can stand in a fake per seam (see test/game-controller.test.ts) — the classes themselves reach +// for electron and the disk. +export type ControllerState = Pick; +export type ControllerWindow = Pick; +export type ControllerStore = Pick< + PcStore, + | 'getPending' + | 'clearPending' + | 'readSyncState' + | 'writeSyncState' + | 'enqueuePcToSd' + | 'hasCardSyncState' +>; +export type ControllerStats = Pick< + StatsService, + 'read' | 'readCardStatsMap' | 'reconcileWithCard' | 'copyToCard' | 'recordPlay' +>; +export type ControllerLibrary = HistorySyncLibrary & + Pick< + LibraryStore, + | 'entry' + | 'entriesForCarousel' + | 'saveFromCard' + | 'noteLaunch' + | 'forget' + | 'readBrowseAssets' + | 'readGridThumb' + | 'clearCollisionAnswers' + | 'markCollisionResolved' + >; +export type ControllerPcLibrary = Pick; +export type ControllerWatcher = Pick; +export type ControllerSettings = Pick; +export type ControllerNotifications = Pick; +export type ControllerActivities = Pick; + +export interface ControllerDeps { + readonly state: ControllerState; + readonly window: ControllerWindow; + readonly store: ControllerStore; + readonly stats: ControllerStats; + /** The play history behind the carousel: copied art/audio of every game inserted on this device. */ + readonly library: ControllerLibrary; + /** The local games added from this PC's own disk — a second, always-present manifest source. */ + readonly pcLibrary: ControllerPcLibrary; + readonly watcher: ControllerWatcher; + /** App-wide settings store — read/patched by the custom-wallpaper handlers (they own AssetReader). */ + readonly settings: ControllerSettings; + /** + * The notification inbox. Fed from the SUCCESS paths of the install/uninstall sequences only — never + * from a state transition: `failSequence` ends in `enterReady` too, and a Steam install never enters + * `installing` at all, so "it finished" cannot be read off the state machine. + */ + readonly notifications: ControllerNotifications; + /** Every game's background activity by id (Steam downloads and removals), apart from the session. */ + readonly activities: ControllerActivities; + /** Platform services (process monitor, Steam locator, launcher, save-path resolver, power) for the OS. */ + readonly platform: Platform; + /** The process waits + the koffi-bound helpers (see ProcessControl). */ + readonly processControl: ProcessControl; + /** + * Whether this is a SteamOS Game Mode (gamescope) session. In Game Mode there is no tray, so every path + * that would hide the window to the tray instead keeps the empty/error screen up. Always false on + * Windows/desktop, so their behaviour is unchanged. + */ + readonly isGamescope: boolean; + /** The current translator (read live so a language change applies to freshly-generated messages). */ + readonly getTranslator: () => Translator; +} diff --git a/src/main/drive-watcher.ts b/src/main/drive-watcher.ts index 24579ab5..4d57ec7c 100644 --- a/src/main/drive-watcher.ts +++ b/src/main/drive-watcher.ts @@ -6,8 +6,10 @@ import path from 'node:path'; import fse from 'fs-extra'; import { list } from 'drivelist'; -import { MANIFEST_FILENAME, type DriveCandidate } from '../shared/types'; +import { type DriveCandidate } from '../shared/types'; +import { MANIFEST_FILENAME } from './manifest-types'; import { type Translator } from '../shared/i18n/index'; +import { parseManifestItems } from './manifest'; import { log } from './logger'; const DEFAULT_INTERVAL_MS = 1000; @@ -86,7 +88,7 @@ export async function listDriveCandidates( * Every mounted volume on the machine, as plain paths — the STARTING points the in-launcher file picker * offers in its left column. Deliberately unfiltered, unlike listDriveCandidates: a game is installed * wherever the user installed it (the usual `C:\Program Files (x86)\Steam\steamapps\common\…` is a system - * disk by any definition), and where to browse is the user's call, not ours. See the plan, Р5.2. + * disk by any definition), and where to browse is the user's call, not ours. */ export async function listAllMountpoints(): Promise { const drives = await list(); @@ -143,10 +145,10 @@ export async function describeManifestContent( if (!hasManifest) return { suffix: blank, signature: '' }; const invalid: ManifestDescription = { suffix: t('drive.invalid'), signature: 'invalid' }; try { - const parsed: unknown = await fse.readJson(manifestPath); // game.json holds a single game object (legacy) OR a non-empty array of them (multi-game card) — the // same top-level union readManifests accepts. - const games: readonly unknown[] = Array.isArray(parsed) ? parsed : [parsed]; + const games = parseManifestItems(await fse.readFile(manifestPath, 'utf8')); + if (games === null) return invalid; const first = games[0]; if (typeof first !== 'object' || first === null) return invalid; const signature = gameIdsSignature(games); @@ -193,8 +195,8 @@ export class DriveWatcher { private errorHandler: ((error: Error) => void) | null = null; /** - * @param automount Optional sweep that mounts an inserted-but-unmounted removable card before scanning - * (Р10). Wired ONLY in a SteamOS Game Mode session, as a SAFETY NET: the session normally mounts the + * @param automount Optional sweep that mounts an inserted-but-unmounted removable card before scanning. + * Wired ONLY in a SteamOS Game Mode session, as a SAFETY NET: the session normally mounts the * card itself, but a card that arrives without a mountpoint has no path for scan() to look under and * would stay invisible. null everywhere else (Windows and the KDE desktop session mount on their * own). Must never throw. diff --git a/src/main/file-picker-service.ts b/src/main/file-picker-service.ts new file mode 100644 index 00000000..19bcc23b --- /dev/null +++ b/src/main/file-picker-service.ts @@ -0,0 +1,262 @@ +// The in-launcher file browser's backend: listing a directory, the roots offered beside it, an image +// thumbnail, and — the part that matters for safety — turning a picked path into what the manifest +// field stores. Split out of GameConfigService, which keeps the root guard the acceptance leans on. +// +// The native dialog used to be the CONSENT GATE: an absolute path could only reach main because the OS +// handed it over. Now the renderer names it, so acceptPickedPaths re-checks what the dialog used to +// guarantee — the path exists, is not a symlink, and its type matches the field. +import path from 'node:path'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import { app, ipcMain } from 'electron'; +import { + IPC, + type ConfigPickKind, + type ConfigPickResult, + type DirEntry, + type DirRoot, + type GameConfigAcceptRequest, + type GameConfigListDirRequest, + type ListDirResult, + type ManifestSource, +} from '../shared/types'; +import { type Translator } from '../shared/i18n/index'; +import { AUDIO_EXTENSIONS, IMAGE_EXTENSIONS, readImageDataUrl } from './asset-reader'; +import { listsAsFile, startDirFor, toCardRelative } from './config-paths'; +import { describePickRejection } from './pick-rejection'; +import { listAllMountpoints } from './drive-watcher'; +import { type PcLibraryStore } from './pc-library'; +import { resolveInside } from './manifest'; +import { describe } from './util'; +import { log } from './logger'; +/** The root guard the picker shares with GameConfigService: the closed set of writable roots. */ +export interface WritableRootGuard { + /** True when `root` is a current removable/non-system mountpoint or the app's own PC-library root. */ + isWritableRoot(root: string): Promise; + /** Which manifest dialect `root` speaks — the PC library's, or a card's. */ + sourceOf(root: string): ManifestSource; +} + +export interface FilePickerDeps { + readonly config: WritableRootGuard; + /** The launcher's currently-active card root (DriveWatcher.getActiveRoot). */ + readonly getActiveRoot: () => string | null; + /** The PC library: its root is a picker root, and a local game's art/music is copied into it. */ + readonly pcLibrary: Pick; + /** The current translator (read live so a language change applies to labels/errors). */ + readonly getTranslator: () => Translator; + /** + * Reverse-maps an absolute PC folder (from the pcSavePath browse) to a `%PREFIX%/…` manifest + * string via the platform SavePathResolver, or null when it lives under none of the allowed bases. + * win32 uses the env-based table; linux returns null (the user types the Windows-dictionary string). + */ + readonly toManifestPcSavePath: (absolute: string) => string | null; +} + +export class FilePickerService { + constructor(private readonly deps: FilePickerDeps) {} + + /** Registers the picker's gameConfig:* invoke handlers once (the service is a singleton). */ + init(): void { + ipcMain.handle( + IPC.gameConfigImagePreview, + (_event, payload: { readonly root: string; readonly path: string }): Promise => + this.imagePreview(payload.root, payload.path), + ); + ipcMain.handle( + IPC.gameConfigAcceptPath, + (_event, payload: GameConfigAcceptRequest): Promise => + this.acceptPickedPaths(payload.root, payload.kind, payload.paths, payload.base), + ); + ipcMain.handle( + IPC.gameConfigListDir, + (_event, payload: GameConfigListDirRequest): Promise => this.listDir(payload), + ); + } + + /** + * Turns absolute path(s) into what the manifest field actually stores: card-RELATIVE with forward + * slashes, a `%PREFIX%/…` save path, a verbatim absolute, or a library-relative asset that was copied + * in. Shared by the native dialog and the in-launcher picker. + * + * The dialog used to be the consent gate for all of this: an absolute path could only arrive because + * the OS handed it over, which is why this file could say "main never trusts the renderer's path" and + * still copy whatever it was given. The in-launcher picker takes that gate away, so the checks are + * stated here instead — the root must be a live candidate, the path must exist and + * not be a symlink, and its TYPE must match the field: an `~/.ssh/id_rsa` offered as a hero image is + * refused before anything reads or copies it. + */ + private async acceptPickedPaths( + root: string, + kind: ConfigPickKind, + absolutePaths: readonly string[], + base?: string, + ): Promise { + const t = this.deps.getTranslator(); + if (!(await this.deps.config.isWritableRoot(root))) { + return { ok: false, message: t('errors.driveUnavailable') }; + } + // A field measured from a sub-directory (see GameConfigAcceptRequest.base) still lives inside the + // root, and `resolveInside` is what proves it: the renderer names the sub-path, so it gets the same + // anti-traversal treatment every other manifest path does. + const measureFrom = base === undefined || base === '' ? root : resolveInside(root, base); + if (measureFrom === null) return { ok: false, message: t('gameConfig.pickOutsideCard') }; + if (absolutePaths.length === 0) return { ok: false, cancelled: true }; + const isPcLibrary = this.deps.config.sourceOf(root) === 'pc'; + // A local game's own executable and its host-side save folder only exist in the PC library. + if ((kind === 'pc-executable' || kind === 'pc-save-local') && !isPcLibrary) { + return { ok: false, message: t('errors.driveUnavailable') }; + } + for (const absolute of absolutePaths) { + const rejection = await describePickRejection(absolute, kind, t); + if (rejection !== null) return { ok: false, message: rejection }; + } + + // pcSavePath points at a PC folder OUTSIDE the card (env-prefixed), so the absolute result is + // converted back to a %PREFIX%/… form the validator accepts. A local game running from this machine's + // own disk keeps the absolute path VERBATIM (`pc-save-local`). Converting it would be actively wrong + // there: on Linux the reverse mapping only knows folders inside a Wine prefix and rejects everything + // else, so the typical local save folder (`~/Games/Hades/Saves`) could not be picked at all — and pc + // mode accepts an absolute path precisely because a %PREFIX% cannot express it. It is the form that + // decides which of the two kinds applies, by launch mode: a local STEAM game keeps `pc-save`, because + // ITS saves sit inside Steam's Proton prefix and only the %PREFIX% form maps onto compatdata (an + // absolute path there would also be read with containerExists: true, which would let a deleted prefix + // be mistaken for deleted saves). + const first = absolutePaths[0]; + if (first === undefined) return { ok: false, cancelled: true }; + if (kind === 'pc-save-local' || kind === 'pc-executable') return { ok: true, paths: [first] }; + if (kind === 'pc-save') { + const pcSavePath = this.deps.toManifestPcSavePath(first); + if (pcSavePath === null) return { ok: false, message: t('gameConfig.pickPcSaveOutside') }; + return { ok: true, paths: [pcSavePath] }; + } + // Art and music for a local game are picked from anywhere and COPIED into the library, so what the + // manifest stores is a library-relative path — the same shape a card's asset has, which is what keeps + // resolveInside and the AssetReader free of any PC-specific branch (and the art alive after the user + // deletes the original). + if (isPcLibrary && (kind === 'image' || kind === 'audio')) { + const extensions = kind === 'image' ? IMAGE_EXTENSIONS : AUDIO_EXTENSIONS; + const relatives: string[] = []; + for (const absolute of absolutePaths) { + try { + relatives.push(await this.deps.pcLibrary.importAsset(absolute, kind, extensions)); + } catch (cause) { + log.warn('[game-config] importing a local asset failed:', describe(cause)); + return { ok: false, message: t('gameConfig.pickImportFailed') }; + } + } + return { ok: true, paths: relatives }; + } + + const relatives: string[] = []; + for (const absolute of absolutePaths) { + const relative = toCardRelative(measureFrom, absolute); + if (relative === null) { + return { + ok: false, + message: t( + kind === 'directory' ? 'gameConfig.pickChooseSubfolder' : 'gameConfig.pickOutsideCard', + ), + }; + } + relatives.push(relative); + } + return { ok: true, paths: relatives }; + } + + // ── Directory listing for the in-launcher file picker ────────────────────── + + /** + * One directory's contents, plus the starting points offered beside it. READ-ONLY and deliberately + * unrestricted: where to browse is the user's business (the most common install path of all, + * `C:\Program Files (x86)\Steam\steamapps\common\…`, is a system directory by any definition). What is + * guarded is the ACCEPTANCE of a path, not the looking — see acceptPickedPaths. + */ + private async listDir(request: GameConfigListDirRequest): Promise { + const t = this.deps.getTranslator(); + const roots = await this.pickerRoots(); + // A field measured from a sub-directory browses from there; an unresolvable one falls back to the + // root rather than failing — this is where the picker OPENS, not what it will accept. + const baseDir = + request.root !== undefined && request.base !== undefined && request.base !== '' + ? resolveInside(request.root, request.base) + : null; + const target = + request.path ?? + startDirFor( + { ...request, ...(baseDir !== null ? { baseDir } : {}) }, + { + homeDir: os.homedir(), + appDataDir: app.getPath('appData'), + downloadsDir: app.getPath('downloads'), + rootIsCard: + request.root !== undefined && this.deps.config.sourceOf(request.root) === 'card', + }, + ); + let names: readonly string[]; + try { + names = await fs.readdir(target); + } catch (cause) { + log.warn(`[game-config] cannot list "${target}":`, describe(cause)); + return { ok: false, message: t('gameConfig.listFailed'), roots }; + } + const entries: DirEntry[] = []; + for (const name of names) { + if (name.startsWith('.')) continue; // dotfiles are noise in a picker for games and artwork + try { + // stat, not lstat: a symlinked folder is a folder to browse. Accepting what is INSIDE it is a + // separate decision, made by acceptPickedPaths, which refuses symlinks on its own. + const full = path.join(target, name); + const stats = await fs.stat(full); + // A macOS `.app` bundle is a directory the user means as one file — see listsAsFile. + const isDir = stats.isDirectory() && !listsAsFile(full, true, request.kind); + entries.push({ name, kind: isDir ? 'dir' : 'file' }); + } catch { + continue; // a dangling link or an unreadable entry — simply not offered + } + } + entries.sort((a, b) => + a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === 'dir' ? -1 : 1, + ); + const parent = path.dirname(target); + return { + ok: true, + path: target, + parent: parent === target ? null : parent, + entries, + roots, + }; + } + + /** The left column: the card, this machine's library, the home folder and every mounted volume. */ + private async pickerRoots(): Promise { + const t = this.deps.getTranslator(); + const roots: DirRoot[] = []; + const card = this.deps.getActiveRoot(); + if (card !== null) roots.push({ path: card, label: card, kind: 'card' }); + roots.push({ path: this.deps.pcLibrary.root, label: t('gameConfig.thisPc'), kind: 'pc' }); + roots.push({ path: os.homedir(), label: t('gameConfig.homeFolder'), kind: 'home' }); + try { + for (const mount of await listAllMountpoints()) { + if (roots.some((entry) => entry.path === mount)) continue; + roots.push({ path: mount, label: mount, kind: 'drive' }); + } + } catch (cause) { + log.warn('[game-config] enumerating volumes for the picker failed:', describe(cause)); + } + return roots; + } + + /** + * Reads a card-relative image into a data URL for the hero preview. Reuses the manifest's anti-traversal + * (`resolveInside`) and the untrusted-root check, so the preview can only read files INSIDE the card. + * Returns null on any rejection/failure (the renderer just shows no thumbnail). + */ + private async imagePreview(root: string, relative: string): Promise { + if (!(await this.deps.config.isWritableRoot(root))) return null; + const resolved = resolveInside(root, relative); + if (resolved === null) return null; + const url = await readImageDataUrl(resolved); + return url ?? null; + } +} diff --git a/src/main/game-actions.ts b/src/main/game-actions.ts new file mode 100644 index 00000000..9b2050dc --- /dev/null +++ b/src/main/game-actions.ts @@ -0,0 +1,221 @@ +import { type GameInfo } from '../shared/types'; +import { type Translator } from '../shared/i18n/index'; +import type { ResolvedManifest } from './manifest-types'; +import { type ControllerDeps } from './controller-deps'; +import { type GameSequences } from './game-sequences'; +import { type GameJobs } from './game-jobs'; +import { resolveActionTarget, type ActionTarget } from './action-target'; +import { log } from './logger'; + +/** What the actions need back from the card session. */ +export interface GameActionsHost { + /** Every game that can be acted on right now (the card's and the PC library's). */ + games(): readonly ResolvedManifest[]; + /** The selected game, or the first one when the selection is gone. */ + current(): ResolvedManifest | null; + sourceAvailable(manifest: ResolvedManifest): boolean; + /** A manifest reload from the Customize screen is in flight. */ + isReloading(): boolean; + /** The id of the game the busy session is about, or null while the state is settled. */ + sessionGameId(): string | null; + /** The renderer's action:select for `id`; refused (left as it was) while the session is busy. */ + select(id: string): Promise; + /** A freshly built GameInfo for a game that is not the one AppState is about. */ + buildGameInfo(manifest: ResolvedManifest): Promise; + sendError(message: string): void; +} + +export type GameActionsDeps = Pick< + ControllerDeps, + 'state' | 'activities' | 'processControl' | 'getTranslator' +> & { + readonly sequences: Pick< + GameSequences, + | 'inFlight' + | 'runningGameImageNames' + | 'runSteamInstall' + | 'runSteamUninstall' + | 'runLaunchSequence' + >; + readonly jobs: Pick; + readonly host: GameActionsHost; +}; + +/** + * The renderer's Play / Install and Uninstall, addressed by game id. With the session free they act on the + * selected game exactly as before (selecting the target first when it is another one); with a game running + * they still install or remove OTHER games in the background, and never start a second one. + */ +export class GameActions { + constructor(private readonly deps: GameActionsDeps) {} + + private get t(): Translator { + return this.deps.getTranslator(); + } + + /** The id AppState is about while ready, else the selection's. */ + private selectedId(): string | null { + const snapshot = this.deps.state.get(); + return snapshot.kind === 'ready' + ? snapshot.game.id + : (this.deps.host.current()?.raw.id ?? null); + } + + /** The game an action names, or the selected one when it names none (an older caller). */ + private targetOf(idRaw: unknown): ResolvedManifest | null { + const id = typeof idRaw === 'string' ? idRaw : this.selectedId(); + return this.deps.host.games().find((manifest) => manifest.raw.id === id) ?? null; + } + + private resolve(action: 'launch' | 'uninstall', manifest: ResolvedManifest): ActionTarget { + const sessionGameId = this.deps.host.sessionGameId(); + return resolveActionTarget({ + action, + id: manifest.raw.id, + selectedId: this.selectedId(), + sessionBusy: sessionGameId !== null || this.deps.sequences.inFlight, + sessionGameId, + hasActivity: this.deps.activities.has(manifest.raw.id), + sourceAvailable: this.deps.host.sourceAvailable(manifest), + reloadInFlight: this.deps.host.isReloading(), + }); + } + + /** Play / the install confirm's Yes for game `idRaw`. */ + async launch(idRaw: unknown): Promise { + const manifest = this.targetOf(idRaw); + if (manifest === null) return; + const id = manifest.raw.id; + switch (this.resolve('launch', manifest)) { + case 'resume-session': + // Play pressed while a game is running (the launcher was summoned over it via the tray): return to the + // game instead of launching. No-op if we don't have the image names yet. + if (this.deps.state.get().kind === 'running') this.resumeRunningGame(); + return; + case 'select-then-act': + await this.deps.host.select(id); + if (this.selectedId() === id) await this.launch(id); + return; + case 'install-only': + await this.installInBackground(manifest); + return; + case 'launch-or-install-selected': + this.launchSelected(); + return; + case 'uninstall-only': + case 'refuse': + log.info(`[launch] refused id=${id}: the game is busy, unavailable or being reloaded`); + return; + } + } + + /** The uninstall confirm's Yes for game `idRaw`. */ + async uninstall(idRaw: unknown): Promise { + const manifest = this.targetOf(idRaw); + if (manifest === null) return; + const id = manifest.raw.id; + switch (this.resolve('uninstall', manifest)) { + case 'select-then-act': + await this.deps.host.select(id); + if (this.selectedId() === id) await this.uninstall(id); + return; + case 'uninstall-only': + await this.uninstallOne(manifest); + return; + default: + log.info(`[uninstall] refused id=${id}`); + } + } + + private launchSelected(): void { + const snapshot = this.deps.state.get(); + // Ignore input outside the ready state — this is the "ignore-gamepad" during play + // (harmless under any interpretation of the Gamepad API focus bug). + if (snapshot.kind !== 'ready' || this.deps.sequences.inFlight) return; + const manifest = this.targetOf(snapshot.game.id); + if (manifest === null) return; + // A local game whose .exe is gone (deleted, or an external drive unplugged). The renderer already + // disables Play, but a gamepad press must not slip past it into a launch that can only fail. + if (snapshot.game.unavailable === true) { + log.info( + `[launch] refused id=${manifest.raw.id}: "${manifest.executablePath}" is not on disk`, + ); + this.deps.host.sendError(this.t('launcher.state.gameFilesMissing')); + return; + } + // A local draft with no launch method chosen yet — same guard, different reason. The renderer already + // disables Play, but a gamepad press must not slip past it into runLaunchSequence. + if (snapshot.game.unconfigured === true) { + log.info(`[launch] refused id=${manifest.raw.id}: no launch method is configured`); + this.deps.host.sendError(this.t('launcher.state.launchNotConfigured')); + return; + } + // Steam mode: not yet installed → open steam://install (fire-and-forget); otherwise launch via + // steam://rungameid. Both inside runSteamInstall / runLaunchSequence's steam branch. + if (manifest.steam !== undefined) { + if (snapshot.game.requiresInstall) { + void this.deps.sequences.runSteamInstall(manifest); + } else { + void this.deps.sequences.runLaunchSequence(manifest, snapshot.game); + } + return; + } + // Card-install mode + not yet installed → run the installer; otherwise it's an ordinary launch + // (this includes a fully-installed game, whose executable now exists → requiresInstall=false). + if (manifest.install !== undefined && snapshot.game.requiresInstall) { + this.deps.jobs.startInstall(manifest); + } else { + void this.deps.sequences.runLaunchSequence(manifest, snapshot.game); + } + } + + /** Installs a game other than the session's: Steam's own download, or a background job. Never a launch. */ + private async installInBackground(manifest: ResolvedManifest): Promise { + const info = await this.deps.host.buildGameInfo(manifest); + if (!info.requiresInstall || info.unconfigured === true) { + log.info( + `[launch] refused id=${manifest.raw.id}: a second game is not launched while one runs`, + ); + return; + } + if (manifest.steam !== undefined) { + void this.deps.sequences.runSteamInstall(manifest); + return; + } + this.deps.jobs.startInstall(manifest); + } + + private async uninstallOne(manifest: ResolvedManifest): Promise { + const snapshot = this.deps.state.get(); + const info = + snapshot.kind === 'ready' && snapshot.game.id === manifest.raw.id + ? snapshot.game + : await this.deps.host.buildGameInfo(manifest); + if (!info.canUninstall) return; // nothing installed to remove + // Steam: delegate removal to Steam (steam://uninstall) — fire-and-forget, the poller flips to Install. + if (manifest.steam !== undefined) { + void this.deps.sequences.runSteamUninstall(manifest); + return; + } + if (manifest.install === undefined) { + // Normal executable game: the only "uninstall" is clearing its Wine prefix (Linux; the game stays on + // the card). canUninstall is set only when that prefix exists — see buildGameInfo / prefixCleanupOnly. + if (info.prefixCleanupOnly === true) this.deps.jobs.startPrefixCleanup(manifest); + return; + } + this.deps.jobs.startUninstall(manifest); + } + + /** + * Return-to-game: raise the running game's own window to the foreground (restoring it if it minimized + * when it lost focus). Best-effort — if the window isn't found (the game is already closing, a race with + * waitForExit) it's a silent no-op; the state machine will move to syncing-out → ready on its own. + */ + private resumeRunningGame(): void { + const names = this.deps.sequences.runningGameImageNames; + if (names === null) return; + if (!this.deps.processControl.focusGameWindow(names)) { + log.info('[resume] running game window not found — no-op (it may be closing)'); + } + } +} diff --git a/src/main/game-config-add.ts b/src/main/game-config-add.ts index 2f87904f..8bc4443d 100644 --- a/src/main/game-config-add.ts +++ b/src/main/game-config-add.ts @@ -7,6 +7,7 @@ import { type HostPlatform, type ManifestSource, } from '../shared/types'; +import { parseManifestItems } from './manifest'; /** The fixed half of a root read — everything that does not depend on whether a game.json is there. */ export interface RootReadBase { @@ -43,13 +44,8 @@ export interface AddedGame { export function addedGamesOf(beforeSignature: string, text: string): readonly AddedGame[] { if (beforeSignature === 'invalid') return []; const before = new Set(beforeSignature.split('|').filter((id) => id.length > 0)); - let parsed: unknown; - try { - parsed = JSON.parse(text); - } catch { - return []; - } - const games: readonly unknown[] = Array.isArray(parsed) ? parsed : [parsed]; + const games = parseManifestItems(text); + if (games === null) return []; const added: AddedGame[] = []; for (const game of games) { if (typeof game !== 'object' || game === null) continue; diff --git a/src/main/game-config.ts b/src/main/game-config.ts index 2dce211d..5358d19e 100644 --- a/src/main/game-config.ts +++ b/src/main/game-config.ts @@ -1,7 +1,8 @@ // Backend of the launcher's Customize screen: reading, validating and writing one game's game.json, -// listing directories for the in-launcher file browser, and turning a picked path into what the manifest -// field stores. Interface-DI (like UpdaterService/StatsService): the active-root accessor, the no-restart -// reload and the id→root lookup all come from GameController. +// for a card, the PC library and the history. The in-launcher file browser (file-picker-service.ts) and +// the move-to-card transaction (game-move-transaction.ts) are built on top of the root guard and the +// game.json reads/writes this service exposes. Interface-DI (like UpdaterService/StatsService): the +// active-root accessor, the no-restart reload and the id→root lookup all come from GameController. // // Two security stances mirror manifest.ts's paranoia about untrusted paths: // • the renderer's `root` is NEVER trusted — every read/save re-checks it against a fresh @@ -21,60 +22,35 @@ // A third stance arrived with the in-launcher file browser, which replaced the native dialog. That dialog // used to be the CONSENT GATE: an absolute path could only reach this file because the OS handed it over. // Now the renderer names it, so acceptPickedPaths re-checks what the dialog used to guarantee — the path -// exists, is not a symlink, and its type matches the field (see the plan, Р5.1). +// exists, is not a symlink, and its type matches the field. import path from 'node:path'; -import fs from 'node:fs/promises'; -import os from 'node:os'; import fse from 'fs-extra'; -import { app, ipcMain } from 'electron'; +import { ipcMain } from 'electron'; import { IPC, - MANIFEST_FILENAME, - type ConfigMoveResult, - type ConfigPickKind, type ConfigPickResult, - type ConfigReadResult, type ConfigRootReadResult, type ConfigSaveResult, type ConfigValidationResult, - type DirEntry, - type DirRoot, type DriveCandidate, - type GameConfigAcceptRequest, - type GameConfigListDirRequest, type GameConfigReadResult, type GameConfigSaveRequest, type GameCollisionAnswer, - type GameMoveRequest, type HistoryConfigAcceptRequest, type HistoryConfigReadResult, type HistoryConfigSaveRequest, - type ListDirResult, type ManifestValidationIssue, type ManifestSource, - type NotificationInput, - type ResolvedManifest, } from '../shared/types'; +import { MANIFEST_FILENAME, type ResolvedManifest } from './manifest-types'; import { type Translator } from '../shared/i18n/index'; import { AUDIO_EXTENSIONS, IMAGE_EXTENSIONS, readImageDataUrl } from './asset-reader'; -import { - acceptsExtensions, - checkPickedType, - hostPlatform, - listsAsFile, - startDirFor, - toCardRelative, - type PickRejection, -} from './config-paths'; -import { describeManifestContent, listAllMountpoints, listDriveCandidates } from './drive-watcher'; +import { describePickRejection } from './pick-rejection'; +import { type NotificationInput } from './notifications'; +import { hostPlatform } from './config-paths'; +import { describeManifestContent, listDriveCandidates } from './drive-watcher'; import { addedGamesOf, rootReadResult } from './game-config-add'; -import { - countGamesWithId, - expectedGameFilePath, - findGameInText, - planAssetCopies, - removeGameFromManifestText, -} from './game-move'; +import { planAssetCopies, removeGameFromManifestText } from './game-move'; import { type PcLibraryStore } from './pc-library'; import { type LibraryStore } from './library-store'; import { type LibraryEntryRecord } from './library-index'; @@ -90,9 +66,7 @@ import { movedHeroAssetPath, movedMusicAssetPath, } from '../shared/asset-move-names'; -import { type PcStore } from './pc-store'; -import { type SavePathResolver } from './platform/types'; -import { isSafeGameId, resolveInside, validateManifestText } from './manifest'; +import { isSafeGameId, validateManifestText } from './manifest'; import { writeFileAtomicEnsuringDir } from './json-store'; import { describe } from './util'; import { log } from './logger'; @@ -173,57 +147,9 @@ function isEmptyManifestList(text: string): boolean { */ const CANDIDATES_TTL_MS = 2000; -/** - * A move that SUCCEEDED but not entirely cleanly. Kept as a discriminated value rather than as the - * user-facing sentence it turns into: the sentence is localized (comparing against it would break the - * moment the UI language changes mid-transaction) and there can be more than one of these in one move. - */ -type MoveWarning = 'save-skipped' | 'duplicate'; - -const MOVE_WARNING_NOTIFICATION: Readonly< - Record -> = { - 'save-skipped': 'game-move-save-skipped', - duplicate: 'game-move-duplicate', -}; - -/** One asset copied onto the card by a move, and whether the destination was already occupied — a - * rollback removes only what the move itself created (an overwrite cannot be undone, so deleting a file - * that predates us would turn a failed move into data loss). */ -interface AssetCopyRecord { - readonly to: string; - readonly existedBefore: boolean; -} - -/** The save folder a move copied INTO, if any. `existedBefore` distinguishes "we made this folder" (undo - * = remove it) from "it was already there and empty" (undo = empty it again, keeping the folder). */ -interface SaveCopyRecord { - dir: string | null; - existedBefore: boolean; -} - -/** The extensions a field accepts, with the two asset lists filled in from the AssetReader. */ -function extensionsFor(kind: ConfigPickKind): readonly string[] | null { - if (kind === 'image') return IMAGE_EXTENSIONS; - if (kind === 'audio') return AUDIO_EXTENSIONS; - return acceptsExtensions(kind); -} - -/** The localized wording of a refusal from checkPickedType. */ -function rejectionMessage(rejection: PickRejection, t: Translator): string { - switch (rejection) { - case 'missing': - return t('gameConfig.pickMissing'); - case 'symlink': - return t('gameConfig.pickSymlink'); - case 'needs-folder': - return t('gameConfig.pickNeedsFolder'); - case 'needs-file': - return t('gameConfig.pickNeedsFile'); - case 'wrong-type': - return t('gameConfig.pickWrongType'); - } -} +/** Result of reading a root's game.json for the editor. */ +export type ConfigReadResult = + { readonly ok: true; readonly text: string } | { readonly ok: false; readonly message: string }; export interface GameConfigDeps { /** The launcher's currently-active card root (DriveWatcher.getActiveRoot). */ @@ -239,17 +165,11 @@ export interface GameConfigDeps { readonly reloadPcLibrary: () => Promise<{ ok: true } | { ok: false; message: string }>; /** The current translator (read live so a language change applies to labels/validation/errors). */ readonly getTranslator: () => Translator; - /** - * Reverse-maps an absolute PC folder (from the pcSavePath browse) to a `%PREFIX%/…` manifest - * string via the platform SavePathResolver (Р5), or null when it lives under none of the allowed bases. - * win32 uses the env-based table; linux returns null (the user types the Windows-dictionary string). - */ - readonly toManifestPcSavePath: (absolute: string) => string | null; /** * Where one game's manifest lives, BY ID (GameController.findGameSource) — the bridge from what the * carousel shows to the file the Customize screen edits. An index is deliberately not part of the * answer: the controller's list is a filtered, reordered union of two sources, so its position says - * nothing about the slot's position in the text (see the plan, Р2). + * nothing about the slot's position in the text. */ readonly findGameSource: ( id: string, @@ -261,20 +181,6 @@ export interface GameConfigDeps { * asks for a save, not for a notification. */ readonly notify: (input: NotificationInput) => void; - /** - * The full RESOLVED manifest of one game, by id (GameController.findManifest) — unlike `findGameSource`, - * moveToCard needs the actual resolved asset paths and the raw fields (steam/pcSavePath) to plan the - * asset/save copies, not just where the file lives. - */ - readonly resolveManifest: (id: string) => ResolvedManifest | null; - /** - * Whether ANY game is currently running/installing/uninstalling (GameController.isBusy) — moveToCard's - * own re-check of the guard the "Move to card…" menu item already applies in the renderer (Р2.5). - */ - readonly isBusy: () => boolean; - /** Drops a game's sync-state baseline (PcStore.removeSyncState) — moveToCard clears the "pc" slot once - * a game leaves the library (Р2.5/Р2.7): the local backup ↔ save-folder pairing it described is gone. */ - readonly pcStore: Pick; /** * The game HISTORY — where a game with no card in gets its stored manifest, its pending edits and its * staged assets (see history-config.ts). The Customize screen reaches it through the `*-history` @@ -293,10 +199,6 @@ export interface GameConfigDeps { readonly isCardLoading: () => boolean; /** Re-pushes the carousel row after an edit changed a history record (GameController.refreshLibrary). */ readonly refreshLibrary: () => void; - /** Resolves a manifest's `pcSavePath` to the LIVE save folder on this machine (platform.savePathResolver) - * — moveToCard copies from there, not from the PC-library backup, so a stale backup can never - * overwrite a fresher save (see the plan, Р2.5 step 3). */ - readonly savePathResolver: Pick; } export class GameConfigService { @@ -328,30 +230,12 @@ export class GameConfigService { (_event, payload: GameConfigSaveRequest): Promise => this.saveChecked(payload), ); - ipcMain.handle( - IPC.gameConfigImagePreview, - (_event, payload: { readonly root: string; readonly path: string }): Promise => - this.imagePreview(payload.root, payload.path), - ); - ipcMain.handle( - IPC.gameConfigAcceptPath, - (_event, payload: GameConfigAcceptRequest): Promise => - this.acceptPickedPaths(payload.root, payload.kind, payload.paths, payload.base), - ); - ipcMain.handle( - IPC.gameConfigListDir, - (_event, payload: GameConfigListDirRequest): Promise => this.listDir(payload), - ); ipcMain.handle(IPC.gameConfigSources, (): Promise => this.candidates(), ); ipcMain.handle(IPC.gameConfigReadRoot, (_event, root: unknown): Promise => this.readRoot(typeof root === 'string' ? root : ''), ); - ipcMain.handle( - IPC.gameConfigMoveToCard, - (_event, payload: GameMoveRequest): Promise => this.moveToCard(payload), - ); // Every history channel addresses a game by id ALONE, and that id becomes a directory name under the // history (LibraryStore.gameDir). The manifest schema validates the ids that come off a card; these // come off the renderer, whose root this file's header says is never trusted — so they are held to @@ -465,7 +349,7 @@ export class GameConfigService { } if (paths.length === 0) return { ok: false, cancelled: true }; for (const absolute of paths) { - const rejection = await this.checkPickedType(absolute, kind); + const rejection = await describePickRejection(absolute, kind, t); if (rejection !== null) return { ok: false, message: rejection }; } const extensions = kind === 'image' ? IMAGE_EXTENSIONS : AUDIO_EXTENSIONS; @@ -551,7 +435,7 @@ export class GameConfigService { } /** Drops the snapshot: our own write changed a manifest the labels/signatures are derived from. */ - private invalidateCandidates(): void { + invalidateCandidates(): void { this.candidatesCache = null; } @@ -581,7 +465,7 @@ export class GameConfigService { } /** Which manifest dialect `root` speaks — the PC library's, or a card's (see ManifestSource). */ - private sourceOf(root: string): ManifestSource { + sourceOf(root: string): ManifestSource { return root === this.deps.pcLibrary.root ? 'pc' : 'card'; } @@ -643,10 +527,10 @@ export class GameConfigService { /** * The media's identity — the same sorted-ids signature a DriveCandidate carries. It answers the one * question `isAllowedRoot` cannot: a card swapped into the same mountpoint keeps the root valid while - * the FILE underneath is someone else's (see the plan, Р6.2). Our own edits do not move it (the ids + * the FILE underneath is someone else's. Our own edits do not move it (the ids * stay), so a second save after the first still goes through. */ - private async signatureOf(root: string): Promise { + async signatureOf(root: string): Promise { const manifestPath = path.join(root, MANIFEST_FILENAME); const { signature } = await describeManifestContent( manifestPath, @@ -686,7 +570,7 @@ export class GameConfigService { // ── Reading / saving game.json ───────────────────────────────────────────── - private async readConfig(root: string): Promise { + async readConfig(root: string): Promise { const t = this.deps.getTranslator(); if (!(await this.isAllowedRoot(root))) { return { ok: false, message: t('errors.driveUnavailable') }; @@ -882,354 +766,9 @@ export class GameConfigService { return this.savePcLibrary(without, t); } - // ── Move to card (Р2.5): a local game leaves the PC library and lands on a card, in one transaction ── - // Two writes (the card's game.json, the library's) cannot be two separate gameConfig:save calls from the - // renderer without a window where the game exists in both places or neither — so the whole thing runs - // here. Order: checks (nothing written) → copy assets → copy saves → write the card → write the - // library → apply/notify → drop the stale sync-state baseline. A failure before the library write rolls - // back everything copied to the card; `fromText` is applied ONLY after the card write has already - // succeeded, and never otherwise (see moveToCard's own comments for exactly where). - // - // Public, unlike its sibling write paths, purely so test/game-move-transaction.test.ts can drive it: the - // rollback branches are the riskiest code in the service and are reachable only through the whole - // sequence, so they are exercised here rather than approximated by a carved-out core. - async moveToCard(request: GameMoveRequest): Promise { - const t = this.deps.getTranslator(); - - // 1. Checks — nothing is written until every one of these passes. - if ( - !(await this.isAllowedRoot(request.fromRoot)) || - !(await this.isAllowedRoot(request.toRoot)) - ) { - return { moved: false, message: t('errors.driveUnavailable') }; - } - if ( - (await this.signatureOf(request.fromRoot)) !== request.fromSignature || - (await this.signatureOf(request.toRoot)) !== request.toSignature - ) { - return { moved: false, message: t('errors.mediaChanged') }; - } - if (this.deps.isBusy()) { - return { moved: false, message: t('gameConfig.moveGameBusy') }; - } - // A move must not rename: everything this PC remembers about the game is keyed by id (stats, the - // history record, the pending-flush queue), so a rename mid-move would orphan the lot. The renderer - // hides the id row while a move is pending; this is the server-side half of that rule, and it is what - // makes addressing the two sides by two different ids below provably equivalent. - if (request.fromId !== request.id) { - return { moved: false, message: t('gameConfig.moveIdChanged') }; - } - if (countGamesWithId(request.id, request.toText) !== 1) { - return { moved: false, message: t('gameConfig.moveIdTaken') }; - } - const toValidation = validateManifestText(request.toText, t, 'card'); - if (!toValidation.ok) { - return { moved: false, message: this.firstIssueMessage(toValidation.issues, t) }; - } - // fromText is derived HERE, from a fresh read — never trusted from the renderer (see game-move.ts). - // Addressed by `fromId` (what the game was READ with), never by the editable `id`. - const fromRead = await this.readConfig(request.fromRoot); - if (!fromRead.ok) return { moved: false, message: fromRead.message }; - // removeGameFromManifestText is a silent no-op for an id that isn't there, which would write the - // library back UNCHANGED and report a successful move — so the game's presence is asserted first. - if (countGamesWithId(request.fromId, fromRead.text) !== 1) { - return { moved: false, message: t('errors.gameNotFound') }; - } - const fromText = removeGameFromManifestText(request.fromId, fromRead.text); - if (fromText === null) return { moved: false, message: t('errors.configInvalid') }; - const fromValidation = validateManifestText(fromText, t, 'pc'); - if (!fromValidation.ok) { - // Our own slot is gone from this text, so whatever is wrong belongs to a game that stays behind — - // say so, or the user reads it as a complaint about the game they are moving. - return { - moved: false, - message: t('gameConfig.moveLibraryInvalid', { - reason: this.firstIssueMessage(fromValidation.issues, t), - }), - }; - } - const manifest = this.deps.resolveManifest(request.fromId); - if (manifest === null || manifest.source !== 'pc') { - return { moved: false, message: t('errors.gameNotFound') }; - } - const targetRaw = findGameInText(request.id, request.toText); - if (targetRaw === null) return { moved: false, message: t('errors.gameNotFound') }; - // The game's OWN files (its exe) must already be on the card — otherwise the card would read as - // having an invalid game.json the instant it is inserted (see the plan, Р2.6). - const expectedFile = expectedGameFilePath(targetRaw); - if (expectedFile !== null) { - const resolvedFile = resolveInside(request.toRoot, expectedFile); - if (resolvedFile === null || !(await fse.pathExists(resolvedFile))) { - return { moved: false, message: t('gameConfig.moveFilesNotOnCard') }; - } - } - // The card's game.json exactly as it stands right now — the ONLY faithful "before" picture for the - // step-5 rollback. Reconstructing it by subtracting our slot back out of `toText` would restore a - // re-serialization of the renderer's making instead (different top-level shape for a single-game - // card), and would lean on `toSignature === ''` to tell "there was no file" from "there was one". - const toBefore = await this.readManifestSnapshot(request.toRoot); - if (!toBefore.ok) return { moved: false, message: toBefore.message }; - - const gameTitle = typeof targetRaw['title'] === 'string' ? targetRaw['title'] : request.id; - // What went not-quite-right, as DATA rather than as prose: the outcomes below drive both the - // notifications and the result's `warning`, and more than one of them can happen in a single move. - const warnings: MoveWarning[] = []; - - // 2. Copy assets (hero/grid/music), under the deterministic names the renderer already wrote into - // toText — see asset-move-names.ts. `existedBefore` is recorded per destination so a rollback removes - // only what this move created: deleting a file that was already there would destroy it outright, - // since the overwrite has no undo. - const copied: AssetCopyRecord[] = []; - const saveCopy: SaveCopyRecord = { dir: null, existedBefore: false }; - const undo = async (): Promise => { - await this.rollbackMoveCopies(copied, saveCopy); - }; - try { - for (const plan of planAssetCopies(manifest, request.id, request.toRoot)) { - // Checked BEFORE the copy so a file the user deleted since the game was configured is reported as - // what it is. Left to fse.copy it would surface as an ENOENT inside the catch below, and the user - // would be told that game.json could not be written — a file nothing has tried to touch yet. - if (!(await fse.pathExists(plan.from))) { - await undo(); - return { moved: false, message: t('gameConfig.moveAssetMissing', { path: plan.from }) }; - } - const existedBefore = await fse.pathExists(plan.to); - await fse.ensureDir(path.dirname(plan.to)); - await fse.copy(plan.from, plan.to, { overwrite: true }); - copied.push({ to: plan.to, existedBefore }); - } - } catch (cause) { - await undo(); - return { - moved: false, - message: t('errors.cannotWriteManifest', { - file: MANIFEST_FILENAME, - cause: describe(cause), - }), - }; - } - - // 3. Copy saves — only when the TARGET manifest actually names a saveOnCard folder, and PREFERABLY - // from the LIVE save location rather than the pc-games/saves/ backup: with no card-side baseline - // yet, the first sync-in falls back to the deterministic card→pc direction (save-sync.ts), so what - // travels here is what that fallback will write back over the live folder. The backup is used only - // when the live location has nothing to offer at all (no folder, or a Wine prefix that does not exist - // yet) — there the fallback has nothing to overwrite, so a stale backup beats no saves. - const targetSaveOnCard = - typeof targetRaw['saveOnCard'] === 'string' ? targetRaw['saveOnCard'] : undefined; - if (targetSaveOnCard !== undefined) { - const saveTargetDir = resolveInside(request.toRoot, targetSaveOnCard); - if (saveTargetDir === null) { - await undo(); - return { moved: false, message: t('gameConfig.pickOutsideCard') }; - } - const sourceDir = await this.liveOrBackupSaveDir(manifest); - if (sourceDir !== null) { - const targetExisted = await fse.pathExists(saveTargetDir); - const targetNonEmpty = targetExisted && (await fse.readdir(saveTargetDir)).length > 0; - if (targetNonEmpty) { - warnings.push('save-skipped'); - } else { - try { - await fse.ensureDir(path.dirname(saveTargetDir)); - await fse.copy(sourceDir, saveTargetDir, { overwrite: true }); - // Recorded even when the folder was already there (empty): what has to be undone is what we - // PUT IN it, not merely the folder we may or may not have created. - saveCopy.dir = saveTargetDir; - saveCopy.existedBefore = targetExisted; - } catch (cause) { - await undo(); - return { - moved: false, - message: t('errors.cannotWriteManifest', { - file: MANIFEST_FILENAME, - cause: describe(cause), - }), - }; - } - } - } - } - - // 4. Write the card. On disk to this exact moment on, `fromText` is the only thing left to apply — - // everything above only touched the CARD side. - try { - await writeFileAtomicEnsuringDir( - path.join(request.toRoot, MANIFEST_FILENAME), - request.toText, - ); - } catch (cause) { - await undo(); - return { - moved: false, - message: t('errors.cannotWriteManifest', { - file: MANIFEST_FILENAME, - cause: describe(cause), - }), - }; - } - - // 5. Write the library. A failure here triggers a best-effort rollback of the card (step 4) back to - // its pre-move bytes — `fromText` is NEVER applied when this happens (see the plan, Р2.5's rollback - // note): applying it would make the game disappear from the PC library without landing on the card, - // which is worse than the duplicate a failed rollback leaves behind. - const fromWrite = await this.writePcLibraryText(fromText, t); - if (!fromWrite.ok) { - const restored = await this.rollbackCardWrite(request.toRoot, toBefore.text); - if (restored) { - // The card is back to its pre-move bytes, so the copies of steps 2/3 are now referenced by - // nothing — and only NOW may they go. Undoing them while the card still names them (the branch - // below) would leave the target manifest pointing at art and saves that are no longer there. - await undo(); - return { moved: false, message: fromWrite.message }; - } - // The card write is stuck AND the library still has the game too — a defined outcome (a card game - // shadows its local twin, README.md "Local games"), not corruption, but worth a loud log: two - // independent writes failed back to back to get here. The copies STAY: the card's game.json still - // refers to them, and a duplicate whose art and saves are intact is the whole point of calling this - // "a defined outcome" rather than damage. - log.error( - `[game-move] id=${request.id}: card write kept but the library write failed and the card` + - ` rollback ALSO failed — the game now exists in both places (${fromWrite.message})`, - ); - warnings.push('duplicate'); - } - - // 6. Apply / defer, exactly like an ordinary card save. - this.invalidateCandidates(); - let applied: 'applied' | 'deferred' | 'failed'; - if (request.toRoot === this.deps.getActiveRoot()) { - const reload = await this.deps.reloadManifest(request.toRoot); - // `failed`, not `applied`, when the re-read was refused — the same verdict `save()` gives for the - // same case. Reporting it as applied sent the caller off to focus a game the carousel has not got. - applied = reload.ok ? 'applied' : 'failed'; - if (!reload.ok) { - log.warn( - `[game-move] id=${request.id}: moved, but reloading the active card failed: ${reload.message}`, - ); - } - } else { - applied = 'deferred'; - this.deps.notify({ kind: 'game-moved-deferred', gameTitle }); - } - // Every warning gets its own notification, which is the ONLY channel it has: the screen closes the - // moment a move succeeds, so a field on the result would have nobody left to show it (and the - // duplicate case must not be the log's secret alone). - for (const kind of warnings) { - this.deps.notify({ kind: MOVE_WARNING_NOTIFICATION[kind], gameTitle }); - } - - // 7. The library backup ↔ save-folder pairing this baseline described is gone now that the game has - // left the library — a stale one would read as a false conflict if the game is ever moved back. Only - // when the library write actually went through: in the duplicate case the game is still there, and - // its baseline is still the truth. - if (fromWrite.ok) await this.deps.pcStore.removeSyncState(request.fromId, 'pc'); - - return { moved: true, applied }; - } - - /** - * The manifest text of a root as it stands right now, or null when the root carries no game.json. An - * unreadable-but-present file is an ERROR rather than a null: null means "delete the file to undo", and - * guessing that for a file we simply failed to read would destroy it. - */ - private async readManifestSnapshot( - root: string, - ): Promise< - | { readonly ok: true; readonly text: string | null } - | { readonly ok: false; readonly message: string } - > { - const file = path.join(root, MANIFEST_FILENAME); - if (!(await fse.pathExists(file))) return { ok: true, text: null }; - try { - return { ok: true, text: await fse.readFile(file, 'utf8') }; - } catch (cause) { - return { - ok: false, - message: this.deps.getTranslator()('errors.cannotReadManifest', { - file: MANIFEST_FILENAME, - cause: describe(cause), - }), - }; - } - } - - private firstIssueMessage( - issues: readonly { readonly path: string; readonly message: string }[], - t: Translator, - ): string { - const first = issues[0]; - return first !== undefined ? `${first.path}: ${first.message}` : t('errors.configInvalid'); - } - - /** The folder a move copies saves FROM: the live location if it (and its container) exist, else the - * PC-library's own backup (`saves/`) as a fallback. Null when neither has anything to copy. */ - private async liveOrBackupSaveDir(manifest: ResolvedManifest): Promise { - if (manifest.pcSavePath !== undefined) { - const live = await this.deps.savePathResolver.resolvePcSavePath( - manifest, - manifest.pcSavePath, - ); - if (live !== null && live.containerExists && (await fse.pathExists(live.path))) { - return live.path; - } - } - if (manifest.saveOnCardPath !== undefined && (await fse.pathExists(manifest.saveOnCardPath))) { - return manifest.saveOnCardPath; - } - return null; - } - - /** - * Undoes what steps 2/3 put on the CARD — never touches the PC library. - * - * An asset whose destination was ALREADY occupied is deliberately left alone: the copy overwrote it and - * there is nothing to restore, so removing it would turn "the move failed" into "and your file is gone - * too". The saves folder is emptied rather than removed when it predates the move. - */ - private async rollbackMoveCopies( - copied: readonly AssetCopyRecord[], - saveCopy: SaveCopyRecord, - ): Promise { - for (const asset of copied) { - if (asset.existedBefore) continue; - try { - await fse.remove(asset.to); - } catch (cause) { - log.warn(`[game-move] failed to roll back copied asset "${asset.to}":`, describe(cause)); - } - } - if (saveCopy.dir === null) return; - try { - await fse.remove(saveCopy.dir); - if (saveCopy.existedBefore) await fse.ensureDir(saveCopy.dir); - } catch (cause) { - log.warn( - `[game-move] failed to roll back the copied save folder "${saveCopy.dir}":`, - describe(cause), - ); - } - } - - /** - * Best-effort revert of the card's game.json after the library write failed post-write: writes back the - * bytes the card carried before the move, or deletes the file when it had none (`toTextBefore === null`). - * Returns whether the revert itself succeeded. - */ - private async rollbackCardWrite(toRoot: string, toTextBefore: string | null): Promise { - try { - if (toTextBefore === null) await fse.remove(path.join(toRoot, MANIFEST_FILENAME)); - else await writeFileAtomicEnsuringDir(path.join(toRoot, MANIFEST_FILENAME), toTextBefore); - return true; - } catch (cause) { - log.warn(`[game-move] failed to roll back the card write at "${toRoot}":`, describe(cause)); - return false; - } - } - /** Writes (or removes, if empty) the PC library's game.json and reloads it — the shared half of * savePcLibrary that moveToCard also needs, without savePcLibrary's ConfigSaveResult shape. */ - private async writePcLibraryText( + async writePcLibraryText( text: string, t: Translator, ): Promise<{ readonly ok: true } | { readonly ok: false; readonly message: string }> { @@ -1257,209 +796,6 @@ export class GameConfigService { return { ok: true }; } - /** - * Turns absolute path(s) into what the manifest field actually stores: card-RELATIVE with forward - * slashes, a `%PREFIX%/…` save path, a verbatim absolute, or a library-relative asset that was copied - * in. Shared by the native dialog and the in-launcher picker. - * - * The dialog used to be the consent gate for all of this: an absolute path could only arrive because - * the OS handed it over, which is why this file could say "main never trusts the renderer's path" and - * still copy whatever it was given. The in-launcher picker takes that gate away, so the checks are - * stated here instead (see the plan, Р5.1) — the root must be a live candidate, the path must exist and - * not be a symlink, and its TYPE must match the field: an `~/.ssh/id_rsa` offered as a hero image is - * refused before anything reads or copies it. - */ - private async acceptPickedPaths( - root: string, - kind: ConfigPickKind, - absolutePaths: readonly string[], - base?: string, - ): Promise { - const t = this.deps.getTranslator(); - if (!(await this.isAllowedRoot(root))) { - return { ok: false, message: t('errors.driveUnavailable') }; - } - // A field measured from a sub-directory (see GameConfigAcceptRequest.base) still lives inside the - // root, and `resolveInside` is what proves it: the renderer names the sub-path, so it gets the same - // anti-traversal treatment every other manifest path does. - const measureFrom = base === undefined || base === '' ? root : resolveInside(root, base); - if (measureFrom === null) return { ok: false, message: t('gameConfig.pickOutsideCard') }; - if (absolutePaths.length === 0) return { ok: false, cancelled: true }; - const isPcLibrary = this.sourceOf(root) === 'pc'; - // A local game's own executable and its host-side save folder only exist in the PC library. - if ((kind === 'pc-executable' || kind === 'pc-save-local') && !isPcLibrary) { - return { ok: false, message: t('errors.driveUnavailable') }; - } - for (const absolute of absolutePaths) { - const rejection = await this.checkPickedType(absolute, kind); - if (rejection !== null) return { ok: false, message: rejection }; - } - - // pcSavePath points at a PC folder OUTSIDE the card (env-prefixed), so the absolute result is - // converted back to a %PREFIX%/… form the validator accepts. A local game running from this machine's - // own disk keeps the absolute path VERBATIM (`pc-save-local`). Converting it would be actively wrong - // there: on Linux the reverse mapping only knows folders inside a Wine prefix and rejects everything - // else, so the typical local save folder (`~/Games/Hades/Saves`) could not be picked at all — and pc - // mode accepts an absolute path precisely because a %PREFIX% cannot express it. It is the form that - // decides which of the two kinds applies, by launch mode: a local STEAM game keeps `pc-save`, because - // ITS saves sit inside Steam's Proton prefix and only the %PREFIX% form maps onto compatdata (an - // absolute path there would also be read with containerExists: true, which would let a deleted prefix - // be mistaken for deleted saves). - const first = absolutePaths[0]; - if (first === undefined) return { ok: false, cancelled: true }; - if (kind === 'pc-save-local' || kind === 'pc-executable') return { ok: true, paths: [first] }; - if (kind === 'pc-save') { - const pcSavePath = this.deps.toManifestPcSavePath(first); - if (pcSavePath === null) return { ok: false, message: t('gameConfig.pickPcSaveOutside') }; - return { ok: true, paths: [pcSavePath] }; - } - // Art and music for a local game are picked from anywhere and COPIED into the library, so what the - // manifest stores is a library-relative path — the same shape a card's asset has, which is what keeps - // resolveInside and the AssetReader free of any PC-specific branch (and the art alive after the user - // deletes the original). - if (isPcLibrary && (kind === 'image' || kind === 'audio')) { - const extensions = kind === 'image' ? IMAGE_EXTENSIONS : AUDIO_EXTENSIONS; - const relatives: string[] = []; - for (const absolute of absolutePaths) { - try { - relatives.push(await this.deps.pcLibrary.importAsset(absolute, kind, extensions)); - } catch (cause) { - log.warn('[game-config] importing a local asset failed:', describe(cause)); - return { ok: false, message: t('gameConfig.pickImportFailed') }; - } - } - return { ok: true, paths: relatives }; - } - - const relatives: string[] = []; - for (const absolute of absolutePaths) { - const relative = toCardRelative(measureFrom, absolute); - if (relative === null) { - return { - ok: false, - message: t( - kind === 'directory' ? 'gameConfig.pickChooseSubfolder' : 'gameConfig.pickOutsideCard', - ), - }; - } - relatives.push(relative); - } - return { ok: true, paths: relatives }; - } - - /** Whether one picked path may be used for `kind`; a localized reason when it may not, else null. */ - private async checkPickedType(absolute: string, kind: ConfigPickKind): Promise { - const t = this.deps.getTranslator(); - let stat: Parameters[2] = null; - try { - const stats = await fs.lstat(absolute); - stat = { - isSymbolicLink: stats.isSymbolicLink(), - isDirectory: stats.isDirectory(), - isFile: stats.isFile(), - }; - } catch { - stat = null; - } - const rejection = checkPickedType(absolute, kind, stat, extensionsFor(kind)); - return rejection === null ? null : rejectionMessage(rejection, t); - } - - // ── Directory listing for the in-launcher file picker ────────────────────── - - /** - * One directory's contents, plus the starting points offered beside it. READ-ONLY and deliberately - * unrestricted: where to browse is the user's business (the most common install path of all, - * `C:\Program Files (x86)\Steam\steamapps\common\…`, is a system directory by any definition). What is - * guarded is the ACCEPTANCE of a path, not the looking — see acceptPickedPaths. - */ - private async listDir(request: GameConfigListDirRequest): Promise { - const t = this.deps.getTranslator(); - const roots = await this.pickerRoots(); - // A field measured from a sub-directory browses from there; an unresolvable one falls back to the - // root rather than failing — this is where the picker OPENS, not what it will accept. - const baseDir = - request.root !== undefined && request.base !== undefined && request.base !== '' - ? resolveInside(request.root, request.base) - : null; - const target = - request.path ?? - startDirFor( - { ...request, ...(baseDir !== null ? { baseDir } : {}) }, - { - homeDir: os.homedir(), - appDataDir: app.getPath('appData'), - downloadsDir: app.getPath('downloads'), - rootIsCard: request.root !== undefined && this.sourceOf(request.root) === 'card', - }, - ); - let names: readonly string[]; - try { - names = await fs.readdir(target); - } catch (cause) { - log.warn(`[game-config] cannot list "${target}":`, describe(cause)); - return { ok: false, message: t('gameConfig.listFailed'), roots }; - } - const entries: DirEntry[] = []; - for (const name of names) { - if (name.startsWith('.')) continue; // dotfiles are noise in a picker for games and artwork - try { - // stat, not lstat: a symlinked folder is a folder to browse. Accepting what is INSIDE it is a - // separate decision, made by acceptPickedPaths, which refuses symlinks on its own. - const full = path.join(target, name); - const stats = await fs.stat(full); - // A macOS `.app` bundle is a directory the user means as one file — see listsAsFile. - const isDir = stats.isDirectory() && !listsAsFile(full, true, request.kind); - entries.push({ name, kind: isDir ? 'dir' : 'file' }); - } catch { - continue; // a dangling link or an unreadable entry — simply not offered - } - } - entries.sort((a, b) => - a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === 'dir' ? -1 : 1, - ); - const parent = path.dirname(target); - return { - ok: true, - path: target, - parent: parent === target ? null : parent, - entries, - roots, - }; - } - - /** The left column: the card, this machine's library, the home folder and every mounted volume. */ - private async pickerRoots(): Promise { - const t = this.deps.getTranslator(); - const roots: DirRoot[] = []; - const card = this.deps.getActiveRoot(); - if (card !== null) roots.push({ path: card, label: card, kind: 'card' }); - roots.push({ path: this.deps.pcLibrary.root, label: t('gameConfig.thisPc'), kind: 'pc' }); - roots.push({ path: os.homedir(), label: t('gameConfig.homeFolder'), kind: 'home' }); - try { - for (const mount of await listAllMountpoints()) { - if (roots.some((entry) => entry.path === mount)) continue; - roots.push({ path: mount, label: mount, kind: 'drive' }); - } - } catch (cause) { - log.warn('[game-config] enumerating volumes for the picker failed:', describe(cause)); - } - return roots; - } - - /** - * Reads a card-relative image into a data URL for the hero preview. Reuses the manifest's anti-traversal - * (`resolveInside`) and the untrusted-root check, so the preview can only read files INSIDE the card. - * Returns null on any rejection/failure (the renderer just shows no thumbnail). - */ - private async imagePreview(root: string, relative: string): Promise { - if (!(await this.isAllowedRoot(root))) return null; - const resolved = resolveInside(root, relative); - if (resolved === null) return null; - const url = await readImageDataUrl(resolved); - return url ?? null; - } - /** * The public face of `isAllowedRoot` — true when `root` is a current removable/non-system mountpoint, * or the app's own PC-library root (the closed set of roots this service will ever write to) — for the diff --git a/src/main/game-controller.ts b/src/main/game-controller.ts new file mode 100644 index 00000000..1120a604 --- /dev/null +++ b/src/main/game-controller.ts @@ -0,0 +1,1391 @@ +// The card session + IPC registration. +// This is where the state machine lives: the controller listens to drive-watcher, reads the inserted +// card and the PC library, replicates AppState to the window, and dispatches the "Launch" / "Uninstall" +// actions from the renderer to GameSequences (sync→spawn→wait→sync lives there). What the window shows +// is held and pushed by BrowsePresenter. All FS/process work happens only in main. +import fse from 'fs-extra'; +import { app, clipboard, ipcMain } from 'electron'; +import { + IPC, + type AppState, + type SfxSet, + type BrowseInfo, + type ConfigSaveResult, + type GameCollision, + type GameCollisionAnswer, + type GameInfo, + type GameLibrary, + type HeroAssets, + type ManifestSource, + type Stats, +} from '../shared/types'; +import type { ResolvedManifest } from './manifest-types'; +import { type Translator } from '../shared/i18n/index'; +import { byRecentlyPlayed } from './library-index'; +import { + commitHistorySync, + rollbackHistorySync, + syncHistoryConfig, + type HistorySyncResult, +} from './history-sync'; +import { localGameGoesAfterMerge } from './history-config'; +import { sweepAtomicTemps } from './json-store'; +import { readManifests, isSafeGameId, type ManifestEnv } from './manifest'; +import { type CollisionResolver, type ControllerDeps } from './controller-deps'; +import { steamInstallStatus } from './steam'; +import { openSteamUri } from './steam-uri'; +import { AssetReader } from './asset-reader'; +import { BrowsePresenter } from './browse-presenter'; +import { GameSequences } from './game-sequences'; +import { SaveSyncFlow } from './save-sync-flow'; +import { SteamActivityWatch, type SteamWatchEntry } from './steam-activity-watch'; +import { GameJobs, installMarkerOf } from './game-jobs'; +import { GameActions } from './game-actions'; +import { describe } from './util'; +import { log } from './logger'; + +/** + * The root-relative asset paths one manifest references (art + music), as written in game.json. Used to + * tell the PC library which files in its `assets/` are still in use — see PcLibraryStore.gcOrphans. + */ +function referencedAssets(manifest: ResolvedManifest): readonly string[] { + const { heroImage, gridImage, backgroundMusic } = manifest.raw; + const heroes = heroImage === undefined ? [] : typeof heroImage === 'string' ? [heroImage] : heroImage; + return [...heroes, ...(gridImage !== undefined ? [gridImage] : []), ...(backgroundMusic !== undefined ? [backgroundMusic] : [])]; +} + +export class GameController { + // A card carries one OR MANY games (game.json is an object or an array). `cardGames` holds every game + // resolved from the inserted card; `pcGames` the local ones from the PC library, which are available + // whether or not a card is in. `games` (below) is their union — the list every consumer reads — and + // `selectedId` names the one currently selected. `current()` derives the single "active" manifest that + // all the existing launch/kill/uninstall/save-sync/stats code reads, so those bodies stay untouched. + // Empty (`cardGames=[]`) whenever no card / rejected. + private cardGames: ResolvedManifest[] = []; + private pcGames: ResolvedManifest[] = []; + // The SELECTION is by id, not by index: with two sources the list is rebuilt from both (a card comes and + // goes underneath it), and an index would silently point at a different game every time it changed. + private selectedId: string | null = null; + private cardPresent = false; + // True for the whole body of loadCard. A game on the card being read right now is about to become + // available, so save-from-history must refuse: an invoke that slipped in after the sync step and before + // the manifests landed would be stored as "pending" and sit there until the NEXT insertion. + private cardLoadInFlight = false; + // True while a collision answer is being carried out. The merge writes the card and reloads it, which + // re-enters loadCard — and the detection at the end of it would ask the very question being answered. + private collisionInFlight = false; + // Mirror of AppSettings.keepOpenWithoutCard (seeded at startup, toggled live from the settings + // window): when true the launcher stays on screen with no card in instead of hiding to the tray. + // Initialized to the SCHEMA's default so the sliver between constructing this controller and the seed + // behaves like the setting it mirrors — keep the two in step if that default ever changes. + private keepOpenWithoutCard = true; + // A manifest reload from the Customize screen is in flight. Unlike launchInFlight it does NOT + // gate on state kind (the reload runs from `ready`), so onLaunchRequested/onUninstallRequested check + // it explicitly: during the reload's awaits (readManifest + hero/audio on a slow SD — hundreds of ms) + // the state stays `ready`, and a gamepad Play would otherwise start a game mid-reload (enterReady over + // launching). Only the reload path is raced like this — an ordinary insert never is. + private reloadInFlight = false; + // The renderer parked the cursor on one of the launcher's own cards (browse:game with null). While it + // holds, main NEVER moves the cursor on its own — a card inserted, a session finished, a library + // reloaded: the row stays where the user left it (see browseToUnlessPinned). Only the renderer clears + // it, by browsing a game again. Not "main knowing about the UI": currentBrowse is the view model + // already, and this flag is what tells "the cursor was set on purpose" from "there is nothing to show". + private browsePinned = false; + // The reconciled Stats per game id, captured in loadCard so onSelectRequested can rebuild the selected + // game's GameInfo without re-reading stats (buildGameInfo still re-reads the .acf for a steam game). + private statsById = new Map(); + // Reads card assets (hero/audio/wallpaper) into data URLs; owns the bundled-wallpaper cache and reads + // the live audio settings via DI. + private readonly assets = new AssetReader({ + getSoundSet: async () => (await this.deps.settings.read()).soundSet, + getAmbientTrack: async () => (await this.deps.settings.read()).ambientTrack, + getOnlyGlobalAmbient: async () => (await this.deps.settings.read()).onlyGlobalAmbient, + }); + // What the window shows (hero/music/ambience/sfx/row/browse cursor) and the pushes that keep it so — + // see browse-presenter.ts. The controller decides which game; the presenter holds and delivers it. + private readonly presenter = new BrowsePresenter({ + assets: this.assets, + send: (channel, payload) => this.deps.window.send(channel, payload), + findManifest: (id) => this.games.find((m) => m.raw.id === id) ?? null, + readBrowseAssets: (id) => this.deps.library.readBrowseAssets(id), + onlyGlobalAmbient: async () => (await this.deps.settings.read()).onlyGlobalAmbient, + }); + private readonly steamWatch: SteamActivityWatch; + readonly jobs: GameJobs; + private readonly actions: GameActions; + // The process lifecycle (launch / install / uninstall / prefix cleanup, force-close, the save sync + // around a session) — see game-sequences.ts. Dispatched to from the renderer's actions below; it + // reaches back through the SequenceHost seam for the card session's answers and transitions. + private readonly sequences: GameSequences; + // The card↔PC save sync: the deferred flush on insert (used here) and the sync-in / sync-out the launch + // sequence brackets a game with — see save-sync-flow.ts. + private readonly saveSync: SaveSyncFlow; + + constructor(private readonly deps: ControllerDeps) { + this.steamWatch = new SteamActivityWatch({ + listSteamGames: () => this.steamGames(), + registry: this.deps.activities, + sessionGameId: () => this.sessionGameId(), + onGameChanged: (id) => this.refreshGame(id), + onInstallCompleted: (game) => + this.deps.notifications.notify({ + kind: 'game-installed', + gameId: game.id, + gameTitle: game.title, + }), + onUninstallCompleted: (game) => + this.deps.notifications.notify({ + kind: 'game-uninstalled', + gameId: game.id, + gameTitle: game.title, + }), + steamLocator: () => this.deps.platform.steamLocator, + }); + this.jobs = new GameJobs({ + ...this.deps, + host: { + sessionGameId: () => this.sessionGameId(), + onGameChanged: (id) => this.refreshGame(id), + isWindowFocused: () => this.deps.window.isFocused(), + sendError: (message) => this.sendError(message), + }, + }); + this.saveSync = new SaveSyncFlow({ + store: this.deps.store, + stats: this.deps.stats, + savePathResolver: this.deps.platform.savePathResolver, + sourceAvailable: (manifest) => this.sourceAvailable(manifest), + }); + this.sequences = new GameSequences({ + state: this.deps.state, + window: this.deps.window, + stats: this.deps.stats, + library: this.deps.library, + settings: this.deps.settings, + notifications: this.deps.notifications, + platform: this.deps.platform, + processControl: this.deps.processControl, + getTranslator: this.deps.getTranslator, + activities: this.deps.activities, + steamWatch: this.steamWatch, + saveSync: this.saveSync, + host: { + current: () => this.current(), + buildGameInfo: (manifest, stats) => this.buildGameInfo(manifest, stats), + enterReady: (info) => this.enterReady(info), + sourceAvailable: (manifest) => this.sourceAvailable(manifest), + currentSourceAvailable: () => this.currentSourceAvailable(), + cardGoneAfterSequence: () => this.cardGoneAfterSequence(), + browseToUnlessPinned: (id) => this.browseToUnlessPinned(id), + refreshLibrary: () => this.refreshLibrary(), + rememberStats: (id, stats) => this.statsById.set(id, stats), + sendError: (message) => this.sendError(message), + onInsert: (root) => this.onInsert(root), + }, + }); + this.actions = new GameActions({ + ...this.deps, + sequences: this.sequences, + jobs: this.jobs, + host: { + games: () => this.games, + current: () => this.current(), + sourceAvailable: (manifest) => this.sourceAvailable(manifest), + isReloading: () => this.reloadInFlight, + sessionGameId: () => this.sessionGameId(), + select: (id) => this.onSelectRequested(id), + buildGameInfo: async (manifest) => + this.buildGameInfo(manifest, this.statsById.get(manifest.raw.id) ?? (await this.deps.stats.read(manifest.raw.id))), + sendError: (message) => this.sendError(message), + }, + }); + } + + /** The current translator (a message is fixed at the language of the moment it is generated). */ + private get t(): Translator { + return this.deps.getTranslator(); + } + + /** + * Every game that can be acted on right now: the inserted card's, then the PC library's. A local game + * whose id is ALSO on the card is dropped here — the card wins (it is the removable, user-visible + * medium, and `id` keys every piece of PC state, so the two cannot coexist). Recomputed on read: both + * lists are tiny, and a cached union would be one more thing to invalidate on every insert/removal. + */ + private get games(): readonly ResolvedManifest[] { + const cardIds = new Set(this.cardGames.map((manifest) => manifest.raw.id)); + return [...this.cardGames, ...this.pcGames.filter((m) => !cardIds.has(m.raw.id))]; + } + + /** Tells the user what the sync step did with their pending edits — one entry per game. */ + private notifyHistorySync(sync: HistorySyncResult): void { + for (const gameTitle of sync.applied) { + this.deps.notifications.notify({ kind: 'history-config-applied', gameTitle }); + } + for (const gameTitle of sync.discarded) { + this.deps.notifications.notify({ kind: 'history-config-discarded', gameTitle }); + } + } + + /** + * The single "active" manifest — the selected game — that every existing consumer reads + * (launch/kill/uninstall/save-sync/stats). Read-only: the games live in `cardGames`/`pcGames`, the + * choice in `selectedId`. Falls back to the first available game when the selection is gone (the card + * carrying it was pulled), and is null only when there is nothing at all. + */ + private current(): ResolvedManifest | null { + const games = this.games; + return games.find((manifest) => manifest.raw.id === this.selectedId) ?? games[0] ?? null; + } + + /** + * The game the CAROUSEL shows first, as a manifest — where a cursor with no opinion of its own belongs. + * `games` is in source order (the card's manifest as authored, then the library's `game.json`), while + * the row is sorted by how recently each game was touched: "the first game" means two different things, + * and the one the user can point at is the row's. Falls back to source order before the row exists, and + * to `current()` for a head that has no manifest (a history entry — only reachable with no game at all, + * since refreshLibrary puts every available game ahead of the history). + */ + private firstCarouselGame(library: GameLibrary | null = this.presenter.library): ResolvedManifest | null { + const headId = library?.games[0]?.id; + if (headId === undefined) return this.current(); + return this.games.find((manifest) => manifest.raw.id === headId) ?? this.current(); + } + + /** + * Whether this game's source is available right now. A card game needs its card in; a local game is on + * this machine's disk, so it always is. Everything that used to read `cardPresent` for a SPECIFIC + * manifest goes through here — with two sources, "no card" no longer means "this game is gone". + */ + private sourceAvailable(manifest: ResolvedManifest): boolean { + return manifest.source === 'pc' || this.cardPresent; + } + + /** sourceAvailable for the selected game; false when there is no game at all (nothing to show). */ + private currentSourceAvailable(): boolean { + const manifest = this.current(); + return manifest !== null && this.sourceAvailable(manifest); + } + + /** Every Steam game whose source is available right now - what the Steam activity watch polls. */ + private steamGames(): readonly SteamWatchEntry[] { + return this.games.flatMap((manifest) => + manifest.steam !== undefined && this.sourceAvailable(manifest) + ? [{ id: manifest.raw.id, title: manifest.raw.title, appid: manifest.steam.appid }] + : [], + ); + } + + /** + * The "the card went away while we were busy" landing, shared by the sequences that target the PC and + * therefore finish anyway (uninstall, prefix cleanup, an abandoned watched launch). With a local game + * left it stays on screen with that game selected; with nothing left it is the previous behaviour + * exactly — idle and out of the way. + */ + private cardGoneAfterSequence(): void { + this.clearCard(); + const remaining = this.firstCarouselGame(); + if (remaining !== null) { + void this.enterReadyForLocal(remaining); + return; + } + this.deps.state.set({ kind: 'idle' }); + this.hideToTrayOrKeepEmpty(); + } + + /** Clears all card-scoped state (games, selection, lock, audio/hero/library channels). The caller sets + * the follow-up AppState (idle/error) and window visibility, exactly as before. */ + private clearCard(): void { + this.cardGames = []; + // The selection falls back to whatever is still there (a local game), or to nothing — see current(). + this.selectedId = null; + this.sequences.unlock(); + this.forgetCardStats(); + // Music is card-only, so there is none on the empty screen. UI sounds are unaffected: they come from + // the bundled set on its own channel, which no card ever touched. + this.presenter.setCardMusic(null); + this.presenter.setHero(null); + // NOT setLibrary(null): the history outlives the card, and this runs from FIVE places (a rejected + // card, onRemove, and a card pulled mid-install/launch/uninstall). Blanking the list in any of them + // would collapse a populated carousel into the empty screen. The list is rebuilt with no active + // games, and the browse cursor moves onto whatever is left (a history entry, or nothing). + this.refreshLibrary(); + void this.reseedBrowse(); + void this.steamWatch.scanNow(); + } + + /** + * Drops the CARD games' cached stats, keeping the local library's. The cache is per-id and shared by + * both sources, so a blanket clear on card removal would strip the local games of their reconciled + * values (they'd fall back to a disk read — correct, but needlessly). + */ + private forgetCardStats(): void { + const localIds = new Set(this.pcGames.map((manifest) => manifest.raw.id)); + for (const id of [...this.statsById.keys()]) { + if (!localIds.has(id)) this.statsById.delete(id); + } + } + + /** + * Hides the launcher to the tray (the background-app default), OR — in SteamOS Game Mode, where there is + * no tray to hide into — keeps the empty "insert a card" screen up instead. Used at every "no card" + * exit point. On Windows/desktop this is a plain hide (unchanged behaviour). + */ + private hideToTrayOrKeepEmpty(): void { + if (this.deps.isGamescope) this.deps.window.showAndFocus(); + else this.deps.window.hide(); + } + + /** Subscriptions to drive-watcher, state replication to the window, IPC handlers. */ + init(): void { + const { state, window, watcher } = this.deps; + + state.subscribe((next) => window.send(IPC.stateUpdate, next)); + + watcher.onInsert((root) => void this.onInsert(root)); + watcher.onRemove(() => this.onRemove()); + watcher.onError((error) => log.error('[drive-watcher]', error)); + this.jobs.init(); + + ipcMain.handle(IPC.stateRequest, (): AppState => state.get()); + // Static for the process lifetime — seeds the renderer's Game Mode UI (e.g. "Close Playhook"). + ipcMain.handle(IPC.gameModeRequest, (): boolean => this.deps.isGamescope); + ipcMain.handle(IPC.cardMusicRequest, (): string | null => this.presenter.cardMusic); + ipcMain.handle(IPC.ambientRequest, (): string | null => this.presenter.ambient); + ipcMain.handle(IPC.heroRequest, (): HeroAssets | null => this.presenter.hero); + ipcMain.handle(IPC.libraryRequest, (): GameLibrary | null => this.presenter.library); + ipcMain.handle(IPC.browseRequest, (): BrowseInfo | null => this.presenter.browse); + ipcMain.handle(IPC.sfxSetRequest, (): SfxSet | null => this.presenter.sfxSet); + // The clipboard as text, for the on-screen keyboard's Paste. Trimmed of nothing here — what the + // field will accept is the keyboard's own rule (osk-text.ts sanitize), and it differs per field. + ipcMain.handle(IPC.clipboardRead, (): string => clipboard.readText()); + // The carousel asks for one card's artwork at a time, only for what is on screen, and caches it by id + // — that is what keeps the list channel light enough to re-push on every change. + ipcMain.handle(IPC.libraryGridRequest, (_event, id: unknown): Promise => { + if (typeof id !== 'string') return Promise.resolve(null); + return this.deps.library.readGridThumb(id); + }); + ipcMain.on( + IPC.libraryBrowse, + (_event, id: unknown, immediate: unknown) => void this.onBrowseRequested(id, immediate), + ); + ipcMain.on(IPC.libraryForget, (_event, id: unknown) => void this.onForgetRequested(id)); + ipcMain.handle( + IPC.gameCollisionResolve, + (_event, answer: GameCollisionAnswer): Promise => + this.resolveCollision(answer), + ); + ipcMain.handle(IPC.wallpaperRequest, (): Promise => this.assets.readWallpaperDataUrl()); + ipcMain.handle( + IPC.startupSoundRequest, + (): Promise => this.assets.readStartupSoundDataUrl(), + ); + ipcMain.on(IPC.actionLaunch, (_event, id: unknown) => void this.actions.launch(id)); + ipcMain.on(IPC.actionUninstall, (_event, id: unknown) => void this.actions.uninstall(id)); + // Game Mode: hiding is meaningless (no tray, and on Linux no summon hotkey) — ignore the Hide button + // so the only window can't vanish with no way back. Desktop/Windows hide to the tray as before. + ipcMain.on(IPC.actionHide, () => { + if (!this.deps.isGamescope) this.deps.window.hide(); + }); + ipcMain.on(IPC.actionOpenSteamDownloads, () => void this.onOpenSteamDownloads()); + ipcMain.on(IPC.actionKill, () => void this.sequences.onKillRequested()); + ipcMain.on(IPC.actionSelect, (_event, id: unknown) => void this.onSelectRequested(id)); + + void this.warmSfxSet(); + void this.warmAmbient(); + // Chained, not fired in parallel: both seed the carousel and the browse cursor, and warmLibrary's + // "no card → show the history" would otherwise race the local games onto the same screen. + void this.warmLibrary() + .then(() => this.loadPcLibrary()) + .catch((cause: unknown) => log.warn('[pc-library] initial load failed:', describe(cause))); + } + + /** Reads the bundled UI sound set once and delivers it to the window. It is screen-independent — the + * same set clicks on the empty screen, the carousel and a game's detail screen. */ + private async warmSfxSet(): Promise { + this.presenter.setSfxSet(await this.assets.readSfxSet()); + } + + /** Seeds the history carousel at startup: with no card inserted, the list and the browse cursor come + * purely from the library (this is what makes "pull the card, keep browsing" work). */ + private async warmLibrary(): Promise { + this.refreshLibrary(); + if (!this.cardPresent) await this.reseedBrowse(); + } + + /** Reads the default ambience from settings once at startup and pushes it to the game window (the + * renderer plays it only while no card music is present — it decides the priority + crossfade). */ + private async warmAmbient(): Promise { + const track = await this.deps.settings.read().then((s) => s.ambientTrack); + this.presenter.setAmbient(await this.assets.readAmbientDataUrl(track)); + } + + /** Sends a transient error to the renderer to surface in the error popup. */ + private sendError(message: string): void { + this.deps.window.send(IPC.errorShow, message); + } + + /** Stops the process waits and the watcher (on application exit). */ + shutdown(): void { + this.presenter.dispose(); + this.sequences.abortInFlight(); + this.steamWatch.stop(); + void this.jobs.abortAll('shutdown'); + this.deps.watcher.stop(); + } + + // ── Ready transition ─────────────────────────────────────────────────────── + + /** The single entry point for the `ready` state; also re-pushes the browse info of the same game. */ + private enterReady(info: GameInfo): void { + this.deps.state.set({ kind: 'ready', game: info }); + // AppState and BrowseInfo carry the SAME GameInfo whenever they are about the same game — and the + // detail screen reads the BROWSE one (`browse.game.requiresInstall` decides whether Play is there, + // `canUninstall` whether the menu offers Uninstall). Pushing only the state left the screen showing + // "Install" after an install had finished, until the user stepped out to the carousel and back in, + // which is what re-asked for the browse info. + // + // Only the INFO is re-pushed, never the assets: the hero images and the music have not changed, and + // re-reading them on every state change would cost megabytes per transition. + const browse = this.presenter.browse; + if (browse !== null && browse.id === info.id && browse.active) { + this.presenter.pushBrowse({ ...browse, game: info }); + } + } + + /** onGameChanged for the watch and the jobs, which fire and forget it. */ + private refreshGame(id: string): void { + void this.onGameChanged(id).catch((cause: unknown) => log.warn(`[refresh] id=${id} failed:`, describe(cause))); + } + + /** + * Steam or a job changed what can be done with game `id`: rebuilds its GameInfo and puts it wherever it is shown. + * Re-checked after the await: the state is only replaced while it is still `ready` on this very game + * with nothing in flight, otherwise only the browse info of the game on screen is refreshed. + */ + private async onGameChanged(id: string): Promise { + const manifest = this.games.find((m) => m.raw.id === id); + if (manifest === undefined) return; + const info = await this.buildGameInfo(manifest, this.statsById.get(id) ?? (await this.deps.stats.read(id))); + if (!this.games.includes(manifest)) return; + const snapshot = this.deps.state.get(); + if (snapshot.kind === 'ready' && snapshot.game.id === id && !this.sequences.inFlight && !this.reloadInFlight) { + this.enterReady(info); + return; + } + const browse = this.presenter.browse; + if (browse !== null && browse.id === id && browse.active) this.presenter.pushBrowse({ ...browse, game: info }); + } + + // ── Reaction to card insertion ─────────────────────────────────────────── + + private async onInsert(root: string): Promise { + // A card was swapped in mid-flight (no empty tick). Don't process it now — that would race the + // in-flight sequence. Stash it, abort the current flow; its finally replays this once it unwinds. + if (this.sequences.inFlight) { + log.info(`[insert] card swapped during launch/install — deferring root="${root}"`); + this.sequences.deferInsert(root); + return; + } + await this.loadCard(root, { focus: true }); + } + + /** + * Reads a card at `root` and drives the launcher to `ready` for the selected game (single- or + * multi-game card), or to `error` — the shared body of an ordinary insert AND a Customize save. + * A multi-game card exposes its other games through the history carousel (the light game list). `focus` + * controls whether the launcher pops to the front: true for a real insertion (unchanged behaviour), false + * for a reload so a Save from the Customize screen doesn't raise the window over what is on top. Returns the + * readManifests verdict so the caller (reloadManifest) can report it; onInsert ignores it. + */ + private async loadCard( + root: string, + opts: { readonly focus: boolean }, + ): Promise<{ ok: true } | { ok: false; message: string }> { + this.cardLoadInFlight = true; + try { + return await this.loadCardBody(root, opts); + } finally { + this.cardLoadInFlight = false; + } + } + + private async loadCardBody( + root: string, + opts: { readonly focus: boolean }, + ): Promise<{ ok: true } | { ok: false; message: string }> { + this.cardPresent = true; + log.info(`[insert] card detected at root="${root}"`); + // A card that was pulled mid-write leaves the temp file of that write behind, under a name unique to + // it that nothing will ever reuse. The card root is where the user SEES it, so it is swept here — in + // the background, because an insertion waits for nothing that is merely tidy. + void sweepAtomicTemps(root).catch((cause: unknown) => + log.warn('[insert] sweeping the card temp files failed:', describe(cause)), + ); + // Documents is resolved via the system Known Folder API (the same one the game uses), + // so %DOCUMENTS% in the manifest maps to the real save folder regardless of UI + // language or OneDrive redirection. Safe to read here — app is ready by now. + const env: ManifestEnv = { documents: app.getPath('documents'), t: this.t }; + // BEFORE the manifests are read: edits the user made from the history are written onto the card here, + // so everything downstream — validation, the resolved manifests, the history copy — sees one already + // reconciled file. It also runs on a plain reload (a Save from Customize), where it is a no-op: an + // available game cannot have pending edits, since save-from-history refuses one. + let sync = await syncHistoryConfig(root, { library: this.deps.library, t: this.t }); + let result = await readManifests(root, env, this.deps.platform.resolveInstallDir); + if (!result.ok && sync.textBefore !== null) { + // We rewrote the file and the card stopped reading. Put the author's own text back and try again + // rather than leave a card that the launcher itself bricked. `sync` becomes the undone version of + // itself, so the commit below leaves the edits pending and the user is told they were NOT applied. + const reverted = await rollbackHistorySync(root, sync); + if (reverted !== null) { + sync = reverted; + result = await readManifests(root, env, this.deps.platform.resolveInstallDir); + } + } + // Only now, and on EVERY path: the card has had its final say, so the history may record it and the + // user may be told. Both used to sit inside the sync itself, which meant a rollback erased the edits + // it claims to preserve and said nothing about it. + await commitHistorySync(sync, this.deps.library); + this.notifyHistorySync(sync); + if (!result.ok) { + // No valid game determined → keep the window hidden (the reason is in the log). We still set + // the error state so a manually-summoned window can show it, but we never auto-surface it. + log.warn(`[insert] manifest rejected: ${result.message}`); + this.clearCard(); + this.deps.state.set({ kind: 'error', message: result.message }); + // Desktop/Windows: keep the window hidden (background app — the error is in the log and only shows + // if the user summons the window). Game Mode: there is no tray to hide into, so surface the manifest + // error on screen instead of hiding. + if (this.deps.isGamescope) this.deps.window.showAndFocus(); + else this.deps.window.hide(); + return { ok: false, message: result.message }; + } + const manifests = result.manifests; + // Keep the selection on a reload if it still points at one of this card's games; a real insert starts + // at the first (an inserted card is what you are meant to be looking at, even mid-browse). + const keepSelection = + !opts.focus && manifests.some((manifest) => manifest.raw.id === this.selectedId); + this.cardGames = manifests; + void this.jobs.sweepPartials(manifests); + if (!keepSelection) this.selectedId = manifests[0]?.raw.id ?? null; + this.warnShadowedLocalGames(); + this.sequences.unlock(); + log.info(`[insert] manifest ok games=${manifests.length} ids=[${manifests.map((m) => m.raw.id).join(',')}] root="${root}"`); + + // Read the card's traveling stats ONCE to detect the pre-multi-game bare-Stats format. Attribution of + // that legacy value is decided HERE (only loadCard knows the game count): a single-game card owns it + // unambiguously; on a multi-game card the owner is unknown → ignore it (the per-id PC mirror is intact). + const cardStatsRead = await this.deps.stats.readCardStatsMap(root); + let legacyForSingle: Stats | null = null; + if (cardStatsRead.kind === 'legacy') { + if (manifests.length === 1) legacyForSingle = cardStatsRead.stats; + else log.warn(`[stats] legacy bare card stats on a ${manifests.length}-game card — owner ambiguous, ignoring (PC mirror per-id is intact)`); + } + + // Reconcile + copy card stats for EVERY game FIRST (so each PC mirror holds the merged value before + // anything writes the card), caching the merged Stats per id so onSelectRequested can rebuild the + // switched-to game's GameInfo without re-reading. Order matters vs the flush below. + this.forgetCardStats(); + for (const manifest of manifests) { + const stats = await this.deps.stats.reconcileWithCard(manifest.raw.id, root, legacyForSingle); + await this.deps.stats.copyToCard(root, manifest.raw.id, stats); + this.statsById.set(manifest.raw.id, stats); + } + + // If a card was yanked mid-game last time — top up the deferred PC→SD (saves snapshot) for ANY game + // that has a pending flush, not just the selected one (else game B's flush hangs until B is selected on + // some future insert). Runs AFTER all reconciles so each flush's stats copy uses the merged value. + for (const manifest of manifests) { + try { + await this.saveSync.flushPendingIfAny(manifest); + } catch (cause) { + log.warn(`[pending-flush] failed on insert for id=${manifest.raw.id}:`, describe(cause)); + } + } + + // The LIGHT carousel list — this card's games (active) followed by the play history. No heavy assets: + // the selected game's hero/audio are built on demand below, the cards' art on request. Built here but + // DELIVERED at the end, after the browse cursor: a row that arrives first is reshuffled twice on + // screen — once into the new order around the game the window is still showing, and again when the + // cursor moves to the card's own game. The renderer holds an early cursor for a row it does not have + // yet (see the carousel's pendingFocusId), so the late delivery costs nothing and the cards travel once. + const library = this.buildLibrary(); + + // A real insert starts on the card's first game AS THE ROW ORDERS THEM (by how recently each was + // played), not as game.json lists them — the cursor has to land where the user can see it. A reload + // keeps whatever was selected (keepSelection above). + if (!keepSelection) this.selectedId = this.firstCarouselGame(library)?.raw.id ?? this.selectedId; + + // Always enter `ready` for the selected game (single- or multi-game card). Its hero/audio go out on the + // existing per-game channels; the carousel handles switching between the card's games. + const selected = manifests.find((manifest) => manifest.raw.id === this.selectedId) ?? manifests[0]; + if (selected !== undefined) { + const stats = this.statsById.get(selected.raw.id) ?? (await this.deps.stats.read(selected.raw.id)); + this.presenter.setCardMusic(await this.presenter.cardMusicFor(selected)); + this.presenter.setHero(await this.assets.readHeroAssets(selected)); + this.enterReady(await this.buildGameInfo(selected, stats)); + // The card's own game is what you look at on insert (the single-game case is then exactly today's + // screen: browse.id === AppState.game.id). + await this.browseToUnlessPinned(selected.raw.id); + } + // …and only now the row, so it lands with the cursor already on the card it is about to put first. + this.presenter.setLibrary(library); + if (opts.focus) this.deps.window.showAndFocus(); + + // Copy this card's art/audio into the history IN THE BACKGROUND: a card is slow media and the window + // is already on screen. One sequential task for the whole card (index.json is a single file — see + // LibraryStore.saveFromCard), then a list refresh so the freshly-copied games get their artwork. + void this.deps.library + .saveFromCard(manifests, sync.slots) + .then(() => this.refreshLibrary()) + .catch((cause: unknown) => log.warn('[library] copying the card assets failed:', describe(cause))); + // Last, and only now: a game that lives on this card AND on this PC needs an answer from the user, + // and asking for one is not allowed to hold the card up (see askAboutCollisions). + this.askAboutCollisions(root); + void this.steamWatch.scanNow(); + return { ok: true }; + } + + /** + * Reads the PC library and folds it into the launcher, the way loadCard does for a card — minus the two + * things that belong to removable media: the card's traveling stats (a local game's mirror is the only + * copy there is) and, deliberately, the pending flush. + * + * NOT flushing is load-bearing, not an omission: a local game HAS a `saveOnCardPath` (its backup in the + * library), so a symmetrical copy of loadCard would pour a snapshot meant for the real card into that + * backup and then clear the queue — silently losing the progress the next card insertion was supposed + * to receive. Pending snapshots are for cards only; see performSyncOut. + */ + private async loadPcLibrary(): Promise { + const env: ManifestEnv = { documents: app.getPath('documents'), t: this.t }; + const read = await this.deps.pcLibrary.read(env, this.deps.platform.resolveInstallDir); + this.pcGames = [...read.manifests]; + void this.jobs.sweepPartials(read.manifests); + log.info(`[pc-library] ${read.manifests.length} local game(s) ids=[${read.manifests.map((m) => m.raw.id).join(',')}]`); + this.warnShadowedLocalGames(); + // A local game that is gone takes its collision answer with it: there is nothing left to collide, + // and a draft recreated under the same id later deserves the question afresh. + void this.deps.library + .clearCollisionAnswers(read.manifests.map((manifest) => manifest.raw.id)) + .catch((cause: unknown) => + log.warn('[collision] clearing the answers of removed local games failed:', describe(cause)), + ); + for (const manifest of read.manifests) { + this.statsById.set(manifest.raw.id, await this.deps.stats.read(manifest.raw.id)); + } + this.refreshLibrary(); + void this.steamWatch.scanNow(); + // With no card in, the local games are what the launcher has to show: leave `idle` for the first of + // them instead of the empty screen. A card (or any activity) present → don't touch the state machine. + if (!this.cardPresent && this.deps.state.get().kind === 'idle' && !this.sequences.inFlight) { + // The row's first card, not the library file's first entry — see firstCarouselGame. refreshLibrary + // above has already built the row this reads, so the two can't disagree. + const selected = this.firstCarouselGame(); + if (selected !== null) { + this.selectedId = selected.raw.id; + this.presenter.setHero(await this.assets.readHeroAssets(selected)); + this.presenter.setCardMusic(await this.presenter.cardMusicFor(selected)); + this.enterReady(await this.buildGameInfo(selected, this.statsById.get(selected.raw.id) ?? (await this.deps.stats.read(selected.raw.id)))); + await this.browseToUnlessPinned(selected.raw.id); + } + } else if (!this.cardPresent) { + await this.reseedBrowse(); + } else if (this.browseIsStale()) { + // A card is in, so neither branch above applies — but a LOCAL game may have just been deleted from + // the manifest, and it may be the very one on screen. The cursor would go on claiming that game is + // available, and the Details menu is built from exactly that claim: Customize would still be + // offered for a game the file no longer has, while "Remove from history" — the item that game now + // needs — would stay missing until the user left the screen and came back. + await this.reseedBrowse(); + } + this.dropStateIfGameGone(); + + // Same background copy a card gets: the artwork already lives in the library root, but the history is + // what the carousel draws from, and it is also what keeps a local game's card on screen after the + // game itself is deleted from disk. A local game SHADOWED by the card is skipped — both would write + // the same history record, and re-inserting the card would then flip its artwork back and forth. + const visibleLocal = this.games.filter((manifest) => manifest.source === 'pc'); + void this.deps.library + .saveFromCard(visibleLocal) + .then(() => this.refreshLibrary()) + .catch((cause: unknown) => log.warn('[library] copying the local games\' assets failed:', describe(cause))); + + // Assets of games the user removed are only orphans when the manifest is TRUSTWORTHY — a library that + // merely failed to parse reports zero games, and sweeping on that would delete every picture in it. + if (read.intact) { + void this.deps.pcLibrary + .gcOrphans(read.manifests.flatMap(referencedAssets)) + .catch((cause: unknown) => log.warn('[pc-library] asset cleanup failed:', describe(cause))); + } + } + + /** + * Re-reads the PC library after the Customize screen saved it (the local twin of reloadManifest). Same + * busy guards: a reload during a launch/install would swap the manifest under the running sequence. + */ + async reloadPcLibrary(): Promise<{ ok: true } | { ok: false; message: string }> { + const kind = this.deps.state.get().kind; + if ((kind !== 'ready' && kind !== 'error' && kind !== 'idle') || this.sequences.inFlight) { + return { ok: false, message: this.t('errors.finishBeforeApply') }; + } + if (this.reloadInFlight) return { ok: false, message: this.t('errors.reloadInProgress') }; + this.reloadInFlight = true; + try { + await this.loadPcLibrary(); + // A local game may have just been edited or removed: rebuild what is on screen so the detail screen + // (title, "Game files not found", Play/Uninstall) matches the manifest that was saved. + const selected = this.games.find((game) => game.raw.id === this.selectedId) ?? this.firstCarouselGame(); + this.selectedId = selected?.raw.id ?? this.selectedId; + if (selected !== null && !this.cardPresent && this.deps.state.get().kind === 'ready') { + const stats = this.statsById.get(selected.raw.id) ?? (await this.deps.stats.read(selected.raw.id)); + this.enterReady(await this.buildGameInfo(selected, stats)); + await this.browseToUnlessPinned(selected.raw.id); + } + await this.refreshBrowsedLocalGame(); + this.jobs.abortGone(this.games.map((manifest) => manifest.raw.id)); + return { ok: true }; + } finally { + this.reloadInFlight = false; + } + } + + /** + * Re-sends the game ON SCREEN once the PC library has been re-read, when that game is a local one. + * + * Everything else in the reload speaks for the SELECTED game and only with no card in — both branches + * here and in loadPcLibrary are gated on `!cardPresent` — while the Customize screen edits the game the + * cursor is BROWSING. With a card inserted nothing above said a word about it, and even without one the + * two cursors are free to point at different games. A game's hero and its music are read once per + * browse, so a track added from that screen stayed unheard until the user flipped to another card and + * back, which is what re-read the manifest. + * + * Nothing to do while the cursor is parked on a launcher card (`currentBrowse` is null there): the + * launcher's own background and its ambience are not the library's to refresh. Immediate rather than + * debounced — a press of Save is a commitment, not a flip through the row. + */ + private async refreshBrowsedLocalGame(): Promise { + const id = this.presenter.browse?.id; + if (id === undefined) return; + // The EFFECTIVE manifest, not the library's own: a local game whose id is also on the card is served + // by the card (see `games`), and the card's reload speaks for that one. + const manifest = this.games.find((game) => game.raw.id === id); + if (manifest === undefined || manifest.source !== 'pc') return; + await this.browseTo(id, true); + } + + /** + * Enters `ready` on a local game with its assets, without touching the window's visibility: this runs + * when a card was pulled, and a launcher the user had hidden must stay hidden (the same intent + * onRemove's hide/show branch respects). + */ + private async enterReadyForLocal(manifest: ResolvedManifest): Promise { + this.selectedId = manifest.raw.id; + const stats = this.statsById.get(manifest.raw.id) ?? (await this.deps.stats.read(manifest.raw.id)); + this.presenter.setHero(await this.assets.readHeroAssets(manifest)); + this.presenter.setCardMusic(await this.presenter.cardMusicFor(manifest)); + this.enterReady(await this.buildGameInfo(manifest, stats)); + await this.browseToUnlessPinned(manifest.raw.id); + } + + /** + * Where one game's manifest lives, by id — the bridge the Customize screen crosses from "the game I am + * looking at" to "the file that describes it". Only games that can be acted on right now are answered + * for (`games`), which is the same rule the screen's menu item is gated on. + * + * The INDEX is deliberately not part of the answer: `games` is a filtered, reordered union of the card + * and the library (a shadowed local game is hidden, the carousel order is applied elsewhere), so a + * position here says nothing about the slot's position inside game.json. The screen finds its slot by + * `id` instead. + */ + findGameSource(id: string): { readonly root: string; readonly source: ManifestSource } | null { + const manifest = this.games.find((game) => game.raw.id === id); + if (manifest === undefined) return null; + return { root: manifest.root, source: manifest.source }; + } + + /** The full RESOLVED manifest of one game, by id — what moveToCard needs to plan its asset/save copies + * (findGameSource only answers where the file lives, not what it resolves to). */ + findManifest(id: string): ResolvedManifest | null { + return this.games.find((game) => game.raw.id === id) ?? null; + } + + /** + * Without `id`: whether the session is busy (a game launching / running / installing / uninstalling). + * With `id`: whether that game is the session's or carries an activity of its own - main's server-side + * mirror of the renderer's own isBusy (app.ts), which gates Delete on the Customize screen and Move to + * card (GameMoveTransaction.moveToCard): a move started while the game is mid-launch would race the + * launcher's own manifest handling. + */ + isBusy(id?: string): boolean { + const sessionId = this.sessionGameId(); + if (id === undefined) return sessionId !== null; + return sessionId === id || this.deps.activities.has(id); + } + + /** The id of the game the session is busy with, or null while the state is settled. */ + private sessionGameId(): string | null { + const snapshot = this.deps.state.get(); + // Stated as the SETTLED states rather than the busy ones (the shape UpdaterService.isBusy uses): the + // list of things a game can be in the middle of grew — launching, either save sync, the Proton prefix + // — and an allow-list that has to be extended for each of them is the reason those four were missing + // from the deny-list this replaced, JSDoc promise of mid-launch cover notwithstanding. + if (snapshot.kind === 'idle' || snapshot.kind === 'ready' || snapshot.kind === 'error') return null; + return snapshot.game.id; + } + + /** Logs the local games the inserted card currently shadows (same id — the card wins, see `games`). */ + /** + * The Customize backend, attached after construction — it needs this controller to exist first (see + * main.ts), and the collision answer needs it back. A narrow view of the service, not the service. + */ + private collisionResolver: CollisionResolver | null = null; + + setCollisionResolver(resolver: CollisionResolver): void { + this.collisionResolver = resolver; + } + + /** The PC library's own manifest for `id`, even while a card of the same id shadows it. */ + findPcManifest(id: string): ResolvedManifest | null { + return this.pcGames.find((manifest) => manifest.raw.id === id) ?? null; + } + + /** + * Asks — once per game — what should happen when the same id turns up on the card AND on this PC. + * + * Today the card simply wins, and the local game's name and artwork appear to vanish whenever it is + * inserted (`warnShadowedLocalGames` says so in the log and nowhere else). A silent reconciliation is + * not an option: the two manifests are independent, not a copy and its original, so there is no + * baseline and no "which is newer" to decide by — hence a question rather than a rule. + * + * Raised at the END of loadCard: the card is already on screen, so nothing about it is held up by the + * answer. One game at a time; an answer is remembered per id (collisionResolvedAt). + */ + private askAboutCollisions(root: string): void { + if (this.collisionInFlight) return; + const cardIds = new Set(this.cardGames.map((manifest) => manifest.raw.id)); + const local = this.pcGames.find( + (manifest) => + cardIds.has(manifest.raw.id) && + (this.deps.library.entry(manifest.raw.id)?.collisionResolvedAt ?? null) === null, + ); + if (local === undefined) return; + void this.pushCollision(root, local); + } + + private async pushCollision(root: string, local: ResolvedManifest): Promise { + // The signature is captured WITH the question: the answer may arrive after the card has been pulled, + // or swapped for a different one carrying the same id, and neither may be written to. + const signature = await this.collisionResolver?.signatureFor(root); + if (signature === undefined || signature === null) return; + const collision: GameCollision = { + id: local.raw.id, + title: local.raw.title, + root, + signature, + }; + this.deps.window.send(IPC.gameCollision, collision); + } + + /** + * The answer. "Merge" puts the local game's look on the card and — for a DRAFT, which has nothing else + * to offer — removes it from the PC library, so the collision is gone for good. A fully configured + * local game stays: it has its own launch and its own saves, and after the merge the flip between the + * two sources is invisible anyway. + * + * The answer is remembered only once it has actually happened: a merge that failed leaves nothing + * written and no decision recorded, so the question honestly comes back. + */ + private async resolveCollision(answer: GameCollisionAnswer): Promise { + // The only renderer argument on this channel that used to be taken on trust. An id nothing knows + // about would still be recorded as "answered", which writes a phantom entry the carousel then shows. + if (!isSafeGameId(answer.id) || this.findPcManifest(answer.id) === null) { + return { saved: false, message: this.t('errors.configInvalid') }; + } + if (answer.choice === 'ignore') { + await this.deps.library.markCollisionResolved(answer.id); + return { saved: true, applied: 'deferred' }; + } + const resolver = this.collisionResolver; + if (resolver === null) return { saved: false, message: this.t('errors.configInvalid') }; + const local = this.findPcManifest(answer.id); + this.collisionInFlight = true; + try { + const result = await resolver.mergeCollision(answer); + if (!result.saved) return result; + if (localGameGoesAfterMerge(result, local)) { + const removed = await resolver.removeLocalGame(answer.id); + if (!removed.saved) { + log.warn(`[collision] the card took id=${answer.id}, but the local draft could not be removed: ${removed.message}`); + } + } + await this.deps.library.markCollisionResolved(answer.id); + return result; + } finally { + this.collisionInFlight = false; + } + } + + private warnShadowedLocalGames(): void { + const cardIds = new Set(this.cardGames.map((manifest) => manifest.raw.id)); + for (const manifest of this.pcGames) { + if (cardIds.has(manifest.raw.id)) { + log.warn(`[pc-library] local game id=${manifest.raw.id} is hidden while a card carries the same id`); + } + } + } + + /** + * Applies an edited game.json to the ACTIVE card without restarting the app (the Customize screen). + * Re-reads the manifest through the same loadCard path an insert uses (readManifest → stats reconcile + * → audio/hero → buildGameInfo → enterReady | error), so nothing is duplicated and the steam poller's + * stale-guard still holds. Focus is NOT taken (opts.focus=false) — the launcher is already in front. + * + * Two guards: (1) on ENTRY — refuse unless idle/ready/error and not launchInFlight (busy guard, like + * UpdaterService.install; also prevents killing an in-flight sequence, since onInsert would abort it); + * (2) reloadInFlight for the DURATION — checked by onLaunchRequested/onUninstallRequested so a gamepad + * Play/Uninstall can't slip in during the reload's awaits. + */ + async reloadManifest(root: string): Promise<{ ok: true } | { ok: false; message: string }> { + const kind = this.deps.state.get().kind; + if ((kind !== 'ready' && kind !== 'error' && kind !== 'idle') || this.sequences.inFlight) { + return { ok: false, message: this.t('errors.finishBeforeApply') }; + } + if (this.reloadInFlight) return { ok: false, message: this.t('errors.reloadInProgress') }; + this.reloadInFlight = true; + try { + const result = await this.loadCard(root, { focus: false }); + this.jobs.abortGone(this.games.map((manifest) => manifest.raw.id)); + return result; + } finally { + this.reloadInFlight = false; + } + } + + // ── Reaction to card removal ───────────────────────────────────────────── + + private onRemove(): void { + this.jobs.abortWhere((job) => job.kind === 'install' && job.source === 'card', 'card-removed'); + this.cardPresent = false; + const kind = this.deps.state.get().kind; + // During play/sync, removal is expected: the flow continues, sync-out + // will see cardPresent=false and put the task into pending-flush. We don't touch state. + if ( + kind === 'running' || + kind === 'launching' || + kind === 'syncing-in' || + kind === 'syncing-out' + ) { + return; + } + this.clearCard(); + // A local game is still playable with no card in, so pulling one must not collapse the launcher to the + // empty screen: stay `ready` on the first card of the row clearCard just rebuilt (NOT the first entry + // of the library file — see firstCarouselGame). Only a truly empty launcher goes idle + hides. + const remaining = this.firstCarouselGame(); + if (remaining !== null) { + void this.enterReadyForLocal(remaining); + return; + } + this.deps.state.set({ kind: 'idle' }); + // Normally the background app hides to the tray when no card is present. With "keep the launcher open + // without a card" on, it stays up instead — BUT only if it's currently on screen. If the user + // minimized it to the tray, pulling the card must not pop it back up (respect that intent). + if (this.deps.isGamescope) { + // Game Mode: no tray — the launcher always stays up (forces keepOpenWithoutCard). + this.deps.window.showAndFocus(); + } else if (this.keepOpenWithoutCard) { + if (this.deps.window.isShown()) this.deps.window.showAndFocus(); + } else { + this.deps.window.hide(); + } + } + + /** + * Applies the "keep the launcher open without a card" setting (seeded at startup, toggled live from the + * Settings screen). Besides caching the flag it reconciles the launcher NOW when we're idle with no + * card: bring it up when turning it on, or hide back to the tray when turning it off. When a card is + * present (ready/busy) nothing changes — the launcher is already visible for the game. + */ + setKeepOpenWithoutCard(on: boolean): void { + this.keepOpenWithoutCard = on; + const kind = this.deps.state.get().kind; + // `ready` counts too when no card is in: that is a LOCAL game on screen, and the setting is about + // whether the launcher sits there with no card — not about which screen it happens to show. + if (this.cardPresent || (kind !== 'idle' && kind !== 'ready')) return; + // Game Mode (gamescope): there is no tray to hide into, and a HIDDEN window leaves gamescope with no + // surface to present — Steam's launch spinner then hangs forever. So the window is ALWAYS shown there + // (the empty "insert a card" screen), regardless of the setting. Desktop/Windows honour the flag. + if (on || this.deps.isGamescope) this.deps.window.showAndFocus(); + else this.deps.window.hide(); + } + + // ── "Launch" action (the A button / click) ────────────────────────────── + + /** + * The carousel entered a game's detail screen (renderer sent action:select with the game id). Switches to + * it: builds that game's hero/audio/GameInfo on demand (only the selected game ever gets heavy assets) + * and enters `ready`. Selection is by id (not index) so a card reload that reorders games can't pick the + * wrong one. Rejected unless we're on `ready` and idle (not locked / launching / reloading) — the same + * guard the launch path enforces, so you can't switch the card's game while one is running. + */ + private async onSelectRequested(idRaw: unknown): Promise { + if (typeof idRaw !== 'string') return; + if (this.deps.state.get().kind !== 'ready' || this.sequences.isLocked || this.sequences.inFlight || this.reloadInFlight) return; + const manifest = this.games.find((m) => m.raw.id === idRaw); + if (manifest === undefined) { + log.warn(`[select] no game with id="${idRaw}" on the current card or in the PC library — ignoring`); + return; + } + this.selectedId = manifest.raw.id; + // Build the switched-to game's assets on demand (mirrors loadCard). Stats come from the loadCard cache + // (buildGameInfo still re-reads a steam game's .acf); fall back to a fresh read if somehow absent. + const stats = this.statsById.get(manifest.raw.id) ?? (await this.deps.stats.read(manifest.raw.id)); + this.presenter.setHero(await this.assets.readHeroAssets(manifest)); + this.presenter.setCardMusic(await this.presenter.cardMusicFor(manifest)); + this.enterReady(await this.buildGameInfo(manifest, stats)); + // Keep what's on screen in step with the selection (the renderer reads the title/stats from here). + await this.browseToUnlessPinned(manifest.raw.id); + } + + /** + * Opens Steam's Downloads page (steam://open/downloads). Triggered by the Play button while a Steam + * download is in progress (its loader is otherwise a no-op) so the user can pause/resume in Steam — + * we can't control Steam's downloads programmatically (no URI/API for pause/resume). + */ + private async onOpenSteamDownloads(): Promise { + try { + await openSteamUri('steam://open/downloads'); + } catch (cause) { + this.sendError(this.t('errors.steamOpenDownloads', { cause: describe(cause) })); + } + } + + // ── Building GameInfo for the UI ───────────────────────────────────────── + + private async buildGameInfo(manifest: ResolvedManifest, stats: Stats): Promise { + // Hero images are NOT part of GameInfo anymore — they travel on the hero:update channel (see + // readHeroAssets / setHero), delivered once per card on insert (not on every state transition). + // Three mutually-exclusive modes decide requiresInstall/canUninstall/installVia. Kept as an EXPLICIT + // 3-way branch (not the old `install !== undefined && !installed` formula, which gives false for a + // steam game and would always show "Play"). executablePath is only read by pathExists in the + // install/normal branches, where it is real (in steam mode it is '' and we never reach that read). + let requiresInstall: boolean; + let canUninstall: boolean; + let installVia: 'steam' | 'copy' | undefined; + let prefixCleanupOnly = false; + if (manifest.steam !== undefined) { + // Steam mode: "installed" is Steam's own .acf state; uninstall is managed in Steam (never here). + const status = await steamInstallStatus(manifest.steam.appid, this.deps.platform.steamLocator); + requiresInstall = status.state !== 'installed'; + // Steam uninstall is delegated to Steam (steam://uninstall) — available once installed. + canUninstall = status.state === 'installed'; + installVia = 'steam'; + } else if (manifest.install !== undefined) { + // Card-install mode: installed ⇔ the resolved executable exists; that also enables Uninstall. + const installed = + (await fse.pathExists(manifest.executablePath)) && !(await fse.pathExists(installMarkerOf(manifest.install.dir))); + requiresInstall = !installed; + canUninstall = installed; + // `copy` shares this branch but not its install-confirm copy: no installer runs, so the silent-mode + // caveat and the destination path (which exists only for the user to paste into an installer's + // picker) are meaningless there. Tell the renderer which of the two notes to show. + installVia = manifest.install.type === 'copy' ? 'copy' : undefined; + } else { + // Normal card game: always ready to play. On Linux it still creates a per-game Wine prefix on first + // launch — offer to clear that prefix (the game stays on the card). win32 has no prefix → null → no + // Uninstall button (unchanged). "Uninstall" here means prefix cleanup, not removing an install. + // A LOCAL game shares this branch: it is an ordinary executable, only one that lives on the PC. + requiresInstall = false; + const cleanupDir = await this.deps.platform.gameLauncher.prefixCleanupDir(manifest.raw.id); + canUninstall = cleanupDir !== null; + prefixCleanupOnly = canUninstall; + installVia = undefined; + } + // A local game's executable is checked HERE, not at read time (a card game's is the other way round): + // its absence must not drop the game from the library — the card, its art and its save backup stay, + // and only Play is disabled. See ManifestSource / the pc block. + // Stated POSITIVELY — "this game carries its own executable, and it is gone" — so it stays right for a + // local STEAM game, whose executablePath is the '' placeholder: `pathExists('')` is false, and a + // by-source check would strip its Play button. Steam's own "not installed" is `requiresInstall`. + const unavailable = + manifest.raw.pc !== undefined && !(await fse.pathExists(manifest.executablePath)); + const unconfigured = manifest.unconfigured === true; + return { + id: manifest.raw.id, + title: manifest.raw.title, + lastPlayedAt: stats.lastPlayedAt, + totalPlaySeconds: stats.totalPlaySeconds, + launchCount: stats.launchCount, + requiresInstall, + canUninstall, + // Installer-view dir: on linux this is the `C:\playhook\games\` the user would paste into a + // non-silent Wine picker; on win32 it equals the host dir. + ...(manifest.install !== undefined ? { installDir: manifest.install.installerDir } : {}), + ...(installVia !== undefined ? { installVia } : {}), + ...(prefixCleanupOnly ? { prefixCleanupOnly: true } : {}), + ...(unavailable ? { unavailable: true } : {}), + ...(unconfigured ? { unconfigured: true } : {}), + }; + } + + // ── Audio (the card's music + the bundled UI sound set) ────────────────── + + /** + * Recomputes and re-pushes the audio after an audio-settings change (the sound set, "only global + * ambience"). Re-reads the sound set (the AssetReader cache re-keys on the set) and re-pushes the + * loaded card's music — that second half is not optional: "only global ambience" is what decides + * whether the card's music exists at all (readMusicDataUrl), so without a re-push the browse channel + * would go silent while a stale card music, read while the flag was off, kept playing over it. + * A set switch leaves the music URL identical, and the renderer treats that as a no-op, so it never + * restarts the track. + */ + async refreshAudio(): Promise { + this.presenter.storeSfxSet(await this.assets.readSfxSet()); + const manifest = this.current(); + this.presenter.setCardMusic(await this.presenter.cardMusicFor(manifest)); + // The carousel plays the BUNDLED set, and what you hear on screen comes from the browse channel — + // both have to follow the setting too, or a change only lands after you flip to another card (the + // browse music outranks the card's own, so a stale value would keep playing over it). + this.presenter.pushSfxSet(); + await this.presenter.refreshBrowseMusic(); + } + + /** Applies a default-ambience change live: re-reads the track as a data URL and pushes it to the game + * window (the renderer crossfades; a card's own music still wins). */ + async setAmbientTrack(track: string | null): Promise { + this.presenter.setAmbient(await this.assets.readAmbientDataUrl(track)); + } + + + // ── Carousel list (the card's games + the play history) ──────────────────── + + /** + * Rebuilds and pushes the carousel list: the inserted card's games first (they are the ones that can be + * launched right now), then the played history — each group most recently played first. No stats are + * read from disk here: the index caches launchCount/lastPlayedAt for exactly this, and the card's + * own games use the reconciled stats already in memory (statsById), falling back to the index for a + * game whose reconcile hasn't happened yet. + * + * A card game is listed even when the library has no record for it yet — the asset copy runs in the + * background after the window is already up, and the carousel must not wait for it. + */ + /** True while a card is being read — see cardLoadInFlight (the Customize-from-history guard). */ + isCardLoading(): boolean { + return this.cardLoadInFlight; + } + + /** Re-pushes the carousel row (GameConfigService calls it after a save from the history). */ + refreshLibraryRow(): void { + this.refreshLibrary(); + } + + private refreshLibrary(): void { + this.presenter.setLibrary(this.buildLibrary()); + } + + /** + * The same list, BUILT but not delivered — for the one caller that must decide something from it before + * the renderer sees it (loadCard: the cursor belongs to the row's first card, and the row has to reach + * the window AFTER that cursor does). + */ + private buildLibrary(): GameLibrary | null { + const activeIds = this.games.map((manifest) => manifest.raw.id); + const active = new Set(activeIds); + // TWO groups, each sorted on its own: the card's games first, then the local ones. Sorting the union + // in one pass would interleave them by date, and the card you just inserted would land behind a local + // game played more recently — the card is the thing the user physically acted on. + const activeGames = [ + ...this.orderedForCarousel(this.cardGames), + ...this.orderedForCarousel(this.games.filter((manifest) => manifest.source === 'pc')), + ]; + const games = [ + ...activeGames.map((game) => { + // `artRev` (the record's savedAt) changes only when the assets were actually re-copied, which is + // what lets the renderer keep its decoded covers cached and still pick up an edited gridImage. + const stored = this.deps.library.entry(game.id); + return { + id: game.id, + title: game.title, + active: true, + source: game.source, + ...(stored !== null ? { artRev: stored.savedAt } : {}), + ...(game.unconfigured === true ? { unconfigured: true as const } : {}), + }; + }), + ...this.deps.library + .entriesForCarousel(activeIds) + .filter((entry) => !active.has(entry.id)) + .map((entry) => ({ + id: entry.id, + title: entry.title, + active: false, + // A record written before `sourceKind` existed is a card's: the history was cards only until + // the PC library came along, and the field fills in the next time the game shows up. + source: entry.sourceKind ?? ('card' as const), + artRev: entry.savedAt, + })), + ]; + return games.length > 0 ? { games } : null; + } + + /** One source's games, most recently played first — the per-group ordering refreshLibrary applies. */ + private orderedForCarousel(manifests: readonly ResolvedManifest[]): readonly { + readonly id: string; + readonly title: string; + readonly source: ManifestSource; + readonly unconfigured?: true; + }[] { + return byRecentlyPlayed( + manifests.map((manifest) => ({ + id: manifest.raw.id, + title: manifest.raw.title, + source: manifest.source, + lastPlayedAt: + this.statsById.get(manifest.raw.id)?.lastPlayedAt ?? + this.deps.library.entry(manifest.raw.id)?.lastPlayedAt ?? + null, + ...(manifest.unconfigured === true ? { unconfigured: true as const } : {}), + })), + ); + } + + // ── Browse (what is on screen) ───────────────────────────────────────────── + + /** + * `library:browse` — the carousel moved onto `id`. Answers with the browse info + that game's assets. + * Deliberately does NOT touch `selectedIndex` or the AppState: looking at a game is not choosing it, so + * this works while another game installs (and with no card at all). + */ + private async onBrowseRequested(idRaw: unknown, immediateRaw: unknown): Promise { + // A launcher card is selected: nothing is on screen. The INFO goes out at once (the title and the + // status line must clear as promptly as they do between games), while the heavy half rides the same + // debounce a game's does — a flip PAST the launcher cards must not tear the background and the music. + if (idRaw === null) { + this.browsePinned = true; + this.presenter.pushBrowse(null); + this.presenter.scheduleBrowseAssets(null, immediateRaw === true); + return; + } + if (typeof idRaw !== 'string') return; + this.browsePinned = false; + await this.browseTo(idRaw, immediateRaw === true); + } + + /** + * `library:forget` — the user dropped a game from the history. REFUSED for a game that is available + * right now: the card's and the PC library's games are rebuilt from their manifests on every insert / + * library load, so forgetting one would achieve nothing but throwing its artwork away until the next + * refresh copies it back. The menu hides the item for those games; this is the same rule on the side + * that owns the data (the renderer's list is a view, not an authority). + */ + private async onForgetRequested(idRaw: unknown): Promise { + if (typeof idRaw !== 'string') return; + if (this.games.some((manifest) => manifest.raw.id === idRaw)) { + log.warn(`[library] refused to forget id=${idRaw}: the game is available right now`); + return; + } + if (!(await this.deps.library.forget(idRaw))) return; + this.refreshLibrary(); + // Only when it was the game ON SCREEN: reseeding otherwise would drag the cursor off whatever the + // user is looking at. With it gone the cursor lands on the next game, or on the empty screen. + if (this.presenter.browse?.id === idRaw) await this.reseedBrowse(); + } + + /** + * Builds and pushes BrowseInfo for `id` (from the card when it is there, else the history) and schedules + * its assets. The INFO goes out at once — the title, the stats and the status line hang off it, and a + * carousel whose name lags behind the highlighted card reads as broken — while the hero images and the + * music, which are megabytes each, are debounced so flipping through the strip doesn't read the disk + * once per step. + */ + private async browseTo(id: string, immediate = false): Promise { + const manifest = this.games.find((m) => m.raw.id === id) ?? null; + if (manifest !== null) { + const stats = this.statsById.get(id) ?? (await this.deps.stats.read(id)); + const info = await this.buildGameInfo(manifest, stats); + this.presenter.pushBrowse({ id, title: manifest.raw.title, active: true, stats, game: info }); + this.presenter.scheduleBrowseAssets(id, immediate); + return; + } + const entry = this.deps.library.entry(id); + if (entry === null) { + log.warn(`[browse] no game with id="${id}" on the card or in the history — ignoring`); + return; + } + const stats = await this.deps.stats.read(id); + // Read off the in-memory record, never off the disk: browseTo runs on EVERY step through the + // carousel, with no debounce in front of it. + const configurable = entry.cardSlotHash !== undefined && entry.sourceKind !== 'pc'; + this.presenter.pushBrowse({ + id, + title: entry.title, + active: false, + stats, + ...(configurable ? { configurable: true as const } : {}), + }); + this.presenter.scheduleBrowseAssets(id, immediate); + } + + /** + * Where the cursor moves on main's own initiative — a card inserted, a library reloaded, a session + * finished. Refused while the user is parked on a launcher card: the position in the row is theirs, and + * an inserted card yanking the screen off Settings is exactly what this exists to prevent. Everything + * else keeps working meanwhile (state:update, hero:update and card:music are pushed regardless), so the + * new card is on screen the moment the user steps back onto a game themselves. + */ + private async browseToUnlessPinned(id: string): Promise { + if (this.browsePinned) return; + await this.browseTo(id); + } + + /** + * Lets go of a `ready` state whose game this read no longer carries and which nothing can replace. + * + * `ready` is not merely a phase: it NAMES a GameInfo. Play launches that GameInfo, and the Details menu + * falls back to it whenever there is no game on screen at all (see screenGame in controls.ts) — so a + * state left pointing at a deleted game keeps offering "Install" for it, and Play would try to launch + * it. The retarget in reloadPcLibrary handles the ordinary case by moving the state onto another game; + * it cannot help with the case that leaves the ghost behind — deleting the LAST game, where there is no + * other game to move onto. Then the honest state is `idle`: the launcher is about nothing. + */ + private dropStateIfGameGone(): void { + const snapshot = this.deps.state.get(); + if (snapshot.kind !== 'ready') return; + if (this.games.some((manifest) => manifest.raw.id === snapshot.game.id)) return; + if (this.current() !== null) return; // there IS something to be about — the retarget names it + this.selectedId = null; + this.presenter.setHero(null); + this.presenter.setCardMusic(null); + this.deps.state.set({ kind: 'idle' }); + } + + /** + * Whether the browse cursor's `active` flag has stopped matching reality — the game on screen is named + * as available while it is no longer in any manifest, or the other way round. It is the one field of + * BrowseInfo that a reload can invalidate WITHOUT moving the cursor, and the renderer decides what the + * Details menu offers by it. + */ + private browseIsStale(): boolean { + const browse = this.presenter.browse; + if (browse === null) return false; + return browse.active !== this.games.some((manifest) => manifest.raw.id === browse.id); + } + + /** + * Moves the browse cursor after the list changed (a card removed, an entry evicted): keep the current + * game if it is still listed, otherwise fall back to the first entry — or to nothing, which is the + * genuine "no card and no history" empty screen. + */ + private async reseedBrowse(): Promise { + const games = this.presenter.library?.games ?? []; + const current = this.presenter.browse?.id; + const next = games.find((game) => game.id === current) ?? games[0]; + if (next === undefined) { + this.presenter.pushBrowse(null); + this.presenter.pushBrowseHero(null); + this.presenter.pushBrowseMusic(null); + return; + } + await this.browseToUnlessPinned(next.id); + } +} diff --git a/src/main/game-jobs.ts b/src/main/game-jobs.ts new file mode 100644 index 00000000..b25601a8 --- /dev/null +++ b/src/main/game-jobs.ts @@ -0,0 +1,645 @@ +import path from 'node:path'; +import fse from 'fs-extra'; +import { ipcMain } from 'electron'; +import { IPC, type AppState, type ManifestSource } from '../shared/types'; +import { type GameActivity } from '../shared/activity'; +import { type Translator } from '../shared/i18n/index'; +import type { + ResolvedCopyInstall, + ResolvedInstall, + ResolvedInstallerRun, + ResolvedManifest, +} from './manifest-types'; +import { type ControllerDeps } from './controller-deps'; +import { type GameProcess } from './platform'; +import { findCaseInsensitiveName } from './manifest'; +import { LaunchAbortedError } from './launch-errors'; +import { removeWithRetry } from './remove-with-retry'; +import { delay, describe } from './util'; +import { log } from './logger'; + +const DEFAULT_MAX_PARALLEL_COPY_INSTALLS = 2; + +const DEFAULT_MAX_PARALLEL_INSTALLERS = 1; + +const INSTALL_POLL_INTERVAL_MS = 1000; + +const PARTIAL_SUFFIX = '.partial'; + +const INSTALL_MARKER = '.playhook-installing'; + +export type GameJobKind = 'install' | 'uninstall' | 'prefix-cleanup'; + +/** Why a job was stopped before it finished; `user` and `shutdown` are never reported as a failure. */ +export type JobAbortReason = 'user' | 'card-removed' | 'shutdown' | 'game-gone'; + +/** Which queue a job waits in: copies and installer runs have limits of their own, removals none. */ +export type JobLane = 'copy' | 'installer' | 'uninstall' | 'prefix-cleanup'; + +/** What the session is doing, as far as starting a job goes. */ +export type SessionPhase = 'free' | 'active' | 'running'; + +/** What is known about an install before it starts; undefined until it has been looked up. */ +export interface JobFacts { + /** The installer shows its wizard (silent installs are switched off in Settings). */ + readonly interactive?: boolean; + /** Preparing the install's prefix would run winetricks. */ + readonly provisions?: boolean; + /** A removal runs the game's own (third-party) uninstaller, not just a directory sweep. */ + readonly uninstaller?: boolean; +} + +export interface StartContext { + readonly session: SessionPhase; + readonly gamescope: boolean; + /** The install runs elevated (Windows: a UAC prompt and a blocking ShellExecuteEx). */ + readonly elevated: boolean; + readonly runningInLane: number; + readonly laneLimit: number; +} + +/** Start now, wait for a free slot, wait for the session to end, or look the facts up first. */ +export type StartVerdict = 'start' | 'wait' | 'wait-session' | 'learn'; + +/** + * Whether a queued job may start. Installer runs: at most `laneLimit` at once (MSI's global mutex fails a + * second one with 1618, repacks eat the machine), never an interactive or elevated one during a session + * (a wizard or a UAC prompt over the game), and under gamescope none at all while a game runs - the game has + * the only surface there, and even a silent installer opens windows. A copy waits under gamescope only when + * its prefix still needs winetricks, for the same reason. A removal that runs the game's own uninstaller + * follows the installer rules for the session (a window or a UAC prompt of its own); other removals start. + */ +export function startVerdict(lane: JobLane, facts: JobFacts, ctx: StartContext): StartVerdict { + if (lane === 'prefix-cleanup') return 'start'; + const gameUnderGamescope = ctx.gamescope && ctx.session === 'running'; + if (lane === 'uninstall') { + if (ctx.session === 'free' || !(gameUnderGamescope || ctx.elevated)) return 'start'; + if (facts.uninstaller === undefined) return 'learn'; + return facts.uninstaller ? 'wait-session' : 'start'; + } + if (ctx.runningInLane >= ctx.laneLimit) return 'wait'; + if (lane === 'copy') { + if (!gameUnderGamescope) return 'start'; + if (facts.provisions === undefined) return 'learn'; + return facts.provisions ? 'wait-session' : 'start'; + } + if (gameUnderGamescope) return 'wait-session'; + if (ctx.session === 'free') return 'start'; + if (ctx.elevated) return 'wait-session'; + if (facts.interactive === undefined) return 'learn'; + return facts.interactive ? 'wait-session' : 'start'; +} + +/** The session phase of an AppState: settled, busy around a game, or with the game itself running. */ +export function sessionPhaseOf(kind: AppState['kind']): SessionPhase { + if (kind === 'idle' || kind === 'ready' || kind === 'error') return 'free'; + return kind === 'running' ? 'running' : 'active'; +} + +/** What a caller may know about a job: whose it is, what it does, and where the game comes from. */ +export interface GameJob { + readonly id: string; + readonly kind: GameJobKind; + readonly source: ManifestSource; +} + +interface Job extends GameJob { + readonly lane: JobLane; + readonly install: ResolvedInstall | null; + readonly title: string; + facts: JobFacts; + learning: boolean; + readonly abort: AbortController; + readonly body: (job: Job) => Promise; + readonly done: Promise; + readonly settle: () => void; + proc: GameProcess | null; + abortReason: JobAbortReason | null; + running: boolean; +} + +/** What the jobs need back from the launcher. */ +export interface GameJobsHost { + /** The id of the game the busy session is about, or null when no session is in flight. */ + sessionGameId(): string | null; + /** A job changed what can be done with game `id` (Install / Play / Uninstall). */ + onGameChanged(id: string): void; + isWindowFocused(): boolean; + sendError(message: string): void; +} + +export type GameJobsDeps = Pick< + ControllerDeps, + 'activities' | 'notifications' | 'getTranslator' | 'settings' | 'isGamescope' +> & { + readonly state: Pick; + readonly platform: { + readonly gameLauncher: Pick< + ControllerDeps['platform']['gameLauncher'], + | 'prepareInstallDir' + | 'needsProvisioning' + | 'launchInstaller' + | 'resolveUninstaller' + | 'launchUninstaller' + | 'uninstallDir' + | 'prefixCleanupDir' + >; + }; + readonly processControl: Pick; + readonly host: GameJobsHost; + /** How many copy installs run at once; the rest wait as `queued`. */ + readonly maxParallelCopies?: number; + /** How many installer runs go at once; the rest wait as `queued`. */ + readonly maxParallelInstallers?: number; +}; + +/** A failure the job reports with its own message rather than the cause's. */ +class JobFailure extends Error {} + +/** The copy-install limit: PLAYHOOK_MAX_PARALLEL_COPY_INSTALLS when it is a positive integer, otherwise 2. */ +export function maxParallelCopyInstalls(env: NodeJS.ProcessEnv = process.env): number { + const parsed = Number.parseInt(env['PLAYHOOK_MAX_PARALLEL_COPY_INSTALLS'] ?? '', 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_PARALLEL_COPY_INSTALLS; +} + +/** The installer-run limit: PLAYHOOK_MAX_PARALLEL_INSTALLERS when it is a positive integer, otherwise 1. */ +export function maxParallelInstallers(env: NodeJS.ProcessEnv = process.env): number { + const parsed = Number.parseInt(env['PLAYHOOK_MAX_PARALLEL_INSTALLERS'] ?? '', 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_PARALLEL_INSTALLERS; +} + +/** Where a copy install stages its files until they are complete: a sibling of the install dir. */ +export function partialDirOf(installDir: string): string { + return `${installDir}${PARTIAL_SUFFIX}`; +} + +/** The file an installer run leaves in its install dir until it finished: there, the game is not installed. */ +export function installMarkerOf(installDir: string): string { + return path.join(installDir, INSTALL_MARKER); +} + +function throwIfAborted(job: Job): void { + if (job.abort.signal.aborted) throw new LaunchAbortedError(); +} + +/** + * Background installs and uninstalls, one per game and any number of games at once. Each job keeps its + * own abort controller and process, shows itself as the game's activity in the registry, and never + * touches the session's AppState or the window. + */ +export class GameJobs { + private readonly jobs = new Map(); + private readonly maxParallelCopies: number; + private readonly maxParallelInstallers: number; + + constructor(private readonly deps: GameJobsDeps) { + this.maxParallelCopies = deps.maxParallelCopies ?? maxParallelCopyInstalls(); + this.maxParallelInstallers = deps.maxParallelInstallers ?? maxParallelInstallers(); + } + + private get t(): Translator { + return this.deps.getTranslator(); + } + + /** + * Registers the renderer's Cancel for a queued or running install, and re-checks the queue on every + * session change: what waits for the game to end starts once the state settles. + */ + init(): void { + ipcMain.on(IPC.actionCancelJob, (_event, id: unknown) => { + if (typeof id === 'string') this.cancel(id); + }); + this.deps.state.subscribe(() => this.pump()); + } + + /** Starts (or queues) an install: a copy or an installer run. False when the game is busy already. */ + startInstall(manifest: ResolvedManifest): boolean { + const install = manifest.install; + if (install === undefined) return false; + if (install.type === 'copy') { + return this.enqueue(manifest, 'install', 'copy', install, (job) => + this.runCopy(job, manifest, install), + ); + } + return this.enqueue(manifest, 'install', 'installer', install, (job) => + this.runInstaller(job, manifest, install), + ); + } + + /** Starts removing an installed game from the PC. False when the game is busy already. */ + startUninstall(manifest: ResolvedManifest): boolean { + const install = manifest.install; + if (install === undefined) return false; + return this.enqueue(manifest, 'uninstall', 'uninstall', install, (job) => + this.runUninstall(job, install), + ); + } + + /** Starts clearing a plain game's Wine prefix. False when the game is busy already. */ + startPrefixCleanup(manifest: ResolvedManifest): boolean { + return this.enqueue(manifest, 'prefix-cleanup', 'prefix-cleanup', null, (job) => + this.runPrefixCleanup(job), + ); + } + + /** The user's Cancel: stops a queued or running install of `id`. False when there is none. */ + cancel(id: string): boolean { + const job = this.jobs.get(id); + if (job?.kind !== 'install') return false; + this.stop(job, 'user'); + return true; + } + + /** Stops every job `predicate` picks, queued or running, for `reason`. */ + abortWhere(predicate: (job: GameJob) => boolean, reason: JobAbortReason): void { + for (const job of [...this.jobs.values()]) { + if (predicate(job)) this.stop(job, reason); + } + } + + /** + * Removes the staging dirs a copy left behind when the launcher was killed mid-way (a quit through the + * tray or the menu waits for the cleanup; a crash or an OS logout does not). Skips games with a job. + */ + async sweepPartials(manifests: readonly ResolvedManifest[]): Promise { + for (const manifest of manifests) { + const install = manifest.install; + if (install?.type !== 'copy' || this.jobs.has(manifest.raw.id)) continue; + const partial = partialDirOf(install.dir); + if (!(await fse.pathExists(partial))) continue; + log.info(`[jobs] removing an orphaned ${PARTIAL_SUFFIX} of id=${manifest.raw.id}`); + await fse + .remove(partial) + .catch((cause: unknown) => + log.warn(`[jobs] could not remove "${partial}":`, describe(cause)), + ); + } + } + + /** Stops the jobs of games no longer among `ids`: a reload dropped them, and nothing names them now. */ + abortGone(ids: readonly string[]): void { + this.abortWhere((job) => !ids.includes(job.id), 'game-gone'); + } + + /** Stops every job and resolves once each has run its cleanup. */ + async abortAll(reason: JobAbortReason): Promise { + const pending = [...this.jobs.values()].map((job) => job.done); + this.abortWhere(() => true, reason); + await Promise.all(pending); + } + + has(id: string): boolean { + return this.jobs.has(id); + } + + /** Whether any job is queued or running. */ + anyActive(): boolean { + return this.jobs.size > 0; + } + + /** How many jobs are queued or running. */ + activeCount(): number { + return this.jobs.size; + } + + private enqueue( + manifest: ResolvedManifest, + kind: GameJobKind, + lane: JobLane, + install: ResolvedInstall | null, + body: (job: Job) => Promise, + ): boolean { + const id = manifest.raw.id; + if ( + this.jobs.has(id) || + this.deps.activities.has(id) || + this.deps.host.sessionGameId() === id + ) { + log.info(`[jobs] refused ${kind} id=${id}: the game is busy`); + return false; + } + let settle: () => void = () => undefined; + const done = new Promise((resolve) => { + settle = resolve; + }); + const job: Job = { + id, + kind, + lane, + install, + facts: {}, + learning: false, + source: manifest.source, + title: manifest.raw.title, + abort: new AbortController(), + body, + done, + settle: () => settle(), + proc: null, + abortReason: null, + running: false, + }; + this.jobs.set(id, job); + this.pump(); + if (!job.running) log.info(`[jobs] queued ${lane} id=${id}`); + return true; + } + + private runningIn(lane: JobLane): number { + return [...this.jobs.values()].filter((job) => job.lane === lane && job.running).length; + } + + private verdict(job: Job): StartVerdict { + return startVerdict(job.lane, job.facts, { + session: sessionPhaseOf(this.deps.state.get().kind), + gamescope: this.deps.isGamescope, + elevated: job.install?.runAsAdmin === true, + runningInLane: this.runningIn(job.lane), + laneLimit: job.lane === 'copy' ? this.maxParallelCopies : this.maxParallelInstallers, + }); + } + + /** Looks up what a queued job's start depends on, then re-checks the queue. */ + private async learn(job: Job): Promise { + const install = job.install; + if (job.learning || install === null) return; + job.learning = true; + try { + if (job.lane === 'uninstall') { + job.facts = { + uninstaller: + install.type !== 'copy' && + (await this.deps.platform.gameLauncher.resolveUninstaller(install)) !== null, + }; + return; + } + const [settings, provisions] = await Promise.all([ + this.deps.settings.read(), + this.deps.platform.gameLauncher.needsProvisioning(install), + ]); + job.facts = { interactive: settings.disableSilentInstall, provisions }; + } catch (cause) { + log.warn( + `[jobs] could not look up how id=${job.id} runs - treating it as interactive:`, + describe(cause), + ); + job.facts = { interactive: true, provisions: true, uninstaller: true }; + } finally { + job.learning = false; + this.pump(); + } + } + + private launch(job: Job): void { + job.running = true; + this.deps.activities.set(job.id, { + kind: job.kind === 'install' ? 'installing' : 'uninstalling', + }); + log.info(`[jobs] started ${job.kind} id=${job.id}`); + void this.execute(job); + } + + private stop(job: Job, reason: JobAbortReason): void { + if (job.abortReason !== null) return; + job.abortReason = reason; + job.abort.abort(); + log.info(`[jobs] stopping ${job.kind} id=${job.id} reason=${reason}`); + if (job.running) { + void job.proc + ?.kill() + .catch((cause: unknown) => log.warn(`[jobs] kill failed id=${job.id}:`, describe(cause))); + return; + } + this.failed(job, new LaunchAbortedError()); + this.finish(job); + } + + private pump(): void { + for (const job of [...this.jobs.values()]) { + if (job.running || job.abortReason !== null) continue; + const verdict = this.verdict(job); + if (verdict === 'start') { + this.launch(job); + continue; + } + this.deps.activities.set(job.id, { + kind: 'queued', + ...(verdict === 'wait-session' ? { reason: 'session' as const } : {}), + ...(job.kind === 'install' ? {} : { removal: true as const }), + }); + if (verdict === 'learn') void this.learn(job); + } + } + + private finish(job: Job): void { + job.proc?.dispose(); + this.jobs.delete(job.id); + this.deps.activities.clear(job.id); + job.settle(); + this.pump(); + } + + private setActivity(job: Job, activity: GameActivity): void { + if (job.abortReason === null) this.deps.activities.set(job.id, activity); + } + + private async execute(job: Job): Promise { + try { + await job.body(job); + throwIfAborted(job); + this.succeeded(job); + } catch (cause) { + this.failed(job, cause); + } finally { + this.finish(job); + } + } + + private succeeded(job: Job): void { + log.info(`[jobs] ${job.kind} completed id=${job.id}`); + this.deps.host.onGameChanged(job.id); + if (job.kind === 'prefix-cleanup') return; + this.deps.notifications.notify({ + kind: job.kind === 'install' ? 'game-installed' : 'game-uninstalled', + gameId: job.id, + gameTitle: job.title, + }); + } + + private failed(job: Job, cause: unknown): void { + this.deps.host.onGameChanged(job.id); + const reason = this.failureReason(job, cause); + if (reason === null) { + log.info(`[jobs] ${job.kind} stopped id=${job.id} reason=${job.abortReason ?? 'aborted'}`); + return; + } + log.warn(`[jobs] ${job.kind} failed id=${job.id}: ${reason}`); + this.deps.notifications.notify({ + kind: job.kind === 'install' ? 'game-install-failed' : 'game-uninstall-failed', + gameId: job.id, + gameTitle: job.title, + reason, + }); + if (this.deps.host.isWindowFocused()) this.deps.host.sendError(reason); + } + + /** What to tell the user about a job that did not finish, or null when it is not worth a word. */ + private failureReason(job: Job, cause: unknown): string | null { + switch (job.abortReason) { + case 'user': + case 'shutdown': + return null; + case 'card-removed': + return this.t('errors.jobCardRemoved'); + case 'game-gone': + return this.t('errors.jobGameGone'); + case null: + if (cause instanceof LaunchAbortedError) return null; + if (cause instanceof JobFailure) return cause.message; + return job.lane === 'copy' + ? this.t('errors.copyGameFailed', { cause: describe(cause) }) + : describe(cause); + } + } + + private async dropPartial(job: Job, install: ResolvedCopyInstall): Promise { + try { + await removeWithRetry(partialDirOf(install.dir)); + } catch (cause) { + log.warn( + `[jobs] leftover ${PARTIAL_SUFFIX} of id=${job.id} could not be removed:`, + describe(cause), + ); + } + } + + /** + * Copies the card's game directory to the PC. The files land in `.partial` first and move into + * `dir` only once complete and holding the executable, so a stopped copy never leaves a playable-looking + * game behind (`requiresInstall` is "the executable is missing"). + */ + private async runCopy( + job: Job, + manifest: ResolvedManifest, + install: ResolvedCopyInstall, + ): Promise { + try { + const partial = partialDirOf(install.dir); + await fse.remove(install.dir); + await fse.remove(partial); + await this.deps.platform.gameLauncher.prepareInstallDir(install, (active) => + this.setActivity(job, { kind: active ? 'configuringProton' : 'installing' }), + ); + throwIfAborted(job); + try { + await fse.copy(install.installerPath, partial, { + dereference: false, + filter: () => { + throwIfAborted(job); + return true; + }, + }); + } catch (cause) { + throwIfAborted(job); + throw new JobFailure(this.t('errors.copyGameFailed', { cause: describe(cause) })); + } + throwIfAborted(job); + const staged = path.join(partial, path.relative(install.dir, manifest.executablePath)); + if (!(await fse.pathExists(staged))) + throw new JobFailure(await this.missingExeMessage(manifest, staged)); + await fse.remove(install.dir); + await fse.move(partial, install.dir); + log.info(`[install] copied id=${job.id} from="${install.installerPath}" to="${install.dir}"`); + } finally { + await this.dropPartial(job, install); + } + } + + /** + * Runs a card installer into the install dir: a marker first (a launcher killed mid-run must not leave a + * playable-looking game behind), the installer, then a grace poll for the executable - some wrappers fork a + * child and exit early. A stopped or failed run sweeps the install dir. + */ + private async runInstaller( + job: Job, + manifest: ResolvedManifest, + install: ResolvedInstallerRun, + ): Promise { + try { + await fse.remove(install.dir); + await fse.outputFile(installMarkerOf(install.dir), ''); + const silent = !(await this.deps.settings.read()).disableSilentInstall; + try { + job.proc = await this.deps.platform.gameLauncher.launchInstaller( + install, + silent, + (active) => this.setActivity(job, { kind: active ? 'configuringProton' : 'installing' }), + ); + } catch (cause) { + throwIfAborted(job); + throw new JobFailure(this.t('errors.startInstaller', { cause: describe(cause) })); + } + await this.deps.processControl.waitForExit(job.proc, job.abort.signal); + if (!(await this.pollForExecutable(job, manifest))) { + throw new JobFailure(this.t('errors.installIncomplete')); + } + await fse.remove(installMarkerOf(install.dir)); + log.info(`[install] completed id=${job.id} dir="${install.dir}"`); + } catch (cause) { + await removeWithRetry(install.dir).catch((error: unknown) => + log.warn(`[jobs] the install dir of id=${job.id} could not be swept:`, describe(error)), + ); + throw cause; + } + } + + /** Waits up to launchTimeoutSec for the executable to appear; throws once the job is stopped. */ + private async pollForExecutable(job: Job, manifest: ResolvedManifest): Promise { + const deadline = Date.now() + manifest.raw.launchTimeoutSec * 1000; + for (;;) { + throwIfAborted(job); + if (await fse.pathExists(manifest.executablePath)) return true; + if (Date.now() >= deadline) return false; + await delay(INSTALL_POLL_INTERVAL_MS); + } + } + + private async missingExeMessage(manifest: ResolvedManifest, staged: string): Promise { + const shown = manifest.raw.executable ?? path.basename(manifest.executablePath); + const found = await findCaseInsensitiveName(staged); + return found !== null + ? this.t('errors.copyExeNotFoundCase', { path: shown, found }) + : this.t('errors.copyExeNotFound', { path: shown }); + } + + /** + * Removes an installed game: its own uninstaller first (never for `copy` - a copied directory's + * uninstaller belongs to an install made on another machine), then a sweep of the uninstall dir. + */ + private async runUninstall(job: Job, install: ResolvedInstall): Promise { + if (install.type !== 'copy') { + const target = await this.deps.platform.gameLauncher.resolveUninstaller(install); + if (target !== null) { + try { + job.proc = await this.deps.platform.gameLauncher.launchUninstaller(target); + await this.deps.processControl.waitForExit(job.proc, job.abort.signal); + } catch (cause) { + if (cause instanceof LaunchAbortedError) throw cause; + log.warn(`[uninstall] uninstaller failed, continuing to cleanup: ${describe(cause)}`); + } + } + } + throwIfAborted(job); + const dir = this.deps.platform.gameLauncher.uninstallDir(install); + await removeWithRetry(dir, job.abort.signal); + log.info(`[uninstall] removed id=${job.id} dir="${dir}"`); + } + + /** Clears a plain game's Wine prefix (Linux): the game stays on its card, only the prefix goes. */ + private async runPrefixCleanup(job: Job): Promise { + const dir = await this.deps.platform.gameLauncher.prefixCleanupDir(job.id); + if (dir === null) return; + await removeWithRetry(dir, job.abort.signal); + log.info(`[prefix-cleanup] removed "${dir}" id=${job.id}`); + } +} diff --git a/src/main/game-launcher.ts b/src/main/game-launcher.ts index 6723ecf6..707b062a 100644 --- a/src/main/game-launcher.ts +++ b/src/main/game-launcher.ts @@ -19,46 +19,17 @@ import path from 'node:path'; import { spawn } from 'node:child_process'; import koffi from 'koffi'; -import { - type LaunchTarget, - type ResolvedManifest, - type ResolvedInstallerRun, -} from '../shared/types'; -import { type ProcessMonitor, type ProcessSnapshot } from './platform/types'; +import type { LaunchTarget, ResolvedManifest, ResolvedInstallerRun } from './manifest-types'; +import { type GameProcess, type ProcessMonitor, type ProcessSnapshot } from './platform/types'; import { buildInstallerArgs, buildParameters } from './launch-args'; import { delay } from './util'; import { log } from './logger'; +import { LaunchAbortedError } from './launch-errors'; const START_POLL_INTERVAL_MS = 1000; const EXIT_POLL_INTERVAL_MS = 2500; const EXIT_DEBOUNCE_READS = 3; -export class LaunchAbortedError extends Error { - constructor() { - super('launch wait aborted'); - this.name = 'LaunchAbortedError'; - } -} - -/** - * A launched game, abstracting the two launch backends so the wait loops don't care which was used. - * `pid` is the real pid for the normal path, 0 for the elevated path (we monitor by HANDLE there). - */ -export interface GameProcess { - readonly pid: number; - isAlive(): Promise; - /** - * Force-terminates the process (force-close from the More menu). Normal path: `taskkill /PID /T - * /F` (the whole tree), guarded by an isAlive() re-check so a reused pid can't take down an unrelated - * process. Elevated path: TerminateProcess on the kept HANDLE, done synchronously (no await before the - * FFI call) and skipped if dispose() already closed the handle. Errors are swallowed — the caller - * decides success by a fact-based control poll, not this call's outcome. - */ - kill(): Promise; - /** Releases the kept HANDLE (elevated path); no-op for the normal path. */ - dispose(): void; -} - function throwIfAborted(signal: AbortSignal | undefined): void { if (signal?.aborted === true) throw new LaunchAbortedError(); } @@ -187,10 +158,10 @@ function loadKernel(): KernelLib { * the UAC dialog blocks the main thread briefly, which is fine while a game is running (input is ignored). * We do NOT wait for taskkill to finish — it runs on after we release our handle; the caller's control * poll decides success by fact. Best-effort: a declined UAC (GetLastError 1223) or any failure is logged - * and swallowed. No-op off Windows / with no names. + * and swallowed. No-op with no names. win32-only, and reached only through the win32 ProcessMonitor. */ export function killImagesElevated(imageNames: readonly string[]): void { - if (process.platform !== 'win32' || imageNames.length === 0) return; + if (imageNames.length === 0) return; const shell = loadShell(); const kernel = loadKernel(); const args = ['/F', '/T']; @@ -300,9 +271,6 @@ async function launchNormal( * few seconds and gamepad input is ignored outside `ready`, so the brief block is acceptable. */ function launchElevated(target: LaunchTarget, mode: LaunchMode): GameProcess { - if (process.platform !== 'win32') { - throw new Error('elevated launch (runAsAdmin) is Windows-only'); - } const shell = loadShell(); const kernel = loadKernel(); @@ -355,7 +323,7 @@ function launchElevated(target: LaunchTarget, mode: LaunchMode): GameProcess { if (!disposed) { const ok = kernel.TerminateProcess(handle, KILL_EXIT_CODE); if (ok === 0) { - // Undocumented whether SEE_MASK_NOCLOSEPROCESS grants PROCESS_TERMINATE (see K-Д1) — leave a + // Undocumented whether SEE_MASK_NOCLOSEPROCESS grants PROCESS_TERMINATE — leave a // breadcrumb; the controller's control poll turns a still-alive game into errors.killFailed. log.warn(`[kill] TerminateProcess failed (GetLastError=${kernel.GetLastError()})`); } diff --git a/src/main/game-move-transaction.ts b/src/main/game-move-transaction.ts new file mode 100644 index 00000000..a8262e0a --- /dev/null +++ b/src/main/game-move-transaction.ts @@ -0,0 +1,457 @@ +// Move to card: a local game leaves the PC library and lands on a card, in one transaction. The pure +// parts (which assets travel, how a slot leaves the library text) live in game-move.ts; this is the +// orchestration — the order of the steps and the two levels of rollback — split out of GameConfigService, +// which keeps the root guard and the game.json reads/writes the transaction is built from. +import path from 'node:path'; +import fse from 'fs-extra'; +import { ipcMain } from 'electron'; +import { IPC, type ConfigMoveResult, type GameMoveRequest } from '../shared/types'; +import { MANIFEST_FILENAME, type ResolvedManifest } from './manifest-types'; +import { type Translator } from '../shared/i18n/index'; +import { type ConfigReadResult } from './game-config'; +import { type NotificationInput } from './notifications'; +import { + countGamesWithId, + expectedGameFilePath, + findGameInText, + planAssetCopies, + removeGameFromManifestText, +} from './game-move'; +import { type PcStore } from './pc-store'; +import { type SavePathResolver } from './platform/types'; +import { resolveInside, validateManifestText } from './manifest'; +import { writeFileAtomicEnsuringDir } from './json-store'; +import { describe } from './util'; +import { log } from './logger'; + +/** + * A move that SUCCEEDED but not entirely cleanly. Kept as a discriminated value rather than as the + * user-facing sentence it turns into: the sentence is localized (comparing against it would break the + * moment the UI language changes mid-transaction) and there can be more than one of these in one move. + */ +type MoveWarning = 'save-skipped' | 'duplicate'; + +const MOVE_WARNING_NOTIFICATION: Readonly< + Record +> = { + 'save-skipped': 'game-move-save-skipped', + duplicate: 'game-move-duplicate', +}; + +/** One asset copied onto the card by a move, and whether the destination was already occupied — a + * rollback removes only what the move itself created (an overwrite cannot be undone, so deleting a file + * that predates us would turn a failed move into data loss). */ +interface AssetCopyRecord { + readonly to: string; + readonly existedBefore: boolean; +} + +/** The save folder a move copied INTO, if any. `existedBefore` distinguishes "we made this folder" (undo + * = remove it) from "it was already there and empty" (undo = empty it again, keeping the folder). */ +interface SaveCopyRecord { + dir: string | null; + existedBefore: boolean; +} + +/** What the transaction is built from: GameConfigService's root guard and its game.json reads/writes. */ +export interface GameMoveConfig { + /** True when `root` is a current removable/non-system mountpoint or the app's own PC-library root. */ + isWritableRoot(root: string): Promise; + /** The media's identity — the sorted-ids signature a DriveCandidate carries. */ + signatureOf(root: string): Promise; + /** One root's game.json text, behind the root guard. */ + readConfig(root: string): Promise; + /** Writes (or removes, if empty) the PC library's game.json and reloads it. */ + writePcLibraryText( + text: string, + t: Translator, + ): Promise<{ readonly ok: true } | { readonly ok: false; readonly message: string }>; + /** Drops the candidates snapshot: our own write changed a manifest the labels/signatures derive from. */ + invalidateCandidates(): void; +} + +export interface GameMoveDeps { + readonly config: GameMoveConfig; + /** The current translator (read live so a language change applies to labels/validation/errors). */ + readonly getTranslator: () => Translator; + /** The launcher's currently-active card root (DriveWatcher.getActiveRoot). */ + readonly getActiveRoot: () => string | null; + /** Applies an edited game.json to the active card without a restart (GameController.reloadManifest). */ + readonly reloadManifest: (root: string) => Promise<{ ok: true } | { ok: false; message: string }>; + /** Files a notification (NotificationsService.notify) for a deferred move and for its warnings. */ + readonly notify: (input: NotificationInput) => void; + /** + * The full RESOLVED manifest of one game, by id (GameController.findManifest) — the transaction needs + * the actual resolved asset paths and the raw fields (steam/pcSavePath) to plan the asset/save copies, + * not just where the file lives. + */ + readonly resolveManifest: (id: string) => ResolvedManifest | null; + /** + * Whether game `id` is the session's or carries an activity (GameController.isBusy) - moveToCard's + * own re-check of the guard the "Move to card…" menu item already applies in the renderer. + */ + readonly isBusy: (id: string) => boolean; + /** Drops a game's sync-state baseline (PcStore.removeSyncState) — moveToCard clears the "pc" slot once + * a game leaves the library: the local backup ↔ save-folder pairing it described is gone. */ + readonly pcStore: Pick; + /** Resolves a manifest's `pcSavePath` to the LIVE save folder on this machine (platform.savePathResolver) + * — moveToCard copies from there, not from the PC-library backup, so a stale backup can never + * overwrite a fresher save. */ + readonly savePathResolver: Pick; +} + +export class GameMoveTransaction { + constructor(private readonly deps: GameMoveDeps) {} + + /** Registers the move's gameConfig:* invoke handler once (the service is a singleton). */ + init(): void { + ipcMain.handle( + IPC.gameConfigMoveToCard, + (_event, payload: GameMoveRequest): Promise => this.moveToCard(payload), + ); + } + + // Two writes (the card's game.json, the library's) cannot be two separate gameConfig:save calls from the + // renderer without a window where the game exists in both places or neither — so the whole thing runs + // here. Order: checks (nothing written) → copy assets → copy saves → write the card → write the + // library → apply/notify → drop the stale sync-state baseline. A failure before the library write rolls + // back everything copied to the card; `fromText` is applied ONLY after the card write has already + // succeeded, and never otherwise (see moveToCard's own comments for exactly where). + // + // test/game-move-transaction.test.ts drives it through this method rather than a carved-out core: the + // rollback branches are the riskiest code in the service and are reachable only through the whole + // sequence. + async moveToCard(request: GameMoveRequest): Promise { + const t = this.deps.getTranslator(); + + // 1. Checks — nothing is written until every one of these passes. + if ( + !(await this.deps.config.isWritableRoot(request.fromRoot)) || + !(await this.deps.config.isWritableRoot(request.toRoot)) + ) { + return { moved: false, message: t('errors.driveUnavailable') }; + } + if ( + (await this.deps.config.signatureOf(request.fromRoot)) !== request.fromSignature || + (await this.deps.config.signatureOf(request.toRoot)) !== request.toSignature + ) { + return { moved: false, message: t('errors.mediaChanged') }; + } + if (this.deps.isBusy(request.fromId)) { + return { moved: false, message: t('gameConfig.moveGameBusy') }; + } + // A move must not rename: everything this PC remembers about the game is keyed by id (stats, the + // history record, the pending-flush queue), so a rename mid-move would orphan the lot. The renderer + // hides the id row while a move is pending; this is the server-side half of that rule, and it is what + // makes addressing the two sides by two different ids below provably equivalent. + if (request.fromId !== request.id) { + return { moved: false, message: t('gameConfig.moveIdChanged') }; + } + if (countGamesWithId(request.id, request.toText) !== 1) { + return { moved: false, message: t('gameConfig.moveIdTaken') }; + } + const toValidation = validateManifestText(request.toText, t, 'card'); + if (!toValidation.ok) { + return { moved: false, message: this.firstIssueMessage(toValidation.issues, t) }; + } + // fromText is derived HERE, from a fresh read — never trusted from the renderer (see game-move.ts). + // Addressed by `fromId` (what the game was READ with), never by the editable `id`. + const fromRead = await this.deps.config.readConfig(request.fromRoot); + if (!fromRead.ok) return { moved: false, message: fromRead.message }; + // removeGameFromManifestText is a silent no-op for an id that isn't there, which would write the + // library back UNCHANGED and report a successful move — so the game's presence is asserted first. + if (countGamesWithId(request.fromId, fromRead.text) !== 1) { + return { moved: false, message: t('errors.gameNotFound') }; + } + const fromText = removeGameFromManifestText(request.fromId, fromRead.text); + if (fromText === null) return { moved: false, message: t('errors.configInvalid') }; + const fromValidation = validateManifestText(fromText, t, 'pc'); + if (!fromValidation.ok) { + // Our own slot is gone from this text, so whatever is wrong belongs to a game that stays behind — + // say so, or the user reads it as a complaint about the game they are moving. + return { + moved: false, + message: t('gameConfig.moveLibraryInvalid', { + reason: this.firstIssueMessage(fromValidation.issues, t), + }), + }; + } + const manifest = this.deps.resolveManifest(request.fromId); + if (manifest === null || manifest.source !== 'pc') { + return { moved: false, message: t('errors.gameNotFound') }; + } + const targetRaw = findGameInText(request.id, request.toText); + if (targetRaw === null) return { moved: false, message: t('errors.gameNotFound') }; + // The game's OWN files (its exe) must already be on the card — otherwise the card would read as + // having an invalid game.json the instant it is inserted. + const expectedFile = expectedGameFilePath(targetRaw); + if (expectedFile !== null) { + const resolvedFile = resolveInside(request.toRoot, expectedFile); + if (resolvedFile === null || !(await fse.pathExists(resolvedFile))) { + return { moved: false, message: t('gameConfig.moveFilesNotOnCard') }; + } + } + // The card's game.json exactly as it stands right now — the ONLY faithful "before" picture for the + // step-5 rollback. Reconstructing it by subtracting our slot back out of `toText` would restore a + // re-serialization of the renderer's making instead (different top-level shape for a single-game + // card), and would lean on `toSignature === ''` to tell "there was no file" from "there was one". + const toBefore = await this.readManifestSnapshot(request.toRoot); + if (!toBefore.ok) return { moved: false, message: toBefore.message }; + + const gameTitle = typeof targetRaw['title'] === 'string' ? targetRaw['title'] : request.id; + // What went not-quite-right, as DATA rather than as prose: the outcomes below drive both the + // notifications and the result's `warning`, and more than one of them can happen in a single move. + const warnings: MoveWarning[] = []; + + // 2. Copy assets (hero/grid/music), under the deterministic names the renderer already wrote into + // toText — see asset-move-names.ts. `existedBefore` is recorded per destination so a rollback removes + // only what this move created: deleting a file that was already there would destroy it outright, + // since the overwrite has no undo. + const copied: AssetCopyRecord[] = []; + const saveCopy: SaveCopyRecord = { dir: null, existedBefore: false }; + const undo = async (): Promise => { + await this.rollbackMoveCopies(copied, saveCopy); + }; + try { + for (const plan of planAssetCopies(manifest, request.id, request.toRoot)) { + // Checked BEFORE the copy so a file the user deleted since the game was configured is reported as + // what it is. Left to fse.copy it would surface as an ENOENT inside the catch below, and the user + // would be told that game.json could not be written — a file nothing has tried to touch yet. + if (!(await fse.pathExists(plan.from))) { + await undo(); + return { moved: false, message: t('gameConfig.moveAssetMissing', { path: plan.from }) }; + } + const existedBefore = await fse.pathExists(plan.to); + await fse.ensureDir(path.dirname(plan.to)); + await fse.copy(plan.from, plan.to, { overwrite: true }); + copied.push({ to: plan.to, existedBefore }); + } + } catch (cause) { + await undo(); + return { + moved: false, + message: t('errors.cannotWriteManifest', { + file: MANIFEST_FILENAME, + cause: describe(cause), + }), + }; + } + + // 3. Copy saves — only when the TARGET manifest actually names a saveOnCard folder, and PREFERABLY + // from the LIVE save location rather than the pc-games/saves/ backup: with no card-side baseline + // yet, the first sync-in falls back to the deterministic card→pc direction (save-sync.ts), so what + // travels here is what that fallback will write back over the live folder. The backup is used only + // when the live location has nothing to offer at all (no folder, or a Wine prefix that does not exist + // yet) — there the fallback has nothing to overwrite, so a stale backup beats no saves. + const targetSaveOnCard = + typeof targetRaw['saveOnCard'] === 'string' ? targetRaw['saveOnCard'] : undefined; + if (targetSaveOnCard !== undefined) { + const saveTargetDir = resolveInside(request.toRoot, targetSaveOnCard); + if (saveTargetDir === null) { + await undo(); + return { moved: false, message: t('gameConfig.pickOutsideCard') }; + } + const sourceDir = await this.liveOrBackupSaveDir(manifest); + if (sourceDir !== null) { + const targetExisted = await fse.pathExists(saveTargetDir); + const targetNonEmpty = targetExisted && (await fse.readdir(saveTargetDir)).length > 0; + if (targetNonEmpty) { + warnings.push('save-skipped'); + } else { + try { + await fse.ensureDir(path.dirname(saveTargetDir)); + await fse.copy(sourceDir, saveTargetDir, { overwrite: true }); + // Recorded even when the folder was already there (empty): what has to be undone is what we + // PUT IN it, not merely the folder we may or may not have created. + saveCopy.dir = saveTargetDir; + saveCopy.existedBefore = targetExisted; + } catch (cause) { + await undo(); + return { + moved: false, + message: t('errors.cannotWriteManifest', { + file: MANIFEST_FILENAME, + cause: describe(cause), + }), + }; + } + } + } + } + + // 4. Write the card. On disk to this exact moment on, `fromText` is the only thing left to apply — + // everything above only touched the CARD side. + try { + await writeFileAtomicEnsuringDir( + path.join(request.toRoot, MANIFEST_FILENAME), + request.toText, + ); + } catch (cause) { + await undo(); + return { + moved: false, + message: t('errors.cannotWriteManifest', { + file: MANIFEST_FILENAME, + cause: describe(cause), + }), + }; + } + + // 5. Write the library. A failure here triggers a best-effort rollback of the card (step 4) back to + // its pre-move bytes — `fromText` is NEVER applied when this happens: applying it would make the game + // disappear from the PC library without landing on the card, which is worse than the duplicate a + // failed rollback leaves behind. + const fromWrite = await this.deps.config.writePcLibraryText(fromText, t); + if (!fromWrite.ok) { + const restored = await this.rollbackCardWrite(request.toRoot, toBefore.text); + if (restored) { + // The card is back to its pre-move bytes, so the copies of steps 2/3 are now referenced by + // nothing — and only NOW may they go. Undoing them while the card still names them (the branch + // below) would leave the target manifest pointing at art and saves that are no longer there. + await undo(); + return { moved: false, message: fromWrite.message }; + } + // The card write is stuck AND the library still has the game too — a defined outcome (a card game + // shadows its local twin, README.md "Local games"), not corruption, but worth a loud log: two + // independent writes failed back to back to get here. The copies STAY: the card's game.json still + // refers to them, and a duplicate whose art and saves are intact is the whole point of calling this + // "a defined outcome" rather than damage. + log.error( + `[game-move] id=${request.id}: card write kept but the library write failed and the card` + + ` rollback ALSO failed — the game now exists in both places (${fromWrite.message})`, + ); + warnings.push('duplicate'); + } + + // 6. Apply / defer, exactly like an ordinary card save. + this.deps.config.invalidateCandidates(); + let applied: 'applied' | 'deferred' | 'failed'; + if (request.toRoot === this.deps.getActiveRoot()) { + const reload = await this.deps.reloadManifest(request.toRoot); + // `failed`, not `applied`, when the re-read was refused — the same verdict `save()` gives for the + // same case. Reporting it as applied sent the caller off to focus a game the carousel has not got. + applied = reload.ok ? 'applied' : 'failed'; + if (!reload.ok) { + log.warn( + `[game-move] id=${request.id}: moved, but reloading the active card failed: ${reload.message}`, + ); + } + } else { + applied = 'deferred'; + this.deps.notify({ kind: 'game-moved-deferred', gameTitle }); + } + // Every warning gets its own notification, which is the ONLY channel it has: the screen closes the + // moment a move succeeds, so a field on the result would have nobody left to show it (and the + // duplicate case must not be the log's secret alone). + for (const kind of warnings) { + this.deps.notify({ kind: MOVE_WARNING_NOTIFICATION[kind], gameTitle }); + } + + // 7. The library backup ↔ save-folder pairing this baseline described is gone now that the game has + // left the library — a stale one would read as a false conflict if the game is ever moved back. Only + // when the library write actually went through: in the duplicate case the game is still there, and + // its baseline is still the truth. + if (fromWrite.ok) await this.deps.pcStore.removeSyncState(request.fromId, 'pc'); + + return { moved: true, applied }; + } + + /** + * The manifest text of a root as it stands right now, or null when the root carries no game.json. An + * unreadable-but-present file is an ERROR rather than a null: null means "delete the file to undo", and + * guessing that for a file we simply failed to read would destroy it. + */ + private async readManifestSnapshot( + root: string, + ): Promise< + | { readonly ok: true; readonly text: string | null } + | { readonly ok: false; readonly message: string } + > { + const file = path.join(root, MANIFEST_FILENAME); + if (!(await fse.pathExists(file))) return { ok: true, text: null }; + try { + return { ok: true, text: await fse.readFile(file, 'utf8') }; + } catch (cause) { + return { + ok: false, + message: this.deps.getTranslator()('errors.cannotReadManifest', { + file: MANIFEST_FILENAME, + cause: describe(cause), + }), + }; + } + } + + private firstIssueMessage( + issues: readonly { readonly path: string; readonly message: string }[], + t: Translator, + ): string { + const first = issues[0]; + return first !== undefined ? `${first.path}: ${first.message}` : t('errors.configInvalid'); + } + + /** The folder a move copies saves FROM: the live location if it (and its container) exist, else the + * PC-library's own backup (`saves/`) as a fallback. Null when neither has anything to copy. */ + private async liveOrBackupSaveDir(manifest: ResolvedManifest): Promise { + if (manifest.pcSavePath !== undefined) { + const live = await this.deps.savePathResolver.resolvePcSavePath( + manifest, + manifest.pcSavePath, + ); + if (live !== null && live.containerExists && (await fse.pathExists(live.path))) { + return live.path; + } + } + if (manifest.saveOnCardPath !== undefined && (await fse.pathExists(manifest.saveOnCardPath))) { + return manifest.saveOnCardPath; + } + return null; + } + + /** + * Undoes what steps 2/3 put on the CARD — never touches the PC library. + * + * An asset whose destination was ALREADY occupied is deliberately left alone: the copy overwrote it and + * there is nothing to restore, so removing it would turn "the move failed" into "and your file is gone + * too". The saves folder is emptied rather than removed when it predates the move. + */ + private async rollbackMoveCopies( + copied: readonly AssetCopyRecord[], + saveCopy: SaveCopyRecord, + ): Promise { + for (const asset of copied) { + if (asset.existedBefore) continue; + try { + await fse.remove(asset.to); + } catch (cause) { + log.warn(`[game-move] failed to roll back copied asset "${asset.to}":`, describe(cause)); + } + } + if (saveCopy.dir === null) return; + try { + await fse.remove(saveCopy.dir); + if (saveCopy.existedBefore) await fse.ensureDir(saveCopy.dir); + } catch (cause) { + log.warn( + `[game-move] failed to roll back the copied save folder "${saveCopy.dir}":`, + describe(cause), + ); + } + } + + /** + * Best-effort revert of the card's game.json after the library write failed post-write: writes back the + * bytes the card carried before the move, or deletes the file when it had none (`toTextBefore === null`). + * Returns whether the revert itself succeeded. + */ + private async rollbackCardWrite(toRoot: string, toTextBefore: string | null): Promise { + try { + if (toTextBefore === null) await fse.remove(path.join(toRoot, MANIFEST_FILENAME)); + else await writeFileAtomicEnsuringDir(path.join(toRoot, MANIFEST_FILENAME), toTextBefore); + return true; + } catch (cause) { + log.warn(`[game-move] failed to roll back the card write at "${toRoot}":`, describe(cause)); + return false; + } + } +} diff --git a/src/main/game-move.ts b/src/main/game-move.ts index c3a39743..98750ac2 100644 --- a/src/main/game-move.ts +++ b/src/main/game-move.ts @@ -1,13 +1,15 @@ -// The electron-free half of "Move to card…" (see the plan, Р2.4/Р2.5/Р2.8): moving a local (PC-library) -// game onto a card. Pure so it can be unit-tested — the transaction itself (GameConfigService.moveToCard) -// touches fs and cannot be imported in vitest, the same reason game-config-add.ts was carved out. +// The pure half of "Move to card…": moving a local (PC-library) game onto a card — which assets travel +// and how a slot leaves the library text, as functions of strings. Unit-tested on their own +// (test/game-move.test.ts); the transaction that applies them to the disk (GameMoveTransaction.moveToCard) +// is driven end to end in test/game-move-transaction.test.ts, the same split as game-config-add.ts. import path from 'node:path'; import { movedGridAssetPath, movedHeroAssetPath, movedMusicAssetPath, } from '../shared/asset-move-names'; -import { type ResolvedManifest } from '../shared/types'; +import type { ResolvedManifest } from './manifest-types'; +import { parseManifestItems } from './manifest'; function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); @@ -18,20 +20,15 @@ function isRecord(value: unknown): value is Record { * library's post-move text (the "from" side — never sent by the renderer, always derived here; the same * never-trust-the-renderer's-derived-text stance the rest of GameConfigService takes for a write), and the * target card's PRE-move text, recovered by removing the just-inserted slot back out of `toText` (used to - * best-effort restore the card if the library write fails after the card's already been written — see the - * plan, Р2.5's rollback note). Mirrors `gamesToText`'s shape rules (configure-form-model.ts) so the result - * round-trips through the SAME reader every other write does: a lone object for exactly one game left, an - * array for more than one, `'[]\n'` for none. Returns null only if `text` is not valid JSON, which never - * happens for text this function is actually called with (always already schema-validated beforehand). + * best-effort restore the card if the library write fails after the card's already been written). + * Mirrors `gamesToText`'s shape rules (configure-form-model.ts) so the result round-trips through the + * SAME reader every other write does: a lone object for exactly one game left, an array for more than + * one, `'[]\n'` for none. Returns null only if `text` is not valid JSON, which never happens for text + * this function is actually called with (always already schema-validated beforehand). */ export function removeGameFromManifestText(id: string, text: string): string | null { - let parsed: unknown; - try { - parsed = JSON.parse(text) as unknown; - } catch { - return null; - } - const items: readonly unknown[] = Array.isArray(parsed) ? parsed : [parsed]; + const items = parseManifestItems(text); + if (items === null) return null; const kept = items.filter((item) => !(isRecord(item) && item['id'] === id)); if (kept.length === 0) return '[]\n'; const value: unknown = kept.length === 1 ? kept[0] : kept; @@ -41,20 +38,15 @@ export function removeGameFromManifestText(id: string, text: string): string | n /** The raw (already-validated) JSON object for game `id` inside manifest `text` — a single object or one * element of an array. Null when `text` is not valid JSON or names no game with that id. */ export function findGameInText(id: string, text: string): Record | null { - let parsed: unknown; - try { - parsed = JSON.parse(text) as unknown; - } catch { - return null; - } - const items: readonly unknown[] = Array.isArray(parsed) ? parsed : [parsed]; + const items = parseManifestItems(text); + if (items === null) return null; const found = items.find((item) => isRecord(item) && item['id'] === id); return found !== undefined && isRecord(found) ? found : null; } /** - * The card-relative path that must already exist on the target BEFORE a move commits (Р2.6 — "the files - * of the game itself"), or null when there is nothing to check: Steam mode has no card file at all. + * The card-relative path that must already exist on the target BEFORE a move commits ("the files of the + * game itself"), or null when there is nothing to check: Steam mode has no card file at all. * * `carryFormToCard` only ever LANDS a moved game in `steam` or `executable` mode, but the form stays open * afterwards and offers every mode a card allows — Installer among them — so all three are reachable by @@ -78,13 +70,8 @@ export function expectedGameFilePath(raw: Record): string | nul /** How many games in manifest `text` carry `id` — used to detect an id already taken on the target card * (our own inserted slot always counts as one; more than one means a genuine collision). */ export function countGamesWithId(id: string, text: string): number { - let parsed: unknown; - try { - parsed = JSON.parse(text) as unknown; - } catch { - return 0; - } - const items: readonly unknown[] = Array.isArray(parsed) ? parsed : [parsed]; + const items = parseManifestItems(text); + if (items === null) return 0; return items.filter((item) => isRecord(item) && item['id'] === id).length; } diff --git a/src/main/game-sequences.ts b/src/main/game-sequences.ts new file mode 100644 index 00000000..84f72915 --- /dev/null +++ b/src/main/game-sequences.ts @@ -0,0 +1,698 @@ +// The game's process lifecycle: the launch sequence, the Steam pair (fire-and-forget), the force-close, +// and the save sync around a session. Installs and removals are background jobs (game-jobs.ts). Split out of +// GameController, which keeps the card session (insert / remove / collision) and DISPATCHES here from +// the renderer's actions; what the sequences need back from it (GameInfo, the ready transition, "is this +// game's source still there") comes through the SequenceHost seam. The save sync a session is bracketed +// with lives in save-sync-flow.ts. +// +// The blocking launch sequence runs inside one scaffold, `runSequence`: launchInFlight + an AbortController for +// the span, the entering state, the LaunchAbortedError guard (a card swap or shutdown unwinds silently), +// `failSequence` for anything else, and a finally that releases the owned process, clears the flags and +// replays a card that was swapped in mid-flight. Launch alone adds to the finally (the lock and the +// running-game fields) through `onFinally`. +import { type AppState, type GameInfo, type Stats } from '../shared/types'; +import type { ResolvedManifest } from './manifest-types'; +import { type Translator } from '../shared/i18n/index'; +import { type ControllerDeps } from './controller-deps'; +import { LaunchAbortedError } from './launch-errors'; +import { openSteamUri } from './steam-uri'; +import { type GameProcess, type Platform, type ProcessMonitor } from './platform'; +import { normalizeImageNames } from './image-names'; +import { type SteamActivityWatch } from './steam-activity-watch'; +import { type SaveSyncFlow } from './save-sync-flow'; +import { describe, delay } from './util'; +import { log } from './logger'; + +// Force-close verification: after issuing the kills, a `taskkill /F` (or TerminateProcess) returns +// BEFORE the process actually leaves tasklist — a killed process in teardown still shows for a beat, and +// a launcher/wrapper can take longer (the very reason the exit waiters debounce). So we don't judge on a +// single instant snapshot: poll the targets over a window bounded by the manifest's killTimeoutSec +// (default 60s), succeeding as soon as they're all gone, and only reporting killFailed if something is +// STILL alive when the window elapses (a genuine failure, e.g. an elevated handle without +// PROCESS_TERMINATE rights). The poll cadence between snapshots: +const KILL_VERIFY_INTERVAL_MS = 500; + +// For a runAsAdmin (elevated) game, the non-elevated kill can't touch its high-integrity processes. We +// give that first attempt a short grace to prove itself (a normal game dies well within this), and only +// if the targets survive it do we escalate to an elevated taskkill (one UAC prompt). Kept short so the +// UAC prompt isn't needlessly delayed for a game that genuinely needs it. +const KILL_ELEVATE_GRACE_SEC = 3; + +/** What the sequences need back from the controller: the card session's answers and its transitions. */ +export interface SequenceHost { + /** The single "active" manifest — the selected game (see GameController.current). */ + current(): ResolvedManifest | null; + buildGameInfo(manifest: ResolvedManifest, stats: Stats): Promise; + /** The single entry point for the `ready` state. */ + enterReady(info: GameInfo): void; + sourceAvailable(manifest: ResolvedManifest): boolean; + currentSourceAvailable(): boolean; + /** The "the card went away while we were busy" landing. */ + cardGoneAfterSequence(): void; + browseToUnlessPinned(id: string): Promise; + refreshLibrary(): void; + /** Caches a game's reconciled stats (the per-id cache behind the carousel order). */ + rememberStats(id: string, stats: Stats): void; + /** Sends a transient error to the renderer to surface in the error popup. */ + sendError(message: string): void; + /** Replays a card insertion that was deferred while a sequence was in flight. */ + onInsert(root: string): Promise; +} + +export type SequenceDeps = Pick< + ControllerDeps, + | 'state' + | 'window' + | 'stats' + | 'library' + | 'settings' + | 'notifications' + | 'platform' + | 'processControl' + | 'getTranslator' + | 'activities' +> & { + readonly steamWatch: Pick; + /** The card↔PC save sync the launch sequence brackets the game with. */ + readonly saveSync: Pick; + readonly host: SequenceHost; +}; + +type SequencePhase = 'launch'; + +/** The process a sequence spawned, if any — held by reference so the shared finally can dispose it. */ +interface OwnedProcess { + proc: GameProcess | null; +} + +export class GameSequences { + // True while a game is launching/running: main is "locked" on that game — a game switch is refused and + // the carousel cannot enter another game's detail as actionable (its guard is `kind==='ready'`). + private locked = false; + private launchInFlight = false; + private abort: AbortController | null = null; + // A card swapped in WHILE a launch/install was in flight: DriveWatcher can swap without an + // empty tick, so we stash the new root, abort the in-flight sequence, and replay onInsert from its + // finally (after launchInFlight clears) — otherwise the aborted sequence could set state over the new card. + private pendingRoot: string | null = null; + // Image names (lower-case *.exe basenames) of the currently-running game, captured on entry to + // `running` so a Play press in that state can find and raise the game's window (return-to-game). Null + // whenever no game is running; reset in the launch sequence's finally. Matched by image name rather than + // pid so it covers all backends uniformly, incl. elevated games a non-elevated tasklist can't see. + private runningImageNames: readonly string[] | null = null; + // The owned GameProcess of the currently-running game, kept so a force-close can terminate it directly: + // the elevated HANDLE (invisible to taskkill) or the normal pid tree. A REFERENCE to the same object + // disposed in the launch sequence's finally (the single owner) — set alongside runningImageNames on + // entry to `running` (normal/elevated + watched branches; null for steam, which owns no process), + // cleared in that same finally. Never disposed from here. + private runningProc: GameProcess | null = null; + // A force-close (onKillRequested) is underway. Local try/finally flag (mirrors reloadInFlight, NOT the + // launch sequence's finally — a kill has its own short-lived lifecycle) so a double Yes / repeat is a no-op. + private killInFlight = false; + // The installing/launching state to restore once winetricks provisioning ends. Null when not + // provisioning. The "Configuring Proton" screen + its rotating funny suffix are the renderer's job. + private protonConfigPriorState: AppState | null = null; + + constructor(private readonly deps: SequenceDeps) {} + + private get host(): SequenceHost { + return this.deps.host; + } + + /** The current translator (a message is fixed at the language of the moment it is generated). */ + private get t(): Translator { + return this.deps.getTranslator(); + } + + /** The platform process monitor (win32 tasklist / linux /proc), threaded into the launcher + waits. */ + private get monitor(): ProcessMonitor { + return this.deps.platform.processMonitor; + } + + /** The platform game launcher (win32 spawn/ShellExecuteEx / linux umu-run/Proton). */ + private get launcher(): Platform['gameLauncher'] { + return this.deps.platform.gameLauncher; + } + + // ── What the controller reads back ────────────────────────────────────────── + + /** True for the whole span of a blocking sequence (launch / install / uninstall / prefix cleanup). */ + get inFlight(): boolean { + return this.launchInFlight; + } + + /** True while main is locked on a launching/running game (a game switch is refused). */ + get isLocked(): boolean { + return this.locked; + } + + /** Releases the lock — the card session's own cleanup (a card cleared or re-read) does this too. */ + unlock(): void { + this.locked = false; + } + + /** Image names of the running game for return-to-game; null whenever no game is running. */ + get runningGameImageNames(): readonly string[] | null { + return this.runningImageNames; + } + + /** + * A card was swapped in mid-flight: stash its root and abort the running sequence; the finally replays + * the insertion once the sequence has unwound. + */ + deferInsert(root: string): void { + this.pendingRoot = root; + this.abort?.abort(); + } + + /** Aborts whatever sequence is in flight (application exit). */ + abortInFlight(): void { + this.abort?.abort(); + } + + // ── Provisioning + liveness helpers ───────────────────────────────────── + + /** + * Linux prefix provisioning (winetricks) started/finished — the launcher's onProvisioning callback + * On start: stash the current launching state and show the rotating "Configuring Proton" screen. On + * finish: stop the rotation and restore the stashed state (the launch sequence then continues from where + * it was). No-op on win32 (the launcher never fires this). + */ + private setProvisioning(active: boolean, game: GameInfo): void { + if (active) { + this.protonConfigPriorState = this.deps.state.get(); + this.deps.state.set({ kind: 'configuringProton', game }); + } else if (this.protonConfigPriorState !== null) { + this.deps.state.set(this.protonConfigPriorState); + this.protonConfigPriorState = null; + } + } + + /** True if any of the given image names is currently running (fresh snapshot; empty list → false). */ + private async anyTargetAlive(targets: readonly string[]): Promise { + if (targets.length === 0) return false; + const snapshot = await this.monitor.snapshot(); + return targets.some((name) => snapshot.hasImageName(name)); + } + + // ── Force-close ───────────────────────────────────────────────────────────── + + /** + * Force-close the running game (More → Force close → confirmed Yes). Flips the running snapshot into its + * `killing` sub-state (the launcher shows "Force closing…" and hides the Force close button), kills the + * main executable AND every watchProcess, then lets the EXISTING exit waiters (waitForExit / + * waitForWatchedExit) notice the processes vanish and carry the flow through syncing-out → sync → ready + * — no state machine of its own. Guarded by the running state + a killInFlight flag (double Yes / + * repeat is a no-op). + * + * A non-elevated launcher can't terminate a runAsAdmin game's high-integrity processes (taskkill → + * ACCESS_DENIED, the ShellExecuteEx HANDLE lacks PROCESS_TERMINATE). So for a runAsAdmin game, if the + * targets survive a short grace, we escalate to ONE elevated `taskkill /F /T /IM …` (a single UAC + * prompt). Non-elevated games never trigger UAC. + * + * Success is judged by FACT, not command exit codes: a "not found" from taskkill just means the target + * is already dead (success). After the kills we verify over a WINDOW bounded by killTimeoutSec (a killed + * process lingers in tasklist for a beat, so a single instant snapshot would false-positive): success as + * soon as the targets are gone (the `killing` indicator stays until an exit waiter advances the flow). + * If something is still alive when the window elapses, we DROP back to plain running (the game is still + * up) and surface a soft errors.killFailed. + */ + async onKillRequested(): Promise { + const snapshot = this.deps.state.get(); + if (snapshot.kind !== 'running') return; // only meaningful while a game is running + if (this.killInFlight) return; // a force-close is already underway (double Yes / repeat) + const manifest = this.host.current(); + if (manifest === null) return; // defensive: `running` always has a current manifest + this.killInFlight = true; + // Show "Force closing…" and hide the Force close button immediately (cleared back on failure). + this.deps.state.set({ ...snapshot, killing: true }); + try { + // Steam mode is tracked/killed by SteamAppId (native + Proton games), with no owned pid and no + // elevation (the schema forbids runAsAdmin there). Every other mode kills the owned process + the + // target image names, escalating to an elevated taskkill for a runAsAdmin game. Both end in the same + // fact-based verdict below (stillAlive). + let stillAlive: boolean; + if (manifest.steam !== undefined) { + const appid = manifest.steam.appid; + const names = manifest.raw.watchProcesses ?? []; + log.info(`[kill] force-close requested id=${manifest.raw.id} steam appid=${appid}`); + await this.monitor.killSteamGame(appid, names); + stillAlive = await this.steamGameStillAlive(appid, names, manifest.raw.killTimeoutSec); + } else { + // Targets are computed HERE from this.current, leaving runningImageNames untouched: a union there + // would regress return-to-game (focusGameWindow picks the first Z-order match — the launcher name + // could steal focus from the game). + const targets = normalizeImageNames([ + manifest.executablePath, + ...(manifest.raw.watchProcesses ?? []), + ]); + log.info( + `[kill] force-close requested id=${manifest.raw.id} targets=[${targets.join(',')}]`, + ); + + // 1. Terminate the owned process (elevated HANDLE, or the normal pid tree with an isAlive re-check + // inside kill()). In the watched path this is usually the already-dead launcher — its "not + // found" is normal, not an error. + const proc = this.runningProc; + if (proc !== null) { + try { + await proc.kill(); + } catch (cause) { + log.warn( + '[kill] owned-process kill failed (continuing to kill by name):', + describe(cause), + ); + } + } + + // 2. Kill each target image by name (non-elevated): win32 `taskkill /F /IM`, linux SIGTERM/SIGKILL + // to every /proc match. Failures are normal ("not found" = already dead). + await this.monitor.killByName(targets); + + // 2b. Elevated escalation (runAsAdmin games only). A non-elevated taskkill / the ShellExecuteEx + // HANDLE can't terminate high-integrity processes, so if the targets survive a short grace we + // run ONE elevated `taskkill /F /T /IM …` (a single UAC prompt). Non-elevated games never reach + // here (no UAC for them). A declined UAC just leaves the targets up → killFailed below. + if ( + manifest.raw.runAsAdmin && + (await this.killTargetsStillAlive(targets, proc, KILL_ELEVATE_GRACE_SEC)) + ) { + log.info( + `[kill] elevated game survived non-elevated kill id=${manifest.raw.id} — escalating to elevated taskkill (UAC)`, + ); + this.monitor.killImagesElevated(targets); + } + + // 3. Fact-based verdict over a window bounded by killTimeoutSec (a killed process lingers for a + // beat — a single instant snapshot would false-positive). + stillAlive = await this.killTargetsStillAlive(targets, proc, manifest.raw.killTimeoutSec); + } + + // killFailed only if something is STILL alive when the window elapsed. + if (stillAlive) { + log.warn( + `[kill] targets still alive after force-close id=${manifest.raw.id} — reporting killFailed`, + ); + // The game is still up → back to plain running (status "Running…", Force close button returns), + // then surface the error. Re-read in case a waiter advanced the state (then leave it be). + const current = this.deps.state.get(); + if (current.kind === 'running') this.deps.state.set({ ...current, killing: false }); + this.host.sendError(this.t('errors.killFailed')); + } else { + log.info( + `[kill] force-close done id=${manifest.raw.id} — exit waiters will finish the flow`, + ); + } + } finally { + this.killInFlight = false; + } + } + + /** + * Polls the kill targets for up to `timeoutSec`, returning false (success — everything is gone) as soon + * as no target image is present AND the owned process (elevated HANDLE / normal pid) is dead, OR once an + * exit waiter has already advanced the state out of `running` (it saw the exit → definitely killed). + * Returns true only if something is still alive when the window elapses — a genuine failure. + */ + private async killTargetsStillAlive( + targets: readonly string[], + proc: GameProcess | null, + timeoutSec: number, + ): Promise { + const deadline = Date.now() + timeoutSec * 1000; + for (;;) { + // An exit waiter that already left `running` proves the process is gone — treat as killed. + if (this.deps.state.get().kind !== 'running') return false; + const ownedAlive = proc !== null && (await proc.isAlive()); + if (!ownedAlive && !(await this.anyTargetAlive(targets))) return false; + if (Date.now() >= deadline) return true; // window elapsed and something is still alive → real fail + await delay(KILL_VERIFY_INTERVAL_MS); + } + } + + /** + * Steam-mode analogue of killTargetsStillAlive: polls the monitor's SteamAppId signal (linux) / watch + * names (win32) until the game is gone or the window elapses. Returns true only if it is STILL running. + */ + private async steamGameStillAlive( + appid: number, + watchNames: readonly string[], + timeoutSec: number, + ): Promise { + const deadline = Date.now() + timeoutSec * 1000; + for (;;) { + if (this.deps.state.get().kind !== 'running') return false; // an exit waiter already left `running` + if (!(await this.monitor.isSteamGameRunning(appid, watchNames))) return false; + if (Date.now() >= deadline) return true; + await delay(KILL_VERIFY_INTERVAL_MS); + } + } + + // ── Steam (fire-and-forget: Steam runs the download / removal itself) ─────── + + /** + * Steam install action: fire-and-forget. Opens `steam://install/` (Steam shows its own dialog + * and the download — possibly hours/GBs) and returns WITHOUT entering a blocking `installing` state. + * We stay on the `ready` ("Install") screen; the Steam activity watch picks the download up on its next + * poll and flips the button to "Play" once Steam's .acf reports the game fully installed. Steam itself collapses + * repeated `steam://install` calls, so no debounce is needed. Pre-checks `steamLocator.locateSteam()`: openExternal + * doesn't reliably reject when steam:// is unregistered. + */ + async runSteamInstall(manifest: ResolvedManifest): Promise { + const appid = manifest.steam?.appid; + if (appid === undefined) return; // defensive: onLaunchRequested only calls this in steam mode + if ((await this.deps.platform.steamLocator.locateSteam()) === null) { + this.host.sendError(this.t('errors.steamNotInstalled')); + return; + } + try { + await openSteamUri(`steam://install/${appid}`); + log.info(`[steam-install] opened steam://install/${appid} id=${manifest.raw.id}`); + } catch (cause) { + this.host.sendError(this.t('errors.steamOpenInstall', { cause: describe(cause) })); + return; + } + void this.deps.steamWatch.scanNow(); + } + + /** + * Steam uninstall action: fire-and-forget, mirroring runSteamInstall. Opens `steam://uninstall/` + * (Steam shows its own confirmation/removal UI) and returns WITHOUT a blocking `uninstalling` state. We + * stay on the `ready` ("Play"/"Uninstall") screen; the Steam activity watch flips the button back to + * "Install" once Steam removes the .acf. Pre-checks `steamLocator.locateSteam()`. + */ + async runSteamUninstall(manifest: ResolvedManifest): Promise { + const appid = manifest.steam?.appid; + if (appid === undefined) return; // defensive: onUninstallRequested only calls this in steam mode + if ((await this.deps.platform.steamLocator.locateSteam()) === null) { + this.host.sendError(this.t('errors.steamNotInstalled')); + return; + } + try { + await openSteamUri(`steam://uninstall/${appid}`); + log.info(`[steam-uninstall] opened steam://uninstall/${appid} id=${manifest.raw.id}`); + } catch (cause) { + this.host.sendError(this.t('errors.steamOpenUninstall', { cause: describe(cause) })); + return; + } + this.deps.steamWatch.requestUninstall(appid); + this.deps.activities.set(manifest.raw.id, { kind: 'steam-uninstalling' }); + void this.deps.steamWatch.scanNow(); + } + + // ── The blocking sequences ────────────────────────────────────────────────── + + /** + * The scaffold every blocking sequence runs inside. Sets launchInFlight and the AbortController for + * the span, enters `entering` for the game, then runs `body`. A LaunchAbortedError (a card swap or + * shutdown) unwinds silently; any other error is a `failSequence` for `phase`. The finally releases the + * process `body` handed into `owned`, clears the flags, runs `onFinally` (launch's running-game + * fields), and last replays a card that was swapped in mid-flight, now that launchInFlight has cleared. + */ + private async runSequence( + phase: SequencePhase, + entering: 'syncing-in', + info: GameInfo, + body: (abort: AbortController, owned: OwnedProcess) => Promise, + onFinally?: () => void, + ): Promise { + this.launchInFlight = true; + const abort = new AbortController(); + this.abort = abort; + const owned: OwnedProcess = { proc: null }; + try { + this.deps.state.set({ kind: entering, game: info }); + await body(abort, owned); + } catch (cause) { + if (cause instanceof LaunchAbortedError) return; // aborted by shutdown or a card swap + this.failSequence(phase, info, describe(cause)); + } finally { + // Release the elevated HANDLE (no-op for the normal spawn path). + owned.proc?.dispose(); + this.launchInFlight = false; + this.abort = null; + onFinally?.(); + // Replay a card that was swapped in mid-flight, now that launchInFlight has cleared. + this.resumePendingInsert(); + } + } + + async runLaunchSequence(manifest: ResolvedManifest, info: GameInfo): Promise { + const { state, window, stats } = this.deps; + // Lock the launcher on this game for the launching→running span: a game switch is refused and the + // switching the card's game is refused. Cleared in the finally alongside the other running-scoped fields. + this.locked = true; + await this.runSequence( + 'launch', + 'syncing-in', + info, + async (abort, owned) => { + // 1. Change-based sync before the game (phase = sync-in). No longer a blind card→PC: if the PC + // saves changed since the last sync (e.g. played on another PC last, or this PC is newer) they are + // NOT overwritten — the changed side wins (see save-sync change-detection). The old card→PC is only + // the first-run fallback (no baseline yet). + if (manifest.pcSavePath !== undefined && manifest.saveOnCardPath !== undefined) { + // Resolve the deferred pcSavePath to this game's save location. null → nothing to sync + // with at all (a steam game with no compatdata) — a logged no-op. + const pcSave = await this.deps.saveSync.resolvePcSavePath(manifest); + if (pcSave === null) { + log.info( + `[sync-in] pcSavePath "${manifest.pcSavePath}" not resolvable yet — skipping sync`, + ); + } else { + // A MISSING prefix is not a reason to skip: the launch below creates it, and the card's saves + // must be in place before the game reads them (e.g. after an uninstall wiped the prefix). The + // copy targets the prefix path directly — launchGame ensureDir's that prefix anyway — and + // runSaveSync drops the stale baseline so the empty PC side can't erase the card. + try { + log.info( + `[sync-in] change-based sync between card "${manifest.saveOnCardPath}" and PC "${pcSave.path}"${pcSave.containerExists ? '' : ' (prefix absent — restoring from card)'}`, + ); + // Soft catch: sync-in can now WRITE to the card (change-detection may pick PC→card) — a new + // failure point BEFORE launch (a full / write-protected / slow card). The launch never depended + // on a card write before, so keep it that way: log and start the game regardless (mirrors sync-out). + await this.deps.saveSync.runSaveSync( + manifest, + manifest.saveOnCardPath, + pcSave.path, + 'card-to-pc', + pcSave.containerExists, + ); + } catch (cause) { + log.warn('[sync-in] change-based sync failed, launching anyway:', describe(cause)); + } + } + } + + // 2/3/4. launch, then wait for the game to appear and to exit. THREE backends: + // - steam: open steam://rungameid (no proc of ours); wait by watched names only (launcherPid=null). + // - watched (launcher/wrapper, manifest.watchProcesses): the game is a SEPARATE process; we wait + // for one of the watched image names to appear (HANDOFF — the launcher may live on in its menu), + // then track that process's presence for exit. + // - normal: the spawned pid IS the game; wait for that pid to appear, then disappear. + // Running-phase note (all paths): gamepad input is ignored (outside ready). The window stays put — + // the game takes the foreground on its own and simply covers the launcher, which avoids the jerky + // hide/show flash. We grab the foreground back in step 6 once the game exits. The global Start+Back + // hotkey is intentionally a no-op while running, so there's nothing to re-summon. + const watchProcesses = manifest.raw.watchProcesses; + let since: number; + if (manifest.steam !== undefined) { + state.set({ kind: 'launching', game: info }); + // Pre-check: openExternal doesn't reliably reject when steam:// is unregistered, so gate the + // launch on Steam actually being installed instead of relying on a reject. + if ((await this.deps.platform.steamLocator.locateSteam()) === null) { + this.failSequence('launch', info, this.t('errors.steamNotInstalled')); + return; + } + try { + await openSteamUri(`steam://rungameid/${manifest.steam.appid}`); + } catch (cause) { + this.failSequence( + 'launch', + info, + this.t('errors.launchViaSteam', { cause: describe(cause) }), + ); + return; + } + // Track by SteamAppId (via the monitor): on linux that reads /proc environ, so native-Linux AND + // Proton games are detected regardless of their binary name; on win32 it maps to the watch names. + const { started } = await this.deps.processControl.waitForSteamStart( + manifest.steam.appid, + watchProcesses ?? [], + manifest.raw.launchTimeoutSec, + this.monitor, + abort.signal, + ); + if (!started) { + // Known MVP limitation: a Steam cold-start or an auto-update before launch may not fit + // launchTimeoutSec → the game-process never appears in the window. We can't tell that apart + // from "didn't start", so we return quietly (recommend a larger launchTimeoutSec). + log.info( + `[launch] steam game never appeared within ${manifest.raw.launchTimeoutSec}s id=${manifest.raw.id} (cold-start/update?)`, + ); + this.abandonWatchedLaunch(info); + return; + } + since = Date.now(); + this.runningImageNames = normalizeImageNames(watchProcesses ?? []); + // Steam owns no process of ours (steam://rungameid returns instantly) — a force-close relies on + // taskkill /IM over the watchProcesses alone. + this.runningProc = null; + state.set({ kind: 'running', game: info, since }); + log.info(`[launch] running (steam) id=${manifest.raw.id} appid=${manifest.steam.appid}`); + await this.deps.processControl.waitForSteamExit( + manifest.steam.appid, + watchProcesses ?? [], + this.monitor, + abort.signal, + ); + log.info(`[launch] exited (steam) id=${manifest.raw.id}`); + } else { + // 2. launch → GameProcess (spawn, or elevated ShellExecuteEx per manifest.runAsAdmin) + state.set({ kind: 'launching', game: info }); + let proc: GameProcess; + try { + proc = await this.launcher.launchGame(manifest, (active) => + this.setProvisioning(active, info), + ); + } catch (cause) { + this.failSequence( + 'launch', + info, + this.t('errors.launchGame', { cause: describe(cause) }), + ); + return; + } + // Handed to the scaffold so `finally` can dispose the kept HANDLE (elevated path). + owned.proc = proc; + if (watchProcesses !== undefined && watchProcesses.length > 0) { + const { started } = await this.deps.processControl.waitForWatchedStart( + proc.pid, + watchProcesses, + manifest.raw.launchTimeoutSec, + this.monitor, + abort.signal, + ); + if (!started) { + // The user closed the launcher without playing, or the game never became visible (often an + // elevated/anticheat launcher — see README). Neither a failure nor a play session. + this.abandonWatchedLaunch(info); + return; + } + // The watched game is up: start the clock now (more accurate than the launcher's spawn time). + since = Date.now(); + this.runningImageNames = normalizeImageNames(watchProcesses); + // The spawned launcher (proc) — usually already dead here; kept so a force-close can also take + // down its pid tree. The game itself is killed by taskkill /IM over the watchProcesses. + this.runningProc = proc; + state.set({ kind: 'running', game: info, since }); + log.info( + `[launch] running (watched) id=${manifest.raw.id} watch=${watchProcesses.join(',')}`, + ); + await this.deps.processControl.waitForWatchedExit( + watchProcesses, + this.monitor, + abort.signal, + ); + log.info(`[launch] exited (watched) id=${manifest.raw.id}`); + } else { + const started = await this.deps.processControl.waitForStart( + proc, + manifest.raw.launchTimeoutSec, + abort.signal, + ); + if (!started) { + this.failSequence('launch', info, this.t('errors.gameDidNotStart')); + return; + } + since = Date.now(); + // normal AND elevated share this branch (differing only by manifest.raw.runAsAdmin): the game + // IS the spawned exe, so its image name is the executable's basename. + this.runningImageNames = normalizeImageNames([manifest.executablePath]); + // The game process itself — a force-close terminates it directly (elevated: via the HANDLE + // invisible to taskkill; normal: its pid tree with an isAlive re-check inside kill()). + this.runningProc = proc; + state.set({ kind: 'running', game: info, since }); + log.info(`[launch] running id=${manifest.raw.id} pid=${proc.pid}`); + await this.deps.processControl.waitForExit(proc, abort.signal); + log.info(`[launch] exited id=${manifest.raw.id} pid=${proc.pid}`); + } + } + + // 5. game closed → write stats to the PC (source of truth) + const playSeconds = (Date.now() - since) / 1000; + const updatedStats = await stats.recordPlay(manifest.raw.id, playSeconds); + const updatedInfo = await this.host.buildGameInfo(manifest, updatedStats); + // The history's cached stats follow the authority, and the game may have just EARNED its place in + // the carousel (an inserted-but-never-played game is not listed until now). + await this.deps.library.noteLaunch(manifest.raw.id, updatedStats); + // Before the refresh, not after: the card's own games are ordered by these very dates, and this + // game has just become the most recently played one. + this.host.rememberStats(manifest.raw.id, updatedStats); + this.host.refreshLibrary(); + + // 6. PC→SD + stats copy (or pending-flush, if the card is already gone). The game just exited, + // so reclaim the foreground (forceForeground) to surface the launcher over Steam/desktop. + state.set({ kind: 'syncing-out', game: updatedInfo }); + window.showAndFocus(true); + await this.deps.saveSync.performSyncOut(manifest, updatedStats); + + // 7. done + this.host.enterReady(updatedInfo); + // Refresh what's on screen too: the play time / launch count the detail screen shows just changed. + await this.host.browseToUnlessPinned(manifest.raw.id); + window.showAndFocus(); + }, + () => { + // The game is done → unlock (switching the card's game is allowed again). + this.locked = false; + // The game is no longer running → forget its image names (return-to-game only applies while running). + this.runningImageNames = null; + // Drop the owned-process reference (proc.dispose() in the scaffold is the single owner-side release; + // this is just the reference the force-close used while running). + this.runningProc = null; + }, + ); + } + + /** Replays a card insertion deferred during an in-flight launch/install. No-op if none pending. */ + private resumePendingInsert(): void { + const root = this.pendingRoot; + if (root === null) return; + this.pendingRoot = null; + void this.host.onInsert(root); + } + + /** + * A launch attempt failed: return to the 'ready' screen with the SAME info and surface the reason in the + * error popup. The info is unchanged, so the button stays "Play"; the user can read the error, close it + * (B / veil) and retry. + */ + private failSequence( + phase: SequencePhase, + game: GameInfo, + message: string, + ): void { + log.warn(`[${phase}] failed: ${message}`); + this.host.enterReady(game); + this.deps.window.showAndFocus(); + this.host.sendError(message); + } + + /** + * The watched-launcher path ended without the game ever becoming visible: the user closed the launcher + * without playing, or the game runs elevated / as a service and `tasklist` can't see it. This is + * neither a failure nor a play session — we do NOT call stats.recordPlay (it would bump launchCount and + * lastPlayedAt for a 0s session) and we do NOT surface an error popup. Back to the normal screen; if the + * card is already gone, go idle and hide, mirroring onRemove's cleanup. + */ + private abandonWatchedLaunch(game: GameInfo): void { + log.info('[launch] watched game never appeared — returning without recording a session'); + if (!this.host.currentSourceAvailable()) { + this.host.cardGoneAfterSequence(); + return; + } + this.host.enterReady(game); + this.deps.window.showAndFocus(); + } +} diff --git a/src/main/gamescope.ts b/src/main/gamescope.ts index a697a6ea..8ea76662 100644 --- a/src/main/gamescope.ts +++ b/src/main/gamescope.ts @@ -1,8 +1,8 @@ // SteamOS Game Mode (gamescope) detection. In Game Mode there is no tray, no window focus/always-on-top // control and exactly one window — so the launcher's "hide to tray" model collapses into "always show the // empty/error screen", and closing the window means quitting the app (Steam ends a non-Steam game that -// way). Every hide/show decision that must differ under gamescope funnels through isGamescopeSession() -// (see the port plan Р8), so the branching lives in one place instead of being smeared across call sites. +// way). Every hide/show decision that must differ under gamescope funnels through isGamescopeSession(), +// so the branching lives in one place instead of being smeared across call sites. // // Pure and env-only (no electron), so it is unit-testable and safe to import anywhere. diff --git a/src/main/history-config.ts b/src/main/history-config.ts index 2874bd2e..5d3d85e0 100644 --- a/src/main/history-config.ts +++ b/src/main/history-config.ts @@ -57,7 +57,7 @@ export function extractGameSlot(text: string, id: string): SlotResult { * * The file is re-serialized, so hand-authored formatting of the OTHER slots is lost — their DATA is not, * unknown keys included. That trade is deliberate: a range-level text replacement would be both dearer - * and more fragile (see the plan, п.7). + * and more fragile. */ export function replaceGameSlot(text: string, id: string, slot: GameSlot): SlotTextResult { const items = parseSlots(text); diff --git a/src/main/history-sync.ts b/src/main/history-sync.ts index 628ee9b2..7b304608 100644 --- a/src/main/history-sync.ts +++ b/src/main/history-sync.ts @@ -1,5 +1,5 @@ // Applying edits made from the HISTORY onto the card that carries the game — the step that runs at the -// very start of an insertion, before the manifests are read (see the plan, Р2/Р3/Р9). +// very start of an insertion, before the manifests are read. // // Everything here is guarded, because it writes to somebody's card: // @@ -18,7 +18,7 @@ import path from 'node:path'; import { createHash } from 'node:crypto'; import fse from 'fs-extra'; -import { MANIFEST_FILENAME } from '../shared/types'; +import { MANIFEST_FILENAME } from './manifest-types'; import type { Translator } from '../shared/i18n'; import { validateManifestText } from './manifest'; import type { ManifestValidationIssue } from '../shared/types'; @@ -40,8 +40,14 @@ import { describe } from './util'; /** Where a staged asset lands on the card — the same `assets/` convention the PC library writes. */ const CARD_ASSETS_DIRNAME = 'assets'; +/** The slice of the history the sync reads edits from and commits card slots to. */ +export type HistorySyncLibrary = Pick< + LibraryStore, + 'entry' | 'readEditedSlot' | 'readCardSlot' | 'dropEdits' | 'takeCardSlot' | 'stagedFiles' | 'stagedFilePath' +>; + export interface HistorySyncDeps { - readonly library: LibraryStore; + readonly library: HistorySyncLibrary; readonly t: Translator; } @@ -170,7 +176,7 @@ export async function syncHistoryConfig( */ export async function commitHistorySync( sync: HistorySyncResult, - library: LibraryStore, + library: HistorySyncLibrary, ): Promise { for (const [id, slot] of sync.uncommitted) { await library.takeCardSlot(id, slot); diff --git a/src/main/ipc.ts b/src/main/ipc.ts deleted file mode 100644 index 29b5e222..00000000 --- a/src/main/ipc.ts +++ /dev/null @@ -1,2833 +0,0 @@ -// Flow orchestrator + IPC registration. -// This is where the state machine lives: the controller listens to drive-watcher, reacts to -// the "Launch" action from the renderer, runs the sequence sync→spawn→wait→sync -// and replicates AppState to the window. All FS/process work happens only here (in main). -import path from 'node:path'; -import fse from 'fs-extra'; -import { app, clipboard, ipcMain } from 'electron'; -import { - IPC, - type AppState, - type SfxSet, - type BrowseInfo, - type ConfigSaveResult, - type GameCollision, - type GameCollisionAnswer, - type GameInfo, - type GameLibrary, - type HeroAssets, - type InstallerRunType, - type ResolvedInstallerRun, - type ResolvedCopyInstall, - type LaunchTarget, - type ManifestSource, - type ResolvedManifest, - type Stats, -} from '../shared/types'; -import { type Translator } from '../shared/i18n/index'; -import { type StateManager } from './state'; -import { type GameWindow } from './window'; -import { acceptsPendingFlush, type PcStore, type SyncSlot } from './pc-store'; -import { type StatsService } from './stats'; -import { type LibraryStore } from './library-store'; -import { type PcLibraryStore } from './pc-library'; -import { byRecentlyPlayed } from './library-index'; -import { - commitHistorySync, - rollbackHistorySync, - syncHistoryConfig, - type HistorySyncResult, -} from './history-sync'; -import { localGameGoesAfterMerge } from './history-config'; -import { sweepAtomicTemps } from './json-store'; -import { type DriveWatcher } from './drive-watcher'; -import { readManifests, findCaseInsensitiveName, isSafeGameId, type ManifestEnv } from './manifest'; -import { syncDir, syncByChange, snapshotTree } from './save-sync'; -import { - waitForExit, - waitForStart, - waitForWatchedExit, - waitForWatchedStart, - waitForSteamStart, - waitForSteamExit, - killImagesElevated, - LaunchAbortedError, - type GameProcess, -} from './game-launcher'; -import { findUninstallEntry } from './registry'; -import { steamInstallStatus } from './steam'; -import { openSteamUri } from './steam-uri'; -import { type PcSaveLocation, type Platform, type ProcessMonitor } from './platform'; -import { AssetReader } from './asset-reader'; -import { type AppSettingsStore } from './app-settings'; -import { type NotificationsService } from './notifications'; -import { focusGameWindow } from './window-finder'; -import { normalizeImageNames } from './image-names'; -import { SteamInstallWatch } from './steam-install-watch'; -import { describe, delay } from './util'; -import { log } from './logger'; - -/** - * What the collision answer needs from the Customize backend (GameConfigService). Attached after - * construction — the service is built from this controller, so it cannot also be one of its deps. - */ -export interface CollisionResolver { - /** The card's content signature right now, or null when it cannot be read. */ - signatureFor(root: string): Promise; - /** Puts a local game's name and artwork onto the card that carries the same id. */ - mergeCollision(answer: GameCollisionAnswer): Promise; - /** Drops a local game from the PC library (the draft whose look has just moved onto the card). */ - removeLocalGame(id: string): Promise; -} - -export interface ControllerDeps { - readonly state: StateManager; - readonly window: GameWindow; - readonly store: PcStore; - readonly stats: StatsService; - /** The play history behind the carousel: copied art/audio of every game inserted on this device. */ - readonly library: LibraryStore; - /** The local games added from this PC's own disk — a second, always-present manifest source. */ - readonly pcLibrary: PcLibraryStore; - readonly watcher: DriveWatcher; - /** App-wide settings store — read/patched by the custom-wallpaper handlers (they own AssetReader). */ - readonly settings: AppSettingsStore; - /** - * The notification inbox. Fed from the SUCCESS paths of the install/uninstall sequences only — never - * from a state transition: `failSequence` ends in `enterReady` too, and a Steam install never enters - * `installing` at all, so "it finished" cannot be read off the state machine. - */ - readonly notifications: NotificationsService; - /** Platform services (process monitor, Steam locator, launcher, save-path resolver, power) for the OS. */ - readonly platform: Platform; - /** - * Whether this is a SteamOS Game Mode (gamescope) session. In Game Mode there is no tray, so every path - * that would hide the window to the tray instead keeps the empty/error screen up (Р8). Always false on - * Windows/desktop, so their behaviour is unchanged. - */ - readonly isGamescope: boolean; - /** The current translator (read live so a language change applies to freshly-generated messages). */ - readonly getTranslator: () => Translator; -} - -// How long the browsed game's HEAVY assets (hero images, music — megabytes of data URL each) wait before -// being read. The light BrowseInfo goes out immediately, so the title/status/stats track the carousel -// with no lag; only the expensive half is debounced, and a burst of moves reads the disk once. -// -// It must outlast the GAP the renderer leaves between two chained auto-moves — releasing the pad for a -// beat and pressing again (AUTO_CHAIN_MS + NAV_REPEAT_MS in auto-repeat.ts, ~310 ms). Shorter than that -// and every such gap starts a megabyte-sized read plus a base64 encode for a game the user is already -// flipping past, which is what made a rapid press-release-press stutter. The renderer holds the swap for -// the same span (FLIP_SETTLE_MS in app.ts), so the two wait side by side rather than one after the other -// — this costs nothing on a single step. Keep the three in step if any of them changes. -const BROWSE_ASSETS_DEBOUNCE_MS = 320; - -// Grace-poll cadence after the installer exits, waiting for the game executable to appear. -const INSTALL_POLL_INTERVAL_MS = 1000; - -// Force-close verification: after issuing the kills, a `taskkill /F` (or TerminateProcess) returns -// BEFORE the process actually leaves tasklist — a killed process in teardown still shows for a beat, and -// a launcher/wrapper can take longer (the very reason the exit waiters debounce). So we don't judge on a -// single instant snapshot: poll the targets over a window bounded by the manifest's killTimeoutSec -// (default 60s), succeeding as soon as they're all gone, and only reporting killFailed if something is -// STILL alive when the window elapses (a genuine failure, e.g. an elevated handle without -// PROCESS_TERMINATE rights). The poll cadence between snapshots: -const KILL_VERIFY_INTERVAL_MS = 500; - -// For a runAsAdmin (elevated) game, the non-elevated kill can't touch its high-integrity processes. We -// give that first attempt a short grace to prove itself (a normal game dies well within this), and only -// if the targets survive it do we escalate to an elevated taskkill (one UAC prompt). Kept short so the -// UAC prompt isn't needlessly delayed for a game that genuinely needs it. -const KILL_ELEVATE_GRACE_SEC = 3; - -// Directory removal retries: an Inno uninstaller forks a copy of itself into -// temp and exits early, so right after waitForExit it may still hold `unins000.*` for a moment — a -// few backed-off retries let the lock clear before fse.remove succeeds. -const REMOVE_RETRY_ATTEMPTS = 3; -const REMOVE_RETRY_BASE_MS = 300; - -// ── Uninstaller resolution (FS search in the install dir → registry fallback) ── - -/** Silent flags we build ourselves per installer family (the same families' silent semantics, minus - * the dir-key). Never used for `custom` (it has no known silent-uninstall convention). */ -function silentUninstallArgs(type: InstallerRunType): string[] { - switch (type) { - case 'nsis': - return ['/S']; - case 'inno': - return ['/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART']; - case 'custom': - return []; - } -} - -/** - * Step 1 — deterministic FS search for the uninstaller INSIDE the app-controlled install dir (we put - * it there via the installer's dir-key, so it lives in the root): Inno drops `unins###.exe` (pick the - * highest if several), NSIS drops `Uninstall.exe`/`uninst*.exe`. `custom` has no known convention → null. - * - * `copy` is excluded by TYPE, not by a branch: a copied directory is a game somebody else installed - * earlier, so it may well carry a foreign `unins000.exe` that the nsis fallback below would happily - * find and run with `/S` — silently uninstalling from the wrong machine's point of view. - */ -async function findUninstallerInDir( - dir: string, - type: InstallerRunType, -): Promise { - if (type === 'custom') return null; - let names: readonly string[]; - try { - names = await fse.readdir(dir); - } catch { - return null; - } - if (type === 'inno') { - const candidates = names.filter((name) => /^unins\d{3}\.exe$/i.test(name)).sort(); - const chosen = candidates.at(-1); - return chosen !== undefined ? path.join(dir, chosen) : null; - } - // nsis: the name is set by the .nsi but is almost always Uninstall.exe / uninst*.exe in the root. - const match = names.find((name) => /^uninst(all)?.*\.exe$/i.test(name)); - return match !== undefined ? path.join(dir, match) : null; -} - -/** - * Parses a Windows command line into LOGICAL argv tokens following CommandLineToArgvW's backslash/quote - * rules (2n backslashes + quote → n backslashes and a quote toggle; 2n+1 → n backslashes and a literal - * quote). Used to split a registry UninstallString into exe + args; the original quoting is dropped (the - * launcher re-quotes uniformly under verbatim:false). - */ -function parseCommandLine(command: string): string[] { - const args: string[] = []; - let arg = ''; - let inQuotes = false; - let started = false; - let i = 0; - while (i < command.length) { - const ch = command[i]; - if (ch === undefined) break; - if (ch === '\\') { - let backslashes = 0; - while (command[i] === '\\') { - backslashes += 1; - i += 1; - } - if (command[i] === '"') { - arg += '\\'.repeat(Math.floor(backslashes / 2)); - if (backslashes % 2 === 1) { - arg += '"'; // escaped literal quote - } else { - inQuotes = !inQuotes; - } - i += 1; - } else { - arg += '\\'.repeat(backslashes); - } - started = true; - continue; - } - if (ch === '"') { - inQuotes = !inQuotes; - started = true; - i += 1; - continue; - } - if ((ch === ' ' || ch === '\t') && !inQuotes) { - if (started) { - args.push(arg); - arg = ''; - started = false; - } - i += 1; - continue; - } - arg += ch; - started = true; - i += 1; - } - if (started) args.push(arg); - return args; -} - -/** - * Resolves what to launch to uninstall an install-mode game: FS search in the install dir first - * (deterministic, no parsing/encoding issues — we build the silent args), then a registry fallback for a - * rare nonstandard NSIS uninstaller name. Returns null → the caller does a plain directory removal. - */ -async function resolveUninstaller(install: ResolvedInstallerRun): Promise { - // Linux (Р7f): uninstall removes the WHOLE per-game Wine prefix (see GameProcessLauncher.uninstallDir), - // so running the game's own in-prefix uninstaller first is pointless (its registry/shortcut cleanup - // lives in the prefix we're about to delete). Skip it — win32 still runs it (no prefix; it must clean - // the shared system before the install dir is removed). - if (process.platform !== 'win32') return null; - - // Step 1: FS search in the install dir, with self-built silent flags. - const found = await findUninstallerInDir(install.dir, install.type); - if (found !== null) { - return { - file: found, - args: silentUninstallArgs(install.type), - cwd: install.dir, - runAsAdmin: install.runAsAdmin, - }; - } - if (install.type === 'custom') return null; // no FS match and no silent convention → plain remove - - // Step 2: registry fallback (rare — nonstandard NSIS uninstaller name). win32-only (reached only on - // win32; the non-win32 early return above skips the whole uninstaller path). - const entry = await findUninstallEntry(install.dir); - if (entry === null) return null; - const command = entry.quietUninstallString ?? entry.uninstallString; - if (command === undefined) return null; - const tokens = parseCommandLine(command); - const file = tokens[0]; - if (file === undefined) return null; - const rest = tokens.slice(1); - // QuietUninstallString is already silent; a plain UninstallString needs the family's silent flag. - const args = - entry.quietUninstallString !== undefined ? rest : [...rest, ...silentUninstallArgs(install.type)]; - return { - file, - args, - cwd: install.dir, - runAsAdmin: entry.fromHKLM || install.runAsAdmin, - }; -} - -/** - * Removes a directory with a few backed-off retries: the forked Inno uninstaller may still hold - * files for a moment after waitForExit. Checks `signal.aborted` between attempts (fse.remove itself is - * not interruptible). Throws the last error if every attempt fails. - */ -async function removeWithRetry(dir: string, signal?: AbortSignal): Promise { - let lastError: unknown; - for (let attempt = 1; attempt <= REMOVE_RETRY_ATTEMPTS; attempt += 1) { - if (signal?.aborted === true) return; - try { - await fse.remove(dir); - return; - } catch (cause) { - lastError = cause; - if (attempt < REMOVE_RETRY_ATTEMPTS) await delay(REMOVE_RETRY_BASE_MS * attempt); - } - } - throw lastError instanceof Error ? lastError : new Error(String(lastError)); -} - -/** - * The root-relative asset paths one manifest references (art + music), as written in game.json. Used to - * tell the PC library which files in its `assets/` are still in use — see PcLibraryStore.gcOrphans. - */ -function referencedAssets(manifest: ResolvedManifest): readonly string[] { - const { heroImage, gridImage, backgroundMusic } = manifest.raw; - const heroes = heroImage === undefined ? [] : typeof heroImage === 'string' ? [heroImage] : heroImage; - return [...heroes, ...(gridImage !== undefined ? [gridImage] : []), ...(backgroundMusic !== undefined ? [backgroundMusic] : [])]; -} - -export class GameController { - // A card carries one OR MANY games (game.json is an object or an array). `cardGames` holds every game - // resolved from the inserted card; `pcGames` the local ones from the PC library, which are available - // whether or not a card is in. `games` (below) is their union — the list every consumer reads — and - // `selectedId` names the one currently selected. `current()` derives the single "active" manifest that - // all the existing launch/kill/uninstall/save-sync/stats code reads, so those bodies stay untouched. - // Empty (`cardGames=[]`) whenever no card / rejected. - private cardGames: ResolvedManifest[] = []; - private pcGames: ResolvedManifest[] = []; - // The SELECTION is by id, not by index: with two sources the list is rebuilt from both (a card comes and - // goes underneath it), and an index would silently point at a different game every time it changed. - private selectedId: string | null = null; - // True while a game is launching/running: main is "locked" on that game — a game switch is refused and - // the carousel cannot enter another game's detail as actionable (its guard is `kind==='ready'`). - private locked = false; - private cardPresent = false; - // True for the whole body of loadCard. A game on the card being read right now is about to become - // available, so save-from-history must refuse: an invoke that slipped in after the sync step and before - // the manifests landed would be stored as "pending" and sit there until the NEXT insertion. - private cardLoadInFlight = false; - // True while a collision answer is being carried out. The merge writes the card and reloads it, which - // re-enters loadCard — and the detection at the end of it would ask the very question being answered. - private collisionInFlight = false; - // The id of the game with a Steam download/removal in flight, or null. Steam operations are the one - // kind of activity that leaves the state `ready`, so this is what stops a SECOND game from being - // launched or installed underneath them (see onLaunchRequested). - private steamBusyId: string | null = null; - // Mirror of AppSettings.keepOpenWithoutCard (seeded at startup, toggled live from the settings - // window): when true the launcher stays on screen with no card in instead of hiding to the tray. - // Initialized to the SCHEMA's default so the sliver between constructing this controller and the seed - // behaves like the setting it mirrors — keep the two in step if that default ever changes. - private keepOpenWithoutCard = true; - private launchInFlight = false; - // A manifest reload from the Customize screen is in flight. Unlike launchInFlight it does NOT - // gate on state kind (the reload runs from `ready`), so onLaunchRequested/onUninstallRequested check - // it explicitly: during the reload's awaits (readManifest + hero/audio on a slow SD — hundreds of ms) - // the state stays `ready`, and a gamepad Play would otherwise start a game mid-reload (enterReady over - // launching). Only the reload path is raced like this — an ordinary insert never is. - private reloadInFlight = false; - private abort: AbortController | null = null; - // A card swapped in WHILE a launch/install was in flight: DriveWatcher can swap without an - // empty tick, so we stash the new root, abort the in-flight sequence, and replay onInsert from its - // finally (after launchInFlight clears) — otherwise the aborted sequence could set state over the new card. - private pendingRoot: string | null = null; - // Image names (lower-case *.exe basenames) of the currently-running game, captured on entry to - // `running` so a Play press in that state can find and raise the game's window (return-to-game). Null - // whenever no game is running; reset in the launch sequence's finally. Matched by image name rather than - // pid so it covers all backends uniformly, incl. elevated games a non-elevated tasklist can't see. - private runningImageNames: readonly string[] | null = null; - // The owned GameProcess of the currently-running game, kept so a force-close can terminate it directly: - // the elevated HANDLE (invisible to taskkill) or the normal pid tree. A REFERENCE to the same object - // disposed in the launch sequence's finally (the single owner) — set alongside runningImageNames on - // entry to `running` (normal/elevated + watched branches; null for steam, which owns no process), - // cleared in that same finally. Never disposed from here. - private runningProc: GameProcess | null = null; - // A force-close (onKillRequested) is underway. Local try/finally flag (mirrors reloadInFlight, NOT the - // launch sequence's finally — a kill has its own short-lived lifecycle) so a double Yes / repeat is a no-op. - private killInFlight = false; - // The installing/launching state to restore once winetricks provisioning ends (Р7g). Null when not - // provisioning. The "Configuring Proton" screen + its rotating funny suffix are the renderer's job (Р7j). - private protonConfigPriorState: AppState | null = null; - // The inserted card's background music, sent on its own channel (not on every AppState) — it is the - // card's only audio contribution. Null when there is no card, or when "only global ambience" mutes it. - private currentCardMusic: string | null = null; - // The bundled UI sound set chosen in Settings — the only source of UI sounds there is, on every screen. - // Read once at init (warmSfxSet); null until then. - private sfxSet: SfxSet | null = null; - // The default ambience data URL, delivered on its own channel (independent of the card's music). The - // renderer prioritizes a card's own music over this and crossfades between them. Null = no ambience. - private currentAmbient: string | null = null; - // Hero images for the current card, sent on their own channel (not on every AppState) — see HeroAssets. - private currentHero: HeroAssets | null = null; - // The light carousel list ({id,title,active}) — the inserted card's games plus the play history — in - // display order, pushed on every change (insert / removal / finished session / eviction). Null when - // there is nothing at all to show. The artwork travels separately, per card, on library:grid-request. - private currentLibrary: GameLibrary | null = null; - // What is on screen (see BrowseInfo): the truth for the title/stats/hero/music, INDEPENDENT of AppState - // — which describes one game's process and cannot represent "a history game while no card is in", nor - // "browsing game B while game A installs". Null only when there is neither a card nor any history. - private currentBrowse: BrowseInfo | null = null; - // The renderer parked the cursor on one of the launcher's own cards (browse:game with null). While it - // holds, main NEVER moves the cursor on its own — a card inserted, a session finished, a library - // reloaded: the row stays where the user left it (see browseToUnlessPinned). Only the renderer clears - // it, by browsing a game again. Not "main knowing about the UI": currentBrowse is the view model - // already, and this flag is what tells "the cursor was set on purpose" from "there is nothing to show". - private browsePinned = false; - // Monotonic ticket for browse-asset reads: only the newest may push (see pushBrowseAssets). - private browseAssetsSeq = 0; - // Pending read of the browsed game's hero/music (see BROWSE_ASSETS_DEBOUNCE_MS). - private browseAssetsTimer: ReturnType | null = null; - // The reconciled Stats per game id, captured in loadCard so onSelectRequested can rebuild the selected - // game's GameInfo without re-reading stats (buildGameInfo still re-reads the .acf for a steam game). - private statsById = new Map(); - // Reads card assets (hero/audio/wallpaper) into data URLs; owns the bundled-wallpaper cache and reads - // the live audio settings via DI. - private readonly assets = new AssetReader({ - getSoundSet: async () => (await this.deps.settings.read()).soundSet, - getAmbientTrack: async () => (await this.deps.settings.read()).ambientTrack, - getOnlyGlobalAmbient: async () => (await this.deps.settings.read()).onlyGlobalAmbient, - }); - // Steam-mode background re-detect poller (timer + tick + optimistic uninstall request), extracted from - // this controller. Reaches back only through the narrow accessor seam below. - private readonly steamWatch = new SteamInstallWatch({ - getManifest: () => this.current(), - isLaunchInFlight: () => this.launchInFlight, - getState: () => this.deps.state.get(), - isSourceAvailable: () => this.currentSourceAvailable(), - enterReady: (info) => this.enterReady(info), - onInstallCompleted: (game) => - this.deps.notifications.notify({ - kind: 'game-installed', - gameId: game.id, - gameTitle: game.title, - }), - onUninstallCompleted: (game) => - this.deps.notifications.notify({ - kind: 'game-uninstalled', - gameId: game.id, - gameTitle: game.title, - }), - steamLocator: () => this.deps.platform.steamLocator, - }); - - constructor(private readonly deps: ControllerDeps) {} - - /** The current translator (a message is fixed at the language of the moment it is generated). */ - private get t(): Translator { - return this.deps.getTranslator(); - } - - /** The platform process monitor (win32 tasklist / linux /proc), threaded into the launcher + waits. */ - private get monitor(): ProcessMonitor { - return this.deps.platform.processMonitor; - } - - /** The platform game launcher (win32 spawn/ShellExecuteEx / linux umu-run/Proton). */ - private get launcher(): Platform['gameLauncher'] { - return this.deps.platform.gameLauncher; - } - - /** - * Linux prefix provisioning (winetricks) started/finished — the launcher's onProvisioning callback - * (Р7g). On start: stash the current installing/launching state and show the rotating "Configuring - * Proton" screen. On finish: stop the rotation and restore the stashed state (the launch/install - * sequence then continues from where it was). No-op on win32 (the launcher never fires this). - */ - private setProvisioning(active: boolean, game: GameInfo): void { - if (active) { - this.protonConfigPriorState = this.deps.state.get(); - this.deps.state.set({ kind: 'configuringProton', game }); - } else if (this.protonConfigPriorState !== null) { - this.deps.state.set(this.protonConfigPriorState); - this.protonConfigPriorState = null; - } - } - - /** True if any of the given image names is currently running (fresh snapshot; empty list → false). */ - private async anyTargetAlive(targets: readonly string[]): Promise { - if (targets.length === 0) return false; - const snapshot = await this.monitor.snapshot(); - return targets.some((name) => snapshot.hasImageName(name)); - } - - /** - * Every game that can be acted on right now: the inserted card's, then the PC library's. A local game - * whose id is ALSO on the card is dropped here — the card wins (it is the removable, user-visible - * medium, and `id` keys every piece of PC state, so the two cannot coexist). Recomputed on read: both - * lists are tiny, and a cached union would be one more thing to invalidate on every insert/removal. - */ - private get games(): readonly ResolvedManifest[] { - const cardIds = new Set(this.cardGames.map((manifest) => manifest.raw.id)); - return [...this.cardGames, ...this.pcGames.filter((m) => !cardIds.has(m.raw.id))]; - } - - /** Tells the user what the sync step did with their pending edits — one entry per game. */ - private notifyHistorySync(sync: HistorySyncResult): void { - for (const gameTitle of sync.applied) { - this.deps.notifications.notify({ kind: 'history-config-applied', gameTitle }); - } - for (const gameTitle of sync.discarded) { - this.deps.notifications.notify({ kind: 'history-config-discarded', gameTitle }); - } - } - - /** - * The single "active" manifest — the selected game — that every existing consumer reads - * (launch/kill/uninstall/save-sync/stats). Read-only: the games live in `cardGames`/`pcGames`, the - * choice in `selectedId`. Falls back to the first available game when the selection is gone (the card - * carrying it was pulled), and is null only when there is nothing at all. - */ - private current(): ResolvedManifest | null { - const games = this.games; - return games.find((manifest) => manifest.raw.id === this.selectedId) ?? games[0] ?? null; - } - - /** - * The game the CAROUSEL shows first, as a manifest — where a cursor with no opinion of its own belongs. - * `games` is in source order (the card's manifest as authored, then the library's `game.json`), while - * the row is sorted by how recently each game was touched: "the first game" means two different things, - * and the one the user can point at is the row's. Falls back to source order before the row exists, and - * to `current()` for a head that has no manifest (a history entry — only reachable with no game at all, - * since refreshLibrary puts every available game ahead of the history). - */ - private firstCarouselGame(library: GameLibrary | null = this.currentLibrary): ResolvedManifest | null { - const headId = library?.games[0]?.id; - if (headId === undefined) return this.current(); - return this.games.find((manifest) => manifest.raw.id === headId) ?? this.current(); - } - - /** - * Whether this game's source is available right now. A card game needs its card in; a local game is on - * this machine's disk, so it always is. Everything that used to read `cardPresent` for a SPECIFIC - * manifest goes through here — with two sources, "no card" no longer means "this game is gone". - */ - private sourceAvailable(manifest: ResolvedManifest): boolean { - return manifest.source === 'pc' || this.cardPresent; - } - - /** sourceAvailable for the selected game; false when there is no game at all (nothing to show). */ - private currentSourceAvailable(): boolean { - const manifest = this.current(); - return manifest !== null && this.sourceAvailable(manifest); - } - - /** - * sourceAvailable for a game named by id — for the callers that hold a GameInfo, not a manifest. An - * unknown id is `false` on purpose: `games` hides a local game shadowed by the card (see the getter), - * and there is nothing to poll about a game that cannot be acted on right now. - */ - private sourceAvailableFor(id: string): boolean { - const manifest = this.games.find((m) => m.raw.id === id); - return manifest !== undefined && this.sourceAvailable(manifest); - } - - /** - * The "the card went away while we were busy" landing, shared by the sequences that target the PC and - * therefore finish anyway (uninstall, prefix cleanup, an abandoned watched launch). With a local game - * left it stays on screen with that game selected; with nothing left it is the previous behaviour - * exactly — idle and out of the way. - */ - private cardGoneAfterSequence(): void { - this.clearCard(); - const remaining = this.firstCarouselGame(); - if (remaining !== null) { - void this.enterReadyForLocal(remaining); - return; - } - this.deps.state.set({ kind: 'idle' }); - this.hideToTrayOrKeepEmpty(); - } - - /** Clears all card-scoped state (games, selection, lock, audio/hero/library channels). The caller sets - * the follow-up AppState (idle/error) and window visibility, exactly as before. */ - private clearCard(): void { - // Only a CARD game's Steam operation stops being ours to guard when the card goes: a local game's - // download keeps running and must keep refusing a second launch/install on top of it. Read before the - // list is emptied — afterwards there is no way to tell whose id it was. - if (this.steamBusyId !== null && this.cardGames.some((m) => m.raw.id === this.steamBusyId)) { - this.steamBusyId = null; - } - this.cardGames = []; - // The selection falls back to whatever is still there (a local game), or to nothing — see current(). - this.selectedId = null; - this.locked = false; - this.forgetCardStats(); - // Music is card-only, so there is none on the empty screen. UI sounds are unaffected: they come from - // the bundled set on its own channel, which no card ever touched. - this.setCardMusic(null); - this.setHero(null); - // NOT setLibrary(null): the history outlives the card, and this runs from FIVE places (a rejected - // card, onRemove, and a card pulled mid-install/launch/uninstall). Blanking the list in any of them - // would collapse a populated carousel into the empty screen. The list is rebuilt with no active - // games, and the browse cursor moves onto whatever is left (a history entry, or nothing). - this.refreshLibrary(); - void this.reseedBrowse(); - } - - /** - * Drops the CARD games' cached stats, keeping the local library's. The cache is per-id and shared by - * both sources, so a blanket clear on card removal would strip the local games of their reconciled - * values (they'd fall back to a disk read — correct, but needlessly). - */ - private forgetCardStats(): void { - const localIds = new Set(this.pcGames.map((manifest) => manifest.raw.id)); - for (const id of [...this.statsById.keys()]) { - if (!localIds.has(id)) this.statsById.delete(id); - } - } - - /** - * Hides the launcher to the tray (the background-app default), OR — in SteamOS Game Mode, where there is - * no tray to hide into — keeps the empty "insert a card" screen up instead (Р8). Used at every "no card" - * exit point. On Windows/desktop this is a plain hide (unchanged behaviour). - */ - private hideToTrayOrKeepEmpty(): void { - if (this.deps.isGamescope) this.deps.window.showAndFocus(); - else this.deps.window.hide(); - } - - /** Subscriptions to drive-watcher, state replication to the window, IPC handlers. */ - init(): void { - const { state, window, watcher } = this.deps; - - state.subscribe((next) => { - const browserWindow = window.browserWindow; - if (browserWindow !== null && !browserWindow.isDestroyed()) { - browserWindow.webContents.send(IPC.stateUpdate, next); - } - }); - - watcher.onInsert((root) => void this.onInsert(root)); - watcher.onRemove(() => this.onRemove()); - watcher.onError((error) => log.error('[drive-watcher]', error)); - - ipcMain.handle(IPC.stateRequest, (): AppState => state.get()); - // Static for the process lifetime — seeds the renderer's Game Mode UI (e.g. "Close Playhook"). - ipcMain.handle(IPC.gameModeRequest, (): boolean => this.deps.isGamescope); - ipcMain.handle(IPC.cardMusicRequest, (): string | null => this.currentCardMusic); - ipcMain.handle(IPC.ambientRequest, (): string | null => this.currentAmbient); - ipcMain.handle(IPC.heroRequest, (): HeroAssets | null => this.currentHero); - ipcMain.handle(IPC.libraryRequest, (): GameLibrary | null => this.currentLibrary); - ipcMain.handle(IPC.browseRequest, (): BrowseInfo | null => this.currentBrowse); - ipcMain.handle(IPC.sfxSetRequest, (): SfxSet | null => this.sfxSet); - // The clipboard as text, for the on-screen keyboard's Paste. Trimmed of nothing here — what the - // field will accept is the keyboard's own rule (osk-text.ts sanitize), and it differs per field. - ipcMain.handle(IPC.clipboardRead, (): string => clipboard.readText()); - // The carousel asks for one card's artwork at a time, only for what is on screen, and caches it by id - // — that is what keeps the list channel light enough to re-push on every change (Р5). - ipcMain.handle(IPC.libraryGridRequest, (_event, id: unknown): Promise => { - if (typeof id !== 'string') return Promise.resolve(null); - return this.deps.library.readGridThumb(id); - }); - ipcMain.on( - IPC.libraryBrowse, - (_event, id: unknown, immediate: unknown) => void this.onBrowseRequested(id, immediate), - ); - ipcMain.on(IPC.libraryForget, (_event, id: unknown) => void this.onForgetRequested(id)); - ipcMain.handle( - IPC.gameCollisionResolve, - (_event, answer: GameCollisionAnswer): Promise => - this.resolveCollision(answer), - ); - ipcMain.handle(IPC.wallpaperRequest, (): Promise => this.assets.readWallpaperDataUrl()); - ipcMain.handle( - IPC.startupSoundRequest, - (): Promise => this.assets.readStartupSoundDataUrl(), - ); - ipcMain.on(IPC.actionLaunch, () => void this.onLaunchRequested()); - ipcMain.on(IPC.actionUninstall, () => void this.onUninstallRequested()); - // Game Mode: hiding is meaningless (no tray, and on Linux no summon hotkey) — ignore the Hide button - // so the only window can't vanish with no way back. Desktop/Windows hide to the tray as before. - ipcMain.on(IPC.actionHide, () => { - if (!this.deps.isGamescope) this.deps.window.hide(); - }); - ipcMain.on(IPC.actionOpenSteamDownloads, () => void this.onOpenSteamDownloads()); - ipcMain.on(IPC.actionKill, () => void this.onKillRequested()); - ipcMain.on(IPC.actionSelect, (_event, id: unknown) => void this.onSelectRequested(id)); - - void this.warmSfxSet(); - void this.warmAmbient(); - // Chained, not fired in parallel: both seed the carousel and the browse cursor, and warmLibrary's - // "no card → show the history" would otherwise race the local games onto the same screen. - void this.warmLibrary() - .then(() => this.loadPcLibrary()) - .catch((cause: unknown) => log.warn('[pc-library] initial load failed:', describe(cause))); - } - - /** Reads the bundled UI sound set once and delivers it to the window. It is screen-independent — the - * same set clicks on the empty screen, the carousel and a game's detail screen. */ - private async warmSfxSet(): Promise { - this.sfxSet = await this.assets.readSfxSet(); - this.pushSfxSet(); - } - - /** Seeds the history carousel at startup: with no card inserted, the list and the browse cursor come - * purely from the library (this is what makes "pull the card, keep browsing" work). */ - private async warmLibrary(): Promise { - this.refreshLibrary(); - if (!this.cardPresent) await this.reseedBrowse(); - } - - /** Reads the default ambience from settings once at startup and pushes it to the game window (the - * renderer plays it only while no card music is present — it decides the priority + crossfade). */ - private async warmAmbient(): Promise { - const track = await this.deps.settings.read().then((s) => s.ambientTrack); - this.currentAmbient = await this.assets.readAmbientDataUrl(track); - this.pushAmbient(this.currentAmbient); - } - - /** Sends a transient error to the renderer to surface in the error popup. */ - private sendError(message: string): void { - const browserWindow = this.deps.window.browserWindow; - if (browserWindow !== null && !browserWindow.isDestroyed()) { - browserWindow.webContents.send(IPC.errorShow, message); - } - } - - /** Stops the process waits and the watcher (on application exit). */ - shutdown(): void { - if (this.browseAssetsTimer !== null) clearTimeout(this.browseAssetsTimer); - this.abort?.abort(); - this.steamWatch.stop(); - this.steamWatch.clearUninstallRequest(); - this.deps.watcher.stop(); - } - - // ── Ready transition + Steam re-detect poller ────────────────────────────── - - /** - * The single entry point for the `ready` state. Besides setting the state, it manages the Steam - * background re-detect poller: started when the current game is a Steam game still showing "Install" - * (and the card is present), stopped otherwise. ALL ready transitions go through here so the poller's - * lifecycle is governed in exactly one place (StateManager is not a controller hook). - */ - private enterReady(info: GameInfo): void { - // Remember WHICH game Steam is busy with. A Steam download/removal keeps the state `ready` (it is - // non-blocking by design — it can run for hours), so the usual `kind !== 'ready'` guard does not cover - // it; and switching to another game rebuilds AppState around THAT game, which would otherwise erase - // the only trace of the operation. Cleared by the same game reporting itself idle again. - if (info.steamInstalling === true || info.steamUninstalling === true) this.steamBusyId = info.id; - else if (this.steamBusyId === info.id) this.steamBusyId = null; - this.deps.state.set({ kind: 'ready', game: info }); - // AppState and BrowseInfo carry the SAME GameInfo whenever they are about the same game — and the - // detail screen reads the BROWSE one (`browse.game.requiresInstall` decides whether Play is there, - // `canUninstall` whether the menu offers Uninstall). Pushing only the state left the screen showing - // "Install" after an install had finished, until the user stepped out to the carousel and back in, - // which is what re-asked for the browse info. - // - // Only the INFO is re-pushed, never the assets: the hero images and the music have not changed, and - // re-reading them on every state change would cost megabytes per transition. - const browse = this.currentBrowse; - if (browse !== null && browse.id === info.id && browse.active) { - this.pushBrowse({ ...browse, game: info }); - } - // Poll for ANY steam game whose source is available: it catches install completion (Install→Play), - // uninstall completion (Play→Install) — incl. an uninstall the user triggers in Steam directly — and - // download progress. A LOCAL steam game's source is always available, so this poll is no longer bounded - // by how long a card stays in: the launcher sitting on such a game polls it for as long as it is shown. - // The .acf read is cheap enough for that to be an acceptable price (see the plan, §9.3). - if (info.installVia === 'steam' && this.sourceAvailableFor(info.id)) { - this.steamWatch.start(); - } else { - this.steamWatch.stop(); - } - } - - // ── Reaction to card insertion ─────────────────────────────────────────── - - private async onInsert(root: string): Promise { - // A card was swapped in mid-flight (no empty tick). Don't process it now — that would race the - // in-flight sequence. Stash it, abort the current flow; its finally replays this once it unwinds. - if (this.launchInFlight) { - log.info(`[insert] card swapped during launch/install — deferring root="${root}"`); - this.pendingRoot = root; - this.abort?.abort(); - return; - } - await this.loadCard(root, { focus: true }); - } - - /** - * Reads a card at `root` and drives the launcher to `ready` for the selected game (single- or - * multi-game card), or to `error` — the shared body of an ordinary insert AND a Customize save. - * A multi-game card exposes its other games through the history carousel (the light game list). `focus` - * controls whether the launcher pops to the front: true for a real insertion (unchanged behaviour), false - * for a reload so a Save from the Customize screen doesn't raise the window over what is on top. Returns the - * readManifests verdict so the caller (reloadManifest) can report it; onInsert ignores it. - */ - private async loadCard( - root: string, - opts: { readonly focus: boolean }, - ): Promise<{ ok: true } | { ok: false; message: string }> { - this.cardLoadInFlight = true; - try { - return await this.loadCardBody(root, opts); - } finally { - this.cardLoadInFlight = false; - } - } - - private async loadCardBody( - root: string, - opts: { readonly focus: boolean }, - ): Promise<{ ok: true } | { ok: false; message: string }> { - this.cardPresent = true; - log.info(`[insert] card detected at root="${root}"`); - // A card that was pulled mid-write leaves the temp file of that write behind, under a name unique to - // it that nothing will ever reuse. The card root is where the user SEES it, so it is swept here — in - // the background, because an insertion waits for nothing that is merely tidy. - void sweepAtomicTemps(root).catch((cause: unknown) => - log.warn('[insert] sweeping the card temp files failed:', describe(cause)), - ); - // Documents is resolved via the system Known Folder API (the same one the game uses), - // so %DOCUMENTS% in the manifest maps to the real save folder regardless of UI - // language or OneDrive redirection. Safe to read here — app is ready by now. - const env: ManifestEnv = { documents: app.getPath('documents'), t: this.t }; - // BEFORE the manifests are read: edits the user made from the history are written onto the card here, - // so everything downstream — validation, the resolved manifests, the history copy — sees one already - // reconciled file. It also runs on a plain reload (a Save from Customize), where it is a no-op: an - // available game cannot have pending edits, since save-from-history refuses one. - let sync = await syncHistoryConfig(root, { library: this.deps.library, t: this.t }); - let result = await readManifests(root, env, this.deps.platform.resolveInstallDir); - if (!result.ok && sync.textBefore !== null) { - // We rewrote the file and the card stopped reading. Put the author's own text back and try again - // rather than leave a card that the launcher itself bricked. `sync` becomes the undone version of - // itself, so the commit below leaves the edits pending and the user is told they were NOT applied. - const reverted = await rollbackHistorySync(root, sync); - if (reverted !== null) { - sync = reverted; - result = await readManifests(root, env, this.deps.platform.resolveInstallDir); - } - } - // Only now, and on EVERY path: the card has had its final say, so the history may record it and the - // user may be told. Both used to sit inside the sync itself, which meant a rollback erased the edits - // it claims to preserve and said nothing about it. - await commitHistorySync(sync, this.deps.library); - this.notifyHistorySync(sync); - if (!result.ok) { - // No valid game determined → keep the window hidden (the reason is in the log). We still set - // the error state so a manually-summoned window can show it, but we never auto-surface it. - log.warn(`[insert] manifest rejected: ${result.message}`); - this.clearCard(); - this.deps.state.set({ kind: 'error', message: result.message }); - // Desktop/Windows: keep the window hidden (background app — the error is in the log and only shows - // if the user summons the window). Game Mode: there is no tray to hide into, so surface the manifest - // error on screen instead of hiding (Р8, point 1). - if (this.deps.isGamescope) this.deps.window.showAndFocus(); - else this.deps.window.hide(); - return { ok: false, message: result.message }; - } - const manifests = result.manifests; - // Keep the selection on a reload if it still points at one of this card's games; a real insert starts - // at the first (an inserted card is what you are meant to be looking at, even mid-browse). - const keepSelection = - !opts.focus && manifests.some((manifest) => manifest.raw.id === this.selectedId); - this.cardGames = manifests; - if (!keepSelection) this.selectedId = manifests[0]?.raw.id ?? null; - this.warnShadowedLocalGames(); - this.locked = false; - log.info(`[insert] manifest ok games=${manifests.length} ids=[${manifests.map((m) => m.raw.id).join(',')}] root="${root}"`); - - // Read the card's traveling stats ONCE to detect the pre-multi-game bare-Stats format. Attribution of - // that legacy value is decided HERE (only loadCard knows the game count): a single-game card owns it - // unambiguously; on a multi-game card the owner is unknown → ignore it (the per-id PC mirror is intact). - const cardStatsRead = await this.deps.stats.readCardStatsMap(root); - let legacyForSingle: Stats | null = null; - if (cardStatsRead.kind === 'legacy') { - if (manifests.length === 1) legacyForSingle = cardStatsRead.stats; - else log.warn(`[stats] legacy bare card stats on a ${manifests.length}-game card — owner ambiguous, ignoring (PC mirror per-id is intact)`); - } - - // Reconcile + copy card stats for EVERY game FIRST (so each PC mirror holds the merged value before - // anything writes the card), caching the merged Stats per id so onSelectRequested can rebuild the - // switched-to game's GameInfo without re-reading. Order matters vs the flush below. - this.forgetCardStats(); - for (const manifest of manifests) { - const stats = await this.deps.stats.reconcileWithCard(manifest.raw.id, root, legacyForSingle); - await this.deps.stats.copyToCard(root, manifest.raw.id, stats); - this.statsById.set(manifest.raw.id, stats); - } - - // If a card was yanked mid-game last time — top up the deferred PC→SD (saves snapshot) for ANY game - // that has a pending flush, not just the selected one (else game B's flush hangs until B is selected on - // some future insert). Runs AFTER all reconciles so each flush's stats copy uses the merged value. - for (const manifest of manifests) { - try { - await this.flushPendingIfAny(manifest); - } catch (cause) { - log.warn(`[pending-flush] failed on insert for id=${manifest.raw.id}:`, describe(cause)); - } - } - - // The LIGHT carousel list — this card's games (active) followed by the play history. No heavy assets: - // the selected game's hero/audio are built on demand below, the cards' art on request. Built here but - // DELIVERED at the end, after the browse cursor: a row that arrives first is reshuffled twice on - // screen — once into the new order around the game the window is still showing, and again when the - // cursor moves to the card's own game. The renderer holds an early cursor for a row it does not have - // yet (see the carousel's pendingFocusId), so the late delivery costs nothing and the cards travel once. - const library = this.buildLibrary(); - - // A real insert starts on the card's first game AS THE ROW ORDERS THEM (by how recently each was - // played), not as game.json lists them — the cursor has to land where the user can see it. A reload - // keeps whatever was selected (keepSelection above). - if (!keepSelection) this.selectedId = this.firstCarouselGame(library)?.raw.id ?? this.selectedId; - - // Always enter `ready` for the selected game (single- or multi-game card). Its hero/audio go out on the - // existing per-game channels; the carousel handles switching between the card's games. - const selected = manifests.find((manifest) => manifest.raw.id === this.selectedId) ?? manifests[0]; - if (selected !== undefined) { - const stats = this.statsById.get(selected.raw.id) ?? (await this.deps.stats.read(selected.raw.id)); - this.setCardMusic(await this.cardMusicFor(selected)); - this.setHero(await this.assets.readHeroAssets(selected)); - this.enterReady(await this.buildGameInfo(selected, stats)); - // The card's own game is what you look at on insert (the single-game case is then exactly today's - // screen: browse.id === AppState.game.id). - await this.browseToUnlessPinned(selected.raw.id); - } - // …and only now the row, so it lands with the cursor already on the card it is about to put first. - this.setLibrary(library); - if (opts.focus) this.deps.window.showAndFocus(); - - // Copy this card's art/audio into the history IN THE BACKGROUND: a card is slow media and the window - // is already on screen. One sequential task for the whole card (index.json is a single file — see - // LibraryStore.saveFromCard), then a list refresh so the freshly-copied games get their artwork. - void this.deps.library - .saveFromCard(manifests, sync.slots) - .then(() => this.refreshLibrary()) - .catch((cause: unknown) => log.warn('[library] copying the card assets failed:', describe(cause))); - // Last, and only now: a game that lives on this card AND on this PC needs an answer from the user, - // and asking for one is not allowed to hold the card up (see askAboutCollisions). - this.askAboutCollisions(root); - return { ok: true }; - } - - /** - * Reads the PC library and folds it into the launcher, the way loadCard does for a card — minus the two - * things that belong to removable media: the card's traveling stats (a local game's mirror is the only - * copy there is) and, deliberately, the pending flush. - * - * NOT flushing is load-bearing, not an omission: a local game HAS a `saveOnCardPath` (its backup in the - * library), so a symmetrical copy of loadCard would pour a snapshot meant for the real card into that - * backup and then clear the queue — silently losing the progress the next card insertion was supposed - * to receive. Pending snapshots are for cards only; see performSyncOut. - */ - private async loadPcLibrary(): Promise { - const env: ManifestEnv = { documents: app.getPath('documents'), t: this.t }; - const read = await this.deps.pcLibrary.read(env, this.deps.platform.resolveInstallDir); - this.pcGames = [...read.manifests]; - log.info(`[pc-library] ${read.manifests.length} local game(s) ids=[${read.manifests.map((m) => m.raw.id).join(',')}]`); - this.warnShadowedLocalGames(); - // A local game that is gone takes its collision answer with it: there is nothing left to collide, - // and a draft recreated under the same id later deserves the question afresh. - void this.deps.library - .clearCollisionAnswers(read.manifests.map((manifest) => manifest.raw.id)) - .catch((cause: unknown) => - log.warn('[collision] clearing the answers of removed local games failed:', describe(cause)), - ); - for (const manifest of read.manifests) { - this.statsById.set(manifest.raw.id, await this.deps.stats.read(manifest.raw.id)); - } - this.refreshLibrary(); - // With no card in, the local games are what the launcher has to show: leave `idle` for the first of - // them instead of the empty screen. A card (or any activity) present → don't touch the state machine. - if (!this.cardPresent && this.deps.state.get().kind === 'idle' && !this.launchInFlight) { - // The row's first card, not the library file's first entry — see firstCarouselGame. refreshLibrary - // above has already built the row this reads, so the two can't disagree. - const selected = this.firstCarouselGame(); - if (selected !== null) { - this.selectedId = selected.raw.id; - this.setHero(await this.assets.readHeroAssets(selected)); - this.setCardMusic(await this.cardMusicFor(selected)); - this.enterReady(await this.buildGameInfo(selected, this.statsById.get(selected.raw.id) ?? (await this.deps.stats.read(selected.raw.id)))); - await this.browseToUnlessPinned(selected.raw.id); - } - } else if (!this.cardPresent) { - await this.reseedBrowse(); - } else if (this.browseIsStale()) { - // A card is in, so neither branch above applies — but a LOCAL game may have just been deleted from - // the manifest, and it may be the very one on screen. The cursor would go on claiming that game is - // available, and the Details menu is built from exactly that claim: Customize would still be - // offered for a game the file no longer has, while "Remove from history" — the item that game now - // needs — would stay missing until the user left the screen and came back. - await this.reseedBrowse(); - } - this.dropStateIfGameGone(); - - // Same background copy a card gets: the artwork already lives in the library root, but the history is - // what the carousel draws from, and it is also what keeps a local game's card on screen after the - // game itself is deleted from disk. A local game SHADOWED by the card is skipped — both would write - // the same history record, and re-inserting the card would then flip its artwork back and forth. - const visibleLocal = this.games.filter((manifest) => manifest.source === 'pc'); - void this.deps.library - .saveFromCard(visibleLocal) - .then(() => this.refreshLibrary()) - .catch((cause: unknown) => log.warn('[library] copying the local games\' assets failed:', describe(cause))); - - // Assets of games the user removed are only orphans when the manifest is TRUSTWORTHY — a library that - // merely failed to parse reports zero games, and sweeping on that would delete every picture in it. - if (read.intact) { - void this.deps.pcLibrary - .gcOrphans(read.manifests.flatMap(referencedAssets)) - .catch((cause: unknown) => log.warn('[pc-library] asset cleanup failed:', describe(cause))); - } - } - - /** - * Re-reads the PC library after the Customize screen saved it (the local twin of reloadManifest). Same - * busy guards: a reload during a launch/install would swap the manifest under the running sequence. - */ - async reloadPcLibrary(): Promise<{ ok: true } | { ok: false; message: string }> { - const kind = this.deps.state.get().kind; - if ((kind !== 'ready' && kind !== 'error' && kind !== 'idle') || this.launchInFlight) { - return { ok: false, message: this.t('errors.finishBeforeApply') }; - } - if (this.reloadInFlight) return { ok: false, message: this.t('errors.reloadInProgress') }; - this.reloadInFlight = true; - try { - await this.loadPcLibrary(); - // A local game may have just been edited or removed: rebuild what is on screen so the detail screen - // (title, "Game files not found", Play/Uninstall) matches the manifest that was saved. - const selected = this.current(); - if (selected !== null && !this.cardPresent && this.deps.state.get().kind === 'ready') { - const stats = this.statsById.get(selected.raw.id) ?? (await this.deps.stats.read(selected.raw.id)); - this.enterReady(await this.buildGameInfo(selected, stats)); - await this.browseToUnlessPinned(selected.raw.id); - } - await this.refreshBrowsedLocalGame(); - return { ok: true }; - } finally { - this.reloadInFlight = false; - } - } - - /** - * Re-sends the game ON SCREEN once the PC library has been re-read, when that game is a local one. - * - * Everything else in the reload speaks for the SELECTED game and only with no card in — both branches - * here and in loadPcLibrary are gated on `!cardPresent` — while the Customize screen edits the game the - * cursor is BROWSING. With a card inserted nothing above said a word about it, and even without one the - * two cursors are free to point at different games. A game's hero and its music are read once per - * browse, so a track added from that screen stayed unheard until the user flipped to another card and - * back, which is what re-read the manifest. - * - * Nothing to do while the cursor is parked on a launcher card (`currentBrowse` is null there): the - * launcher's own background and its ambience are not the library's to refresh. Immediate rather than - * debounced — a press of Save is a commitment, not a flip through the row. - */ - private async refreshBrowsedLocalGame(): Promise { - const id = this.currentBrowse?.id; - if (id === undefined) return; - // The EFFECTIVE manifest, not the library's own: a local game whose id is also on the card is served - // by the card (see `games`), and the card's reload speaks for that one. - const manifest = this.games.find((game) => game.raw.id === id); - if (manifest === undefined || manifest.source !== 'pc') return; - await this.browseTo(id, true); - } - - /** - * Enters `ready` on a local game with its assets, without touching the window's visibility: this runs - * when a card was pulled, and a launcher the user had hidden must stay hidden (the same intent - * onRemove's hide/show branch respects). - */ - private async enterReadyForLocal(manifest: ResolvedManifest): Promise { - this.selectedId = manifest.raw.id; - const stats = this.statsById.get(manifest.raw.id) ?? (await this.deps.stats.read(manifest.raw.id)); - this.setHero(await this.assets.readHeroAssets(manifest)); - this.setCardMusic(await this.cardMusicFor(manifest)); - this.enterReady(await this.buildGameInfo(manifest, stats)); - await this.browseToUnlessPinned(manifest.raw.id); - } - - /** - * Where one game's manifest lives, by id — the bridge the Customize screen crosses from "the game I am - * looking at" to "the file that describes it". Only games that can be acted on right now are answered - * for (`games`), which is the same rule the screen's menu item is gated on. - * - * The INDEX is deliberately not part of the answer: `games` is a filtered, reordered union of the card - * and the library (a shadowed local game is hidden, the carousel order is applied elsewhere), so a - * position here says nothing about the slot's position inside game.json. The screen finds its slot by - * `id` instead — see the plan, Р2. - */ - findGameSource(id: string): { readonly root: string; readonly source: ManifestSource } | null { - const manifest = this.games.find((game) => game.raw.id === id); - if (manifest === undefined) return null; - return { root: manifest.root, source: manifest.source }; - } - - /** The full RESOLVED manifest of one game, by id — what moveToCard needs to plan its asset/save copies - * (findGameSource only answers where the file lives, not what it resolves to). */ - findManifest(id: string): ResolvedManifest | null { - return this.games.find((game) => game.raw.id === id) ?? null; - } - - /** - * Whether ANY game is currently running/installing/uninstalling (incl. a Steam op in flight) — - * main's server-side mirror of the renderer's own isBusy (app.ts), which gates Delete on the Customize - * screen and — new here — Move to card (GameConfigService.moveToCard, Р2.5): a move started while the - * game is mid-launch would race the launcher's own manifest handling. - */ - isBusy(): boolean { - const kind = this.deps.state.get().kind; - // Stated as the SETTLED states rather than the busy ones (the shape UpdaterService.isBusy uses): the - // list of things a game can be in the middle of grew — launching, either save sync, the Proton prefix - // — and an allow-list that has to be extended for each of them is the reason those four were missing - // from the deny-list this replaced, JSDoc promise of mid-launch cover notwithstanding. - const settled = kind === 'idle' || kind === 'ready' || kind === 'error'; - return !settled || this.steamBusyId !== null; - } - - /** Logs the local games the inserted card currently shadows (same id — the card wins, see `games`). */ - /** - * The Customize backend, attached after construction — it needs this controller to exist first (see - * main.ts), and the collision answer needs it back. A narrow view of the service, not the service. - */ - private collisionResolver: CollisionResolver | null = null; - - setCollisionResolver(resolver: CollisionResolver): void { - this.collisionResolver = resolver; - } - - /** The PC library's own manifest for `id`, even while a card of the same id shadows it (Р10). */ - findPcManifest(id: string): ResolvedManifest | null { - return this.pcGames.find((manifest) => manifest.raw.id === id) ?? null; - } - - /** - * Asks — once per game — what should happen when the same id turns up on the card AND on this PC. - * - * Today the card simply wins, and the local game's name and artwork appear to vanish whenever it is - * inserted (`warnShadowedLocalGames` says so in the log and nowhere else). A silent reconciliation is - * not an option: the two manifests are independent, not a copy and its original, so there is no - * baseline and no "which is newer" to decide by — hence a question rather than a rule. - * - * Raised at the END of loadCard: the card is already on screen, so nothing about it is held up by the - * answer. One game at a time; an answer is remembered per id (collisionResolvedAt). - */ - private askAboutCollisions(root: string): void { - if (this.collisionInFlight) return; - const cardIds = new Set(this.cardGames.map((manifest) => manifest.raw.id)); - const local = this.pcGames.find( - (manifest) => - cardIds.has(manifest.raw.id) && - (this.deps.library.entry(manifest.raw.id)?.collisionResolvedAt ?? null) === null, - ); - if (local === undefined) return; - void this.pushCollision(root, local); - } - - private async pushCollision(root: string, local: ResolvedManifest): Promise { - // The signature is captured WITH the question: the answer may arrive after the card has been pulled, - // or swapped for a different one carrying the same id, and neither may be written to. - const signature = await this.collisionResolver?.signatureFor(root); - if (signature === undefined || signature === null) return; - const collision: GameCollision = { - id: local.raw.id, - title: local.raw.title, - root, - signature, - }; - const browserWindow = this.deps.window.browserWindow; - if (browserWindow !== null && !browserWindow.isDestroyed()) { - browserWindow.webContents.send(IPC.gameCollision, collision); - } - } - - /** - * The answer. "Merge" puts the local game's look on the card and — for a DRAFT, which has nothing else - * to offer — removes it from the PC library, so the collision is gone for good. A fully configured - * local game stays: it has its own launch and its own saves, and after the merge the flip between the - * two sources is invisible anyway. - * - * The answer is remembered only once it has actually happened: a merge that failed leaves nothing - * written and no decision recorded, so the question honestly comes back. - */ - private async resolveCollision(answer: GameCollisionAnswer): Promise { - // The only renderer argument on this channel that used to be taken on trust. An id nothing knows - // about would still be recorded as "answered", which writes a phantom entry the carousel then shows. - if (!isSafeGameId(answer.id) || this.findPcManifest(answer.id) === null) { - return { saved: false, message: this.t('errors.configInvalid') }; - } - if (answer.choice === 'ignore') { - await this.deps.library.markCollisionResolved(answer.id); - return { saved: true, applied: 'deferred' }; - } - const resolver = this.collisionResolver; - if (resolver === null) return { saved: false, message: this.t('errors.configInvalid') }; - const local = this.findPcManifest(answer.id); - this.collisionInFlight = true; - try { - const result = await resolver.mergeCollision(answer); - if (!result.saved) return result; - if (localGameGoesAfterMerge(result, local)) { - const removed = await resolver.removeLocalGame(answer.id); - if (!removed.saved) { - log.warn(`[collision] the card took id=${answer.id}, but the local draft could not be removed: ${removed.message}`); - } - } - await this.deps.library.markCollisionResolved(answer.id); - return result; - } finally { - this.collisionInFlight = false; - } - } - - private warnShadowedLocalGames(): void { - const cardIds = new Set(this.cardGames.map((manifest) => manifest.raw.id)); - for (const manifest of this.pcGames) { - if (cardIds.has(manifest.raw.id)) { - log.warn(`[pc-library] local game id=${manifest.raw.id} is hidden while a card carries the same id`); - } - } - } - - /** - * Applies an edited game.json to the ACTIVE card without restarting the app (the Customize screen). - * Re-reads the manifest through the same loadCard path an insert uses (readManifest → stats reconcile - * → audio/hero → buildGameInfo → enterReady | error), so nothing is duplicated and the steam poller's - * stale-guard still holds. Focus is NOT taken (opts.focus=false) — the launcher is already in front. - * - * Two guards: (1) on ENTRY — refuse unless idle/ready/error and not launchInFlight (busy guard, like - * UpdaterService.install; also prevents killing an in-flight sequence, since onInsert would abort it); - * (2) reloadInFlight for the DURATION — checked by onLaunchRequested/onUninstallRequested so a gamepad - * Play/Uninstall can't slip in during the reload's awaits. - */ - async reloadManifest(root: string): Promise<{ ok: true } | { ok: false; message: string }> { - const kind = this.deps.state.get().kind; - if ((kind !== 'ready' && kind !== 'error' && kind !== 'idle') || this.launchInFlight) { - return { ok: false, message: this.t('errors.finishBeforeApply') }; - } - if (this.reloadInFlight) return { ok: false, message: this.t('errors.reloadInProgress') }; - this.reloadInFlight = true; - try { - return await this.loadCard(root, { focus: false }); - } finally { - this.reloadInFlight = false; - } - } - - private async flushPendingIfAny(manifest: ResolvedManifest): Promise { - // Enforced by the predicate rather than by "we only call this from loadCard": a local game HAS a - // saveOnCardPath (its own backup), so a future symmetrical call from the PC-library path would - // otherwise empty the queue into that backup and lose the progress meant for the card. - const cardPath = manifest.saveOnCardPath; - if (!acceptsPendingFlush(manifest) || cardPath === undefined) return; - const pending = await this.deps.store.getPending(manifest.raw.id); - if (pending === null) return; - // Direct, NOT change-based (deliberate — see the plan, part B): the snapshot exists precisely because - // the card was yanked mid-game and we are OBLIGED to top up the promised PC progress onto the card. - // LWW here would silently drop that flush if the card looked "unchanged"/newer, so keep it a plain - // snapshot→card replace. - await syncDir(pending.savesSnapshotDir, cardPath); - const stats = await this.deps.stats.read(manifest.raw.id); - await this.deps.stats.copyToCard(manifest.root, manifest.raw.id, stats); - await this.deps.store.clearPending(manifest.raw.id); - // The card now holds the flushed progress, so both sides are back in sync. Rebase the baseline from - // the real folders (each in its own mtime scale) so the next launch sees them as synced, not as a - // spurious card-side change that would trigger a needless card→PC. - await this.rebaseSyncStateAfterFlush(manifest); - } - - /** - * Resolves the manifest's DEFERRED pcSavePath (Р5/Э6) to this game's save location via the platform - * SavePathResolver, or null when there's nothing to sync (no pcSavePath declared, or a steam game with - * no compatdata yet). win32 keeps the exact env-based expansion the manifest used to do eagerly; linux - * maps inside the game's prefix. `containerExists` tells whether that prefix is actually there — see - * runSaveSync for why that matters. - */ - private async resolvePcSavePath(manifest: ResolvedManifest): Promise { - if (manifest.pcSavePath === undefined) return null; - return this.deps.platform.savePathResolver.resolvePcSavePath(manifest, manifest.pcSavePath); - } - - /** Records a fresh sync baseline from both real save folders (used after a direct pending-flush). */ - private async rebaseSyncStateAfterFlush(manifest: ResolvedManifest): Promise { - const cardPath = manifest.saveOnCardPath; - if (cardPath === undefined || manifest.pcSavePath === undefined) return; - const pcSave = await this.resolvePcSavePath(manifest); - // No prefix → no PC half worth recording: a baseline whose `pc` describes a non-existent container is - // exactly what makes the next sync-in mistake "prefix wiped" for "saves deleted" (see runSaveSync). - if (pcSave === null || !pcSave.containerExists) return; - await this.deps.store.writeSyncState(manifest.raw.id, { - card: await snapshotTree(cardPath), - pc: await snapshotTree(pcSave.path), - syncedAt: Date.now(), - }); - } - - // ── Reaction to card removal ───────────────────────────────────────────── - - private onRemove(): void { - this.cardPresent = false; - const kind = this.deps.state.get().kind; - // During play/sync, removal is expected: the flow continues, sync-out - // will see cardPresent=false and put the task into pending-flush. We don't touch state. - if ( - kind === 'running' || - kind === 'launching' || - kind === 'installing' || - kind === 'uninstalling' || - kind === 'syncing-in' || - kind === 'syncing-out' - ) { - // During install, removal is also expected: the installer reads from the card, so yanking - // it makes the install fail → won't appear → we stay on "Install"; next attempt pre-cleans. - // During uninstall it targets the PC, so it completes; runUninstallSequence then sees cardPresent - // = false and goes idle + hide on its own. - return; - } - // ready / error / idle → no card. Stop any Steam re-detect poller (the card is gone; a Steam game in - // `ready` reaches here since its kind is never running/installing). - this.steamWatch.stop(); - this.steamWatch.clearUninstallRequest(); - this.clearCard(); - // A local game is still playable with no card in, so pulling one must not collapse the launcher to the - // empty screen: stay `ready` on the first card of the row clearCard just rebuilt (NOT the first entry - // of the library file — see firstCarouselGame). Only a truly empty launcher goes idle + hides. - const remaining = this.firstCarouselGame(); - if (remaining !== null) { - void this.enterReadyForLocal(remaining); - return; - } - this.deps.state.set({ kind: 'idle' }); - // Normally the background app hides to the tray when no card is present. With "keep the launcher open - // without a card" on, it stays up instead — BUT only if it's currently on screen. If the user - // minimized it to the tray, pulling the card must not pop it back up (respect that intent). - if (this.deps.isGamescope) { - // Game Mode: no tray — the launcher always stays up (forces keepOpenWithoutCard). - this.deps.window.showAndFocus(); - } else if (this.keepOpenWithoutCard) { - if (this.deps.window.isShown()) this.deps.window.showAndFocus(); - } else { - this.deps.window.hide(); - } - } - - /** - * Applies the "keep the launcher open without a card" setting (seeded at startup, toggled live from the - * settings window). Besides caching the flag it reconciles the launcher NOW when we're idle with no - * card: bring it up when turning it on, or hide back to the tray when turning it off. When a card is - * present (ready/busy) nothing changes — the launcher is already visible for the game. - */ - setKeepOpenWithoutCard(on: boolean): void { - this.keepOpenWithoutCard = on; - const kind = this.deps.state.get().kind; - // `ready` counts too when no card is in: that is a LOCAL game on screen, and the setting is about - // whether the launcher sits there with no card — not about which screen it happens to show. - if (this.cardPresent || (kind !== 'idle' && kind !== 'ready')) return; - // Game Mode (gamescope): there is no tray to hide into, and a HIDDEN window leaves gamescope with no - // surface to present — Steam's launch spinner then hangs forever. So the window is ALWAYS shown there - // (the empty "insert a card" screen), regardless of the setting. Desktop/Windows honour the flag. - if (on || this.deps.isGamescope) this.deps.window.showAndFocus(); - else this.deps.window.hide(); - } - - // ── "Launch" action (the A button / click) ────────────────────────────── - - private onLaunchRequested(): void { - const snapshot = this.deps.state.get(); - // Play pressed while a game is running (the launcher was summoned over it via the tray): return to the - // game instead of launching. Checked BEFORE the ready-guard — launchInFlight is true during running, - // but we never reach its check. No-op if we don't have the image names yet. - if (snapshot.kind === 'running') { - this.resumeRunningGame(); - return; - } - // Ignore input outside the ready state — this is the "ignore-gamepad" during play - // (harmless under any interpretation of the Gamepad API focus bug). - if (snapshot.kind !== 'ready' || this.launchInFlight || this.reloadInFlight) return; - const manifest = this.current(); - if (manifest === null) return; - // A local game whose .exe is gone (deleted, or an external drive unplugged). The renderer already - // disables Play, but a gamepad press must not slip past it into a launch that can only fail. - if (snapshot.game.unavailable === true) { - log.info(`[launch] refused id=${manifest.raw.id}: "${manifest.executablePath}" is not on disk`); - this.sendError(this.t('launcher.state.gameFilesMissing')); - return; - } - // A local draft with no launch method chosen yet — same guard, different reason. The renderer already - // disables Play, but a gamepad press must not slip past it into runLaunchSequence. - if (snapshot.game.unconfigured === true) { - log.info(`[launch] refused id=${manifest.raw.id}: no launch method is configured`); - this.sendError(this.t('launcher.state.launchNotConfigured')); - return; - } - // A Steam download/removal of ANOTHER game is in flight. Every other kind of activity moves the state - // out of `ready` and is caught by the guard above; a Steam operation deliberately does not (it can run - // for hours and the window stays usable), so it needs this explicit check — otherwise a second game - // could be launched or installed on top of it from the carousel. - if (this.steamBusyId !== null && this.steamBusyId !== manifest.raw.id) { - log.info(`[launch] refused id=${manifest.raw.id}: a Steam operation is in flight for id=${this.steamBusyId}`); - this.sendError(this.t('errors.steamBusyOther')); - return; - } - // Steam mode: not yet installed → open steam://install (fire-and-forget); otherwise launch via - // steam://rungameid. Both inside runSteamInstall / runLaunchSequence's steam branch. - if (manifest.steam !== undefined) { - if (snapshot.game.requiresInstall) { - void this.runSteamInstall(manifest, snapshot.game); - } else { - void this.runLaunchSequence(manifest, snapshot.game); - } - return; - } - // Card-install mode + not yet installed → run the installer; otherwise it's an ordinary launch - // (this includes a fully-installed game, whose executable now exists → requiresInstall=false). - if (manifest.install !== undefined && snapshot.game.requiresInstall) { - void this.runInstallSequence(manifest, snapshot.game); - } else { - void this.runLaunchSequence(manifest, snapshot.game); - } - } - - /** - * Return-to-game: raise the running game's own window to the foreground (restoring it if it minimized - * when it lost focus). Best-effort — if the window isn't found (the game is already closing, a race with - * waitForExit) it's a silent no-op; the state machine will move to syncing-out → ready on its own. - */ - private resumeRunningGame(): void { - const names = this.runningImageNames; - if (names === null) return; - if (!focusGameWindow(names)) { - log.info('[resume] running game window not found — no-op (it may be closing)'); - } - } - - /** - * The carousel entered a game's detail screen (renderer sent action:select with the game id). Switches to - * it: builds that game's hero/audio/GameInfo on demand (only the selected game ever gets heavy assets) - * and enters `ready`. Selection is by id (not index) so a card reload that reorders games can't pick the - * wrong one. Rejected unless we're on `ready` and idle (not locked / launching / reloading) — the same - * guard the launch path enforces, so you can't switch the card's game while one is running. - */ - private async onSelectRequested(idRaw: unknown): Promise { - if (typeof idRaw !== 'string') return; - const snapshot = this.deps.state.get(); - if (snapshot.kind !== 'ready' || this.locked || this.launchInFlight || this.reloadInFlight) return; - const manifest = this.games.find((m) => m.raw.id === idRaw); - if (manifest === undefined) { - log.warn(`[select] no game with id="${idRaw}" on the current card or in the PC library — ignoring`); - return; - } - this.selectedId = manifest.raw.id; - // Build the switched-to game's assets on demand (mirrors loadCard). Stats come from the loadCard cache - // (buildGameInfo still re-reads a steam game's .acf); fall back to a fresh read if somehow absent. - const stats = this.statsById.get(manifest.raw.id) ?? (await this.deps.stats.read(manifest.raw.id)); - this.setHero(await this.assets.readHeroAssets(manifest)); - this.setCardMusic(await this.cardMusicFor(manifest)); - this.enterReady(await this.buildGameInfo(manifest, stats)); - // Keep what's on screen in step with the selection (the renderer reads the title/stats from here). - await this.browseToUnlessPinned(manifest.raw.id); - } - - /** - * Force-close the running game (More → Force close → confirmed Yes). Flips the running snapshot into its - * `killing` sub-state (the launcher shows "Force closing…" and hides the Force close button), kills the - * main executable AND every watchProcess, then lets the EXISTING exit waiters (waitForExit / - * waitForWatchedExit) notice the processes vanish and carry the flow through syncing-out → sync → ready - * (K-Д3) — no state machine of its own. Guarded by the running state + a killInFlight flag (double Yes / - * repeat is a no-op). - * - * A non-elevated launcher can't terminate a runAsAdmin game's high-integrity processes (taskkill → - * ACCESS_DENIED, the ShellExecuteEx HANDLE lacks PROCESS_TERMINATE). So for a runAsAdmin game, if the - * targets survive a short grace, we escalate to ONE elevated `taskkill /F /T /IM …` (a single UAC - * prompt). Non-elevated games never trigger UAC. - * - * Success is judged by FACT, not command exit codes: a "not found" from taskkill just means the target - * is already dead (success). After the kills we verify over a WINDOW bounded by killTimeoutSec (a killed - * process lingers in tasklist for a beat, so a single instant snapshot would false-positive): success as - * soon as the targets are gone (the `killing` indicator stays until an exit waiter advances the flow). - * If something is still alive when the window elapses, we DROP back to plain running (the game is still - * up) and surface a soft errors.killFailed. - */ - private async onKillRequested(): Promise { - const snapshot = this.deps.state.get(); - if (snapshot.kind !== 'running') return; // only meaningful while a game is running - if (this.killInFlight) return; // a force-close is already underway (double Yes / repeat) - const manifest = this.current(); - if (manifest === null) return; // defensive: `running` always has a current manifest - this.killInFlight = true; - // Show "Force closing…" and hide the Force close button immediately (cleared back on failure). - this.deps.state.set({ ...snapshot, killing: true }); - try { - // Steam mode is tracked/killed by SteamAppId (native + Proton games), with no owned pid and no - // elevation (the schema forbids runAsAdmin there). Every other mode kills the owned process + the - // target image names, escalating to an elevated taskkill for a runAsAdmin game. Both end in the same - // fact-based verdict below (stillAlive). - let stillAlive: boolean; - if (manifest.steam !== undefined) { - const appid = manifest.steam.appid; - const names = manifest.raw.watchProcesses ?? []; - log.info(`[kill] force-close requested id=${manifest.raw.id} steam appid=${appid}`); - await this.monitor.killSteamGame(appid, names); - stillAlive = await this.steamGameStillAlive(appid, names, manifest.raw.killTimeoutSec); - } else { - // Targets are computed HERE from this.current, leaving runningImageNames untouched: a union there - // would regress return-to-game (focusGameWindow picks the first Z-order match — the launcher name - // could steal focus from the game). - const targets = normalizeImageNames([ - manifest.executablePath, - ...(manifest.raw.watchProcesses ?? []), - ]); - log.info(`[kill] force-close requested id=${manifest.raw.id} targets=[${targets.join(',')}]`); - - // 1. Terminate the owned process (elevated HANDLE, or the normal pid tree with an isAlive re-check - // inside kill()). In the watched path this is usually the already-dead launcher — its "not - // found" is normal, not an error. - const proc = this.runningProc; - if (proc !== null) { - try { - await proc.kill(); - } catch (cause) { - log.warn('[kill] owned-process kill failed (continuing to kill by name):', describe(cause)); - } - } - - // 2. Kill each target image by name (non-elevated): win32 `taskkill /F /IM`, linux SIGTERM/SIGKILL - // to every /proc match. Failures are normal ("not found" = already dead). - await this.monitor.killByName(targets); - - // 2b. Elevated escalation (runAsAdmin games only). A non-elevated taskkill / the ShellExecuteEx - // HANDLE can't terminate high-integrity processes, so if the targets survive a short grace we - // run ONE elevated `taskkill /F /T /IM …` (a single UAC prompt). Non-elevated games never reach - // here (no UAC for them). A declined UAC just leaves the targets up → killFailed below. - if (manifest.raw.runAsAdmin && (await this.killTargetsStillAlive(targets, proc, KILL_ELEVATE_GRACE_SEC))) { - log.info(`[kill] elevated game survived non-elevated kill id=${manifest.raw.id} — escalating to elevated taskkill (UAC)`); - killImagesElevated(targets); - } - - // 3. Fact-based verdict over a window bounded by killTimeoutSec (a killed process lingers for a - // beat — a single instant snapshot would false-positive). - stillAlive = await this.killTargetsStillAlive(targets, proc, manifest.raw.killTimeoutSec); - } - - // killFailed only if something is STILL alive when the window elapsed. - if (stillAlive) { - log.warn(`[kill] targets still alive after force-close id=${manifest.raw.id} — reporting killFailed`); - // The game is still up → back to plain running (status "Running…", Force close button returns), - // then surface the error. Re-read in case a waiter advanced the state (then leave it be). - const current = this.deps.state.get(); - if (current.kind === 'running') this.deps.state.set({ ...current, killing: false }); - this.sendError(this.t('errors.killFailed')); - } else { - log.info(`[kill] force-close done id=${manifest.raw.id} — exit waiters will finish the flow`); - } - } finally { - this.killInFlight = false; - } - } - - /** - * Polls the kill targets for up to `timeoutSec`, returning false (success — everything is gone) as soon - * as no target image is present AND the owned process (elevated HANDLE / normal pid) is dead, OR once an - * exit waiter has already advanced the state out of `running` (it saw the exit → definitely killed). - * Returns true only if something is still alive when the window elapses — a genuine failure. - */ - private async killTargetsStillAlive( - targets: readonly string[], - proc: GameProcess | null, - timeoutSec: number, - ): Promise { - const deadline = Date.now() + timeoutSec * 1000; - for (;;) { - // An exit waiter that already left `running` proves the process is gone — treat as killed. - if (this.deps.state.get().kind !== 'running') return false; - const ownedAlive = proc !== null && (await proc.isAlive()); - if (!ownedAlive && !(await this.anyTargetAlive(targets))) return false; - if (Date.now() >= deadline) return true; // window elapsed and something is still alive → real fail - await delay(KILL_VERIFY_INTERVAL_MS); - } - } - - /** - * Steam-mode analogue of killTargetsStillAlive: polls the monitor's SteamAppId signal (linux) / watch - * names (win32) until the game is gone or the window elapses. Returns true only if it is STILL running. - */ - private async steamGameStillAlive( - appid: number, - watchNames: readonly string[], - timeoutSec: number, - ): Promise { - const deadline = Date.now() + timeoutSec * 1000; - for (;;) { - if (this.deps.state.get().kind !== 'running') return false; // an exit waiter already left `running` - if (!(await this.monitor.isSteamGameRunning(appid, watchNames))) return false; - if (Date.now() >= deadline) return true; - await delay(KILL_VERIFY_INTERVAL_MS); - } - } - - /** "Uninstall" action (the user confirmed in the popup). Only for an installed install-mode game. */ - private onUninstallRequested(): void { - const snapshot = this.deps.state.get(); - if (snapshot.kind !== 'ready' || this.launchInFlight || this.reloadInFlight) return; - const manifest = this.current(); - if (manifest === null) return; - if (!snapshot.game.canUninstall) return; // nothing installed to remove - // Steam: delegate removal to Steam (steam://uninstall) — fire-and-forget, the poller flips to Install. - if (manifest.steam !== undefined) { - void this.runSteamUninstall(manifest, snapshot.game); - return; - } - if (manifest.install === undefined) { - // Normal executable game: the only "uninstall" is clearing its Wine prefix (Linux; the game stays on - // the card). canUninstall is set only when that prefix exists — see buildGameInfo / prefixCleanupOnly. - if (snapshot.game.prefixCleanupOnly === true) { - void this.runPrefixCleanupSequence(manifest, snapshot.game); - } - return; - } - void this.runUninstallSequence(manifest, snapshot.game); - } - - /** - * Clears a normal executable game's Wine prefix (Linux). No installer/uninstaller is involved — the game - * lives on the card, its only PC footprint is the prefix — so this is just the directory sweep + the same - * card-swap / rebuild-info handling as runUninstallSequence, minus the uninstaller run. - */ - private async runPrefixCleanupSequence(manifest: ResolvedManifest, info: GameInfo): Promise { - const dir = await this.deps.platform.gameLauncher.prefixCleanupDir(manifest.raw.id); - if (dir === null) return; // defensive: canUninstall was set only when the prefix existed - const { state, window, stats } = this.deps; - this.launchInFlight = true; - const abort = new AbortController(); - this.abort = abort; - try { - state.set({ kind: 'uninstalling', game: info }); - await removeWithRetry(dir, abort.signal); - if (abort.signal.aborted) return; - // Card yanked mid-cleanup (this targets the PC, so it completed): idle + hide, like runUninstall. - // A local game's source cannot go away, so it always continues to the rebuild below. - if (!this.sourceAvailable(manifest)) { - this.cardGoneAfterSequence(); - return; - } - // Prefix gone → prefixCleanupDir now returns null → canUninstall recomputes false → "Uninstall" - // disappears, leaving just "Play". - const currentStats = await stats.read(manifest.raw.id); - const updatedInfo = await this.buildGameInfo(manifest, currentStats); - log.info(`[prefix-cleanup] removed "${dir}" id=${manifest.raw.id}`); - this.enterReady(updatedInfo); - window.showAndFocus(); - } catch (cause) { - if (cause instanceof LaunchAbortedError) return; // aborted by shutdown or a card swap - this.failSequence('uninstall', info, describe(cause)); - } finally { - this.launchInFlight = false; - this.abort = null; - this.resumePendingInsert(); - } - } - - /** - * Steam install action: fire-and-forget. Opens `steam://install/` (Steam shows its own dialog - * and the download — possibly hours/GBs) and returns WITHOUT entering a blocking `installing` state. - * We stay on the `ready` ("Install") screen; the background re-detect poller (started by enterReady) - * flips the button to "Play" once Steam's .acf reports the game fully installed. Steam itself collapses - * repeated `steam://install` calls, so no debounce is needed. Pre-checks getSteamPath: openExternal - * doesn't reliably reject when steam:// is unregistered. - */ - private async runSteamInstall(manifest: ResolvedManifest, info: GameInfo): Promise { - const appid = manifest.steam?.appid; - if (appid === undefined) return; // defensive: onLaunchRequested only calls this in steam mode - if ((await this.deps.platform.steamLocator.locateSteam()) === null) { - this.sendError(this.t('errors.steamNotInstalled')); - return; - } - try { - await openSteamUri(`steam://install/${appid}`); - log.info(`[steam-install] opened steam://install/${appid} id=${manifest.raw.id}`); - } catch (cause) { - this.sendError(this.t('errors.steamOpenInstall', { cause: describe(cause) })); - return; - } - // Ensure the re-detect poller is running so the button flips to "Play" when the download completes - // (no-op if already running; info confirms this is a steam game still requiring install). - if (info.installVia === 'steam' && info.requiresInstall && this.sourceAvailableFor(info.id)) { - this.steamWatch.start(); - } - } - - /** - * Steam uninstall action: fire-and-forget, mirroring runSteamInstall. Opens `steam://uninstall/` - * (Steam shows its own confirmation/removal UI) and returns WITHOUT a blocking `uninstalling` state. We - * stay on the `ready` ("Play"/"Uninstall") screen; the background poller flips the button back to - * "Install" once Steam removes the .acf. Pre-checks getSteamPath. - */ - /** - * Opens Steam's Downloads page (steam://open/downloads). Triggered by the Play button while a Steam - * download is in progress (its loader is otherwise a no-op) so the user can pause/resume in Steam — - * we can't control Steam's downloads programmatically (no URI/API for pause/resume). - */ - private async onOpenSteamDownloads(): Promise { - try { - await openSteamUri('steam://open/downloads'); - } catch (cause) { - this.sendError(this.t('errors.steamOpenDownloads', { cause: describe(cause) })); - } - } - - private async runSteamUninstall(manifest: ResolvedManifest, info: GameInfo): Promise { - const appid = manifest.steam?.appid; - if (appid === undefined) return; // defensive: onUninstallRequested only calls this in steam mode - if ((await this.deps.platform.steamLocator.locateSteam()) === null) { - this.sendError(this.t('errors.steamNotInstalled')); - return; - } - try { - await openSteamUri(`steam://uninstall/${appid}`); - log.info(`[steam-uninstall] opened steam://uninstall/${appid} id=${manifest.raw.id}`); - } catch (cause) { - this.sendError(this.t('errors.steamOpenUninstall', { cause: describe(cause) })); - return; - } - // Optimistically show "Uninstalling…": record the request and flip the UI. The poller clears it when - // the .acf is gone (→ Install) or on timeout (assumed cancel → back to Play/Uninstall). enterReady - // (re)arms the poller for the inserted steam card. - this.steamWatch.requestUninstall(appid); - this.enterReady({ ...info, steamUninstalling: true, canUninstall: false }); - } - - private async runLaunchSequence(manifest: ResolvedManifest, info: GameInfo): Promise { - const { state, window, stats } = this.deps; - this.launchInFlight = true; - // Lock the launcher on this game for the launching→running span: a game switch is refused and the - // switching the card's game is refused. Cleared in the finally alongside the other running-scoped fields. - this.locked = true; - const abort = new AbortController(); - this.abort = abort; - // Declared before the try so `finally` can dispose the kept HANDLE (elevated path). - let proc: GameProcess | null = null; - try { - // 1. Change-based sync before the game (phase = sync-in). No longer a blind card→PC: if the PC - // saves changed since the last sync (e.g. played on another PC last, or this PC is newer) they are - // NOT overwritten — the changed side wins (see save-sync change-detection). The old card→PC is only - // the first-run fallback (no baseline yet). - state.set({ kind: 'syncing-in', game: info }); - if (manifest.pcSavePath !== undefined && manifest.saveOnCardPath !== undefined) { - // Resolve the deferred pcSavePath to this game's save location (Р5/Э6). null → nothing to sync - // with at all (a steam game with no compatdata) — a logged no-op. - const pcSave = await this.resolvePcSavePath(manifest); - if (pcSave === null) { - log.info( - `[sync-in] pcSavePath "${manifest.pcSavePath}" not resolvable yet — skipping sync`, - ); - } else { - // A MISSING prefix is not a reason to skip: the launch below creates it, and the card's saves - // must be in place before the game reads them (e.g. after an uninstall wiped the prefix). The - // copy targets the prefix path directly — launchGame ensureDir's that prefix anyway — and - // runSaveSync drops the stale baseline so the empty PC side can't erase the card. - try { - log.info( - `[sync-in] change-based sync between card "${manifest.saveOnCardPath}" and PC "${pcSave.path}"${pcSave.containerExists ? '' : ' (prefix absent — restoring from card)'}`, - ); - // Soft catch: sync-in can now WRITE to the card (change-detection may pick PC→card) — a new - // failure point BEFORE launch (a full / write-protected / slow card). The launch never depended - // on a card write before, so keep it that way: log and start the game regardless (mirrors sync-out). - await this.runSaveSync( - manifest, - manifest.saveOnCardPath, - pcSave.path, - 'card-to-pc', - pcSave.containerExists, - ); - } catch (cause) { - log.warn('[sync-in] change-based sync failed, launching anyway:', describe(cause)); - } - } - } - - // 2/3/4. launch, then wait for the game to appear and to exit. THREE backends: - // - steam: open steam://rungameid (no proc of ours); wait by watched names only (launcherPid=null). - // - watched (launcher/wrapper, manifest.watchProcesses): the game is a SEPARATE process; we wait - // for one of the watched image names to appear (HANDOFF — the launcher may live on in its menu), - // then track that process's presence for exit. - // - normal: the spawned pid IS the game; wait for that pid to appear, then disappear. - // Running-phase note (all paths): gamepad input is ignored (outside ready). The window stays put — - // the game takes the foreground on its own and simply covers the launcher, which avoids the jerky - // hide/show flash. We grab the foreground back in step 6 once the game exits. The global Start+Back - // hotkey is intentionally a no-op while running, so there's nothing to re-summon. - const watchProcesses = manifest.raw.watchProcesses; - let since: number; - if (manifest.steam !== undefined) { - state.set({ kind: 'launching', game: info }); - // Pre-check: openExternal doesn't reliably reject when steam:// is unregistered, so gate the - // launch on Steam actually being installed instead of relying on a reject. - if ((await this.deps.platform.steamLocator.locateSteam()) === null) { - this.failSequence('launch', info, this.t('errors.steamNotInstalled')); - return; - } - try { - await openSteamUri(`steam://rungameid/${manifest.steam.appid}`); - } catch (cause) { - this.failSequence('launch', info, this.t('errors.launchViaSteam', { cause: describe(cause) })); - return; - } - // Track by SteamAppId (via the monitor): on linux that reads /proc environ, so native-Linux AND - // Proton games are detected regardless of their binary name; on win32 it maps to the watch names. - const { started } = await waitForSteamStart( - manifest.steam.appid, - watchProcesses ?? [], - manifest.raw.launchTimeoutSec, - this.monitor, - abort.signal, - ); - if (!started) { - // Known MVP limitation: a Steam cold-start or an auto-update before launch may not fit - // launchTimeoutSec → the game-process never appears in the window. We can't tell that apart - // from "didn't start", so we return quietly (recommend a larger launchTimeoutSec). - log.info( - `[launch] steam game never appeared within ${manifest.raw.launchTimeoutSec}s id=${manifest.raw.id} (cold-start/update?)`, - ); - this.abandonWatchedLaunch(info); - return; - } - since = Date.now(); - this.runningImageNames = normalizeImageNames(watchProcesses ?? []); - // Steam owns no process of ours (steam://rungameid returns instantly) — a force-close relies on - // taskkill /IM over the watchProcesses alone. - this.runningProc = null; - state.set({ kind: 'running', game: info, since }); - log.info(`[launch] running (steam) id=${manifest.raw.id} appid=${manifest.steam.appid}`); - await waitForSteamExit(manifest.steam.appid, watchProcesses ?? [], this.monitor, abort.signal); - log.info(`[launch] exited (steam) id=${manifest.raw.id}`); - } else { - // 2. launch → GameProcess (spawn, or elevated ShellExecuteEx per manifest.runAsAdmin) - state.set({ kind: 'launching', game: info }); - try { - proc = await this.launcher.launchGame(manifest, (active) => this.setProvisioning(active, info)); - } catch (cause) { - this.failSequence('launch', info, this.t('errors.launchGame', { cause: describe(cause) })); - return; - } - if (watchProcesses !== undefined && watchProcesses.length > 0) { - const { started } = await waitForWatchedStart( - proc.pid, - watchProcesses, - manifest.raw.launchTimeoutSec, - this.monitor, - abort.signal, - ); - if (!started) { - // The user closed the launcher without playing, or the game never became visible (often an - // elevated/anticheat launcher — see README). Neither a failure nor a play session. - this.abandonWatchedLaunch(info); - return; - } - // The watched game is up: start the clock now (more accurate than the launcher's spawn time). - since = Date.now(); - this.runningImageNames = normalizeImageNames(watchProcesses); - // The spawned launcher (proc) — usually already dead here; kept so a force-close can also take - // down its pid tree. The game itself is killed by taskkill /IM over the watchProcesses. - this.runningProc = proc; - state.set({ kind: 'running', game: info, since }); - log.info(`[launch] running (watched) id=${manifest.raw.id} watch=${watchProcesses.join(',')}`); - await waitForWatchedExit(watchProcesses, this.monitor, abort.signal); - log.info(`[launch] exited (watched) id=${manifest.raw.id}`); - } else { - const started = await waitForStart(proc, manifest.raw.launchTimeoutSec, abort.signal); - if (!started) { - this.failSequence('launch', info, this.t('errors.gameDidNotStart')); - return; - } - since = Date.now(); - // normal AND elevated share this branch (differing only by manifest.raw.runAsAdmin): the game - // IS the spawned exe, so its image name is the executable's basename. - this.runningImageNames = normalizeImageNames([manifest.executablePath]); - // The game process itself — a force-close terminates it directly (elevated: via the HANDLE - // invisible to taskkill; normal: its pid tree with an isAlive re-check inside kill()). - this.runningProc = proc; - state.set({ kind: 'running', game: info, since }); - log.info(`[launch] running id=${manifest.raw.id} pid=${proc.pid}`); - await waitForExit(proc, abort.signal); - log.info(`[launch] exited id=${manifest.raw.id} pid=${proc.pid}`); - } - } - - // 5. game closed → write stats to the PC (source of truth) - const playSeconds = (Date.now() - since) / 1000; - const updatedStats = await stats.recordPlay(manifest.raw.id, playSeconds); - const updatedInfo = await this.buildGameInfo(manifest, updatedStats); - // The history's cached stats follow the authority, and the game may have just EARNED its place in - // the carousel (an inserted-but-never-played game is not listed until now). - await this.deps.library.noteLaunch(manifest.raw.id, updatedStats); - // Before the refresh, not after: the card's own games are ordered by these very dates, and this - // game has just become the most recently played one. - this.statsById.set(manifest.raw.id, updatedStats); - this.refreshLibrary(); - - // 6. PC→SD + stats copy (or pending-flush, if the card is already gone). The game just exited, - // so reclaim the foreground (forceForeground) to surface the launcher over Steam/desktop. - state.set({ kind: 'syncing-out', game: updatedInfo }); - window.showAndFocus(true); - await this.performSyncOut(manifest, updatedStats); - - // 7. done - this.enterReady(updatedInfo); - // Refresh what's on screen too: the play time / launch count the detail screen shows just changed. - await this.browseToUnlessPinned(manifest.raw.id); - window.showAndFocus(); - } catch (cause) { - if (cause instanceof LaunchAbortedError) return; // application is shutting down - this.failSequence('launch', info, describe(cause)); - } finally { - // Release the elevated HANDLE (no-op for the normal spawn path). - proc?.dispose(); - this.launchInFlight = false; - // The game is done → unlock (switching the card's game is allowed again). - this.locked = false; - this.abort = null; - // The game is no longer running → forget its image names (return-to-game only applies while running). - this.runningImageNames = null; - // Drop the owned-process reference (proc.dispose() above is the single owner-side release; this is - // just the reference the force-close used while running). - this.runningProc = null; - // Replay a card that was swapped in mid-flight, now that launchInFlight has cleared. - this.resumePendingInsert(); - } - } - - /** - * Runs the installer for an install-mode game that isn't installed yet (mirrors runLaunchSequence's - * infrastructure: launchInFlight/abort, the LaunchAbortedError guard, the pendingRoot replay). - * Pre-cleans the install dir, runs the installer silently, then grace-polls for the executable — - * on success the button becomes "Play"; otherwise we stay on "Install" and surface the reason. - */ - private async runInstallSequence(manifest: ResolvedManifest, info: GameInfo): Promise { - const install = manifest.install; - if (install === undefined) return; // defensive: onLaunchRequested only calls this in install mode - const { state, window, stats } = this.deps; - this.launchInFlight = true; // set/cleared explicitly, like runLaunchSequence - const abort = new AbortController(); - this.abort = abort; - let proc: GameProcess | null = null; - try { - state.set({ kind: 'installing', game: info }); - - // Pre-clean: a partial install left by a previous failed attempt could carry a stale → - // a bogus "Play". We're (re)installing anyway, so a clean directory is safe. - await fse.remove(install.dir); - - if (install.type === 'copy') { - // "Move game to PC": no installer to run — copy the card's game directory into the install dir. - if (!(await this.runCopyInstall(install, manifest, info, abort))) return; - } else { - // Silent by default; a user who enabled "disable silent installer mode" gets the visible wizard - // (needed for repacks that skip a crack/patch step under silent — `skipifsilent`). - const silent = !(await this.deps.settings.read()).disableSilentInstall; - try { - proc = await this.launcher.launchInstaller(install, silent, (active) => - this.setProvisioning(active, info), - ); - } catch (cause) { - this.failSequence('install', info, this.t('errors.startInstaller', { cause: describe(cause) })); - return; - } - - // Wait for the installer to exit, then grace-poll for the executable: some installers (often - // custom wrappers) fork a child and exit early, so may appear shortly AFTER waitForExit. - await waitForExit(proc, abort.signal); - const installed = await this.pollForExecutable( - manifest.executablePath, - manifest.raw.launchTimeoutSec, - abort.signal, - ); - if (!installed) { - this.failSequence('install', info, this.t('errors.installIncomplete')); - return; - } - } - - // Installed: rebuild GameInfo so requiresInstall recomputes to false (the executable now exists), - // flipping the button back to "Play". The next press launches normally from the install dir. - const currentStats = await stats.read(manifest.raw.id); - const installedInfo = await this.buildGameInfo(manifest, currentStats); - log.info(`[install] completed id=${manifest.raw.id} dir="${install.dir}"`); - this.enterReady(installedInfo); - // The "install finished" cue belongs to the notification now (its own `notify` sound). It used to - // be a bare "play" sound pushed straight to the renderer from here — two sounds would now land on - // the same moment, and that one also chirped from a hidden window while a game was running. - this.deps.notifications.notify({ - kind: 'game-installed', - gameId: manifest.raw.id, - gameTitle: installedInfo.title, - }); - window.showAndFocus(); - } catch (cause) { - if (cause instanceof LaunchAbortedError) return; // aborted by shutdown or a card swap - this.failSequence('install', info, describe(cause)); - } finally { - proc?.dispose(); - this.launchInFlight = false; - this.abort = null; - this.resumePendingInsert(); - } - } - - /** - * The `copy` install type ("move game to PC"): instead of running an installer, copy the game - * directory from the card into the app-controlled install dir. Called by runInstallSequence, which - * owns the state/abort infrastructure and the shared tail — this only covers copy's own steps. - * - * Returns true when the game is in place and the caller should finish the sequence; false when it must - * stop (a failure was already surfaced, or the sequence was aborted and must unwind silently). - */ - private async runCopyInstall( - install: ResolvedCopyInstall, - manifest: ResolvedManifest, - info: GameInfo, - abort: AbortController, - ): Promise { - // Prepare the destination's environment BEFORE the files land in it (linux: create + provision the - // Wine prefix; win32: no-op). This is what launchInstaller does implicitly on the installer path — - // without it a copied game would sit in a bare prefix with none of the baseline runtimes that the - // installer it originally came from would have pulled in. A failure here propagates to the caller's - // catch (it is an environment fault, like a failed installer launch). - await this.launcher.prepareInstallDir(install, (active) => this.setProvisioning(active, info)); - - try { - // `dereference: false` — copy symlinks as symlinks (a game's own internal links stay internal). - await fse.copy(install.installerPath, install.dir, { dereference: false }); - } catch (cause) { - // fse.copy takes no AbortSignal, so a card swap mid-copy surfaces as a plain ENOENT (the source - // vanished) rather than a LaunchAbortedError. Check the flag before reporting: the new card is - // already on screen, and an error popup about the old one over it would be nonsense. - if (abort.signal.aborted) return false; - this.failSequence('install', info, this.t('errors.copyGameFailed', { cause: describe(cause) })); - return false; - } - - // Same reason as in runUninstallSequence: the copy itself isn't interruptible, so check the abort - // flag manually before touching any state. - if (abort.signal.aborted) return false; - - // A single existence check, not pollForExecutable: the grace-poll exists for installers that fork a - // child and exit early, whereas fse.copy is done when it resolves. Polling would only add - // launchTimeoutSec of waiting on an already-known-bad path. - if (!(await fse.pathExists(manifest.executablePath))) { - // The usual cause is a wrong source root: `executable` is card-relative in the form, but here it - // resolves inside the copied directory. Second most likely on linux: a Windows-authored card whose - // exe case doesn't match the files copied onto a case-sensitive FS — say so instead of "not found". - const shown = manifest.raw.executable ?? path.basename(manifest.executablePath); - const found = await findCaseInsensitiveName(manifest.executablePath); - this.failSequence( - 'install', - info, - found !== null - ? this.t('errors.copyExeNotFoundCase', { path: shown, found }) - : this.t('errors.copyExeNotFound', { path: shown }), - ); - return false; - } - - log.info( - `[install] copied id=${manifest.raw.id} from="${install.installerPath}" to="${install.dir}"`, - ); - return true; - } - - /** - * Polls for the game executable to appear within `timeoutSec` (grace window after the installer - * exits). Throws LaunchAbortedError if aborted, so a mid-install card swap unwinds WITHOUT - * setting state over the new card — never returns false on abort. - */ - private async pollForExecutable( - executablePath: string, - timeoutSec: number, - signal: AbortSignal, - ): Promise { - const deadline = Date.now() + timeoutSec * 1000; - for (;;) { - if (signal.aborted) throw new LaunchAbortedError(); - if (await fse.pathExists(executablePath)) return true; - if (Date.now() >= deadline) return false; - await delay(INSTALL_POLL_INTERVAL_MS); - } - } - - /** - * Uninstalls an installed install-mode game (mirrors runInstallSequence's infrastructure: - * launchInFlight/abort, the LaunchAbortedError guard, the pendingRoot replay). Runs the game's own - * uninstaller (best-effort — it cleans the registry/shortcuts), then ALWAYS sweeps the app-controlled - * install dir, so on success the executable is gone → requiresInstall recomputes true → "Install". - */ - private async runUninstallSequence(manifest: ResolvedManifest, info: GameInfo): Promise { - const install = manifest.install; - if (install === undefined) return; // defensive: onUninstallRequested only calls this in install mode - const { state, window, stats } = this.deps; - this.launchInFlight = true; // set/cleared explicitly, like runInstallSequence - const abort = new AbortController(); - this.abort = abort; - let proc: GameProcess | null = null; - try { - state.set({ kind: 'uninstalling', game: info }); - - // Run the game's own uninstaller if we can resolve one (FS search → registry fallback). Any - // launch/wait failure is NON-fatal: we log it and fall through to the directory sweep. Only a - // LaunchAbortedError (from waitForExit on a card swap) propagates to unwind cleanly. - // - // `copy` is skipped entirely: nothing was installed, so there is no uninstaller of OURS to run. - // A copied game directory is one that was installed on some OTHER machine, so any `unins*.exe` - // inside it belongs to that install — running it would clean a foreign registry and might pop a - // wizard. Straight to the sweep instead (which is the whole uninstall for copy). - if (install.type !== 'copy') { - const target = await resolveUninstaller(install); - if (target !== null) { - try { - proc = await this.launcher.launchUninstaller(target); - await waitForExit(proc, abort.signal); - } catch (cause) { - if (cause instanceof LaunchAbortedError) throw cause; - log.warn(`[uninstall] uninstaller failed, continuing to cleanup: ${describe(cause)}`); - } - } - } - - // Sweep the platform's uninstall target — after the uninstaller, and as the fallback when no target - // was resolved (custom / nothing found). win32: the install dir. linux: the whole per-game Wine - // prefix (game files + provisioned runtimes), so the full disk footprint is reclaimed (Р7f). - const uninstallDir = this.launcher.uninstallDir(install); - await removeWithRetry(uninstallDir, abort.signal); - - // fse.remove is NOT interrupted by the signal (unlike waitForExit), so check the abort flag - // manually — strictly BEFORE reading cardPresent / rebuilding info — so a mid-uninstall card swap - // doesn't set state over the new card (the finally → resumePendingInsert handles it). - if (abort.signal.aborted) return; - - // The card may have been yanked during the uninstall (it targets the PC, so it completed): no card - // → idle + hide, mirroring abandonWatchedLaunch / onRemove's cleanup. - if (!this.sourceAvailable(manifest)) { - this.cardGoneAfterSequence(); - return; - } - - // Done: rebuild GameInfo so requiresInstall recomputes true and canUninstall false (the executable - // is gone) → the button flips back to "Install" and "Uninstall" disappears. - const currentStats = await stats.read(manifest.raw.id); - const updatedInfo = await this.buildGameInfo(manifest, currentStats); - log.info(`[uninstall] completed id=${manifest.raw.id} removed="${uninstallDir}"`); - this.enterReady(updatedInfo); - this.deps.notifications.notify({ - kind: 'game-uninstalled', - gameId: manifest.raw.id, - gameTitle: updatedInfo.title, - }); - window.showAndFocus(); - } catch (cause) { - if (cause instanceof LaunchAbortedError) return; // aborted by shutdown or a card swap - this.failSequence('uninstall', info, describe(cause)); - } finally { - proc?.dispose(); - this.launchInFlight = false; - this.abort = null; - this.resumePendingInsert(); - } - } - - /** Replays a card insertion deferred during an in-flight launch/install. No-op if none pending. */ - private resumePendingInsert(): void { - const root = this.pendingRoot; - if (root === null) return; - this.pendingRoot = null; - void this.onInsert(root); - } - - /** - * A launch/install/uninstall attempt failed: return to the 'ready' screen with the SAME info and - * surface the reason in the error popup. The info is unchanged, so the flags recompute to the pre-attempt - * button (launch → "Play", failed install → still "Install", failed uninstall → still "Uninstall"); the - * user can read the error, close it (B / veil) and retry. Only the log prefix differs per phase. - */ - private failSequence(phase: 'launch' | 'install' | 'uninstall', game: GameInfo, message: string): void { - log.warn(`[${phase}] failed: ${message}`); - this.enterReady(game); - this.deps.window.showAndFocus(); - this.sendError(message); - } - - /** - * The watched-launcher path ended without the game ever becoming visible: the user closed the launcher - * without playing, or the game runs elevated / as a service and `tasklist` can't see it. This is - * neither a failure nor a play session — we do NOT call stats.recordPlay (it would bump launchCount and - * lastPlayedAt for a 0s session) and we do NOT surface an error popup. Back to the normal screen; if the - * card is already gone, go idle and hide, mirroring onRemove's cleanup. - */ - private abandonWatchedLaunch(game: GameInfo): void { - log.info('[launch] watched game never appeared — returning without recording a session'); - if (!this.currentSourceAvailable()) { - this.steamWatch.stop(); - this.cardGoneAfterSequence(); - return; - } - this.enterReady(game); - this.deps.window.showAndFocus(); - } - - /** - * Runs a bidirectional, change-based save sync (syncByChange) and persists the new baseline. The - * `fallback` direction is used only on the FIRST run (no baseline yet): 'card-to-pc' for sync-in, - * 'pc-to-card' for sync-out — i.e. the phase's old deterministic direction. Otherwise the direction is - * chosen by which side changed since the last sync. A conflict (both changed) and a fallback are logged. - * Throws propagate to the caller (sync-in swallows them softly; sync-out defers to pending-flush). - */ - /** - * Runs one change-detected sync between the card and this game's PC save folder. - * - * `containerExists=false` (linux: the game's Wine prefix is gone — never created, or wiped by an - * uninstall) DISCARDS the baseline. That is a data-integrity rule, not an optimisation: change-detection - * reads an empty PC side against a baseline that lists files as "every save was deleted here" and would - * replicate that deletion onto the card — destroying the only surviving copy. The container being absent - * means the PC side has no authority at all, so the baseline describes a world that no longer exists; - * dropping it falls back to the phase direction (card→PC on sync-in), which restores the card's saves. - */ - private async runSaveSync( - manifest: ResolvedManifest, - cardPath: string, - pcPath: string, - fallback: 'card-to-pc' | 'pc-to-card', - containerExists: boolean, - ): Promise { - const id = manifest.raw.id; - // A local game syncs against its own backup, not against a card, so it keeps its baseline in its own - // slot: one shared baseline for both pairings would make each sync see the other's changes as a - // conflict (see PcStore.syncStatePath). - const slot: SyncSlot = manifest.source === 'pc' ? 'pc' : 'card'; - const baseline = containerExists ? await this.deps.store.readSyncState(id, slot) : null; - if (!containerExists) { - log.info(`[save-sync] id=${id} PC container absent → baseline discarded, card is authoritative`); - } - const result = await syncByChange(cardPath, pcPath, baseline, fallback); - if (result.conflict) { - // The only branch that can lose data: both sides changed, LWW picked one. The losing side survives - // only as syncDir's `.bak`. Logged loudly so it's visible in the diagnostics. - log.warn( - `[save-sync] CONFLICT id=${id}: both sides changed since last sync → ${result.direction} by LWW (losing side kept as .bak)`, - ); - } - log.info( - `[save-sync] id=${id} direction=${result.direction}${result.usedFallback ? ' (fallback: no baseline)' : ''}`, - ); - await this.deps.store.writeSyncState(id, result.state, slot); - } - - private async performSyncOut(manifest: ResolvedManifest, stats: Stats): Promise { - const id = manifest.raw.id; - // Resolve the deferred pcSavePath once for this game (Р5/Э6). The game just ran, so its prefix exists - // and (on win32) the env expansion always succeeds — this matches the pre-port physical path exactly. - // A prefix that is somehow absent here means the game wrote nothing we could carry back: there is no - // source to copy from, so treat it as "no PC side" rather than syncing an emptiness onto the card. - const resolved = await this.resolvePcSavePath(manifest); - const pcPath = resolved !== null && resolved.containerExists ? resolved.path : null; - if (resolved !== null && !resolved.containerExists) { - log.warn(`[sync-out] the Wine prefix for id=${id} is gone — nothing to copy back to the card`); - } - // The card is already removed (the expected scenario) → defer PC→SD into pending-flush. A local game - // is never "removed", so it always takes the sync path below (its backup is always reachable). - if (!this.sourceAvailable(manifest)) { - if (pcPath !== null) { - await this.deps.store.enqueuePcToSd(id, pcPath); - } - return; - } - if (pcPath !== null && manifest.saveOnCardPath !== undefined) { - // Diagnostic (silent-failure guard): syncDir no-ops when the source is missing. If the PC save - // folder doesn't exist after a play session, pcSavePath is almost certainly wrong in game.json - // (e.g. %APPDATA% used for an AppData\LocalLow path) — warn instead of failing silently. - if (!(await fse.pathExists(pcPath))) { - log.warn( - `[sync-out] pcSavePath does not exist — nothing copied to the card. Check the manifest path: "${manifest.pcSavePath}" (resolved: "${pcPath}")`, - ); - } else { - try { - // Change-based sync after the game (phase = sync-out). The old blind PC→card is only the - // first-run fallback; normally the changed side wins (this PC just played → usually PC→card). - log.info( - `[sync-out] change-based sync between PC "${pcPath}" and card "${manifest.saveOnCardPath}"`, - ); - // containerExists is true here by construction (pcPath is null otherwise), so the baseline is - // honoured exactly as before — sync-out semantics are unchanged. - await this.runSaveSync(manifest, manifest.saveOnCardPath, pcPath, 'pc-to-card', true); - if (manifest.source === 'pc') await this.queueLocalProgressForCard(manifest, pcPath); - } catch (cause) { - // The card may have been yanked during the sync → saves.bak is intact, we'll finish on insertion. - log.warn('[sync-out] failed, deferring to pending-flush:', describe(cause)); - await this.deps.store.enqueuePcToSd(id, pcPath); - return; - } - } - } - // A local game's stats mirror lives on the PC and is the only copy there is — there is no card to - // write a travelling stats.json to, and writing one into userData would mean nothing. - if (manifest.source === 'card') { - await this.deps.stats.copyToCard(manifest.root, manifest.raw.id, stats); - } - } - - /** - * "The saves move to the card": after a local game's session, ALSO queue a PC→SD flush, so inserting a - * card that carries the same game tops it up with the progress made without it (the existing - * flushPendingIfAny on insert does the actual copy). - * - * Only when a CARD baseline exists for this id — i.e. that card has been seen on this machine before. - * Without that condition every local session would leave a third full copy of the saves behind, growing - * on disk forever, for a card that may never exist. - */ - private async queueLocalProgressForCard(manifest: ResolvedManifest, pcPath: string): Promise { - const id = manifest.raw.id; - if (!(await this.deps.store.hasCardSyncState(id))) return; - await this.deps.store.enqueuePcToSd(id, pcPath); - log.info(`[sync-out] id=${id} local session queued for the card it was last synced with`); - } - - // ── Building GameInfo for the UI ───────────────────────────────────────── - - private async buildGameInfo(manifest: ResolvedManifest, stats: Stats): Promise { - // Hero images are NOT part of GameInfo anymore — they travel on the hero:update channel (see - // readHeroAssets / setHero), delivered once per card on insert (not on every state transition). - // Three mutually-exclusive modes decide requiresInstall/canUninstall/installVia. Kept as an EXPLICIT - // 3-way branch (not the old `install !== undefined && !installed` formula, which gives false for a - // steam game and would always show "Play"). executablePath is only read by pathExists in the - // install/normal branches, where it is real (in steam mode it is '' and we never reach that read). - let requiresInstall: boolean; - let canUninstall: boolean; - let installVia: 'steam' | 'copy' | undefined; - let prefixCleanupOnly = false; - let steamInstalling = false; - let steamPaused = false; - let steamPausedProgress: number | undefined; - if (manifest.steam !== undefined) { - // Steam mode: "installed" is Steam's own .acf state; uninstall is managed in Steam (never here). - const status = await steamInstallStatus(manifest.steam.appid, this.deps.platform.steamLocator); - requiresInstall = status.state !== 'installed'; - // Steam uninstall is delegated to Steam (steam://uninstall) — available once installed. - canUninstall = status.state === 'installed'; - installVia = 'steam'; - // Non-blocking "Installing…" indicator while Steam is downloading (no live percent — see types.ts); - // `paused` flips it to "Installing paused on N%…" using the snapshot percent. - steamInstalling = status.state === 'downloading'; - steamPaused = status.state === 'downloading' && status.paused; - steamPausedProgress = status.state === 'downloading' ? (status.progress ?? undefined) : undefined; - } else if (manifest.install !== undefined) { - // Card-install mode: installed ⇔ the resolved executable exists; that also enables Uninstall. - const installed = await fse.pathExists(manifest.executablePath); - requiresInstall = !installed; - canUninstall = installed; - // `copy` shares this branch but not its install-confirm copy: no installer runs, so the silent-mode - // caveat and the destination path (which exists only for the user to paste into an installer's - // picker) are meaningless there. Tell the renderer which of the two notes to show. - installVia = manifest.install.type === 'copy' ? 'copy' : undefined; - } else { - // Normal card game: always ready to play. On Linux it still creates a per-game Wine prefix on first - // launch — offer to clear that prefix (the game stays on the card). win32 has no prefix → null → no - // Uninstall button (unchanged). "Uninstall" here means prefix cleanup, not removing an install. - // A LOCAL game shares this branch: it is an ordinary executable, only one that lives on the PC. - requiresInstall = false; - const cleanupDir = await this.deps.platform.gameLauncher.prefixCleanupDir(manifest.raw.id); - canUninstall = cleanupDir !== null; - prefixCleanupOnly = canUninstall; - installVia = undefined; - } - // A local game's executable is checked HERE, not at read time (a card game's is the other way round): - // its absence must not drop the game from the library — the card, its art and its save backup stay, - // and only Play is disabled. See ManifestSource / the pc block. - // Stated POSITIVELY — "this game carries its own executable, and it is gone" — so it stays right for a - // local STEAM game, whose executablePath is the '' placeholder: `pathExists('')` is false, and a - // by-source check would strip its Play button. Steam's own "not installed" is `requiresInstall`. - const unavailable = - manifest.raw.pc !== undefined && !(await fse.pathExists(manifest.executablePath)); - const unconfigured = manifest.unconfigured === true; - return { - id: manifest.raw.id, - title: manifest.raw.title, - lastPlayedAt: stats.lastPlayedAt, - totalPlaySeconds: stats.totalPlaySeconds, - launchCount: stats.launchCount, - requiresInstall, - canUninstall, - // Installer-view dir (Р7): on linux this is the `C:\playhook\games\` the user would paste into a - // non-silent Wine picker; on win32 it equals the host dir. - ...(manifest.install !== undefined ? { installDir: manifest.install.installerDir } : {}), - ...(installVia !== undefined ? { installVia } : {}), - ...(prefixCleanupOnly ? { prefixCleanupOnly: true } : {}), - ...(steamInstalling ? { steamInstalling: true } : {}), - ...(steamPaused ? { steamPaused: true } : {}), - ...(steamPausedProgress !== undefined ? { steamPausedProgress } : {}), - ...(unavailable ? { unavailable: true } : {}), - ...(unconfigured ? { unconfigured: true } : {}), - }; - } - - // ── Hero images (delivered once per card, rotated in the renderer) ─────── - - /** Stores the current hero images and pushes them to the window (null when no card / on error). */ - private setHero(assets: HeroAssets | null): void { - this.currentHero = assets; - const browserWindow = this.deps.window.browserWindow; - if (browserWindow !== null && !browserWindow.isDestroyed()) { - browserWindow.webContents.send(IPC.heroUpdate, assets); - } - } - - // ── Audio (the card's music + the bundled UI sound set) ────────────────── - - /** - * The music that belongs to the CARD channel — the one a game with no music of its own falls back to - * (see the fallback chain in audio.ts: browsed game → card → ambience). - * - * A LOCAL game never fills it, and that is the whole point of this helper. The fallback says "you are - * looking at a game with no theme, so keep playing the card's" — which is right for a card, whose - * games travel together, and wrong for the PC library, where the selected game is just whichever one - * happens to be highlighted: its theme would then play under every other local game, drowning out the - * ambience the user chose in Settings. A local game's own music still reaches the ear through the - * browse channel, which is what plays the game you are actually looking at. - */ - private async cardMusicFor(manifest: ResolvedManifest | null): Promise { - if (manifest === null || manifest.source !== 'card') return null; - return this.assets.readMusicDataUrl(manifest); - } - - /** Stores the current card's music and pushes it to the window (null when no card / on error). */ - private setCardMusic(url: string | null): void { - this.currentCardMusic = url; - const browserWindow = this.deps.window.browserWindow; - if (browserWindow !== null && !browserWindow.isDestroyed()) { - browserWindow.webContents.send(IPC.cardMusicUpdate, url); - } - } - - /** - * Recomputes and re-pushes the audio after an audio-settings change (the sound set, "only global - * ambience"). Re-reads the sound set (the AssetReader cache re-keys on the set) and re-pushes the - * loaded card's music — that second half is not optional: "only global ambience" is what decides - * whether the card's music exists at all (readMusicDataUrl), so without a re-push the browse channel - * would go silent while a stale card music, read while the flag was off, kept playing over it. - * A set switch leaves the music URL identical, and the renderer treats that as a no-op, so it never - * restarts the track. - */ - async refreshAudio(): Promise { - this.sfxSet = await this.assets.readSfxSet(); - const manifest = this.current(); - this.setCardMusic(await this.cardMusicFor(manifest)); - // The carousel plays the BUNDLED set, and what you hear on screen comes from the browse channel — - // both have to follow the setting too, or a change only lands after you flip to another card (the - // browse music outranks the card's own, so a stale value would keep playing over it). - this.pushSfxSet(); - await this.refreshBrowseMusic(); - } - - /** - * Re-sends the browsed game's music after an audio-settings change ("only global ambience", the sound - * set). Music only — re-running the whole browse would re-encode the hero images for nothing. - */ - private async refreshBrowseMusic(): Promise { - const browse = this.currentBrowse; - if (browse === null) return; - this.pushBrowseMusic(await this.browseMusicFor(browse.id)); - } - - /** - * The music to play for a browsed game: the card's own file when it is on the inserted card, else the - * copy in the history. "Only global ambience" suppresses BOTH — AssetReader applies it for the card, - * and the history copy is checked here (LibraryStore knows nothing about settings), so a history game - * cannot smuggle its theme past a setting that silenced the card games. - */ - private async browseMusicFor(id: string): Promise { - const manifest = this.games.find((m) => m.raw.id === id) ?? null; - if (manifest !== null) return this.assets.readMusicDataUrl(manifest); - if ((await this.deps.settings.read()).onlyGlobalAmbient) return null; - return (await this.deps.library.readBrowseAssets(id)).music; - } - - /** Applies a default-ambience change live: re-reads the track as a data URL and pushes it to the game - * window (the renderer crossfades; a card's own music still wins). */ - async setAmbientTrack(track: string | null): Promise { - this.currentAmbient = await this.assets.readAmbientDataUrl(track); - this.pushAmbient(this.currentAmbient); - } - - /** Pushes the default-ambience data URL (or null) to the game window. */ - private pushAmbient(url: string | null): void { - const browserWindow = this.deps.window.browserWindow; - if (browserWindow !== null && !browserWindow.isDestroyed()) { - browserWindow.webContents.send(IPC.ambientUpdate, url); - } - } - - - // ── Carousel list (the card's games + the play history) ──────────────────── - - /** Stores the current carousel list and pushes it to the window (null when there is nothing to show). */ - private setLibrary(library: GameLibrary | null): void { - this.currentLibrary = library; - const browserWindow = this.deps.window.browserWindow; - if (browserWindow !== null && !browserWindow.isDestroyed()) { - browserWindow.webContents.send(IPC.libraryUpdate, library); - } - } - - /** - * Rebuilds and pushes the carousel list: the inserted card's games first (they are the ones that can be - * launched right now), then the played history — each group most recently played first. No stats are - * read from disk here: the index caches launchCount/lastPlayedAt for exactly this (Р1), and the card's - * own games use the reconciled stats already in memory (statsById), falling back to the index for a - * game whose reconcile hasn't happened yet. - * - * A card game is listed even when the library has no record for it yet — the asset copy runs in the - * background after the window is already up, and the carousel must not wait for it. - */ - /** True while a card is being read — see cardLoadInFlight (the Customize-from-history guard). */ - isCardLoading(): boolean { - return this.cardLoadInFlight; - } - - /** Re-pushes the carousel row (GameConfigService calls it after a save from the history). */ - refreshLibraryRow(): void { - this.refreshLibrary(); - } - - private refreshLibrary(): void { - this.setLibrary(this.buildLibrary()); - } - - /** - * The same list, BUILT but not delivered — for the one caller that must decide something from it before - * the renderer sees it (loadCard: the cursor belongs to the row's first card, and the row has to reach - * the window AFTER that cursor does). - */ - private buildLibrary(): GameLibrary | null { - const activeIds = this.games.map((manifest) => manifest.raw.id); - const active = new Set(activeIds); - // TWO groups, each sorted on its own: the card's games first, then the local ones. Sorting the union - // in one pass would interleave them by date, and the card you just inserted would land behind a local - // game played more recently — the card is the thing the user physically acted on. - const activeGames = [ - ...this.orderedForCarousel(this.cardGames), - ...this.orderedForCarousel(this.games.filter((manifest) => manifest.source === 'pc')), - ]; - const games = [ - ...activeGames.map((game) => { - // `artRev` (the record's savedAt) changes only when the assets were actually re-copied, which is - // what lets the renderer keep its decoded covers cached and still pick up an edited gridImage. - const stored = this.deps.library.entry(game.id); - return { - id: game.id, - title: game.title, - active: true, - source: game.source, - ...(stored !== null ? { artRev: stored.savedAt } : {}), - ...(game.unconfigured === true ? { unconfigured: true as const } : {}), - }; - }), - ...this.deps.library - .entriesForCarousel(activeIds) - .filter((entry) => !active.has(entry.id)) - .map((entry) => ({ - id: entry.id, - title: entry.title, - active: false, - // A record written before `sourceKind` existed is a card's: the history was cards only until - // the PC library came along, and the field fills in the next time the game shows up. - source: entry.sourceKind ?? ('card' as const), - artRev: entry.savedAt, - })), - ]; - return games.length > 0 ? { games } : null; - } - - /** One source's games, most recently played first — the per-group ordering refreshLibrary applies. */ - private orderedForCarousel(manifests: readonly ResolvedManifest[]): readonly { - readonly id: string; - readonly title: string; - readonly source: ManifestSource; - readonly unconfigured?: true; - }[] { - return byRecentlyPlayed( - manifests.map((manifest) => ({ - id: manifest.raw.id, - title: manifest.raw.title, - source: manifest.source, - lastPlayedAt: - this.statsById.get(manifest.raw.id)?.lastPlayedAt ?? - this.deps.library.entry(manifest.raw.id)?.lastPlayedAt ?? - null, - ...(manifest.unconfigured === true ? { unconfigured: true as const } : {}), - })), - ); - } - - // ── Browse (what is on screen) ───────────────────────────────────────────── - - /** Stores the browsed game and pushes it to the window (null = nothing to show at all). */ - private pushBrowse(browse: BrowseInfo | null): void { - this.currentBrowse = browse; - const browserWindow = this.deps.window.browserWindow; - if (browserWindow !== null && !browserWindow.isDestroyed()) { - browserWindow.webContents.send(IPC.browseUpdate, browse); - } - } - - /** Pushes the browsed game's backgrounds. A SEPARATE channel from hero:update on purpose: that one - * keeps carrying the inserted card's selected game, so browsing can never overwrite (and strand) it. */ - private pushBrowseHero(assets: HeroAssets | null): void { - const browserWindow = this.deps.window.browserWindow; - if (browserWindow !== null && !browserWindow.isDestroyed()) { - browserWindow.webContents.send(IPC.browseHero, assets); - } - } - - /** Pushes the browsed game's music (music only — the SFX set is never rebuilt by browsing). */ - private pushBrowseMusic(url: string | null): void { - const browserWindow = this.deps.window.browserWindow; - if (browserWindow !== null && !browserWindow.isDestroyed()) { - browserWindow.webContents.send(IPC.browseMusic, url); - } - } - - /** Pushes the bundled UI sound set (every UI sound the app plays). */ - private pushSfxSet(): void { - const browserWindow = this.deps.window.browserWindow; - if (browserWindow !== null && !browserWindow.isDestroyed()) { - browserWindow.webContents.send(IPC.sfxSetUpdate, this.sfxSet); - } - } - - /** - * `library:browse` — the carousel moved onto `id`. Answers with the browse info + that game's assets. - * Deliberately does NOT touch `selectedIndex` or the AppState: looking at a game is not choosing it, so - * this works while another game installs (and with no card at all). - */ - private async onBrowseRequested(idRaw: unknown, immediateRaw: unknown): Promise { - // A launcher card is selected: nothing is on screen. The INFO goes out at once (the title and the - // status line must clear as promptly as they do between games), while the heavy half rides the same - // debounce a game's does — a flip PAST the launcher cards must not tear the background and the music. - if (idRaw === null) { - this.browsePinned = true; - this.pushBrowse(null); - this.scheduleBrowseAssets(null, immediateRaw === true); - return; - } - if (typeof idRaw !== 'string') return; - this.browsePinned = false; - await this.browseTo(idRaw, immediateRaw === true); - } - - /** - * `library:forget` — the user dropped a game from the history. REFUSED for a game that is available - * right now: the card's and the PC library's games are rebuilt from their manifests on every insert / - * library load, so forgetting one would achieve nothing but throwing its artwork away until the next - * refresh copies it back. The menu hides the item for those games; this is the same rule on the side - * that owns the data (the renderer's list is a view, not an authority). - */ - private async onForgetRequested(idRaw: unknown): Promise { - if (typeof idRaw !== 'string') return; - if (this.games.some((manifest) => manifest.raw.id === idRaw)) { - log.warn(`[library] refused to forget id=${idRaw}: the game is available right now`); - return; - } - if (!(await this.deps.library.forget(idRaw))) return; - this.refreshLibrary(); - // Only when it was the game ON SCREEN: reseeding otherwise would drag the cursor off whatever the - // user is looking at. With it gone the cursor lands on the next game, or on the empty screen. - if (this.currentBrowse?.id === idRaw) await this.reseedBrowse(); - } - - /** - * Builds and pushes BrowseInfo for `id` (from the card when it is there, else the history) and schedules - * its assets. The INFO goes out at once — the title, the stats and the status line hang off it, and a - * carousel whose name lags behind the highlighted card reads as broken — while the hero images and the - * music, which are megabytes each, are debounced so flipping through the strip doesn't read the disk - * once per step. - */ - private async browseTo(id: string, immediate = false): Promise { - const manifest = this.games.find((m) => m.raw.id === id) ?? null; - if (manifest !== null) { - const stats = this.statsById.get(id) ?? (await this.deps.stats.read(id)); - const info = await this.buildGameInfo(manifest, stats); - this.pushBrowse({ id, title: manifest.raw.title, active: true, stats, game: info }); - this.scheduleBrowseAssets(id, immediate); - return; - } - const entry = this.deps.library.entry(id); - if (entry === null) { - log.warn(`[browse] no game with id="${id}" on the card or in the history — ignoring`); - return; - } - const stats = await this.deps.stats.read(id); - // Read off the in-memory record, never off the disk: browseTo runs on EVERY step through the - // carousel, with no debounce in front of it. - const configurable = entry.cardSlotHash !== undefined && entry.sourceKind !== 'pc'; - this.pushBrowse({ - id, - title: entry.title, - active: false, - stats, - ...(configurable ? { configurable: true as const } : {}), - }); - this.scheduleBrowseAssets(id, immediate); - } - - /** - * Where the cursor moves on main's own initiative — a card inserted, a library reloaded, a session - * finished. Refused while the user is parked on a launcher card: the position in the row is theirs, and - * an inserted card yanking the screen off Settings is exactly what this exists to prevent. Everything - * else keeps working meanwhile (state:update, hero:update and card:music are pushed regardless), so the - * new card is on screen the moment the user steps back onto a game themselves. - */ - private async browseToUnlessPinned(id: string): Promise { - if (this.browsePinned) return; - await this.browseTo(id); - } - - /** Debounced read+push of the browsed game's hero/music; a newer browse cancels the pending one. - * `null` is the launcher-card case: the same debounce, pushing empty assets at the end of it. */ - private scheduleBrowseAssets(id: string | null, immediate = false): void { - if (this.browseAssetsTimer !== null) clearTimeout(this.browseAssetsTimer); - this.browseAssetsTimer = null; - // `immediate` is the renderer saying the user has COMMITTED to this game (opened its screen) rather - // than flipped onto it. Waiting out the debounce there means a quarter second of the previous game's - // background and music on a screen that is already the new game's. - if (immediate) { - void this.pushBrowseAssets(id); - return; - } - this.browseAssetsTimer = setTimeout(() => { - this.browseAssetsTimer = null; - void this.pushBrowseAssets(id); - }, BROWSE_ASSETS_DEBOUNCE_MS); - } - - private async pushBrowseAssets(id: string | null): Promise { - const seq = ++this.browseAssetsSeq; - // Checked before EVERY push, not just on entry. Each read below is megabytes off the disk plus a - // base64 encode, and the selection keeps moving while it runs — so a read started for a game the user - // flipped past would otherwise land on the game they stopped on, dragging its background, its colours - // and its music along. The sequence covers the other half: two reads in flight at once (a debounced - // one and an immediate one) can finish out of order, and only the newest may speak. - const current = (): boolean => - seq === this.browseAssetsSeq && - (id === null ? this.currentBrowse === null : this.currentBrowse?.id === id); - if (!current()) return; - // A launcher card: no background and no music of its own. The renderer answers an empty payload with - // the idle wallpaper and the global ambience (see hero.applyIdleBackground / audio.setIdle). - if (id === null) { - this.pushBrowseHero(null); - this.pushBrowseMusic(null); - return; - } - const manifest = this.games.find((m) => m.raw.id === id) ?? null; - if (manifest !== null) { - const hero = await this.assets.readHeroAssets(manifest); - if (!current()) return; - this.pushBrowseHero(hero); - const music = await this.assets.readMusicDataUrl(manifest); - if (!current()) return; - this.pushBrowseMusic(music); - return; - } - const assets = await this.deps.library.readBrowseAssets(id); - if (!current()) return; - // A history game with no hero of its own falls back to the wallpaper, exactly like a card game does - // (readHeroAssets). Without it this push carried `null`, the renderer had nothing to paint, and the - // PREVIOUS game's background stayed on screen under the new game's name. - const hero = assets.hero ?? (await this.wallpaperHero()); - if (!current()) return; - this.pushBrowseHero(hero); - const music = await this.browseMusicFor(id); - if (!current()) return; - this.pushBrowseMusic(music); - } - - /** The wallpaper as a one-image hero payload — the per-game fallback shared by both browse paths. */ - private async wallpaperHero(): Promise { - const wallpaper = await this.assets.readWallpaperDataUrl(); - return wallpaper === null ? null : { images: [wallpaper] }; - } - - /** - * Lets go of a `ready` state whose game this read no longer carries and which nothing can replace. - * - * `ready` is not merely a phase: it NAMES a GameInfo. Play launches that GameInfo, and the Details menu - * falls back to it whenever there is no game on screen at all (see screenGame in controls.ts) — so a - * state left pointing at a deleted game keeps offering "Install" for it, and Play would try to launch - * it. The retarget in reloadPcLibrary handles the ordinary case by moving the state onto another game; - * it cannot help with the case that leaves the ghost behind — deleting the LAST game, where there is no - * other game to move onto. Then the honest state is `idle`: the launcher is about nothing. - */ - private dropStateIfGameGone(): void { - const snapshot = this.deps.state.get(); - if (snapshot.kind !== 'ready') return; - if (this.games.some((manifest) => manifest.raw.id === snapshot.game.id)) return; - if (this.current() !== null) return; // there IS something to be about — the retarget names it - this.selectedId = null; - this.setHero(null); - this.setCardMusic(null); - this.steamWatch.stop(); - this.deps.state.set({ kind: 'idle' }); - } - - /** - * Whether the browse cursor's `active` flag has stopped matching reality — the game on screen is named - * as available while it is no longer in any manifest, or the other way round. It is the one field of - * BrowseInfo that a reload can invalidate WITHOUT moving the cursor, and the renderer decides what the - * Details menu offers by it. - */ - private browseIsStale(): boolean { - const browse = this.currentBrowse; - if (browse === null) return false; - return browse.active !== this.games.some((manifest) => manifest.raw.id === browse.id); - } - - /** - * Moves the browse cursor after the list changed (a card removed, an entry evicted): keep the current - * game if it is still listed, otherwise fall back to the first entry — or to nothing, which is the - * genuine "no card and no history" empty screen. - */ - private async reseedBrowse(): Promise { - const games = this.currentLibrary?.games ?? []; - const current = this.currentBrowse?.id; - const next = games.find((game) => game.id === current) ?? games[0]; - if (next === undefined) { - this.pushBrowse(null); - this.pushBrowseHero(null); - this.pushBrowseMusic(null); - return; - } - await this.browseToUnlessPinned(next.id); - } -} diff --git a/src/main/json-store.ts b/src/main/json-store.ts index e362d072..29090009 100644 --- a/src/main/json-store.ts +++ b/src/main/json-store.ts @@ -11,12 +11,10 @@ import type { z } from 'zod'; import { log } from './logger'; import { delay } from './util'; -function isMissingFile(cause: unknown): boolean { - return ( - cause instanceof Error && - 'code' in cause && - (cause as { readonly code?: unknown }).code === 'ENOENT' - ); +/** Whether a thrown value is the fs "no such file" error — the benign absence every store treats as + * a normal first run, as opposed to a read failure worth a breadcrumb. */ +export function isEnoent(cause: unknown): boolean { + return errorCode(cause) === 'ENOENT'; } /** @@ -32,7 +30,7 @@ export async function readJsonValidated( try { raw = await fse.readJson(filePath); } catch (cause) { - if (!isMissingFile(cause)) { + if (!isEnoent(cause)) { log.warn(`[store] failed to read "${filePath}", using default:`, cause); } return fallback; diff --git a/src/main/launch-args.ts b/src/main/launch-args.ts index 606be894..6f246b2b 100644 --- a/src/main/launch-args.ts +++ b/src/main/launch-args.ts @@ -3,7 +3,7 @@ // native FFI addon into any importer — impossible to unit-test in plain Node. These helpers are // pure string logic (quoting rules, installer flag families) with no koffi/electron dependency, so // they live here and can be covered directly. game-launcher.ts re-exports them for its own use. -import { type InstallerRunType } from '../shared/types'; +import type { InstallerRunType } from './manifest-types'; /** * Quotes a single argument for ShellExecuteEx's raw lpParameters command line, following the @@ -43,7 +43,7 @@ export function buildParameters(args: readonly string[]): string { * - `nsis` → `/S` … `/D=` — `/D=` MUST be last and always UNQUOTED (NSIS reads everything after * it, to end of line, as the path — even with spaces), on both platforms. * - `inno` → `/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /DIR=` … — Inno's `/DIR=` quoting is the - * ONLY platform-varying piece (Р7): see `quoteDir`. + * ONLY platform-varying piece: see `quoteDir`. * - `custom`→ the card author's own args, with `{dir}` substituted; they own the quoting/flags. * Extra `customArgs` for nsis/inno are appended (after the silent flags, before the trailing `/D=` for nsis). * diff --git a/src/main/launch-errors.ts b/src/main/launch-errors.ts new file mode 100644 index 00000000..d61e94e2 --- /dev/null +++ b/src/main/launch-errors.ts @@ -0,0 +1,11 @@ +// The abort signal of every launch / install / uninstall sequence, as a throwable. Lives on its own, +// away from game-launcher.ts, so the controller and its tests can name it without pulling the koffi +// FFI that module binds at import. + +/** Thrown by the process waits (and the sequences' own abort checks) when their AbortSignal fires. */ +export class LaunchAbortedError extends Error { + constructor() { + super('launch wait aborted'); + this.name = 'LaunchAbortedError'; + } +} diff --git a/src/main/library-index.ts b/src/main/library-index.ts index 64b0f8b4..4ad43af4 100644 --- a/src/main/library-index.ts +++ b/src/main/library-index.ts @@ -58,7 +58,7 @@ export interface LibraryIndex { export const EMPTY_LIBRARY_INDEX: LibraryIndex = { schemaVersion: 1, entries: [] }; /** Result of an upsert: the new index plus whether an entry for this id already existed under a - * DIFFERENT TITLE — a card-id collision between two cards, which the store logs (Р3). */ + * DIFFERENT TITLE — a card-id collision between two cards, which the store logs. */ export interface UpsertResult { readonly index: LibraryIndex; readonly replacedForeign: boolean; diff --git a/src/main/library-store.ts b/src/main/library-store.ts index 5de93e42..db269ad8 100644 --- a/src/main/library-store.ts +++ b/src/main/library-store.ts @@ -24,9 +24,10 @@ import path from 'node:path'; import fse from 'fs-extra'; import { nativeImage } from 'electron'; import { z } from 'zod'; -import type { HeroAssets, ResolvedManifest, Stats } from '../shared/types'; +import type { HeroAssets, Stats } from '../shared/types'; +import type { ResolvedManifest } from './manifest-types'; import { readAudioDataUrl, readImageDataUrl } from './asset-reader'; -import { readJsonValidated, writeFileAtomicEnsuringDir, writeJsonAtomic } from './json-store'; +import { isEnoent, readJsonValidated, writeFileAtomicEnsuringDir, writeJsonAtomic } from './json-store'; import { uniqueAssetFileName } from './asset-file-names'; import { assertImportableAsset, type ImportKind } from './asset-import'; import { slotHash, type GameSlot } from './history-config'; @@ -110,7 +111,7 @@ const indexSchema = z.object({ export interface LibraryStoreDeps { /** app.getPath('userData') — `library/` is created inside it. */ readonly baseDir: string; - /** Reads a game's authoritative stats (PcStore). The index only CACHES these numbers — see Р1. */ + /** Reads a game's authoritative stats (PcStore). The index only CACHES these numbers. */ readonly readStats: (id: string) => Promise; } @@ -158,7 +159,7 @@ export class LibraryStore { } } - /** The carousel order for the given card ids: the card's games first, then the played history (Р1). */ + /** The carousel order for the given card ids: the card's games first, then the played history. */ entriesForCarousel(activeIds: readonly string[]): readonly LibraryEntryRecord[] { return orderForCarousel(this.index.entries, activeIds); } @@ -204,7 +205,7 @@ export class LibraryStore { // (a false warning, and a full re-copy of every asset on every insert). const pristineTitle = (await this.readCardSlot(id))?.['title']; // The snapshot belongs to the CARD path alone: a PC-library game's slot speaks the `pc` dialect and - // would make a card unreadable if it were ever applied to one (see the plan, Р1). + // would make a card unreadable if it were ever applied to one. const cardSlotHash = manifest.source === 'card' && cardSlot !== undefined ? await this.takeCardSlot(id, cardSlot) @@ -301,17 +302,21 @@ export class LibraryStore { configuredAt: current?.configuredAt ?? null, collisionResolvedAt: current?.collisionResolvedAt ?? null, }; - const result = upsertEntry( - index, - record, - typeof pristineTitle === 'string' ? pristineTitle : undefined, - ); + // A PC-library game has no foreign card to collide with: its game.json IS the pristine text, so a + // title that differs from the record is the user's own rename, never another card's game. + const before = + typeof pristineTitle === 'string' + ? pristineTitle + : sourceKind === 'pc' + ? record.title + : undefined; + const result = upsertEntry(index, record, before); replacedForeign = result.replacedForeign; return result.index; }); if (replacedForeign) { // Two cards sharing a manifest id now overwrite each other's COVER AND NAME, not just their stats - // numbers — a new, visible class of mistake, so it gets a breadcrumb (Р3). + // numbers — a new, visible class of mistake, so it gets a breadcrumb. log.warn( `[library] id="${id}" already existed with a different title/source — the history entry was overwritten (colliding manifest ids across cards)`, ); @@ -558,7 +563,7 @@ export class LibraryStore { try { return await fse.readdir(this.stagedDir(id)); } catch (cause) { - if (!isNotFound(cause)) { + if (!isEnoent(cause)) { log.warn(`[library] cannot list the staged assets of id=${id}:`, describe(cause)); } return []; @@ -610,7 +615,7 @@ export class LibraryStore { try { names = await fse.readdir(gameDir); } catch (cause) { - if (!isNotFound(cause)) log.warn(`[library] cannot list "${gameDir}":`, describe(cause)); + if (!isEnoent(cause)) log.warn(`[library] cannot list "${gameDir}":`, describe(cause)); return; } for (const name of names) { @@ -679,16 +684,12 @@ async function readTextFile(filePath: string): Promise { try { return await fse.readFile(filePath, 'utf8'); } catch (cause) { - if (!isNotFound(cause)) log.warn(`[library] cannot read "${filePath}":`, describe(cause)); + if (!isEnoent(cause)) log.warn(`[library] cannot read "${filePath}":`, describe(cause)); return null; } } /** True for an "it isn't there" fs error — an absent history file is a normal state, not a failure. */ -function isNotFound(cause: unknown): boolean { - return typeof cause === 'object' && cause !== null && (cause as { code?: unknown }).code === 'ENOENT'; -} - /** * Narrows a validated (mutable) index entry to the domain record. An entry written by an older build may * still carry fields this one no longer knows (the `sounds` map of the card-supplied UI sounds, dropped diff --git a/src/main/main.ts b/src/main/main.ts index 92ba8c2c..0d8da702 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -13,15 +13,30 @@ import { StatsService } from './stats'; import { LibraryStore } from './library-store'; import { PcLibraryStore } from './pc-library'; import { DriveWatcher } from './drive-watcher'; -import { GameController } from './ipc'; +import { GameController } from './game-controller'; +import { ActivityRegistry } from './activity-registry'; +import { QuitGate, quitGraceMs, withGrace } from './quit-gate'; +import { quitQuestion } from '../shared/quit'; +import { + waitForExit, + waitForStart, + waitForSteamExit, + waitForSteamStart, + waitForWatchedExit, + waitForWatchedStart, +} from './game-launcher'; +import { focusGameWindow } from './window-finder'; import { GlobalGamepad } from './gamepad-global'; import { createTray, buildTrayMenu, type TrayCallbacks, type TraySteamState } from './tray'; import { createSteamShortcutService } from './steam-shortcut'; import { installDaemonUnit, removeDaemonUnit } from './daemon-unit'; import { UpdaterService } from './updater'; +import { SettingsService } from './settings-service'; import { NotificationsService } from './notifications'; import { NotificationsStore } from './notifications-store'; import { GameConfigService } from './game-config'; +import { FilePickerService } from './file-picker-service'; +import { GameMoveTransaction } from './game-move-transaction'; import { HttpClient } from './metadata/http'; import { MetadataService } from './metadata/service'; import { SteamProvider } from './metadata/steam'; @@ -84,7 +99,7 @@ function configureAutoLaunch(): void { } /** - * Linux autostart (Р11). Electron's app.setLoginItemSettings is macOS/Windows-only, so we write an XDG + * Linux autostart. Electron's app.setLoginItemSettings is macOS/Windows-only, so we write an XDG * autostart entry by hand. Only meaningful for the packaged AppImage (Exec points at $APPIMAGE) and only * in Desktop Mode — in Game Mode the app runs as a non-Steam game with no autostart mechanism, so we skip * it there. Best-effort: any failure is logged, never fatal. @@ -135,8 +150,11 @@ function openGamesFolder(): void { void shell.openPath(dir); } -function quit(): void { +/** The managed exit: waits a bounded grace for the running jobs to clean up, then leaves. */ +async function quit(): Promise { quitting = true; + const jobs = controllerRef?.jobs; + if (jobs?.anyActive() === true) await withGrace(jobs.abortAll('shutdown'), quitGraceMs()); controllerRef?.shutdown(); globalGamepadRef?.stop(); keepAwakeRef?.dispose(); @@ -180,8 +198,7 @@ async function bootstrap(): Promise { app.getPath('userData'), (next) => { steamGridDbKey = next.steamGridDbApiKey; - const bw = windowRef?.browserWindow ?? null; - if (bw !== null && !bw.isDestroyed()) bw.webContents.send(IPC.settingsUpdate, next); + windowRef?.send(IPC.settingsUpdate, next); }, // A settings write that fails leaves the user looking at a toggle that flipped back (or a language // that did not change) with no explanation — every setter logs its own cause, this says it on screen. @@ -217,10 +234,7 @@ async function bootstrap(): Promise { gameRunning: state.get().kind === 'running', }; }, - push: (channel, payload) => { - const bw = windowRef?.browserWindow ?? null; - if (bw !== null && !bw.isDestroyed()) bw.webContents.send(channel, payload); - }, + push: (channel, payload) => windowRef?.send(channel, payload), }); notificationsRef = notifications; // the settings store reports a failed write through it (see above) await notifications.init(); @@ -262,7 +276,7 @@ async function bootstrap(): Promise { getTranslator, }); - // Game Mode only (Р10), as a safety net: the gamescope session normally mounts an inserted card itself, + // Game Mode only, as a safety net: the gamescope session normally mounts an inserted card itself, // but one that arrives unmounted is invisible to the scan (it has no path to look under). Sweeping it // into /run/media keeps hot-swap working in that case. Windows and the KDE desktop session mount on // their own → no sweep wired there. @@ -272,8 +286,11 @@ async function bootstrap(): Promise { ); windowRef = window; + const activities = new ActivityRegistry(); + activities.init((channel, payload) => window.send(channel, payload)); const controller = new GameController({ state, + activities, window, store, stats, @@ -283,12 +300,41 @@ async function bootstrap(): Promise { settings, notifications, platform, + processControl: { + waitForStart, + waitForExit, + waitForWatchedStart, + waitForWatchedExit, + waitForSteamStart, + waitForSteamExit, + focusGameWindow, + }, isGamescope: gameModeSession, getTranslator, }); controllerRef = controller; controller.init(); + const quitGate = new QuitGate({ + activeJobs: () => controller.jobs.activeCount(), + showAndFocus: () => window.showAndFocus(), + askInWindow: (action) => window.send(IPC.quitConfirm, action), + askNatively: async (action, jobs) => { + const t = getTranslator(); + const { response } = await dialog.showMessageBox({ + type: 'question', + buttons: [t('common.no'), t('common.yes')], + defaultId: 0, + cancelId: 0, + message: quitQuestion(t, action, jobs), + }); + return response === 1; + }, + }); + ipcMain.on(IPC.quitConfirmReply, (_event, reply: unknown) => { + if (reply === 'shown' || reply === 'dismissed') quitGate.reply(reply); + }); + // Keep the display awake while the launcher owns the session. Single recompute point over two flags // (the setting + whether the window is on screen) plus the running AppState — main is single-threaded, // so the three sources (visibility / setting / state) can't race. The blocker itself is idempotent. @@ -304,11 +350,12 @@ async function bootstrap(): Promise { // `|| running` holds the blocker even if a game minimized our window into exclusive-fullscreen // (windowVisible would be false there), which is the whole point of covering the running state. const running = state.get().kind === 'running'; - keepAwake.setActive(preventScreensaverEnabled && (windowVisible || running)); + keepAwake.setActive(preventScreensaverEnabled && (windowVisible || running || controller.jobs.anyActive())); }; // Recompute on every state change so entering/leaving `running` toggles the blocker (the window-visibility // and setting sources push their own recompute). A second subscriber alongside the controller's replicator. state.subscribe(() => recomputeKeepAwake()); + activities.subscribe(() => recomputeKeepAwake()); // Update service. isBusy covers ALL in-flight states (not just a running game), so a manual install // can't tear down a save-sync / game install. beforeInstall drops the windows' close-guards @@ -316,14 +363,23 @@ async function bootstrap(): Promise { const updater = new UpdaterService({ settings, notifications, - isBusy: () => { - const kind = state.get().kind; - return kind !== 'idle' && kind !== 'ready' && kind !== 'error'; - }, + isBusy: () => controller.isBusy() || controller.jobs.anyActive(), beforeInstall: () => { quitting = true; window.allowClose(); }, + getTranslator, + }); + + // The Settings screen's backend: persists every settings:* write and hands each live side effect to + // the service that owns it (below). The two update-related settings reach electron-updater through + // the updater's guarded setters, so the updater no longer owns any settings channel itself. + const settingsService = new SettingsService({ + settings, + appVersion: () => app.getVersion(), + isSteamAvailable: () => steamShortcut.isAvailable(), + onAutoUpdateModeChanged: (mode) => updater.applyAutoUpdateMode(mode), + onPrereleaseChanged: (on) => updater.setAllowPrerelease(on), onSummonHotkeyChanged: (enabled) => { summonHotkeyEnabled = enabled; }, @@ -335,18 +391,13 @@ async function bootstrap(): Promise { // Game Mode auto-launch toggle (Steam Deck): installs or tears down the watcher unit. Turning it off // stops a separate process, so the memory is actually returned — that is the point of the option. onSteamAutoLaunchChanged: (enabled) => steamShortcut.applyAutoLaunch(enabled), - isSteamAvailable: () => steamShortcut.isAvailable(), - onVolumesChanged: (volumes) => { - const bw = window.browserWindow; - if (bw !== null && !bw.isDestroyed()) bw.webContents.send(IPC.volumeUpdate, volumes); - }, + onVolumesChanged: (volumes) => window.send(IPC.volumeUpdate, volumes), // The sound-set / ambience / only-global changes are re-read + re-pushed by the controller (it owns the // AssetReader and the game window) — the Settings screen only persisted the new value. onSoundSetChanged: () => void controller.refreshAudio(), onAudioScopeChanged: () => void controller.refreshAudio(), onAmbientChanged: (track) => void controller.setAmbientTrack(track), onLanguageChanged: (mode) => applyLanguage(mode), - getTranslator, }); // Backend of the launcher's Customize screen. getActiveRoot / reloadManifest / findGameSource come @@ -357,19 +408,36 @@ async function bootstrap(): Promise { pcLibrary, reloadPcLibrary: () => controller.reloadPcLibrary(), getTranslator, - toManifestPcSavePath: (absolute) => platform.savePathResolver.toManifestPcSavePath(absolute), findGameSource: (id) => controller.findGameSource(id), notify: (input) => notifications.notify(input), - resolveManifest: (id) => controller.findManifest(id), findPcManifest: (id) => controller.findPcManifest(id), - isBusy: () => controller.isBusy(), library, isCardLoading: () => controller.isCardLoading(), refreshLibrary: () => controller.refreshLibraryRow(), + }); + gameConfig.init(); + // The in-launcher file browser and the move-to-card transaction, both built on the root guard and the + // game.json reads/writes GameConfigService exposes (see the two modules' headers). + const filePicker = new FilePickerService({ + config: gameConfig, + getActiveRoot: () => watcher.getActiveRoot(), + pcLibrary, + getTranslator, + toManifestPcSavePath: (absolute) => platform.savePathResolver.toManifestPcSavePath(absolute), + }); + filePicker.init(); + const gameMove = new GameMoveTransaction({ + config: gameConfig, + getTranslator, + getActiveRoot: () => watcher.getActiveRoot(), + reloadManifest: (root) => controller.reloadManifest(root), + notify: (input) => notifications.notify(input), + resolveManifest: (id) => controller.findManifest(id), + isBusy: (id) => controller.isBusy(id), pcStore: store, savePathResolver: platform.savePathResolver, }); - gameConfig.init(); + gameMove.init(); // Both ways round: the service is built FROM the controller, and the controller's collision answer // (a game on the card and on this PC at once) is carried out BY the service. controller.setCollisionResolver(gameConfig); @@ -427,7 +495,7 @@ async function bootstrap(): Promise { recomputeKeepAwake(); }, // Game Mode: no tray to hide into, and Steam closes a non-Steam game by closing its window → let the - // close through and quit on window-all-closed (Р8, point 5). Desktop/Windows keep the hide-to-tray guard. + // close through and quit on window-all-closed. Desktop/Windows keep the hide-to-tray guard. { hideToTrayOnClose: !gameModeSession }, ); // The update status is pushed to the launcher, which is where the Settings screen lives now. Attached @@ -504,7 +572,7 @@ async function bootstrap(): Promise { onToggleSteamShortcut: () => { void (steamShortcut.isRegistered() ? steamShortcut.remove() : steamShortcut.add()); }, - onQuit: () => quit(), + onQuit: () => quitGate.request('quit', false, () => void quit()), }; // SteamOS Game Mode has no system tray (gamescope). Skip it there — the window is always shown and Steam // manages the app as a non-Steam game. Desktop Mode (KDE) and Windows keep the tray (refreshTrayMenu @@ -524,7 +592,7 @@ async function bootstrap(): Promise { } // UI-locale wiring. The launcher seeds via an invoke (effective Locale) and receives live pushes; the - // set-language SEND lives in UpdaterService (with the other settings:* writes). No did-finish-load hook + // set-language SEND lives in SettingsService (with the other settings:* writes). No did-finish-load hook // — the invoke-seed covers startup instead. ipcMain.handle(IPC.languageRequest, (): Locale => localeService.current()); @@ -533,30 +601,31 @@ async function bootstrap(): Promise { // the bootstrap quit() (drops the window close-guards), sleep suspends in place. const power = createPowerService({ backend: platform.powerBackend, - quit: () => quit(), - showError: (message) => { - const bw = window.browserWindow; - if (bw !== null && !bw.isDestroyed()) bw.webContents.send(IPC.errorShow, message); - }, + quit: () => void quit(), + showError: (message) => window.send(IPC.errorShow, message), getTranslator, }); - ipcMain.on(IPC.actionShutdown, () => void power.perform('shutdown')); - ipcMain.on(IPC.actionReboot, () => void power.perform('reboot')); + ipcMain.on(IPC.actionShutdown, (_event, confirmed: unknown) => + quitGate.request('shutdown', confirmed === true, () => void power.perform('shutdown')), + ); + ipcMain.on(IPC.actionReboot, (_event, confirmed: unknown) => + quitGate.request('reboot', confirmed === true, () => void power.perform('reboot')), + ); ipcMain.on(IPC.actionSleep, () => void power.perform('sleep')); // Game Mode "Close Playhook": full quit via the same bootstrap path as the tray Quit (drops the window // close-guards, disposes services). Only ever sent from the Game Mode power menu — Desktop keeps hiding. - ipcMain.on(IPC.actionQuit, () => quit()); + ipcMain.on(IPC.actionQuit, (_event, confirmed: unknown) => + quitGate.request('quit', confirmed === true, () => void quit()), + ); // Applies a language change everywhere: re-resolve the locale, rebuild the tray menu, re-title the // window and push the effective locale to the launcher. - // Called from the settings set-language handler and from resetSettings (both via UpdaterService deps). + // Called from the settings set-language handler and from resetSettings (both via SettingsService deps). function applyLanguage(mode: typeof initialSettings.language): void { localeService.setMode(mode); const locale = localeService.current(); refreshTrayMenu(); - const gameBw = window.browserWindow; - if (gameBw !== null && !gameBw.isDestroyed()) - gameBw.webContents.send(IPC.languageUpdate, locale); + window.send(IPC.languageUpdate, locale); } // Global Start+Back hotkey: re-summon the launcher when it's hidden (e.g. minimized to the tray @@ -565,15 +634,16 @@ async function bootstrap(): Promise { const globalGamepad = new GlobalGamepad(); globalGamepadRef = globalGamepad; globalGamepad.onChord(() => { - if (!summonHotkeyEnabled) return; // toggled off in the settings window + if (!summonHotkeyEnabled) return; // toggled off on the Settings screen window.showAndFocus(true); }); globalGamepad.start(); watcher.start(); configureAutoLaunch(); - // Registers all update:* / settings:* / app:version IPC synchronously, then (packaged only) wires - // autoUpdater + the periodic timer per the persisted auto-update mode. + // Registers the settings:* / app:version IPC, then the update:* IPC; the updater then (packaged only) + // wires autoUpdater + the periodic timer per the persisted auto-update mode. + settingsService.init(); updater.init().catch((cause: unknown) => log.error('[updater] init failed:', cause)); } @@ -597,7 +667,7 @@ if (!gotSingleInstanceLock) { // A background app doesn't quit when the window is closed/hidden — it lives in the tray. Exception: // SteamOS Game Mode has no tray and the window's close isn't guarded, so a real close means the user - // ended the (non-Steam) game → quit (Р8, point 5). + // ended the (non-Steam) game → quit. app.on('window-all-closed', () => { if (quitting || gameModeSession) app.quit(); }); diff --git a/src/main/manifest-issues.ts b/src/main/manifest-issues.ts new file mode 100644 index 00000000..a80a4838 --- /dev/null +++ b/src/main/manifest-issues.ts @@ -0,0 +1,37 @@ +// How a zod verdict on a manifest is worded for the user — the one place that turns an issue into a +// sentence, shared by the card reader (one line for the error popup) and the Customize validator (one +// line per field). A refine stores a MessageKey; a structural message is already localized via +// z.config in locale.ts; a MISSING required field gets its own wording, because zod's "expected string, +// received undefined" is what the form would otherwise show under an empty row. +import { type z } from 'zod'; +import { translateIssueMessage, type Translator } from '../shared/i18n/index'; + +/** The first issue as `where: what` — the card reader reports one line, not a list. */ +export function formatZodError(error: z.ZodError, item: unknown, t: Translator): string { + const first = error.issues[0]; + if (first === undefined) return t('manifest.invalid'); + const joined = first.path.join('.'); + const where = joined.length > 0 ? joined : '(root)'; + return `${where}: ${issueMessage(first, item, t)}`; +} + +/** What one zod issue says to the user; `item` is the parsed input the issue's path points into. */ +export function issueMessage(issue: z.core.$ZodIssue, item: unknown, t: Translator): string { + if ( + issue.code === 'invalid_type' && + issue.path.length > 0 && + valueAt(item, issue.path) === undefined + ) { + return t('manifest.fieldRequired', { field: issue.path.join('.') }); + } + return translateIssueMessage(issue.message, t); +} + +function valueAt(item: unknown, path: readonly PropertyKey[]): unknown { + let current: unknown = item; + for (const key of path) { + if (typeof current !== 'object' || current === null) return undefined; + current = (current as Record)[key]; + } + return current; +} diff --git a/src/main/manifest-types.ts b/src/main/manifest-types.ts new file mode 100644 index 00000000..2adb2bef --- /dev/null +++ b/src/main/manifest-types.ts @@ -0,0 +1,283 @@ +// The manifest domain model: what a card's (or the PC library's) game.json declares, and the RESOLVED +// form the launcher works with once every path has been checked. Main-only — the renderer edits a +// manifest as text and reads the launcher's view of a game through GameInfo / BrowseInfo — so it lives +// next to manifest.ts rather than in the shared contract. +import type { LocalizedText, GamePlatform, ManifestSource } from '../shared/types'; + +/** Manifest file name in the card root. */ +export const MANIFEST_FILENAME = 'game.json' as const; + +/** + * Optional `install` block in `game.json` (install mode). + * When present, the card carries an INSTALLER (not the game itself): the app runs it silently, + * feeding it the install directory through the installer's own dir-key, and only afterwards does + * `executable` resolve relative to that install directory (not the card root). See ResolvedManifest. + * + * `type: 'copy'` is the exception: nothing is installed and no process runs — the card carries the + * game's directory and the app copies it into the install dir ("move game to PC" in the UI). It reuses + * this block because everything AROUND the installer run is identical (install dir, Install/Play + * routing, requiresInstall, uninstall). + */ +export interface InstallManifest { + /** + * Path RELATIVE to the card root. For `nsis`/`inno`/`custom`: the installer file (e.g. setup.exe). + * For `copy`: the root of the game DIRECTORY to copy to the PC. + */ + readonly installer: string; + /** + * Installer family — decides how the install directory is passed silently: + * `nsis` → `/S /D=`, `inno` → `/VERYSILENT /DIR=""`, `custom` → caller-supplied `args` + * with a single `{dir}` placeholder. MSI is out of MVP (its dir-property name isn't standardized). + * `copy` runs no installer at all — the app copies `installer` (a directory) into the install dir. + */ + readonly type: 'nsis' | 'inno' | 'custom' | 'copy'; + /** + * Run the installer elevated (UAC). Forbidden for `custom` (the card would control elevated argv) + * and for `copy` (no process to elevate). + */ + readonly runAsAdmin: boolean; + /** + * For `custom`: the full argument list, with exactly one token containing the `{dir}` placeholder + * (the install directory is substituted in). For `nsis`/`inno`: optional EXTRA flags appended to the + * built-in silent + dir flags. Forbidden (must be empty) for `copy`. + */ + readonly args: readonly string[]; + /** + * Linux-only: extra winetricks verbs provisioned into the game's Wine prefix before the installer + * runs, on top of the app's baseline set (e.g. a skinned Inno installer needing `mfc42`/`gdiplus`, or a + * game needing `dotnet48`). Ignored on Windows. Empty by default (schema `.default([])`). + */ + readonly winetricks: readonly string[]; +} + +/** + * The install types that actually RUN an installer process, i.e. everything but `copy`. + * Narrowing a parameter to this makes the compiler PROVE that `copy` never reaches installer-argv or + * uninstaller code (where it would otherwise be silently mistaken for nsis) — the caller must rule it + * out explicitly. Preferred over a runtime throw: the guarantee holds at compile time. + */ +export type InstallerRunType = Exclude; + +/** Fields shared by every resolved install descriptor, whatever the type (see ResolvedInstall). */ +interface ResolvedInstallBase { + /** Absolute path on the card: the installer file, or — for `copy` — the game directory to copy. */ + readonly installerPath: string; + readonly runAsAdmin: boolean; + readonly args: readonly string[]; + /** + * Extra winetricks verbs provisioned into the game's Wine prefix before install, on top of the + * linux baseline set. Linux-only; ignored on Windows. Empty by default. + */ + readonly winetricks: readonly string[]; + /** + * Host-view of the app-controlled install directory: every fs op (pre-clean, uninstaller search, + * sweep) and the resolved `executable` live under it. win32: `%LOCALAPPDATA%\playhook\games\`; + * linux: `/drive_c/playhook/games/` (inside the game's Wine prefix). + */ + readonly dir: string; + /** + * Installer-view of the SAME directory, fed to the silent dir-arg (`/DIR=` / `/D=`). win32: identical + * to `dir`; linux: `C:\playhook\games\` — the path the installer sees under Wine. + */ + readonly installerDir: string; +} + +/** A resolved install that RUNS an installer — the only shape installer/uninstaller code accepts. */ +export interface ResolvedInstallerRun extends ResolvedInstallBase { + readonly type: InstallerRunType; +} + +/** A resolved `copy` install: `installerPath` is a DIRECTORY, copied wholesale into `dir`. */ +export interface ResolvedCopyInstall extends ResolvedInstallBase { + readonly type: 'copy'; +} + +/** + * Resolved install descriptor. A discriminated union on `type` so that ruling out `copy` narrows the + * whole descriptor — that is what lets the compiler prove `copy` never reaches installer-argv or + * uninstaller code, instead of it silently falling into the nsis branch. + */ +export type ResolvedInstall = ResolvedInstallerRun | ResolvedCopyInstall; + +/** + * Optional `steam` block in `game.json` (Steam mode). + * When present, the card is just a POINTER to a Steam app (by appid) — it carries no game files, + * only the manifest, cover art and optional saves. Launch/install go through `steam://` URIs + * (shell.openExternal), and "installed" is decided by Steam's own `.acf` state — NOT by a file on + * the card. A separate backend from install mode (no card installer, no app-controlled dir). + */ +export interface SteamManifest { + /** The Steam application id. For base games `rungameid == appid`. */ + readonly appid: number; +} + +/** + * Optional `pc` block in `game.json` (PC mode — a game already installed on this machine's disk). + * Only valid in the PC library (`/pc-games/game.json`): it is the one place a manifest may + * name an ABSOLUTE path, because there is no card root to be relative to. Mutually exclusive with + * `install`/`steam`/`executable`/`saveOnCard` (enforced by the schema). + */ +export interface PcManifest { + /** + * ABSOLUTE path to the game's .exe on this PC. Its existence is NOT checked at read time: a game + * deleted from disk keeps its library card (art, stats, save backup) and is merely `unavailable` — + * exactly like an install-mode game that isn't installed yet. + */ + readonly executable: string; +} + +/** + * Raw `game.json` manifest after zod-schema validation. + * The executable/saveOnCard paths and each heroImage entry are relative to the SD root; + * pcSavePath is absolute with an env prefix from the whitelist. + */ +export interface GameManifest { + readonly schemaVersion: 1; + readonly id: string; + readonly title: string; + /** Card-relative path to the game/launcher .exe. Omitted in Steam mode (launch goes via steam://). */ + readonly executable?: string; + readonly args: readonly string[]; + /** Launch the .exe elevated (UAC "runas") for executables whose manifest requires administrator. */ + readonly runAsAdmin: boolean; + /** + * Image names of the GAME's own processes (e.g. ["Game-Win64-Shipping.exe"]) for launcher/wrapper + * setups where `executable` spawns a launcher that starts the game in a separate process and exits. + * When set, liveness is tracked by these names (presence in `tasklist`), not (only) by the spawned + * launcher's pid. When omitted, behaviour is unchanged — the pid path stays the default for + * self-contained .exe games. + * + * The `.exe` suffix is optional: a native macOS process has none, and steam mode requires this field. + * Keep `*.exe` names on a card meant to travel (the macOS matcher normalizes the suffix away, so one + * spelling works on all three OSes); a bare name is for a mac-only record. See the schema in manifest.ts. + */ + readonly watchProcesses?: readonly string[]; + /** + * Card-relative hero background image(s). Accepts a single path OR a non-empty array of paths. + * When several are given, the renderer cross-fades between them (GTA-5-style loading rotation). + * Normalized to an array of resolved paths in ResolvedManifest.heroImagePaths. + */ + readonly heroImage?: string | readonly string[]; + /** + * Card-relative GRID image — the game's card in the launcher's history carousel (a portrait cover, not a + * background). Optional: when absent the carousel falls back to the first heroImage, cropped to the card + * (object-fit: cover), so existing cards keep working unchanged. + */ + readonly gridImage?: string; + readonly saveOnCard?: string; + readonly pcSavePath?: string; + readonly launchTimeoutSec: number; + /** + * How many seconds a force-close (More → Force close) waits for the game's processes to actually + * disappear before reporting a failure. A killed process lingers in `tasklist` for a moment (and a + * launcher/wrapper may take longer to tear down), so this is the MAX wait — the wait ends early the + * instant every target process is gone. Default 60. Raise it for games that shut down slowly. + */ + readonly killTimeoutSec: number; + /** + * Optional install mode: when set, the card holds an installer and `executable` is interpreted + * relative to the install directory (controlled by the app), not the card root. See InstallManifest. + */ + readonly install?: InstallManifest; + /** + * Optional Steam mode: when set, the card is a pointer to a Steam app (by appid) and there are no + * game files on the card — launch/install go through `steam://` URIs. Mutually exclusive with + * `install`/`executable` and requires `watchProcesses` (enforced by the schema). See SteamManifest. + */ + readonly steam?: SteamManifest; + /** + * Optional PC mode: the game already lives on this machine's disk and `pc.executable` is its absolute + * path. Accepted ONLY in the PC library (see ManifestSource); mutually exclusive with + * `install`/`steam`/`executable`/`saveOnCard`. See PcManifest. + */ + readonly pc?: PcManifest; + /** Optional looping background music (card-relative path), played while the window is visible. */ + readonly backgroundMusic?: string; + /** + * Optional localized description of the game (en/ru), filled by the "Find online" flow. Nothing in the + * UI reads it yet — it is stored now so the data exists when a screen for it does. Parsed leniently: a + * malformed value is dropped, never a reason to reject the manifest (see manifest.ts). + */ + readonly description?: LocalizedText; + /** Genres, in the English store's wording. Same deal as `description`: stored now, shown later. */ + readonly genres?: readonly string[]; + /** Release date, `YYYY-MM-DD` or `YYYY`. Stored now, shown later. */ + readonly releaseDate?: string; + /** Platforms the store states native support for. Stored now, shown later. */ + readonly platforms?: readonly GamePlatform[]; + /** + * Linux-only: extra winetricks verbs provisioned into the game's Wine prefix before the game + * launches, on top of the app's baseline set (e.g. `d3dx9` for an old DX9 title). Ignored on Windows. + * Empty by default (schema `.default([])`). + */ + readonly winetricks: readonly string[]; + /** + * Linux-only: the umu `GAMEID` used when launching the game — a Steam appid or a custom UMU_ID — + * so umu applies that game's protonfix instead of the generic `umu-default`. Absent → `umu-default`. + * Ignored on Windows. + */ + readonly umuGameId?: string; +} + +/** + * Manifest with already-resolved and security-checked paths. + * All *Path values are absolute; the card's relative paths are verified to stay + * "inside the root", and pcSavePath is expanded from the env whitelist. + */ +export interface ResolvedManifest { + readonly raw: GameManifest; + readonly root: string; + /** + * Which root this manifest was read from — a card, or the PC library. Set in exactly one place (the + * resolver), and branched on wherever "is this game's source available?" differs: a card game needs its + * card inserted, a PC game is always there. See ManifestSource. + */ + readonly source: ManifestSource; + /** + * The effective launch target. In install mode this is `/` (and `cwd` its + * dirname) — it may NOT exist yet (that is exactly the "not installed" state). For a normal game it + * is `/`, verified to exist at read time. + * + * In Steam mode there is no card executable, so both are empty strings (`''`). They are NEVER read + * in Steam mode: every consumer (launchGame, pollForExecutable, the buildGameInfo existence check) + * branches on `steam` first. Kept as required `string` on purpose — making them optional would ripple + * type errors into the hot normal/install paths whose only fix is a non-null assertion (banned). + */ + readonly executablePath: string; + readonly cwd: string; + /** Resolved, card-relative hero image paths (normalized to an array when at least one is set). */ + readonly heroImagePaths?: readonly string[]; + /** Resolved, card-relative grid (carousel card) image path. Absent → the carousel falls back to hero. */ + readonly gridImagePath?: string; + readonly saveOnCardPath?: string; + /** + * The Windows-dictionary save location (`%APPDATA%\…`), stored VERBATIM — a DEFERRED field. + * Only its syntax is validated at read time (prefix allowlist + no traversal); the physical folder is + * resolved per-game at sync time via the platform SavePathResolver. This matters on Linux, where the + * real location lives inside the game's Wine prefix / Steam compatdata and may not exist until the first + * launch — resolving it eagerly (and rejecting the card when absent) would break install/steam modes. + */ + readonly pcSavePath?: string; + /** Resolved background-music file path. */ + readonly backgroundMusicPath?: string; + /** Resolved install descriptor (install mode only). */ + readonly install?: ResolvedInstall; + /** Resolved Steam descriptor (Steam mode only). When present, launch/install go through steam://. */ + readonly steam?: { + readonly appid: number; + }; + /** PC library only: no launch method chosen yet — the game is visible but cannot be started. */ + readonly unconfigured?: true; +} + +/** + * What to launch, decoupled from a manifest so both a game launch and an installer launch reuse the + * same backend (normal spawn vs elevated ShellExecuteEx). The args are FINAL tokens — any installer + * quoting is already baked in (see buildInstallerArgs), so the backend passes them through verbatim. + */ +export interface LaunchTarget { + readonly file: string; + readonly args: readonly string[]; + readonly cwd: string; + readonly runAsAdmin: boolean; +} diff --git a/src/main/manifest.ts b/src/main/manifest.ts index b38fa330..0ef7de0b 100644 --- a/src/main/manifest.ts +++ b/src/main/manifest.ts @@ -12,15 +12,17 @@ import path from 'node:path'; import fse from 'fs-extra'; import { z } from 'zod'; import { - MANIFEST_FILENAME, MAX_HERO_IMAGES, - type GameManifest, type ManifestSource, type ManifestValidationIssue, type ConfigValidationResult, - type ResolvedManifest, } from '../shared/types'; -import { translateIssueMessage, type Translator } from '../shared/i18n/index'; +import { MANIFEST_FILENAME, type GameManifest, type ResolvedManifest } from './manifest-types'; +import { type Translator } from '../shared/i18n/index'; +import { formatZodError, issueMessage } from './manifest-issues'; +import { type InstallDirResolver } from './platform/types'; +import { isEnoent } from './json-store'; +import { describe } from './util'; import { log } from './logger'; // Install-mode block (optional). When present, the card holds an installer and `executable` is @@ -40,7 +42,7 @@ const installSchema = z // For `custom`: the full argv with exactly one {dir} token. For nsis/inno: optional extra flags. // Forbidden for `copy` (no process is started). args: z.array(z.string()).default([]), - // Linux-only (Р7b): extra winetricks verbs/settings provisioned into the game's Wine prefix BEFORE the + // Linux-only: extra winetricks verbs/settings provisioned into the game's Wine prefix BEFORE the // installer runs, on top of the app's baseline set. Lets a card cover runtimes its installer needs // (e.g. a skinned Inno installer needing mfc42/gdiplus) or a setting like `vd=1920x1080`. Ignored on // Windows. Strictly validated (`=` allowed for `key=value` settings; shell-less execFile — defense in depth). @@ -53,7 +55,7 @@ const installSchema = z // surface beyond the read-only tasklist we use today. The app builds nsis/inno args itself, so // elevated is fine there. // Custom messages are stored as dictionary KEYS (translated later at the issue-mapping points via - // translateIssueMessage — see formatZodError / validateManifestText). The schema is module-private, so + // translateIssueMessage — see manifest-issues.ts / validateManifestText). The schema is module-private, so // it is never rebuilt per locale. .refine((v) => !(v.type === 'custom' && v.runAsAdmin), { message: 'manifest.installRunAsAdminCustom', @@ -126,7 +128,7 @@ const manifestSchema = z // guard against accidental generic names. `.min(1)` rejects an empty array (defense in depth vs the // `?.length` branch in ipc). Names are compared case-insensitively (lower-cased) at match time. // - // The `.exe` suffix is OPTIONAL rather than required (Д5): a native macOS binary is not called + // The `.exe` suffix is OPTIONAL rather than required: a native macOS binary is not called // `*.exe`, and steam mode REQUIRES watchProcesses, so demanding the suffix would make steam mode // impossible on macOS. The convention that goes with it: a CROSS-PLATFORM card stores `*.exe` names // (that is what Windows and Proton both run, and the darwin matcher normalizes the suffix away, so @@ -178,7 +180,7 @@ const manifestSchema = z .optional() .catch(undefined), platforms: z.array(z.enum(['windows', 'mac', 'linux'])).optional().catch(undefined), - // Linux-only (Р7b): extra winetricks verbs/settings provisioned into the game's Wine prefix BEFORE the + // Linux-only: extra winetricks verbs/settings provisioned into the game's Wine prefix BEFORE the // game launches, on top of the app's baseline set — a runtime a game needs on a bare Proton prefix // (e.g. `d3dx9`) OR a winetricks SETTING like `vd=1920x1080` (virtual desktop — fixes old games that // crash on a fullscreen display-mode change). Ignored on Windows. Strictly validated (`=` allowed for @@ -186,7 +188,7 @@ const manifestSchema = z winetricks: z .array(z.string().regex(/^[A-Za-z0-9_.=-]+$/, 'manifest.winetricksName')) .default([]), - // Linux-only (Р7i): the umu `GAMEID` used when LAUNCHING the game — a Steam appid (e.g. `814380`) or a + // Linux-only: the umu `GAMEID` used when LAUNCHING the game — a Steam appid (e.g. `814380`) or a // custom UMU_ID (e.g. `umu-nfsu2`), so umu applies that game's protonfix instead of the generic // `umu-default`. Absent → `umu-default` (current behaviour). Ignored on Windows. umuGameId: z.string().regex(/^[A-Za-z0-9_-]+$/, 'manifest.umuGameIdName').optional(), @@ -319,7 +321,7 @@ const ENV_PREFIXES = ['APPDATA', 'LOCALAPPDATA', 'USERPROFILE'] as const; export function resolveInside(root: string, relative: string): string | null { // Card-relative manifest paths are authored on Windows and may use `\` (e.g. `"bin\\game.exe"`). On // Linux `\` is NOT a separator, so `path.resolve` would treat the whole thing as one filename with - // literal backslashes → "not found" and the card falls apart (Р12). Normalize `\`→`/` on BOTH platforms + // literal backslashes → "not found" and the card falls apart. Normalize `\`→`/` on BOTH platforms // (Windows `path.resolve` already accepts both, so its behaviour is unchanged) before resolving. const normalized = relative.replaceAll('\\', '/'); if (path.isAbsolute(normalized)) return null; @@ -336,7 +338,7 @@ export function resolveInside(root: string, relative: string): string | null { * `executable` must be relative to that directory. But a card author (or the Configure form on manual * entry, unlike its Browse picker which already trims) often gives it card-root-relative — INCLUDING the * source dir, e.g. source `game`, executable `game/game.exe`. Strip a leading `/` so both spellings - * resolve to the same copied file. Separators are normalized first (Р12: a Windows card uses `\`). No-op + * resolve to the same copied file. Separators are normalized first (a Windows card uses `\`). No-op * when the executable isn't under the source (already relative to it, or an unrelated path). Exported for * unit tests. */ @@ -349,7 +351,7 @@ export function stripCopySourcePrefix(executable: string, source: string): strin } /** - * One-level case-insensitive lookup for a diagnostic hint when a card-relative file isn't found (Р12). + * One-level case-insensitive lookup for a diagnostic hint when a card-relative file isn't found. * On a case-sensitive FS (ext4/Linux) a Windows-authored `"Game.EXE"` won't match the on-disk `game.exe` * → the existence check fails. This scans the parent directory for an entry that differs from the wanted * name only in case and returns it, so the error can say "found game.exe, fix the case". Platform-agnostic @@ -448,15 +450,6 @@ export function absoluteToPcSavePath(absolute: string, env: ManifestEnv): string return null; } -function formatZodError(error: z.ZodError, t: Translator): string { - const first = error.issues[0]; - if (first === undefined) return t('manifest.invalid'); - const joined = first.path.join('.'); - const where = joined.length > 0 ? joined : '(root)'; - // A schema refine stores a MessageKey; a structural zod message is already localized via z.config. - return `${where}: ${translateIssueMessage(first.message, t)}`; -} - type InstallResolveResult = | { readonly ok: true; @@ -466,30 +459,10 @@ type InstallResolveResult = } | { readonly ok: false; readonly message: string }; -/** - * The app-controlled install directory in BOTH views (Р7). On win32 they are identical - * (`%LOCALAPPDATA%\playhook\games\`); on linux they diverge: - * - `hostDir` — the real filesystem path inside the game's Wine prefix - * (`/drive_c/playhook/games/`): every fs op and the resolved `executable` live under it; - * - `installerDir` — the SAME location as the installer sees it under Wine (`C:\playhook\games\`), - * fed to the silent dir-arg (`/DIR=` / `/D=`). - */ -export interface InstallDir { - readonly hostDir: string; - readonly installerDir: string; -} - -/** - * Platform install-dir resolution, injected into readManifests (Р7): maps a game `id` to both views of - * its app-controlled install dir, or null when install mode is unsupported on this platform/config - * (win32 with `%LOCALAPPDATA%` unset). `id` is already validated as a safe single path segment. - */ -export type InstallDirResolver = (id: string) => InstallDir | null; - /** * Resolves the install-mode block: verifies the installer exists on the card, derives the * app-controlled install dir via the platform `resolveInstallDir` (win32 `%LOCALAPPDATA%\…`; linux the - * game's Wine prefix — Р7), and resolves `executable` RELATIVE to its HOST view (traversal forbidden, + * game's Wine prefix), and resolves `executable` RELATIVE to its HOST view (traversal forbidden, * existence NOT checked — its absence is exactly the "not installed" state). */ async function resolveInstall( @@ -513,7 +486,7 @@ async function resolveInstall( return { ok: false, message: t('manifest.installerNotFound', { path: install.installer }) }; } - // The install dir is platform-specific (Р7): win32 derives `%LOCALAPPDATA%\playhook\games\`; + // The install dir is platform-specific: win32 derives `%LOCALAPPDATA%\playhook\games\`; // linux places it inside the game's Wine prefix. null → install mode is unsupported here (e.g. // `%LOCALAPPDATA%` absent) and the card is rejected, exactly as the pre-port Windows-only check did. const dirs = resolveInstallDir(id); @@ -559,20 +532,12 @@ export interface ManifestReadOptions { } /** True for an "the file isn't there" fs error — the one read failure that is a normal state, not damage. */ -function isNotFound(cause: unknown): boolean { - return ( - typeof cause === 'object' && - cause !== null && - (cause as { code?: unknown }).code === 'ENOENT' - ); -} - /** * Reads and fully validates ALL games on the card. `game.json` may hold a single object (legacy * single-game — behaves exactly as before) or a non-empty array of game objects (multi-game). Reads the * file once, normalizes to a list, and resolves each entry. * - * Failure policy (see the plan): a STRUCTURAL problem (unreadable file, top-level not an object/array, + * Failure policy: a STRUCTURAL problem (unreadable file, top-level not an object/array, * empty array) is fatal. A single game that fails to resolve (e.g. a normal-mode executable missing on * disk) is SKIPPED with a breadcrumb so one broken entry doesn't kill a multi-game card; if NONE resolve, * the card is fatal with the first skip reason (so a single-game card keeps its precise message — BC). @@ -595,7 +560,7 @@ export async function readManifests( // No PC library file yet is the normal first run — an empty library, not a failure. Every OTHER read // problem (unparsable JSON, EACCES) stays an error for both sources: silently swallowing corrupted // user data is exactly what the error-handling convention forbids. - if (source === 'pc' && isNotFound(cause)) return { ok: true, manifests: [] }; + if (source === 'pc' && isEnoent(cause)) return { ok: true, manifests: [] }; return { ok: false, message: t('errors.cannotReadManifest', { file: MANIFEST_FILENAME, cause: describe(cause) }), @@ -657,7 +622,7 @@ async function resolveOne( const { t } = env; const parsed = manifestSchema.safeParse(rawParsed); if (!parsed.success) { - return { ok: false, message: formatZodError(parsed.error, t) }; + return { ok: false, message: formatZodError(parsed.error, rawParsed, t) }; } const raw: GameManifest = parsed.data; @@ -718,7 +683,7 @@ async function resolveOne( steamResolved = { appid: raw.steam.appid }; } else if (source === 'pc') { // Draft: no pc, no steam, and (per the checks above) no executable/install either — the game is - // visible in the PC library but has no configured way to launch it yet (Р1 — unconfigured launch). + // visible in the PC library but has no configured way to launch it yet. executablePath = ''; cwd = ''; unconfigured = true; @@ -734,7 +699,7 @@ async function resolveOne( } if (!(await fse.pathExists(resolved))) { // On ext4 a wrong-case executable (Windows-authored `Game.EXE` vs on-disk `game.exe`) reads as - // missing — add a case-fix hint when exactly that is the case (Р12). + // missing — add a case-fix hint when exactly that is the case. const found = await findCaseInsensitiveName(resolved); if (found !== null) { return { @@ -810,7 +775,7 @@ async function resolveOne( let pcSavePath: string | undefined; if (raw.pcSavePath !== undefined) { - // Deferred resolution (Р5/Э6): validate only the SYNTAX here (prefix allowlist + no traversal). The + // Deferred resolution: validate only the SYNTAX here (prefix allowlist + no traversal). The // physical folder is resolved per-game at sync time via the platform SavePathResolver — on Linux a // location inside the game's Wine prefix / Steam compatdata that may not exist until first launch, // which must NOT reject the card at read time. The stored value is the Windows-dictionary string. @@ -936,7 +901,7 @@ function pushIfEscapes( * Beyond the traversal / pcSave / pairing checks that mirror readManifest, this enforces the multi-game * POLICY that every game must carry ≥1 heroImage. That is intentionally editor-only (it gates Save) and * NOT in the runtime readManifests path, which stays lenient (a hero-less legacy card still loads via the - * wallpaper fallback) — see the plan, decision 3. + * wallpaper fallback). */ function pushGameSemanticIssues( issues: ManifestValidationIssue[], @@ -998,7 +963,7 @@ function pushGameSemanticIssues( // Multi-game policy: a hero image is required for every game (editor-only gate — see above). issues.push({ path: field('heroImage'), message: t('manifest.heroRequired') }); } - // gridImage stays OPTIONAL (Р12): only its traversal is checked. The "without it the carousel card is + // gridImage stays OPTIONAL: only its traversal is checked. The "without it the carousel card is // cropped from hero" advice is a static hint under the field in Configure — an issue here would gate // Save and turn every existing card invalid, which is exactly what the optionality is for. if (raw.gridImage !== undefined) { @@ -1030,6 +995,23 @@ function pushGameSemanticIssues( * semantic checks (zod's superRefine issues only appear after the base schema passes). The schema stays * module-private — only this pure function is exported, so there is a single source of truth. */ +/** + * The items of a manifest text as the file lists them: the single game object, or every element of the + * array form — the one top-level shape game.json takes. Null when the text is not JSON at all. The shape + * of each item is NOT checked here; that is the caller's business (validation, a lookup by id, a count). + * A UTF-8 BOM is dropped first: Notepad writes one, `JSON.parse` refuses it, and `fse.readJson` (which + * readManifests still uses) strips it silently — so a card that loads there must parse here too. + */ +export function parseManifestItems(text: string): readonly unknown[] | null { + let parsed: unknown; + try { + parsed = JSON.parse(text.startsWith('\uFEFF') ? text.slice(1) : text) as unknown; + } catch { + return null; + } + return Array.isArray(parsed) ? (parsed as readonly unknown[]) : [parsed]; +} + export function validateManifestText( text: string, t: Translator, @@ -1067,10 +1049,9 @@ export function validateManifestText( if (!result.success) { for (const issue of result.error.issues) { const joined = issue.path.join('.'); - // A refine stores a MessageKey; a structural zod message is already localized via z.config. issues.push({ path: joined.length > 0 ? `${prefix}${joined}` : rootPath, - message: translateIssueMessage(issue.message, t), + message: issueMessage(issue, item, t), }); } return; // can't run semantic checks without parsed data @@ -1118,8 +1099,3 @@ export function manifestJsonSchema(): unknown { const objectSchema = z.toJSONSchema(manifestSchema, { unrepresentable: 'any', io: 'input' }); return { oneOf: [objectSchema, { type: 'array', items: objectSchema, minItems: 1 }] }; } - -function describe(cause: unknown): string { - if (cause instanceof Error) return cause.message; - return String(cause); -} diff --git a/src/main/metadata/service.ts b/src/main/metadata/service.ts index 671548a7..fc23bf9e 100644 --- a/src/main/metadata/service.ts +++ b/src/main/metadata/service.ts @@ -33,7 +33,7 @@ import { type MusicAlbum, type MusicTrack, } from '../../shared/types'; -import { type Translator } from '../../shared/i18n/index'; +import { translateIssueMessage, type Translator } from '../../shared/i18n/index'; import { AUDIO_EXTENSIONS, IMAGE_EXTENSIONS } from '../asset-reader'; import { resolveInside } from '../manifest'; import { type PcLibraryStore } from '../pc-library'; @@ -425,7 +425,14 @@ export class MetadataService { { page: next, minSize: QUALITY_FLOOR[pool.filter.quality] }, signal, ); - return answer === undefined ? [] : [answer.then((result) => ({ id: provider.id, result }))]; + return answer === undefined + ? [] + : [ + answer.then((result) => ({ + id: provider.id, + result: this.noted(provider.id, result), + })), + ]; }); return Promise.all(asked); }); @@ -703,12 +710,28 @@ export class MetadataService { ) => Promise> | undefined, ): Promise[]> { return this.run(async (signal) => { - const asked = this.deps.providers.map((provider) => ask(provider, signal)); - const answers = await Promise.all(asked.filter((answer) => answer !== undefined)); - return answers; + const asked = this.deps.providers.flatMap((provider) => { + const answer = ask(provider, signal); + return answer === undefined + ? [] + : [answer.then((result) => this.noted(provider.id, result))]; + }); + return Promise.all(asked); }); } + /** + * A refused answer leaves a breadcrumb and comes back localized. The other sources usually cover for + * the failed one, so the user never sees it — which is exactly why the log must: a rejected + * SteamGridDB key, say, would otherwise look like a source with nothing to offer. + */ + private noted(id: MetadataProviderId, result: MetadataResult): MetadataResult { + if (result.ok) return result; + const message = translateIssueMessage(result.message, this.deps.getTranslator()); + log.warn(`[metadata] ${id}: ${message}`); + return { ok: false, message }; + } + /** Runs one piece of work under a fresh AbortController that `metadata:cancel` can reach. */ private async run(work: (signal: AbortSignal) => Promise): Promise { const controller = new AbortController(); diff --git a/src/main/metadata/steamgriddb.ts b/src/main/metadata/steamgriddb.ts index 3a63069a..4036ff52 100644 --- a/src/main/metadata/steamgriddb.ts +++ b/src/main/metadata/steamgriddb.ts @@ -100,6 +100,16 @@ export function toArtworkOffers(items: readonly SgdbArtItem[]): readonly Artwork })); } +/** + * A 401/403 from this host is the key being refused, not the game being unknown — said as a MessageKey + * (the service translates it, as manifest.ts does for its refines) rather than as the raw URL + status. + */ +function keyRejection(message: string): MetadataResult | undefined { + return /: HTTP 40[13]$/.test(message) + ? { ok: false, message: 'metadata.steamGridDbKeyRejected' } + : undefined; +} + export interface SteamGridDbDeps { readonly http: HttpClient; /** Read live: the user can paste a key while the app runs, and the next search must already use it. */ @@ -123,7 +133,7 @@ export class SteamGridDbProvider implements MetadataProvider { const options = this.options(signal); if (options === undefined) return { ok: true, value: [] }; const answer = await this.deps.http.json(autocompleteUrl(query), searchSchema, options); - if (!answer.ok) return answer; + if (!answer.ok) return keyRejection(answer.message) ?? answer; return { ok: true, value: answer.value.data.map((item) => ({ @@ -148,7 +158,7 @@ export class SteamGridDbProvider implements MetadataProvider { const target = this.artRef(ref); if (target === undefined) return nothing; const answer = await this.deps.http.json(coversUrl(target), artworkSchema, options); - if (!answer.ok) return answer; + if (!answer.ok) return keyRejection(answer.message) ?? answer; return { ok: true, value: { offers: toArtworkOffers(answer.value.data), hasMore: false } }; } diff --git a/src/main/notifications-store.ts b/src/main/notifications-store.ts index 6c07a5b7..dfd31ba1 100644 --- a/src/main/notifications-store.ts +++ b/src/main/notifications-store.ts @@ -36,6 +36,24 @@ const notificationSchema = z.discriminatedUnion('kind', [ gameId: z.string(), gameTitle: z.string(), }), + z.object({ + kind: z.literal('game-install-failed'), + id: z.string(), + at: z.number(), + read: z.boolean(), + gameId: z.string(), + gameTitle: z.string(), + reason: z.string(), + }), + z.object({ + kind: z.literal('game-uninstall-failed'), + id: z.string(), + at: z.number(), + read: z.boolean(), + gameId: z.string(), + gameTitle: z.string(), + reason: z.string(), + }), z.object({ kind: z.literal('game-added-deferred'), id: z.string(), diff --git a/src/main/notifications.ts b/src/main/notifications.ts index 52339ba3..60125d4b 100644 --- a/src/main/notifications.ts +++ b/src/main/notifications.ts @@ -8,7 +8,7 @@ // in the controller". import { randomUUID } from 'node:crypto'; import { ipcMain } from 'electron'; -import { IPC, type AppNotification, type NotificationInput } from '../shared/types'; +import { IPC, type AppNotification, type NotificationBase } from '../shared/types'; import { log } from './logger'; import { addNotification, @@ -20,6 +20,13 @@ import { } from './notifications-model'; import { type NotificationsStore } from './notifications-store'; +// Distributes over the union so each member loses the base fields on its own (a plain Omit would +// collapse the three into one non-discriminated object). +type WithoutNotificationBase = T extends unknown ? Omit : never; + +/** What a source of events hands to NotificationsService.notify — the base fields are main's to fill. */ +export type NotificationInput = WithoutNotificationBase; + /** * How many toasts are worth playing back one after another when the user returns. Beyond this the queue * would hold the screen for ten seconds of plates nobody reads — a single "N unread" says the same thing. diff --git a/src/main/pc-library.ts b/src/main/pc-library.ts index 98304d06..bf9c3731 100644 --- a/src/main/pc-library.ts +++ b/src/main/pc-library.ts @@ -11,17 +11,23 @@ // from the daemon's graph and from unit tests, per CLAUDE.md. import path from 'node:path'; import fse from 'fs-extra'; -import { MANIFEST_FILENAME, PC_LIBRARY_DIRNAME, type ResolvedManifest } from '../shared/types'; -import { - readManifests, - type InstallDirResolver, - type ManifestEnv, -} from './manifest'; +import { MANIFEST_FILENAME, type ResolvedManifest } from './manifest-types'; +import { readManifests, type ManifestEnv } from './manifest'; +import { type InstallDirResolver } from './platform/types'; +import { isEnoent } from './json-store'; import { log } from './logger'; import { uniqueAssetFileName } from './asset-file-names'; import { assertImportableAsset, type ImportKind } from './asset-import'; import { describe } from './util'; +/** + * Directory under `userData` that holds the PC library — the local games added from this machine's own + * disk. It is laid out exactly like a card (`game.json` + `assets/`, plus `saves//` standing in for + * the card's save copy), so the whole manifest/asset/history pipeline reads it as an always-inserted + * card. See ManifestSource. + */ +const PC_LIBRARY_DIRNAME = 'pc-games' as const; + export interface PcLibraryDeps { /** The app data directory (`app.getPath('userData')` in main). The library lives under it. */ readonly baseDir: string; @@ -123,7 +129,7 @@ export class PcLibraryStore { try { names = await fse.readdir(this.assetsDir); } catch (cause) { - if (!isNotFound(cause)) log.warn('[pc-library] cannot list assets for cleanup:', describe(cause)); + if (!isEnoent(cause)) log.warn('[pc-library] cannot list assets for cleanup:', describe(cause)); return; } for (const name of names) { @@ -142,8 +148,3 @@ export class PcLibraryStore { } /** True for an "it isn't there" fs error — an empty library is a normal state, not a failure. */ -function isNotFound(cause: unknown): boolean { - return ( - typeof cause === 'object' && cause !== null && (cause as { code?: unknown }).code === 'ENOENT' - ); -} diff --git a/src/main/pc-store.ts b/src/main/pc-store.ts index 6e84bd75..32964c3d 100644 --- a/src/main/pc-store.ts +++ b/src/main/pc-store.ts @@ -7,7 +7,8 @@ import path from 'node:path'; import fse from 'fs-extra'; import { z } from 'zod'; -import { type ResolvedManifest, type Stats } from '../shared/types'; +import { type Stats } from '../shared/types'; +import type { ResolvedManifest } from './manifest-types'; import { readJsonValidated, writeJsonAtomic } from './json-store'; import { type SyncState } from './save-sync'; import { log } from './logger'; @@ -128,25 +129,23 @@ export class PcStore { await fse.ensureDir(savesSnapshotDir); } const meta = { schemaVersion: 1 as const, id, enqueuedAt: new Date().toISOString() }; - await fse.writeJson(path.join(entryDir, 'meta.json'), meta, { spaces: 2 }); + await writeJsonAtomic(path.join(entryDir, 'meta.json'), meta); } - /** Returns the deferred flush for game `id`, if it exists and is valid. */ + /** + * Returns the deferred flush for game `id`, if it exists and is valid. A missing entry is the normal + * case (nothing queued); a present-but-unreadable one is logged by the shared reader and treated as + * absent — the snapshot stays on disk for the next enqueue to overwrite. + */ async getPending(id: string): Promise { const entryDir = this.pendingEntryDir(id); - const metaPath = path.join(entryDir, 'meta.json'); - try { - const raw: unknown = await fse.readJson(metaPath); - const parsed = pendingMetaSchema.safeParse(raw); - if (!parsed.success) return null; - return { - id: parsed.data.id, - enqueuedAt: parsed.data.enqueuedAt, - savesSnapshotDir: path.join(entryDir, 'saves'), - }; - } catch { - return null; - } + const meta = await readJsonValidated(path.join(entryDir, 'meta.json'), pendingMetaSchema.nullable(), null); + if (meta === null) return null; + return { + id: meta.id, + enqueuedAt: meta.enqueuedAt, + savesSnapshotDir: path.join(entryDir, 'saves'), + }; } async clearPending(id: string): Promise { @@ -179,22 +178,9 @@ export class PcStore { * treated as absent so a damaged file can't wedge sync — the next successful sync rewrites it). */ async readSyncState(id: string, slot: SyncSlot = 'card'): Promise { - let raw: unknown; - try { - raw = await fse.readJson(this.syncStatePath(id, slot)); - } catch (cause) { - // ENOENT is the expected first-run case → silent; anything else is a real read anomaly → warn. - if (cause instanceof Error && (cause as { code?: unknown }).code !== 'ENOENT') { - log.warn(`[sync-state] failed to read baseline for "${id}":`, cause); - } - return null; - } - const parsed = syncStateSchema.safeParse(raw); - if (!parsed.success) { - log.warn(`[sync-state] baseline for "${id}" failed validation, ignoring:`, parsed.error.message); - return null; - } - return parsed.data; + // ENOENT is the expected first-run case → silent; anything else is a real read anomaly → the shared + // reader warns and answers null. + return readJsonValidated(this.syncStatePath(id, slot), syncStateSchema.nullable(), null); } async writeSyncState(id: string, state: SyncState, slot: SyncSlot = 'card'): Promise { @@ -204,7 +190,7 @@ export class PcStore { } /** - * Drops a game's sync baseline. Used when a local game moves to a card (Р2.5): its `pc` baseline + * Drops a game's sync baseline. Used when a local game moves to a card: its `pc` baseline * partnered the PC-library backup with the local save folder, and that pairing no longer exists once * the game leaves the library — keeping it would read as a stale baseline and could report a false * conflict if the game is ever moved back to the PC. Silent on an already-absent file (the normal case diff --git a/src/main/pick-rejection.ts b/src/main/pick-rejection.ts new file mode 100644 index 00000000..a6af09dd --- /dev/null +++ b/src/main/pick-rejection.ts @@ -0,0 +1,53 @@ +// Whether a path may fill a manifest field of a given kind, in words the user reads. Sits beside +// config-paths.ts (the pure type check) rather than in the picker service: GameConfigService asks the +// same question for a path that reaches it from the history editor, and the core must not import from +// the picker built on top of it. +import fs from 'node:fs/promises'; +import { type ConfigPickKind } from '../shared/types'; +import { type Translator } from '../shared/i18n/index'; +import { AUDIO_EXTENSIONS, IMAGE_EXTENSIONS } from './asset-reader'; +import { acceptsExtensions, checkPickedType, type PickRejection } from './config-paths'; + +/** The extensions a field accepts, with the two asset lists filled in from the AssetReader. */ +function extensionsFor(kind: ConfigPickKind): readonly string[] | null { + if (kind === 'image') return IMAGE_EXTENSIONS; + if (kind === 'audio') return AUDIO_EXTENSIONS; + return acceptsExtensions(kind); +} + +/** The localized wording of a refusal from checkPickedType. */ +function rejectionMessage(rejection: PickRejection, t: Translator): string { + switch (rejection) { + case 'missing': + return t('gameConfig.pickMissing'); + case 'symlink': + return t('gameConfig.pickSymlink'); + case 'needs-folder': + return t('gameConfig.pickNeedsFolder'); + case 'needs-file': + return t('gameConfig.pickNeedsFile'); + case 'wrong-type': + return t('gameConfig.pickWrongType'); + } +} + +/** Whether one picked path may be used for `kind`; a localized reason when it may not, else null. */ +export async function describePickRejection( + absolute: string, + kind: ConfigPickKind, + t: Translator, +): Promise { + let stat: Parameters[2] = null; + try { + const stats = await fs.lstat(absolute); + stat = { + isSymbolicLink: stats.isSymbolicLink(), + isDirectory: stats.isDirectory(), + isFile: stats.isFile(), + }; + } catch { + stat = null; + } + const rejection = checkPickedType(absolute, kind, stat, extensionsFor(kind)); + return rejection === null ? null : rejectionMessage(rejection, t); +} diff --git a/src/main/platform/app-bundle.darwin.ts b/src/main/platform/app-bundle.darwin.ts index 65ca32aa..57e5f8fe 100644 --- a/src/main/platform/app-bundle.darwin.ts +++ b/src/main/platform/app-bundle.darwin.ts @@ -1,4 +1,4 @@ -// Resolving the real executable inside a macOS `.app` bundle (Д2). A `.app` is a DIRECTORY, so it cannot +// Resolving the real executable inside a macOS `.app` bundle. A `.app` is a DIRECTORY, so it cannot // be spawned — the binary lives at `Contents/MacOS/`, named by the bundle's Info.plist. // Spawning that binary directly (rather than `open -a`) is what keeps the pid valid, so the existing // pid-based tracking works for a local mac game exactly as it does for a Windows .exe. diff --git a/src/main/platform/darwin.ts b/src/main/platform/darwin.ts index c2bf152e..de1de707 100644 --- a/src/main/platform/darwin.ts +++ b/src/main/platform/darwin.ts @@ -3,14 +3,14 @@ // mode are out of scope and refuse with their own message rather than failing obscurely. // // Per-service notes: -// ProcessMonitor → `ps` (process-monitor.darwin.ts; Д1 — no readable env of foreign processes, so a Steam +// ProcessMonitor → `ps` (process-monitor.darwin.ts — no readable env of foreign processes, so a Steam // game is matched by the watched image names, as on win32) -// SteamLocator → `~/Library/Application Support/Steam` (steam-locator.darwin.ts; Д8) -// GameLauncher → direct spawn / `.app` bundle resolution (game-launcher.darwin.ts; Д2) -// SavePathResolver → the Windows dictionary mapped onto the mac profile (save-path.darwin.ts; Д3) -// PowerBackend → `pmset sleepnow` + System Events via osascript (Д4) +// SteamLocator → `~/Library/Application Support/Steam` (steam-locator.darwin.ts) +// GameLauncher → direct spawn / `.app` bundle resolution (game-launcher.darwin.ts) +// SavePathResolver → the Windows dictionary mapped onto the mac profile (save-path.darwin.ts) +// PowerBackend → `pmset sleepnow` + System Events via osascript // SteamShortcuts → unsupported (Game Mode is a Steam Deck thing) -// RemovableMounter → no-op: macOS automounts removable volumes into /Volumes (Д9) +// RemovableMounter → no-op: macOS automounts removable volumes into /Volumes // resolveInstallDir → null: install mode is unsupported, so no card can resolve one import os from 'node:os'; import { execFile } from 'node:child_process'; @@ -36,7 +36,7 @@ const execFileAsync = promisify(execFile); // Shutdown/restart have no such command (`shutdown` itself does require root), so they ask System Events // via osascript, the same route the Apple menu takes. // -// Д4: sending Apple Events from a PACKAGED app requires `NSAppleEventsUsageDescription` in Info.plist +// Sending Apple Events from a PACKAGED app requires `NSAppleEventsUsageDescription` in Info.plist // (electron-builder `mac.extendInfo`) AND the user's consent in the TCC prompt. In dev the responsible // process is the terminal, which already has that consent — so this path can only be finally verified on a // packaged build. A refusal comes back as osascript error -1743, reported here in words the user can act on. @@ -89,7 +89,7 @@ function createSteamShortcuts(): SteamShortcuts { } // ── RemovableMounter (no-op) ───────────────────────────────────────────────── -// Д9: macOS mounts removable media itself (an exFAT card appears under /Volumes, executable), so there is +// macOS mounts removable media itself (an exFAT card appears under /Volumes, executable), so there is // nothing to sweep — same situation as Windows. function createRemovableMounter(): RemovableMounter { diff --git a/src/main/platform/game-launcher.darwin.ts b/src/main/platform/game-launcher.darwin.ts index 95e1db5f..c973e754 100644 --- a/src/main/platform/game-launcher.darwin.ts +++ b/src/main/platform/game-launcher.darwin.ts @@ -1,4 +1,4 @@ -// macOS GameProcessLauncher: run a NATIVE mac game — a bare mach-o binary or a `.app` bundle (Д2). +// macOS GameProcessLauncher: run a NATIVE mac game — a bare mach-o binary or a `.app` bundle. // // A `.app` is a directory, so it cannot be spawned; the real binary at `Contents/MacOS/` // is resolved first and spawned directly. `open -a` would be the obvious alternative and is deliberately @@ -8,11 +8,10 @@ // What this launcher REFUSES, each with its own message rather than a generic failure: // • a Windows `*.exe` — the card is cross-platform, macOS is not (Wine/CrossOver is out of scope); // • install mode — `resolveInstallDir` is null on darwin, so an install-mode card never resolves anyway; -// • a Gatekeeper-blocked binary (Р6) — a quarantined game downloaded from the internet is SIGKILLed by +// • a Gatekeeper-blocked binary — a quarantined game downloaded from the internet is SIGKILLed by // syspolicyd with no UI at all, so an instant death right after spawn is reported as what it is. import { spawn } from 'node:child_process'; -import type { GameProcessLauncher, ProcessMonitor } from './types'; -import type { GameProcess } from '../game-launcher'; +import type { GameProcess, GameProcessLauncher, ProcessMonitor } from './types'; import type { Translator } from '../../shared/i18n/index'; import { isAppBundlePath, resolveAppBundleExecutable } from './app-bundle.darwin'; import { delay } from '../util'; @@ -20,7 +19,7 @@ import { log } from '../logger'; /** * How long a freshly spawned game is watched for the instant, UI-less SIGKILL that Gatekeeper delivers to a - * quarantined binary (Р6). Only the RESOLVE of the launch is delayed by this, never the game itself; a real + * quarantined binary. Only the RESOLVE of the launch is delayed by this, never the game itself; a real * game is still alive when the window closes. */ const GATEKEEPER_PROBE_MS = 500; @@ -102,7 +101,7 @@ export function createDarwinGameLauncher(deps: DarwinGameLauncherDeps): GameProc async launchGame(manifest): Promise { const t = deps.getTranslator(); if (manifest.raw.runAsAdmin) { - // Symmetric with linux (Р6): there is no elevation to ask for here, and refusing would break a + // Symmetric with linux: there is no elevation to ask for here, and refusing would break a // legitimate two-platform card that sets runAsAdmin for its Windows side. log.warn(`[launch] runAsAdmin ignored on macOS (no elevation) id=${manifest.raw.id}`); } @@ -126,7 +125,9 @@ export function createDarwinGameLauncher(deps: DarwinGameLauncherDeps): GameProc // null so an install-mode card is already rejected at manifest-read. These stay total and explicit. launchInstaller: () => refuseInstall(), prepareInstallDir: () => refuseInstall(), + needsProvisioning: () => Promise.resolve(false), launchUninstaller: () => refuseInstall(), + resolveUninstaller: () => Promise.resolve(null), // Never reached (install mode never resolves on darwin); returns the same dir win32 would, so the // interface stays total rather than throwing from a getter-shaped method. uninstallDir: (install) => install.dir, diff --git a/src/main/platform/game-launcher.linux.ts b/src/main/platform/game-launcher.linux.ts index de4eb552..9265ee04 100644 --- a/src/main/platform/game-launcher.linux.ts +++ b/src/main/platform/game-launcher.linux.ts @@ -1,17 +1,16 @@ -// Linux GameProcessLauncher: run the card's Windows .exe through umu-launcher / Proton (Р1/Р2). The +// Linux GameProcessLauncher: run the card's Windows .exe through umu-launcher / Proton. The // bundled umu-run zipapp is invoked as `python3 ` in the game's own Wine prefix. // umu-run stays alive for the whole session, so the launched CHILD is the completion signal (its exit -// event) — no /proc snapshots needed for the plain exe path (Р3); watchProcesses games still track via the -// ProcessMonitor. Install/uninstall run the same way (Р7): the installer/uninstaller .exe is launched +// event) — no /proc snapshots needed for the plain exe path; watchProcesses games still track via the +// ProcessMonitor. Install/uninstall run the same way: the installer/uninstaller .exe is launched // through umu-run in the game's OWN Wine prefix, so what it writes to `C:\playhook\games\` lands in // that prefix's `drive_c` — exactly where the game later launches from. import path from 'node:path'; import { spawn, execFile } from 'node:child_process'; import { promisify } from 'node:util'; import fse from 'fs-extra'; -import type { GameProcessLauncher, ProcessMonitor } from './types'; -import type { ResolvedInstall } from '../../shared/types'; -import type { GameProcess } from '../game-launcher'; +import type { GameProcess, GameProcessLauncher, ProcessMonitor } from './types'; +import type { ResolvedInstall } from '../manifest-types'; import { prefixDir, prefixForInstall, @@ -23,6 +22,7 @@ import { } from './umu'; import { buildInstallerArgs } from '../launch-args'; import { log } from '../logger'; +import { createProvisionLock } from './provision-lock'; const execFileAsync = promisify(execFile); @@ -46,7 +46,7 @@ export interface LinuxGameLauncherDeps { function protonLogDir(userData: string): string | undefined { const flag = process.env['PLAYHOOK_PROTON_LOG']; if (flag === undefined || flag === '' || flag === '0') return undefined; - return path.join(userData, 'proton-logs'); + return path.posix.join(userData, 'proton-logs'); } /** Ensures the Proton-log dir exists (Proton won't create PROTON_LOG_DIR itself). No-op when off. */ @@ -64,7 +64,7 @@ async function collectProtonLog(logDir: string): Promise { const names = (await fse.readdir(logDir)).filter((name) => name.endsWith('.log')); let newest: { readonly path: string; readonly mtimeMs: number } | null = null; for (const name of names) { - const full = path.join(logDir, name); + const full = path.posix.join(logDir, name); const stat = await fse.stat(full); if (newest === null || stat.mtimeMs > newest.mtimeMs) newest = { path: full, mtimeMs: stat.mtimeMs }; } @@ -79,7 +79,7 @@ async function collectProtonLog(logDir: string): Promise { let python3Available = false; /** - * Verifies a system python3 is present (the umu zipapp needs it — Р1). SteamOS ships it; on desktop + * Verifies a system python3 is present (the umu zipapp needs it). SteamOS ships it; on desktop * distros it is almost always there. Throws a clear, user-facing error otherwise so the controller can * surface it instead of a cryptic spawn failure. */ @@ -163,20 +163,22 @@ function spawnUmuProcess( }); } -// ── Prefix dependency provisioning (Р7b) ───────────────────────────────────── +// ── Prefix dependency provisioning ─────────────────────────────────────────── // Before an installer runs, its Wine prefix gets a baseline set of runtimes (mfc42/gdiplus/vcrun/…) plus // any card-specific extras, via `umu-run winetricks`. A skinned Inno installer (isskin.dll) fails to load // on a bare prefix without these; games in the same prefix benefit too. Idempotent: the applied verbs are // recorded in a per-prefix sentinel so re-installs skip the (slow) step; the winetricks DOWNLOADS are // globally cached by winetricks, so a new prefix re-applies without re-downloading. +const provisionLock = createProvisionLock(); + /** Per-prefix marker file listing the winetricks verbs already provisioned (newline-separated). */ const WINETRICKS_SENTINEL = '.playhook-winetricks'; /** Reads the verbs already provisioned in `prefix` (empty if the sentinel is missing/unreadable). */ async function readDoneVerbs(prefix: string): Promise { try { - const text = await fse.readFile(path.join(prefix, WINETRICKS_SENTINEL), 'utf8'); + const text = await fse.readFile(path.posix.join(prefix, WINETRICKS_SENTINEL), 'utf8'); return text.split('\n').map((line) => line.trim()).filter((line) => line.length > 0); } catch { return []; // missing sentinel = nothing provisioned yet (normal first install) @@ -225,11 +227,11 @@ function runWinetricks( } /** - * Ensures the baseline + card-`extra` winetricks verbs are provisioned in `prefix` (Р7b). Idempotent via + * Ensures the baseline + card-`extra` winetricks verbs are provisioned in `prefix`. Idempotent via * the per-prefix sentinel; a no-op when everything is already applied. Best-effort: a winetricks failure * is logged and provisioning does NOT record success (so the next attempt retries), but the install is * allowed to proceed regardless (chosen behaviour — the installer surfaces its own error if deps are - * truly missing). Needs network the first time (like the GE-Proton download), same as game launch in Э4. + * truly missing). Needs network the first time (like the GE-Proton download), same as a game launch. */ async function ensurePrefixDeps( umuRunPath: string, @@ -237,11 +239,26 @@ async function ensurePrefixDeps( extra: readonly string[], logDir: string | undefined, onProvisioning?: (active: boolean) => void, +): Promise { + if (pendingWinetricks(extra, await readDoneVerbs(prefix)).length === 0) return; + await provisionLock.run(() => provisionPrefix(umuRunPath, prefix, extra, logDir, onProvisioning)); +} + +/** + * The body of ensurePrefixDeps, run under the provision lock: re-reads what is already applied (an earlier + * holder of the lock may have provisioned this very prefix) and runs winetricks for the rest. + */ +async function provisionPrefix( + umuRunPath: string, + prefix: string, + extra: readonly string[], + logDir: string | undefined, + onProvisioning?: (active: boolean) => void, ): Promise { const done = await readDoneVerbs(prefix); const pending = pendingWinetricks(extra, done); if (pending.length === 0) return; // nothing to do → no "Configuring Proton" status - // Signal the provisioning window ONLY when winetricks actually runs (Р7g). `finally` guarantees the + // Signal the provisioning window ONLY when winetricks actually runs. `finally` guarantees the // status is torn down even if the run throws, so the launch screen never gets stuck on it. onProvisioning?.(true); try { @@ -254,7 +271,7 @@ async function ensurePrefixDeps( // Persist the union of previously-done and newly-applied verbs so re-installs skip this step. const union = [...new Set([...done, ...pending])]; try { - await fse.writeFile(path.join(prefix, WINETRICKS_SENTINEL), `${union.join('\n')}\n`); + await fse.writeFile(path.posix.join(prefix, WINETRICKS_SENTINEL), `${union.join('\n')}\n`); } catch (cause) { // Non-fatal: without the sentinel the next install re-runs winetricks (idempotent, just slower). log.warn(`[install] failed to write winetricks sentinel: ${cause instanceof Error ? cause.message : String(cause)}`); @@ -266,7 +283,7 @@ async function ensurePrefixDeps( /** * Brings the Wine prefix that backs an install dir into a ready state: created, Proton-initialized and - * provisioned with the baseline runtimes plus the card's `install.winetricks` (Р7b). Shared by + * provisioned with the baseline runtimes plus the card's `install.winetricks`. Shared by * launchInstaller (which needs it before the installer .exe runs) and prepareInstallDir (which needs it * before `copy` writes the game's files in) — the two must init the prefix the SAME way, so a copied game * lands in the same environment an installed one would. @@ -283,7 +300,7 @@ async function preparePrefixForInstall( await fse.ensureDir(prefix); const logDir = protonLogDir(deps.userData); await ensureLogDir(logDir); - // Р7b: provision the runtimes the installer/game need (baseline + card extras). Also initializes the + // Provision the runtimes the installer/game need (baseline + card extras). Also initializes the // prefix (the first `umu-run` here does the Proton upgrade). await ensurePrefixDeps(deps.umuRunPath, prefix, install.winetricks, logDir, onProvisioning); return { prefix, logDir }; @@ -294,7 +311,7 @@ export function createLinuxGameLauncher(deps: LinuxGameLauncherDeps): GameProces return { async launchGame(manifest, onProvisioning): Promise { if (manifest.raw.runAsAdmin) { - // Р6: there is no elevation under Proton — everything runs as the user. Ignore rather than reject, + // There is no elevation under Proton — everything runs as the user. Ignore rather than reject, // so a legitimate two-platform card (runAsAdmin for Windows) still launches on Linux. log.warn(`[launch] runAsAdmin ignored on Linux (no elevation under Proton) id=${manifest.raw.id}`); } @@ -303,12 +320,12 @@ export function createLinuxGameLauncher(deps: LinuxGameLauncherDeps): GameProces await fse.ensureDir(prefix); const logDir = protonLogDir(deps.userData); await ensureLogDir(logDir); - // Р7b: provision the game's own winetricks verbs (baseline + card `winetricks`) before launch — only + // Provision the game's own winetricks verbs (baseline + card `winetricks`) before launch — only // when the card lists any, so an ordinary game with no verbs launches unchanged (no extra step). if (manifest.raw.winetricks.length > 0) { await ensurePrefixDeps(deps.umuRunPath, prefix, manifest.raw.winetricks, logDir, onProvisioning); } - // Р7i: a card-specified umu GAMEID (Steam appid / UMU_ID) → umu applies that game's protonfix; absent + // A card-specified umu GAMEID (Steam appid / UMU_ID) → umu applies that game's protonfix; absent // → `umu-default`. Only for the game launch (the installer/winetricks steps stay generic). const gameId = manifest.raw.umuGameId ?? UMU_GAMEID; const env = buildUmuEnv(process.env, { prefix, proton: DEFAULT_PROTON, protonLogDir: logDir, gameId }); @@ -318,10 +335,10 @@ export function createLinuxGameLauncher(deps: LinuxGameLauncherDeps): GameProces ); return spawnUmuProcess(args, manifest.cwd, env, deps.monitor); }, - // Install mode (Р7): run the card's installer .exe through umu-run in the game's Wine prefix, feeding + // Install mode: run the card's installer .exe through umu-run in the game's Wine prefix, feeding // it the app-controlled dir via the family's silent dir-key (unquoted on linux — see buildInstallerArgs). // cwd is the installer's own folder on the card (host path); the install dir may not exist yet (the - // installer creates it, and the controller pre-cleaned it). runAsAdmin is ignored (no elevation — Р6). + // installer creates it, and the controller pre-cleaned it). runAsAdmin is ignored (no elevation). async launchInstaller(install, silent, onProvisioning): Promise { if (install.runAsAdmin) { log.warn('[install] runAsAdmin ignored on Linux (no elevation under Proton)'); @@ -332,7 +349,7 @@ export function createLinuxGameLauncher(deps: LinuxGameLauncherDeps): GameProces // on linux — umu surfaces the Wine window whenever the installer isn't running silently). const installerArgs = buildInstallerArgs(install.type, install.installerDir, install.args, false, silent); const args = buildUmuArgs(deps.umuRunPath, install.installerPath, installerArgs); - const cwd = path.dirname(install.installerPath); + const cwd = path.posix.dirname(install.installerPath); log.info( `[install] umu-run installer silent=${silent} prefix="${prefix}" installer="${install.installerPath}" dir="${install.installerDir}"`, ); @@ -342,11 +359,15 @@ export function createLinuxGameLauncher(deps: LinuxGameLauncherDeps): GameProces // copied files are about to be written INTO (launchGame only provisions when the card lists top-level // winetricks, and never applies the install baseline). Do it here, before the copy — same order and // same environment as the installer path: prefix first, game files second. + async needsProvisioning(install): Promise { + const prefix = prefixForInstall(install.dir); + return pendingWinetricks(install.winetricks, await readDoneVerbs(prefix)).length > 0; + }, async prepareInstallDir(install, onProvisioning): Promise { const { prefix } = await preparePrefixForInstall(deps, install, onProvisioning); log.info(`[install] prefix ready for copy prefix="${prefix}" dir="${install.dir}"`); }, - // Uninstall (Р7): the target (uninstaller .exe found in the install dir + silent flags) is resolved by + // Uninstall: the target (uninstaller .exe found in the install dir + silent flags) is resolved by // the controller; there is no registry fallback on linux. Run it through umu-run in the same prefix. async launchUninstaller(target): Promise { if (target.runAsAdmin) { @@ -363,7 +384,11 @@ export function createLinuxGameLauncher(deps: LinuxGameLauncherDeps): GameProces log.info(`[uninstall] umu-run uninstaller prefix="${prefix}" file="${target.file}"`); return spawnUmuProcess(args, target.cwd, env, deps.monitor); }, - // Р7f: uninstall removes the WHOLE per-game prefix (it contains the install dir + the game's runtimes), + // Uninstall removes the WHOLE per-game prefix (see uninstallDir), so running the game's own in-prefix + // uninstaller first is pointless: its registry/shortcut cleanup lives in the prefix about to be + // deleted. Skip it — win32 still runs it (no prefix; it must clean the shared system). + resolveUninstaller: () => Promise.resolve(null), + // Uninstall removes the WHOLE per-game prefix (it contains the install dir + the game's runtimes), // reclaiming the full disk footprint — not just the game files under drive_c/playhook/games/. uninstallDir: (install) => prefixForInstall(install.dir), // A normal executable game creates its prefix on first launch; offer to clear it once it exists. Same diff --git a/src/main/platform/index.ts b/src/main/platform/index.ts index ad052657..2c77e563 100644 --- a/src/main/platform/index.ts +++ b/src/main/platform/index.ts @@ -25,6 +25,10 @@ export type { PcSaveLocation, PowerBackend, RemovableMounter, + GameProcess, + PowerAction, + InstallDir, + InstallDirResolver, } from './types'; /** Builds the platform service bundle for the running OS. Bootstrapped once in main. */ diff --git a/src/main/platform/linux.ts b/src/main/platform/linux.ts index 9e7867ad..d4832637 100644 --- a/src/main/platform/linux.ts +++ b/src/main/platform/linux.ts @@ -1,9 +1,9 @@ -// Linux (SteamOS / Proton) implementations of the platform services. Built up stage by stage per the -// SteamOS port plan; in the foundation stage (Э0) these replicate the pre-port graceful degradation on -// non-Windows (null Steam path, no visible processes, no power actions) so createPlatform is complete and -// behaviour on the untouched paths is unchanged. Each service is filled in by its own stage: -// ProcessMonitor → Э2 (/proc scan) SteamLocator → Э3 (known paths) GameProcessLauncher → Э4 (umu-run) -// SavePathResolver → Э5/Э6 (Wine prefix / compatdata) PowerBackend → Э7 (systemctl / logind) +// Linux (SteamOS / Proton) implementations of the platform services. Built up service by service: the +// foundation replicated the pre-port graceful degradation on non-Windows (null Steam path, no visible +// processes, no power actions) so createPlatform was complete and behaviour on the untouched paths +// unchanged, and each service was then filled in on its own: +// ProcessMonitor → /proc scan SteamLocator → known paths GameProcessLauncher → umu-run +// SavePathResolver → Wine prefix / compatdata PowerBackend → systemctl / logind import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import type { @@ -21,9 +21,9 @@ import { installDirs } from './umu'; const execFileAsync = promisify(execFile); -// ── PowerBackend — Э7 (systemctl poweroff/reboot/suspend via logind). ── +// ── PowerBackend — systemctl poweroff/reboot/suspend via logind. ── // SteamOS runs logind, which lets the active session's user issue poweroff/reboot/suspend without root -// (the standard polkit policy for a logged-in seat — Р9). No `--no-wall`/root needed; `systemctl` is on +// (the standard polkit policy for a logged-in seat). No `--no-wall`/root needed; `systemctl` is on // PATH. Rejects propagate to PowerService, which surfaces the error copy and does NOT quit. function createPowerBackend(): PowerBackend { return { @@ -55,10 +55,10 @@ export function createLinuxPlatform(deps: PlatformDeps): Platform { }), savePathResolver: createLinuxSavePathResolver({ userData: deps.userData, steamLocator }), powerBackend: createPowerBackend(), - // Game Mode automount (Р10): the caller only sweeps in a gamescope session — the KDE desktop session + // Game Mode automount: the caller only sweeps in a gamescope session — the KDE desktop session // mounts on its own, so wiring it there would be redundant work. removableMounter: createLinuxRemovableMounter(), - // Install mode is always supported on linux — the prefix is created on demand (Р7). Both views come + // Install mode is always supported on linux — the prefix is created on demand. Both views come // from umu.installDirs (host path inside the prefix + the `C:\playhook\games\` installer view). resolveInstallDir: (id) => installDirs(deps.userData, id), }; diff --git a/src/main/platform/mount.linux.ts b/src/main/platform/mount.linux.ts index c14deb47..de732150 100644 --- a/src/main/platform/mount.linux.ts +++ b/src/main/platform/mount.linux.ts @@ -1,4 +1,4 @@ -// Automounting removable cards in SteamOS Game Mode (Р10). A SAFETY NET, not the primary path: current +// Automounting removable cards in SteamOS Game Mode. A SAFETY NET, not the primary path: current // SteamOS mounts an inserted card itself (exFAT included), and this sweep only covers the case where one // arrives WITHOUT a mountpoint — an older/other gamescope session, a filesystem the session skips, or a // card the automounter simply didn't pick up. Such a card is enumerated as a block device with no diff --git a/src/main/platform/proc.ts b/src/main/platform/proc.ts index 77c69538..f4e2a4ef 100644 --- a/src/main/platform/proc.ts +++ b/src/main/platform/proc.ts @@ -1,4 +1,4 @@ -// Linux ProcessMonitor backed by /proc (Р3). Watched-game tracking on Linux can't use `comm` +// Linux ProcessMonitor backed by /proc. Watched-game tracking on Linux can't use `comm` // (/proc//comm is truncated to 15 chars, and names like `Game-Win64-Shipping.exe` are longer), so we // read /proc//cmdline and take the basename of argv[0]. A Proton/Wine process shows its exe path there // (often `Z:\...\Game.exe` or `C:\...`), so we split on BOTH separators and match the bare `*.exe` name @@ -6,7 +6,7 @@ // // The pure parsing/matching helpers below carry no electron/koffi/fs-at-import baggage, so they are unit- // tested directly (test/proc.test.ts). The /proc read (scanProc) and the ProcessMonitor it powers use -// node:fs + process signals and are exercised on the device (smoke test — Р3, допущение §5.9). +// node:fs + process signals and are exercised on the device (smoke test). import fs from 'node:fs/promises'; import type { ProcessMonitor, ProcessSnapshot } from './types'; @@ -80,7 +80,7 @@ export function buildProcIndex(entries: readonly ProcEntry[]): { } /** Builds a ProcessSnapshot from indexed processes. A watched name matches by exact (case-insensitive) - * basename — more precise than the win32 substring match, on purpose (Р3). Pure so it is unit-tested. */ + * basename — more precise than the win32 substring match, on purpose. Pure so it is unit-tested. */ export function snapshotFromEntries(entries: readonly ProcEntry[]): ProcessSnapshot { const { names, pids } = buildProcIndex(entries); return { @@ -173,7 +173,7 @@ export function createLinuxProcessMonitor(): ProcessMonitor { } }, killTree(pid): Promise { - // Best-effort group kill (Р3): a child spawned with detached:true is its own process-group leader, so + // Best-effort group kill: a child spawned with detached:true is its own process-group leader, so // `-pid` hits the group. wineserver does setsid and escapes the group, so the by-name sweep // (killByName) is the real mechanism; this is the cheap first step. Also signal the pid directly. for (const signal of KILL_SIGNALS) { @@ -207,6 +207,8 @@ export function createLinuxProcessMonitor(): ProcessMonitor { const snap = snapshotFromEntries(await scanProc()); return watchNames.some((name) => snap.hasImageName(name)); }, + // No elevation under Proton — a plain kill is all there is. + killImagesElevated: () => undefined, async killSteamGame(appid, watchNames): Promise { signalPids(await steamAppPids(appid)); // Fallback sweep by the Windows watch names (Proton `.exe`) — harmless if none match. diff --git a/src/main/platform/process-monitor.darwin.ts b/src/main/platform/process-monitor.darwin.ts index 866f4eee..53cbf2cf 100644 --- a/src/main/platform/process-monitor.darwin.ts +++ b/src/main/platform/process-monitor.darwin.ts @@ -1,11 +1,11 @@ -// macOS ProcessMonitor backed by `ps` (Д1). There is no /proc on darwin and no way to read another +// macOS ProcessMonitor backed by `ps`. There is no /proc on darwin and no way to read another // process's environment without privileges, so a Steam game is identified the way win32 identifies it — // by the watched image names — rather than by the `SteamAppId` tag the linux monitor keys on. // // The snapshot comes from `ps -axwwo pid=,comm=`: `-ww` disables the column truncation that would cut long // bundle paths, and `comm` is the executable's full path, so the basename is a reliable image name. // -// Name matching normalizes the `.exe` suffix away on BOTH sides (Д5): a card written for Windows/Deck +// Name matching normalizes the `.exe` suffix away on BOTH sides: a card written for Windows/Deck // stores `valheim.exe`, while the native mac process is `valheim`. A mac-only record may store the bare // name; both spellings therefore match the same process. // @@ -37,7 +37,7 @@ export interface DarwinProcParent { /** * A comparable image name: the basename (both separators, so a Windows-dictionary `dir\game.exe` also * reduces), lower-cased, with a trailing `.exe` dropped. Dropping the suffix is what lets a card written - * for Windows match the native mac binary of the same game (Д5/Р1). Pure. + * for Windows match the native mac binary of the same game. Pure. */ export function normalizeImageName(name: string): string { return pathBasename(name).toLowerCase().replace(/\.exe$/, ''); @@ -196,7 +196,7 @@ export function createDarwinProcessMonitor(): ProcessMonitor { // and a pid whose process honoured SIGTERM may by now belong to something else entirely. signalPids(stillAlive(pids), 'SIGKILL'); }, - // Д1: a mac process carries no readable Steam tag, so the watched image names ARE the running signal — + // A mac process carries no readable Steam tag, so the watched image names ARE the running signal — // the same rule win32 uses. The appid is unused here. async isSteamGameRunning(_appid, watchNames): Promise { if (watchNames.length === 0) return false; @@ -206,6 +206,8 @@ export function createDarwinProcessMonitor(): ProcessMonitor { killSteamGame(_appid, watchNames): Promise { return monitor.killByName(watchNames); }, + // No elevated launch on macOS (runAsAdmin is a no-op there), so there is nothing to kill elevated. + killImagesElevated: () => undefined, }; return monitor; } diff --git a/src/main/platform/provision-lock.ts b/src/main/platform/provision-lock.ts new file mode 100644 index 00000000..bcb8681d --- /dev/null +++ b/src/main/platform/provision-lock.ts @@ -0,0 +1,23 @@ +/** An async queue: sections handed to `run` execute one at a time, in the order they were handed in. */ +export interface ProvisionLock { + run(section: () => Promise): Promise; +} + +/** + * Creates a lock for the steps that must not overlap between games: winetricks shares one download cache + * (`~/.cache/winetricks`) across every prefix and takes no lock of its own. A section that throws releases + * the lock like one that succeeds. + */ +export function createProvisionLock(): ProvisionLock { + let tail: Promise = Promise.resolve(); + return { + run(section: () => Promise): Promise { + const result = tail.then(section); + tail = result.then( + () => undefined, + () => undefined, + ); + return result; + }, + }; +} diff --git a/src/main/platform/save-path.darwin.ts b/src/main/platform/save-path.darwin.ts index 9173b034..02f7d1e4 100644 --- a/src/main/platform/save-path.darwin.ts +++ b/src/main/platform/save-path.darwin.ts @@ -1,4 +1,4 @@ -// macOS SavePathResolver (Д3): maps the card's Windows-dictionary `pcSavePath` onto a real folder in the +// macOS SavePathResolver: maps the card's Windows-dictionary `pcSavePath` onto a real folder in the // user's home. There is no Wine prefix here — a mac game writes into the mac profile — so the mapping is a // best-effort translation of the Windows known folders: // @@ -8,7 +8,7 @@ // // The three AppData prefixes collapsing onto one base is deliberate: Unity on macOS writes to // `~/Library/Application Support//`, which is exactly what it puts in LocalLow on -// Windows, and for everything else this is the closest approximation available. Р2: when a game keeps its +// Windows, and for everything else this is the closest approximation available. When a game keeps its // saves elsewhere, the sync simply reports a missing folder — nothing destructive. // // The REVERSE mapping is therefore ambiguous, and that ambiguity is contained rather than papered over: @@ -19,7 +19,7 @@ // // The prefix→path mapping is pure (unit-tested without fs); paths are built with `path.posix` (CLAUDE.md). import path from 'node:path'; -import type { ResolvedManifest } from '../../shared/types'; +import type { ResolvedManifest } from '../manifest-types'; import type { PcSaveLocation, SavePathResolver } from './types'; /** The home-relative bases the Windows prefixes map onto. Resolved once from the OS/Electron paths. */ diff --git a/src/main/platform/save-path.linux.ts b/src/main/platform/save-path.linux.ts index 0408bb3c..3b55489d 100644 --- a/src/main/platform/save-path.linux.ts +++ b/src/main/platform/save-path.linux.ts @@ -1,18 +1,18 @@ -// Linux SavePathResolver (Р5/Э6): resolves the Windows-dictionary `pcSavePath` to a physical folder +// Linux SavePathResolver: resolves the Windows-dictionary `pcSavePath` to a physical folder // INSIDE the game's Wine prefix (exe/install modes) or its Steam compatdata prefix (steam mode). The // moment of resolution is sync-time, not manifest-read, so a prefix that doesn't exist yet is a no-op // sync (null), not a rejected card. The prefix→subpath mapping is a pure function (unit-tested without // fs); the async wrapper adds the per-game prefix lookup + existence gate. import path from 'node:path'; import fse from 'fs-extra'; -import type { ResolvedManifest } from '../../shared/types'; +import type { ResolvedManifest } from '../manifest-types'; import type { PcSaveLocation, SavePathResolver, SteamLocator } from './types'; import { prefixDir } from './umu'; import { steamLibraryDirs } from '../steam'; import { log } from '../logger'; /** - * Where each Windows env-prefix lives inside a Wine prefix, relative to `drive_c` (Р5). Wine lays the + * Where each Windows env-prefix lives inside a Wine prefix, relative to `drive_c`. Wine lays the * steamuser home out exactly like a Windows profile, so the mapping is a fixed table. `%USERPROFILE%` is * the steamuser home itself (empty tail). Modern Proton uses the Vista+ `AppData\…` / `Documents` layout * (not the XP `My Documents` one), matching the prefixes we create and the compatdata Steam maintains. @@ -45,7 +45,7 @@ export function resolveInsideWinePrefix(pfx: string, pcSavePath: string): string } /** - * Reverse of resolveInsideWinePrefix, for the Configure window's pcSavePath Browse (Р5). Takes an + * Reverse of resolveInsideWinePrefix, for the Customize screen's pcSavePath Browse. Takes an * ABSOLUTE host folder the user picked and expresses it as a `%PREFIX%/…` manifest string, or null when it * lives in no Wine prefix at all (then it cannot be a Windows game's save location and the picker rejects * it). Pure. @@ -88,7 +88,7 @@ async function findCompatdataPrefix(appid: number, steamLocator: SteamLocator): if (steamPath === null) return null; const libs = await steamLibraryDirs(steamPath); for (const lib of libs) { - const pfx = path.join(lib, 'steamapps', 'compatdata', String(appid), 'pfx'); + const pfx = path.posix.join(lib, 'steamapps', 'compatdata', String(appid), 'pfx'); if (await fse.pathExists(pfx)) return pfx; } return null; @@ -97,7 +97,7 @@ async function findCompatdataPrefix(appid: number, steamLocator: SteamLocator): /** * The Wine prefix that owns this game's saves, and whether it exists yet. * - * exe/install: the path is DETERMINISTIC (`/prefixes/` — Р2), so it is always returned, even + * exe/install: the path is DETERMINISTIC (`/prefixes/`), so it is always returned, even * before the prefix exists. That is what lets sync-in restore card saves into a prefix that a launch is * about to create (launchGame ensureDir's it anyway); `exists: false` tells the caller the PC side has no * authority, so a stale baseline must not turn the empty prefix into a phantom deletion on the card. @@ -130,7 +130,7 @@ export function createLinuxSavePathResolver(deps: LinuxSavePathDeps): SavePathRe const prefix = await prefixForManifest(manifest, deps); if (prefix === null) { // Steam mode with no compatdata: the game has never run under Proton (or isn't installed), so - // there is no location to sync with — a logged no-op, not an error (Р5). + // there is no location to sync with — a logged no-op, not an error. log.info(`[save-sync] no Steam compatdata for "${manifest.raw.id}" yet — pcSavePath "${pcSavePath}" unresolved`); return null; } diff --git a/src/main/platform/steam-locator.darwin.ts b/src/main/platform/steam-locator.darwin.ts index 09c967a1..87207760 100644 --- a/src/main/platform/steam-locator.darwin.ts +++ b/src/main/platform/steam-locator.darwin.ts @@ -1,4 +1,4 @@ -// macOS SteamLocator (Д8): Steam keeps its data under `~/Library/Application Support/Steam`, and the +// macOS SteamLocator: Steam keeps its data under `~/Library/Application Support/Steam`, and the // validity check is the same one the linux locator uses — the presence of `steamapps/libraryfolders.vdf`, // Steam's own library index (the file the `.acf` walk in steam.ts reads). There is only one candidate on // macOS: no flatpak, no snap, and the App Store carries no Steam. diff --git a/src/main/platform/steam-locator.linux.ts b/src/main/platform/steam-locator.linux.ts index e8279b65..a01468e5 100644 --- a/src/main/platform/steam-locator.linux.ts +++ b/src/main/platform/steam-locator.linux.ts @@ -1,4 +1,4 @@ -// Linux SteamLocator (Р4): probe the well-known Steam data directories and accept the first that carries a +// Linux SteamLocator: probe the well-known Steam data directories and accept the first that carries a // `steamapps/libraryfolders.vdf` (Steam's own library index — the same file the .acf walk reads). Covers // the native install, the `~/.steam/steam` symlink, the flatpak install and the snap install. The candidate // list + validity path are pure (unit-tested); locateSteam does the fs probe. diff --git a/src/main/platform/steam-shortcuts.linux.ts b/src/main/platform/steam-shortcuts.linux.ts index cdc39921..41863ff3 100644 --- a/src/main/platform/steam-shortcuts.linux.ts +++ b/src/main/platform/steam-shortcuts.linux.ts @@ -2,7 +2,7 @@ // Game Mode tile. This is the one place in the app that writes a file Steam owns, so the whole module is // built around not damaging it. // -// Safety rules (from the plan's §3.5, several of them established by experiment on a real Deck): +// Safety rules (several of them established by experiment on a real Deck): // - Steam does NOT need to be closed: it does not rewrite shortcuts.vdf on exit (verified — a Steam that // was killed by a mode switch left an externally-modified file untouched). // - Read-modify-write, never a blind overwrite: foreign shortcuts are carried through verbatim. diff --git a/src/main/platform/steam-vdf.ts b/src/main/platform/steam-vdf.ts index 51ae3a8e..d5d339d4 100644 --- a/src/main/platform/steam-vdf.ts +++ b/src/main/platform/steam-vdf.ts @@ -1,9 +1,9 @@ // Binary VDF (Valve's key-value binary format) reader/writer, scoped to what `shortcuts.vdf` uses. // Pure and electron-free (unit-tested in test/steam-shortcuts-vdf.test.ts). // -// Why hand-rolled and not a dependency: the plan's candidate `steam-binary-vdf` is a single 0.1.0 release -// over a year old that drags in six transitive deps (hex2dec, polycrc, buffer-reader, node-int64) — it -// failed both liveness and dependency checks, so the plan's explicit fallback applies. The format is small +// Why hand-rolled and not a dependency: the obvious candidate, `steam-binary-vdf`, is a single 0.1.0 +// release over a year old that drags in six transitive deps (hex2dec, polycrc, buffer-reader, node-int64) +// — it failed both liveness and dependency checks. The format is small // and frozen; a round-trip test covers it far more cheaply than auditing that tree. // // Format: a stream of `` entries, terminated by 0x08. diff --git a/src/main/platform/types.ts b/src/main/platform/types.ts index 159e1436..14f3e805 100644 --- a/src/main/platform/types.ts +++ b/src/main/platform/types.ts @@ -1,8 +1,8 @@ // Platform abstraction layer (interface-DI). The launcher's OS-specific behaviour — process // monitoring, Steam discovery, game/installer spawning, save-path resolution and power actions — lives // behind these interfaces so a win32 and a linux (SteamOS/Proton) implementation can be swapped wholesale -// by createPlatform(process.platform). See the SteamOS port plan (decisions Р3/Р4/Р5/Р9) and CLAUDE.md -// ("Adding a new service": interface-DI is the testable shape). +// by createPlatform(process.platform). See CLAUDE.md ("Adding a new service": interface-DI is the +// testable shape). // // Types only — no koffi/electron here, so this file is import-safe from anywhere (incl. unit tests). The // concrete win32/linux implementations (which DO pull koffi on Windows) live in ./win32 and ./linux and @@ -12,12 +12,50 @@ import type { LaunchTarget, ResolvedInstall, ResolvedInstallerRun, -} from '../../shared/types'; -import type { GameProcess } from '../game-launcher'; -import type { PowerAction } from '../power'; -import type { InstallDirResolver } from '../manifest'; +} from '../manifest-types'; import type { Translator } from '../../shared/i18n/index'; +/** + * A launched game, abstracting the launch backends so the wait loops don't care which was used. + * `pid` is the real pid for the normal path, 0 for the elevated path (we monitor by HANDLE there). + */ +export interface GameProcess { + readonly pid: number; + isAlive(): Promise; + /** + * Force-terminates the process (force-close from the More menu). Normal path: `taskkill /PID /T + * /F` (the whole tree), guarded by an isAlive() re-check so a reused pid can't take down an unrelated + * process. Elevated path: TerminateProcess on the kept HANDLE, done synchronously (no await before the + * FFI call) and skipped if dispose() already closed the handle. Errors are swallowed — the caller + * decides success by a fact-based control poll, not this call's outcome. + */ + kill(): Promise; + /** Releases the kept HANDLE (elevated path); no-op for the normal path. */ + dispose(): void; +} + +export type PowerAction = 'shutdown' | 'reboot' | 'sleep'; + +/** + * The app-controlled install directory in BOTH views. On win32 they are identical + * (`%LOCALAPPDATA%\playhook\games\`); on linux they diverge: + * - `hostDir` — the real filesystem path inside the game's Wine prefix + * (`/drive_c/playhook/games/`): every fs op and the resolved `executable` live under it; + * - `installerDir` — the SAME location as the installer sees it under Wine (`C:\playhook\games\`), + * fed to the silent dir-arg (`/DIR=` / `/D=`). + */ +export interface InstallDir { + readonly hostDir: string; + readonly installerDir: string; +} + +/** + * Platform install-dir resolution, injected into readManifests: maps a game `id` to both views of + * its app-controlled install dir, or null when install mode is unsupported on this platform/config + * (win32 with `%LOCALAPPDATA%` unset). `id` is already validated as a safe single path segment. + */ +export type InstallDirResolver = (id: string) => InstallDir | null; + /** * An atomic snapshot of the running processes (one OS call). The same snapshot answers BOTH "is a watched * image running?" and "is the launcher pid alive?" so the watched-process waits test both against one @@ -33,7 +71,7 @@ export interface ProcessSnapshot { /** * Platform process control: the snapshot-based watched-process tracking plus targeted liveness and - * force-kill. win32 wraps `tasklist`/`taskkill`; linux walks `/proc` and sends signals (Р3). + * force-kill. win32 wraps `tasklist`/`taskkill`; linux walks `/proc` and sends signals. */ export interface ProcessMonitor { /** One atomic snapshot of all visible processes. */ @@ -56,6 +94,12 @@ export interface ProcessMonitor { * every process tagged with this `SteamAppId`, plus a by-name sweep as a fallback. */ killSteamGame(appid: number, watchNames: readonly string[]): Promise; + /** + * Force-kills the given image names ELEVATED — win32 only: a runAsAdmin game's high-integrity processes + * survive a plain taskkill, so this runs ONE elevated `taskkill /F /T /IM …` (a single UAC prompt). + * Best-effort and fire-and-forget; the caller judges success by a fact-based poll. No-op elsewhere. + */ + killImagesElevated(imageNames: readonly string[]): void; } /** Locates the local Steam installation (the source of the steamapps libraries + compatdata prefixes). */ @@ -63,7 +107,7 @@ export interface SteamLocator { /** * The Steam install root (the dir containing `steamapps/`), or null when Steam isn't found. On win32 * this reads the registry (Valve\Steam); on linux it probes the well-known data dirs — native, - * flatpak, snap (Р4). Best-effort: any error → null. + * flatpak, snap. Best-effort: any error → null. */ locateSteam(): Promise; } @@ -71,7 +115,7 @@ export interface SteamLocator { /** * Spawns the game / installer / uninstaller. win32 dispatches to a direct `spawn` or an elevated * ShellExecuteEx per manifest.runAsAdmin; linux runs everything through umu-run in the game's Wine - * prefix (Р1/Р7) with no elevation (runAsAdmin is a no-op there — Р6). + * prefix with no elevation (runAsAdmin is a no-op there). */ export interface GameProcessLauncher { /** @@ -106,10 +150,23 @@ export interface GameProcessLauncher { install: ResolvedInstall, onProvisioning?: (active: boolean) => void, ): Promise; + /** + * Whether preparing `install`'s environment would run winetricks - linux: verbs still missing in the + * install's prefix; win32 / darwin: never. Background jobs use it to hold such a step back while a game + * runs under gamescope, where any window it opens would steal the game's only surface. + */ + needsProvisioning(install: ResolvedInstall): Promise; /** Launches a resolved uninstaller target silently. Throws on failure. */ launchUninstaller(target: LaunchTarget): Promise; /** - * The directory whose removal fully uninstalls the game (removed best-effort by the controller — Р7f). + * What to launch to uninstall an install-mode game, or null for a plain directory sweep. win32 runs + * the game's own uninstaller first (it must clean the shared system before the install dir is + * removed); linux removes the WHOLE per-game Wine prefix, so an in-prefix uninstaller is pointless + * (its registry/shortcut cleanup lives in the prefix about to be deleted) → always null there. + */ + resolveUninstaller(install: ResolvedInstallerRun): Promise; + /** + * The directory whose removal fully uninstalls the game (removed best-effort by the controller). * win32: the app-controlled install dir. linux: the WHOLE per-game Wine prefix — it holds the install * dir AND the game's provisioned runtimes (dotnet/GE-Proton env), so uninstall reclaims all of it. */ @@ -124,7 +181,7 @@ export interface GameProcessLauncher { } /** - * Resolves the Windows-dictionary `pcSavePath` to a physical folder for THIS game (Р5). The moment of + * Resolves the Windows-dictionary `pcSavePath` to a physical folder for THIS game. The moment of * resolution moved from manifest-read to sync-time so an unresolvable location (a Wine/compatdata prefix * that doesn't exist yet) is a no-op sync, not a rejected card. win32 keeps the env-based mapping; linux * maps every prefix inside the game's Wine prefix (exe/install) or the Steam compatdata prefix (steam). @@ -161,8 +218,8 @@ export interface SavePathResolver { } /** - * Executes the OS power actions behind the launcher's Shutdown/Reboot/Sleep menu (Р9). win32 uses the - * `shutdown` command + the powrprof SetSuspendState FFI; linux uses logind via `systemctl` (Э7). The + * Executes the OS power actions behind the launcher's Shutdown/Reboot/Sleep menu. win32 uses the + * `shutdown` command + the powrprof SetSuspendState FFI; linux uses logind via `systemctl`. The * PowerService (power.ts) owns the user-facing flow (confirm, quit, error copy) — this is only the OS bit. */ export interface PowerBackend { @@ -175,7 +232,7 @@ export interface PowerBackend { } /** - * Mounts inserted-but-unmounted removable volumes so the drive watcher can see them (Р10). Only SteamOS + * Mounts inserted-but-unmounted removable volumes so the drive watcher can see them. Only SteamOS * **Game Mode** wires this, as a safety net: the session normally mounts the card itself, but one that * lands there unmounted is just a block device with no mountpoint, and `scan()` (which walks mountpoints) * can never find it. win32 and the KDE desktop session mount on their own → no-op there. The caller @@ -268,7 +325,7 @@ export interface Platform { readonly powerBackend: PowerBackend; readonly removableMounter: RemovableMounter; /** - * Resolves the app-controlled install directory for an install-mode game id (Р7), injected into + * Resolves the app-controlled install directory for an install-mode game id, injected into * readManifests. win32 derives `%LOCALAPPDATA%\playhook\games\`; linux the game's Wine prefix. */ readonly resolveInstallDir: InstallDirResolver; @@ -280,7 +337,7 @@ export interface PlatformDeps { readonly getDocuments: () => string; /** app.getPath('userData') — the base for per-game Wine prefixes on linux (`/prefixes/`). */ readonly userData: string; - /** Absolute path to the bundled umu-run zipapp (extraResources), run via system python3 on linux (Р1). + /** Absolute path to the bundled umu-run zipapp (extraResources), run via system python3 on linux. * Unused on win32. */ readonly umuRunPath: string; /** diff --git a/src/main/platform/umu.ts b/src/main/platform/umu.ts index 3260a2c9..d01ee717 100644 --- a/src/main/platform/umu.ts +++ b/src/main/platform/umu.ts @@ -1,7 +1,7 @@ -// Pure helpers for launching Windows games through umu-launcher / Proton on Linux (Р1/Р2). No electron, +// Pure helpers for launching Windows games through umu-launcher / Proton on Linux. No electron, // no child_process — just the path/env/argv construction — so they are unit-tested directly. The bundled // umu-run is a python zipapp; we invoke it as `python3 ` (no reliance on the -// executable bit surviving packaging — Р11) with the WINEPREFIX/GAMEID/PROTONPATH env umu expects. +// executable bit surviving packaging) with the WINEPREFIX/GAMEID/PROTONPATH env umu expects. import path from 'node:path'; /** Default PROTONPATH: umu downloads and caches the latest GE-Proton into Steam's compatibilitytools.d. */ @@ -11,7 +11,7 @@ export const DEFAULT_PROTON = 'GE-Proton' as const; export const UMU_GAMEID = 'umu-default' as const; /** - * The per-game Wine prefix directory: `/prefixes/` (Р2). `id` is already validated as + * The per-game Wine prefix directory: `/prefixes/`. `id` is already validated as * `[A-Za-z0-9._-]` (no separators, not `.`/`..`), so it is a safe single path segment. */ export function prefixDir(userData: string, id: string): string { @@ -24,12 +24,12 @@ export function prefixDir(userData: string, id: string): string { const INSTALL_HOST_SUBPATH = ['drive_c', 'playhook', 'games'] as const; /** - * The app-controlled install directory for an install-mode game, in BOTH views (Р7): + * The app-controlled install directory for an install-mode game, in BOTH views: * - `hostDir` — the real path inside the game's Wine prefix (`/drive_c/playhook/games/`), * where the installed files physically land (all fs ops + the resolved executable); * - `installerDir` — the SAME place as the installer sees it under Wine (`C:\playhook\games\`), * fed to the silent dir-arg. The path has no spaces by construction (`id` ∈ `[A-Za-z0-9._-]`), so the - * Linux dir-arg can be passed unquoted (Р7). + * Linux dir-arg can be passed unquoted. */ export function installDirs( userData: string, @@ -41,7 +41,7 @@ export function installDirs( } /** - * Baseline winetricks verbs provisioned into every install-mode prefix before the installer runs (Р7b). + * Baseline winetricks verbs provisioned into every install-mode prefix before the installer runs. * These runtimes are what skinned Inno installers (isskin.dll) and many games need under a bare Proton * prefix; installing them proactively makes install mode work out of the box. Card-specific extras * (`install.winetricks`) are appended on top. @@ -95,7 +95,7 @@ export function buildUmuEnv( readonly prefix: string; readonly proton: string; readonly protonLogDir?: string; - /** umu GAMEID — a Steam appid or custom UMU_ID for the game's protonfix (Р7i). Defaults to `umu-default`. */ + /** umu GAMEID — a Steam appid or custom UMU_ID for the game's protonfix. Defaults to `umu-default`. */ readonly gameId?: string; }, ): NodeJS.ProcessEnv { @@ -114,7 +114,7 @@ export function buildUmuEnv( } // The Electron AppImage injects LD_LIBRARY_PATH / LD_PRELOAD pointing at its OWN bundled libraries. A // spawned system binary (python3 → umu → Proton) that inherits them loads mismatched libs and dies - // instantly (§5.1). Strip them so umu-run runs against the clean system libraries. umu/Proton set up + // instantly. Strip them so umu-run runs against the clean system libraries. umu/Proton set up // their own library environment from scratch, so nothing of ours needs to survive here. for (const key of ENV_STRIP_KEYS) delete env[key]; return env; diff --git a/src/main/platform/win32.ts b/src/main/platform/win32.ts index eec54bca..4dd7ab0a 100644 --- a/src/main/platform/win32.ts +++ b/src/main/platform/win32.ts @@ -1,12 +1,13 @@ // Windows implementations of the platform services. These wrap the existing win32 code (tasklist/taskkill // process control, the registry Steam lookup, the spawn/ShellExecuteEx launchers, the env-based save-path // mapping, the `shutdown` command + powrprof suspend). Behaviour is 1:1 with the pre-port code — the port -// only routes it through the interfaces so a linux implementation can take its place (Р3/Р4/Р5/Р9). +// only routes it through the interfaces so a linux implementation can take its place. import path from 'node:path'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import type { GameProcessLauncher, + InstallDirResolver, Platform, PlatformDeps, PowerBackend, @@ -18,19 +19,18 @@ import type { SteamShortcuts, } from './types'; import { + killImagesElevated, launchGame, launchInstaller, launchUninstaller, } from '../game-launcher'; +import { resolveUninstaller } from '../uninstaller.win32'; import { getSteamPath } from '../registry'; import { suspendToSleep } from '../power-native'; -import { - expandPcSavePath, - absoluteToPcSavePath, - type ManifestEnv, - type InstallDirResolver, -} from '../manifest'; +import { expandPcSavePath, absoluteToPcSavePath, type ManifestEnv } from '../manifest'; import { createTranslator } from '../../shared/i18n/index'; +import { log } from '../logger'; +import { describe } from '../util'; const execFileAsync = promisify(execFile); @@ -53,7 +53,10 @@ function createProcessMonitor(): ProcessMonitor { try { const { stdout } = await execFileAsync('tasklist', ['/NH', '/FO', 'CSV'], { windowsHide: true }); return makeWin32Snapshot(stdout.toLowerCase()); - } catch { + } catch (cause) { + // An empty snapshot reads as "everything exited" to the waits, so a tasklist failure must at + // least leave a trace — a game that quietly "ended" is otherwise impossible to diagnose. + log.warn('[process-monitor] tasklist failed — empty snapshot:', describe(cause)); return makeWin32Snapshot(''); } }, @@ -94,6 +97,7 @@ function createProcessMonitor(): ProcessMonitor { killSteamGame(_appid, watchNames): Promise { return monitor.killByName(watchNames); }, + killImagesElevated: (imageNames) => killImagesElevated(imageNames), }; return monitor; } @@ -131,7 +135,11 @@ function createGameLauncher(monitor: ProcessMonitor): GameProcessLauncher { // No Wine prefix on Windows: the install dir needs no preparation beyond the controller's own // pre-clean, and `copy` can write into it straight away. prepareInstallDir: () => Promise.resolve(), + needsProvisioning: () => Promise.resolve(false), launchUninstaller: (target) => launchUninstaller(target, monitor), + // The game's own uninstaller first (FS search in the install dir → registry fallback): it must clean + // the shared system before the install dir is removed. + resolveUninstaller: (install) => resolveUninstaller(install), // win32: no Wine prefix — uninstall removes the app-controlled install dir (after the game's own // uninstaller runs). 1:1 with the pre-port behaviour. uninstallDir: (install) => install.dir, @@ -141,7 +149,7 @@ function createGameLauncher(monitor: ProcessMonitor): GameProcessLauncher { } // ── SavePathResolver (env-based %PREFIX% mapping) ──────────────────────────── -// Reuses the existing pure expanders. The resolver contract returns null on failure (Р5: "nothing to +// Reuses the existing pure expanders. The resolver contract returns null on failure ("nothing to // sync"), so the translator only matters for messages we never surface here — a fixed 'en' one suffices. const noopTranslator = createTranslator('en'); diff --git a/src/main/power.ts b/src/main/power.ts index 6418d274..09c48384 100644 --- a/src/main/power.ts +++ b/src/main/power.ts @@ -1,6 +1,6 @@ // Power actions for the launcher's Shutdown/Reboot/Sleep menu. Interface-DI (like StatsService / // UpdaterService): the OS-touching bits are delegated to the platform PowerBackend (win32 `shutdown` + -// powrprof FFI; linux `systemctl` via logind — Р9), and the app quit + error channel are injected, so +// powrprof FFI; linux `systemctl` via logind), and the app quit + error channel are injected, so // this module is pure/electron/koffi-free on import and unit-testable in vitest with a fake backend (see // test/power.test.ts). main.ts bootstraps it with the real backend and registers the IPC channels (NOT // GameController — power isn't part of the game flow). @@ -9,14 +9,13 @@ // no further prompt. import type { Translator } from '../shared/i18n/index'; import type { PowerBackend } from './platform'; +import { type PowerAction } from './platform/types'; import { log } from './logger'; import { describe } from './util'; -export type PowerAction = 'shutdown' | 'reboot' | 'sleep'; - export interface PowerServiceDeps { /** OS backend for the actual power commands (platform.powerBackend). `supported=false` → we surface - * "unsupported" instead of running anything, replacing the old win32-only guard (Р9). */ + * "unsupported" instead of running anything, replacing the old win32-only guard. */ readonly backend: PowerBackend; /** Quits the app after a successful shutdown/reboot (real: the bootstrap quit(), which drops close-guards). */ quit(): void; diff --git a/src/main/quit-gate.ts b/src/main/quit-gate.ts new file mode 100644 index 00000000..ff8d0f18 --- /dev/null +++ b/src/main/quit-gate.ts @@ -0,0 +1,109 @@ +import type { QuitAction, QuitConfirmReply } from '../shared/quit'; +import { delay } from './util'; +import { log } from './logger'; + +const DEFAULT_QUIT_CONFIRM_ACK_MS = 2000; + +const DEFAULT_QUIT_GRACE_MS = 3000; + +/** What the gate needs from the app: the job count, the launcher window, and a native fallback. */ +export interface QuitGateDeps { + /** How many background installs / uninstalls are queued or running. */ + activeJobs(): number; + showAndFocus(): void; + /** Asks the launcher window to show its own quit question for `action`. */ + askInWindow(action: QuitAction): void; + /** The emergency question when the window never confirms it is asking; true means "go ahead". */ + askNatively(action: QuitAction, jobs: number): Promise; + /** How long the window has to confirm it is asking before the native fallback. */ + readonly ackTimeoutMs?: number; +} + +interface PendingQuit { + readonly action: QuitAction; + readonly run: () => void; + timer: ReturnType | null; +} + +/** A positive integer number of milliseconds from `env[name]`, otherwise `fallback`. */ +function envMs(env: NodeJS.ProcessEnv, name: string, fallback: number): number { + const parsed = Number.parseInt(env[name] ?? '', 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +/** PLAYHOOK_QUIT_CONFIRM_ACK_MS, two seconds by default. */ +export function quitConfirmAckMs(env: NodeJS.ProcessEnv = process.env): number { + return envMs(env, 'PLAYHOOK_QUIT_CONFIRM_ACK_MS', DEFAULT_QUIT_CONFIRM_ACK_MS); +} + +/** PLAYHOOK_QUIT_GRACE_MS, three seconds by default. */ +export function quitGraceMs(env: NodeJS.ProcessEnv = process.env): number { + return envMs(env, 'PLAYHOOK_QUIT_GRACE_MS', DEFAULT_QUIT_GRACE_MS); +} + +/** Waits for `work`, but never longer than `ms`: the exit must not hang on a cleanup that does. */ +export async function withGrace(work: Promise, ms: number): Promise { + await Promise.race([work.then(() => undefined), delay(ms)]); +} + +/** + * The single gate every managed way out goes through (Quit from the launcher or the tray, Shutdown, + * Reboot). With installs or uninstalls running, an unconfirmed request does not leave: the launcher window + * asks first, and when it never confirms it is asking (destroyed, hung, reloading), a native dialog does. + */ +export class QuitGate { + private pending: PendingQuit | null = null; + private readonly ackTimeoutMs: number; + + constructor(private readonly deps: QuitGateDeps) { + this.ackTimeoutMs = deps.ackTimeoutMs ?? quitConfirmAckMs(); + } + + /** Runs `run` now when confirmed or nothing is running; otherwise asks first. True when it ran. */ + request(action: QuitAction, confirmed: boolean, run: () => void): boolean { + if (confirmed || this.deps.activeJobs() === 0) { + this.settle(); + run(); + return true; + } + this.ask(action, run); + return false; + } + + /** The window's answer to the question: shown (or queued), or closed without a Yes. */ + reply(reply: QuitConfirmReply): void { + const pending = this.pending; + if (pending === null) return; + if (reply === 'dismissed') { + this.settle(); + return; + } + if (pending.timer !== null) clearTimeout(pending.timer); + pending.timer = null; + } + + private ask(action: QuitAction, run: () => void): void { + if (this.pending !== null) return; + log.info(`[quit] ${action} waits for a confirmation: ${this.deps.activeJobs()} job(s) running`); + this.deps.showAndFocus(); + this.deps.askInWindow(action); + const pending: PendingQuit = { action, run, timer: null }; + pending.timer = setTimeout(() => void this.askNatively(pending), this.ackTimeoutMs); + this.pending = pending; + } + + private async askNatively(pending: PendingQuit): Promise { + pending.timer = null; + log.warn('[quit] the launcher window did not confirm the question - asking natively'); + const go = await this.deps.askNatively(pending.action, this.deps.activeJobs()); + if (this.pending !== pending) return; + this.pending = null; + if (go) pending.run(); + } + + private settle(): void { + const timer = this.pending?.timer ?? null; + if (timer !== null) clearTimeout(timer); + this.pending = null; + } +} diff --git a/src/main/registry.ts b/src/main/registry.ts index b51dc86f..8dcf52f7 100644 --- a/src/main/registry.ts +++ b/src/main/registry.ts @@ -159,11 +159,11 @@ function openKeyReadString( /** * Reads the Steam install path from the registry, or null. Primary: HKCU\Software\Valve\Steam\SteamPath * (a REG_SZ with FORWARD slashes — Steam's own convention). Fallback: HKLM\SOFTWARE\WOW6432Node\Valve\ - * Steam\InstallPath (backslashes). Best-effort: any error or non-Windows → null. Used to locate - * steamapps/.acf for the "installed in Steam" detection and to gate steam:// launches. + * Steam\InstallPath (backslashes). Best-effort: any error → null. Used to locate steamapps/.acf for + * the "installed in Steam" detection and to gate steam:// launches. win32-only: reached only through the + * win32 SteamLocator. */ export async function getSteamPath(): Promise { - if (process.platform !== 'win32') return null; // Async signature for a uniform call site (mirrors findUninstallEntry); the work itself is sync FFI. return Promise.resolve().then(() => { try { @@ -238,10 +238,10 @@ function scanBranch( /** * Finds the Uninstall registry entry whose InstallLocation equals `installDir` (across HKLM 64-bit, - * HKLM WOW6432Node, and HKCU), or null. Best-effort: returns null on any error or on non-Windows. + * HKLM WOW6432Node, and HKCU), or null. Best-effort: returns null on any error. win32-only: reached + * only through the win32 GameProcessLauncher's resolveUninstaller. */ export async function findUninstallEntry(installDir: string): Promise { - if (process.platform !== 'win32') return null; // Async signature for a uniform call site (mirrors resolveUninstaller); the work itself is sync FFI. return Promise.resolve().then(() => { try { diff --git a/src/main/remove-with-retry.ts b/src/main/remove-with-retry.ts new file mode 100644 index 00000000..483c2b87 --- /dev/null +++ b/src/main/remove-with-retry.ts @@ -0,0 +1,30 @@ +// The backed-off directory sweep every uninstall path ends in — the win32 uninstall, the linux prefix +// cleanup, the copy-mode uninstall. OS-neutral, so it lives outside the win32 module it was carved from. +import fse from 'fs-extra'; +import { delay } from './util'; + +// Directory removal retries: an Inno uninstaller forks a copy of itself into +// temp and exits early, so right after waitForExit it may still hold `unins000.*` for a moment — a +// few backed-off retries let the lock clear before fse.remove succeeds. +const REMOVE_RETRY_ATTEMPTS = 3; +const REMOVE_RETRY_BASE_MS = 300; + +/** + * Removes a directory with a few backed-off retries: the forked Inno uninstaller may still hold + * files for a moment after waitForExit. Checks `signal.aborted` between attempts (fse.remove itself is + * not interruptible). Throws the last error if every attempt fails. + */ +export async function removeWithRetry(dir: string, signal?: AbortSignal): Promise { + let lastError: unknown; + for (let attempt = 1; attempt <= REMOVE_RETRY_ATTEMPTS; attempt += 1) { + if (signal?.aborted === true) return; + try { + await fse.remove(dir); + return; + } catch (cause) { + lastError = cause; + if (attempt < REMOVE_RETRY_ATTEMPTS) await delay(REMOVE_RETRY_BASE_MS * attempt); + } + } + throw lastError instanceof Error ? lastError : new Error(String(lastError)); +} diff --git a/src/main/save-sync-flow.ts b/src/main/save-sync-flow.ts new file mode 100644 index 00000000..8de55346 --- /dev/null +++ b/src/main/save-sync-flow.ts @@ -0,0 +1,192 @@ +// The save sync around a session, and the deferred flush a card receives on insert: the change-based +// card↔PC sync before a launch (sync-in) and after it (sync-out), the pending-flush queue for a card that +// was pulled mid-game, and the baseline the next change-detection reads. Split out of the sequences so +// the launch sequence reads as launch → wait → sync, and the insert path shares the same flush. +import fse from 'fs-extra'; +import { type Stats } from '../shared/types'; +import type { ResolvedManifest } from './manifest-types'; +import { type ControllerDeps } from './controller-deps'; +import { acceptsPendingFlush, type SyncSlot } from './pc-store'; +import { syncDir, syncByChange, snapshotTree } from './save-sync'; +import { type PcSaveLocation, type Platform } from './platform'; +import { describe } from './util'; +import { log } from './logger'; + +export type SaveSyncFlowDeps = Pick & { + readonly savePathResolver: Platform['savePathResolver']; + /** Whether this game's source is available right now (a card game needs its card in). */ + readonly sourceAvailable: (manifest: ResolvedManifest) => boolean; +}; + +export class SaveSyncFlow { + constructor(private readonly deps: SaveSyncFlowDeps) {} + + async flushPendingIfAny(manifest: ResolvedManifest): Promise { + // Enforced by the predicate rather than by "we only call this from loadCard": a local game HAS a + // saveOnCardPath (its own backup), so a future symmetrical call from the PC-library path would + // otherwise empty the queue into that backup and lose the progress meant for the card. + const cardPath = manifest.saveOnCardPath; + if (!acceptsPendingFlush(manifest) || cardPath === undefined) return; + const pending = await this.deps.store.getPending(manifest.raw.id); + if (pending === null) return; + // Direct, NOT change-based (deliberate): the snapshot exists precisely because + // the card was yanked mid-game and we are OBLIGED to top up the promised PC progress onto the card. + // LWW here would silently drop that flush if the card looked "unchanged"/newer, so keep it a plain + // snapshot→card replace. + await syncDir(pending.savesSnapshotDir, cardPath); + const stats = await this.deps.stats.read(manifest.raw.id); + await this.deps.stats.copyToCard(manifest.root, manifest.raw.id, stats); + await this.deps.store.clearPending(manifest.raw.id); + // The card now holds the flushed progress, so both sides are back in sync. Rebase the baseline from + // the real folders (each in its own mtime scale) so the next launch sees them as synced, not as a + // spurious card-side change that would trigger a needless card→PC. + await this.rebaseSyncStateAfterFlush(manifest); + } + + /** + * Resolves the manifest's DEFERRED pcSavePath to this game's save location via the platform + * SavePathResolver, or null when there's nothing to sync (no pcSavePath declared, or a steam game with + * no compatdata yet). win32 keeps the exact env-based expansion the manifest used to do eagerly; linux + * maps inside the game's prefix. `containerExists` tells whether that prefix is actually there — see + * runSaveSync for why that matters. + */ + async resolvePcSavePath(manifest: ResolvedManifest): Promise { + if (manifest.pcSavePath === undefined) return null; + return this.deps.savePathResolver.resolvePcSavePath(manifest, manifest.pcSavePath); + } + + /** Records a fresh sync baseline from both real save folders (used after a direct pending-flush). */ + private async rebaseSyncStateAfterFlush(manifest: ResolvedManifest): Promise { + const cardPath = manifest.saveOnCardPath; + if (cardPath === undefined || manifest.pcSavePath === undefined) return; + const pcSave = await this.resolvePcSavePath(manifest); + // No prefix → no PC half worth recording: a baseline whose `pc` describes a non-existent container is + // exactly what makes the next sync-in mistake "prefix wiped" for "saves deleted" (see runSaveSync). + if (pcSave === null || !pcSave.containerExists) return; + await this.deps.store.writeSyncState(manifest.raw.id, { + card: await snapshotTree(cardPath), + pc: await snapshotTree(pcSave.path), + syncedAt: Date.now(), + }); + } + + /** + * Runs a bidirectional, change-based save sync (syncByChange) and persists the new baseline. The + * `fallback` direction is used only on the FIRST run (no baseline yet): 'card-to-pc' for sync-in, + * 'pc-to-card' for sync-out — i.e. the phase's old deterministic direction. Otherwise the direction is + * chosen by which side changed since the last sync. A conflict (both changed) and a fallback are logged. + * Throws propagate to the caller (sync-in swallows them softly; sync-out defers to pending-flush). + * + * `containerExists=false` (linux: the game's Wine prefix is gone — never created, or wiped by an + * uninstall) DISCARDS the baseline. That is a data-integrity rule, not an optimisation: change-detection + * reads an empty PC side against a baseline that lists files as "every save was deleted here" and would + * replicate that deletion onto the card — destroying the only surviving copy. The container being absent + * means the PC side has no authority at all, so the baseline describes a world that no longer exists; + * dropping it falls back to the phase direction (card→PC on sync-in), which restores the card's saves. + */ + async runSaveSync( + manifest: ResolvedManifest, + cardPath: string, + pcPath: string, + fallback: 'card-to-pc' | 'pc-to-card', + containerExists: boolean, + ): Promise { + const id = manifest.raw.id; + // A local game syncs against its own backup, not against a card, so it keeps its baseline in its own + // slot: one shared baseline for both pairings would make each sync see the other's changes as a + // conflict (see PcStore.syncStatePath). + const slot: SyncSlot = manifest.source === 'pc' ? 'pc' : 'card'; + const baseline = containerExists ? await this.deps.store.readSyncState(id, slot) : null; + if (!containerExists) { + log.info( + `[save-sync] id=${id} PC container absent → baseline discarded, card is authoritative`, + ); + } + const result = await syncByChange(cardPath, pcPath, baseline, fallback); + if (result.conflict) { + // The only branch that can lose data: both sides changed, LWW picked one. The losing side survives + // only as syncDir's `.bak`. Logged loudly so it's visible in the diagnostics. + log.warn( + `[save-sync] CONFLICT id=${id}: both sides changed since last sync → ${result.direction} by LWW (losing side kept as .bak)`, + ); + } + log.info( + `[save-sync] id=${id} direction=${result.direction}${result.usedFallback ? ' (fallback: no baseline)' : ''}`, + ); + await this.deps.store.writeSyncState(id, result.state, slot); + } + + async performSyncOut(manifest: ResolvedManifest, stats: Stats): Promise { + const id = manifest.raw.id; + // Resolve the deferred pcSavePath once for this game. The game just ran, so its prefix exists + // and (on win32) the env expansion always succeeds — this matches the pre-port physical path exactly. + // A prefix that is somehow absent here means the game wrote nothing we could carry back: there is no + // source to copy from, so treat it as "no PC side" rather than syncing an emptiness onto the card. + const resolved = await this.resolvePcSavePath(manifest); + const pcPath = resolved !== null && resolved.containerExists ? resolved.path : null; + if (resolved !== null && !resolved.containerExists) { + log.warn( + `[sync-out] the Wine prefix for id=${id} is gone — nothing to copy back to the card`, + ); + } + // The card is already removed (the expected scenario) → defer PC→SD into pending-flush. A local game + // is never "removed", so it always takes the sync path below (its backup is always reachable). + if (!this.deps.sourceAvailable(manifest)) { + if (pcPath !== null) { + await this.deps.store.enqueuePcToSd(id, pcPath); + } + return; + } + if (pcPath !== null && manifest.saveOnCardPath !== undefined) { + // Diagnostic (silent-failure guard): syncDir no-ops when the source is missing. If the PC save + // folder doesn't exist after a play session, pcSavePath is almost certainly wrong in game.json + // (e.g. %APPDATA% used for an AppData\LocalLow path) — warn instead of failing silently. + if (!(await fse.pathExists(pcPath))) { + log.warn( + `[sync-out] pcSavePath does not exist — nothing copied to the card. Check the manifest path: "${manifest.pcSavePath}" (resolved: "${pcPath}")`, + ); + } else { + try { + // Change-based sync after the game (phase = sync-out). The old blind PC→card is only the + // first-run fallback; normally the changed side wins (this PC just played → usually PC→card). + log.info( + `[sync-out] change-based sync between PC "${pcPath}" and card "${manifest.saveOnCardPath}"`, + ); + // containerExists is true here by construction (pcPath is null otherwise), so the baseline is + // honoured exactly as before — sync-out semantics are unchanged. + await this.runSaveSync(manifest, manifest.saveOnCardPath, pcPath, 'pc-to-card', true); + if (manifest.source === 'pc') await this.queueLocalProgressForCard(manifest, pcPath); + } catch (cause) { + // The card may have been yanked during the sync → saves.bak is intact, we'll finish on insertion. + log.warn('[sync-out] failed, deferring to pending-flush:', describe(cause)); + await this.deps.store.enqueuePcToSd(id, pcPath); + return; + } + } + } + // A local game's stats mirror lives on the PC and is the only copy there is — there is no card to + // write a travelling stats.json to, and writing one into userData would mean nothing. + if (manifest.source === 'card') { + await this.deps.stats.copyToCard(manifest.root, manifest.raw.id, stats); + } + } + + /** + * "The saves move to the card": after a local game's session, ALSO queue a PC→SD flush, so inserting a + * card that carries the same game tops it up with the progress made without it (the existing + * flushPendingIfAny on insert does the actual copy). + * + * Only when a CARD baseline exists for this id — i.e. that card has been seen on this machine before. + * Without that condition every local session would leave a third full copy of the saves behind, growing + * on disk forever, for a card that may never exist. + */ + private async queueLocalProgressForCard( + manifest: ResolvedManifest, + pcPath: string, + ): Promise { + const id = manifest.raw.id; + if (!(await this.deps.store.hasCardSyncState(id))) return; + await this.deps.store.enqueuePcToSd(id, pcPath); + log.info(`[sync-out] id=${id} local session queued for the card it was last synced with`); + } +} diff --git a/src/main/save-sync.ts b/src/main/save-sync.ts index 85a61b11..372711da 100644 --- a/src/main/save-sync.ts +++ b/src/main/save-sync.ts @@ -68,7 +68,7 @@ export async function syncDir(src: string, dest: string): Promise { // compare is unreliable across FAT32/DST). A side "changed" iff a file was added/removed or a common // file's mtime grew beyond a tolerance. Deletions are caught for free: a file present in the snapshot but // missing now means that side changed → it becomes the source, and the folder-level replace carries the -// deletion across. See the plan (part B) for the full rationale. +// deletion across. /** A snapshot of a save folder: relative file path (POSIX-normalized) → mtimeMs. Empty for a missing folder. */ export type TreeSnapshot = Record; diff --git a/src/main/settings-service.ts b/src/main/settings-service.ts new file mode 100644 index 00000000..d19f44ff --- /dev/null +++ b/src/main/settings-service.ts @@ -0,0 +1,230 @@ +// The Settings screen's backend: every settings:* write, the settings snapshot / reset, the audio +// options and the app version the screen shows. Interface-DI (like UpdaterService / NotificationsService): +// each setting that has a live side effect hands the new value to an `on*Changed` callback owned by +// main.ts, which is where the affected service lives (the global gamepad listener, the powerSaveBlocker, +// the game window's audio, the locale, electron-updater's flags). The service itself only persists. +// +// Every write funnels through AppSettingsStore, whose own onChange pushes settings:update to the launcher — +// so no handler here answers with the new snapshot; the screen repaints from that push. +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { ipcMain } from 'electron'; +import { log } from './logger'; +import { + IPC, + type AppSettings, + type AudioOptions, + type AudioVolumes, + type AutoUpdateMode, + type LanguageMode, +} from '../shared/types'; +import { type AppSettingsStore } from './app-settings'; +import { DEFAULT_SOUND_SET } from './asset-reader'; + +export interface SettingsServiceDeps { + readonly settings: AppSettingsStore; + /** The running app's version, shown on the Settings screen. */ + readonly appVersion: () => string; + /** Whether the Steam-shortcut feature exists on this machine (linux + packaged AppImage). */ + readonly isSteamAvailable: () => boolean; + /** Applies a persisted auto-update mode to electron-updater (a no-op where updates are unsupported). */ + readonly onAutoUpdateModeChanged: (mode: AutoUpdateMode) => void; + /** Applies the pre-release channel flag to electron-updater (a no-op where updates are unsupported). */ + readonly onPrereleaseChanged: (on: boolean) => void; + /** Applies the Start+Back summon-hotkey toggle to the running global gamepad listener. */ + readonly onSummonHotkeyChanged: (enabled: boolean) => void; + /** Applies the keep-display-awake toggle (recomputes the powerSaveBlocker in main). */ + readonly onPreventScreensaverChanged: (enabled: boolean) => void; + /** + * Applies the Game Mode auto-launch toggle: installs or tears down the watcher service. Steam Deck + * only; a no-op elsewhere. + */ + readonly onSteamAutoLaunchChanged: (enabled: boolean) => Promise; + /** Applies the "always show the no-card screen" toggle (reconciles the launcher's visibility). */ + readonly onKeepOpenWithoutCardChanged: (enabled: boolean) => void; + /** Pushes new audio volumes to the game renderer so they apply live. */ + readonly onVolumesChanged: (volumes: AudioVolumes) => void; + /** Applies a navigation-sound-set change (re-reads + re-pushes the current sfx to the game window). */ + readonly onSoundSetChanged: (set: string) => void; + /** Applies an "only global ambience" toggle (recomputes + re-pushes the card's music). */ + readonly onAudioScopeChanged: () => void; + /** Applies a default-ambience change (re-reads the track + pushes it to the game window). */ + readonly onAmbientChanged: (track: string | null) => void; + /** Applies a UI-language change (re-resolve locale, rebuild tray/titles, push to live windows). */ + readonly onLanguageChanged: (mode: LanguageMode) => void; +} + +export class SettingsService { + constructor(private readonly deps: SettingsServiceDeps) {} + + /** + * The single point where all settings:* / app:version / volume / audio-options IPC is registered. + * Keeping IPC registration here — and NOWHERE else — rules out a duplicate ipcMain.handle (a crash) + * or a forgotten channel. + */ + init(): void { + ipcMain.handle(IPC.settingsRequest, () => this.deps.settings.read()); + // Drives whether the Steam settings are rendered at all — the renderer has no way to know the OS. + ipcMain.handle(IPC.settingsSteamAvailable, () => this.deps.isSteamAvailable()); + ipcMain.on(IPC.settingsSetAutoUpdate, (_event, mode: AutoUpdateMode) => { + void this.deps.settings + .setAutoUpdate(mode) + .then(() => this.deps.onAutoUpdateModeChanged(mode)) + .catch((cause: unknown) => + log.error('[settings] failed to persist auto-update mode:', cause), + ); + }); + ipcMain.on(IPC.settingsSetPrerelease, (_event, on: boolean) => { + void this.deps.settings + .patch({ allowPrerelease: on }) + .then(() => this.deps.onPrereleaseChanged(on)) + .catch((cause: unknown) => + log.error('[settings] failed to persist prerelease flag:', cause), + ); + }); + ipcMain.on(IPC.settingsSetSummonHotkey, (_event, on: boolean) => { + void this.deps.settings + .patch({ summonHotkeyEnabled: on }) + .then(() => this.deps.onSummonHotkeyChanged(on)) + .catch((cause: unknown) => log.error('[settings] failed to persist summon hotkey:', cause)); + }); + ipcMain.on(IPC.settingsSetPreventScreensaver, (_event, on: boolean) => { + void this.deps.settings + .patch({ preventScreensaver: on }) + .then(() => this.deps.onPreventScreensaverChanged(on)) + .catch((cause: unknown) => + log.error('[settings] failed to persist prevent-screensaver:', cause), + ); + }); + ipcMain.on(IPC.settingsSetKeepOpenWithoutCard, (_event, on: boolean) => { + void this.deps.settings + .patch({ keepOpenWithoutCard: on }) + .then(() => this.deps.onKeepOpenWithoutCardChanged(on)) + .catch((cause: unknown) => + log.error('[settings] failed to persist always-show-empty-screen:', cause), + ); + }); + // No side-effect on toggle: the install flow reads disableSilentInstall from settings at install time. + ipcMain.on(IPC.settingsSetSteamAutoLaunch, (_event, on: boolean) => { + this.deps.settings + .patch({ steamAutoLaunch: on }) + .then(() => this.deps.onSteamAutoLaunchChanged(on)) + .catch((cause: unknown) => log.error('[settings] failed to set steam auto-launch:', cause)); + }); + + ipcMain.on(IPC.settingsSetDisableSilentInstall, (_event, on: boolean) => { + void this.deps.settings + .patch({ disableSilentInstall: on }) + .catch((cause: unknown) => + log.error('[settings] failed to persist disable-silent-install:', cause), + ); + }); + ipcMain.on(IPC.settingsSetMusicVolume, (_event, volume: number) => { + void this.setVolume({ musicVolume: volume }); + }); + ipcMain.on(IPC.settingsSetSfxVolume, (_event, volume: number) => { + void this.setVolume({ sfxVolume: volume }); + }); + ipcMain.on(IPC.settingsSetSoundSet, (_event, set: string) => { + void this.deps.settings + .patch({ soundSet: set }) + .then(() => this.deps.onSoundSetChanged(set)) + .catch((cause: unknown) => log.error('[settings] failed to persist sound set:', cause)); + }); + ipcMain.on(IPC.settingsSetAmbientTrack, (_event, track: string | null) => { + void this.deps.settings + .patch({ ambientTrack: track }) + .then(() => this.deps.onAmbientChanged(track)) + .catch((cause: unknown) => log.error('[settings] failed to persist ambient track:', cause)); + }); + ipcMain.on(IPC.settingsSetOnlyGlobalAmbient, (_event, on: boolean) => { + void this.deps.settings + .patch({ onlyGlobalAmbient: on }) + .then(() => this.deps.onAudioScopeChanged()) + .catch((cause: unknown) => + log.error('[settings] failed to persist only-global-ambient:', cause), + ); + }); + // No side-effect on change: MetadataService reads the key from settings at request time, so the next + // search already uses whatever was typed here. + ipcMain.on(IPC.settingsSetSteamGridDbKey, (_event, key: string) => { + void this.deps.settings + .patch({ steamGridDbApiKey: key }) + .catch((cause: unknown) => + log.error('[settings] failed to persist the SteamGridDB key:', cause), + ); + }); + // Language mirrors the summon-hotkey path: persist, then hand the mode to the deps callback (main + // re-resolves the locale, rebuilds tray/titles and pushes the effective locale to every live window). + ipcMain.on(IPC.settingsSetLanguage, (_event, mode: LanguageMode) => { + void this.deps.settings + .setLanguage(mode) + .then(() => this.deps.onLanguageChanged(mode)) + .catch((cause: unknown) => log.error('[settings] failed to persist language:', cause)); + }); + ipcMain.handle(IPC.settingsReset, (): Promise => this.resetSettings()); + // game-renderer startup: hand it the current volumes to seed its AudioController. + ipcMain.handle(IPC.volumeRequest, async (): Promise => { + const settings = await this.deps.settings.read(); + return { music: settings.musicVolume, sfx: settings.sfxVolume }; + }); + ipcMain.handle(IPC.appVersionRequest, (): string => this.deps.appVersion()); + ipcMain.handle(IPC.audioOptionsRequest, (): Promise => this.readAudioOptions()); + } + + // Resets settings to defaults and re-applies every side effect (auto-update mode, prerelease flag, + // summon-hotkey toggle, renderer volumes). The Settings screen repaints from the settings:update push + // the write itself emits (AppSettingsStore.onChange), not from this return value — which is kept + // because settings:reset is an invoke. + private async resetSettings(): Promise { + const next = await this.deps.settings.reset(); + this.deps.onPrereleaseChanged(next.allowPrerelease); + this.deps.onAutoUpdateModeChanged(next.autoUpdate); + this.deps.onSummonHotkeyChanged(next.summonHotkeyEnabled); + this.deps.onPreventScreensaverChanged(next.preventScreensaver); + this.deps.onKeepOpenWithoutCardChanged(next.keepOpenWithoutCard); + // A reset turns auto-launch back on — the watcher unit has to come back with it, or the setting + // would say "on" while nothing is actually watching. + await this.deps.onSteamAutoLaunchChanged(next.steamAutoLaunch); + this.deps.onVolumesChanged({ music: next.musicVolume, sfx: next.sfxVolume }); + this.deps.onSoundSetChanged(next.soundSet); + this.deps.onAmbientChanged(next.ambientTrack); + this.deps.onLanguageChanged(next.language); + return next; + } + + // Persists a volume change and pushes the full volume pair to the game renderer so it applies live. + private async setVolume( + partial: { musicVolume?: number } | { sfxVolume?: number }, + ): Promise { + try { + const next = await this.deps.settings.patch(partial); + this.deps.onVolumesChanged({ music: next.musicVolume, sfx: next.sfxVolume }); + } catch (cause) { + log.error('[settings] failed to persist volume:', cause); + } + } + + // The bundled sound sets + ambience tracks, read once from dist/audio/index.json (generated at build + // time by copy-assets — the runtime never does a readdir over the asar). A read/parse failure falls back + // to a minimal, always-valid set so the settings dropdowns still populate. + private audioOptions: AudioOptions | null = null; + private async readAudioOptions(): Promise { + if (this.audioOptions !== null) return this.audioOptions; + try { + const text = await fs.readFile(path.join(__dirname, '../audio/index.json'), 'utf8'); + const parsed = JSON.parse(text) as { soundSets?: unknown; ambientTracks?: unknown }; + const asStringArray = (value: unknown): string[] => + Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []; + const soundSets = asStringArray(parsed.soundSets); + this.audioOptions = { + soundSets: soundSets.length > 0 ? soundSets : [DEFAULT_SOUND_SET], + ambientTracks: asStringArray(parsed.ambientTracks), + }; + } catch (cause) { + log.warn('[settings] failed to read audio index — using defaults:', cause); + this.audioOptions = { soundSets: [DEFAULT_SOUND_SET], ambientTracks: [] }; + } + return this.audioOptions; + } +} diff --git a/src/main/stats.ts b/src/main/stats.ts index 3068d65d..089719d4 100644 --- a/src/main/stats.ts +++ b/src/main/stats.ts @@ -12,12 +12,15 @@ import path from 'node:path'; import fse from 'fs-extra'; import { z } from 'zod'; -import { CARD_STATS_FILENAME, type Stats } from '../shared/types'; +import { type Stats } from '../shared/types'; import { parseStats, statsSchema, type PcStore } from './pc-store'; -import { writeFileAtomicEnsuringDir } from './json-store'; +import { isEnoent, writeFileAtomicEnsuringDir } from './json-store'; import { log } from './logger'; import { describe } from './util'; +/** File name of the stats copy on the card (best-effort). */ +export const CARD_STATS_FILENAME = 'stats.json' as const; + // The per-id card-stats map (v2). Built on the shared single-game statsSchema so "a valid Stats record" // has one definition. `games` is a record id→Stats; `schemaVersion` distinguishes it from a bare v1 file. const cardStatsMapSchema = z.object({ @@ -78,9 +81,7 @@ export class StatsService { try { raw = await fse.readJson(path.join(cardRoot, CARD_STATS_FILENAME)); } catch (cause) { - if (cause instanceof Error && (cause as { code?: unknown }).code === 'ENOENT') { - return { kind: 'empty' }; - } + if (isEnoent(cause)) return { kind: 'empty' }; log.warn(`[stats] failed to read card stats at "${cardRoot}":`, describe(cause)); return { kind: 'corrupt' }; } diff --git a/src/main/steam-activity-watch.ts b/src/main/steam-activity-watch.ts new file mode 100644 index 00000000..5c9bce92 --- /dev/null +++ b/src/main/steam-activity-watch.ts @@ -0,0 +1,248 @@ +import { type GameActivity } from '../shared/activity'; +import { type ActivityRegistry } from './activity-registry'; +import { steamInstallStatuses, type SteamInstallStatus } from './steam'; +import { type SteamLocator } from './platform'; +import { log } from './logger'; +import { describe } from './util'; + +const DEFAULT_STEAM_POLL_MS = 5000; + +const STEAM_UNINSTALL_TIMEOUT_MS = 60_000; + +/** The game one of the completion callbacks is about — everything a notification needs to name it. */ +export interface SteamWatchGame { + readonly id: string; + readonly title: string; +} + +/** One Steam game the watch polls: its id, its title and the appid Steam knows it by. */ +export interface SteamWatchEntry extends SteamWatchGame { + readonly appid: number; +} + +/** What the watch needs from the launcher. */ +export interface SteamActivityWatchDeps { + /** Every Steam game whose source is available right now. */ + listSteamGames(): readonly SteamWatchEntry[]; + readonly registry: Pick; + steamLocator(): SteamLocator; + /** The id of the game the busy session is about, or null when no session is in flight. */ + sessionGameId(): string | null; + /** Steam changed what the launcher can do with game `id` (Install / Play / Uninstall). */ + onGameChanged(id: string): void; + /** A download that started as an install finished. */ + onInstallCompleted(game: SteamWatchGame): void; + /** A steam://uninstall the launcher requested actually removed the game. */ + onUninstallCompleted(game: SteamWatchGame): void; +} + +/** Test seams: the poll cadence and the clock the uninstall timeout is measured with. */ +export interface SteamActivityWatchOptions { + readonly intervalMs?: number; + readonly now?: () => number; +} + +type SteamState = SteamInstallStatus['state']; + +interface TrackedGame { + readonly state: SteamState; + readonly updating: boolean; +} + +/** The poll cadence: PLAYHOOK_STEAM_POLL_MS when it is a positive integer, otherwise five seconds. */ +export function steamPollIntervalMs(env: NodeJS.ProcessEnv = process.env): number { + const parsed = Number.parseInt(env['PLAYHOOK_STEAM_POLL_MS'] ?? '', 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_STEAM_POLL_MS; +} + +/** + * The activity a download shows: an install or an update, with the snapshot percent while it is paused, + * and an install marked as a finished pre-load while the game waits for its release. + */ +function downloadActivity( + kind: 'steam-installing' | 'steam-updating', + status: Extract, +): GameActivity { + const base: GameActivity = + status.paused && status.progress !== null + ? { kind, paused: true, pausedProgress: status.progress } + : { kind, paused: status.paused }; + return base.kind === 'steam-installing' && status.preloaded ? { ...base, preloaded: true } : base; +} + +/** Whether the activity is one this watch owns (it never touches a launcher job's activity). */ +function isSteamActivity(activity: GameActivity | undefined): boolean { + return activity !== undefined && activity.kind.startsWith('steam-'); +} + +/** + * Polls Steam's `.acf` state of every available Steam game and keeps each game's Steam activity in the + * registry: downloads, updates and removals the launcher asked for, for all games at once and independently + * of what the launcher has selected. + */ +export class SteamActivityWatch { + private timer: ReturnType | null = null; + private tickInFlight = false; + private rescan = false; + private stopped = false; + private lastTick: Promise = Promise.resolve(); + private readonly tracked = new Map(); + private readonly uninstallRequests = new Map(); + private readonly intervalMs: number; + private readonly now: () => number; + + constructor( + private readonly deps: SteamActivityWatchDeps, + options: SteamActivityWatchOptions = {}, + ) { + this.intervalMs = options.intervalMs ?? steamPollIntervalMs(); + this.now = options.now ?? Date.now; + } + + /** + * Polls right away instead of waiting for the next tick; a tick in flight is followed by another one. + * Resolves once that poll has been applied (it never rejects). + */ + scanNow(): Promise { + if (this.stopped) return Promise.resolve(); + if (this.tickInFlight) { + this.rescan = true; + return this.lastTick.then(() => this.lastTick); + } + this.clearTimer(); + return this.runTick(); + } + + /** Records a steam://uninstall the launcher opened, so the game reads as uninstalling until it is gone. */ + requestUninstall(appid: number): void { + this.uninstallRequests.set(appid, this.now()); + } + + /** Stops polling for good (application exit). */ + stop(): void { + this.stopped = true; + this.clearTimer(); + this.uninstallRequests.clear(); + } + + private clearTimer(): void { + if (this.timer === null) return; + clearTimeout(this.timer); + this.timer = null; + } + + private runTick(): Promise { + this.lastTick = this.tick(); + return this.lastTick; + } + + private async tick(): Promise { + this.timer = null; + const games = this.deps.listSteamGames(); + this.forgetGone(games); + if (games.length === 0) return; + this.tickInFlight = true; + try { + const statuses = await steamInstallStatuses( + games.map((game) => game.appid), + this.deps.steamLocator(), + ); + if (statuses === null || this.stopped) return; + const stillListed = new Set(this.deps.listSteamGames().map((game) => game.id)); + for (const game of games) { + if (!stillListed.has(game.id)) continue; + const status = statuses.get(game.appid); + if (status !== undefined) this.apply(game, status); + } + } catch (cause) { + log.warn('[steam-watch] tick failed:', describe(cause)); + } finally { + this.tickInFlight = false; + this.rearm(); + } + } + + private rearm(): void { + if (this.stopped) return; + if (this.rescan) { + this.rescan = false; + void this.runTick(); + return; + } + if (this.deps.listSteamGames().length === 0) return; + this.timer = setTimeout(() => void this.runTick(), this.intervalMs); + } + + private forgetGone(games: readonly SteamWatchEntry[]): void { + const ids = new Set(games.map((game) => game.id)); + const appids = new Set(games.map((game) => game.appid)); + for (const id of [...this.tracked.keys()]) { + if (ids.has(id)) continue; + this.tracked.delete(id); + if (isSteamActivity(this.deps.registry.get(id))) this.deps.registry.clear(id); + } + for (const appid of [...this.uninstallRequests.keys()]) { + if (!appids.has(appid)) this.uninstallRequests.delete(appid); + } + } + + private apply(game: SteamWatchEntry, status: SteamInstallStatus): void { + if (game.id === this.deps.sessionGameId()) return; + if (this.applyUninstallRequest(game, status)) return; + const before = this.tracked.get(game.id); + const updating = + status.state === 'downloading' && + (before?.state === 'installed' || before?.updating === true); + this.tracked.set(game.id, { state: status.state, updating }); + if (status.state === 'downloading') { + this.deps.registry.set( + game.id, + downloadActivity(updating ? 'steam-updating' : 'steam-installing', status), + ); + } else { + this.clearSteamActivity(game.id); + } + const changed = + before === undefined ? status.state === 'downloading' : before.state !== status.state; + if (!changed) return; + log.info(`[steam-watch] appid=${game.appid} ${before?.state ?? 'unknown'} → ${status.state}`); + this.deps.onGameChanged(game.id); + if (before?.state === 'downloading' && !before.updating && status.state === 'installed') { + this.deps.onInstallCompleted({ id: game.id, title: game.title }); + } + } + + /** + * Handles a steam://uninstall the launcher opened for this game. True when the request decided this tick + * (the game is still being removed, or has just been); false when there is none, or it timed out and the + * ordinary transition takes over. + */ + private applyUninstallRequest(game: SteamWatchEntry, status: SteamInstallStatus): boolean { + const since = this.uninstallRequests.get(game.appid); + if (since === undefined) return false; + if (status.state === 'installed') { + if (this.now() - since <= STEAM_UNINSTALL_TIMEOUT_MS) { + this.tracked.set(game.id, { state: 'installed', updating: false }); + this.deps.registry.set(game.id, { kind: 'steam-uninstalling' }); + return true; + } + log.info( + `[steam-uninstall] appid=${game.appid} still installed after timeout - assuming cancel`, + ); + this.uninstallRequests.delete(game.appid); + this.clearSteamActivity(game.id); + return false; + } + log.info(`[steam-uninstall] appid=${game.appid} removed`); + this.uninstallRequests.delete(game.appid); + this.tracked.set(game.id, { state: status.state, updating: false }); + this.clearSteamActivity(game.id); + this.deps.onGameChanged(game.id); + this.deps.onUninstallCompleted({ id: game.id, title: game.title }); + return true; + } + + private clearSteamActivity(id: string): void { + if (isSteamActivity(this.deps.registry.get(id))) this.deps.registry.clear(id); + } +} diff --git a/src/main/steam-install-watch.ts b/src/main/steam-install-watch.ts deleted file mode 100644 index 3d2a4ad3..00000000 --- a/src/main/steam-install-watch.ts +++ /dev/null @@ -1,196 +0,0 @@ -// Steam-mode background re-detect poller (split out of the GameController god-object). -// While a Steam game sits on the `ready` screen with the card present, this polls Steam's .acf state -// so the UI flips Install↔Play↔Uninstall as a (possibly hours-long) download/uninstall completes — -// including changes the user makes in Steam directly. It owns its timer, the in-flight guard and the -// optimistic uninstall request; it reaches back into the controller only through the narrow `deps` -// seam below (accessors + enterReady), so the delicate re-arm/staleness logic stays in one place. -import { type AppState, type GameInfo, type ResolvedManifest } from '../shared/types'; -import { steamInstallStatus, type SteamInstallStatus } from './steam'; -import { type SteamLocator } from './platform'; -import { log } from './logger'; -import { describe } from './util'; - -// Steam-mode background re-detect cadence: while a Steam game shows "Install" (not yet installed in -// Steam), poll its .acf state so the button flips to "Play" once the (possibly hours-long) download -// finishes. Non-blocking — no `installing` state is entered. -const STEAM_INSTALL_WATCH_INTERVAL_MS = 5000; - -// How long to keep showing "Uninstalling…" after we open steam://uninstall before giving up. Steam's -// uninstall is fire-and-forget: we can't tell "user is reading the dialog / cancelled" from "removing". -// If the .acf is still present after this window, we assume a cancel and return to "Play"/"Uninstall". -// Safe either way — the poller keeps running and will flip to "Install" if removal completes later. -const STEAM_UNINSTALL_TIMEOUT_MS = 60_000; - -/** The game one of the completion callbacks is about — everything a notification needs to name it. */ -export interface SteamWatchGame { - readonly id: string; - readonly title: string; -} - -/** The narrow view of the controller the poller needs — accessors plus the single mutation it makes. */ -export interface SteamWatchDeps { - /** The current resolved manifest (null when no card / rejected). */ - getManifest(): ResolvedManifest | null; - /** Whether a launch/install/uninstall sequence is in flight (the poller must not race it). */ - isLaunchInFlight(): boolean; - /** The current AppState snapshot. */ - getState(): AppState; - /** Whether the current game's source is available: its card is in, or it is a local game (always). */ - isSourceAvailable(): boolean; - /** Transition to `ready` with the given info (also re-arms/stops the poller, exactly as before). */ - enterReady(info: GameInfo): void; - /** Fired once when a Steam download completes (requiresInstall flips true→false) — the launcher - * notifies about the game. Not fired for an already-installed card (that never enters this poller). */ - onInstallCompleted(game: SteamWatchGame): void; - /** Fired once when a steam://uninstall WE requested has actually removed the game (its .acf is gone). - * A cancel (the timeout branch) never reaches here — nothing was removed. */ - onUninstallCompleted(game: SteamWatchGame): void; - /** Platform Steam locator (win32 registry / linux known paths), used for the .acf state read. */ - steamLocator(): SteamLocator; -} - -export class SteamInstallWatch { - // Recursive setTimeout (no overlap). Non-null only while a Steam game whose source is available is on - // the ready screen. - private timer: ReturnType | null = null; - // True while a tick is mid-flight (between nulling the timer and finishing). Prevents a concurrent - // start() (e.g. an Install/Uninstall action landing during the tick's await) from spinning up a SECOND - // poller. The tick re-arms itself in its finally. - private tickInFlight = false; - // A steam://uninstall we requested (appid + when), driving the optimistic "Uninstalling…" indicator - // until the .acf disappears or STEAM_UNINSTALL_TIMEOUT_MS elapses (assumed cancel). Null = none. - private uninstallRequest: { readonly appid: number; readonly since: number } | null = null; - - constructor(private readonly deps: SteamWatchDeps) {} - - /** (Re)starts the recursive re-detect timer. No-op if already running or a tick is in flight. */ - start(): void { - if (this.timer !== null || this.tickInFlight) return; - this.timer = setTimeout(() => void this.tick(), STEAM_INSTALL_WATCH_INTERVAL_MS); - } - - /** Stops the re-detect timer. */ - stop(): void { - if (this.timer === null) return; - clearTimeout(this.timer); - this.timer = null; - } - - /** Records an optimistic steam://uninstall request so the next tick shows "Uninstalling…". */ - requestUninstall(appid: number): void { - this.uninstallRequest = { appid, since: Date.now() }; - } - - /** Clears any pending uninstall request (card removed / shutdown). */ - clearUninstallRequest(): void { - this.uninstallRequest = null; - } - - /** - * One re-detect tick: captures the current manifest's appid, reads Steam's .acf state, and — only if - * the card/state is still the same after the await (same steam game, ready, no launch in flight) — - * reconciles the UI flags (requiresInstall/canUninstall/progress/uninstalling) by PATCHING the current - * GameInfo in place (cheap — no hero re-read). Catches install completion, uninstall completion (incl. - * an uninstall done in Steam directly), live download progress, and the uninstall-cancel timeout. - */ - private async tick(): Promise { - this.timer = null; // consumed; the finally re-arms it if we should still be polling - const manifest = this.deps.getManifest(); - const appid = manifest?.steam?.appid; - if (manifest === null || appid === undefined) return; // not steam → stop - this.tickInFlight = true; - try { - let status: SteamInstallStatus = { state: 'absent' }; - try { - status = await steamInstallStatus(appid, this.deps.steamLocator()); - } catch (cause) { - log.warn('[steam-watch] detect failed:', describe(cause)); - } - - // Re-validate everything that could have changed during the await (card swap, launch in flight). - const snapshot = this.deps.getState(); - if ( - this.deps.isLaunchInFlight() || - this.deps.getManifest() !== manifest || - snapshot.kind !== 'ready' || - snapshot.game.installVia !== 'steam' - ) { - return; // stale — drop this result (the finally re-arms iff still a steam card on ready) - } - const prev = snapshot.game; - - // Reconcile the UI flags with Steam's fresh .acf state. Only these flags change on install/uninstall; - // the rest of GameInfo (title/hero/stats) is unaffected, so we patch `prev` in place — no hero re-read. - const requiresInstall = status.state !== 'installed'; - let canUninstall = status.state === 'installed'; - const steamInstalling = status.state === 'downloading'; - const steamPaused = status.state === 'downloading' && status.paused; - const steamPausedProgress = - status.state === 'downloading' ? (status.progress ?? undefined) : undefined; - let steamUninstalling = false; - // Whether THIS tick is the one that saw our uninstall request actually take effect — the moment - // the notification belongs to. The flag is needed because the completion is decided here but the - // announcement waits for the `changed` block below, which is what makes it fire exactly once. - let uninstalled = false; - - // A requested steam://uninstall is in flight for this game. - const req = this.uninstallRequest; - if (req !== null && req.appid === appid) { - if (status.state === 'installed') { - // .acf still present: either Steam is removing files, or the user is still on / cancelled the - // dialog. Keep "Uninstalling…" until the timeout, then assume cancel and restore Play/Uninstall. - if (Date.now() - req.since > STEAM_UNINSTALL_TIMEOUT_MS) { - log.info(`[steam-uninstall] appid=${appid} still installed after timeout — assuming cancel`); - this.uninstallRequest = null; - } else { - steamUninstalling = true; - canUninstall = false; // hide Uninstall while the indicator is up - } - } else { - // .acf gone (absent/downloading) → Steam removed the game; finish the uninstall. - log.info(`[steam-uninstall] appid=${appid} removed — flipping to Install`); - this.uninstallRequest = null; - uninstalled = true; - } - } - - const changed = - prev.requiresInstall !== requiresInstall || - prev.canUninstall !== canUninstall || - (prev.steamInstalling ?? false) !== steamInstalling || - (prev.steamPaused ?? false) !== steamPaused || - prev.steamPausedProgress !== steamPausedProgress || - (prev.steamUninstalling ?? false) !== steamUninstalling; - if (changed) { - if (!steamUninstalling) { - log.info( - `[steam-watch] appid=${appid} state=${status.state}${steamPaused ? ' (paused)' : ''} → requiresInstall=${requiresInstall} canUninstall=${canUninstall}`, - ); - } - this.deps.enterReady({ - ...prev, - requiresInstall, - canUninstall, - steamInstalling, - steamPaused, - steamPausedProgress, - steamUninstalling, - }); - // Download just finished (Install→Play): notify, like the installer/copy path. Guarded by the - // prev→now transition so it fires once, not on every post-install poll. - const game = { id: prev.id, title: prev.title }; - if (prev.requiresInstall && !requiresInstall) this.deps.onInstallCompleted(game); - // …and its mirror: the game we asked Steam to remove is gone. Announced from here rather than - // from the flag flip above so it shares the once-per-transition guarantee. - if (uninstalled) this.deps.onUninstallCompleted(game); - } - } finally { - this.tickInFlight = false; - // Re-arm iff we should still be watching this steam game (mirrors enterReady's start condition). - const s = this.deps.getState(); - if (s.kind === 'ready' && s.game.installVia === 'steam' && this.deps.isSourceAvailable()) { - this.start(); - } - } - } -} diff --git a/src/main/steam-uri.ts b/src/main/steam-uri.ts index 61dccadd..ac2b45ab 100644 --- a/src/main/steam-uri.ts +++ b/src/main/steam-uri.ts @@ -1,13 +1,13 @@ // Opening `steam://` URIs. Split out of steam.ts so that module can stay electron-free: it is reached // from `save-path.linux.ts`, which the Game Mode daemon loads under ELECTRON_RUN_AS_NODE — a context -// where importing `electron` fails outright. Only the GUI (ipc.ts) opens URIs, so only this file needs +// where importing `electron` fails outright. Only the GUI (game-controller.ts, game-sequences.ts) opens URIs, so only this file needs // electron, and nothing on the daemon's import path touches it. import { shell } from 'electron'; /** * Opens a `steam://` URI (rungameid/install) via Electron's shell.openExternal. NOTE: openExternal does * NOT reliably reject when `steam://` is unregistered (Steam not installed) — callers must gate on - * getSteamPath() !== null BEFORE calling this. Here we only guarantee that any sync/async failure + * `steamLocator.locateSteam()` !== null BEFORE calling this. Here we only guarantee that any sync/async failure * propagates as a rejected promise so the caller can surface it. */ export async function openSteamUri(uri: string): Promise { diff --git a/src/main/steam.ts b/src/main/steam.ts index 843916b2..55c5fa70 100644 --- a/src/main/steam.ts +++ b/src/main/steam.ts @@ -1,6 +1,6 @@ // Steam-mode backend: detecting whether a Steam app is installed (Steam's own .acf state) and opening // steam:// URIs for launch/install. A separate backend from install mode — no card installer, no -// app-controlled dir. Windows-only in practice (getSteamPath is null off-Windows ⇒ "not installed"); +// app-controlled dir. Wherever the platform's SteamLocator finds no Steam, the game reads as "not installed"; // dev builds on macOS degrade gracefully rather than crashing. // // Why .acf and not a registry DWORD: Steam's `appmanifest_.acf` (in each library's `steamapps`) @@ -20,6 +20,8 @@ import { log } from './logger'; /** StateFlags bit set by Steam once an app is fully installed (not merely downloading/updating). */ const STATE_FULLY_INSTALLED = 4; +const UPDATE_RESULT_NOT_RELEASED = 25; + /** * Where a Steam app stands locally, derived from its `.acf`: * - `installed` — fully installed (StateFlags bit 4), ready to launch. @@ -31,7 +33,12 @@ const STATE_FULLY_INSTALLED = 4; */ export type SteamInstallStatus = | { readonly state: 'installed' } - | { readonly state: 'downloading'; readonly paused: boolean; readonly progress: number | null } + | { + readonly state: 'downloading'; + readonly paused: boolean; + readonly progress: number | null; + readonly preloaded: boolean; + } | { readonly state: 'absent' }; /** @@ -89,6 +96,7 @@ type AcfState = { readonly paused: boolean; /** Snapshot completion fraction 0..1 (staged ?? downloaded), or null if not computable. */ readonly progress: number | null; + readonly preloaded: boolean; }; /** num/den clamped to 0..1, or null when not computable. */ @@ -97,28 +105,60 @@ function fraction(num: number | null, den: number | null): number | null { return Math.max(0, Math.min(1, num / den)); } -async function readAcfState(acfPath: string): Promise { +async function readAcfState(acfPath: string): Promise { let content: string; try { content = await fse.readFile(acfPath, 'utf8'); } catch { - return null; // no .acf in this library + return 'missing'; // no .acf in this library } const flags = readAcfNumber(content, 'StateFlags'); - if (flags === null) return null; + if (flags === null) return 'unreadable'; // Staged progress matches Steam's displayed percent best; fall back to download progress. const progress = fraction(readAcfNumber(content, 'BytesStaged'), readAcfNumber(content, 'BytesToStage')) ?? fraction(readAcfNumber(content, 'BytesDownloaded'), readAcfNumber(content, 'BytesToDownload')); // EResult: 0 (none/in-progress) and 1 (OK) are "active/fine"; >=2 means the download isn't moving. const updateResult = readAcfNumber(content, 'UpdateResult') ?? 0; + const downloaded = fraction(readAcfNumber(content, 'BytesDownloaded'), readAcfNumber(content, 'BytesToDownload')); return { fullyInstalled: (flags & STATE_FULLY_INSTALLED) === STATE_FULLY_INSTALLED, paused: updateResult >= 2, progress, + preloaded: updateResult === UPDATE_RESULT_NOT_RELEASED && downloaded === 1, }; } +/** + * Where `appid` stands across the given Steam library roots: the `appmanifest_.acf` walk shared by + * the single lookup and the batch one below. Null when the only `.acf` found could not be parsed - most + * likely caught halfway through Steam rewriting it - so the state is unknown rather than "absent". + */ +async function statusInLibraries(appid: number, libs: readonly string[]): Promise { + let downloading: SteamInstallStatus | null = null; + let unreadable = false; + for (const lib of libs) { + const acfPath = path.join(lib, 'steamapps', `appmanifest_${appid}.acf`); + const acf = await readAcfState(acfPath); + if (acf === 'missing') continue; + if (acf === 'unreadable') { + unreadable = true; + continue; + } + if (acf.fullyInstalled) return { state: 'installed' }; + // .acf exists but not fully installed → downloading/updating in this library. The percent is only + // trustworthy when paused (byte counters are stale while actively downloading). + downloading = { + state: 'downloading', + paused: acf.paused, + progress: acf.paused ? acf.progress : null, + preloaded: acf.preloaded, + }; + } + if (downloading !== null) return downloading; + return unreadable ? null : { state: 'absent' }; +} + /** * Where the given Steam app stands locally (per Steam's own .acf state), walking every Steam library for * `appmanifest_.acf`. Best-effort: Steam not found, no manifest, or any error → `absent`. The @@ -131,22 +171,7 @@ export async function steamInstallStatus( try { const steamPath = await locator.locateSteam(); if (steamPath === null) return { state: 'absent' }; - const libs = await steamLibraryDirs(steamPath); - let downloading: SteamInstallStatus | null = null; - for (const lib of libs) { - const acfPath = path.join(lib, 'steamapps', `appmanifest_${appid}.acf`); - const acf = await readAcfState(acfPath); - if (acf === null) continue; - if (acf.fullyInstalled) return { state: 'installed' }; - // .acf exists but not fully installed → downloading/updating in this library. The percent is only - // trustworthy when paused (byte counters are stale while actively downloading). - downloading = { - state: 'downloading', - paused: acf.paused, - progress: acf.paused ? acf.progress : null, - }; - } - return downloading ?? { state: 'absent' }; + return (await statusInLibraries(appid, await steamLibraryDirs(steamPath))) ?? { state: 'absent' }; } catch (cause) { log.warn( '[steam] install check failed:', @@ -155,3 +180,32 @@ export async function steamInstallStatus( return { state: 'absent' }; } } + +/** + * Where each of `appids` stands locally, from ONE Steam lookup and ONE library listing for the whole batch. + * Null when Steam is not found or the lookup fails, so a caller polling many games keeps what it last knew + * instead of reading every one of them as removed; an appid whose `.acf` could not be parsed is left out of + * the map for the same reason. + */ +export async function steamInstallStatuses( + appids: readonly number[], + locator: SteamLocator, +): Promise | null> { + try { + const steamPath = await locator.locateSteam(); + if (steamPath === null) return null; + const libs = await steamLibraryDirs(steamPath); + const statuses = await Promise.all( + appids.map(async (appid) => [appid, await statusInLibraries(appid, libs)] as const), + ); + return new Map( + statuses.flatMap(([appid, status]) => (status === null ? [] : [[appid, status] as const])), + ); + } catch (cause) { + log.warn( + '[steam] batch install check failed:', + cause instanceof Error ? cause.message : String(cause), + ); + return null; + } +} diff --git a/src/main/tray.ts b/src/main/tray.ts index 0ae0f1e7..9a409289 100644 --- a/src/main/tray.ts +++ b/src/main/tray.ts @@ -4,9 +4,12 @@ // A background app lives in the tray; closing the window doesn't quit the program. import path from 'node:path'; import { Tray, Menu, nativeImage, type NativeImage } from 'electron'; -import { APP_NAME } from '../shared/types'; import { type Translator } from '../shared/i18n/index'; +/** Display name (window title / tray tooltip). The %APPDATA% data folder is derived separately by + * Electron from package.json `name` (currently "playhook"). */ +const APP_NAME = 'Playhook' as const; + export interface TrayCallbacks { readonly onShow: () => void; /** Opens the log folder in the OS file manager (moved here from the settings window). */ @@ -50,8 +53,8 @@ export function buildTrayMenu(t: Translator, callbacks: TrayCallbacks, steam: Tr }, ] : []), - { label: t('settings.openLogs'), click: () => callbacks.onOpenLogs() }, - { label: t('settings.openGames'), click: () => callbacks.onOpenGamesFolder() }, + { label: t('tray.openLogs'), click: () => callbacks.onOpenLogs() }, + { label: t('tray.openGames'), click: () => callbacks.onOpenGamesFolder() }, { type: 'separator' }, { label: t('tray.quit'), click: () => callbacks.onQuit() }, ]); @@ -83,7 +86,7 @@ function menuBarImage(source: NativeImage): NativeImage { export function createTray(t: Translator, callbacks: TrayCallbacks, steam: TraySteamState): Tray { // The app icon doubles as the tray icon (the separate icon-tray.* files are gone), copied into dist by // copy-assets. Windows uses the .ico; Linux (Desktop Mode/KDE) and macOS need a PNG — a .ico yields an - // empty image via nativeImage there (Р8). Falls back to an empty image if the file is missing. + // empty image via nativeImage there. Falls back to an empty image if the file is missing. const iconFile = process.platform === 'win32' ? '../icon.ico' : '../icon.png'; const iconPath = path.join(__dirname, iconFile); const loaded = nativeImage.createFromPath(iconPath); diff --git a/src/main/uninstaller.win32.ts b/src/main/uninstaller.win32.ts new file mode 100644 index 00000000..094e6e99 --- /dev/null +++ b/src/main/uninstaller.win32.ts @@ -0,0 +1,155 @@ +// Resolving and running a game's own uninstaller on Windows (FS search in the install dir, then the +// registry). Split out of the controller: the pure parts (the silent flags per installer family, the +// CommandLineToArgvW parser, the in-dir search) are unit-tested on their own — see +// test/uninstaller.test.ts. The directory sweep every uninstall path ends in is OS-neutral and lives in +// remove-with-retry.ts. +import path from 'node:path'; +import fse from 'fs-extra'; +import type { InstallerRunType, LaunchTarget, ResolvedInstallerRun } from './manifest-types'; +import { findUninstallEntry } from './registry'; + +// ── Uninstaller resolution (FS search in the install dir → registry fallback) ── + +/** Silent flags we build ourselves per installer family (the same families' silent semantics, minus + * the dir-key). Never used for `custom` (it has no known silent-uninstall convention). */ +export function silentUninstallArgs(type: InstallerRunType): string[] { + switch (type) { + case 'nsis': + return ['/S']; + case 'inno': + return ['/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART']; + case 'custom': + return []; + } +} + +/** + * Step 1 — deterministic FS search for the uninstaller INSIDE the app-controlled install dir (we put + * it there via the installer's dir-key, so it lives in the root): Inno drops `unins###.exe` (pick the + * highest if several), NSIS drops `Uninstall.exe`/`uninst*.exe`. `custom` has no known convention → null. + * + * `copy` is excluded by TYPE, not by a branch: a copied directory is a game somebody else installed + * earlier, so it may well carry a foreign `unins000.exe` that the nsis fallback below would happily + * find and run with `/S` — silently uninstalling from the wrong machine's point of view. + */ +export async function findUninstallerInDir( + dir: string, + type: InstallerRunType, +): Promise { + if (type === 'custom') return null; + let names: readonly string[]; + try { + names = await fse.readdir(dir); + } catch { + return null; + } + if (type === 'inno') { + const candidates = names.filter((name) => /^unins\d{3}\.exe$/i.test(name)).sort(); + const chosen = candidates.at(-1); + return chosen !== undefined ? path.join(dir, chosen) : null; + } + // nsis: the name is set by the .nsi but is almost always Uninstall.exe / uninst*.exe in the root. + const match = names.find((name) => /^uninst(all)?.*\.exe$/i.test(name)); + return match !== undefined ? path.join(dir, match) : null; +} + +/** + * Parses a Windows command line into LOGICAL argv tokens following CommandLineToArgvW's backslash/quote + * rules (2n backslashes + quote → n backslashes and a quote toggle; 2n+1 → n backslashes and a literal + * quote). Used to split a registry UninstallString into exe + args; the original quoting is dropped (the + * launcher re-quotes uniformly under verbatim:false). + */ +export function parseCommandLine(command: string): string[] { + const args: string[] = []; + let arg = ''; + let inQuotes = false; + let started = false; + let i = 0; + while (i < command.length) { + const ch = command[i]; + if (ch === undefined) break; + if (ch === '\\') { + let backslashes = 0; + while (command[i] === '\\') { + backslashes += 1; + i += 1; + } + if (command[i] === '"') { + arg += '\\'.repeat(Math.floor(backslashes / 2)); + if (backslashes % 2 === 1) { + arg += '"'; // escaped literal quote + } else { + inQuotes = !inQuotes; + } + i += 1; + } else { + arg += '\\'.repeat(backslashes); + } + started = true; + continue; + } + if (ch === '"') { + inQuotes = !inQuotes; + started = true; + i += 1; + continue; + } + if ((ch === ' ' || ch === '\t') && !inQuotes) { + if (started) { + args.push(arg); + arg = ''; + started = false; + } + i += 1; + continue; + } + arg += ch; + started = true; + i += 1; + } + if (started) args.push(arg); + return args; +} + +/** + * Resolves what to launch to uninstall an install-mode game: FS search in the install dir first + * (deterministic, no parsing/encoding issues — we build the silent args), then a registry fallback for a + * rare nonstandard NSIS uninstaller name. Returns null → the caller does a plain directory removal. + * win32-only: the linux side never runs an in-prefix uninstaller (see GameProcessLauncher.resolveUninstaller). + */ +export async function resolveUninstaller( + install: ResolvedInstallerRun, +): Promise { + // Step 1: FS search in the install dir, with self-built silent flags. + const found = await findUninstallerInDir(install.dir, install.type); + if (found !== null) { + return { + file: found, + args: silentUninstallArgs(install.type), + cwd: install.dir, + runAsAdmin: install.runAsAdmin, + }; + } + if (install.type === 'custom') return null; // no FS match and no silent convention → plain remove + + // Step 2: registry fallback (rare — nonstandard NSIS uninstaller name). + const entry = await findUninstallEntry(install.dir); + if (entry === null) return null; + const command = entry.quietUninstallString ?? entry.uninstallString; + if (command === undefined) return null; + const tokens = parseCommandLine(command); + const file = tokens[0]; + if (file === undefined) return null; + const rest = tokens.slice(1); + // QuietUninstallString is already silent; a plain UninstallString needs the family's silent flag. + const args = + entry.quietUninstallString !== undefined + ? rest + : [...rest, ...silentUninstallArgs(install.type)]; + return { + file, + args, + cwd: install.dir, + runAsAdmin: entry.fromHKLM || install.runAsAdmin, + }; +} diff --git a/src/main/updater.ts b/src/main/updater.ts index 88ff8ba8..15792d92 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -10,8 +10,9 @@ // • It supports a MANUAL path — check / download / install triggered from the Settings screen. The // manual install (quitAndInstall) DOES restart the app, which breaks the original "never interrupt" // philosophy, so install() is double-guarded (see below) so it can only run when it's safe. -// • It applies an auto-update MODE (download-install / download / off) from AppSettingsStore, mapping -// it onto autoUpdater.autoDownload / autoInstallOnAppQuit and the periodic-check timer. +// • It applies an auto-update MODE (download-install / download / off) — read from AppSettingsStore at +// startup, then handed over by SettingsService on every change — mapping it onto +// autoUpdater.autoDownload / autoInstallOnAppQuit and the periodic-check timer. // // Two install guards protect the "never interrupt an in-flight operation" invariant: // (a) status guard — install only from the `downloaded` snapshot (closes a race where the mode is @@ -24,24 +25,13 @@ // (AppUpdater docs), bypassing main.ts.quit(). GameWindow holds a // close→preventDefault+hide guard, so the install could hang on those guards. Hence beforeInstall() is // called SYNCHRONOUSLY right before quitAndInstall() to drop those guards first. -import path from 'node:path'; -import fs from 'node:fs/promises'; import { app, type BrowserWindow } from 'electron'; import { autoUpdater } from 'electron-updater'; import { log } from './logger'; -import { - IPC, - type AppSettings, - type AudioOptions, - type AudioVolumes, - type AutoUpdateMode, - type LanguageMode, - type UpdateStatus, -} from '../shared/types'; +import { IPC, type AutoUpdateMode, type UpdateStatus } from '../shared/types'; import { type Translator } from '../shared/i18n/index'; import { type AppSettingsStore } from './app-settings'; import { type NotificationsService } from './notifications'; -import { DEFAULT_SOUND_SET } from './asset-reader'; import { ipcMain } from 'electron'; const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // re-check every 6h for long-running instances @@ -49,7 +39,7 @@ const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // re-check every 6h for long-runn /** * Whether this build can self-update at all: it must be PACKAGED, and it must not be the macOS one — * Squirrel.Mac only applies an update to a code-signed bundle, and this project ships an unsigned dmg - * (Д6). Everywhere that would otherwise touch `autoUpdater` asks this first, so the macOS build cannot + * (no Apple Developer account). Everywhere that would otherwise touch `autoUpdater` asks this first, so the macOS build cannot * start a check whose install step is guaranteed to fail. */ function updatesSupported(): boolean { @@ -69,29 +59,6 @@ export interface UpdaterDeps { readonly isBusy: () => boolean; /** Drops both windows' close-guards synchronously right before quitAndInstall. */ readonly beforeInstall: () => void; - /** Applies the Start+Back summon-hotkey toggle to the running global gamepad listener. */ - readonly onSummonHotkeyChanged: (enabled: boolean) => void; - /** Applies the keep-display-awake toggle (recomputes the powerSaveBlocker in main). */ - readonly onPreventScreensaverChanged: (enabled: boolean) => void; - /** - * Applies the Game Mode auto-launch toggle: installs or tears down the watcher service. Steam Deck - * only; a no-op elsewhere. - */ - readonly onSteamAutoLaunchChanged: (enabled: boolean) => Promise; - /** Whether the Steam-shortcut feature exists on this machine (linux + packaged AppImage). */ - readonly isSteamAvailable: () => boolean; - /** Applies the "always show the no-card screen" toggle (reconciles the launcher's visibility). */ - readonly onKeepOpenWithoutCardChanged: (enabled: boolean) => void; - /** Pushes new audio volumes to the game renderer so they apply live. */ - readonly onVolumesChanged: (volumes: AudioVolumes) => void; - /** Applies a navigation-sound-set change (re-reads + re-pushes the current sfx to the game window). */ - readonly onSoundSetChanged: (set: string) => void; - /** Applies an "only global ambience" toggle (recomputes + re-pushes the card's music). */ - readonly onAudioScopeChanged: () => void; - /** Applies a default-ambience change (re-reads the track + pushes it to the game window). */ - readonly onAmbientChanged: (track: string | null) => void; - /** Applies a UI-language change (re-resolve locale, rebuild tray/titles, push to live windows). */ - readonly onLanguageChanged: (mode: LanguageMode) => void; /** The current translator (for the install-busy soft error surfaced in the launcher). */ readonly getTranslator: () => Translator; } @@ -107,17 +74,17 @@ export class UpdaterService { constructor(private readonly deps: UpdaterDeps) {} /** - * The single point where all update:* / settings:* / app:version IPC is registered, plus (when - * packaged) autoUpdater subscriptions, the initial check and the periodic timer. Keeping IPC - * registration here — and NOWHERE else — rules out a duplicate ipcMain.handle (a crash) or a - * forgotten channel. In dev / non-packaged the IPC is still registered (so the Settings screen can - * show the version and persist the mode), but there are NO autoUpdater subscriptions and NO timer. + * The single point where all update:* IPC is registered, plus (when packaged) autoUpdater + * subscriptions, the initial check and the periodic timer. Keeping IPC registration here — and + * NOWHERE else — rules out a duplicate ipcMain.handle (a crash) or a forgotten channel. In dev / + * non-packaged the IPC is still registered (so the Settings screen can show the status), but there + * are NO autoUpdater subscriptions and NO timer. The settings:* channels live in SettingsService. */ async init(): Promise { this.registerIpc(); // macOS FIRST, before the packaged check: Squirrel.Mac refuses to apply an update to an app bundle - // that is not code-signed, and this build is not (no Apple Developer account — Д6). Wiring autoUpdater + // that is not code-signed, and this build is not (no Apple Developer account). Wiring autoUpdater // anyway would mean a check that finds a version, downloads it and then fails at install — so the // Settings screen is told to explain manual updating instead. // @@ -173,172 +140,21 @@ export class UpdaterService { ipcMain.on(IPC.updateCheck, () => this.check()); ipcMain.on(IPC.updateDownload, () => this.download()); ipcMain.on(IPC.updateInstall, () => void this.install()); - ipcMain.handle(IPC.settingsRequest, () => this.deps.settings.read()); - // Drives whether the Steam settings are rendered at all — the renderer has no way to know the OS. - ipcMain.handle(IPC.settingsSteamAvailable, () => this.deps.isSteamAvailable()); - ipcMain.on(IPC.settingsSetAutoUpdate, (_event, mode: AutoUpdateMode) => { - void this.deps.settings - .setAutoUpdate(mode) - .then(() => { - // Persist always, but only touch autoUpdater in a packaged build. - if (updatesSupported()) this.applyMode(mode); - }) - .catch((cause: unknown) => - log.error('[updater] failed to persist auto-update mode:', cause), - ); - }); - ipcMain.on(IPC.settingsSetPrerelease, (_event, on: boolean) => { - void this.deps.settings - .patch({ allowPrerelease: on }) - .then(() => { - if (updatesSupported()) autoUpdater.allowPrerelease = on; - }) - .catch((cause: unknown) => - log.error('[updater] failed to persist prerelease flag:', cause), - ); - }); - ipcMain.on(IPC.settingsSetSummonHotkey, (_event, on: boolean) => { - void this.deps.settings - .patch({ summonHotkeyEnabled: on }) - .then(() => this.deps.onSummonHotkeyChanged(on)) - .catch((cause: unknown) => log.error('[updater] failed to persist summon hotkey:', cause)); - }); - ipcMain.on(IPC.settingsSetPreventScreensaver, (_event, on: boolean) => { - void this.deps.settings - .patch({ preventScreensaver: on }) - .then(() => this.deps.onPreventScreensaverChanged(on)) - .catch((cause: unknown) => - log.error('[updater] failed to persist prevent-screensaver:', cause), - ); - }); - ipcMain.on(IPC.settingsSetKeepOpenWithoutCard, (_event, on: boolean) => { - void this.deps.settings - .patch({ keepOpenWithoutCard: on }) - .then(() => this.deps.onKeepOpenWithoutCardChanged(on)) - .catch((cause: unknown) => - log.error('[updater] failed to persist always-show-empty-screen:', cause), - ); - }); - // No side-effect on toggle: the install flow reads disableSilentInstall from settings at install time. - ipcMain.on(IPC.settingsSetSteamAutoLaunch, (_event, on: boolean) => { - this.deps.settings - .patch({ steamAutoLaunch: on }) - .then(() => this.deps.onSteamAutoLaunchChanged(on)) - .catch((cause: unknown) => log.error('[updater] failed to set steam auto-launch:', cause)); - }); - - ipcMain.on(IPC.settingsSetDisableSilentInstall, (_event, on: boolean) => { - void this.deps.settings - .patch({ disableSilentInstall: on }) - .catch((cause: unknown) => - log.error('[updater] failed to persist disable-silent-install:', cause), - ); - }); - ipcMain.on(IPC.settingsSetMusicVolume, (_event, volume: number) => { - void this.setVolume({ musicVolume: volume }); - }); - ipcMain.on(IPC.settingsSetSfxVolume, (_event, volume: number) => { - void this.setVolume({ sfxVolume: volume }); - }); - ipcMain.on(IPC.settingsSetSoundSet, (_event, set: string) => { - void this.deps.settings - .patch({ soundSet: set }) - .then(() => this.deps.onSoundSetChanged(set)) - .catch((cause: unknown) => log.error('[updater] failed to persist sound set:', cause)); - }); - ipcMain.on(IPC.settingsSetAmbientTrack, (_event, track: string | null) => { - void this.deps.settings - .patch({ ambientTrack: track }) - .then(() => this.deps.onAmbientChanged(track)) - .catch((cause: unknown) => log.error('[updater] failed to persist ambient track:', cause)); - }); - ipcMain.on(IPC.settingsSetOnlyGlobalAmbient, (_event, on: boolean) => { - void this.deps.settings - .patch({ onlyGlobalAmbient: on }) - .then(() => this.deps.onAudioScopeChanged()) - .catch((cause: unknown) => log.error('[updater] failed to persist only-global-ambient:', cause)); - }); - // No side-effect on change: MetadataService reads the key from settings at request time, so the next - // search already uses whatever was typed here. - ipcMain.on(IPC.settingsSetSteamGridDbKey, (_event, key: string) => { - void this.deps.settings - .patch({ steamGridDbApiKey: key }) - .catch((cause: unknown) => log.error('[updater] failed to persist the SteamGridDB key:', cause)); - }); - // Language mirrors the summon-hotkey path: persist, then hand the mode to the deps callback (main - // re-resolves the locale, rebuilds tray/titles and pushes the effective locale to every live window). - ipcMain.on(IPC.settingsSetLanguage, (_event, mode: LanguageMode) => { - void this.deps.settings - .setLanguage(mode) - .then(() => this.deps.onLanguageChanged(mode)) - .catch((cause: unknown) => log.error('[updater] failed to persist language:', cause)); - }); - ipcMain.handle(IPC.settingsReset, (): Promise => this.resetSettings()); - // game-renderer startup: hand it the current volumes to seed its AudioController. - ipcMain.handle(IPC.volumeRequest, async (): Promise => { - const settings = await this.deps.settings.read(); - return { music: settings.musicVolume, sfx: settings.sfxVolume }; - }); - ipcMain.handle(IPC.appVersionRequest, (): string => app.getVersion()); - ipcMain.handle(IPC.audioOptionsRequest, (): Promise => this.readAudioOptions()); } - // Resets settings to defaults and re-applies every side effect (auto-update mode, prerelease flag, - // summon-hotkey toggle, renderer volumes). The Settings screen repaints from the settings:update push - // the write itself emits (AppSettingsStore.onChange), not from this return value — which is kept - // because settings:reset is an invoke. - private async resetSettings(): Promise { - const next = await this.deps.settings.reset(); - if (updatesSupported()) { - autoUpdater.allowPrerelease = next.allowPrerelease; - this.applyMode(next.autoUpdate); - } - this.deps.onSummonHotkeyChanged(next.summonHotkeyEnabled); - this.deps.onPreventScreensaverChanged(next.preventScreensaver); - this.deps.onKeepOpenWithoutCardChanged(next.keepOpenWithoutCard); - // A reset turns auto-launch back on — the watcher unit has to come back with it, or the setting - // would say "on" while nothing is actually watching. - await this.deps.onSteamAutoLaunchChanged(next.steamAutoLaunch); - this.deps.onVolumesChanged({ music: next.musicVolume, sfx: next.sfxVolume }); - this.deps.onSoundSetChanged(next.soundSet); - this.deps.onAmbientChanged(next.ambientTrack); - this.deps.onLanguageChanged(next.language); - return next; - } + // ── Settings side effects (handed over by SettingsService) ──────────────── - // Persists a volume change and pushes the full volume pair to the game renderer so it applies live. - private async setVolume( - partial: { musicVolume?: number } | { sfxVolume?: number }, - ): Promise { - try { - const next = await this.deps.settings.patch(partial); - this.deps.onVolumesChanged({ music: next.musicVolume, sfx: next.sfxVolume }); - } catch (cause) { - log.error('[updater] failed to persist volume:', cause); - } + /** + * Applies a persisted auto-update mode. Persisting is SettingsService's job; this only touches + * autoUpdater, and only in a build that can self-update at all. + */ + applyAutoUpdateMode(mode: AutoUpdateMode): void { + if (updatesSupported()) this.applyMode(mode); } - // The bundled sound sets + ambience tracks, read once from dist/audio/index.json (generated at build - // time by copy-assets — the runtime never does a readdir over the asar). A read/parse failure falls back - // to a minimal, always-valid set so the settings dropdowns still populate. - private audioOptions: AudioOptions | null = null; - private async readAudioOptions(): Promise { - if (this.audioOptions !== null) return this.audioOptions; - try { - const text = await fs.readFile(path.join(__dirname, '../audio/index.json'), 'utf8'); - const parsed = JSON.parse(text) as { soundSets?: unknown; ambientTracks?: unknown }; - const asStringArray = (value: unknown): string[] => - Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []; - const soundSets = asStringArray(parsed.soundSets); - this.audioOptions = { - soundSets: soundSets.length > 0 ? soundSets : [DEFAULT_SOUND_SET], - ambientTracks: asStringArray(parsed.ambientTracks), - }; - } catch (cause) { - log.warn('[updater] failed to read audio index — using defaults:', cause); - this.audioOptions = { soundSets: [DEFAULT_SOUND_SET], ambientTracks: [] }; - } - return this.audioOptions; + /** Applies the pre-release channel flag, under the same guard as the mode. */ + setAllowPrerelease(on: boolean): void { + if (updatesSupported()) autoUpdater.allowPrerelease = on; } // ── autoUpdater event mapping ───────────────────────────────────────── @@ -384,7 +200,11 @@ export class UpdaterService { // • anything else (a failed check, idle, available) → "up to date" — the background auto-check / // autoDownload will still pick up a real update later, so this is the least-surprising message. private handleError(): void { - if (this.status.kind === 'downloaded' || this.status.kind === 'unsupported') return; + if (this.status.kind === 'unsupported') return; + if (this.status.kind === 'downloaded') { + this.pushStatus(); + return; + } if (this.status.kind === 'downloading') { this.setStatus( this.pendingVersion !== null @@ -398,7 +218,7 @@ export class UpdaterService { // ── Auto-update mode → electron-updater flags + timer ────────────────── - applyMode(mode: AutoUpdateMode): void { + private applyMode(mode: AutoUpdateMode): void { autoUpdater.autoDownload = mode !== 'off'; autoUpdater.autoInstallOnAppQuit = mode === 'download-install'; this.stopTimer(); @@ -464,16 +284,18 @@ export class UpdaterService { // off mid-flight is the root cause of settings loss after an update. beforeInstall stays SYNCHRONOUS // right before quitAndInstall (nothing awaited between them) — its contract of dropping the window // close-guards with no yield in the way is preserved. + const { version } = this.status; await this.deps.settings.flush(); // Same reason for the inbox: a notification written as the process goes down would come back // truncated, and the file is read on the very next start. await this.deps.notifications.flush(); log.info('[updater] installing update — quitAndInstall'); + this.pushTransient({ kind: 'installing', version }); this.deps.beforeInstall(); // drop both windows' close-guards synchronously first - autoUpdater.quitAndInstall(); + autoUpdater.quitAndInstall(true, true); } - // ── Pushing status to the settings window ────────────────────────────────── + // ── Pushing status to the Settings screen ────────────────────────────────── private setStatus(next: UpdateStatus): void { this.status = next; diff --git a/src/main/util.ts b/src/main/util.ts index 60cc78ab..9befa58a 100644 --- a/src/main/util.ts +++ b/src/main/util.ts @@ -1,5 +1,5 @@ -// Tiny shared helpers used across main-process modules (de-duplicates the copies of -// `delay`/`describe` that had drifted into individual files). +// Tiny shared helpers used across main-process modules (the copies of `delay`/`describe` that had +// drifted into individual files were folded in here). /** Human-readable message for an unknown thrown value. */ export function describe(cause: unknown): string { diff --git a/src/main/window-hide-guard.ts b/src/main/window-hide-guard.ts index 9baa6638..732b3beb 100644 --- a/src/main/window-hide-guard.ts +++ b/src/main/window-hide-guard.ts @@ -1,7 +1,7 @@ // Shared "X hides to tray instead of quitting" window behavior. GameWindow and // SettingsWindow both had an identical forceClosing flag + on('close')→preventDefault/hide + allowClose -// pair; this centralizes it. `onClose` runs on every close attempt regardless of the guard (e.g. the -// settings window detaches its updater there). +// pair; this centralizes it. `onClose` runs on every close attempt regardless of the guard (the +// settings window used it to detach its updater; today no caller passes one). import { type BrowserWindow } from 'electron'; export interface HideOnCloseGuard { diff --git a/src/main/window.ts b/src/main/window.ts index f22e65d8..183d2abc 100644 --- a/src/main/window.ts +++ b/src/main/window.ts @@ -113,7 +113,7 @@ export class GameWindow { // Closing the window with the X doesn't quit the app — we hide it to the tray. In SteamOS Game Mode // there is no tray and Steam ends a non-Steam game by closing its window, so the guard is skipped - // (`hideToTrayOnClose: false`): the close proceeds and main quits on window-all-closed (see Р8, point 5). + // (`hideToTrayOnClose: false`): the close proceeds and main quits on window-all-closed. if (opts.hideToTrayOnClose ?? true) { this.closeGuard = installHideOnClose(window); } @@ -135,6 +135,16 @@ export class GameWindow { return this.window; } + /** + * Pushes one message to the renderer — a no-op before the window exists and after it is destroyed, so + * every push from main can be unconditional. The channel is a string, not `IPC[...]`, on purpose: the + * services that push (notifications, the controller) already name their channels through the IPC table. + */ + send(channel: string, payload: unknown): void { + const window = this.window; + if (window !== null && !window.isDestroyed()) window.webContents.send(channel, payload); + } + /** * Shows and focuses the launcher. With `forceForeground` (the Start+Back hotkey, summoning over * a running game) it does a minimize→restore to reliably grab the foreground — at the cost of a @@ -184,6 +194,12 @@ export class GameWindow { return window !== null && window.isVisible() && !window.isMinimized(); } + /** Whether the launcher window has the OS focus right now. */ + isFocused(): boolean { + const window = this.window; + return window !== null && !window.isDestroyed() && window.isFocused(); + } + /** Allows the window to actually close (when quitting the app). */ allowClose(): void { this.closeGuard?.allowClose(); diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 7dee3983..5cec3ec6 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -55,14 +55,21 @@ import type { } from '../shared/types'; import type { IPC } from '../shared/types'; import type { Locale } from '../shared/i18n/index'; +import type { ActivityMap } from '../shared/activity'; +import type { QuitAction, QuitConfirmReply } from '../shared/quit'; const CHANNELS = { stateUpdate: 'state:update', stateRequest: 'state:request', + activityUpdate: 'activity:update', + activityRequest: 'activity:request', actionLaunch: 'action:launch', + actionCancelJob: 'action:cancel-job', actionUninstall: 'action:uninstall', actionHide: 'action:hide', actionQuit: 'action:quit', + quitConfirm: 'quit:confirm', + quitConfirmReply: 'quit:confirm-reply', gameModeRequest: 'app:game-mode-request', actionOpenSteamDownloads: 'action:open-steam-downloads', actionShutdown: 'action:shutdown', @@ -170,17 +177,36 @@ const api: RendererApi = { requestState(): Promise { return ipcRenderer.invoke(CHANNELS.stateRequest) as Promise; }, - requestLaunch(): void { - ipcRenderer.send(CHANNELS.actionLaunch); + onActivityUpdate(callback: (activities: ActivityMap) => void): void { + ipcRenderer.on(CHANNELS.activityUpdate, (_event: IpcRendererEvent, activities: ActivityMap) => { + callback(activities); + }); + }, + requestActivities(): Promise { + return ipcRenderer.invoke(CHANNELS.activityRequest) as Promise; + }, + requestLaunch(id?: string): void { + ipcRenderer.send(CHANNELS.actionLaunch, id); + }, + requestUninstall(id?: string): void { + ipcRenderer.send(CHANNELS.actionUninstall, id); }, - requestUninstall(): void { - ipcRenderer.send(CHANNELS.actionUninstall); + cancelJob(id: string): void { + ipcRenderer.send(CHANNELS.actionCancelJob, id); }, requestHide(): void { ipcRenderer.send(CHANNELS.actionHide); }, - requestQuit(): void { - ipcRenderer.send(CHANNELS.actionQuit); + requestQuit(confirmed?: boolean): void { + ipcRenderer.send(CHANNELS.actionQuit, confirmed === true); + }, + onQuitConfirm(callback: (action: QuitAction) => void): void { + ipcRenderer.on(CHANNELS.quitConfirm, (_event: IpcRendererEvent, action: QuitAction) => { + callback(action); + }); + }, + quitConfirmReply(reply: QuitConfirmReply): void { + ipcRenderer.send(CHANNELS.quitConfirmReply, reply); }, requestGameMode(): Promise { return ipcRenderer.invoke(CHANNELS.gameModeRequest) as Promise; @@ -188,11 +214,11 @@ const api: RendererApi = { openSteamDownloads(): void { ipcRenderer.send(CHANNELS.actionOpenSteamDownloads); }, - requestShutdown(): void { - ipcRenderer.send(CHANNELS.actionShutdown); + requestShutdown(confirmed?: boolean): void { + ipcRenderer.send(CHANNELS.actionShutdown, confirmed === true); }, - requestReboot(): void { - ipcRenderer.send(CHANNELS.actionReboot); + requestReboot(confirmed?: boolean): void { + ipcRenderer.send(CHANNELS.actionReboot, confirmed === true); }, requestSleep(): void { ipcRenderer.send(CHANNELS.actionSleep); diff --git a/src/renderer/app.ts b/src/renderer/app.ts index 9177f985..d200f620 100644 --- a/src/renderer/app.ts +++ b/src/renderer/app.ts @@ -1,21 +1,19 @@ // Renderer UI logic — the assembly point. Drives a persistent DOM (built once in index.html) by toggling // classes and data-attributes per AppState, so CSS transitions animate smoothly between states. The // autonomous subsystems live in their own modules: hero background + palette (hero.ts), the interaction -// layer — popups, focus, actions (controls.ts) — and the pure state views (state-view.ts). render() here -// wires them together and owns only the bits that don't belong to any one subsystem (phase attribute, -// info panel, title slide, music gating). +// layer — popups, focus, actions (controls.ts) — the boot reveal (boot.ts), the busy-phase chatter +// (chatter.ts) and the pure state views (state-view.ts). render() here wires them together and owns only +// the bits that don't belong to any one subsystem (phase attribute, info panel, title slide, music gating). // IMPORTANT: title/data come from the card (untrusted) — rendered via textContent, never innerHTML. -import type { AppNotification, AppState, BrowseInfo, LibraryEntry, Stats } from '../shared/types'; -import { - createTranslator, - type Locale, - type Translator, - type MessageKey, -} from '../shared/i18n/index.js'; +import type { AppNotification, AppState, BrowseInfo, LibraryEntry, Stats } from '../shared/types.js'; +import type { ActivityMap } from '../shared/activity.js'; +import { createTranslator, type Locale, type Translator } from '../shared/i18n/index.js'; import { localizeDocument } from './i18n-dom.js'; import { AUTO_CHAIN_MS, NAV_REPEAT_MS } from './auto-repeat.js'; import { createAudioController } from './audio.js'; import { createHeroController } from './hero.js'; +import { createBootSequence } from './boot.js'; +import { createChatter } from './chatter.js'; import { createControls } from './controls.js'; import { createSettingsScreen, type SettingsScreenApi } from './settings-screen.js'; import { createGameSettingsScreen, type GameSettingsScreenApi } from './game-settings-screen.js'; @@ -27,7 +25,7 @@ import { createCardArtCache } from './card-art.js'; import { createLibraryScreen } from './library-screen.js'; import { createToast } from './toast.js'; import { formatDate, formatNotification, formatPlaytime } from './format.js'; -import { busyKindOf, gameOf, phaseOf, statusOf, steamBusy } from './state-view.js'; +import { activityStatus, busyIds, busyKindOfView, gameOf, jobCountOf, phaseOf, screenActions, screenActivityOf, statusOf } from './state-view.js'; import { req } from './dom.js'; const app = req('app'); @@ -41,6 +39,7 @@ let currentState: AppState = { kind: 'idle' }; // stays the truth for the PHASE (busy/ready/error) and the status text. null = neither card nor history, // i.e. the genuine "Insert a game card" screen. See BrowseInfo in shared/types. let currentBrowse: BrowseInfo | null = null; +let currentActivities: ActivityMap = {}; // The carousel hid the title + status for a pending selection change; the next render reveals the new one. let textSwapPending = false; // A direction is being held. While it is, the title/status stay hidden rather than being re-revealed on @@ -85,7 +84,6 @@ const audio = createAudioController(); // bleeding one game's background colors into another's. const hero = createHeroController({ hasGameOnScreen: () => currentBrowse !== null, - getGameId: () => currentBrowse?.id ?? '', }); // ── Settings screen (the fourth surface, see settings-screen.ts) ───────────── @@ -226,13 +224,9 @@ const gameSettingsScreen = createGameSettingsScreen({ // declared below, and no message can arrive before the user has opened this screen. notify: (text) => toast.show(text), showError: (text) => controls.showError(text), - // Editing while the game runs is legal (Р3); DELETING it is not — the launcher would be left holding a - // manifest the file no longer has. - isBusy: () => - currentState.kind === 'running' || - currentState.kind === 'installing' || - currentState.kind === 'uninstalling' || - steamBusy(currentState), + isBusy: (id) => + (phaseOf(currentState) === 'busy' && gameOf(currentState)?.id === id) || + currentActivities[id] !== undefined, }); // ── The notification toast (see toast.ts) ──────────────────────────────────── @@ -272,6 +266,24 @@ const toast = createToast({ }); const controls = createControls({ + api: { + requestLaunch: (id) => window.api.requestLaunch(id), + requestUninstall: (id) => window.api.requestUninstall(id), + cancelJob: (id) => window.api.cancelJob(id), + requestKill: () => window.api.requestKill(), + forgetGame: (id) => window.api.forgetGame(id), + openSteamDownloads: () => window.api.openSteamDownloads(), + requestShutdown: (confirmed) => window.api.requestShutdown(confirmed), + requestReboot: (confirmed) => window.api.requestReboot(confirmed), + requestSleep: () => window.api.requestSleep(), + requestHide: () => window.api.requestHide(), + requestQuit: (confirmed) => window.api.requestQuit(confirmed), + quitConfirmReply: (reply) => window.api.quitConfirmReply(reply), + resolveGameCollision: (answer) => window.api.resolveGameCollision(answer), + markNotificationsRead: () => window.api.markNotificationsRead(), + dismissNotification: (id) => window.api.dismissNotification(id), + clearNotifications: () => window.api.clearNotifications(), + }, getState: () => currentState, getLocale: () => currentLocale, getNotifications: () => notificationItems, @@ -279,8 +291,10 @@ const controls = createControls({ openGameDetail: (id) => openGameDetail(id), // Read lazily, like the carousel seam: the boot state is declared further down this module, and the // first press cannot arrive before it exists. - isBooting: () => !bootRevealed, + isBooting: () => !boot.isRevealed(), getBrowse: () => currentBrowse, + getScreenActivity: () => screenActivityOf(currentBrowse, currentActivities), + getJobCount: () => jobCountOf(currentActivities), audio, getTranslator, settings: settingsScreen, @@ -545,7 +559,7 @@ function buildInfoPanel(stats: Stats): void { // ── Title / status busy layout ────────────────────────────────────────────── // While busy (or during a Steam install/uninstall indicator) the title drops DOWN and the status line // fades in above it — a two-line block that keeps the long title fully visible (it no longer slides -// right into the More button). Both moves are pure CSS, keyed off #app[data-phase]/[data-steam-busy] +// right into the More button). Both moves are pure CSS, keyed off #app[data-phase]/[data-status] // (see styles.css), so there's no JS measurement here anymore. // ── Background music gating ────────────────────────────────────────────────── @@ -558,65 +572,9 @@ function syncMusic(): void { audio.setMusicPlaying(visible && !running); } -// ── "Chatter": a rotating funny suffix for long busy phases (install / Proton config — Р7j) ────────── -// The base status ("Установка..." / "Конфигурация Proton...") shows alone for the first MINUTE; after that -// a random funny suffix is APPENDED and swapped every 20s, so a long silent install/provision doesn't feel -// stuck. Renderer-owned (pure presentation) — main only sets the base state. -const CHATTER_DELAY_MS = 60_000; // base-only for the first minute -const CHATTER_ROTATE_MS = 20_000; // then swap the funny suffix every 20 seconds -const INSTALL_SUFFIX_KEYS: readonly MessageKey[] = [ - 'launcher.installChatter1', - 'launcher.installChatter2', - 'launcher.installChatter3', - 'launcher.installChatter4', - 'launcher.installChatter5', - 'launcher.installChatter6', - 'launcher.installChatter7', - 'launcher.installChatter8', - 'launcher.installChatter9', - 'launcher.installChatter10', -]; -// Reuse the Proton funny lines as suffixes appended to "Configuring Proton..." (protonConfig1 is the base). -const PROTON_SUFFIX_KEYS: readonly MessageKey[] = [ - 'launcher.protonConfig2', - 'launcher.protonConfig3', - 'launcher.protonConfig4', - 'launcher.protonConfig5', - 'launcher.protonConfig6', - 'launcher.protonConfig7', - 'launcher.protonConfig8', - 'launcher.protonConfig9', - 'launcher.protonConfig10', - 'launcher.protonConfig11', - 'launcher.protonConfig12', -]; - -type ChatterKind = 'installing' | 'configuringProton'; -let chatterKind: ChatterKind | null = null; -let chatterSuffix: MessageKey | null = null; -let chatterDelayTimer = 0; -let chatterRotateTimer = 0; - -function chatterPool(kind: ChatterKind): readonly MessageKey[] { - return kind === 'installing' ? INSTALL_SUFFIX_KEYS : PROTON_SUFFIX_KEYS; -} - -function stopChatterTimers(): void { - if (chatterDelayTimer !== 0) { - window.clearTimeout(chatterDelayTimer); - chatterDelayTimer = 0; - } - if (chatterRotateTimer !== 0) { - window.clearInterval(chatterRotateTimer); - chatterRotateTimer = 0; - } -} - -function rotateChatter(kind: ChatterKind): void { - const pool = chatterPool(kind); - chatterSuffix = pool[Math.floor(Math.random() * pool.length)] ?? null; - applyStatus(); -} +// ── Status line ───────────────────────────────────────────────────────────── +// The rotating funny suffix for long busy phases lives in chatter.ts; this module composes the line. +const chatter = createChatter({ onRotate: () => applyStatus() }); // Sets the status line: the base label for the current state, plus the current funny suffix when active. // The status belongs to the game AppState is about, so it is blank while you look at a DIFFERENT game @@ -632,33 +590,20 @@ function applyStatus(): void { /** The status line for what is ON SCREEN — empty while looking at a game the state isn't about. */ function statusText(): string { + const activity = screenActivityOf(currentBrowse, currentActivities); + if (activity !== undefined) return withChatter(activityStatus(activity, translator), activity.kind); const subject = gameOf(currentState)?.id; // Nothing on screen is a state of its own now — a launcher card — and the state's status belongs to a // game, so it says nothing there: "Installing…" under "Settings" would be a lie, and the line's mere // presence shifts the title (see [data-status] in styles.css). if (currentBrowse === null || (subject !== undefined && subject !== currentBrowse.id)) return ''; - const base = statusOf(currentState, translator); - return chatterSuffix !== null && currentState.kind === chatterKind - ? `${base} ${translator(chatterSuffix)}` - : base; + return withChatter(statusOf(currentState, translator), currentState.kind); } -// (Re)starts / stops the chatter timer as the state enters/leaves a long busy phase. First suffix appears -// at the first tick (~1 min); base-only before that. A phase change resets it (each phase gets its minute). -function syncChatter(state: AppState): void { - const kind: ChatterKind | null = - state.kind === 'installing' || state.kind === 'configuringProton' ? state.kind : null; - if (kind === chatterKind) return; // same phase (or same non-phase) — keep the running timers - stopChatterTimers(); - chatterKind = kind; - chatterSuffix = null; // base only for the first minute - if (kind !== null) { - chatterDelayTimer = window.setTimeout(() => { - chatterDelayTimer = 0; - rotateChatter(kind); // first funny suffix at 1 minute - chatterRotateTimer = window.setInterval(() => rotateChatter(kind), CHATTER_ROTATE_MS); // then every 20s - }, CHATTER_DELAY_MS); - } +/** The status line plus the current funny suffix, when one is running for `kind`. */ +function withChatter(base: string, kind: string): string { + const suffix = chatter.suffixFor(kind); + return suffix !== null ? `${base} ${translator(suffix)}` : base; } // ── Render ────────────────────────────────────────────────────────────────── @@ -713,14 +658,13 @@ function render(state: AppState): void { // screen; stop otherwise, e.g. on the idle screen). Idempotent — see hero.startRotation. hero.startRotation(); - // Steam non-blocking install/uninstall indicator: reuse the busy visuals (loader/status/slid title) - // via a dedicated attribute, while the logical phase stays 'ready' (window hideable, card pullable). - const busySteam = steamBusy(state); - if (busySteam) app.dataset['steamBusy'] = 'true'; - else delete app.dataset['steamBusy']; + const screenActivity = screenActivityOf(browse, currentActivities); + if (screenActivity !== undefined) app.dataset['activity'] = screenActivity.kind; + else delete app.dataset['activity']; // Play-button busy visual: gear (system activity) vs spinner (game phases). Absent when not busy. - const busyKind = busyKindOf(state); + const actions = screenActions(state, browse, screenActivity); + const busyKind = busyKindOfView(actions.playView); if (busyKind !== 'none') app.dataset['busy'] = busyKind; else delete app.dataset['busy']; @@ -735,13 +679,10 @@ function render(state: AppState): void { // (c) a LOCAL game whose executable is no longer on disk: it is active (it is in the library and keeps // its art and stats) but there is nothing to start, so it gets the same title + More layout, with the // status line saying why. - const hasPlay = - browse !== null && - browse.active && - browse.game?.unavailable !== true && - browse.game?.unconfigured !== true && - !(phase === 'ready' && browse.game?.requiresInstall === true && !busySteam); + const hasPlay = browse !== null && browse.active && actions.playView !== 'hidden'; app.dataset['cardMorph'] = hasPlay ? 'on' : 'off'; + if (actions.playView === 'play' && !actions.canPlay) app.dataset['playLocked'] = 'true'; + else delete app.dataset['playLocked']; // Only on the detail screen: in the carousel Play is hidden anyway, and the attribute's `.title{left:0}` // half would fight the carousel's own title placement. @@ -751,18 +692,18 @@ function render(state: AppState): void { // The busy game keeps a pulsing dot on its own card, so "game A is installing" stays visible while you // browse game B (whose status line is blank — see applyStatus). - const busyGame = phase === 'busy' || busySteam ? (gameOf(state)?.id ?? null) : null; - carousel.setBusyGame(busyGame); - libraryScreen.setBusyGame(busyGame); + const busy = busyIds(state, currentActivities); + carousel.setBusyGames(busy); + libraryScreen.setBusyGames(busy); - syncChatter(state); + chatter.sync(screenActivity?.kind ?? state.kind); applyStatus(); // Force-close popups off the ready screen, then re-apply the focus highlight (see controls.refresh). controls.refresh(); syncMusic(); - // Empty-screen error (Р8, point 1): a card that fails to load sets state=error with no game. In Game + // Empty-screen error: a card that fails to load sets state=error with no game. In Game // Mode the window is shown (no tray to hide into), so surface the reason over the idle screen via the // error popup. Only on ENTERING the error (prev not already error) so a locale/wallpaper re-render // doesn't re-pop a popup the user has closed. Desktop/Windows keep hiding, so this rarely fires there. @@ -771,137 +712,16 @@ function render(state: AppState): void { } } -// ── Boot reveal ───────────────────────────────────────────────────────────── -// index.html ships #app[data-boot], which hides the bar and the carousel strip (styles.css): -// the launcher opens on the background alone. The order is deliberate — wallpaper, then the game's own -// hero, then the UI: -// 1. the bundled wallpaper is the fastest image main can hand over, so it paints on the boot backdrop -// (#hero-boot — a layer of its own, ABOVE the hero) and keeps the screen for WALLPAPER_HOLD_MS, -// however quickly the rest arrives; -// 2. the card's hero paints on the hero layers UNDERNEATH it as soon as it lands, and the backdrop -// then dissolves to reveal a background that is already settled — the alternative, unwinding a -// shared zoom, made the picture travel backwards at the exact moment the UI arrived; -// 3. only then does the UI fade in — so it is never seen assembling itself, and never changes colour -// under the user's eyes a beat after appearing. -// The UI waits for ALL THREE seeds — the state, a settled background, and the carousel list — and never -// appears before BOOT_MIN_MS, so the reveal reads as an intro rather than as a stutter. The list is a -// seed in its own right because the strip's container is switched on in ONE frame (its opacity -// transition belongs to the card morph, see styles.css): arriving after the reveal, the whole carousel -// simply appeared, as if it had been display:none. The deadline covers a seed that never arrives -// (unreadable wallpaper, no hero, no library at all): the UI must not stay hidden forever. -/** - * How long the bundled wallpaper owns the screen at startup. A hero arriving earlier is painted right - * away but stays hidden under the backdrop, so the launcher always opens on the same picture for the - * same beat instead of flashing whatever loaded first. It is also the length of the startup jingle's - * FIRST half (assets/playhook-startup.mp3): the swell is the backdrop's, the tail plays over the UI - * arriving — which is why the countdown runs from the moment the sound starts, not from window load. - */ -const WALLPAPER_HOLD_MS = 2000; -/** The UI never appears before this — the hold plus the cross-fade it hands over to. */ -const BOOT_MIN_MS = WALLPAPER_HOLD_MS; -const BOOT_DEADLINE_MS = 5000; -/** Matches the backdrop's fade in styles.css (#hero-boot.is-gone). */ -const BOOT_FADE_MS = 1000; -const bootBackdrop = req('hero-boot'); -const bootStart = performance.now(); -let bootStateReady = false; -let bootHeroReady = false; -let bootLibraryReady = false; -let bootRevealed = false; -let revealTimer = 0; -// When the startup jingle actually began playing; null until it does (or forever, if it can't). -let jingleStartedAt: number | null = null; - -/** - * Hands the screen over to the hero underneath: the backdrop fades out and, over the same beat, travels - * to where that hero layer currently sits. Converging rather than parting matters because the two are - * often the SAME image — with no game on screen the background is this very wallpaper — and any offset left - * between them shows up as a double image sliding apart. Then it is taken out of the page entirely: it - * has nothing left to show, and a full-screen composited layer is not free. - */ -function dissolveBootBackdrop(): void { - const settled = hero.currentLayerTransform(); - // 'none' means there is no image under it at all (no wallpaper, no hero) — then there is nothing to - // converge on, and pulling the backdrop back to the identity transform would be the very lurch this - // whole arrangement exists to avoid. It just fades where it is. - if (settled !== 'none') bootBackdrop.style.transform = settled; - bootBackdrop.classList.add('is-gone'); - window.setTimeout(() => { - bootBackdrop.hidden = true; - }, BOOT_FADE_MS); -} - -function revealUi(): void { - if (bootRevealed) return; - bootRevealed = true; - delete app.dataset['boot']; - dissolveBootBackdrop(); - // The strip's cards were held at zero behind the boot screen — let them fan in now, so the carousel's - // own entrance is actually seen instead of having happened under the wallpaper. - carousel.playIntro(); -} - -/** - * When the boot image's turn is up: BOOT_MIN_MS after the jingle started, or — when there is no jingle - * (unreadable file, muted output, a refused autoplay) — after the window itself opened. The jingle is - * fetched over IPC and can start a beat late; letting the hold slide with it is what keeps the swell and - * the picture in step, rather than the sound arriving over a UI that is already up. - */ -function bootHoldEndsAt(): number { - return (jingleStartedAt ?? bootStart) + BOOT_MIN_MS; -} - -/** Arms (or re-arms) the reveal for the end of the hold. No-op until every seed is in. */ -function scheduleReveal(): void { - if (bootRevealed || !bootStateReady || !bootHeroReady || !bootLibraryReady) return; - if (revealTimer !== 0) window.clearTimeout(revealTimer); - revealTimer = window.setTimeout(revealUi, Math.max(0, bootHoldEndsAt() - performance.now())); -} - -function noteBootSeed(seed: 'state' | 'hero' | 'library'): void { - if (seed === 'state') bootStateReady = true; - else if (seed === 'hero') bootHeroReady = true; - else bootLibraryReady = true; - scheduleReveal(); -} - -window.setTimeout(revealUi, BOOT_DEADLINE_MS); - -// The startup jingle, played once. Requested as early as everything else and started the moment it -// lands; the boot hold is then re-armed around it (see bootHoldEndsAt). The deadline above is the -// backstop: a jingle that arrives absurdly late can delay the reveal, but never hold it hostage. -void window.api.requestStartupSound().then(async (url) => { - if (url === null || bootRevealed) return; - await audio.playStartup(url); - if (bootRevealed) return; - jingleStartedAt = performance.now(); - scheduleReveal(); -}); - -// The startup push on the backdrop (#hero-boot in styles.css): a wider, faster drift than the hero's -// perpetual pan, and it never unwinds — the layer dissolves mid-travel instead. Two frames of delay -// because a transition needs its starting value painted first: set in the same frame as the load and -// there is nothing to move from. The direction is randomized like the layers' own pan, so the launcher -// doesn't always open drifting the same way. -requestAnimationFrame(() => { - requestAnimationFrame(() => { - if (bootRevealed) return; - bootBackdrop.style.setProperty('--boot-pan', Math.random() < 0.5 ? '4.5%' : '-4.5%'); - bootBackdrop.classList.add('is-panning'); - }); +// ── Boot reveal (see boot.ts) ─────────────────────────────────────────────── +// The seeds it waits for are reported from the wiring below: the first state, a settled background (the +// wallpaper and the hero channel), and the carousel list. +const boot = createBootSequence({ + requestStartupSound: () => window.api.requestStartupSound(), + playStartup: (url) => audio.playStartup(url), + settledTransform: () => hero.currentLayerTransform(), + onRevealed: () => carousel.playIntro(), }); -// Whether the background that will STAY is up: the card's hero when it has one, the wallpaper when it -// does not. The wallpaper alone is not enough while a hero is still expected — that is the cross-fade -// the reveal is supposed to happen after, not during. -let heroPayload: 'pending' | 'none' | 'present' = 'pending'; -let wallpaperPainted = false; - -function noteBackgroundSettled(): void { - if (heroPayload === 'present' || (heroPayload === 'none' && wallpaperPainted)) - noteBootSeed('hero'); -} - // ── Wiring ────────────────────────────────────────────────────────────────── // UI locale: subscribe BEFORE the invoke-seed so a push arriving in between isn't lost (seed pattern). @@ -925,10 +745,20 @@ function applyLocale(locale: Locale): void { window.api.onLanguageUpdate(applyLocale); void window.api.getLanguage().then(applyLocale); +window.api.onActivityUpdate((activities) => { + currentActivities = activities; + render(currentState); +}); +void window.api.requestActivities().then((activities) => { + currentActivities = activities; + render(currentState); +}); + +window.api.onQuitConfirm((action) => controls.askQuit(action)); window.api.onStateUpdate(render); void window.api.requestState().then((state) => { render(state); - noteBootSeed('state'); + boot.noteState(); }); // What is on screen (title / stats / active / GameInfo). Subscribe BEFORE the seed, like every other @@ -940,7 +770,7 @@ function applyBrowse(browse: BrowseInfo | null): void { currentBrowse = browse; // The Customize screen is about ONE game's file. When the card carrying it is pulled or swapped — // everything under the screen is rebuilt by then — there is nothing left to edit, so it closes rather - // than staying open over a game that is gone (see the plan, Р6.2). + // than staying open over a game that is gone. gameSettingsScreen.applyBrowse(browse); // The game the screen was about is GONE — the last history entry was forgotten, the card was pulled — // and main has nothing to put in its place. A detail screen is one game's screen, so with no game there @@ -1021,15 +851,12 @@ void Promise.all([ // that is the background a card whose hero never arrives is left with once the backdrop dissolves. void window.api.requestWallpaper().then((url) => { hero.setWallpaper(url); - if (url === null) bootBackdrop.hidden = true; - else bootBackdrop.style.backgroundImage = `url("${url}")`; if (gameOf(currentState) === undefined) { hero.applyIdleBackground(); // The title the empty screen used to carry belongs to render() now (a launcher card names itself). render(currentState); } else hero.showWallpaperBackdrop(); - wallpaperPainted = url !== null; - noteBackgroundSettled(); + boot.noteWallpaper(url); }); // The card's music is delivered on its own channel (not in AppState); load it and keep music in sync. @@ -1042,7 +869,7 @@ void window.api.requestCardMusic().then((url) => { syncMusic(); }); -// The default ambience is app-wide (set in the settings window) and delivered on its own channel; the +// The default ambience is app-wide (set on the Settings screen) and delivered on its own channel; the // audio engine plays it only while the card has no music of its own (a game's music always wins) and // crossfades between the two. Seed on startup and update live. syncMusic re-asserts the gate so a seed // arriving before the first visibility sync still starts (or stays paused) correctly. @@ -1059,7 +886,7 @@ void window.api.requestAmbient().then((url) => { window.api.onSfxSet((set) => audio.setSounds(set)); void window.api.requestSfxSet().then((set) => audio.setSounds(set)); -// Audio volumes are app-wide (set in the settings window): seed them on startup and update live. +// Audio volumes are app-wide (set on the Settings screen): seed them on startup and update live. const applyVolumes = (volumes: { music: number; sfx: number }): void => { audio.setMusicVolume(volumes.music); audio.setSfxVolume(volumes.sfx); @@ -1076,15 +903,11 @@ window.api.onWindowFocus((focused) => controls.setGamepadPaused(!focused)); // locally, so we never re-send this large payload on every state transition. See hero.applyAssets. window.api.onHeroUpdate((assets) => { hero.applyAssets(assets); - if (assets !== null) { - heroPayload = 'present'; - noteBackgroundSettled(); - } + if (assets !== null) boot.noteHero('present'); }); void window.api.requestHero().then((assets) => { hero.applyAssets(assets); - heroPayload = assets === null ? 'none' : 'present'; - noteBackgroundSettled(); + boot.noteHero(assets === null ? 'none' : 'present'); }); // The carousel list (the inserted card's games + the play history, already ordered) arrives on its own @@ -1119,7 +942,7 @@ void window.api.requestLibrary().then((library) => { applyLibrary(library?.games ?? []); // Even an empty list counts: it settles `data-screen`, which is what decides whether the strip's // container is on at all. Waiting for it is what keeps the carousel from popping in afterwards. - noteBootSeed('library'); + boot.noteLibrary(); }); // The notification inbox and the plates main asks us to show. The list is the popup's only source of diff --git a/src/renderer/asset-lightbox.ts b/src/renderer/asset-lightbox.ts new file mode 100644 index 00000000..c3fad070 --- /dev/null +++ b/src/renderer/asset-lightbox.ts @@ -0,0 +1,117 @@ +// The Customize screen's artwork viewer and its thumbnails: the lightbox a picture opens in at full size +// (the topmost surface of all — a look at a picture, closed by B or the veil), and the strip of +// thumbnails the artwork rows draw. Both read pictures through ONE seam, `locate`: the screen knows +// where a manifest-relative path's bytes are right now (a card, the PC library, a history game's staging +// directory, the SOURCE side of a pending move), this module only knows how to show them. +import type { AudioController } from './audio.js'; +import { req } from './dom.js'; +import { applyThumbnails, type RenderedGameRow } from './game-settings-view.js'; + +/** Where a path's bytes are read from right now, and the cache key that names those bytes. */ +export interface AssetSource { + /** + * Includes the root (or the history id): the same card-relative STRING can name different bytes in the + * PC library and on a move's target card, and thumbnails must not conflate them — nor may every history + * game share the '' key and serve each other's covers. + */ + readonly key: string; + /** One invoke, answering the preview URL — null when it cannot be read. */ + read(): Promise; +} + +export interface AssetLightboxDeps { + readonly audio: Pick; + /** The screen's answer to "where is this path right now" — null when nowhere (no root yet). */ + locate(path: string): AssetSource | null; +} + +export interface AssetLightbox { + isOpen(): boolean; + /** Opens the artwork at `path` at full size. Nothing but a look — B (or the veil) closes it. */ + show(path: string): Promise; + /** The lightbox itself, shared by a file already on the card and a variant still only online. */ + open(url: string, caption: string): void; + close(options?: { readonly silent?: boolean }): void; + /** Reads the artwork rows' thumbnails (one invoke per path, cached) and drops them into their rows. */ + refreshThumbnails(rows: readonly RenderedGameRow[]): Promise; + /** Forgets the thumbnails read so far — a screen re-opened after the files moved starts fresh. */ + clearCache(): void; +} + +export function createAssetLightbox(deps: AssetLightboxDeps): AssetLightbox { + const lightboxEl = req('lightbox'); + const lightboxImage = req('lightbox-image'); + const lightboxCaption = req('lightbox-caption'); + let open = false; + /** + * The thumbnails read so far, by path. Stepping back onto a section re-renders its rows, and reading + * every picture off the disk again for a strip that has not changed is both a round trip per image and + * a visible re-decode. + */ + const thumbnails = new Map(); + + async function thumbnailFor(source: AssetSource): Promise { + const cached = thumbnails.get(source.key); + if (cached !== undefined) return cached; + const url = await source.read(); + thumbnails.set(source.key, url); + return url; + } + + function openLightbox(url: string, caption: string): void { + deps.audio.play('popup-open'); + lightboxImage.src = url; + lightboxCaption.textContent = caption; + open = true; + lightboxEl.classList.add('is-open'); + lightboxEl.setAttribute('aria-hidden', 'false'); + } + + function close(options?: { readonly silent?: boolean }): void { + if (!open) return; + if (options?.silent !== true) deps.audio.play('popup-close'); + open = false; + lightboxEl.classList.remove('is-open'); + lightboxEl.setAttribute('aria-hidden', 'true'); + lightboxImage.removeAttribute('src'); + } + + lightboxEl.querySelector('.lightbox-veil')?.addEventListener('click', () => { + close(); + }); + + return { + isOpen: () => open, + show: async (path) => { + if (path === '') return; + const source = deps.locate(path); + if (source === null) return; + const url = await source.read(); + if (url === null) return; // a preview that could not be read never became a surface — and never sounds + openLightbox(url, path); + }, + open: openLightbox, + close, + refreshThumbnails: async (rows) => { + for (const row of rows) { + const source = row.row; + if (source.kind === 'list' && source.preview !== undefined) { + const urls = await Promise.all( + source.items.map((item) => { + const at = deps.locate(item); + return at === null ? Promise.resolve(null) : thumbnailFor(at); + }), + ); + applyThumbnails(row, urls, source.preview, source.items); + } else if (source.kind === 'path' && source.preview !== undefined) { + const at = source.value === '' ? null : deps.locate(source.value); + const url = at === null ? null : await thumbnailFor(at); + applyThumbnails(row, [url], source.preview, [source.value]); + } + } + }, + clearCache: () => { + thumbnails.clear(); + }, + }; +} diff --git a/src/renderer/audio.ts b/src/renderer/audio.ts index 0c136854..828ee1cd 100644 --- a/src/renderer/audio.ts +++ b/src/renderer/audio.ts @@ -6,7 +6,7 @@ // `gameMusic ?? ambient` and CROSSFADES between sources (so inserting/removing a card, or switching the // ambience, glides instead of cutting). Music/ambience share one volume; UI sounds have their own. // Playback is gated by app.ts (visible && !running) via setMusicPlaying. -import type { SfxName, SfxSet } from '../shared/types'; +import type { SfxName, SfxSet } from '../shared/types.js'; import { shouldPlayLimit } from './sfx-limit.js'; // Fallback volumes until the persisted ones arrive from main (music historically played at 0.5). diff --git a/src/renderer/auto-repeat.ts b/src/renderer/auto-repeat.ts index 2654135e..96b6557b 100644 --- a/src/renderer/auto-repeat.ts +++ b/src/renderer/auto-repeat.ts @@ -1,5 +1,5 @@ // The tempo of a HELD direction, shared by both input models: the gamepad polls its own buttons -// (gamepad.ts) while the keyboard runs on timers (controls.ts), but the delay before the auto-move +// (gamepad.ts) while the keyboard runs on timers (keyboard.ts), but the delay before the auto-move // starts, its cadence, and the rule for chaining one run into the next must feel identical on both. /** How long a direction must be HELD before the auto-move kicks in (a normal press stays one move). */ diff --git a/src/renderer/boot.ts b/src/renderer/boot.ts new file mode 100644 index 00000000..b086500f --- /dev/null +++ b/src/renderer/boot.ts @@ -0,0 +1,181 @@ +// The boot reveal: the launcher opens on the background alone and the UI fades in once everything it +// needs has arrived. +// +// index.html ships #app[data-boot], which hides the bar and the carousel strip (styles.css): +// the launcher opens on the background alone. The order is deliberate — wallpaper, then the game's own +// hero, then the UI: +// 1. the bundled wallpaper is the fastest image main can hand over, so it paints on the boot backdrop +// (#hero-boot — a layer of its own, ABOVE the hero) and keeps the screen for WALLPAPER_HOLD_MS, +// however quickly the rest arrives; +// 2. the card's hero paints on the hero layers UNDERNEATH it as soon as it lands, and the backdrop +// then dissolves to reveal a background that is already settled — the alternative, unwinding a +// shared zoom, made the picture travel backwards at the exact moment the UI arrived; +// 3. only then does the UI fade in — so it is never seen assembling itself, and never changes colour +// under the user's eyes a beat after appearing. +// The UI waits for ALL THREE seeds — the state, a settled background, and the carousel list — and never +// appears before BOOT_MIN_MS, so the reveal reads as an intro rather than as a stutter. The list is a +// seed in its own right because the strip's container is switched on in ONE frame (its opacity +// transition belongs to the card morph, see styles.css): arriving after the reveal, the whole carousel +// simply appeared, as if it had been display:none. The deadline covers a seed that never arrives +// (unreadable wallpaper, no hero, no library at all): the UI must not stay hidden forever. +import { req } from './dom.js'; + +/** + * How long the bundled wallpaper owns the screen at startup. A hero arriving earlier is painted right + * away but stays hidden under the backdrop, so the launcher always opens on the same picture for the + * same beat instead of flashing whatever loaded first. It is also the length of the startup jingle's + * FIRST half (assets/playhook-startup.mp3): the swell is the backdrop's, the tail plays over the UI + * arriving — which is why the countdown runs from the moment the sound starts, not from window load. + */ +const WALLPAPER_HOLD_MS = 2000; +/** The UI never appears before this — the hold plus the cross-fade it hands over to. */ +const BOOT_MIN_MS = WALLPAPER_HOLD_MS; +const BOOT_DEADLINE_MS = 5000; +/** Matches the backdrop's fade in styles.css (#hero-boot.is-gone). */ +const BOOT_FADE_MS = 1000; + +export interface BootDeps { + /** The startup jingle's URL, fetched over IPC (null when there is none to play). */ + requestStartupSound(): Promise; + /** Plays it once; resolves when it actually started (or could not — a refused autoplay, a muted output). */ + playStartup(url: string): Promise; + /** + * Where the hero layer under the backdrop currently sits — what the backdrop converges on as it + * dissolves. 'none' when there is no image under it at all (see dissolveBootBackdrop). + */ + settledTransform(): string; + /** The UI is on: the strip's cards may fan in now. */ + onRevealed(): void; +} + +export interface BootSequence { + /** Whether the boot screen has come down. Every input path is fenced on this (see controls.ts). */ + isRevealed(): boolean; + /** The first AppState landed. */ + noteState(): void; + /** The carousel list landed — even an empty one counts (it settles `data-screen`). */ + noteLibrary(): void; + /** Paints the bundled wallpaper on the backdrop — or takes the backdrop out when there is none. */ + noteWallpaper(url: string | null): void; + /** What the hero channel delivered: a picture, or nothing for this game. */ + noteHero(payload: 'none' | 'present'): void; +} + +export function createBootSequence(deps: BootDeps): BootSequence { + const app = req('app'); + const bootBackdrop = req('hero-boot'); + const bootStart = performance.now(); + let bootStateReady = false; + let bootHeroReady = false; + let bootLibraryReady = false; + let bootRevealed = false; + let revealTimer = 0; + // When the startup jingle actually began playing; null until it does (or forever, if it can't). + let jingleStartedAt: number | null = null; + + /** + * Hands the screen over to the hero underneath: the backdrop fades out and, over the same beat, travels + * to where that hero layer currently sits. Converging rather than parting matters because the two are + * often the SAME image — with no game on screen the background is this very wallpaper — and any offset left + * between them shows up as a double image sliding apart. Then it is taken out of the page entirely: it + * has nothing left to show, and a full-screen composited layer is not free. + */ + function dissolveBootBackdrop(): void { + const settled = deps.settledTransform(); + // 'none' means there is no image under it at all (no wallpaper, no hero) — then there is nothing to + // converge on, and pulling the backdrop back to the identity transform would be the very lurch this + // whole arrangement exists to avoid. It just fades where it is. + if (settled !== 'none') bootBackdrop.style.transform = settled; + bootBackdrop.classList.add('is-gone'); + window.setTimeout(() => { + bootBackdrop.hidden = true; + }, BOOT_FADE_MS); + } + + function revealUi(): void { + if (bootRevealed) return; + bootRevealed = true; + delete app.dataset['boot']; + dissolveBootBackdrop(); + // The strip's cards were held at zero behind the boot screen — let them fan in now, so the carousel's + // own entrance is actually seen instead of having happened under the wallpaper. + deps.onRevealed(); + } + + /** + * When the boot image's turn is up: BOOT_MIN_MS after the jingle started, or — when there is no jingle + * (unreadable file, muted output, a refused autoplay) — after the window itself opened. The jingle is + * fetched over IPC and can start a beat late; letting the hold slide with it is what keeps the swell and + * the picture in step, rather than the sound arriving over a UI that is already up. + */ + function bootHoldEndsAt(): number { + return (jingleStartedAt ?? bootStart) + BOOT_MIN_MS; + } + + /** Arms (or re-arms) the reveal for the end of the hold. No-op until every seed is in. */ + function scheduleReveal(): void { + if (bootRevealed || !bootStateReady || !bootHeroReady || !bootLibraryReady) return; + if (revealTimer !== 0) window.clearTimeout(revealTimer); + revealTimer = window.setTimeout(revealUi, Math.max(0, bootHoldEndsAt() - performance.now())); + } + + function noteBootSeed(seed: 'state' | 'hero' | 'library'): void { + if (seed === 'state') bootStateReady = true; + else if (seed === 'hero') bootHeroReady = true; + else bootLibraryReady = true; + scheduleReveal(); + } + + window.setTimeout(revealUi, BOOT_DEADLINE_MS); + + // The startup jingle, played once. Requested as early as everything else and started the moment it + // lands; the boot hold is then re-armed around it (see bootHoldEndsAt). The deadline above is the + // backstop: a jingle that arrives absurdly late can delay the reveal, but never hold it hostage. + void deps.requestStartupSound().then(async (url) => { + if (url === null || bootRevealed) return; + await deps.playStartup(url); + if (bootRevealed) return; + jingleStartedAt = performance.now(); + scheduleReveal(); + }); + + // The startup push on the backdrop (#hero-boot in styles.css): a wider, faster drift than the hero's + // perpetual pan, and it never unwinds — the layer dissolves mid-travel instead. Two frames of delay + // because a transition needs its starting value painted first: set in the same frame as the load and + // there is nothing to move from. The direction is randomized like the layers' own pan, so the launcher + // doesn't always open drifting the same way. + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (bootRevealed) return; + bootBackdrop.style.setProperty('--boot-pan', Math.random() < 0.5 ? '4.5%' : '-4.5%'); + bootBackdrop.classList.add('is-panning'); + }); + }); + + // Whether the background that will STAY is up: the card's hero when it has one, the wallpaper when it + // does not. The wallpaper alone is not enough while a hero is still expected — that is the cross-fade + // the reveal is supposed to happen after, not during. + let heroPayload: 'pending' | 'none' | 'present' = 'pending'; + let wallpaperPainted = false; + + function noteBackgroundSettled(): void { + if (heroPayload === 'present' || (heroPayload === 'none' && wallpaperPainted)) + noteBootSeed('hero'); + } + + return { + isRevealed: () => bootRevealed, + noteState: () => noteBootSeed('state'), + noteLibrary: () => noteBootSeed('library'), + noteWallpaper: (url) => { + if (url === null) bootBackdrop.hidden = true; + else bootBackdrop.style.backgroundImage = `url("${url}")`; + wallpaperPainted = url !== null; + noteBackgroundSettled(); + }, + noteHero: (payload) => { + heroPayload = payload; + noteBackgroundSettled(); + }, + }; +} diff --git a/src/renderer/carousel.ts b/src/renderer/carousel.ts index 952d4bba..68f34f40 100644 --- a/src/renderer/carousel.ts +++ b/src/renderer/carousel.ts @@ -9,7 +9,7 @@ // attribute. What is SHOWN for the selected card (title, stats, background, music) is main's answer to // `browseGame(id)` — this module never derives it; a launcher card answers `browseNone()`, which is main's // "nothing is on screen". The geometry lives in carousel-geometry.ts (pure). -import type { LibraryEntry } from '../shared/types'; +import type { LibraryEntry } from '../shared/types.js'; import type { Translator } from '../shared/i18n/index.js'; import { MAX_STRIP_GAMES, @@ -27,6 +27,7 @@ import { systemCardIcon } from './system-card-icons.js'; import { req, reqCanvas } from './dom.js'; import { FALLBACK_COLOUR, JELLY, createFocusJelly, jellyBoxOf } from './focus-jelly.js'; import { pxUnit } from './screen-scroller.js'; +import { sameIds } from './state-view.js'; /** The two levels of the launcher screen (mirrors `#app[data-screen]`). */ export type Screen = 'carousel' | 'detail'; @@ -98,8 +99,8 @@ export interface Carousel { * never arrive, and the row sits there under the launcher's idle background. */ announce(): void; - /** Marks the game AppState is busy with, so its card can pulse wherever it sits in the list. */ - setBusyGame(id: string | null): void; + /** Marks the games that are busy (an activity or the session), so their cards pulse wherever they sit. */ + setBusyGames(ids: ReadonlySet): void; /** Whether the inbox holds anything unread — the Notifications card wears the same dot a game does. */ setUnread(unread: boolean): void; /** @@ -153,7 +154,7 @@ export function createCarousel(deps: CarouselDeps): Carousel { let items: readonly CarouselItem[] = [...systemItems]; let index = 0; let screen: Screen = 'detail'; - let busyId: string | null = null; + let busyIds: ReadonlySet = new Set(); // Whether the inbox holds unread entries (main's push, relayed by app.ts) — the Notifications card's dot. let unread = false; // While the strip is coming back from the detail screen the selected card is still growing out of the @@ -202,7 +203,7 @@ export function createCarousel(deps: CarouselDeps): Carousel { */ function showsDot(item: CarouselItem): boolean { if (item.kind === 'system') return item.card.id === 'notifications' && unread; - return (item.game.active && item.game.unconfigured !== true) || item.game.id === busyId; + return (item.game.active && item.game.unconfigured !== true) || busyIds.has(item.game.id); } /** @@ -269,7 +270,7 @@ export function createCarousel(deps: CarouselDeps): Carousel { const card = cards.get(key); if (card === undefined) return; card.classList.toggle('is-selected', key === currentKey); - card.classList.toggle('is-busy', item.kind === 'game' && item.game.id === busyId); + card.classList.toggle('is-busy', item.kind === 'game' && busyIds.has(item.game.id)); card.classList.toggle('shows-dot', showsDot(item)); // Past the shown window (see VISIBLE_CARDS): still laid out — the strip's offset is positional and // a removed node would shift every card after it — but faded out, so it slides in softly when the @@ -599,9 +600,9 @@ export function createCarousel(deps: CarouselDeps): Carousel { announce(): void { announceSelection(); }, - setBusyGame(id: string | null): void { - if (id === busyId) return; - busyId = id; + setBusyGames(ids: ReadonlySet): void { + if (sameIds(ids, busyIds)) return; + busyIds = ids; applyLayout(); }, }; diff --git a/src/renderer/chatter.ts b/src/renderer/chatter.ts new file mode 100644 index 00000000..09fea530 --- /dev/null +++ b/src/renderer/chatter.ts @@ -0,0 +1,101 @@ +// "Chatter": a rotating funny suffix for long busy phases (install / Proton config). +// +// The base status ("Installing..." / "Configuring Proton...") shows alone for the first MINUTE; after that +// a random funny suffix is APPENDED and swapped every 20s, so a long silent install/provision doesn't feel +// stuck. Renderer-owned (pure presentation) — main only sets the base state. This module owns the timers +// and the current suffix; app.ts composes the line (the base status belongs to what is ON SCREEN, which +// only app.ts knows). +import type { MessageKey } from '../shared/i18n/index.js'; + +const CHATTER_DELAY_MS = 60_000; // base-only for the first minute +const CHATTER_ROTATE_MS = 20_000; // then swap the funny suffix every 20 seconds +const INSTALL_SUFFIX_KEYS: readonly MessageKey[] = [ + 'launcher.installChatter1', + 'launcher.installChatter2', + 'launcher.installChatter3', + 'launcher.installChatter4', + 'launcher.installChatter5', + 'launcher.installChatter6', + 'launcher.installChatter7', + 'launcher.installChatter8', + 'launcher.installChatter9', + 'launcher.installChatter10', +]; +// Reuse the Proton funny lines as suffixes appended to "Configuring Proton..." (protonConfig1 is the base). +const PROTON_SUFFIX_KEYS: readonly MessageKey[] = [ + 'launcher.protonConfig2', + 'launcher.protonConfig3', + 'launcher.protonConfig4', + 'launcher.protonConfig5', + 'launcher.protonConfig6', + 'launcher.protonConfig7', + 'launcher.protonConfig8', + 'launcher.protonConfig9', + 'launcher.protonConfig10', + 'launcher.protonConfig11', + 'launcher.protonConfig12', +]; + +type ChatterKind = 'installing' | 'configuringProton'; + +export interface ChatterDeps { + /** A new suffix was picked — the status line has to be redrawn. */ + onRotate(): void; +} + +export interface Chatter { + /** (Re)starts / stops the timers as the shown phase (a session state or an activity) enters / leaves a long busy one. */ + sync(kind: string): void; + /** The suffix to append for phase `kind`, or null while the base label shows alone (or for another phase). */ + suffixFor(kind: string): MessageKey | null; +} + +function chatterPool(kind: ChatterKind): readonly MessageKey[] { + return kind === 'installing' ? INSTALL_SUFFIX_KEYS : PROTON_SUFFIX_KEYS; +} + +export function createChatter(deps: ChatterDeps): Chatter { + let kind: ChatterKind | null = null; + let suffix: MessageKey | null = null; + let delayTimer = 0; + let rotateTimer = 0; + + function stopTimers(): void { + if (delayTimer !== 0) { + window.clearTimeout(delayTimer); + delayTimer = 0; + } + if (rotateTimer !== 0) { + window.clearInterval(rotateTimer); + rotateTimer = 0; + } + } + + function rotate(of: ChatterKind): void { + const pool = chatterPool(of); + suffix = pool[Math.floor(Math.random() * pool.length)] ?? null; + deps.onRotate(); + } + + // First suffix appears at the first tick (~1 min); base-only before that. A phase change resets it + // (each phase gets its minute). + function sync(of: string): void { + const next: ChatterKind | null = of === 'installing' || of === 'configuringProton' ? of : null; + if (next === kind) return; // same phase (or same non-phase) — keep the running timers + stopTimers(); + kind = next; + suffix = null; // base only for the first minute + if (next !== null) { + delayTimer = window.setTimeout(() => { + delayTimer = 0; + rotate(next); // first funny suffix at 1 minute + rotateTimer = window.setInterval(() => rotate(next), CHATTER_ROTATE_MS); // then every 20s + }, CHATTER_DELAY_MS); + } + } + + return { + sync, + suffixFor: (of) => (suffix !== null && of === kind ? suffix : null), + }; +} diff --git a/src/renderer/configure-form-model.ts b/src/renderer/configure-form-model.ts index bac0de9e..203a10fe 100644 --- a/src/renderer/configure-form-model.ts +++ b/src/renderer/configure-form-model.ts @@ -1,5 +1,5 @@ // Pure (DOM-free, electron-free) bridge between the Configure form and game.json TEXT — the single source -// of truth stays the manifest text (see plan R2), so the form only ever converts to/from a string that the +// of truth stays the manifest text, so the form only ever converts to/from a string that the // existing config:save pipeline writes verbatim. Testable in vitest. // // Two escape hatches keep the round-trip lossless and honest: @@ -64,7 +64,7 @@ export interface InstallModel { readonly type: InstallType; readonly runAsAdmin: boolean; readonly args: readonly string[]; - /** Extra winetricks verbs provisioned into the prefix before the installer runs (Linux; Р7b). */ + /** Extra winetricks verbs provisioned into the prefix before the installer runs (Linux). */ readonly winetricks: readonly string[]; readonly rest: Readonly>; } @@ -83,7 +83,7 @@ export interface PcModel { /** * All form fields, including the sections hidden by the current launch mode (they live here until - * serialization, so switching modes and back restores what was typed — see plan R5). Numbers are kept as + * serialization, so switching modes and back restores what was typed). Numbers are kept as * TEXT (`launchTimeoutSec`, `steam.appid`) because the Fluent text-input value is a string; serialization * parses them. */ @@ -103,9 +103,9 @@ export interface ManifestFormModel { readonly launchTimeoutSec: string; readonly killTimeoutSec: string; readonly backgroundMusic: string; - /** Extra winetricks verbs provisioned into the prefix before the GAME launches (Linux; Р7b). */ + /** Extra winetricks verbs provisioned into the prefix before the GAME launches (Linux). */ readonly winetricks: readonly string[]; - /** umu GAMEID for launch — a Steam appid or custom UMU_ID (Linux; Р7i). '' = default `umu-default`. */ + /** umu GAMEID for launch — a Steam appid or custom UMU_ID (Linux). '' = default `umu-default`. */ readonly umuGameId: string; /** The `install` block for INSTALLER mode (types nsis/inno/custom). Never holds `copy`. */ readonly install: InstallModel; @@ -136,7 +136,7 @@ export type ParseFormResult = /** Known top-level keys with an invalid value type, kept raw and written back verbatim. */ readonly corrupt: Readonly>; /** The source carried blocks for more than one launch mode (steam + install/executable) — the form - * activates one and a banner warns that saving drops the others (plan R5). */ + * activates one and a banner warns that saving drops the others. */ readonly mixed: boolean; } | { readonly ok: false; readonly message: string }; @@ -195,7 +195,7 @@ function emptyPc(): PcModel { /** * A pristine, all-empty form model — used for a blank drive and the empty baseline of the template-replace - * confirm (plan R8). The mode is a PARAMETER because a blank PC library must start in `pc` mode: it is the + * confirm. The mode is a PARAMETER because a blank PC library must start in `pc` mode: it is the * only mode valid there, so defaulting to `executable` would hand the user a form whose every save is * rejected. */ @@ -294,7 +294,7 @@ function parseSteam(source: Record): SteamModel | null { /** * Parses manifest TEXT into a form model. ok:false only for a syntax error or a non-object top-level (the - * form cannot represent those — the caller keeps the JSON tab, plan R4). A syntactically valid but + * form cannot represent those — the caller keeps the JSON tab). A syntactically valid but * schema-invalid manifest still parses: wrong-typed known fields go to `corrupt` and are written back * verbatim so the server validator still reports them. */ @@ -424,7 +424,7 @@ function valueToFormResult(parsed: unknown): ParseFormResult { else corrupt['pc'] = value; } - // Launch mode: pc > steam > install > executable (plan R5). Presence (not validity) decides — a corrupt + // Launch mode: pc > steam > install > executable. Presence (not validity) decides — a corrupt // block still selects its mode, and its raw value is re-emitted from `corrupt` so the error shows. // `install` with `type: 'copy'` is the exception: it is Executable mode with the checkbox on, so it // must NOT be shown as an Installer (the user never chose that mode). @@ -584,11 +584,11 @@ function buildManifestObject( // Executable checkbox a `type: 'copy'` one (its `installer` being the game directory to copy). if (model.launchMode === 'installer') out.install = buildInstall(model.install); else if (model.copyToPc) out.install = buildInstall(model.copyInstall); - // Game-launch prefix provisioning (Linux; Р7b) — applies to our own prefix (executable/installer + // Game-launch prefix provisioning (Linux) — applies to our own prefix (executable/installer // modes), not steam (which runs in Steam's compatdata). const winetricks = nonEmpty(model.winetricks); if (winetricks.length > 0) out.winetricks = winetricks; - // umu GAMEID for the launch (Linux; Р7i) — same scope: our own umu-run, not steam://. + // umu GAMEID for the launch (Linux) — same scope: our own umu-run, not steam://. if (model.umuGameId !== '') out.umuGameId = model.umuGameId; } @@ -639,7 +639,7 @@ export interface FormGameSlot { * may carry several games, `readManifests` SKIPS the ones that do not resolve, and the rest stay * perfectly playable — so the user edits game B while game A sits in the same file, unrepresentable. With * only `FormGameSlot` to serialize from, saving B would have to drop A. Preserving it verbatim is not a - * nicety; it is the difference between editing a game and destroying its neighbour (see the plan, Р2). + * nicety; it is the difference between editing a game and destroying its neighbour. */ export interface RawGameSlot { readonly raw: unknown; @@ -734,7 +734,7 @@ export function slotsWithNewGame(text: string | null, launchMode: LaunchMode): N /** * Serializes a LIST of game form states back to manifest TEXT: exactly one game → a single object (legacy - * shape, maximal backwards compatibility), more than one → an array (see the plan, decision 2). + * shape, maximal backwards compatibility), more than one → an array. * * An EMPTY list serializes to `[]` — the PC library's "there are no local games any more", which main * turns into deleting game.json. A card never reaches this (its last game cannot be removed). diff --git a/src/renderer/confirm.ts b/src/renderer/confirm.ts new file mode 100644 index 00000000..9e4b93ca --- /dev/null +++ b/src/renderer/confirm.ts @@ -0,0 +1,311 @@ +// The confirm view of the popup column (popups.ts), as two pure halves: what the question SAYS for a +// given mode (`describeConfirm`), and what a "Yes" DOES (`runConfirmedAction`). The popup keeps the +// state — which mode is up, where B returns to, the id a "forget" was asked about — and applies both. +import type { AudioController } from './audio.js'; +import type { ControlsApi, GameSettingsNav, SettingsNav } from './controls-deps.js'; +import type { BrowseInfo, GameCollision, GameInfo } from '../shared/types.js'; +import type { Translator } from '../shared/i18n/index.js'; +import { quitQuestion, type QuitAction } from '../shared/quit.js'; + +// Which action the confirm view is asking about (only meaningful while the popup shows 'confirm'). +export type ConfirmMode = + | 'install' + | 'uninstall' + | 'kill' + | 'forget' + | 'shutdown' + | 'reboot' + | 'sleep' + | 'reset-settings' + | 'reset-game-settings' + | 'delete-game' + // The second half of the delete question: whether the game's HISTORY record goes with it. Its "No" is + // an answer rather than a cancel — see the confirmNo branch in triggerStackButton. + | 'delete-game-history' + | 'discard-game-settings' + | 'switch-game-source' + // Leaving the screen (B/veil/Close) while a "Move to card…" is pending — drops the pending move and + // returns the form to the PC library's baseline, WITHOUT closing the screen (see game-settings-screen.ts + // PendingMove). Kept apart from 'discard-game-settings', whose "Yes" closes the whole screen. + | 'cancel-move-game-settings' + // Taking the store's spelling into the Customize form's Title — the one thing the "Find online" screen + // does that REPLACES something the user may have typed rather than adding a file beside the game. + | 'replace-game-title' + // The same game turned up on the card AND on this PC. Both answers are answers — "No" means "leave + // them as they are", not "never mind" — and both are remembered (see GameCollision). + | 'game-collision' + // Quit with background installs / uninstalls running: they would be cancelled, so it is asked first. + | 'quit-with-jobs'; + +/** Where B/Esc/veil returns FROM the confirm view: install/uninstall come from Details, the power + * actions come from Power; a screen's question returns to that screen, which is still open underneath. */ +export type ConfirmReturnTo = 'details' | 'power' | 'settings' | 'game-settings' | 'none'; + +/** What the confirm view shows for one question. Fields left undefined leave the DOM as it was. */ +export interface ConfirmCopy { + readonly returnTo: ConfirmReturnTo; + readonly message: string; + /** Which note the install confirm shows (styles.css keys on data-install-via); null takes it off. */ + readonly installVia: 'steam' | 'copy' | null; + /** Prefix-cleanup uninstall shows its own note in the detail; null takes it off. Only the + * install/uninstall question touches it. */ + readonly uninstallVia?: 'prefix' | null; + /** The destination path under an install question ('' for steam / copy, which have none to type). */ + readonly path?: string; + /** The note under a delete / collision question. */ + readonly note?: string; + /** The game a "forget" is about — captured HERE, not read again on Yes (see the branch). */ + readonly forgetId?: string; +} + +export interface ConfirmContext { + /** The game the launch/uninstall actions apply to right now — undefined when none does. */ + readonly game: GameInfo | undefined; + readonly browse: BrowseInfo | null; + /** The collision the open question is about, if one was raised. */ + readonly collision: GameCollision | null; + /** Whether the game about to be deleted is a LOCAL one, whose save backups survive the deletion. */ + deletesLocalGame(): boolean; + /** The game name the "replace the title?" question quotes. */ + readonly title: string; + /** How many background installs / uninstalls a quit, shutdown or reboot would cancel. */ + readonly jobCount: number; +} + +/** + * The copy of the confirm for `mode`, or null when the question does not apply any more (nothing to + * install, a history game that became playable, a collision nobody raised) — the popup then stays as is. + */ +export function describeConfirm( + mode: ConfirmMode, + ctx: ConfirmContext, + t: Translator, +): ConfirmCopy | null { + if (mode === 'install' || mode === 'uninstall') { + const game = ctx.game; + if (game === undefined) return null; + if (mode === 'install' && !game.requiresInstall) return null; // nothing to install + if (mode === 'uninstall' && !game.canUninstall) return null; // nothing to uninstall + const isSteam = game.installVia === 'steam'; + const isCopy = game.installVia === 'copy'; + const isSteamInstall = mode === 'install' && isSteam; + // Picks WHICH note the confirm shows: steam → none, copy → "it will be copied here and run from + // here", absent → the card-installer one with the destination path. + const installVia = isSteamInstall ? 'steam' : mode === 'install' && isCopy ? 'copy' : null; + // Prefix-cleanup uninstall shows its own note in the detail (CSS) — the heading stays a short question. + const uninstallVia = mode === 'uninstall' && game.prefixCleanupOnly === true ? 'prefix' : null; + const message = isSteam + ? t(mode === 'install' ? 'launcher.confirm.steamInstall' : 'launcher.confirm.steamUninstall') + : mode === 'install' + ? t('launcher.confirm.install') + : // Uninstall: a normal exe game's "uninstall" only clears its Proton prefix (the game stays on + // the card) — a different message from removing an installed game. prefixCleanupOnly flags it. + t( + game.prefixCleanupOnly === true + ? 'launcher.confirm.uninstallPrefix' + : 'launcher.confirm.uninstall', + ); + return { + returnTo: 'details', + message, + installVia, + uninstallVia, + // Card path only for a card-INSTALLER install: steam has no install dir, and for copy the path is + // ours to manage — the user has nothing to type it into. + ...(mode === 'install' + ? { path: isSteamInstall || isCopy ? '' : (game.installDir ?? '') } + : {}), + }; + } + if (mode === 'forget') { + // Remove-from-history confirm (from Details). The id is captured HERE, not read again on Yes: main + // can move the screen onto another game while the popup is open (a card is inserted), and the one + // the question was asked about is the only one it may answer for. + const browse = ctx.browse; + if (browse === null || browse.active) return null; // gone or now playable — the item no longer applies + return { + returnTo: 'details', + message: t('launcher.confirm.forget', { title: browse.title }), + installVia: null, + forgetId: browse.id, + }; + } + if (mode === 'game-collision') { + // Not a menu question: main raised it after a card came in, so there is nothing underneath to + // return to — B and the veil simply leave it unanswered, and the next insertion asks again. + const collision = ctx.collision; + if (collision === null) return null; + return { + returnTo: 'details', + message: t('launcher.confirm.collision', { title: collision.title }), + installVia: null, + note: t('launcher.confirm.collisionNote'), + }; + } + if (mode === 'reset-settings') { + // Asked from the Settings screen, which stays open UNDER the popup — so "No" must return there, + // not to the Details menu the screen was reached through. + return { returnTo: 'settings', message: t('settings.confirmReset'), installVia: null }; + } + if ( + mode === 'reset-game-settings' || + mode === 'delete-game' || + mode === 'delete-game-history' || + mode === 'discard-game-settings' || + mode === 'switch-game-source' || + mode === 'cancel-move-game-settings' || + mode === 'replace-game-title' + ) { + // The Customize screen's questions. Same shape as the Settings reset: the screen stays open + // underneath, so "No" simply closes the popup and hands control back to it. + const browse = ctx.browse; + const message = + mode === 'replace-game-title' + ? t('metadata.titleConfirm', { title: ctx.title }) + : mode === 'reset-game-settings' + ? t('gameSettings.confirmReset') + : mode === 'discard-game-settings' + ? t('gameSettings.confirmDiscard') + : mode === 'switch-game-source' + ? t('gameSettings.confirmSwitchSource') + : mode === 'cancel-move-game-settings' + ? t('gameSettings.confirmCancelMove') + : mode === 'delete-game-history' + ? t('gameSettings.confirmDeleteHistory', { title: browse?.title ?? '' }) + : t('gameSettings.confirmDelete', { title: browse?.title ?? '' }); + // The second question's own note: what each of ITS answers costs. It matters more than the first + // one's, because "No" here does not mean "never mind" — it deletes the game and keeps the card. + // A local game's save backups survive the deletion — gcOrphans sweeps artwork and never touches + // saves/ — and a confirm that stayed silent about it would read as "everything goes". + const note = + mode === 'delete-game-history' + ? t('gameSettings.confirmDeleteHistoryNote') + : mode === 'delete-game' + ? t( + ctx.deletesLocalGame() + ? 'gameSettings.confirmDeleteSavesNote' + : 'gameSettings.confirmDeleteNote', + ) + : undefined; + return { + returnTo: 'game-settings', + message, + installVia: null, + ...(note === undefined ? {} : { note }), + }; + } + if (mode === 'kill') { + // Force-close confirm (from Details): no path note; returns to Details. The message warns about + // unsaved progress. data-mode ≠ 'install' hides the path note (styles.css). + return { returnTo: 'details', message: t('launcher.confirm.kill'), installVia: null }; + } + // Power action: a single-question confirm, no path note (data-mode ≠ 'install' hides it). + if (mode === 'sleep') return { returnTo: 'power', message: t('launcher.confirm.sleep'), installVia: null }; + const action = mode === 'quit-with-jobs' ? 'quit' : mode; + return { returnTo: 'power', message: quitQuestion(t, action, ctx.jobCount), installVia: null }; +} + +/** The QuitAction a quit / shutdown / reboot question is about, or null for any other question. */ +export function quitModeAction(mode: ConfirmMode): QuitAction | null { + if (mode === 'quit-with-jobs') return 'quit'; + return mode === 'shutdown' || mode === 'reboot' ? mode : null; +} + +export interface ConfirmActionDeps { + readonly api: ControlsApi; + readonly audio: Pick; + readonly settings: Pick; + readonly gameSettings: Pick; + /** The id the open "forget" was asked about, handed over once (the popup clears it). */ + takeForgetId(): string | null; + /** The collision question's "yes". */ + mergeCollision(): void; + /** The game an install / uninstall question was opened for - fixed then, never re-read on Yes. */ + readonly targetId: string | null; +} + +/** + * What a "Yes" runs, once the popup has already closed. `delete-game` never reaches here: its "Yes" + * grows the second question on the same surface (see acceptConfirm in popups.ts). + */ +export function runConfirmedAction( + mode: Exclude, + deps: ConfirmActionDeps, +): void { + const { audio } = deps; + switch (mode) { + case 'install': + audio.play('play'); + deps.api.requestLaunch(deps.targetId ?? undefined); // main decides install vs launch from requiresInstall + break; + case 'uninstall': + audio.play('button'); // neutral sound for the destructive confirm + deps.api.requestUninstall(deps.targetId ?? undefined); + break; + case 'kill': + audio.play('button'); // neutral sound for the destructive confirm + deps.api.requestKill(); + break; + case 'forget': { + audio.play('button'); // neutral sound for the destructive confirm + const forgetId = deps.takeForgetId(); + if (forgetId !== null) deps.api.forgetGame(forgetId); + break; + } + case 'shutdown': + audio.play('button'); + deps.api.requestShutdown(true); + break; + case 'reboot': + audio.play('button'); + deps.api.requestReboot(true); + break; + case 'quit-with-jobs': + audio.play('button'); + deps.api.requestQuit(true); + break; + case 'sleep': + audio.play('button'); + deps.api.requestSleep(); + break; + case 'reset-settings': + audio.play('button'); // neutral sound for the destructive confirm + deps.settings.resetSettings(); + break; + case 'reset-game-settings': + audio.play('button'); // neutral sound for the destructive confirm + deps.gameSettings.confirmAccepted('reset'); + break; + case 'delete-game-history': + audio.play('button'); // neutral sound for the destructive confirm + deps.gameSettings.confirmAccepted('delete-history'); + break; + case 'discard-game-settings': + audio.play('back'); + deps.gameSettings.confirmAccepted('discard'); + break; + case 'switch-game-source': + audio.play('button'); + deps.gameSettings.confirmAccepted('switch-source'); + break; + case 'cancel-move-game-settings': + audio.play('back'); + deps.gameSettings.confirmAccepted('cancel-move'); + break; + case 'game-collision': + audio.play('button'); + deps.mergeCollision(); + break; + case 'replace-game-title': + audio.play('button'); + deps.gameSettings.confirmAccepted('replace-title'); + break; + default: { + // A mode with no branch here is a Yes that closes the popup and does nothing, which is exactly + // how "Update title" came to be a button that asked and then ignored the answer. Now a missing + // branch is a compile error. + const unhandled: never = mode; + return unhandled; + } + } +} diff --git a/src/renderer/controls-deps.ts b/src/renderer/controls-deps.ts new file mode 100644 index 00000000..53bebc29 --- /dev/null +++ b/src/renderer/controls-deps.ts @@ -0,0 +1,174 @@ +// The seams of the interaction layer (controls.ts): what it sends to main, and what it needs from the +// rest of the renderer — the app state, the audio controller and the surfaces it routes the six +// primitives into. Types only, kept apart from the controller so a test can build the deps without it. +import type { + AppNotification, + AppState, + BrowseInfo, + ConfigSaveResult, + GameCollisionAnswer, +} from '../shared/types.js'; +import type { GameActivity } from '../shared/activity.js'; +import type { QuitConfirmReply } from '../shared/quit.js'; +import type { Locale, MessageKey, Translator } from '../shared/i18n/index.js'; +import type { AudioController } from './audio.js'; +import type { MoveResult } from './carousel.js'; +import type { NavSurface } from './nav-surface.js'; + +/** + * What this module sends to main. A seam, so app.ts owns the `window.api` wiring and a DOM test can fake + * it — the same shape `SettingsScreenApi` and `GameSettingsScreenApi` take. + */ +export interface ControlsApi { + /** Play / the install confirm's Yes — main decides install vs launch from `requiresInstall`. */ + requestLaunch(id?: string): void; + requestUninstall(id?: string): void; + /** "Cancel installation": stops the queued or running install of game `id`. */ + cancelJob(id: string): void; + requestKill(): void; + /** Drops a history game's record — the id is captured when the confirm opens (see openConfirm). */ + forgetGame(id: string): void; + /** The gear on a Steam download: opens Steam's own Downloads page, the only pause/resume there is. */ + openSteamDownloads(): void; + /** `confirmed`: the user already said yes to it, with the running jobs named in the question. */ + requestShutdown(confirmed?: boolean): void; + requestReboot(confirmed?: boolean): void; + requestSleep(): void; + /** "Minimize Playhook" — hide to the tray. */ + requestHide(): void; + /** "Close Playhook" - the full quit; `confirmed` as for requestShutdown. */ + requestQuit(confirmed?: boolean): void; + /** Tells main its quit question is up (or queued), or was closed without a Yes. */ + quitConfirmReply(reply: QuitConfirmReply): void; + /** The answer to a card-vs-PC collision question; fails when the card is no longer the one asked about. */ + resolveGameCollision(answer: GameCollisionAnswer): Promise; + /** Opening the inbox IS reading it. */ + markNotificationsRead(): void; + /** Pressing an entry removes it. */ + dismissNotification(id: string): void; + clearNotifications(): void; +} + +/** What the interaction layer needs from the rest of the renderer. */ +export interface ControlsDeps { + readonly api: ControlsApi; + /** The current AppState snapshot (app.ts owns it; updated before it calls into here). */ + getState(): AppState; + /** + * What is on screen (browse:update). Needed because AppState alone can no longer answer "does Play act + * on what I'm looking at?": while a card is inserted the state describes ITS game, but the screen may + * be showing a history game — pressing Play there would launch someone else. + */ + getBrowse(): BrowseInfo | null; + /** The activity of the game on screen (a Steam download, a removal), or undefined when it is free. */ + getScreenActivity(): GameActivity | undefined; + /** How many background installs / uninstalls are queued or running - a quit asks first while any are. */ + getJobCount(): number; + /** The shared audio controller (UI sounds). */ + audio: AudioController; + /** The current translator (read live so menu/confirm copy follows the language). */ + getTranslator(): Translator; + /** The current UI locale — the notification list formats its timestamps with it. */ + getLocale(): Locale; + /** The history carousel — the THIRD focus group, above the bar and the popup stack (see navLeft…). */ + carousel: CarouselNav; + /** The Settings screen — the FOURTH surface, between the popup and the carousel (see navLeft…). */ + settings: SettingsNav; + /** The Customize screen — the fifth surface, at the same level as Settings (see `overlays` below). */ + gameSettings: GameSettingsNav; + /** The Library screen — the sixth surface, at that same level. */ + library: LibraryNav; + /** + * A direction is being HELD, i.e. the strip is flipping on its own (true), or it has just been let go + * (false). The background subsystem holds its image for the duration — see hero.setFlipping. + */ + onFlipping(flipping: boolean): void; + /** The inbox as main last pushed it — the popup list and the More item's dot are drawn from it. */ + getNotifications(): readonly AppNotification[]; + /** The popup finished closing. The toast shares this corner and holds its queue while it is up. */ + onPopupClosed(): void; + /** Opens a game's detail screen (a notification about a game leads there). Owned by app.ts. */ + openGameDetail(id: string): void; + /** + * Whether the boot screen is still up (app.ts owns the reveal). The whole UI is built and laid out + * behind the wallpaper — the bar sits at opacity 0, the cards are held at zero — so every surface is + * already drivable while nothing of it can be seen: A on the invisible row opened the Notifications + * card behind the boot image, and a direction flipped a carousel nobody was looking at. + */ + isBooting(): boolean; +} + +/** + * What the interaction layer needs from the Settings screen. The screen owns its rows, focus and IPC + * (settings-screen.ts); this module only routes the six primitives to it and guards the mechanisms that + * would otherwise keep running underneath (idle timer, wheel, Y). + */ +export interface SettingsNav extends NavSurface { + /** `sectionKey` deep-links to one section — an "update ready" notification lands on Updates. + * `silent` suppresses the screen's own opening sound — see SettingsScreen.open. */ + open(sectionKey?: MessageKey, options?: { readonly silent?: boolean }): void; + close(): void; + /** Runs the reset once the shared confirm popup says yes. */ + resetSettings(): void; +} + +/** + * The same seam for the Customize screen. It is an OVERLAY like Settings — same level, never both open — + * which is why the routing below asks "which overlay is up?" rather than naming one: a third screen + * (adding a game) then costs one line here instead of a rewrite of every primitive. + */ +export interface GameSettingsNav extends NavSurface { + open(id: string): void; + /** Opens the same screen for a game whose card is not in — see GameSettingsScreen.openFromHistory. */ + openFromHistory(id: string): void; + /** Opens the same screen to CREATE a game — the "Add game" item of the Details menu. */ + openNew(): void; + close(): void; + /** Whether there are unsaved edits — decides whether leaving asks first. */ + isDirty(): boolean; + /** Whether the game about to be deleted is a LOCAL one, whose save backups survive the deletion. */ + deletesLocalGame(): boolean; + /** The shared confirm popup said yes to one of the screen's questions. */ + confirmAccepted( + kind: + | 'reset' + | 'delete' + | 'delete-history' + | 'discard' + | 'switch-source' + | 'cancel-move' + | 'replace-title', + ): void; +} + +/** + * The Library screen, the third overlay — and the one the "Add game" route runs through, which is why + * this module opens it: the screen it hands over to (Customize in add mode) is this module's to open. + */ +export interface LibraryNav extends NavSurface { + open(): void; + /** `silent` is a hand-over to another surface (the detail screen, Add game) — see LibraryScreen. */ + close(silent?: boolean): void; +} + +/** + * What the interaction layer needs from the carousel. A narrow seam on purpose: the carousel owns its + * strip and selection, this module owns which surface the buttons currently drive. + */ +export interface CarouselNav { + /** 'carousel' (the strip) or 'detail' (the bar screen). */ + screen(): 'carousel' | 'detail'; + /** Moves the selection by `delta` cards; says whether it moved, hit an end, or was locked mid-morph. */ + move(delta: number): MoveResult; + /** Enters the selected card's detail screen. */ + activate(): void; + /** + * Whether the strip is standing on a GAME rather than one of the launcher's own cards. Down opens a + * game and nothing else, so it has to ask before acting (see navDown). + */ + onGame(): boolean; + /** Steps back from a detail screen to the strip; false when the strip is already the screen. */ + leaveDetail(): boolean; + /** Whether the inbox holds anything unread — the Notifications CARD wears the dot now. */ + setUnread(unread: boolean): void; +} diff --git a/src/renderer/controls.ts b/src/renderer/controls.ts index 145d5fdb..65eb5dd0 100644 --- a/src/renderer/controls.ts +++ b/src/renderer/controls.ts @@ -1,185 +1,25 @@ -// Interaction layer (split out of app.ts). Owns the single popup (Details / Power / Confirm / Error), -// the two focus groups (the main bar + the popup's vertical action stack) and the user actions they -// trigger, plus all their wiring: button/veil clicks, mouse hover, the gamepad controller and the -// keyboard Esc handler. These are bidirectionally coupled (popups call applyFocus; focus reads the -// popup-open flag), so they live together as one cohesive controller rather than two half-modules with -// fragile circular wiring. It reaches back into app.ts only through the narrow `deps` seam (current -// state + the audio controller); app.ts drives it via applyGameButtons/clearGameButtons/refresh/ -// showError and starts it with start(). -// -// The popup is a state machine: one #popup element whose content + action stack switch by data-view. -// Navigation is vertical (up/down) inside a stack; the default focus is always the BOTTOM button -// (Close / No / Sleep), which the mockup draws filled. B/Esc/veil step BACK one level. -import type { AppNotification, AppState, BrowseInfo, GameInfo } from '../shared/types'; -import type { Locale, MessageKey, Translator } from '../shared/i18n/index.js'; -import { formatNotification, formatNotificationTime } from './format.js'; -import { createScroller } from './screen-scroller.js'; -import { HOLD_DELAY_MS, NAV_REPEAT_MS, createAutoRepeatChain } from './auto-repeat.js'; +// Interaction layer (split out of app.ts): the router of the six navigation primitives. It owns the +// main bar's focus group (Play / More) and the user actions on it, decides which surface the primitives +// drive — the popup column (popups.ts), an open screen, the carousel strip, or the bar — and wires the +// input models to that routing: the gamepad controller (gamepad.ts), the keyboard (keyboard.ts), mouse +// hover and clicks, the wheel, right-click-as-back. The idle countdown and the mouse's sleep live in +// idle.ts. It reaches back into app.ts only through the narrow `deps` seam (controls-deps.ts); app.ts +// drives it via applyGameButtons/clearGameButtons/refresh/showError and starts it with start(). +import type { AppState } from '../shared/types.js'; +import type { Translator } from '../shared/i18n/index.js'; +import { NAV_REPEAT_MS, createAutoRepeatChain } from './auto-repeat.js'; import { createGamepadController } from './gamepad.js'; -import { createWakeMeter } from './mouse-sleep.js'; +import { createKeyboardController } from './keyboard.js'; +import { createIdleGuard } from './idle.js'; +import { createPopups } from './popups.js'; +import { createHoverGuard } from './hover-guard.js'; import type { NavSurface } from './nav-surface.js'; -import type { MoveResult } from './carousel.js'; import type { SystemCardId } from './system-cards.js'; -import { type AudioController } from './audio.js'; -import { gameOf, phaseOf, steamBusy } from './state-view.js'; -import { req, reqQuery } from './dom.js'; +import { gameOf, opensSteamDownloads, phaseOf, screenActions, type ScreenActions } from './state-view.js'; +import { pressFlash, req } from './dom.js'; import type { GameCollision } from '../shared/types.js'; - -// The current popup view (mutually exclusive; 'none' = closed). Mirrors the data-view on #popup. -type PopupView = 'none' | 'details' | 'notifications' | 'power' | 'confirm' | 'busy' | 'error'; -// Which action the confirm view is asking about (only meaningful while popupView === 'confirm'). -type ConfirmMode = - | 'install' - | 'uninstall' - | 'kill' - | 'forget' - | 'shutdown' - | 'reboot' - | 'sleep' - | 'reset-settings' - | 'reset-game-settings' - | 'delete-game' - // The second half of the delete question: whether the game's HISTORY record goes with it. Its "No" is - // an answer rather than a cancel — see the confirmNo branch in triggerStackButton. - | 'delete-game-history' - | 'discard-game-settings' - | 'switch-game-source' - // Leaving the screen (B/veil/Close) while a "Move to card…" is pending — drops the pending move and - // returns the form to the PC library's baseline, WITHOUT closing the screen (see game-settings-screen.ts - // PendingMove). Kept apart from 'discard-game-settings', whose "Yes" closes the whole screen. - | 'cancel-move-game-settings' - // Taking the store's spelling into the Customize form's Title — the one thing the "Find online" screen - // does that REPLACES something the user may have typed rather than adding a file beside the game. - | 'replace-game-title' - // The same game turned up on the card AND on this PC. Both answers are answers — "No" means "leave - // them as they are", not "never mind" — and both are remembered (see GameCollision). - | 'game-collision'; -// Gamepad A doesn't trigger :active, so flash a press class to play the scale-down animation. -const PRESS_MS = 130; -/** How far the pointer must travel before hover may take the focus again (see armHover). */ -const HOVER_WAKE_PX = 6; -/** How long the popup takes to fade out (.popup transition in styles.css) — the window its contents - * must stay frozen for, so the user never watches the menu rewrite itself on the way out. */ -const POPUP_FADE_MS = 350; - -/** What the interaction layer needs from the rest of the renderer. */ -export interface ControlsDeps { - /** The current AppState snapshot (app.ts owns it; updated before it calls into here). */ - getState(): AppState; - /** - * What is on screen (browse:update). Needed because AppState alone can no longer answer "does Play act - * on what I'm looking at?": while a card is inserted the state describes ITS game, but the screen may - * be showing a history game — pressing Play there would launch someone else. - */ - getBrowse(): BrowseInfo | null; - /** The shared audio controller (UI sounds). */ - audio: AudioController; - /** The current translator (read live so menu/confirm copy follows the language). */ - getTranslator(): Translator; - /** The current UI locale — the notification list formats its timestamps with it. */ - getLocale(): Locale; - /** The history carousel — the THIRD focus group, above the bar and the popup stack (see navLeft…). */ - carousel: CarouselNav; - /** The Settings screen — the FOURTH surface, between the popup and the carousel (see navLeft…). */ - settings: SettingsNav; - /** The Customize screen — the fifth surface, at the same level as Settings (see `overlays` below). */ - gameSettings: GameSettingsNav; - /** The Library screen — the sixth surface, at that same level. */ - library: LibraryNav; - /** - * A direction is being HELD, i.e. the strip is flipping on its own (true), or it has just been let go - * (false). The background subsystem holds its image for the duration — see hero.setFlipping. - */ - onFlipping(flipping: boolean): void; - /** The inbox as main last pushed it — the popup list and the More item's dot are drawn from it. */ - getNotifications(): readonly AppNotification[]; - /** The popup finished closing. The toast shares this corner and holds its queue while it is up. */ - onPopupClosed(): void; - /** Opens a game's detail screen (a notification about a game leads there). Owned by app.ts. */ - openGameDetail(id: string): void; - /** - * Whether the boot screen is still up (app.ts owns the reveal). The whole UI is built and laid out - * behind the wallpaper — the bar sits at opacity 0, the cards are held at zero — so every surface is - * already drivable while nothing of it can be seen: A on the invisible row opened the Notifications - * card behind the boot image, and a direction flipped a carousel nobody was looking at. - */ - isBooting(): boolean; -} - -/** - * What the interaction layer needs from the Settings screen. The screen owns its rows, focus and IPC - * (settings-screen.ts); this module only routes the six primitives to it and guards the mechanisms that - * would otherwise keep running underneath (idle timer, wheel, Y). - */ -export interface SettingsNav extends NavSurface { - /** `sectionKey` deep-links to one section — an "update ready" notification lands on Updates. - * `silent` suppresses the screen's own opening sound — see SettingsScreen.open. */ - open(sectionKey?: MessageKey, options?: { readonly silent?: boolean }): void; - close(): void; - /** Runs the reset once the shared confirm popup says yes. */ - resetSettings(): void; -} - -/** - * The same seam for the Customize screen. It is an OVERLAY like Settings — same level, never both open — - * which is why the routing below asks "which overlay is up?" rather than naming one: a third screen - * (adding a game) then costs one line here instead of a rewrite of every primitive. - */ -export interface GameSettingsNav extends NavSurface { - open(id: string): void; - /** Opens the same screen for a game whose card is not in — see GameSettingsScreen.openFromHistory. */ - openFromHistory(id: string): void; - /** Opens the same screen to CREATE a game — the "Add game" item of the Details menu. */ - openNew(): void; - close(): void; - /** Whether there are unsaved edits — decides whether leaving asks first. */ - isDirty(): boolean; - /** Whether the game about to be deleted is a LOCAL one, whose save backups survive the deletion. */ - deletesLocalGame(): boolean; - /** The shared confirm popup said yes to one of the screen's questions. */ - confirmAccepted( - kind: - | 'reset' - | 'delete' - | 'delete-history' - | 'discard' - | 'switch-source' - | 'cancel-move' - | 'replace-title', - ): void; -} - -/** - * The Library screen, the third overlay — and the one the "Add game" route runs through, which is why - * this module opens it: the screen it hands over to (Customize in add mode) is this module's to open. - */ -export interface LibraryNav extends NavSurface { - open(): void; - /** `silent` is a hand-over to another surface (the detail screen, Add game) — see LibraryScreen. */ - close(silent?: boolean): void; -} - -/** - * What the interaction layer needs from the carousel. A narrow seam on purpose: the carousel owns its - * strip and selection, this module owns which surface the buttons currently drive. - */ -export interface CarouselNav { - /** 'carousel' (the strip) or 'detail' (the bar screen). */ - screen(): 'carousel' | 'detail'; - /** Moves the selection by `delta` cards; says whether it moved, hit an end, or was locked mid-morph. */ - move(delta: number): MoveResult; - /** Enters the selected card's detail screen. */ - activate(): void; - /** - * Whether the strip is standing on a GAME rather than one of the launcher's own cards. Down opens a - * game and nothing else, so it has to ask before acting (see navDown). - */ - onGame(): boolean; - /** Steps back from a detail screen to the strip; false when the strip is already the screen. */ - leaveDetail(): boolean; - /** Whether the inbox holds anything unread — the Notifications CARD wears the dot now. */ - setUnread(unread: boolean): void; -} +import type { QuitAction } from '../shared/quit.js'; +import type { ControlsDeps } from './controls-deps.js'; export interface Controls { /** Refreshes the game-dependent menu item (Install/Uninstall text + visibility) from the current state. */ @@ -221,6 +61,8 @@ export interface Controls { * card it is about is already loaded. */ askGameCollision(collision: GameCollision): void; + /** main asks before a quit / shutdown / reboot while background jobs run (tray Quit, a raced Quit). */ + askQuit(action: QuitAction): void; /** Per-render refresh: force-close the popup off the ready screen (or while steam-busy), then re-apply focus. */ refresh(): void; /** Opens the error popup with the given message (a failed launch/action from main). */ @@ -263,38 +105,23 @@ export function createControls(deps: ControlsDeps): Controls { // The shared warmth of an auto-move, so a run handed from one direction to the next — or from the pad // to the keyboard — skips the initial delay instead of stalling (auto-repeat.ts). const autoRepeat = createAutoRepeatChain(); + // Where the pointer was when hover was last disarmed — by a surface opening under it, or by a + // keyboard/gamepad step. Until the mouse travels far enough from there, hover does not move the focus: + // an element arriving under a still cursor is the ELEMENT moving, not the mouse, and Chromium reports + // both the same way (hover-guard.ts). + const hover = createHoverGuard(); const state = (): AppState => deps.getState(); const t = (): Translator => deps.getTranslator(); - /** - * The GameInfo of what is on screen, or undefined when the screen shows a history game (nothing to - * install, uninstall or launch there). Everything that used to read `gameOf(state())` for a SCREEN - * decision goes through here; `state()` is still read for PHASE decisions (busy / running / killing). - */ - const screenGame = (): GameInfo | undefined => { - const browse = deps.getBrowse(); - if (browse === null) return gameOf(state()); // no browse model yet (first paint) — behave as before - return browse.active ? (browse.game ?? gameOf(state())) : undefined; - }; - - /** Whether the launch/uninstall actions apply to what is on screen: it must be a card game AND the one - * AppState is currently about (you can browse game B while game A is busy — B is not actionable). */ - const screenIsActionable = (): boolean => { - const browse = deps.getBrowse(); - if (browse === null) return true; // pre-browse behaviour - if (!browse.active) return false; - const subject = gameOf(state())?.id; - return subject === undefined || subject === browse.id; - }; - // SteamOS Game Mode (gamescope): no tray, so the power menu's primary item quits instead of minimizing. - // Seeded once at startup (setGameMode); false until then — the power menu isn't reachable that early. - let gameMode = false; + /** What can be done with the game on screen, judged by that game alone (state-view's screenActions). */ + const screenActionsNow = (): ScreenActions => + screenActions(state(), deps.getBrowse(), deps.getScreenActivity()); /** * The full-screen overlays, as a set rather than as a named one. Every mechanism that has to stand down * while a screen is up — the idle timer, the wheel, Y, and all six primitives — asks THESE two * questions instead of `settings.isOpen()`, so the next screen is one entry in this list rather than an - * eleventh edit in every primitive (see the plan, Р1). + * eleventh edit in every primitive. * * At most one is ever open: a screen is entered from the Details menu, which closes on the way in, and * a surface that opens on top of a screen (the keyboard, the file picker) belongs to that screen's own @@ -315,584 +142,10 @@ export function createControls(deps: ControlsDeps): Controls { const playButton = req('play-button'); const moreButton = req('more-button'); - // The single popup + its veil, plus the content fields set from JS. - const popup = req('popup'); - const popupVeil = reqQuery('#popup .popup-veil'); - const confirmMessage = req('confirm-message'); - /** The game name the "replace the title?" question quotes — set by whoever asks it. */ - let confirmTitle = ''; - /** What the busy view's Stop does, and how the surface that started the work hears about it. */ - let busyStop: (() => void) | null = null; - const confirmPath = req('confirm-path'); - const errorMessageEl = req('error-message'); - const busyMessageEl = req('busy-message'); - const deleteNote = req('delete-note'); - - // Action-stack buttons (grouped by view in the HTML). - const menuInstallToggle = req('menu-install-toggle'); - const menuKill = req('menu-kill'); - const menuHome = req('menu-home'); - const menuCustomize = req('menu-customize'); - const menuForget = req('menu-forget'); - const menuClose = req('menu-close'); - const powerShutdown = req('power-shutdown'); - const powerReboot = req('power-reboot'); - const powerSleep = req('power-sleep'); - const powerMinimize = req('power-minimize'); - const powerQuit = req('power-quit'); - const powerClose = req('power-close'); - const notificationList = req('notification-list'); - // The same scroller every full-screen surface uses: one fixed duration and easing for the glide, plus - // the edge fades. Reused rather than reinvented — a list that scrolls differently from the Settings - // list would be the only one in the app that does. - const notificationScroller = createScroller(notificationList); - // The Details stack scrolls too: its items are the launcher's whole menu, and on a one-game screen the - // play statistics above it leave less room than the eight items need. Its own scroller, because a - // scroller owns one box's position and fades. - const menuStack = req('menu-stack'); - const menuStackScroller = createScroller(menuStack); - const notificationsClear = req('notifications-clear'); - const notificationsClose = req('notifications-close'); - const confirmYes = req('confirm-yes'); - const confirmNo = req('confirm-no'); - const errorClose = req('error-close'); - const busyStopButton = req('busy-stop'); - - let popupView: PopupView = 'none'; - // The notification entries currently in the DOM. They are recreated on every snapshot, so — unlike - // ALL_STACK_BUTTONS — they cannot be wired or highlighted once at startup; see the click delegation - // below and applyStackFocus. - let notificationButtons: readonly HTMLButtonElement[] = []; - let confirmMode: ConfirmMode = 'uninstall'; - // Where B/Esc/veil returns FROM the confirm view: install/uninstall come from Details, the power - // actions come from Power. - let confirmReturnTo: 'details' | 'power' | 'settings' | 'game-settings' = 'details'; - // How the CURRENT popup was entered: through the Details menu, or straight from a launcher card. It - // decides what B does in the Power / Notifications views — stepping back into a menu that was never - // opened would conjure a game's menu over the carousel. - let popupRoot: 'details' | 'direct' = 'details'; - /** The game the open remove-from-history confirm is about — captured when it opens (see openConfirm). */ - let forgetId: string | null = null; - /** The collision the open question is about, and the one waiting for the popup to free up. */ - let askedCollision: GameCollision | null = null; - let queuedCollision: GameCollision | null = null; - - // ── Popup machine ──────────────────────────────────────────────────────────── - // One #popup element; opening = add .is-open + set data-view; switching views keeps .is-open (so the - // shared veil never cross-fades). Closing removes .is-open. - - function setView(view: Exclude): void { - // Every view change lays a new stack under the pointer — hover must not claim the focus the view - // itself just set (see the mousemove handler). - armHover(); - // Only the FIRST view is an opening; switching views keeps the popup on screen and keeps the - // button/back sounds the callers already play (Р4). - if (popupView === 'none') audio.play('popup-open'); - popupView = view; - popup.dataset['view'] = view; - popup.classList.add('is-open'); - popup.setAttribute('aria-hidden', 'false'); - } - - /** - * Closing is a 0.35s fade, and the menu is still on screen for all of it. Anything that rebuilds its - * items in that window is visible — pressing "Home" leaves the detail screen, which swaps the game's - * items for the launcher's, and the user watched that happen through the fading popup. So the items - * are frozen until the fade is over, then brought up to date in one go for the next opening. - */ - let menuThawTimer = 0; - - function freezeMenuDuringFade(): void { - if (menuThawTimer !== 0) window.clearTimeout(menuThawTimer); - menuThawTimer = window.setTimeout(() => { - menuThawTimer = 0; - applyGameButtons(); - }, POPUP_FADE_MS); - } - - /** Whether the menu's items are currently held still (see freezeMenuDuringFade). */ - function menuFrozen(): boolean { - return menuThawTimer !== 0; - } - - /** Ends the freeze early and rebuilds now — used when the popup opens again mid-fade. */ - function thawMenu(): void { - if (menuThawTimer === 0) return; - window.clearTimeout(menuThawTimer); - menuThawTimer = 0; - } - - function closePopup(options?: { readonly silent?: boolean }): void { - if (popupView === 'none') return; - // `silent` is for a close that is only half of a bigger move — the popup handing over to a screen, - // where the destination's own popup-open is the single sound of that gesture (Р5). - if (options?.silent !== true) audio.play('popup-close'); - popupView = 'none'; - popup.classList.remove('is-open'); - popup.setAttribute('aria-hidden', 'true'); - // The toast lives in the corner this column is fading out of, so it is released only once the fade - // is over — otherwise a plate would fade IN over a popup still fading OUT, in the same 20 pixels. - window.setTimeout(() => deps.onPopupClosed(), POPUP_FADE_MS); - freezeMenuDuringFade(); - applyStackFocus(); // clear the stack highlight (stackActive becomes false) - applyFocus(); // restore the main bar highlight - flushQueuedCollision(); - } - - /** Raises a collision question that arrived while the column was busy, once it is free again. */ - function flushQueuedCollision(): void { - const waiting = queuedCollision; - if (waiting === null || popupView !== 'none') return; - if (deps.gameSettings.isOpen() || deps.settings.isOpen()) return; - queuedCollision = null; - askedCollision = waiting; - // After the fade, or the question would open into a column still fading the previous one out. - window.setTimeout(() => { - if (popupView !== 'none' || askedCollision === null) return; - openConfirm('game-collision'); - }, POPUP_FADE_MS); - } - - // Details menu (from More): game stats on top + Shutdown / Install|Uninstall / Close stack. Works on - // every screen — on the empty (no-card) screen there are no stats and no Install/Uninstall, so it - // degrades to just System + Close. - function openDetails(): void { - thawMenu(); // a re-open inside the fade window must show the CURRENT items, not the frozen ones - applyMenuInstallToggle(); // keep the toggle's text/visibility fresh for the current game - applyMenuKill(); // keep the force-close item's visibility fresh (running-only) - applyMenuHome(); // keep the "Home" item fresh (only when there is a carousel to go back to) - applyMenuCustomize(); // …and "Customize", which only applies to a game we can reach the file of - applyMenuForget(); // keep the "Remove from history" item fresh (history-only games) - popupRoot = 'details'; - setView('details'); - focusStackBottom(); // default focus: Close - applyFocus(); // main highlight clears (focusActive false with a popup open) - // Open at the BOTTOM of the stack when it does not all fit — that is where the focus already is, and - // a menu that opens at the top and then glides down shows the wrong end first. Instant, and next - // frame: the items were relabelled/unhidden this tick and the box has not been laid out yet. A stack - // that fits clamps this to 0, so nothing moves. - requestAnimationFrame(() => menuStackScroller.to(menuStack.scrollHeight, true)); - } - - /** - * The Notifications popup (from Details → Notifications). Opening it IS reading the inbox — that is - * one of the only two gestures that clear the unread state, the other being pressing an entry — so - * main is told straight away and the dot beside the More item goes out. - */ - function openNotifications(): void { - window.api.markNotificationsRead(); - setView('notifications'); - renderNotificationList(); - focusStackBottom(); // default focus: Close, as in every other view - applyFocus(); - // Open at the BOTTOM of the list: the freshest notifications are the last ones (the stack reads - // oldest-first, like every other one here), and those are what the user came for. Instant — a list - // that opens at the top and then glides down is showing the wrong end first either way. - // Next frame, because the entries were inserted this tick and the box has not been laid out yet. - requestAnimationFrame(() => { - notificationScroller.to(notificationList.scrollHeight, true); - }); - } - - /** The notification whose entry currently holds the focus — the anchor a repaint restores. */ - function focusedNotificationId(): string | undefined { - if (popupView !== 'notifications') return undefined; - return stackFocusables()[stackIndex]?.dataset['notificationId']; - } - - /** One entry: what happened and when, plus the unread dot. */ - function buildNotificationButton(item: AppNotification): HTMLButtonElement { - const button = document.createElement('button'); - button.type = 'button'; - button.className = 'text-button notification-item'; - const line = document.createElement('span'); - line.className = 'notification-line'; - const text = document.createElement('span'); - text.className = 'notification-text'; - line.append(text); - const dot = document.createElement('span'); - dot.className = 'notification-dot'; - line.append(dot); - const time = document.createElement('span'); - time.className = 'notification-time'; - button.append(line, time); - patchNotificationButton(button, item); - return button; - } - - /** Writes one notification into an entry node — the same path for a fresh node and for a reused one. */ - function patchNotificationButton(button: HTMLButtonElement, item: AppNotification): void { - button.dataset['notificationId'] = item.id; - const text = button.querySelector('.notification-text'); - const dot = button.querySelector('.notification-dot'); - const time = button.querySelector('.notification-time'); - // textContent, never innerHTML: the title comes off the card and is untrusted data. - if (text !== null) text.textContent = formatNotification(item, t()); - if (dot !== null) dot.classList.toggle('is-hidden', item.read); - if (time !== null) { - time.textContent = formatNotificationTime(item.at, Date.now(), t(), deps.getLocale()); - } - } - - /** - * Rebuilds the list from the latest snapshot (main is the only source of truth — nothing here edits the - * inbox locally and hopes main agrees). Two things make the rebuild safe while the popup is on screen: - * • it stands down entirely during the popup's fade-out, so the user never watches the menu rewrite - * itself on the way out (the same freeze every applyMenu* helper respects); - * • the focus is re-anchored by notification ID rather than by index — `stackIndex` is only clamped - * when the stack changes, so a snapshot arriving under an open list would otherwise slide the - * highlight quietly onto a different entry. - */ - function renderNotificationList(): void { - if (menuFrozen()) return; - const items = deps.getNotifications(); - // Same entries, different values — opening the popup marks them all read, and main echoes that back - // a beat later. Recreating the nodes for it would replay the whole staggered entrance under the - // user's eyes, right after the list appeared; patching in place does not (the same reason the stats - // panel in app.ts updates its rows rather than rebuilding them). - // `length > 0` guards the very first open of an EMPTY inbox: both sides are empty, every() is - // vacuously true, and the shortcut would return without ever putting the empty-state line in. - if ( - items.length > 0 && - notificationButtons.length === items.length && - items.every((item, at) => notificationButtons[at]?.dataset['notificationId'] === item.id) - ) { - items.forEach((item, at) => { - const button = notificationButtons[at]; - if (button !== undefined) patchNotificationButton(button, item); - }); - return; - } - const anchorId = focusedNotificationId(); - const anchorButton = popupView === 'notifications' ? stackFocusables()[stackIndex] : undefined; - notificationButtons = items.map(buildNotificationButton); - notificationList.replaceChildren(...notificationButtons); - if (items.length === 0) { - // A line, not a button: there is nothing to press, so it must not be focusable either. - const empty = document.createElement('div'); - empty.className = 'notification-empty'; - empty.textContent = t()('notifications.empty'); - notificationList.append(empty); - } - // Nothing to clear when there is nothing there — the button would be an action with no effect, sitting - // right where the eye lands. It folds away like every other volatile item in a stack. - notificationsClear.classList.toggle('is-hidden', items.length === 0); - // The fades are computed from the laid-out box, which this tick's insertions have not produced yet. - requestAnimationFrame(() => notificationScroller.fades()); - if (popupView === 'notifications') { - const stack = stackFocusables(); - const at = - anchorId !== undefined - ? stack.findIndex((button) => button.dataset['notificationId'] === anchorId) - : anchorButton === undefined - ? -1 - : stack.indexOf(anchorButton); - // The entry that had the focus is gone (pressed, or evicted) → fall back to the bottom button, - // which is "Close" — the same safe default every stack opens on. - stackIndex = at === -1 ? Math.max(0, stack.length - 1) : at; - } - applyStackFocus(); - } - - /** - * The unread state: the same dot a game card wears, on the Notifications CARD in the row. The inbox - * belongs to the launcher, and the launcher's own cards are where it lives now. - */ - function applyUnreadDot(): void { - deps.carousel.setUnread(deps.getNotifications().some((item) => !item.read)); - } - - /** - * Pressing an entry removes it (this is an inbox — the press IS the handling) and then goes where the - * notification points. A game that is no longer in the list — its card is out, its record evicted — - * simply has nowhere to go, and the popup just closes. - */ - function activateNotification(button: HTMLButtonElement): void { - const id = button.dataset['notificationId']; - if (id === undefined) return; - const item = deps.getNotifications().find((candidate) => candidate.id === id); - window.api.dismissNotification(id); - // Muted when the entry leads to Settings — that screen's popup-open is the sound of the whole - // gesture (Р5). With nowhere to go, the popup simply closes and says so. - closePopup({ silent: item?.kind === 'update-ready' }); - if (item === undefined) return; - if (item.kind === 'update-ready') { - openSettings('settings.sectionUpdates'); - return; - } - // A game written to a card that is not active has no entry in the library to open — the notification - // says where it went, and pressing it does nothing beyond dismissing it. - if ( - item.kind === 'game-added-deferred' || - item.kind === 'game-moved-deferred' || - item.kind === 'game-move-save-skipped' || - item.kind === 'game-move-duplicate' || - item.kind === 'history-config-applied' || - item.kind === 'history-config-discarded' || - item.kind === 'settings-write-failed' - ) - return; - deps.openGameDetail(item.gameId); - } - - /** - * One of the carousel's launcher cards was pressed. The three surfaces are the ones the Details menu - * used to hold at the launcher level; they are now reached from the row itself, which is why `popupRoot` - * is set to 'direct' — B out of them goes back to the cards, not into a menu nobody opened. - */ - function openSystemCard(id: SystemCardId): void { - popupRoot = 'direct'; - // A switch with an exhaustive default, not a chain ending in openPower(): a card added to - // SYSTEM_CARDS and forgotten here used to fall through to "shut the machine down", and no type would - // have caught it. Now the missing branch is a compile error. - switch (id) { - case 'library': - // Same as Settings: the card's own `button` (app.ts) is this press's sound. - deps.library.open(); - break; - case 'notifications': - openNotifications(); - break; - case 'settings': - // The card's own `button` (app.ts) is the sound of this press; the screen adds none of its own. - // The other cards open a popup, whose `popup-open` is a different sound and layers fine. - openSettings(undefined, { silent: true }); - break; - case 'power': - openPower(); - break; - default: { - const exhaustive: never = id; - throw new Error(`unhandled launcher card ${String(exhaustive)}`); - } - } - applyFocus(); - } - - // Power submenu (from a launcher card, or from Details → System on a game screen): Shutdown / Reboot / - // Sleep. Each opens a Yes/No confirm. - function openPower(): void { - setView('power'); - focusStackBottom(); // default focus: Close (bottom) — a safe non-destructive default - applyFocus(); - } - - // Confirm view — install/uninstall (from Details) or a power action (from Power). Yes runs the action - // and closes the whole stack; No/back returns to where it came from. - function openConfirm(mode: ConfirmMode): void { - if (mode === 'install' || mode === 'uninstall') { - const game = screenIsActionable() ? screenGame() : undefined; - if (game === undefined) return; - if (mode === 'install' && !game.requiresInstall) return; // nothing to install - if (mode === 'uninstall' && !game.canUninstall) return; // nothing to uninstall - confirmReturnTo = 'details'; - const isSteam = game.installVia === 'steam'; - const isCopy = game.installVia === 'copy'; - const isSteamInstall = mode === 'install' && isSteam; - popup.dataset['mode'] = mode; // 'install' shows the note (card install only, see styles.css) - // Picks WHICH note the confirm shows: steam → none, copy → "it will be copied here and run from - // here", absent → the card-installer one with the destination path. - if (isSteamInstall) popup.dataset['installVia'] = 'steam'; - else if (mode === 'install' && isCopy) popup.dataset['installVia'] = 'copy'; - else delete popup.dataset['installVia']; - // Prefix-cleanup uninstall shows its own note in the detail (CSS) — the heading stays a short question. - if (mode === 'uninstall' && game.prefixCleanupOnly === true) - popup.dataset['uninstallVia'] = 'prefix'; - else delete popup.dataset['uninstallVia']; - if (isSteam) { - confirmMessage.textContent = t()( - mode === 'install' ? 'launcher.confirm.steamInstall' : 'launcher.confirm.steamUninstall', - ); - } else if (mode === 'install') { - confirmMessage.textContent = t()('launcher.confirm.install'); - } else { - // Uninstall: a normal exe game's "uninstall" only clears its Proton prefix (the game stays on the - // card) — a different message from removing an installed game. prefixCleanupOnly flags that case. - confirmMessage.textContent = t()( - game.prefixCleanupOnly === true - ? 'launcher.confirm.uninstallPrefix' - : 'launcher.confirm.uninstall', - ); - } - // Card path only for a card-INSTALLER install: steam has no install dir, and for copy the path is - // ours to manage — the user has nothing to type it into. - if (mode === 'install') { - confirmPath.textContent = isSteamInstall || isCopy ? '' : (game.installDir ?? ''); - } - } else if (mode === 'forget') { - // Remove-from-history confirm (from Details). The id is captured HERE, not read again on Yes: main - // can move the screen onto another game while the popup is open (a card is inserted), and the one - // the question was asked about is the only one it may answer for. - const browse = deps.getBrowse(); - if (browse === null || browse.active) return; // gone or now playable — the item no longer applies - forgetId = browse.id; - confirmReturnTo = 'details'; - popup.dataset['mode'] = mode; - delete popup.dataset['installVia']; - confirmMessage.textContent = t()('launcher.confirm.forget', { title: browse.title }); - } else if (mode === 'game-collision') { - // Not a menu question: main raised it after a card came in, so there is nothing underneath to - // return to — B and the veil simply leave it unanswered, and the next insertion asks again. - const collision = askedCollision; - if (collision === null) return; - confirmReturnTo = 'details'; - popup.dataset['mode'] = mode; - delete popup.dataset['installVia']; - confirmMessage.textContent = t()('launcher.confirm.collision', { title: collision.title }); - deleteNote.textContent = t()('launcher.confirm.collisionNote'); - } else if (mode === 'reset-settings') { - // Asked from the Settings screen, which stays open UNDER the popup — so "No" must return there, - // not to the Details menu the screen was reached through. - confirmReturnTo = 'settings'; - popup.dataset['mode'] = mode; - delete popup.dataset['installVia']; - confirmMessage.textContent = t()('settings.confirmReset'); - } else if ( - mode === 'reset-game-settings' || - mode === 'delete-game' || - mode === 'delete-game-history' || - mode === 'discard-game-settings' || - mode === 'switch-game-source' || - mode === 'cancel-move-game-settings' || - mode === 'replace-game-title' - ) { - // The Customize screen's questions. Same shape as the Settings reset: the screen stays open - // underneath, so "No" simply closes the popup and hands control back to it. - confirmReturnTo = 'game-settings'; - popup.dataset['mode'] = mode; - delete popup.dataset['installVia']; - const browse = deps.getBrowse(); - confirmMessage.textContent = - mode === 'replace-game-title' - ? t()('metadata.titleConfirm', { title: confirmTitle }) - : mode === 'reset-game-settings' - ? t()('gameSettings.confirmReset') - : mode === 'discard-game-settings' - ? t()('gameSettings.confirmDiscard') - : mode === 'switch-game-source' - ? t()('gameSettings.confirmSwitchSource') - : mode === 'cancel-move-game-settings' - ? t()('gameSettings.confirmCancelMove') - : mode === 'delete-game-history' - ? t()('gameSettings.confirmDeleteHistory', { title: browse?.title ?? '' }) - : t()('gameSettings.confirmDelete', { title: browse?.title ?? '' }); - // The second question's own note: what each of ITS answers costs. It matters more than the first - // one's, because "No" here does not mean "never mind" — it deletes the game and keeps the card. - if (mode === 'delete-game-history') { - deleteNote.textContent = t()('gameSettings.confirmDeleteHistoryNote'); - } - if (mode === 'delete-game') { - // A local game's save backups survive the deletion — gcOrphans sweeps artwork and never touches - // saves/ — and a confirm that stayed silent about it would read as "everything goes". - deleteNote.textContent = t()( - deps.gameSettings.deletesLocalGame() - ? 'gameSettings.confirmDeleteSavesNote' - : 'gameSettings.confirmDeleteNote', - ); - } - } else if (mode === 'kill') { - // Force-close confirm (from Details): no path note; returns to Details. The message warns about - // unsaved progress. data-mode ≠ 'install' hides the path note (styles.css). - confirmReturnTo = 'details'; - popup.dataset['mode'] = mode; - delete popup.dataset['installVia']; - confirmMessage.textContent = t()('launcher.confirm.kill'); - } else { - // Power action: a single-question confirm, no path note (data-mode ≠ 'install' hides it). - confirmReturnTo = 'power'; - popup.dataset['mode'] = mode; - delete popup.dataset['installVia']; - const key = - mode === 'shutdown' - ? 'launcher.confirm.shutdown' - : mode === 'reboot' - ? 'launcher.confirm.reboot' - : 'launcher.confirm.sleep'; - confirmMessage.textContent = t()(key); - } - confirmMode = mode; - setView('confirm'); - focusStackBottom(); // default focus: No (safe default) - applyFocus(); - } - - /** - * Work in progress — a message and a Stop, in the same column everything else speaks through. Opened - * by a surface that started something long (a download that becomes a file beside a game) and closed - * by that same surface when the work answers, so nothing is left standing over a finished job. - */ - function openBusy(message: string, onStop: () => void): void { - busyMessageEl.textContent = message; - busyStop = onStop; - setView('busy'); - focusStackBottom(); // the sole button (Stop) - applyFocus(); - } - - function stopBusy(): void { - const stop = busyStop; - busyStop = null; - closePopup(); - stop?.(); - } - - // Error popup — opened by main via showError (a failed launch/action). A single Close button. - function openError(messageText: string): void { - errorMessageEl.textContent = messageText; - setView('error'); - focusStackBottom(); // the sole button (Close) - applyFocus(); - } - - // B / Esc / veil: step BACK one level. power → details, confirm → wherever it was opened from, - // details / error → close. Default focus lands on the bottom button of the destination stack. - function back(): void { - switch (popupView) { - case 'power': - case 'notifications': - // Opened straight from a launcher card, there is no menu underneath to step back into: the level - // above these is the carousel itself, so the popup simply goes. - if (popupRoot === 'direct') { - closePopup(); - break; - } - audio.play('back'); - setView('details'); - focusStackBottom(); - break; - case 'confirm': - // Neither 'settings' nor 'game-settings' is a popup view: that screen is already open - // underneath, so the popup just goes and the screen has the focus again. - if (confirmReturnTo === 'settings' || confirmReturnTo === 'game-settings') { - closePopup(); - break; - } - audio.play('back'); - setView(confirmReturnTo); - focusStackBottom(); - break; - case 'busy': - // B on work in progress means Stop: there is nothing else this view can answer. - stopBusy(); - break; - case 'details': - case 'error': - closePopup(); - break; - default: - break; - } - } - // ── Settings screen (the fourth surface) ───────────────────────────────────── // Opening/closing lives here because the bar focus does: the screen is entered from More and returns // to it. Everything INSIDE the screen belongs to settings-screen.ts. - function openSettings(sectionKey?: MessageKey, options?: { readonly silent?: boolean }): void { - deps.settings.open(sectionKey, options); - applyFocus(); // the bar highlight clears (focusActive is false with the screen open) - } - /** * "Add game", from the Library's column: the ONE way to create a game from inside the launcher (the * Details menu lost its item in 4c0d3dc, and openNew() has had no caller since). The library steps @@ -905,17 +158,6 @@ export function createControls(deps: ControlsDeps): Controls { applyFocus(); } - function openCustomize(): void { - const browse = deps.getBrowse(); - if (browse === null) return; - // The item's own rule, re-checked at the press — and it decides WHICH screen opens: an available - // game is edited on its card, a history one through the stored snapshot. - if (browse.active) deps.gameSettings.open(browse.id); - else if (browse.configurable === true) deps.gameSettings.openFromHistory(browse.id); - else return; - applyFocus(); - } - /** * The screen closed itself (B / Esc / veil): put the highlight back on the More button it came from — * on a detail screen. Opened from a launcher card, the screen came from the CAROUSEL, where the bar is @@ -927,101 +169,7 @@ export function createControls(deps: ControlsDeps): Controls { if (more !== -1) focusIndex = more; focusRevealed = true; applyFocus(); - armIdleTimer(); // the countdown was suspended while the screen was up - } - - // ── Menu item: Install / Uninstall (game-dependent) ────────────────────────── - // One button whose text + visibility follow the current game: "Install" when it needs installing, - // "Uninstall" when installed & removable, hidden entirely for a plain executable (no install block). - /** - * Whether the Details menu currently belongs to ONE game. On the carousel it does not: the strip is a - * browsing surface, and its More is the launcher-level menu (System + Close). Every game-specific item - * is gated on this, so none of them can appear over a row of cards. - */ - function onGameScreen(): boolean { - return deps.carousel.screen() === 'detail'; - } - - function applyMenuInstallToggle(): void { - if (menuFrozen()) return; - if (!onGameScreen()) { - menuInstallToggle.classList.add('is-hidden'); - return; - } - const game = screenIsActionable() ? screenGame() : undefined; - // While an install/uninstall (card or Steam) is in flight, the Install/Uninstall item is hidden — - // acting on it mid-operation makes no sense (Details still opens for the stats + power actions). - const busy = phaseOf(state()) === 'busy' || steamBusy(state()); - const showInstall = !busy && game?.requiresInstall === true; - const showUninstall = !busy && game?.canUninstall === true; - const show = showInstall || showUninstall; - menuInstallToggle.classList.toggle('is-hidden', !show); - if (show) { - menuInstallToggle.textContent = t()( - showInstall ? 'launcher.menu.install' : 'launcher.menu.uninstall', - ); - // Which action Yes will run — read back in the stack trigger. - menuInstallToggle.dataset['action'] = showInstall ? 'install' : 'uninstall'; - } - } - - // ── Menu item: Force close (running-only) ──────────────────────────────────── - // The MIRROR IMAGE of the install toggle: shown ONLY while a game is running (running is a busy phase, - // so this is the exact opposite of the install toggle, which hides during busy). Text from JS (no - // data-i18n) so a language change re-labels it at render time and it stays out of the i18n HTML test. - function applyMenuKill(): void { - if (menuFrozen()) return; - // Shown only while a game is running AND a force-close isn't already in flight (during killing the - // status reads "Force closing…" and the button would be a no-op — main guards a repeat anyway). - const s = state(); - const running = onGameScreen() && s.kind === 'running' && s.killing !== true; - menuKill.classList.toggle('is-hidden', !running); - if (running) menuKill.textContent = t()('launcher.menu.forceClose'); - } - - // ── Menu item: Home (back to the history carousel) ─────────────────────────── - // The MOUSE route out of a detail screen — the gamepad/keyboard have B for it, but a mouse user had no - // way back to the strip. Shown only on a detail screen that has a carousel behind it. - function applyMenuHome(): void { - if (menuFrozen()) return; - const show = deps.carousel.screen() === 'detail'; - menuHome.classList.toggle('is-hidden', !show); - if (show) menuHome.textContent = t()('launcher.menu.goBack'); - } - - // ── Menu item: Remove from history (history-only games) ────────────────────── - // Offered ONLY for a game that is not available right now — `active` is main's word for "on the card or - // in the PC library". Those games are rebuilt from their manifests on every insert, so removing one - // would be a lie the next refresh undoes; what CAN be removed is the record of a game you no longer - // have. Since the history-config feature it can share the menu with Customize (see below). - // ── Menu item: Customize (the per-game manifest editor) ────────────────────── - // Offered for an AVAILABLE game — `active` is main's word for "on the card or in the PC library", and - // it is the condition under which a game.json to edit exists right now — and, since the history-config - // feature, for a history game main has a card snapshot of (`configurable`): those are edited with no - // card in, and the edits reach the card on its next insertion. So this and "Remove from history" now - // SHARE the menu for a history game — they used to be mutually exclusive by construction. - function applyMenuCustomize(): void { - if (menuFrozen()) return; - const browse = deps.getBrowse(); - const show = onGameScreen() && browse !== null && (browse.active || browse.configurable === true); - menuCustomize.classList.toggle('is-hidden', !show); - if (show) menuCustomize.textContent = t()('launcher.menu.customize'); - } - - function applyMenuForget(): void { - if (menuFrozen()) return; - const browse = deps.getBrowse(); - const show = onGameScreen() && browse !== null && !browse.active; - menuForget.classList.toggle('is-hidden', !show); - if (show) menuForget.textContent = t()('launcher.menu.forget'); - } - - // The power menu carries both ways out of the launcher: "Minimize Playhook" (hide to the tray) and - // "Close Playhook" (full quit). In Game Mode the first one goes — there is no tray to hide into, so - // hiding is a no-op there, and the quit is the honest option (mirrors how closing the window quits in - // Game Mode). - function applyPowerItems(): void { - powerMinimize.classList.toggle('is-hidden', gameMode); + idle.arm(); // the countdown was suspended while the screen was up } // ── Main bar focus (gamepad / mouse) ───────────────────────────────────────── @@ -1033,44 +181,40 @@ export function createControls(deps: ControlsDeps): Controls { // it wakes again only on an explicit gamepad move or a mouse hover. `wasActive` tracks the edge. let focusRevealed = true; let wasActive = false; - // Idle timeout, shared by the bar focus and the mouse: after 5s with no input the bar highlight goes - // dormant AND the mouse falls asleep. Any input restarts the countdown; the gamepad puts the mouse to - // sleep at once (the user switched to the pad), a shove wakes it back up (see the note* helpers). - const IDLE_MS = 5_000; - let idleTimer = 0; - // The launcher OPENS with the mouse asleep (index.html carries the class from the first frame, so there - // is no moment where a parked pointer can hover something before this file runs). Waking it takes a - // deliberate shove — see mouse-sleep.ts and the swallowing listener below. - let mouseAsleep = true; - const wakeMeter = createWakeMeter(); + // The popup column (popups.ts): every view of #popup, its vertical stack and what its buttons do. + const popups = createPopups({ + ...deps, + hover, + screenActions: screenActionsNow, + onFocusChanged: () => applyFocus(), + }); + + // Idle timeout, shared by the bar focus and the mouse (idle.ts): after 5s with no input the bar + // highlight goes dormant AND the mouse falls asleep. Any input restarts the countdown; the gamepad puts + // the mouse to sleep at once (the user switched to the pad), a shove wakes it back up. + const idle = createIdleGuard({ + hover, + isSuspended: () => overlays.isAnyOpen(), + onIdle: () => { + if (focusRevealed && focusActive()) { + focusRevealed = false; + applyFocus(); + } + }, + }); function mainFocusables(): readonly HTMLButtonElement[] { // The carousel has no bar to focus at all: Play is the selected card's invisible stand-in for the // morph (styles.css) and More is hidden there — the launcher-level actions are cards in the row now. if (deps.carousel.screen() === 'carousel') return []; - // Steam install/uninstall indicator up (phase stays 'ready'): the gear opens Steam's Downloads page - // and More opens Details — both focusable. - if (steamBusy(state())) return [playButton, moreButton]; - // Running with the launcher summoned over the game: Play returns to the game, so it's focusable too — - // EXCEPT while a force-close is in flight (killing), when Play is a non-interactive loading spinner. - const running = state(); - if (running.kind === 'running') - return running.killing === true ? [moreButton] : [playButton, moreButton]; - // Hard busy (install / uninstall / launch / save-sync): the Play button is a non-interactive activity - // indicator (spinner/gear), so only More is focusable — it still opens Details. - if (phaseOf(state()) === 'busy') return [moreButton]; - // Empty screen, a HISTORY game (nothing to launch) or a requiresInstall installer/steam game → Play is - // hidden, only More. - const game = screenIsActionable() ? screenGame() : undefined; - if (game === undefined || game.requiresInstall === true) return [moreButton]; - return [playButton, moreButton]; + return screenActionsNow().canPlay ? [playButton, moreButton] : [moreButton]; } // Main focus is meaningful on every DETAIL screen (the More button is always present there) with the // popup closed. On the carousel the strip owns the selection, and nothing else on that screen can hold // the focus at all. function focusActive(): boolean { - if (popupView !== 'none') return false; + if (popups.isOpen()) return false; // The Settings screen covers the bar (which is faded out and pointer-events:none underneath). if (overlays.isAnyOpen()) return false; return deps.carousel.screen() === 'detail'; @@ -1094,7 +238,7 @@ export function createControls(deps: ControlsDeps): Controls { // The Play button's aria-label follows the state: "Return to game" while a game is running (the // launcher was summoned over it), "Play" otherwise. Set at render time via the translator (not the - // static data-i18n-aria-label, which only re-applies on a language change) — see plan F1-5. + // static data-i18n-aria-label, which only re-applies on a language change). function applyPlayAria(): void { // "Return to game" only when running and NOT force-closing (during killing Play is a loader, so the // default "Play" label fits better than an action it won't perform). @@ -1106,70 +250,6 @@ export function createControls(deps: ControlsDeps): Controls { ); } - /** Puts the mouse to sleep or wakes it: hides the cursor AND turns every pointer gesture on or off. */ - function setMouseAsleep(asleep: boolean): void { - if (mouseAsleep === asleep) return; - mouseAsleep = asleep; - document.documentElement.classList.toggle('mouse-asleep', asleep); - wakeMeter.reset(); - } - - // (Re)start the idle countdown (IDLE_MS). On expiry the cursor hides and the bar highlight - // goes dormant if it's shown with nothing open — both "went idle" at the same moment. - function armIdleTimer(): void { - if (idleTimer !== 0) window.clearTimeout(idleTimer); - // With the Settings screen up there is no bar highlight to retire and no carousel to hand back to: - // firing would strip the return point on More and light the strip up under the veil. - if (overlays.isAnyOpen()) return; - idleTimer = window.setTimeout(() => { - idleTimer = 0; - setMouseAsleep(true); - if (focusRevealed && focusActive()) { - focusRevealed = false; - applyFocus(); - } - }, IDLE_MS); - } - - // Where the pointer was when hover was last disarmed — by a surface opening under it, or by a - // keyboard/gamepad step. Until the mouse travels HOVER_WAKE_PX from there, hover does not move the - // focus: an element arriving under a still cursor is the ELEMENT moving, not the mouse, and Chromium - // reports both the same way. Cleared by the first genuine move. - let hoverArmedAt: { readonly x: number; readonly y: number } | null = null; - - function armHover(): void { - hoverArmedAt = { x: lastMouseX, y: lastMouseY }; - } - - function hoverAwake(x: number, y: number): boolean { - if (hoverArmedAt === null) return true; - if (Math.hypot(x - hoverArmedAt.x, y - hoverArmedAt.y) < HOVER_WAKE_PX) return false; - hoverArmedAt = null; - return true; - } - - // Gamepad/keyboard input = activity: the mouse goes to sleep at once (the user switched to the pad, so - // the pointer parked on screen stops counting as input at all), hover is disarmed, the idle countdown - // restarts. - function noteGamepadActivity(): void { - setMouseAsleep(true); - // Explicitly, not just via setMouseAsleep: while the mouse is ALREADY asleep that call is a no-op, - // and the travel a bumped trackpad has quietly banked up has to die on every pad step regardless — - // otherwise a hand resting on the Deck adds up to a wake across a whole session of pressing buttons. - wakeMeter.reset(); - // Every keyboard/gamepad step re-arms the hover guard: last input wins. Without this, one real mouse - // move wakes hover for good, and from then on any element that slides under the still cursor — a - // scrolling list, a popup opening — can take the focus back off the key that just moved it. - armHover(); - armIdleTimer(); - } - - // Real mouse movement, with the mouse already awake = activity: keep the cursor up, restart the idle. - function noteMouseActivity(): void { - setMouseAsleep(false); - armIdleTimer(); - } - function moveFocus(delta: number, repeat = false): void { if (!focusActive()) return; // Dormant (an active state or the idle timeout cleared the highlight): the first d-pad press only @@ -1191,160 +271,23 @@ export function createControls(deps: ControlsDeps): Controls { applyFocus(); } - // ── Popup stack focus (vertical) ───────────────────────────────────────────── - // A single dynamic group covering all four views; the visible buttons depend on the view (and, for - // Details, whether the Install/Uninstall item is present). Default focus is the BOTTOM button. - const ALL_STACK_BUTTONS: readonly HTMLButtonElement[] = [ - menuInstallToggle, - menuKill, - menuForget, - menuHome, - menuCustomize, - menuClose, - notificationsClear, - notificationsClose, - powerShutdown, - powerReboot, - powerSleep, - powerMinimize, - powerQuit, - powerClose, - confirmYes, - confirmNo, - errorClose, - busyStopButton, - ]; - let stackIndex = 0; - - function stackFocusables(): readonly HTMLButtonElement[] { - switch (popupView) { - case 'details': { - // MUST match the DOM order in index.html — this list IS the up/down order, and a mismatch would - // move the highlight somewhere other than where the eye follows. Volatile items first (they come - // and go with the game's phase), then the fixed block that ends at Close: see the note there. - const items: HTMLButtonElement[] = []; - if (!menuInstallToggle.classList.contains('is-hidden')) items.push(menuInstallToggle); - if (!menuKill.classList.contains('is-hidden')) items.push(menuKill); - if (!menuForget.classList.contains('is-hidden')) items.push(menuForget); - if (!menuHome.classList.contains('is-hidden')) items.push(menuHome); - if (!menuCustomize.classList.contains('is-hidden')) items.push(menuCustomize); - items.push(menuClose); - return items; - } - case 'notifications': { - // The list first (oldest at the top, freshest just above the buttons — the DOM order), then the - // buttons. This IS the up/down order, so it must match the DOM exactly. - const items: HTMLButtonElement[] = [...notificationButtons]; - if (!notificationsClear.classList.contains('is-hidden')) items.push(notificationsClear); - items.push(notificationsClose); - return items; - } - case 'power': { - const items: HTMLButtonElement[] = [powerShutdown, powerReboot, powerSleep]; - if (!powerMinimize.classList.contains('is-hidden')) items.push(powerMinimize); - items.push(powerQuit, powerClose); - return items; - } - case 'confirm': - return [confirmYes, confirmNo]; - case 'busy': - return [busyStopButton]; - case 'error': - return [errorClose]; - default: - return []; - } - } - - function stackActive(): boolean { - return popupView !== 'none'; - } - - function applyStackFocus(): void { - const items = stackFocusables(); - stackIndex = Math.min(items.length - 1, Math.max(0, stackIndex)); - const focused = stackActive() ? items[stackIndex] : undefined; - // The notification entries are not in ALL_STACK_BUTTONS — they are rebuilt on every snapshot — so - // they are cleared alongside it, or a stale highlight would sit on two buttons at once. - for (const btn of [...ALL_STACK_BUTTONS, ...notificationButtons]) - btn.classList.toggle('is-focused', btn === focused); - if (focused === undefined) return; - // A focused item is revealed BY the box that scrolls it, which also keeps that box's edge fades in - // step. Anything outside those two boxes has nothing to scroll — and must NOT fall back to - // scrollIntoView there: with no scrollable ancestor Chromium walks up to the app itself and moves the - // whole screen, which is what an overflowing menu used to do. - if (focused.classList.contains('notification-item')) notificationScroller.reveal(focused); - else if (popupView === 'notifications') { - // Clear all / Close live BELOW the scrolling list, not inside it, so they need no revealing of - // their own — but the list does. Left where it was, it keeps showing the top entries while the - // focus has moved past their end, and the highlight travels across a stretch of list that has - // nothing to do with where it is going. Sending the list to its last entry keeps the two together. - const last = notificationButtons[notificationButtons.length - 1]; - if (last !== undefined) notificationScroller.reveal(last); - } else if (popupView === 'details') menuStackScroller.reveal(focused); - else focused.scrollIntoView({ block: 'nearest' }); - } - - function focusStackBottom(): void { - stackIndex = Math.max(0, stackFocusables().length - 1); - applyStackFocus(); - } - - function moveStackFocus(delta: number): void { - if (!stackActive()) return; - const items = stackFocusables(); - if (items.length === 0) return; - // Cyclic navigation (wrap around) — shared by every popup stack. The early return keeps a single-button - // view (error) from playing `navigate` without moving: at len===1 the wrap formula returns the same index. - const next = (stackIndex + delta + items.length) % items.length; - if (next === stackIndex) { - audio.playLimit(); - return; - } - stackIndex = next; - audio.play('navigate'); - applyStackFocus(); - } - - function pressFlash(btn: HTMLElement): void { - btn.classList.add('is-pressed'); - window.setTimeout(() => btn.classList.remove('is-pressed'), PRESS_MS); - } - // ── User-initiated actions ─────────────────────────────────────────────────── function triggerPlay(): void { if (!focusActive()) return; // the bar is not the surface driving the press — not a dead end - // Play acts on the game AppState is about, so it must be the one on screen: a history game has - // nothing to launch, and while you browse game B, "Play" must not start game A behind your back. - if (!screenIsActionable()) return audio.playLimit(); - const game = screenGame(); - // A local game whose files are gone: there is nothing to start, and the status line already says so. - if (game?.unavailable === true) return audio.playLimit(); - // A local game with no launch method configured yet: same dead end, different reason. - if (game?.unconfigured === true) return audio.playLimit(); // Steam download in progress: the gear opens Steam's Downloads page, where the user can // pause/resume (we can't control that programmatically). - if (game?.steamInstalling === true) { + if (opensSteamDownloads(deps.getScreenActivity())) { audio.play('button'); - window.api.openSteamDownloads(); - return; + return deps.api.openSteamDownloads(); } - // Steam uninstall in progress (gear) → nothing useful to do, ignore the press. - if (game?.steamUninstalling === true) return audio.playLimit(); - // Force-close in flight: Play is a loading spinner, not return-to-game — ignore the press. - const s = state(); - if (s.kind === 'running' && s.killing === true) return audio.playLimit(); - // In a hard-busy phase the Play button is just an activity indicator (spinner/gear) — no launch. - // EXCEPT `running`: the launcher was summoned over the game and Play returns to it (main branches on - // the running state and raises the game's window instead of launching). - if (phaseOf(state()) !== 'ready' && state().kind !== 'running') return audio.playLimit(); + if (!screenActionsNow().canPlay) return audio.playLimit(); audio.play('play'); - window.api.requestLaunch(); + deps.api.requestLaunch(deps.getBrowse()?.id ?? gameOf(state())?.id); } function triggerMore(): void { - openDetails(); // the panel's own popup-open is the sound of this press + popups.openDetails(); // the panel's own popup-open is the sound of this press } function activateFocused(): void { @@ -1358,232 +301,10 @@ export function createControls(deps: ControlsDeps): Controls { else triggerPlay(); } - // Dispatch a stack button (shared by gamepad A and mouse click). Each opener/back plays its own sound. - function triggerStackButton(btn: HTMLButtonElement): void { - if (btn === menuInstallToggle) { - audio.play('button'); - openConfirm(menuInstallToggle.dataset['action'] === 'install' ? 'install' : 'uninstall'); - } else if (btn === menuKill) { - audio.play('button'); - openConfirm('kill'); - } else if (btn === menuForget) { - audio.play('button'); - openConfirm('forget'); - } else if (btn.classList.contains('notification-item')) { - activateNotification(btn); - } else if (btn === notificationsClear) { - // The popup deliberately stays open on its empty state: "Clear all" answers "get rid of these", - // not "take me out of here", and closing would hide the very result of the press. - audio.play('button'); - window.api.clearNotifications(); - } else if (btn === menuCustomize) { - // Like Settings: the menu it was opened from closes first — the screen is a surface of its own. - closePopup({ silent: true }); - openCustomize(); - } else if (btn === menuHome) { - // Non-destructive, so no confirm: close the popup and hand control back to the strip. - closePopup(); - deps.carousel.leaveDetail(); - } else if (btn === busyStopButton) { - // Stop: the surface that started the work is told, and the popup goes with it. - audio.play('back'); - stopBusy(); - } else if ( - btn === menuClose || - btn === errorClose || - btn === powerClose || - btn === notificationsClose - ) { - // back() dispatches by the current view: Details/Error → close the popup; Power → step back to - // the Details menu (so "Close" in the Power submenu returns you one level up, like the B gesture). - back(); - } else if (btn === powerShutdown) { - audio.play('button'); - openConfirm('shutdown'); - } else if (btn === powerReboot) { - audio.play('button'); - openConfirm('reboot'); - } else if (btn === powerSleep) { - audio.play('button'); - openConfirm('sleep'); - } else if (btn === powerMinimize) { - // Hide to the tray (same as the empty-screen Hide button); never shown in Game Mode, where there is - // no tray and this would be a no-op. No confirm — hiding is non-destructive. Close the popup first - // so a re-summoned launcher shows a clean bar, not this menu. - closePopup(); - window.api.requestHide(); - } else if (btn === powerQuit) { - // The full quit. No confirm either: it is as recoverable as relaunching from the Steam library — - // and in Game Mode this is the only way out, so a confirm would sit between the user and the exit - // every single time. - closePopup(); - window.api.requestQuit(); - } else if (btn === confirmYes) { - acceptConfirm(); - } else if (btn === confirmNo) { - // No IS back everywhere else — one gesture, one meaning. The history question is the exception: it - // asks how FAR the deletion goes, so "No" answers it (delete the game, keep its card) while B and - // the veil keep meaning "get me out of here" and cancel the deletion outright. - if (popupView === 'confirm' && confirmMode === 'delete-game-history') { - audio.play('button'); - closePopup(); - deps.gameSettings.confirmAccepted('delete'); - return; - } - // "Leave them as they are" is an ANSWER to the collision question, and it is remembered — so the - // launcher stops asking about a game the user has already decided about. - if (popupView === 'confirm' && confirmMode === 'game-collision') { - audio.play('button'); - closePopup(); - answerCollision('ignore'); - return; - } - back(); // cancel → returns to Details / Power - } - } - - /** - * Sends the answer and, if it failed, says why. A failure means the card is no longer the one the - * question was asked about (pulled, or swapped for another carrying the same id) — nothing was written - * and nothing was remembered, so the question honestly returns on the next insertion. - */ - function answerCollision(choice: 'merge' | 'ignore'): void { - const collision = askedCollision; - askedCollision = null; - if (collision === null) return; - void window.api - .resolveGameCollision({ - id: collision.id, - choice, - root: collision.root, - signature: collision.signature, - }) - .then((result) => { - if (!result.saved) openError(result.message); - }); - } - - function activateStack(): void { - if (!stackActive()) return; - const btn = stackFocusables()[stackIndex]; - if (btn === undefined) return; - pressFlash(btn); - triggerStackButton(btn); - } - - // "Yes" — closes the ENTIRE popup stack (→ 'none') and runs the action. Closing first is critical for - // steam-install: after Yes the state stays 'ready', so the popup wouldn't self-close on a state change. - function acceptConfirm(): void { - // Deleting is asked in two parts, and the second one replaces the first ON THE SAME SURFACE: closing - // the popup and opening it again would flash it out and back in for what the user experiences as one - // question growing a follow-up. - if (confirmMode === 'delete-game') { - audio.play('button'); // neutral sound for the destructive confirm - openConfirm('delete-game-history'); - return; - } - const mode = confirmMode; - closePopup(); - switch (mode) { - case 'install': - audio.play('play'); - window.api.requestLaunch(); // main decides install vs launch from requiresInstall - break; - case 'uninstall': - audio.play('button'); // neutral sound for the destructive confirm - window.api.requestUninstall(); - break; - case 'kill': - audio.play('button'); // neutral sound for the destructive confirm - window.api.requestKill(); - break; - case 'forget': - audio.play('button'); // neutral sound for the destructive confirm - if (forgetId !== null) window.api.forgetGame(forgetId); - forgetId = null; - break; - case 'shutdown': - audio.play('button'); - window.api.requestShutdown(); - break; - case 'reboot': - audio.play('button'); - window.api.requestReboot(); - break; - case 'sleep': - audio.play('button'); - window.api.requestSleep(); - break; - case 'reset-settings': - audio.play('button'); // neutral sound for the destructive confirm - deps.settings.resetSettings(); - break; - case 'reset-game-settings': - audio.play('button'); // neutral sound for the destructive confirm - deps.gameSettings.confirmAccepted('reset'); - break; - case 'delete-game-history': - audio.play('button'); // neutral sound for the destructive confirm - deps.gameSettings.confirmAccepted('delete-history'); - break; - case 'discard-game-settings': - audio.play('back'); - deps.gameSettings.confirmAccepted('discard'); - break; - case 'switch-game-source': - audio.play('button'); - deps.gameSettings.confirmAccepted('switch-source'); - break; - case 'cancel-move-game-settings': - audio.play('back'); - deps.gameSettings.confirmAccepted('cancel-move'); - break; - case 'game-collision': - audio.play('button'); - answerCollision('merge'); - break; - case 'replace-game-title': - audio.play('button'); - deps.gameSettings.confirmAccepted('replace-title'); - break; - default: { - // A mode with no branch here is a Yes that closes the popup and does nothing, which is exactly - // how "Update title" came to be a button that asked and then ignored the answer. Now a missing - // branch is a compile error. - const unhandled: never = mode; - return unhandled; - } - } - } - // ── Wiring ──────────────────────────────────────────────────────────────── playButton.addEventListener('click', () => triggerPlay()); moreButton.addEventListener('click', () => triggerMore()); - popupVeil.addEventListener('click', () => back()); - - // A mouse click on a stack button triggers THAT button (regardless of the current highlight); only the - // active view's group is visible/clickable, so a click can't reach a hidden view's button. - ALL_STACK_BUTTONS.forEach((btn) => { - btn.addEventListener('click', () => { - pressFlash(btn); - triggerStackButton(btn); - }); - }); - - // The list's entries are recreated on every snapshot, so the one-off wiring above cannot reach them — - // a click on a fresh entry would land on nothing (hover already works: it resolves its target through - // closest('.text-button')). Delegation on the container covers whatever is in it at press time. - notificationList.addEventListener('click', (event) => { - const target = - event.target instanceof Element - ? event.target.closest('.notification-item') - : null; - if (target === null) return; - pressFlash(target); - triggerStackButton(target); - }); - // ONE window-level mouse handler for both surfaces (the bar and the popup stack), guarded against // SYNTHETIC moves — and that guard is the whole point, not a detail. // @@ -1602,21 +323,16 @@ export function createControls(deps: ControlsDeps): Controls { if (event.clientX === lastMouseX && event.clientY === lastMouseY) return; // synthetic — ignore lastMouseX = event.clientX; lastMouseY = event.clientY; + hover.track(event.clientX, event.clientY); // Asleep, a move is not input — it only feeds the meter. Nothing hovers, nothing focuses and the // cursor stays hidden until the travel adds up to a shove. The position above is recorded either way: // whatever wakes the mouse next has to know where the pointer already is. - if (mouseAsleep && !wakeMeter.moved(event.clientX, event.clientY, performance.now())) return; - noteMouseActivity(); - if (!hoverAwake(event.clientX, event.clientY)) return; + if (!idle.pointerMoved(event.clientX, event.clientY, performance.now())) return; + if (!hover.awake(event.clientX, event.clientY)) return; const element = event.target instanceof Element ? event.target : null; // The popup owns the pointer while it is open: its stack is the only thing hover may move. - if (stackActive()) { - const button = element?.closest('.text-button') ?? null; - if (button === null) return; - const idx = stackFocusables().indexOf(button); - if (idx === -1 || idx === stackIndex) return; - stackIndex = idx; - applyStackFocus(); + if (popups.isOpen()) { + popups.hover(element); return; } if (!focusActive()) return; @@ -1631,55 +347,6 @@ export function createControls(deps: ControlsDeps): Controls { } }); - // Every OTHER thing a pointer can do, switched off in one place for as long as the mouse is asleep. - // - // Asleep means the mouse is OUT of the UI, not merely invisible: clicks, the wheel, right-click-as-back, - // the hover reads on every surface. Gating each of those where it lives would be a list to keep in sync, - // and one forgotten entry is a stutter nobody can reproduce — which is exactly how a resting cursor kept - // stealing the popup's focus. So the gestures die here, in the capture phase on window, before any - // surface sees them. Moves are the deliberate exception: they are the way back (see above). - // - // Two things still get through. Untrusted events, because a synthetic .click() is our own code driving - // the UI rather than a mouse (file-picker.ts does that). And touch: a finger on the Deck's screen is a - // poke at one specific thing, never a pointer drifting under a resting hand, so it wakes the mouse and - // proceeds — the click Chromium synthesises after it then lands on a UI that is already awake. - const SLEPT_THROUGH: readonly string[] = [ - 'click', - 'dblclick', - 'auxclick', - 'contextmenu', - 'wheel', - 'mousedown', - 'mouseup', - 'mouseover', - 'mouseout', - 'mouseenter', - 'mouseleave', - 'pointerdown', - 'pointerup', - 'pointerover', - 'pointerout', - 'pointerenter', - 'pointerleave', - ]; - SLEPT_THROUGH.forEach((type) => { - window.addEventListener( - type, - (event) => { - if (!mouseAsleep || !event.isTrusted) return; - if (event instanceof PointerEvent && event.pointerType === 'touch') { - noteMouseActivity(); - return; - } - event.stopImmediatePropagation(); - // Not merely "don't route it": the default has to go too, or a sleeping wheel still scrolls the - // list under the cursor and a sleeping middle-click still opens Chromium's autoscroll. - if (event.cancelable) event.preventDefault(); - }, - { capture: true, passive: false }, - ); - }); - // The six navigation primitives, shared by the gamepad AND the keyboard (below) so both drive the exact // same custom-highlight model and can never diverge. Each notes activity first (hides the cursor, // restarts the idle countdown), then does its job: left/right move the bar (no-op with a popup open — the @@ -1689,7 +356,7 @@ export function createControls(deps: ControlsDeps): Controls { // Which surface the six primitives drive. Three, in priority order: the popup stack (when open), the // carousel strip (the top-level screen), then the bar. The primitives themselves are unchanged — the // routing lives HERE, in one place, so the gamepad and the keyboard can never diverge. - const onCarousel = (): boolean => popupView === 'none' && deps.carousel.screen() === 'carousel'; + const onCarousel = (): boolean => !popups.isOpen() && deps.carousel.screen() === 'carousel'; /** Whether the STRIP is the surface the nav keys drive — the carousel screen, minus the spell in which * Y has handed the focus to the bar (then left/right/A belong to More, like on any other screen). */ const stripActive = (): boolean => onCarousel(); @@ -1732,15 +399,15 @@ export function createControls(deps: ControlsDeps): Controls { } function navLeft(repeat = false): void { - noteGamepadActivity(); + idle.noteGamepadActivity(); if (repeat) noteFlip(); // Left is "out" of a popup, the same step B takes: the stacks live on the right edge of the screen, // so moving left off them means leaving — the reading the layout already suggests on the carousel // (where left walks from the More button back to the strip). Sub-views step up one level rather than // closing outright, exactly as B does there. A HELD left is ignored: at the repeat cadence it would // walk out through every level and land on the carousel, flipping cards nobody asked to flip. - if (popupView !== 'none') { - if (!repeat) back(); + if (popups.isOpen()) { + popups.navLeft(repeat); return; } // BEFORE stripActive(): left/right are the slider's own gesture (and the dropdown's fast path), and @@ -1758,11 +425,11 @@ export function createControls(deps: ControlsDeps): Controls { moveFocus(-1, repeat); } function navRight(repeat = false): void { - noteGamepadActivity(); + idle.noteGamepadActivity(); if (repeat) noteFlip(); // Same early branch as navLeft — `repeat` is irrelevant here: a held right is exactly what a slider // wants, one step per repeat, and the screen has no "at the end, hand the focus over" rule. - const overlay = popupView === 'none' ? overlays.active() : null; + const overlay = popups.isOpen() ? null : overlays.active(); if (overlay !== null) { overlay.navRight(repeat); return; @@ -1774,19 +441,19 @@ export function createControls(deps: ControlsDeps): Controls { if (deps.carousel.move(1) === 'at-end' && !repeat) audio.playLimit(); return; } - if (popupView === 'none') moveFocus(1, repeat); + if (!popups.isOpen()) moveFocus(1, repeat); } // Vertical hold-to-repeat exists for the Settings LIST, which is long enough to warrant it. The popup // stacks are short and cyclic — repeating there would spin them — so a repeat is dropped anywhere else. function navUp(repeat = false): void { - noteGamepadActivity(); + idle.noteGamepadActivity(); if (repeat) noteFlip(); - if (popupView !== 'none') { + if (popups.isOpen()) { // Held presses move here like they do in every other vertical list: the notification inbox is a // LIST, long enough that stepping it one press at a time is work, and the shorter action stacks - // follow the same rule so a hold means one thing everywhere. The focus wraps (moveStackFocus), so + // follow the same rule so a hold means one thing everywhere. The focus wraps (popups.ts), so // there is no edge to stop at — a hold simply keeps going until it is released. - moveStackFocus(-1); + popups.navUp(); return; } const overlay = overlays.active(); @@ -1803,10 +470,10 @@ export function createControls(deps: ControlsDeps): Controls { else audio.playLimit(); // on the strip there is nothing above the cards to step up to } function navDown(repeat = false): void { - noteGamepadActivity(); + idle.noteGamepadActivity(); if (repeat) noteFlip(); - if (popupView !== 'none') { - moveStackFocus(1); // see navUp — a held direction runs the stack, same as any other list + if (popups.isOpen()) { + popups.navDown(); // see navUp — a held direction runs the stack, same as any other list return; } const overlay = overlays.active(); @@ -1830,18 +497,18 @@ export function createControls(deps: ControlsDeps): Controls { deps.carousel.activate(); } function navActivate(): void { - noteGamepadActivity(); - if (popupView !== 'none') activateStack(); + idle.noteGamepadActivity(); + if (popups.isOpen()) popups.navActivate(); else if (overlays.active() !== null) overlays.active()?.navActivate(); else if (stripActive()) deps.carousel.activate(); else activateFocused(); } function navBack(): void { - noteGamepadActivity(); + idle.noteGamepadActivity(); // Deepest level first: a popup closes, then the bar hands the focus back to the strip, then a detail // screen steps back to the carousel. On the strip itself B does nothing — it is the top level. - if (popupView !== 'none') { - back(); + if (popups.isOpen()) { + popups.navBack(); return; } const overlay = overlays.active(); @@ -1864,7 +531,7 @@ export function createControls(deps: ControlsDeps): Controls { * between Play and More. Everywhere else it is an honest dead end. */ function navY(): void { - noteGamepadActivity(); + idle.noteGamepadActivity(); const overlay = overlays.active(); if (overlay !== null) { if (overlay.navTertiary === undefined) audio.playLimit(); @@ -1882,7 +549,7 @@ export function createControls(deps: ControlsDeps): Controls { * unchanged" (nav-surface.ts). */ function navSecondary(repeat = false): void { - const claimed = popupView === 'none' && overlays.active()?.navSecondary !== undefined; + const claimed = !popups.isOpen() && overlays.active()?.navSecondary !== undefined; if (!claimed) { if (!repeat) audio.playLimit(); return; @@ -1891,7 +558,7 @@ export function createControls(deps: ControlsDeps): Controls { } function navShoulder(direction: -1 | 1): void { - const claimed = popupView === 'none' && overlays.active()?.navShoulder !== undefined; + const claimed = !popups.isOpen() && overlays.active()?.navShoulder !== undefined; if (!claimed) { audio.playLimit(); return; @@ -1900,7 +567,7 @@ export function createControls(deps: ControlsDeps): Controls { } function navCommit(): void { - const claimed = popupView === 'none' && overlays.active()?.navCommit !== undefined; + const claimed = !popups.isOpen() && overlays.active()?.navCommit !== undefined; if (!claimed) { audio.playLimit(); return; @@ -1920,7 +587,7 @@ export function createControls(deps: ControlsDeps): Controls { if (deps.isBooting()) return; // the row is behind the boot screen — see whileAwake if (overlays.isAnyOpen()) return; if (!onCarousel()) return; - noteMouseActivity(); + idle.noteMouseActivity(); const delta = event.deltaY !== 0 ? event.deltaY : event.deltaX; if (delta === 0) return; const now = performance.now(); @@ -1946,7 +613,7 @@ export function createControls(deps: ControlsDeps): Controls { navBack(); // AFTER, not before: navBack() is written for the gamepad and hides the cursor as its first act. // This click IS the mouse, so the cursor has to come back — and it is this call that restores it. - noteMouseActivity(); + idle.noteMouseActivity(); }); /** @@ -1985,204 +652,54 @@ export function createControls(deps: ControlsDeps): Controls { autoRepeat, ); - // Keyboard navigation (Desktop Mode / no gamepad): WASD + arrows move, Space/Enter activate, Tab/Backspace - // (and Esc) step back — the SAME six primitives as the gamepad, so the two input models stay in lockstep. - // No key of its own for "go to More": on home, back has nothing above it to return to, so it doubles as - // that toggle (see navBack) and Tab / Esc / B all reach the button. - // Edge-only (event.repeat ignored) to match the gamepad's one-move-per-press feel. preventDefault stops - // the browser default (Tab focus traversal, Space scroll / native button press, arrow scroll) from firing - // alongside our custom navigation. A backgrounded launcher doesn't receive keydown (the OS routes keys to - // the focused window), so — unlike the Gamepad API — no explicit pause is needed here. - const KEY_NAV: Readonly void>> = { - a: navLeft, - arrowleft: navLeft, - d: navRight, - arrowright: navRight, - w: navUp, - arrowup: navUp, - s: navDown, - arrowdown: navDown, - ' ': navActivate, - enter: navActivate, - tab: navBack, - backspace: navBack, - escape: navBack, - }; - // The four directions are the exception to the edge model: holding one flips through the carousel, - // runs down the Settings list or through a popup stack, matching the gamepad's hold-to-repeat. The - // repeat is OURS, on a timer — the OS supplies its - // own, but at a rate and an initial delay that are the user's system settings, not ours, so the two - // input models would drift apart (and chaining one run into the next would be impossible: the OS - // restarts its full delay on every new key). Native repeats are dropped. Every other key stays one - // action per press. - const REPEATABLE_KEYS = new Set([ - 'a', - 'arrowleft', - 'd', - 'arrowright', - 'w', - 'arrowup', - 's', - 'arrowdown', - ]); - // The key whose repeat is running, and its timer. Only one at a time: with two directions down the - // last one pressed owns the run, which is what a keyboard's own repeat does too. - let heldKey: string | null = null; - let keyRepeatTimer = 0; - - function stopKeyRepeat(): void { - if (keyRepeatTimer !== 0) { - window.clearTimeout(keyRepeatTimer); - keyRepeatTimer = 0; - } - heldKey = null; - } - - function scheduleKeyRepeat(key: string, handler: (repeat: boolean) => void, delay: number): void { - keyRepeatTimer = window.setTimeout(() => { - keyRepeatTimer = 0; - if (heldKey !== key) return; - autoRepeat.noteRepeat(performance.now()); - handler(true); - scheduleKeyRepeat(key, handler, NAV_REPEAT_MS); - }, delay); - } - - window.addEventListener('keydown', (event) => { - const key = event.key.toLowerCase(); - const handler = KEY_NAV[key]; - if (handler === undefined) return; - event.preventDefault(); // suppress the native default even on auto-repeat (e.g. Tab traversal) - if (event.repeat) return; // the OS cadence is not ours — the timer below drives the run - // The boot fence, as a full return rather than a gated call (see whileAwake): the repeat timer armed - // below outlives the boot screen, so a direction merely GATED here would come back to life the moment - // the UI appeared and flip the row for a press made before it existed. - if (deps.isBooting()) return; - handler(false); - if (!REPEATABLE_KEYS.has(key)) return; - stopKeyRepeat(); // a second direction takes the run over from the first - heldKey = key; - // A key taken up while the previous run is still warm continues it, delay skipped — same rule as the - // pad's (auto-repeat.ts), so swinging left→right glides on either device. - const now = performance.now(); - scheduleKeyRepeat(key, handler, autoRepeat.continues(now) ? NAV_REPEAT_MS : HOLD_DELAY_MS); - }); - // The keyboard's half of "the hold is over". A keyup can be missed (the window loses focus mid-hold and - // the release goes to whoever took it), which is what the watchdog in noteFlip covers — and the blur - // below, which also has to stop a timer nobody would otherwise turn off. - window.addEventListener('keyup', (event) => { - const key = event.key.toLowerCase(); - if (heldKey === key) stopKeyRepeat(); - if (REPEATABLE_KEYS.has(key)) endInput(); - }); - window.addEventListener('blur', () => { - if (heldKey === null) return; - stopKeyRepeat(); - endInput(); - }); - - function applyGameButtons(): void { - // The game-dependent Details items: the Install/Uninstall toggle and the running-only Force close. - // Refreshed every render so they stay correct if the game state changes while Details is open (a - // running→syncing-out self-exit must drop Force close; a ready→ready update doesn't close the popup). - applyMenuInstallToggle(); - applyMenuKill(); - applyMenuHome(); - applyMenuCustomize(); - applyMenuForget(); - } - - function clearGameButtons(): void { - if (menuFrozen()) return; - // No game → no Install/Uninstall item and no Force close (the popup is force-closed off the ready - // screen anyway; no-game is never `running`). - menuInstallToggle.classList.add('is-hidden'); - menuKill.classList.add('is-hidden'); - menuCustomize.classList.add('is-hidden'); // no game on screen → no manifest to customize - menuForget.classList.add('is-hidden'); // no game on screen → nothing to remove from the history - applyMenuHome(); // the carousel can still be there with no game on screen (history only) - } + // The keyboard's half of the input model (keyboard.ts): the same primitives, its own hold-to-repeat. + createKeyboardController( + { + onLeft: navLeft, + onRight: navRight, + onUp: navUp, + onDown: navDown, + onActivate: navActivate, + onBack: navBack, + onDirectionsReleased: endInput, + }, + autoRepeat, + { isFenced: () => deps.isBooting() }, + ); function refresh(): void { - // The popup lives on both screens (on the carousel it is what a launcher card opens). Only a - // game-specific install/uninstall Confirm is void once the card is pulled (no game), so close that - // one; Details/Power/power-Confirm/Error all remain valid with or without a card. A failed launch - // returns to 'ready' first, THEN opens the error popup (separate IPC), so the error survives. - if ( - popupView === 'confirm' && - (confirmMode === 'install' || confirmMode === 'uninstall') && - screenGame() === undefined - ) { - closePopup(); - } + popups.refresh(); // When an active state (install / launch / uninstall / steam) APPEARS, drop the bar highlight so it // doesn't sit on a button the user didn't choose. It wakes again on a gamepad move or a mouse hover. - const active = phaseOf(state()) === 'busy' || steamBusy(state()); + const active = phaseOf(state()) === 'busy' || deps.getScreenActivity() !== undefined; if (active && !wasActive) focusRevealed = false; wasActive = active; - applyPowerItems(); applyFocus(); - applyStackFocus(); applyPlayAria(); } return { - applyGameButtons, - clearGameButtons, + applyGameButtons: () => popups.applyGameButtons(), + clearGameButtons: () => popups.clearGameButtons(), settingsClosed, - confirmResetSettings: () => openConfirm('reset-settings'), - confirmGameSettings: (kind, options) => { - confirmTitle = options?.title ?? ''; - openConfirm( - kind === 'reset' - ? 'reset-game-settings' - : kind === 'delete' - ? 'delete-game' - : kind === 'delete-history' - ? 'delete-game-history' - : kind === 'switch-source' - ? 'switch-game-source' - : kind === 'cancel-move' - ? 'cancel-move-game-settings' - : kind === 'replace-title' - ? 'replace-game-title' - : 'discard-game-settings', - ); - }, - askGameCollision: (collision) => { - // One at a time, and never over something the user is in the middle of: a confirm, the Customize - // screen's own question, the power menu. It is picked up the moment the surface clears. - if (popupView !== 'none' || deps.gameSettings.isOpen() || deps.settings.isOpen()) { - queuedCollision = collision; - return; - } - askedCollision = collision; - openConfirm('game-collision'); - }, - showBusy: openBusy, - closeBusy: () => { - if (popupView !== 'busy') return; - busyStop = null; - closePopup(); - }, - openSystemCard, + confirmResetSettings: () => popups.confirmResetSettings(), + confirmGameSettings: (kind, options) => popups.confirmGameSettings(kind, options), + askGameCollision: (collision) => popups.askGameCollision(collision), + askQuit: (action) => popups.askQuit(action), + showBusy: (message, onStop) => popups.showBusy(message, onStop), + closeBusy: () => popups.closeBusy(), + openSystemCard: (id) => popups.openSystemCard(id), openAddGame, refresh, - showError: openError, - setGameMode: (value: boolean) => { - gameMode = value; - applyPowerItems(); - }, + showError: (message) => popups.showError(message), + setGameMode: (value: boolean) => popups.setGameMode(value), start: () => { gamepad.start(); - armIdleTimer(); // begin the countdown so an untouched launcher hides its cursor (IDLE_MS) + idle.arm(); // begin the countdown so an untouched launcher hides its cursor (IDLE_MS) }, /** Pause/resume acting on gamepad input (paused while the launcher is backgrounded — a game on top). */ setGamepadPaused: (paused: boolean) => gamepad.setPaused(paused), - applyNotifications: () => { - applyUnreadDot(); - if (popupView === 'notifications') renderNotificationList(); - }, - isPopupOpen: () => popupView !== 'none', + applyNotifications: () => popups.applyNotifications(), + isPopupOpen: () => popups.isOpen(), }; } diff --git a/src/renderer/deferred-questions.ts b/src/renderer/deferred-questions.ts new file mode 100644 index 00000000..60453a2e --- /dev/null +++ b/src/renderer/deferred-questions.ts @@ -0,0 +1,93 @@ +import type { GameCollision } from '../shared/types.js'; +import type { QuitAction, QuitConfirmReply } from '../shared/quit.js'; + +/** What the queue needs from the popup column it waits on. */ +export interface DeferredQuestionsDeps { + /** Which view the popup column shows, 'none' while it is closed. */ + popupView(): string; + /** A full-screen surface with questions of its own is up (Customize, Settings). */ + isScreenAsking(): boolean; + openCollision(collision: GameCollision): void; + /** Opens main's quit question for `action`; true once it is on screen. */ + openQuit(action: QuitAction): boolean; + replyQuit(reply: QuitConfirmReply): void; + readonly fadeMs: number; +} + +/** The questions main raises on its own, held back while the popup column is in use. */ +export interface DeferredQuestions { + askCollision(collision: GameCollision): void; + /** main asks before `action` while jobs run; it hears at once that the question is shown or queued. */ + askQuit(action: QuitAction): void; + /** main's quit question got its Yes: closing the column is not a dismissal then. */ + quitAnswered(): void; + /** The column closed: reports an unanswered quit question, then raises whatever waited. */ + columnClosed(): void; +} + +/** + * Creates the queue of main's own questions: the card-vs-PC collision and the quit question asked while + * background jobs run. Neither may wipe out a question, an error or work in progress the user is looking + * at; each waits for the column to close and comes up after its fade. + */ +export function createDeferredQuestions(deps: DeferredQuestionsDeps): DeferredQuestions { + let queuedCollision: GameCollision | null = null; + let queuedQuit: QuitAction | null = null; + let quitAsked = false; + + function after(fade: () => void): void { + window.setTimeout(() => { + if (deps.popupView() === 'none') fade(); + }, deps.fadeMs); + } + + /** Raises a collision question that arrived while the column was busy, once it is free again. */ + function flushCollision(): void { + const waiting = queuedCollision; + if (waiting === null || deps.popupView() !== 'none' || deps.isScreenAsking()) return; + queuedCollision = null; + // After the fade, or the question would open into a column still fading the previous one out. + after(() => deps.openCollision(waiting)); + } + + function flushQuit(): void { + const waiting = queuedQuit; + if (waiting === null || deps.popupView() !== 'none') return; + queuedQuit = null; + after(() => { + quitAsked = deps.openQuit(waiting); + }); + } + + return { + askCollision: (collision) => { + // One at a time, and never over something the user is in the middle of: a confirm, the Customize + // screen's own question, the power menu. It is picked up the moment the surface clears. + if (deps.popupView() !== 'none' || deps.isScreenAsking()) { + queuedCollision = collision; + return; + } + deps.openCollision(collision); + }, + askQuit: (action) => { + deps.replyQuit('shown'); + const view = deps.popupView(); + if (view === 'confirm' || view === 'busy' || view === 'error') { + queuedQuit = action; + return; + } + quitAsked = deps.openQuit(action); + }, + quitAnswered: () => { + quitAsked = false; + }, + columnClosed: () => { + if (quitAsked) { + quitAsked = false; + deps.replyQuit('dismissed'); + } + flushCollision(); + flushQuit(); + }, + }; +} diff --git a/src/renderer/details-menu.ts b/src/renderer/details-menu.ts new file mode 100644 index 00000000..be87e4d8 --- /dev/null +++ b/src/renderer/details-menu.ts @@ -0,0 +1,161 @@ +// The items of the popup's Details and Power views that come and go with the game and the screen: +// Install/Uninstall, Force close, Home, Customize, Remove from history — and Minimize, which Game Mode +// has no tray for. Their visibility rules live here; the popup (popups.ts) owns the stack they sit in, +// which is why it can hold them still during its fade-out (`isFrozen`). +import type { AppState, BrowseInfo } from '../shared/types.js'; +import type { Translator } from '../shared/i18n/index.js'; +import type { CarouselNav } from './controls-deps.js'; +import { req } from './dom.js'; +import type { ScreenActions } from './state-view.js'; + +export interface DetailsMenuDeps { + getState(): AppState; + getBrowse(): BrowseInfo | null; + getTranslator(): Translator; + readonly carousel: Pick; + + /** What can be done with the game on screen (state-view's screenActions). */ + screenActions(): ScreenActions; + /** The popup is fading out: its items must not rewrite themselves in view (see popups.ts). */ + isFrozen(): boolean; +} + +export interface DetailsMenu { + /** Refreshes the game-dependent Details items from the current state. */ + applyGameButtons(): void; + /** Clears them for the idle/no-game screen. */ + clearGameButtons(): void; + /** Whether this is a Game Mode session — "Minimize Playhook" goes then. */ + applyPowerItems(gameMode: boolean): void; +} + +export function createDetailsMenu(deps: DetailsMenuDeps): DetailsMenu { + const t = (): Translator => deps.getTranslator(); + const menuInstallToggle = req('menu-install-toggle'); + const menuKill = req('menu-kill'); + const menuHome = req('menu-home'); + const menuCustomize = req('menu-customize'); + const menuForget = req('menu-forget'); + const powerMinimize = req('power-minimize'); + + // ── Menu item: Install / Uninstall (game-dependent) ────────────────────────── + // One button whose text + visibility follow the current game: "Install" when it needs installing, + // "Uninstall" when installed & removable, hidden entirely for a plain executable (no install block). + /** + * Whether the Details menu currently belongs to ONE game. On the carousel it does not: the strip is a + * browsing surface, and its More is the launcher-level menu (System + Close). Every game-specific item + * is gated on this, so none of them can appear over a row of cards. + */ + function onGameScreen(): boolean { + return deps.carousel.screen() === 'detail'; + } + + function applyMenuInstallToggle(): void { + if (deps.isFrozen()) return; + if (!onGameScreen()) { + menuInstallToggle.classList.add('is-hidden'); + return; + } + const actions = deps.screenActions(); + if (actions.canCancel) { + menuInstallToggle.classList.remove('is-hidden'); + menuInstallToggle.textContent = t()('launcher.menu.cancelInstall'); + menuInstallToggle.dataset['action'] = 'cancel'; + return; + } + const showInstall = actions.canInstall; + const showUninstall = actions.canUninstall; + const show = showInstall || showUninstall; + menuInstallToggle.classList.toggle('is-hidden', !show); + if (show) { + menuInstallToggle.textContent = t()( + showInstall ? 'launcher.menu.install' : 'launcher.menu.uninstall', + ); + // Which action Yes will run — read back in the stack trigger. + menuInstallToggle.dataset['action'] = showInstall ? 'install' : 'uninstall'; + } + } + + // ── Menu item: Force close (running-only) ──────────────────────────────────── + // The MIRROR IMAGE of the install toggle: shown ONLY while a game is running (running is a busy phase, + // so this is the exact opposite of the install toggle, which hides during busy). Text from JS (no + // data-i18n) so a language change re-labels it at render time and it stays out of the i18n HTML test. + function applyMenuKill(): void { + if (deps.isFrozen()) return; + // Shown only while a game is running AND a force-close isn't already in flight (during killing the + // status reads "Force closing…" and the button would be a no-op — main guards a repeat anyway). + const running = onGameScreen() && deps.screenActions().canForceClose; + menuKill.classList.toggle('is-hidden', !running); + if (running) menuKill.textContent = t()('launcher.menu.forceClose'); + } + + // ── Menu item: Home (back to the history carousel) ─────────────────────────── + // The MOUSE route out of a detail screen — the gamepad/keyboard have B for it, but a mouse user had no + // way back to the strip. Shown only on a detail screen that has a carousel behind it. + function applyMenuHome(): void { + if (deps.isFrozen()) return; + const show = deps.carousel.screen() === 'detail'; + menuHome.classList.toggle('is-hidden', !show); + if (show) menuHome.textContent = t()('launcher.menu.goBack'); + } + + // ── Menu item: Remove from history (history-only games) ────────────────────── + // Offered ONLY for a game that is not available right now — `active` is main's word for "on the card or + // in the PC library". Those games are rebuilt from their manifests on every insert, so removing one + // would be a lie the next refresh undoes; what CAN be removed is the record of a game you no longer + // have. Since the history-config feature it can share the menu with Customize (see below). + // ── Menu item: Customize (the per-game manifest editor) ────────────────────── + // Offered for an AVAILABLE game — `active` is main's word for "on the card or in the PC library", and + // it is the condition under which a game.json to edit exists right now — and, since the history-config + // feature, for a history game main has a card snapshot of (`configurable`): those are edited with no + // card in, and the edits reach the card on its next insertion. So this and "Remove from history" now + // SHARE the menu for a history game — they used to be mutually exclusive by construction. + function applyMenuCustomize(): void { + if (deps.isFrozen()) return; + const browse = deps.getBrowse(); + const show = + onGameScreen() && browse !== null && (browse.active || browse.configurable === true); + menuCustomize.classList.toggle('is-hidden', !show); + if (show) menuCustomize.textContent = t()('launcher.menu.customize'); + } + + function applyMenuForget(): void { + if (deps.isFrozen()) return; + const browse = deps.getBrowse(); + const show = onGameScreen() && browse !== null && !browse.active; + menuForget.classList.toggle('is-hidden', !show); + if (show) menuForget.textContent = t()('launcher.menu.forget'); + } + + // The power menu carries both ways out of the launcher: "Minimize Playhook" (hide to the tray) and + // "Close Playhook" (full quit). In Game Mode the first one goes — there is no tray to hide into, so + // hiding is a no-op there, and the quit is the honest option (mirrors how closing the window quits in + // Game Mode). + function applyPowerItems(gameMode: boolean): void { + powerMinimize.classList.toggle('is-hidden', gameMode); + } + + function applyGameButtons(): void { + // The game-dependent Details items: the Install/Uninstall toggle and the running-only Force close. + // Refreshed every render so they stay correct if the game state changes while Details is open (a + // running→syncing-out self-exit must drop Force close; a ready→ready update doesn't close the popup). + applyMenuInstallToggle(); + applyMenuKill(); + applyMenuHome(); + applyMenuCustomize(); + applyMenuForget(); + } + + function clearGameButtons(): void { + if (deps.isFrozen()) return; + // No game → no Install/Uninstall item and no Force close (the popup is force-closed off the ready + // screen anyway; no-game is never `running`). + menuInstallToggle.classList.add('is-hidden'); + menuKill.classList.add('is-hidden'); + menuCustomize.classList.add('is-hidden'); // no game on screen → no manifest to customize + menuForget.classList.add('is-hidden'); // no game on screen → nothing to remove from the history + applyMenuHome(); // the carousel can still be there with no game on screen (history only) + } + + return { applyGameButtons, clearGameButtons, applyPowerItems }; +} diff --git a/src/renderer/dom.ts b/src/renderer/dom.ts index 56340707..52d26658 100644 --- a/src/renderer/dom.ts +++ b/src/renderer/dom.ts @@ -22,3 +22,12 @@ export function reqCanvas(id: string): HTMLCanvasElement { if (!(el instanceof HTMLCanvasElement)) throw new Error(`#${id} is not a canvas`); return el; } + +/** Gamepad A doesn't trigger :active — how long the press class stays on to play the scale-down. */ +export const PRESS_MS = 130; + +/** The same press flash the rest of the UI uses on a button that was pressed by the pad or the keyboard. */ +export function pressFlash(el: HTMLElement): void { + el.classList.add('is-pressed'); + window.setTimeout(() => el.classList.remove('is-pressed'), PRESS_MS); +} diff --git a/src/renderer/file-picker.ts b/src/renderer/file-picker.ts index 561b7750..37dc24c8 100644 --- a/src/renderer/file-picker.ts +++ b/src/renderer/file-picker.ts @@ -5,7 +5,7 @@ // It is READ-ONLY and unrestricted on purpose: where to browse is the user's business, and the commonest // install path there is (`…/steamapps/common`) is a system directory by any definition. What is guarded // is what main ACCEPTS back — the type/extension checks and the import limits live there, where a -// renderer cannot talk its way past them (see the plan, Р5.1/Р5.2). +// renderer cannot talk its way past them. // // Two columns: the starting points on the left (the card, this PC, the home folder, every mounted // volume), the current directory on the right. Left/right move between them, up/down inside one, A enters @@ -16,14 +16,14 @@ import type { DirEntry, DirRoot, ListDirResult, -} from '../shared/types'; +} from '../shared/types.js'; import type { Translator } from '../shared/i18n/index.js'; import { type AudioController } from './audio.js'; import { req } from './dom.js'; import { createHoverGuard } from './hover-guard.js'; import { clampIndex } from './index-math.js'; import { createScroller } from './screen-scroller.js'; -import type { FilePickerSurface } from './game-settings-screen.js'; +import type { FilePickerSurface } from './nav-surface.js'; /** What the picker asks main. A seam, so app.ts owns the window.api wiring. */ export interface FilePickerApi { diff --git a/src/renderer/format.ts b/src/renderer/format.ts index a1fce388..61c28a2b 100644 --- a/src/renderer/format.ts +++ b/src/renderer/format.ts @@ -1,7 +1,7 @@ // Pure display formatters for the game info panel and the notification list (split out of app.ts). The // translator and locale are passed in (kept pure): plural units go through `tp`, dates through // toLocaleString. -import type { AppNotification } from '../shared/types'; +import type { AppNotification } from '../shared/types.js'; import type { Locale, Translator } from '../shared/i18n/index.js'; export function formatPlaytime(totalSeconds: number, t: Translator): string { @@ -37,6 +37,10 @@ export function formatNotification(item: AppNotification, t: Translator): string return t('notifications.gameInstalled', { title: item.gameTitle }); case 'game-uninstalled': return t('notifications.gameUninstalled', { title: item.gameTitle }); + case 'game-install-failed': + return t('notifications.gameInstallFailed', { title: item.gameTitle, reason: item.reason }); + case 'game-uninstall-failed': + return t('notifications.gameUninstallFailed', { title: item.gameTitle, reason: item.reason }); case 'game-added-deferred': return t('notifications.gameAddedDeferred', { title: item.gameTitle }); case 'game-moved-deferred': diff --git a/src/renderer/game-settings-model.ts b/src/renderer/game-settings-model.ts index 2d8a1b9a..624b3f62 100644 --- a/src/renderer/game-settings-model.ts +++ b/src/renderer/game-settings-model.ts @@ -12,15 +12,15 @@ import { type ConfigPickKind, type HostPlatform, type ManifestSource, -} from '../shared/types'; -import type { MessageKey } from '../shared/i18n/index'; +} from '../shared/types.js'; +import type { MessageKey } from '../shared/i18n/index.js'; import { movedGridAssetPath, movedHeroAssetPath, movedMusicAssetPath, -} from '../shared/asset-move-names'; -import { emptyFormModel } from './configure-form-model'; -import type { InstallType, LaunchMode, ManifestFormModel } from './configure-form-model'; +} from '../shared/asset-move-names.js'; +import { emptyFormModel } from './configure-form-model.js'; +import type { InstallType, LaunchMode, ManifestFormModel } from './configure-form-model.js'; import type { RowLabel, CoreActionRow, @@ -33,7 +33,7 @@ import type { CoreStaticRow, CoreTextRow, CoreToggleRow, -} from './row-view-core'; +} from './row-view-core.js'; /** * Every row of this screen, named by the manifest path it edits (or by what it is, for the ones that edit @@ -116,7 +116,7 @@ export interface GameSettingsEnv { * inferring all of that from the length of a list reads like a puzzle. */ readonly mode: 'edit' | 'add'; - /** A "Move to card…" target has been chosen (Р2.2) — Save is labelled and routed for a move instead of + /** A "Move to card…" target has been chosen — Save is labelled and routed for a move instead of * an ordinary edit; Reset/Delete make no sense mid-move and are left out by the screen's own canDelete. */ readonly move: boolean; /** Where a NEW game may go — the roots offered by the source row. Empty in edit mode (no such row). */ @@ -141,7 +141,7 @@ export interface GameSettingsEnv { readonly mixed: boolean; /** This game's validation problems, by the field path the validator reported (already localized). */ readonly issues: ReadonlyMap; - /** Problems in OTHER games of a multi-game file, already worded for display (see the plan, Э4). */ + /** Problems in OTHER games of a multi-game file, already worded for display. */ readonly otherIssues: readonly string[]; /** A status line under the actions: what the last save did, or why Save is unavailable. */ readonly status: string | null; @@ -215,8 +215,8 @@ function lockedForHistory(rows: readonly GameSettingsRow[]): readonly GameSettin /** * The launch modes a source allows. A card cannot host a `pc` game (or the `none` draft state — a card - * is portable and must stay resolvable on its own, see the plan's assumption 3); a local game is only - * ever one, but may also be a draft with none configured yet (Р1). + * is portable and must stay resolvable on its own); a local game is only ever one, but may also be a + * draft with none configured yet. */ export function launchModesFor(source: ManifestSource): readonly LaunchMode[] { return source === 'pc' ? ['pc', 'steam', 'none'] : ['executable', 'installer', 'steam']; @@ -363,7 +363,7 @@ export function buildGameSettingsModel( }); // Absent while a move is pending: the id is what BOTH halves of the move are addressed by (which slot // leaves the PC library, which stats/saves follow the game), so a move that also renames would orphan - // all of it — see the plan's assumption 4, and the matching refusal in GameConfigService.moveToCard. + // all of it — hence the matching refusal in GameMoveTransaction.moveToCard. // Renaming stays available as an ordinary edit, before or after the move. if (!env.move) { basics.push({ @@ -598,6 +598,7 @@ export function buildGameSettingsModel( step: 5, min: 1, max: 3600, + fallback: 30, hint: { key: 'gameSettings.launchTimeoutHint' }, ...error('launchTimeoutSec'), }, @@ -610,6 +611,7 @@ export function buildGameSettingsModel( step: 5, min: 1, max: 3600, + fallback: 60, hint: { key: 'gameSettings.killTimeoutHint' }, ...error('killTimeoutSec'), }, @@ -764,6 +766,108 @@ export function buildGameSettingsModel( }; } +/** The title's slug, in the same shape configure-form-model's slugifyId produces. */ +export function slugifyTitle(title: string): string { + return title + .normalize('NFKD') + .replace(/[̀-ͯ]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +/** + * Writes one text / number / path field by row id. The same object comes back when the id names no such + * field, so a caller can tell a write from a no-op by identity. + * + * `idFollowsTitle` is the Add-game rule: the id tracks the title's slug until the user takes the id + * over, because a slug is a good first guess. For an EXISTING game it is a trap — its id keys the + * playtime, the saves backup and the library entry, and a rename must not quietly detach all three — + * so the editor passes false and the id only changes when the user edits the id row itself. + */ +export function withField( + form: ManifestFormModel, + id: GameRowId, + value: string, + idFollowsTitle = false, +): ManifestFormModel { + switch (id) { + case 'title': { + const followed = idFollowsTitle && (form.id === '' || form.id === slugifyTitle(form.title)); + return { ...form, title: value, ...(followed ? { id: slugifyTitle(value) } : {}) }; + } + case 'id': + // Lower case wherever it comes from, so the field agrees with the slug a title proposes — the + // keyboard already refuses to type anything else (osk.ts). + return { ...form, id: value.toLowerCase() }; + case 'executable': + return { ...form, executable: value }; + case 'pc.executable': + return { ...form, pc: { ...form.pc, executable: value } }; + case 'install.installer': + return { ...form, install: { ...form.install, installer: value } }; + case 'copyInstall.installer': + return { ...form, copyInstall: { ...form.copyInstall, installer: value } }; + case 'steam.appid': + return { ...form, steam: { ...form.steam, appid: value } }; + case 'gridImage': + return { ...form, gridImage: value }; + case 'saveOnCard': + return { ...form, saveOnCard: value }; + case 'pcSavePath': + return { ...form, pcSavePath: value }; + case 'backgroundMusic': + return { ...form, backgroundMusic: value }; + case 'launchTimeoutSec': + return { ...form, launchTimeoutSec: value }; + case 'killTimeoutSec': + return { ...form, killTimeoutSec: value }; + case 'umuGameId': + return { ...form, umuGameId: value }; + default: + return form; + } +} + +/** Writes one list field by row id; the same object comes back for an id that is not a list. */ +export function withList( + form: ManifestFormModel, + id: GameRowId, + items: readonly string[], +): ManifestFormModel { + switch (id) { + case 'args': + return { ...form, args: items }; + case 'watchProcesses': + return { ...form, watchProcesses: items }; + case 'heroImage': + return { ...form, heroImage: items }; + case 'winetricks': + return { ...form, winetricks: items }; + case 'install.args': + return { ...form, install: { ...form.install, args: items } }; + case 'install.winetricks': + return { ...form, install: { ...form.install, winetricks: items } }; + default: + return form; + } +} + +/** Flips one checkbox by row id; the same object comes back for a toggle that may not flip right now. */ +export function withToggle(form: ManifestFormModel, id: GameRowId): ManifestFormModel { + switch (id) { + case 'runAsAdmin': + return { ...form, runAsAdmin: !form.runAsAdmin }; + case 'copyToPc': + return { ...form, copyToPc: !form.copyToPc }; + case 'install.runAsAdmin': + if (form.install.type === 'custom') return form; // forced off — the manifest forbids the pair + return { ...form, install: { ...form.install, runAsAdmin: !form.install.runAsAdmin } }; + default: + return form; + } +} + /** Applies a new launch mode to the form state. The hidden modes' fields are kept — see the model note. */ export function withLaunchMode(form: ManifestFormModel, mode: LaunchMode): ManifestFormModel { return { ...form, launchMode: mode }; @@ -815,7 +919,7 @@ export function hasSourceBoundValues(form: ManifestFormModel): boolean { * radio button was changed. */ /** - * Moves a LOADED PC-library form onto a card (Р2.2 — the pure half of "Move to card…"). Unlike + * Moves a LOADED PC-library form onto a card (the pure half of "Move to card…"). Unlike * `carryFormAcrossSources` (which starts a NEW, half-filled ADD form and has nothing of the old root's to * keep), this carries a REAL game's data across: everything the card dialect can express survives, * including the artwork/music, whose paths become the DETERMINISTIC names the game gets on the diff --git a/src/renderer/game-settings-screen.ts b/src/renderer/game-settings-screen.ts index f980002e..f2edfd51 100644 --- a/src/renderer/game-settings-screen.ts +++ b/src/renderer/game-settings-screen.ts @@ -6,8 +6,8 @@ // Three things are worth knowing before reading the rest: // // • THE FILE IS THE UNIT, THE GAME IS THE SLOT. gameConfig:read hands over the whole game.json text; the -// screen finds ITS slot by `id` (never by an index from main — see the plan, Р2), edits that one, and -// serializes every slot back. A neighbour the form cannot represent is carried through verbatim as a +// screen finds ITS slot by `id` (never by an index from main: the file can change under it), edits +// that one, and serializes every slot back. A neighbour the form cannot represent is carried through verbatim as a // raw slot, so saving one game never destroys another. // // • SAVING IS EXPLICIT. Unlike Settings, a value change writes nothing: every keystroke would mean a @@ -20,9 +20,7 @@ // surfaces on a stack inside it, and the six primitives are routed to whichever is on top. import type { BrowseInfo, - SfxName, ConfigMoveResult, - ConfigPickKind, ConfigPickResult, ConfigRootReadResult, ConfigSaveResult, @@ -40,19 +38,23 @@ import type { ManifestSource, MetadataApplyRequest, MetadataApplyResult, - MetadataApplySlot, MetadataResult, -} from '../shared/types'; +} from '../shared/types.js'; import type { MessageKey, Translator } from '../shared/i18n/index.js'; -import { MAX_HERO_IMAGES } from '../shared/types'; import { type AudioController } from './audio.js'; -import { req } from './dom.js'; +import { pressFlash, req } from './dom.js'; import { createEntrance } from './entrance.js'; import { createHoverGuard } from './hover-guard.js'; -import { clampIndex, wrapIndex } from './index-math.js'; -import { createScroller, pxUnit } from './screen-scroller.js'; -import { createSidebar, type SidebarEntry } from './screen-sidebar.js'; -import type { NavSurface } from './nav-surface.js'; +import { wrapIndex } from './index-math.js'; +import { createScroller } from './screen-scroller.js'; +import { createSidebar } from './screen-sidebar.js'; +import { createListScreenCore, sectionByKey } from './list-screen-core.js'; +import { createMenuStack, type MenuEntry } from './menu-stack.js'; +import { createAssetLightbox } from './asset-lightbox.js'; +import { createListEditor } from './list-editor.js'; +import { createOnlineFlow } from './online-flow.js'; +import { createManifestValidator, issueKey } from './manifest-validation.js'; +import type { FilePickerSurface, NavSurface, TextEntrySurface } from './nav-surface.js'; import type { ApplyOutcome, OnlinePickerSurface } from './online-picker.js'; import { emptyFormModel, @@ -74,30 +76,30 @@ import { draftModeFor, hasSourceBoundValues, pickKindFor, + withField, withInstallType, withLaunchMode, + withList, + withToggle, type GameRowId, type GameSettingsModel, type GameSettingsRow, } from './game-settings-model.js'; import { - applyThumbnails, + artworkSignature, + buildStatusNote, + columnEntries, isFocusable, patchGameRow, relocalizeGameRow, relocalizeGameSections, renderGameSettings, screenHeading, + statusNotes, type RenderedGameRow, } from './game-settings-view.js'; -import { optionLabel, optionLabelNode, rowLabelText, type CoreOption } from './row-view-core.js'; - -/** Gamepad A doesn't trigger :active — the same press flash the rest of the UI uses. */ -const PRESS_MS = 130; -/** How long the screen waits after a change before asking main to validate the text. */ -const VALIDATE_DEBOUNCE_MS = 400; -/** Marquee speed for a clipped menu label, in DESIGN px per second (the Settings dropdown's constant). */ -const MARQUEE_SPEED_PX_PER_S = 60; +import { optionLabel, rowLabelText, type CoreOption } from './row-view-core.js'; + /** * How often the screen re-asks whether there is a card to move onto. A poll rather than a push because * nothing announces a BLANK card: main's watcher only reports media carrying a game.json, and an empty @@ -134,7 +136,7 @@ export interface GameSettingsScreenApi { * ever sent after the game has left the manifest: main refuses to forget a game that is available. */ forgetHistory(id: string): void; - /** Moves a local (PC-library) game onto a card in one transaction (see the plan, Р2.5). */ + /** Moves a local (PC-library) game onto a card in one transaction. */ moveToCard(request: GameMoveRequest): Promise; /** The same conversion the in-launcher picker uses (main re-checks/converts a picked path) — used * outside a Browse to carry an absolute PC-side pcSavePath over as a %PREFIX% string when moving a @@ -170,49 +172,13 @@ export type GameSettingsConfirm = // Asked by the "Find online" surface, answered here: taking the store's spelling into Title. | 'replace-title'; -/** A surface that opens ON TOP of the screen and hands a value back when it is done. */ -export interface TextEntrySurface extends NavSurface { - open(request: { - readonly value: string; - readonly mode: 'text' | 'id' | 'number'; - readonly title: string; - readonly onDone: (value: string) => void; - }): void; - /** - * Dismisses the keyboard without committing. Called when a SCREEN closes under it: the keyboard is not - * inside any screen (see #osk in index.html), so nothing else would take it off the display — it would - * stay up over the carousel, still holding the focus of a screen that is gone. - */ - close(): void; -} - -export interface FilePickerSurface extends NavSurface { - open(request: { - /** Where picked paths are measured from. Empty for a history game — there is no card to measure - * against, and `historyId` names where the file is copied to instead. */ - readonly root: string; - readonly kind: ConfigPickKind; - readonly current: string; - readonly multi: boolean; - /** The root-relative sub-directory this field is measured from, when it has one (see baseFor). */ - readonly base?: string; - /** - * Set when the screen is editing a game from the HISTORY: what is picked is copied into that game's - * staging directory on this PC (the card it is for is not in), and the field stores the path the - * file will have on the card once the edits are applied. - */ - readonly historyId?: string; - readonly onDone: (result: ConfigPickResult) => void; - }): void; -} - export interface GameSettingsScreenDeps { readonly audio: AudioController; getTranslator(): Translator; readonly api: GameSettingsScreenApi; - /** The on-screen keyboard — without it there is no way to type on a gamepad (see the plan, Р4). */ + /** The on-screen keyboard — without it there is no way to type on a gamepad. */ readonly keyboard: TextEntrySurface; - /** The in-launcher file browser — the native dialog cannot be driven in Game Mode (Р5). */ + /** The in-launcher file browser — the native dialog cannot be driven in Game Mode. */ readonly picker: FilePickerSurface; /** The online artwork gallery — the surface "Find online" picks a cover or a background in. */ readonly onlinePicker: OnlinePickerSurface; @@ -225,8 +191,8 @@ export interface GameSettingsScreenDeps { * title with X?" is about a candidate the popup has never heard of. */ onConfirmRequested(kind: GameSettingsConfirm, options?: { readonly title?: string }): void; - /** Whether the game is running / installing / being force-closed — Delete is hidden then (Р3). */ - isBusy(): boolean; + /** Whether game `id` is the session's game or carries an activity - Delete, Move and Save stand down. */ + isBusy(id: string): boolean; /** A game was added AND applied: the launcher's library has it now, so the carousel goes to it. */ onAdded(id: string): void; /** @@ -251,7 +217,7 @@ export interface GameSettingsScreen extends NavSurface { /** Opens the same screen with no game behind it — the form CREATES one (see `mode`). */ openNew(): void; close(): void; - /** browse:update arrived: the screen closes when its game is gone or no longer playable (Р6.2). */ + /** browse:update arrived: the screen closes when its game is gone or no longer playable. */ applyBrowse(browse: BrowseInfo | null): void; /** The confirm popup answered yes for `kind`. */ confirmAccepted(kind: GameSettingsConfirm): void; @@ -284,35 +250,6 @@ export interface GameSettingsScreen extends NavSurface { heroCount(): number; } -/** - * One level of the column menu. `select` is a list of VALUES — the current one is focused and choosing - * one is the way out, so it needs no Close. `menu` is a genuine action popup (a path's Browse/Clear, the - * list editor): it gets a Close entry appended and opens focused on it, which is the rule every action - * stack in this launcher follows. - */ -interface MenuLevel { - readonly kind: 'select' | 'menu'; - readonly title: string; - readonly entries: readonly MenuEntry[]; - focus: number; - /** - * What X does on this level, if anything. Only the track list claims it (auditioning the focused - * track): everywhere else X still means nothing inside a menu and says so with the dead-end sound. - */ - readonly secondary?: (index: number) => void; -} - -interface MenuEntry { - readonly label: string; - /** Marks the value a dropdown currently holds (underlined, like the Settings dropdown). */ - readonly current?: boolean; - /** Which sound this entry makes. One runner plays it, so a press and a click sound identical. - * 'none' is for an entry whose own surface speaks for it — opening the file browser or the lightbox, - * where the primitive plays popup-open (Р5). */ - readonly sound?: SfxName | 'none'; - readonly run: () => void; -} - export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSettingsScreen { const app = req('app'); const screen = req('game-settings'); @@ -325,10 +262,6 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett const titleEl = req('game-settings-title'); const menuEl = req('game-settings-options'); const menuListEl = req('game-settings-options-list'); - const menuVeil = menuEl.querySelector('.settings-options-veil'); - const lightboxEl = req('lightbox'); - const lightboxImage = req('lightbox-image'); - const lightboxCaption = req('lightbox-caption'); const sourceEl = req('game-settings-source'); const t = (): Translator => deps.getTranslator(); @@ -357,7 +290,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett * reach: * * `media` — the ordinary case: a real root (a card, or the PC library) plus the content signature - * it was read against, which is the swap guard every save is checked against (Р6.2). + * it was read against, which is the swap guard every save is checked against. * `history` — a game whose card is NOT in. There is no root to browse, no signature to guard and no * file to write; everything is addressed by the game's ID instead, and the edits are * stored on this PC until that card comes back (see history-config.ts). @@ -386,7 +319,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett let slotIndex = -1; /** * A SECOND, PARALLEL set of "which file, which slot" — active only while moving a local game onto a - * card (Р2.2). The screen still works against one file at a time, but which one flips: `currentText` / + * card. The screen still works against one file at a time, but which one flips: `currentText` / * `runValidate` / `canSave` all read `pendingMove` first and fall back to `origin`/`slots`/`slotIndex` * only when it is null. Nothing is written to disk while this is set — see beginMove/adoptMoveTarget. */ @@ -428,7 +361,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett /** * The problems that were ALREADY in the other games' slots when the screen opened. Save is allowed * while they are there — the file is not ours to fix from a per-game screen, and a game that failed to - * resolve is not even in the carousel — but a NEW one means we introduced it (see the plan, Э4). + * resolve is not even in the carousel — but a NEW one means we introduced it. */ let baselineOtherIssues: ReadonlySet = new Set(); /** @@ -441,16 +374,6 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett let status: string | null = null; let model: GameSettingsModel | null = null; - /** The rows of the SELECTED section only — the pane shows one section at a time. */ - let rendered: readonly RenderedGameRow[] = []; - let focusIndex = 0; - /** Which titled section the pane is showing, by its translation key. */ - let sectionKey: MessageKey | null = null; - /** …and which one the pane is actually showing. The two differ for as long as a preview is pending. */ - let paneKey: MessageKey | null = null; - let validateTimer = 0; - /** Guards a late answer from a validation whose text is already stale. */ - let validateToken = 0; /** * Whether a card is plugged in for "Move to card…" to reach. Starts false: the row is offered inert * until the first listing says otherwise, which is the honest order — an item that looks pressable @@ -459,14 +382,76 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett let moveTargets = false; let moveTargetsTimer = 0; - const menuStack: MenuLevel[] = []; - let menuButtons: readonly HTMLButtonElement[] = []; - /** The artwork viewer is the topmost surface of all — a look at a picture, closed by B or the veil. */ - let lightboxOpen = false; const listScroller = createScroller(listEl); - const menuScroller = createScroller(menuListEl); const hover = createHoverGuard(); + /** The column menu — a row's dropdown, a path's actions, the list editor (menu-stack.ts). */ + const menu = createMenuStack({ + audio: deps.audio, + screen, + menuEl, + listEl: menuListEl, + hover, + closeLabel: () => t()('launcher.menu.close'), + onLeave: () => onlineFlow.stop(), + }); + /** The artwork viewer and the thumbnail strips (asset-lightbox.ts); where a path is READ from is answered here. */ + const lightbox = createAssetLightbox({ + audio: deps.audio, + locate: (path) => { + // A history game's files are not on any root the renderer can name: what is staged sits in the app's + // own storage, and the rest exists only as the copy the history keeps (see historyAssetPreview). + const id = historyId(); + if (id !== null) { + return { key: `history:${id} ${path}`, read: () => deps.api.historyAssetPreview(id, path) }; + } + const at = assetPreviewRoot(path); + if (at === null) return null; + return { + key: `${at.root} ${at.relative}`, + read: () => deps.api.imagePreview(at.root, at.relative), + }; + }, + }); + /** The debounced whole-file validation (manifest-validation.ts); the request is assembled in runValidate. */ + const validator = createManifestValidator({ + validate: (root, text, source) => deps.api.validate(root, text, source), + getTranslator: () => deps.getTranslator(), + onDue: () => void runValidate(), + }); + /** The list rows' editor — its own levels of the column menu (list-editor.ts). */ + const listEditor = createListEditor({ + menu, + keyboard: deps.keyboard, + getTranslator: () => deps.getTranslator(), + browse: (id, current, multi, onPicked) => browseInto(id, current, multi, onPicked), + showImage: (path) => void lightbox.show(path), + setList, + rowTitle, + }); + /** The "Find online" half that touches this screen: the query, the downloads, the fields (online-flow.ts). */ + const onlineFlow = createOnlineFlow({ + api: deps.api, + onlinePicker: deps.onlinePicker, + keyboard: deps.keyboard, + getTranslator: () => deps.getTranslator(), + isOpen: () => open, + form: () => form, + // Mirrors browseInto's choice of root: a pending move is already about the TARGET card, so the + // assets belong there too. A history game downloads nothing — there is no game root to put a file + // beside, which is why the online surface offers it the TEXT only. + assetRoot: () => (pendingMove !== null ? pendingMove.target.root : (mediaOrigin()?.root ?? null)), + isHistoryGame: () => historyId() !== null, + setField, + setList, + mergeRest: (known) => { + // `rest` is the screen's own slot for keys the form model has no field for; currentText() folds it + // back into the manifest text, so this alone makes the screen dirty and Save carries it through. + rest = { ...rest, ...known }; + updateForm(form); + }, + requestTitleConfirm: (title) => deps.onConfirmRequested('replace-title', { title }), + }); /** * The section column. It carries this screen's actions too — Save, Discard edits, Delete, Close — @@ -476,16 +461,39 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett const sidebar = createSidebar(navEl, { audio: deps.audio, onSection: (id, entered) => { - sectionKey = id; + core.selectSection(id); if (entered) { - enterPane(); + core.enterPane(); return; } - schedulePreview(); + core.schedulePreview(); }, onAction: (id) => runAction(id), }); + /** The row focus, the column ⇄ pane steps and the delayed section preview — shared with Settings. */ + const core = createListScreenCore({ + audio: deps.audio, + listEl, + sidebar, + scroller: listScroller, + hover, + isFocusable, + renderPane: () => renderPane(), + stepRow: (row, delta) => { + if (row.kind === 'select') { + cycleSelect(row, delta); + return true; + } + if (row.kind === 'number') { + stepNumber(row, delta); + return true; + } + return false; + }, + onLeavePane: () => menu.close(), + }); + // ── Form state ───────────────────────────────────────────────────────────── /** @@ -523,7 +531,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett // Deleting a history game would mean deleting it from a card that is not here. "Remove from history" // in the carousel menu is the action that DOES apply there, and it is a different thing entirely. const at = mediaOrigin(); - if (at === null || deps.isBusy()) return false; + if (at === null || deps.isBusy(gameId)) return false; return at.source === 'pc' ? true : slots.length >= 2; } @@ -532,7 +540,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett function canMove(): boolean { if (pendingMove !== null) return false; const at = mediaOrigin(); - if (at === null || deps.isBusy()) return false; + if (at === null || deps.isBusy(gameId)) return false; return at.source === 'pc'; } @@ -567,7 +575,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett // A write of this screen's is in flight. Nothing here is idempotent — main's swap guard rejects the // second Save of the same signature AFTER the first has already landed, so the user is shown an error // for a save that worked — and Add/Move remove things the retry then cannot find. - if (writing) return false; + if (writing || deps.isBusy(gameId)) return false; const move = pendingMove; if (move !== null) { if (unreadable !== null) return false; @@ -600,7 +608,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett ? move.target.label : (sources.find((candidate) => candidate.root === at)?.label ?? null), // While a move is pending the form is edited AS THE TARGET CARD would read it — the whole point of - // "the form expands" (see the plan, Р2.2/Р2.3): rows, launch modes and pickers all key off this. + // "the form expands": rows, launch modes and pickers all key off this. // A history game is a card's game by definition — its manifest came off one. source: move !== null || media === null ? 'card' : media.source, platform: where.platform, @@ -630,10 +638,6 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett // ── Rendering ────────────────────────────────────────────────────────────── - function rowsOf(next: GameSettingsModel): readonly GameSettingsRow[] { - return next.sections.flatMap((section) => section.rows); - } - /** Whether two models describe the same rows in the same order (a patch is enough when they do). */ function sameComposition(a: GameSettingsModel, b: GameSettingsModel): boolean { const ids = (m: GameSettingsModel): string => @@ -643,24 +647,13 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett return ids(a) === ids(b); } - /** Every artwork path the screen currently shows, so a patch can tell whether the strips are stale. */ - function artworkSignature(from: GameSettingsModel): string { - return rowsOf(from) - .map((row) => { - if (row.kind === 'list' && row.preview !== undefined) return row.items.join(','); - if (row.kind === 'path' && row.preview !== undefined) return row.value; - return ''; - }) - .join('|'); - } - function renderMessage(text: string): void { listEl.replaceChildren(); const line = document.createElement('div'); line.className = 'settings-section-title'; line.textContent = text; listEl.append(line); - rendered = []; + core.setRendered([]); } /** How long the staggered row entrance runs — the marks come off once it is over. */ @@ -670,64 +663,6 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett /** The one-shot entrance (see .setting-row.is-entering in styles.css, and entrance.ts for the shape). */ const entrance = createEntrance(listEl, '.setting-row', ENTRANCE_MS); - /** - * How long the pane waits before showing the section the column moved onto. A held direction walks - * through the column faster than that, so the pane is drawn ONCE, when the movement stops, instead of - * being torn down and rebuilt — thumbnails and all — at every step. - */ - const PREVIEW_MS = 120; - let previewTimer = 0; - - function schedulePreview(): void { - if (previewTimer !== 0) window.clearTimeout(previewTimer); - previewTimer = window.setTimeout(() => { - previewTimer = 0; - renderPane(); - }, PREVIEW_MS); - } - - /** - * Brings the pane up to date with the selected section NOW, cancelling a pending preview. Anything that - * reads the rendered rows has to call this first — including the paths that never scheduled a preview - * at all: a MOUSE click on a section activates it without ever moving onto it, and that used to leave - * the focus stepping into the section the pane was showing before. - */ - function flushPreview(): void { - if (previewTimer !== 0) { - window.clearTimeout(previewTimer); - previewTimer = 0; - } - if (paneKey !== sectionKey) renderPane(); - } - - /** A section that HAS a title — i.e. one the column can name and the pane can show. */ - interface TitledSection { - readonly titleKey: MessageKey; - readonly rows: readonly GameSettingsRow[]; - } - - function titledSections(from: GameSettingsModel): readonly TitledSection[] { - return from.sections.flatMap((section) => { - const key = section.titleKey; - return key === undefined ? [] : [{ titleKey: key, rows: section.rows }]; - }); - } - - function currentSection(from: GameSettingsModel): TitledSection | undefined { - const titled = titledSections(from); - return titled.find((section) => section.titleKey === sectionKey) ?? titled[0]; - } - - /** The rows that are NOT in any titled section: this screen's actions and its notes. */ - function trailingRows(from: GameSettingsModel): readonly GameSettingsRow[] { - return from.sections.filter((s) => s.titleKey === undefined).flatMap((s) => s.rows); - } - - /** - * The column: the sections, then the actions. The NOTES that share the model's last section stay out - * of it — they are the screen's own feedback (what the last save did, why Save is unavailable), so - * they go under both columns where they are readable from anywhere. - */ /** * What the column WOULD show — so it is only rebuilt when that actually changed. `null` means "nothing * is known about what is on screen", which is NOT the same as "it is empty": a re-opened screen that @@ -736,7 +671,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett let columnSignature: string | null = null; function renderColumn(from: GameSettingsModel): void { - const entries = columnEntries(from); + const entries = columnEntries(from, t()); const signature = entries .map((entry) => `${entry.id}:${entry.label}:${entry.disabled === true ? '1' : '0'}`) .join('|'); @@ -751,64 +686,28 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett // sidebar's own fallback is its first entry, and it reports that to nobody, so the cursor ends up // naming one section while the pane still shows another. Put it back on the section actually on // screen: `select` only moves the cursor (no onSection), which is the point — the pane must not move. - if ( - selectedBefore !== undefined && - sidebar.selected()?.id !== selectedBefore && - paneKey !== null - ) { - sidebar.select(paneKey); + const shown = core.paneKey(); + if (selectedBefore !== undefined && sidebar.selected()?.id !== selectedBefore && shown !== null) { + sidebar.select(shown); } } - function columnEntries(from: GameSettingsModel): readonly SidebarEntry[] { - return [ - ...titledSections(from).map((section) => ({ - id: section.titleKey, - label: t()(section.titleKey), - kind: 'section' as const, - })), - ...trailingRows(from).flatMap((row) => - row.kind === 'action' - ? [ - { - id: row.id, - label: rowLabelText(row.label, t()), - kind: 'action' as const, - ...(row.danger === true ? { danger: true } : {}), - ...(row.disabled === true ? { disabled: true } : {}), - }, - ] - : [], - ), - ]; - } - /** The same, for the status strip — and the same reason for the null. */ let statusSignature: string | null = null; /** The notes, under both columns. */ function renderStatus(from: GameSettingsModel): void { - const notes = trailingRows(from).flatMap((row) => (row.kind === 'note' ? [row] : [])); + const notes = statusNotes(from); const signature = notes.map((note) => `${note.tone}:${rowLabelText(note.text, t())}`).join('|'); if (signature === statusSignature) return; statusSignature = signature; - statusEl.replaceChildren( - ...notes.map((note) => { - const el = document.createElement('div'); - el.className = `setting-row setting-row-note is-inert is-${note.tone}`; - const text = document.createElement('div'); - text.className = 'setting-note-text'; - text.textContent = rowLabelText(note.text, t()); - el.append(text); - return el; - }), - ); + statusEl.replaceChildren(...notes.map((note) => buildStatusNote(note, t()))); } function render(): void { // A pending preview means `rendered` belongs to the section BEFORE the one sectionKey now names — // patching it against the new section's values would write them into the old section's rows. - flushPreview(); + core.flushPreview(); const next = currentModel(); titleEl.textContent = t()( mode === 'add' ? 'gameSettings.addTitle' : 'gameSettings.screenTitle', @@ -832,10 +731,10 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett // screen's, whose entries only change when a section appears. renderColumn(next); renderStatus(next); - if (previous !== null && sameComposition(previous, next) && rendered.length > 0) { + if (previous !== null && sameComposition(previous, next) && core.rendered().length > 0) { const rows = visibleRows(next); const artworkChanged = artworkSignature(previous) !== artworkSignature(next); - rendered.forEach((row, index) => { + core.rendered().forEach((row, index) => { const nextRow = rows[index]; if (nextRow !== undefined) patchGameRow(row, nextRow, t()); }); @@ -850,52 +749,31 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett /** The rows the pane currently shows — one section's worth. */ function visibleRows(from: GameSettingsModel): readonly GameSettingsRow[] { - return currentSection(from)?.rows ?? []; + return sectionByKey(from.sections, core.sectionKey())?.rows ?? []; } function renderPane(): void { const from = model; if (from === null) return; - const section = currentSection(from); + const section = sectionByKey(from.sections, core.sectionKey()); if (section === undefined) return; - sectionKey = section.titleKey; - paneKey = section.titleKey; + core.showSection(section.titleKey); // WITHOUT its title: the column beside it already names the section, and printing the name again at // the top of the pane says the same thing twice. - rendered = renderGameSettings( - listEl, - { ...from, sections: [{ rows: section.rows }] }, - t(), - ).rows; - rendered.forEach((row, at) => + core.setRendered( + renderGameSettings(listEl, { ...from, sections: [{ rows: section.rows }] }, t()).rows, + ); + core.rendered().forEach((row, at) => row.el.style.setProperty('--row-index', String(Math.min(at, ENTRANCE_STEPS))), ); entrance.play(); - focusIndex = nearestFocusable(focusIndex, 1); - applyRowFocus(true); + core.seatFocus(); + core.applyRowFocus(true); listScroller.to(0, true); requestAnimationFrame(() => listScroller.fades()); void refreshThumbnails(); } - /** Hands the focus from the column to the pane, at its first focusable row. */ - function enterPane(): void { - flushPreview(); // whatever the column last moved onto is what the focus is stepping into - if (rendered.length === 0) return; - sidebar.setFocused(false); - focusIndex = nearestFocusable(0, 1); - hover.arm(); - applyRowFocus(); - } - - /** …and back. The column is the only place the screen can be left from. */ - function leavePane(): void { - closeMenus(); - sidebar.setFocused(true); - hover.arm(); - applyRowFocus(); - } - /** * The state for a slot the form cannot show at all. Sending the user off to "edit game.json by hand" is * no answer — in Game Mode on a Deck that means "you cannot" — and the JSON tab the old window fell @@ -911,368 +789,31 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett title.textContent = t()('gameSettings.slotUnreadable', { message: unreadable ?? '' }); section.append(title); listEl.append(section); - rendered = []; + core.setRendered([]); } - /** - * The thumbnails read so far, by path. Stepping back onto a section re-renders its rows, and reading - * every picture off the disk again for a strip that has not changed is both a round trip per image and - * a visible re-decode. Emptied on open, so a screen re-opened after the files moved starts fresh. - */ - const thumbnails = new Map(); - - /** Cache key includes the root: the same card-relative STRING can name different bytes in the PC - * library and on a move's target card (see assetPreviewRoot), and thumbnails must not conflate them. */ - async function thumbnailFor(root: string, path: string): Promise { - // A history game has no root, so its cache namespace is the game itself — otherwise every history - // game would share the '' key and serve each other's covers. - const id = historyId(); - const key = id !== null ? `history:${id} ${path}` : `${root} ${path}`; - const cached = thumbnails.get(key); - if (cached !== undefined) return cached; - const url = id !== null ? await deps.api.historyAssetPreview(id, path) : await deps.api.imagePreview(root, path); - thumbnails.set(key, url); - return url; - } - - /** Reads the artwork rows' thumbnails (one invoke per path) and drops them into their rows. */ + /** The artwork rows' thumbnails, for the pane as it stands (see asset-lightbox.ts). */ async function refreshThumbnails(): Promise { if (origin === null) return; - // Card-relative during a pending move (see assetPreviewRoot): a hero/grid image the move carried - // over unedited previews from the PC library, everything else from wherever the form is pointed. - // A history game's assets are addressed by id inside thumbnailFor; the path travels unchanged. - const forHistory = historyId() !== null; - const thumbnailAt = ( - path: string, - ): { readonly root: string; readonly relative: string } | null => - forHistory ? { root: '', relative: path } : assetPreviewRoot(path); - for (const row of rendered) { - const source = row.row; - if (source.kind === 'list' && source.preview !== undefined) { - const urls = await Promise.all( - source.items.map((item) => { - const at = thumbnailAt(item); - return at === null ? Promise.resolve(null) : thumbnailFor(at.root, at.relative); - }), - ); - applyThumbnails(row, urls, source.preview, source.items); - } else if (source.kind === 'path' && source.preview !== undefined) { - const at = source.value === '' ? null : thumbnailAt(source.value); - const url = at === null ? null : await thumbnailFor(at.root, at.relative); - applyThumbnails(row, [url], source.preview, [source.value]); - } - } - } - - // ── Focus ────────────────────────────────────────────────────────────────── - - /** The nearest focusable row at or after `index`, searching in `direction`; falls back to any. */ - function nearestFocusable(index: number, direction: number): number { - if (rendered.length === 0) return 0; - const start = Math.min(Math.max(index, 0), rendered.length - 1); - for (let i = start; i >= 0 && i < rendered.length; i += direction) { - const row = rendered[i]; - if (row !== undefined && isFocusable(row.row)) return i; - } - for (let i = start; i >= 0 && i < rendered.length; i -= direction) { - const row = rendered[i]; - if (row !== undefined && isFocusable(row.row)) return i; - } - return start; - } - - function applyRowFocus(instant = false): void { - const active = !sidebar.hasFocus(); - // The pane widens to the left while it holds the focus (see .settings-list in styles.css). - listEl.classList.toggle('is-active', active); - rendered.forEach((row, index) => - row.el.classList.toggle('is-focused', active && index === focusIndex), - ); - if (!active) return; - const target = rendered[focusIndex]; - if (target === undefined) return; - listScroller.reveal(target.el, instant); - } - - /** Steps to the next FOCUSABLE row, walking past the notes and static lines in between. */ - function moveRowFocus(delta: number): void { - if (rendered.length === 0) return; - let next = focusIndex; - for (;;) { - const stepped = clampIndex(next, delta, rendered.length); - if (stepped === next) { - deps.audio.playLimit(); // at the edge: no move, and the dead end says so - return; - } - next = stepped; - const row = rendered[next]; - if (row !== undefined && isFocusable(row.row)) break; - } - focusIndex = next; - deps.audio.play('navigate'); - applyRowFocus(); - } - - function pressFlash(el: HTMLElement): void { - el.classList.add('is-pressed'); - window.setTimeout(() => el.classList.remove('is-pressed'), PRESS_MS); - } - - // ── The column menu (expanded dropdown / row actions / list editing) ──────── - - function menuTop(): MenuLevel | undefined { - return menuStack[menuStack.length - 1]; - } - - function paintMenu(): void { - const level = menuTop(); - if (level === undefined) { - menuButtons = []; - menuListEl.replaceChildren(); - screen.classList.remove('is-options-open'); - menuEl.classList.remove('is-open'); - menuEl.setAttribute('aria-hidden', 'true'); - return; - } - const buttons = level.entries.map((entry, index) => { - const button = document.createElement('button'); - button.type = 'button'; - button.className = 'settings-option'; - button.append(optionLabelNode(entry.label)); - button.classList.toggle('is-current', entry.current === true); - button.addEventListener('click', () => { - pressFlash(button); - level.focus = index; - runEntry(entry); - }); - return button; - }); - menuButtons = buttons; - menuListEl.replaceChildren(...buttons); - screen.classList.add('is-options-open'); - menuEl.classList.add('is-open'); - menuEl.setAttribute('aria-hidden', 'false'); - // Measured synchronously: reading clientWidth flushes the layout for the nodes just inserted, which - // a requestAnimationFrame callback would only reach on the next frame — and never at all in a window - // that is not painting. - updateMenuMarquee(); - applyMenuFocus(true); - } - - /** - * Marks every entry whose label does not fit as clipped (a soft fade at the cut) and scrolls the - * FOCUSED one. The labels here are paths and file names, so most of them will not fit — cutting them - * would leave the user choosing between three items that all read the same. - */ - function updateMenuMarquee(): void { - const first = menuButtons[0]?.querySelector('.settings-option-clip'); - if (first !== null && first !== undefined && first.clientWidth === 0) { - requestAnimationFrame(() => updateMenuMarquee()); - return; - } - for (const button of menuButtons) { - const clip = button.querySelector('.settings-option-clip'); - const text = button.querySelector('.settings-option-text'); - if (clip === null || text === null) continue; - const overflow = text.scrollWidth - clip.clientWidth; - const clipped = overflow > 1; - button.classList.toggle('is-clipped', clipped); - if (clipped && button.classList.contains('is-focused')) { - text.style.setProperty('--marquee-shift', `${-overflow}px`); - text.style.setProperty( - '--marquee-duration', - `${Math.max(2, overflow / (MARQUEE_SPEED_PX_PER_S * pxUnit()))}s`, - ); - button.classList.add('is-scrolling'); - } else { - button.classList.remove('is-scrolling'); - text.style.removeProperty('--marquee-shift'); - text.style.removeProperty('--marquee-duration'); - } - } - } - - /** Plays an entry's sound exactly once, then runs it. The only way an entry is ever triggered. */ - function runEntry(entry: MenuEntry): void { - if (entry.sound !== 'none') deps.audio.play(entry.sound ?? 'button'); - entry.run(); - } - - function applyMenuFocus(instant = false): void { - const level = menuTop(); - if (level === undefined) return; - menuButtons.forEach((button, index) => - button.classList.toggle('is-focused', index === level.focus), - ); - const focused = menuButtons[level.focus]; - if (focused !== undefined) menuScroller.reveal(focused, instant); - updateMenuMarquee(); // only the focused label moves - } - - /** - * Appends the Close entry an action popup ends with, and points the focus at it. Same shape as every - * popup stack in the launcher: the way out is the default, and it is at the bottom where the thumb is. - */ - function asMenu(level: { - readonly title: string; - readonly entries: readonly MenuEntry[]; - readonly secondary?: (index: number) => void; - }): MenuLevel { - const entries: MenuEntry[] = [ - ...level.entries, - { label: t()('launcher.menu.close'), sound: 'none', run: () => popMenu() }, - ]; - return { - kind: 'menu', - title: level.title, - entries, - focus: entries.length - 1, - ...(level.secondary === undefined ? {} : { secondary: level.secondary }), - }; - } - - function pushMenu(level: MenuLevel): void { - hover.arm(); - // Only the FIRST level is a surface appearing; going deeper is a step inside one already open (Р4). - if (menuStack.length === 0) deps.audio.play('popup-open'); - menuStack.push(level); - paintMenu(); - } - - /** - * The single voice of leaving a level, so every way out (B, left, the Close entry, the veil) sounds the - * same: stepping out of a deeper level is a step INSIDE the menu and keeps `back`; leaving the last one - * is the menu going away. - */ - /** - * `keepWork` is for a level the SCREEN closes because it is done with it — a question that has just - * been answered — rather than one the user backed out of. Leaving is normally the signal to abandon - * whatever was running, and the answer to a question is immediately followed by acting on it: aborting - * there would cancel the very download the answer just asked for. - */ - function popMenu(options?: { readonly keepWork?: boolean }): void { - if (menuStack.length > 0) deps.audio.play(menuStack.length > 1 ? 'back' : 'popup-close'); - menuStack.pop(); - // Leaving a level ends whatever it had running: an audition belongs to the track list it was - // started from, and a download the user has walked away from has nobody left to arrive for. - if (options?.keepWork !== true) stopMetadataWork(); - paintMenu(); - } - - function closeMenus(options?: { readonly silent?: boolean }): void { - // `silent` for a cascade — the screen closing, or a surface that already played its own close (Р5). - if (menuStack.length > 0 && options?.silent !== true) deps.audio.play('popup-close'); - menuStack.length = 0; - stopMetadataWork(); - paintMenu(); - } - - /** Replaces the top level in place — used after an edit so the list the user is in stays current. */ - function replaceMenu(level: MenuLevel): void { - menuStack.pop(); - menuStack.push(level); - paintMenu(); + await lightbox.refreshThumbnails(core.rendered()); } // ── Field editing ────────────────────────────────────────────────────────── /** Writes one field of the form model by row id. Everything a row can change goes through here. */ function setField(id: GameRowId, value: string): void { - switch (id) { - case 'title': { - // The id follows the title until the user takes the id over, exactly as the old form did: a slug - // is a good first guess and a terrible override. - const slug = slugifyTitle(value); - const followed = form.id === '' || form.id === slugifyTitle(form.title); - updateForm({ ...form, title: value, ...(followed ? { id: slug } : {}) }); - return; - } - case 'id': - // Lower case wherever it comes from, so the field agrees with the slug a title proposes — the - // keyboard already refuses to type anything else (osk.ts). - updateForm({ ...form, id: value.toLowerCase() }); - return; - case 'executable': - updateForm({ ...form, executable: value }); - return; - case 'pc.executable': - updateForm({ ...form, pc: { ...form.pc, executable: value } }); - return; - case 'install.installer': - updateForm({ ...form, install: { ...form.install, installer: value } }); - return; - case 'copyInstall.installer': - updateForm({ ...form, copyInstall: { ...form.copyInstall, installer: value } }); - return; - case 'steam.appid': - updateForm({ ...form, steam: { ...form.steam, appid: value } }); - return; - case 'gridImage': - updateForm({ ...form, gridImage: value }); - return; - case 'saveOnCard': - updateForm({ ...form, saveOnCard: value }); - return; - case 'pcSavePath': - updateForm({ ...form, pcSavePath: value }); - return; - case 'backgroundMusic': - updateForm({ ...form, backgroundMusic: value }); - return; - case 'launchTimeoutSec': - updateForm({ ...form, launchTimeoutSec: value }); - return; - case 'killTimeoutSec': - updateForm({ ...form, killTimeoutSec: value }); - return; - case 'umuGameId': - updateForm({ ...form, umuGameId: value }); - return; - default: - return; - } + const next = withField(form, id, value, mode === 'add'); + if (next !== form) updateForm(next); } function setList(id: GameRowId, items: readonly string[]): void { - switch (id) { - case 'args': - updateForm({ ...form, args: items }); - return; - case 'watchProcesses': - updateForm({ ...form, watchProcesses: items }); - return; - case 'heroImage': - updateForm({ ...form, heroImage: items }); - return; - case 'winetricks': - updateForm({ ...form, winetricks: items }); - return; - case 'install.args': - updateForm({ ...form, install: { ...form.install, args: items } }); - return; - case 'install.winetricks': - updateForm({ ...form, install: { ...form.install, winetricks: items } }); - return; - default: - return; - } + const next = withList(form, id, items); + if (next !== form) updateForm(next); } function toggleField(id: GameRowId): void { - switch (id) { - case 'runAsAdmin': - updateForm({ ...form, runAsAdmin: !form.runAsAdmin }); - return; - case 'copyToPc': - updateForm({ ...form, copyToPc: !form.copyToPc }); - return; - case 'install.runAsAdmin': - if (form.install.type === 'custom') return; // forced off — the manifest forbids the pair - updateForm({ ...form, install: { ...form.install, runAsAdmin: !form.install.runAsAdmin } }); - return; - default: - return; - } + const next = withToggle(form, id); + if (next !== form) updateForm(next); } function setSelect(id: GameRowId, value: string): void { @@ -1320,21 +861,11 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett void adoptRoot(root); } - /** The title's slug, in the same shape configure-form-model's slugifyId produces. */ - function slugifyTitle(title: string): string { - return title - .normalize('NFKD') - .replace(/[̀-ͯ]/g, '') - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); - } - // ── Row activation ───────────────────────────────────────────────────────── function openSelectMenu(row: Extract): void { const options: readonly CoreOption[] = row.options; - pushMenu({ + menu.push({ kind: 'select', title: '', focus: Math.max( @@ -1345,7 +876,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett label: optionLabel(option, t()), current: option.value === row.value, run: () => { - closeMenus(); + menu.close(); setSelect(row.id, option.value); }, })), @@ -1371,7 +902,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett function stepNumber(row: Extract, delta: number): void { const parsed = Number.parseInt(row.value, 10); - const base = Number.isFinite(parsed) ? parsed : 0; + const base = Number.isFinite(parsed) ? parsed : (row.fallback ?? 0); const next = Math.min(row.max, Math.max(row.min, base + delta * row.step)); if (String(next) === row.value) { deps.audio.playLimit(); // already at min / max @@ -1402,7 +933,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett if (row.value !== '' && row.preview !== undefined) { entries.push({ label: t()('gameSettings.viewImage'), - run: () => void showImage(row.value), + run: () => void lightbox.show(row.value), }); } entries.push({ @@ -1413,12 +944,12 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett entries.push({ label: t()('gameSettings.clear'), run: () => { - closeMenus(); + menu.close(); setField(row.id, ''); }, }); } - pushMenu(asMenu({ title: rowTitle(row), entries })); + menu.push(menu.asMenu({ title: rowTitle(row), entries })); } /** @@ -1440,43 +971,6 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett return media === null ? null : { root: media.root, relative }; } - /** Opens the artwork at full size. Nothing but a look — B (or the veil) closes it. */ - async function showImage(relative: string): Promise { - if (relative === '') return; - // A history game's files are not on any root the renderer can name: what is staged sits in the app's - // own storage, and the rest exists only as the copy the history keeps (see historyAssetPreview). - const id = historyId(); - const url = - id !== null ? await deps.api.historyAssetPreview(id, relative) : await mediaImage(relative); - if (url === null) return; // a preview that could not be read never became a surface — and never sounds - openLightbox(url, relative); - } - - async function mediaImage(relative: string): Promise { - const at = assetPreviewRoot(relative); - if (at === null) return null; - return deps.api.imagePreview(at.root, at.relative); - } - - /** The lightbox itself, shared by a file already on the card and a variant still only online. */ - function openLightbox(url: string, caption: string): void { - deps.audio.play('popup-open'); - lightboxImage.src = url; - lightboxCaption.textContent = caption; - lightboxOpen = true; - lightboxEl.classList.add('is-open'); - lightboxEl.setAttribute('aria-hidden', 'false'); - } - - function closeImage(options?: { readonly silent?: boolean }): void { - if (!lightboxOpen) return; - if (options?.silent !== true) deps.audio.play('popup-close'); - lightboxOpen = false; - lightboxEl.classList.remove('is-open'); - lightboxEl.setAttribute('aria-hidden', 'true'); - lightboxImage.removeAttribute('src'); - } - /** * Opens the file browser for a field and writes what it picked back into the form. * @@ -1518,10 +1012,10 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett if (!result.ok) { if (!('cancelled' in result)) failWith(result.message); // Cancelled (or refused): the popup is still up, and the focus goes back to it. - applyMenuFocus(); + menu.applyFocus(); return; } - closeMenus({ silent: true }); // the browser's own popup-close already covered this gesture + menu.close({ silent: true }); // the browser's own popup-close already covered this gesture if (onPicked !== undefined) { onPicked(result.paths); return; @@ -1547,137 +1041,10 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett return form.copyInstall.installer === '' ? null : form.copyInstall.installer; } - // ── List editing (its own level of the column menu) ───────────────────────── - - function openListMenu(row: Extract): void { - pushMenu(buildListLevel(row.id, row.items, row.max, row.preview !== undefined, rowTitle(row))); - } - - function buildListLevel( - id: GameRowId, - items: readonly string[], - max: number, - isPath: boolean, - title: string, - ): MenuLevel { - const entries: MenuEntry[] = items.map((item, index) => ({ - label: item, - run: () => openItemMenu(id, items, index, max, isPath, title), - })); - if (max === 0 || items.length < max) { - entries.push({ - label: t()('gameSettings.listAdd'), - run: () => { - if (isPath) { - browseInto(id, '', max !== 1, (paths) => { - const room = max === 0 ? paths.length : Math.max(0, max - items.length); - setList(id, [...items, ...paths.slice(0, room)]); - }); - return; - } - deps.keyboard.open({ - value: '', - mode: 'text', - title, - onDone: (value) => { - if (value.trim() === '') return; - const next = [...items, value]; - setList(id, next); - replaceMenu(buildListLevel(id, next, max, isPath, title)); - }, - }); - }, - }); - } - return asMenu({ title, entries }); - } - - function openItemMenu( - id: GameRowId, - items: readonly string[], - index: number, - max: number, - isPath: boolean, - title: string, - ): void { - const commit = (next: readonly string[]): void => { - setList(id, next); - // Back to the list itself, refreshed — the user is usually not done after one change. - menuStack.pop(); - replaceMenu(buildListLevel(id, next, max, isPath, title)); - }; - const entries: MenuEntry[] = []; - if (isPath) { - entries.push({ - label: t()('gameSettings.viewImage'), - run: () => void showImage(items[index] ?? ''), - }); - } - entries.push({ - label: t()('gameSettings.listReplace'), - run: () => { - if (isPath) { - browseInto(id, items[index] ?? '', false, (paths) => { - const picked = paths[0]; - if (picked === undefined) return; - setList( - id, - items.map((item, i) => (i === index ? picked : item)), - ); - }); - return; - } - deps.keyboard.open({ - value: items[index] ?? '', - mode: 'text', - title, - onDone: (value) => { - if (value.trim() === '') return; - commit(items.map((item, i) => (i === index ? value : item))); - }, - }); - }, - }); - // Reordering is a gamepad gesture here, not a drag: the manifest's order is load-bearing (the first - // hero image is the one the carousel crops its card from), and a mouse-only affordance would put that - // out of reach in Game Mode. - if (index > 0) { - entries.push({ - label: t()('gameSettings.listMoveUp'), - run: () => commit(swap(items, index, index - 1)), - }); - } - if (index < items.length - 1) { - entries.push({ - label: t()('gameSettings.listMoveDown'), - run: () => commit(swap(items, index, index + 1)), - }); - } - entries.push({ - label: t()('gameSettings.listRemove'), - run: () => commit(items.filter((_, i) => i !== index)), - }); - pushMenu(asMenu({ title: items[index] ?? '', entries })); - } - - function swap(items: readonly string[], a: number, b: number): readonly string[] { - const next = [...items]; - const first = next[a]; - const second = next[b]; - if (first === undefined || second === undefined) return items; - next[a] = second; - next[b] = first; - return next; - } - // ── Validation ───────────────────────────────────────────────────────────── function scheduleValidate(): void { - if (validateTimer !== 0) window.clearTimeout(validateTimer); - validateTimer = window.setTimeout(() => { - validateTimer = 0; - void runValidate(); - }, VALIDATE_DEBOUNCE_MS); + validator.schedule(); } /** @@ -1692,63 +1059,26 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett // off a card and has to keep validating as one. const root = move !== null ? move.target.root : (media?.root ?? ''); if (origin === null || unreadable !== null) return; - const index = move !== null ? move.targetIndex : slotIndex; - const activeSlots = move !== null ? move.targetSlots : slots; - const token = ++validateToken; - const text = currentText(); - const result = await deps.api.validate(root, text, origin.kind === 'history' ? 'card' : undefined); - if (token !== validateToken) return; // a newer edit already asked - const own = new Map(); - const others: string[] = []; - if (!result.ok) { - for (const issue of result.issues) { - const scoped = /^games\.(\d+)\.(.*)$/.exec(issue.path); - if (scoped === null) { - // An unscoped path belongs to the single-game shape — which is ours by definition. - own.set(issue.path, issue.message); - continue; - } - const idx = Number(scoped[1]); - const field = scoped[2] ?? ''; - if (idx === index) own.set(field, issue.message); - else others.push(describeOtherIssue(activeSlots, idx, field, issue.message)); - } - } - issues = own; + const verdict = await validator.run({ + root, + text: currentText(), + ...(origin.kind === 'history' ? { source: 'card' } : {}), + index: move !== null ? move.targetIndex : slotIndex, + slots: move !== null ? move.targetSlots : slots, + }); + if (verdict === null) return; // a newer edit already asked + issues = verdict.own; // Only issues this VISIT introduced block Save. For a game edited from the history the baseline is // its stored manifest, which — on a card written by hand years ago — can fail the editor's stricter // gates all by itself (see manifest.ts). Without this mirror of main's own rule the button would be // dead for such a card and the user would never reach the message explaining why. - ownIssues = [...own].some(([path, message]) => !baselineOwnIssues.has(issueKey(path, message))); - otherIssues = others; + ownIssues = [...verdict.own].some( + ([path, message]) => !baselineOwnIssues.has(issueKey(path, message)), + ); + otherIssues = verdict.others; render(); } - /** How one issue is remembered in a baseline set — path and wording together, as main compares them. */ - function issueKey(path: string, message: string): string { - return `${path}\u0000${message}`; - } - - /** "Hades (game 3): install.args — expected array" — the other game is named when we can name it. */ - function describeOtherIssue( - activeSlots: readonly GameFormState[], - index: number, - field: string, - message: string, - ): string { - const slot = activeSlots[index]; - const title = - slot !== undefined && !isRawSlot(slot) && slot.model.title !== '' - ? slot.model.title - : t()('gameSettings.otherGameUnnamed'); - return t()('gameSettings.otherGameIssue', { - game: title, - number: index + 1, - field: field === '' ? '—' : field, - message, - }); - } - /** * The line under the columns. It now says only what is HAPPENING (a save in flight) — a result that * lives there is a result the user can scroll away from, so those go to the notification plate and the @@ -1839,7 +1169,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett } /** - * "Move to card…" (Р2.1): lists the cards a local game may move to and lets the user pick one. Called + * "Move to card…": lists the cards a local game may move to and lets the user pick one. Called * once `load` has landed — re-checks the source itself, since the menu item's own visibility rule * (controls.ts) can go stale between the press and the read completing. */ @@ -1854,13 +1184,13 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett failWith(t()('gameSettings.moveNoCards')); return; } - pushMenu( - asMenu({ + menu.push( + menu.asMenu({ title: t()('gameSettings.moveToCardTitle'), entries: cards.map((candidate) => ({ label: candidate.label, run: () => { - closeMenus(); + menu.close(); void adoptMoveTarget(candidate); }, })), @@ -1913,7 +1243,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett form = carried; rest = {}; corrupt = {}; - focusIndex = 0; + core.setFocusIndex(0); model = null; render(); await runValidate(); @@ -2009,7 +1339,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett // Exactly as in edit mode: the baseline is the file as the screen would write it RIGHT NOW, so // `dirty` means "the user typed something" rather than "the screen appended an empty game". baseline = currentText(); - focusIndex = 0; + core.setFocusIndex(0); model = null; render(); await runValidate(); @@ -2053,7 +1383,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett mixed = ours.mixed; loadedId = ours.model.id; unreadable = null; - focusIndex = 0; + core.setFocusIndex(0); model = null; // force a full rebuild — the composition is entirely new render(); } @@ -2088,7 +1418,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett baseline = text; // A save while the game is RUNNING writes the file but cannot reload the manifest (the launcher // refuses mid-play). That is not a failure — the file on disk is already right and the launcher picks - // it up on the next read — so it is reported as what it is (see the plan, Р3). + // it up on the next read — so it is reported as what it is. if (result.applied === 'applied') notifyDone(t()('gameSettings.savedApplied')); else if (result.applied === 'deferred') notifyDone(t()('gameSettings.savedDeferred')); else notifyDone(t()('gameSettings.savedNotApplied')); @@ -2096,8 +1426,8 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett } /** - * The Save button while a move is pending (Р2.5) — one IPC, the whole transaction runs in main (see - * GameConfigService.moveToCard). Closes on success exactly like `runAdd`: the game left the PC library, + * The Save button while a move is pending — one IPC, the whole transaction runs in main (see + * GameMoveTransaction.moveToCard). Closes on success exactly like `runAdd`: the game left the PC library, * so there is nothing here to keep editing. `deferred`/a skipped save folder are reported to the user as * NOTIFICATIONS main files itself (game-moved-deferred / game-move-save-skipped), not as screen status — * the screen is already gone by the time either matters. @@ -2225,174 +1555,24 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett void runValidate(); } - // ── "Find online" (the metadata:* flow — see main/metadata/) ─────────────── - // - // The surface itself is online-picker.ts: one screen with the game, the cover, the backgrounds and the - // soundtrack as sections. What lives HERE is the half of it that touches this screen — the keyboard - // for a query, the downloads that land beside the game, and the form fields their paths go into. - // Nothing reaches the manifest until the user saves: an applied file only fills a FORM FIELD, exactly - // as a path chosen in the file browser does. - - /** What a "yes" to the title question runs — the surface's own callback, held until the popup answers. */ - let pendingTitleReplace: (() => void) | null = null; - /** Retires answers belonging to a flow the user has already left (a new search, a closed screen). */ - let metadataToken = 0; - /** Whether an answer from main still belongs to the flow that asked for it. */ - function metadataCurrent(token: number): boolean { - return open && token === metadataToken; - } - - /** - * Where an applied file goes, and under which id it is named. Mirrors browseInto's choice of root: a - * pending move is already about the TARGET card, so the assets belong there too. - */ - function metadataTarget(): { readonly root: string; readonly gameId: string } | null { - const move = pendingMove; - // A history game downloads nothing: there is no game root to put a file beside, which is why the - // online surface offers it the TEXT only (see startFindOnline). - const root = move !== null ? move.target.root : (mediaOrigin()?.root ?? null); - if (root === null) return null; - const id = form.id.trim(); - return id === '' ? null : { root, gameId: id }; - } - - /** - * The entry point. Everything the sources offer lives on ONE surface now (online-picker.ts): the game, - * its cover, its backgrounds and its soundtrack, each a section of the same screen. What stays here is - * what only this screen can do — write into the form, and put the downloaded files beside the game. - * - * A Steam game whose appid is already filled in skips the search: that number is the very thing a - * search exists to find. - */ - function startFindOnline(): void { - metadataToken += 1; - const appId = Number(form.steam.appid.trim()); - const steamApp = form.launchMode === 'steam' && Number.isSafeInteger(appId) && appId > 0; - deps.onlinePicker.open({ - query: form.title.trim(), - ...(steamApp ? { appId } : {}), - // A history game has no root to download a cover into — the text half of the flow still applies. - ...(historyId() !== null ? { textOnly: true } : {}), - }); - } - - /** - * Downloads the chosen variants and writes the resulting manifest paths into the form. - * - * The slot INDEX matters as much as the order: it names the file on disk - * (`assets/-hero-.`), so appending has to start after the backgrounds already there — - * writing from zero would overwrite the very files it is adding to. - */ - async function applyArtwork( - kind: 'grid' | 'hero', - variantKeys: readonly string[], - mode: 'replace' | 'append', - ): Promise { - const target = metadataTarget(); - if (target === null) return { ok: false, message: t()('metadata.needsId') }; - const existing = mode === 'append' ? form.heroImage : []; - const room = kind === 'grid' ? variantKeys.length : MAX_HERO_IMAGES - existing.length; - const accepted = variantKeys.slice(0, Math.max(0, room)); - const token = metadataToken; - const paths: string[] = []; - for (const [index, variantKey] of accepted.entries()) { - const slot: MetadataApplySlot = kind === 'grid' ? 'grid' : { hero: existing.length + index }; - const result = await deps.api.applyMetadata({ ...target, variantKey, slot }); - if (!metadataCurrent(token)) return { ok: false, message: '' }; - if (!result.ok) return { ok: false, message: result.message }; - paths.push(result.path); - } - if (kind === 'grid') { - setField('gridImage', paths[0] ?? ''); - } else { - setList('heroImage', [...existing, ...paths]); - } - // A pick that did not fit says so: silently dropping the third of three chosen backgrounds would - // read as the download having failed. - const dropped = variantKeys.length - accepted.length; - return { - ok: true, - message: - dropped > 0 - ? t()('metadata.appliedPartly', { count: String(dropped) }) - : t()('metadata.applied'), - }; - } - - /** One variant at full size, in the screen's own lightbox (which sits above the gallery). */ - /** - * Fills the manifest's non-picture facts in the background: the description, and the genres, release - * date and platforms a future library view will sort by. main deliberately never writes them itself — - * the manifest TEXT belongs to this form while the screen is open, so a write from the other side - * would be overwritten by the next Save (see configure-form-model.ts and 4.5 of the plan). - */ - async function fetchMetadataDescriptions(candidate: GameCandidate): Promise { - const token = metadataToken; - const result = await deps.api.metadataDescriptions(candidate.key); - if (!metadataCurrent(token) || !result.ok) return; - const { description, genres, releaseDate, platforms } = result.value; - const known = { - ...(description === undefined ? {} : { description }), - ...(genres === undefined ? {} : { genres }), - ...(releaseDate === undefined ? {} : { releaseDate }), - ...(platforms === undefined ? {} : { platforms }), - }; - if (Object.keys(known).length === 0) return; - // `rest` is the screen's own slot for keys the form model has no field for; currentText() folds it - // back into the manifest text, so this alone makes the screen dirty and Save carries it through. - rest = { ...rest, ...known }; - updateForm(form); - } - - async function applyTrackKey(trackKey: string): Promise { - const target = metadataTarget(); - if (target === null) return { ok: false, message: t()('metadata.needsId') }; - const token = metadataToken; - const result = await deps.api.applyMetadata({ - ...target, - variantKey: trackKey, - slot: 'music', - }); - if (!metadataCurrent(token)) return { ok: false, message: '' }; - if (!result.ok) return { ok: false, message: result.message }; - setField('backgroundMusic', result.path); - return { ok: true, message: t()('metadata.applied') }; - } - - /** Everything the flow leaves running, ended in one place: whatever main is still fetching for it. */ - function stopMetadataWork(): void { - metadataToken += 1; - deps.api.cancelMetadata(); - } - // ── The six primitives ───────────────────────────────────────────────────── /** Which surface the primitives drive right now: the deepest open one wins. */ function activeSurface(): NavSurface | 'lightbox' | 'menu' | 'form' { - if (lightboxOpen) return 'lightbox'; + if (lightbox.isOpen()) return 'lightbox'; if (deps.keyboard.isOpen()) return deps.keyboard; if (deps.picker.isOpen()) return deps.picker; if (deps.onlinePicker.isOpen()) return deps.onlinePicker; - if (menuStack.length > 0) return 'menu'; + if (menu.isOpen()) return 'menu'; return 'form'; } - function moveMenuFocus(delta: number): void { - const level = menuTop(); - if (level === undefined || level.entries.length === 0) return; - const next = wrapIndex(level.focus, delta, level.entries.length); - if (next === level.focus) return; - level.focus = next; - deps.audio.play('navigate'); - applyMenuFocus(); - } - function navUp(): void { hover.arm(); const surface = activeSurface(); if (surface === 'lightbox') return deps.audio.playLimit(); // nothing to move in a picture - if (surface === 'menu') return moveMenuFocus(-1); - if (surface === 'form') return sidebar.hasFocus() ? sidebar.move(-1) : moveRowFocus(-1); + if (surface === 'menu') return menu.moveFocus(-1); + if (surface === 'form') return sidebar.hasFocus() ? sidebar.move(-1) : core.moveRowFocus(-1); surface.navUp(); } @@ -2400,37 +1580,11 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett hover.arm(); const surface = activeSurface(); if (surface === 'lightbox') return deps.audio.playLimit(); - if (surface === 'menu') return moveMenuFocus(1); - if (surface === 'form') return sidebar.hasFocus() ? sidebar.move(1) : moveRowFocus(1); + if (surface === 'menu') return menu.moveFocus(1); + if (surface === 'form') return sidebar.hasFocus() ? sidebar.move(1) : core.moveRowFocus(1); surface.navDown(); } - function navHorizontal(delta: number): void { - // From the column, RIGHT steps into the pane. Left is NOT its mirror there: inside the pane it - // belongs to the selects and the number steppers, so leaving is B. - if (sidebar.hasFocus()) { - // As in Settings: left off the column, and right off a row that is not a section, lead nowhere. - if (delta > 0 && sidebar.selected()?.kind === 'section') enterPane(); - else deps.audio.playLimit(); - return; - } - const target = rendered[focusIndex]; - if (target === undefined) return; - const row = target.row; - // A checkbox is NOT stepped through: left/right belong to the rows that have a range to move along - // (the selects, the steppers), and a two-state row answered them by flipping — so a walk across the - // form changed a setting on the way past. A checkbox is switched with A, and only with A. - if (row.kind === 'select') { - cycleSelect(row, delta); - return; - } - if (row.kind === 'number') { - stepNumber(row, delta); - return; - } - deps.audio.playLimit(); // a checkbox, a text or a path row has no range to step along - } - function navLeft(repeat = false): void { hover.arm(); const surface = activeSurface(); @@ -2441,11 +1595,11 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett if (surface === 'menu') { // Left leaves a level, the same way it leaves a popup: the column sits on the right edge, so moving // off it means "out". A HELD left is ignored, or one press would walk out through every level. - if (!repeat) popMenu(); + if (!repeat) menu.pop(); return; } if (surface === 'form') { - navHorizontal(-1); + core.navHorizontal(-1); return; } surface.navLeft(repeat); @@ -2457,7 +1611,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett if (surface === 'lightbox') return deps.audio.playLimit(); if (surface === 'menu') return deps.audio.playLimit(); // a menu is vertical — right leads nowhere if (surface === 'form') { - navHorizontal(1); + core.navHorizontal(1); return; } surface.navRight(); @@ -2500,7 +1654,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett case 'list': deps.audio.play('button'); pressFlash(target.el); - openListMenu(row); + listEditor.open(row); return; case 'action': // Actions live in the column now; a row of this kind should never reach the pane. @@ -2515,7 +1669,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett switch (id) { case 'find-online': deps.audio.play('button'); - startFindOnline(); + onlineFlow.start(); return; case 'save': deps.audio.play('button'); @@ -2553,14 +1707,11 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett hover.arm(); const surface = activeSurface(); if (surface === 'lightbox') { - closeImage(); + lightbox.close(); return; } if (surface === 'menu') { - const level = menuTop(); - const entry = level?.entries[level.focus]; - if (entry === undefined) return; - runEntry(entry); + menu.activate(); return; } if (surface === 'form') { @@ -2568,7 +1719,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett sidebar.activate(); return; } - const target = rendered[focusIndex]; + const target = core.focusedRow(); if (target !== undefined) activateRow(target); return; } @@ -2579,11 +1730,11 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett hover.arm(); const surface = activeSurface(); if (surface === 'lightbox') { - closeImage(); + lightbox.close(); return; } if (surface === 'menu') { - popMenu(); + menu.pop(); return; } if (surface !== 'form') { @@ -2595,7 +1746,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett // screen keeps `back`; leaving it is a popup closing, and close() says so. if (!sidebar.hasFocus()) { deps.audio.play('back'); - leavePane(); + core.leavePane(); return; } leaveScreen(); @@ -2631,21 +1782,15 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett adoptToken += 1; pendingMove = null; deps.audio.play('back'); - // The lightbox, the menu and the keyboard go WITH the screen — one close, one sound (Р5). - closeImage({ silent: true }); - closeMenus({ silent: true }); + // The lightbox, the menu and the keyboard go WITH the screen — one close, one sound. + lightbox.close({ silent: true }); + menu.close({ silent: true }); deps.keyboard.close(); // The online surface holds an audition — real sound, which would outlive the screen otherwise. deps.onlinePicker.close(); entrance.cancel(); - if (previewTimer !== 0) { - window.clearTimeout(previewTimer); - previewTimer = 0; - } - if (validateTimer !== 0) { - window.clearTimeout(validateTimer); - validateTimer = 0; - } + core.cancelPreview(); + validator.cancel(); stopWatchingMoveTargets(); delete app.dataset['overlay']; screen.setAttribute('aria-hidden', 'true'); @@ -2660,18 +1805,18 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett // A thumbnail IS the "show me this picture" affordance for the mouse; the gamepad reaches the same // viewer through the row's own menu. Checked before the row, or the click would also open that menu. if (target instanceof HTMLElement && target.classList.contains('setting-thumb')) { - deps.audio.play('button'); // the press; showImage plays the viewer's own `popup-open` - void showImage(target.dataset['path'] ?? ''); + deps.audio.play('button'); // the press; the viewer plays its own `popup-open` + void lightbox.show(target.dataset['path'] ?? ''); return; } const rowEl = target.closest('.setting-row'); if (rowEl === null) return; - const index = rendered.findIndex((row) => row.el === rowEl); - const entry = rendered[index]; + const index = core.rendered().findIndex((row) => row.el === rowEl); + const entry = core.rendered()[index]; if (entry === undefined || !isFocusable(entry.row)) return; sidebar.setFocused(false); - focusIndex = index; - applyRowFocus(); + core.setFocusIndex(index); + core.applyRowFocus(); const chevronEl = target.closest('.setting-chevron'); if (chevronEl !== null) { const delta = chevronEl.dataset['chevron'] === 'prev' ? -1 : 1; @@ -2682,14 +1827,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett activateRow(entry); }); - lightboxEl.querySelector('.lightbox-veil')?.addEventListener('click', () => { - closeImage(); - }); - veil?.addEventListener('click', () => navBack()); - menuVeil?.addEventListener('click', () => { - popMenu(); - }); window.addEventListener( 'mousemove', @@ -2700,25 +1838,19 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett if (!hover.awake(event.clientX, event.clientY)) return; const target = event.target; if (!(target instanceof Element)) return; - const level = menuTop(); - if (level !== undefined) { - const button = target.closest('.settings-option'); - if (button === null) return; - const index = menuButtons.indexOf(button); - if (index === -1 || index === level.focus) return; - level.focus = index; - applyMenuFocus(); + if (menu.isOpen()) { + menu.hover(target); return; } const rowEl = target.closest('.setting-row'); if (rowEl === null) return; - const index = rendered.findIndex((row) => row.el === rowEl); - const entry = rendered[index]; + const index = core.rendered().findIndex((row) => row.el === rowEl); + const entry = core.rendered()[index]; if (index === -1 || entry === undefined || !isFocusable(entry.row)) return; - if (index === focusIndex && !sidebar.hasFocus()) return; + if (index === core.focusIndex() && !sidebar.hasFocus()) return; sidebar.setFocused(false); - focusIndex = index; - applyRowFocus(); + core.setFocusIndex(index); + core.applyRowFocus(); }, { passive: true }, ); @@ -2735,8 +1867,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett sidebar.reset(); // a re-opened screen starts at the first section, column and pane together sidebar.setFocused(true); // the screen opens on its table of contents, not inside a section sidebar.animateIn(); - sectionKey = null; - paneKey = null; + core.reset(); // NOT '': an empty string is a real signature (a column with no entries, a strip with no notes), // and starting a visit on it made the guards claim the screen already showed that. A game left with // "fix the errors first" under it then kept that line for every game opened after — the strip was @@ -2744,11 +1875,11 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett columnSignature = null; statusSignature = null; hover.arm(); - thumbnails.clear(); + lightbox.clearCache(); listScroller.to(0, true); - focusIndex = 0; + core.setFocusIndex(0); model = null; - rendered = []; + core.setRendered([]); slots = []; slotIndex = -1; pendingMove = null; @@ -2801,36 +1932,14 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett navBack, isDirty: dirty, deletesLocalGame: () => mediaOrigin()?.source === 'pc', - askOnlineQuery: (initial, onDone) => { - deps.keyboard.open({ - value: initial, - mode: 'text', - title: t()('metadata.searchTitle'), - onDone: (value) => { - metadataToken += 1; - onDone(value); - }, - }); - }, - askOnlineTitle: (title, onYes) => { - pendingTitleReplace = onYes; - deps.onConfirmRequested('replace-title', { title }); - }, - applyOnlineArtwork: (kind, variantKeys, mode) => applyArtwork(kind, variantKeys, mode), - applyOnlineTrack: (trackKey) => applyTrackKey(trackKey), + askOnlineQuery: (initial, onDone) => onlineFlow.askQuery(initial, onDone), + askOnlineTitle: (title, onYes) => onlineFlow.askTitle(title, onYes), + applyOnlineArtwork: (kind, variantKeys, mode) => onlineFlow.applyArtwork(kind, variantKeys, mode), + applyOnlineTrack: (trackKey) => onlineFlow.applyTrack(trackKey), applyOnlineTitle: (title) => { setField('title', title); }, - onOnlineCandidate: (candidate) => { - // An empty form takes the name at once, without the question "Take the name" asks: there is - // nothing to replace. It is also what makes the rest of the screen usable — the id follows the - // title (see setField), and the id is what every downloaded file is NAMED by, so a game added - // through this flow could otherwise pick a background and be told it has no id to write it under. - if (form.title.trim() === '') setField('title', candidate.title); - // Only a Steam entry can be asked for facts: the others carry no appid, and the appid is what the - // descriptions, genres and dates are addressed by. - if (candidate.steamAppId !== undefined) void fetchMetadataDescriptions(candidate); - }, + onOnlineCandidate: (candidate) => onlineFlow.onCandidate(candidate), heroCount: () => form.heroImage.length, // The secondary buttons belong to whatever surface is on top, exactly as the six primitives do. // controls.ts routes them to the open OVERLAY — that is this screen — so they die here unless they @@ -2840,7 +1949,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett navSecondary: (repeat = false) => { const surface = activeSurface(); if (surface === 'menu') { - const level = menuTop(); + const level = menu.top(); if (level?.secondary === undefined) { if (!repeat) deps.audio.playLimit(); return; @@ -2890,7 +1999,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett if (origin?.kind === 'history') return; // The card was pulled, or swapped, or the game stopped being playable: the screen is about a file // that is no longer reachable, and everything under it (the carousel, the detail screen) has been - // rebuilt already. Leaving would be worse than closing, so it closes — see the plan, Р6.2. + // rebuilt already. Leaving would be worse than closing, so it closes. if (browse !== null && browse.id === gameId && browse.active) return; close(); }, @@ -2906,19 +2015,15 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett pendingSource = null; if (root !== null) void adoptRoot(root); } else if (kind === 'cancel-move') cancelMove(); - else if (kind === 'replace-title') { - const run = pendingTitleReplace; - pendingTitleReplace = null; - run?.(); - } + else if (kind === 'replace-title') onlineFlow.titleConfirmed(); }, relocalize: () => { if (model !== null) { - const section = currentSection(model); + const section = sectionByKey(model.sections, core.sectionKey()); if (section !== undefined) { relocalizeGameSections(listEl, { ...model, sections: [section] }, t()); } - for (const row of rendered) relocalizeGameRow(row, t()); + for (const row of core.rendered()) relocalizeGameRow(row, t()); // The screen's own name is mode-aware and JS-set, so it is re-read here too — localizeDocument // does not touch it (no data-i18n) and would overwrite the mode if it did. titleEl.textContent = t()( @@ -2936,7 +2041,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett deps.picker.relocalize(); deps.onlinePicker.relocalize(); // A menu's labels are built from the model, so it is rebuilt rather than patched. - if (menuStack.length > 0) paintMenu(); + if (menu.isOpen()) menu.paint(); }, }; } diff --git a/src/renderer/game-settings-view.ts b/src/renderer/game-settings-view.ts index 60d3e25a..49cafaf4 100644 --- a/src/renderer/game-settings-view.ts +++ b/src/renderer/game-settings-view.ts @@ -6,8 +6,11 @@ // // Unlike the Settings view there is no screen-specific row kind here: every kind this screen draws lives // in row-view-core, which is why this module is as short as it is. -import type { GameSettingsModel, GameSettingsRow } from './game-settings-model'; -import type { Translator } from '../shared/i18n/index'; +import type { GameRowId, GameSettingsModel, GameSettingsRow } from './game-settings-model.js'; +import type { MessageKey } from '../shared/i18n/index.js'; +import { titledSections } from './list-screen-core.js'; +import type { SidebarEntry } from './screen-sidebar.js'; +import type { Translator } from '../shared/i18n/index.js'; import { buildCoreRow, div, @@ -15,7 +18,7 @@ import { relocalizeCoreRow, rowLabelText, type PreviewAspect, -} from './row-view-core'; +} from './row-view-core.js'; /** One rendered row: the model row it came from plus the nodes the controller updates. */ export interface RenderedGameRow { @@ -149,3 +152,76 @@ export function screenHeading(model: GameSettingsModel | null): string { /** Exported for the controller's own re-localization pass of an expanded dropdown. */ export { rowLabelText }; + +/** Every row of the model, section by section. */ +export function rowsOf(model: GameSettingsModel): readonly GameSettingsRow[] { + return model.sections.flatMap((section) => section.rows); +} + +/** The rows that are NOT in any titled section: the screen's actions and its notes. */ +export function trailingRows(model: GameSettingsModel): readonly GameSettingsRow[] { + return model.sections.filter((s) => s.titleKey === undefined).flatMap((s) => s.rows); +} + +/** Every artwork path the model shows, so a patch can tell whether the thumbnail strips are stale. */ +export function artworkSignature(model: GameSettingsModel): string { + return rowsOf(model) + .map((row) => { + if (row.kind === 'list' && row.preview !== undefined) return row.items.join(','); + if (row.kind === 'path' && row.preview !== undefined) return row.value; + return ''; + }) + .join('|'); +} + +/** + * The column: the sections, then the actions. The NOTES that share the model's last section stay out + * of it — they are the screen's own feedback (what the last save did, why Save is unavailable), so + * they go under both columns where they are readable from anywhere (see statusNotes). + */ +export function columnEntries( + model: GameSettingsModel, + t: Translator, +): readonly SidebarEntry[] { + return [ + ...titledSections(model.sections).map((section) => ({ + id: section.titleKey, + label: t(section.titleKey), + kind: 'section' as const, + })), + ...trailingRows(model).flatMap((row) => + row.kind === 'action' + ? [ + { + id: row.id, + label: rowLabelText(row.label, t), + kind: 'action' as const, + ...(row.danger === true ? { danger: true } : {}), + ...(row.disabled === true ? { disabled: true } : {}), + }, + ] + : [], + ), + ]; +} + +/** The notes under both columns, as the status strip draws them. */ +export function statusNotes( + model: GameSettingsModel, +): readonly Extract[] { + return trailingRows(model).flatMap((row) => (row.kind === 'note' ? [row] : [])); +} + +/** One note of the status strip. */ +export function buildStatusNote( + note: Extract, + t: Translator, +): HTMLElement { + const el = document.createElement('div'); + el.className = `setting-row setting-row-note is-inert is-${note.tone}`; + const text = document.createElement('div'); + text.className = 'setting-note-text'; + text.textContent = rowLabelText(note.text, t); + el.append(text); + return el; +} diff --git a/src/renderer/hero.ts b/src/renderer/hero.ts index 21b976cf..78be344f 100644 --- a/src/renderer/hero.ts +++ b/src/renderer/hero.ts @@ -3,7 +3,7 @@ // rotation, the idle wallpaper background, and the two-color palette (compute + cache + apply). These // share `shownUrl`/`wallpaperUrl` so they live together — keeping the palette race gate internal rather // than threaded through app.ts. The controller reaches back only through the narrow `deps` seam. -import type { HeroAssets } from '../shared/types'; +import type { HeroAssets } from '../shared/types.js'; import { computePalette, type Palette } from './dominant-color.js'; import { req } from './dom.js'; @@ -13,8 +13,6 @@ const HERO_ROTATE_MS = 60_000; export interface HeroDeps { /** Whether a game (not the idle/empty screen) is currently on screen. */ hasGameOnScreen(): boolean; - /** The current game's id (for the per-hero palette cache key); '' when none. */ - getGameId(): string; } export interface HeroController { @@ -67,6 +65,11 @@ export function createHeroController(deps: HeroDeps): HeroController { // Fallback wallpaper (data URL from main) for the empty / idle screen, and its cached palette. let wallpaperUrl: string | null = null; let wallpaperPalette: Palette | null | undefined; + // Keyed by the IMAGE, never by the game or the slot it sits in: a palette is a property of the pixels. + // A key like `${gameId}#${index}` looked the same but could lie — the browse info for the next game + // lands before its hero payload, so a repaint in between computed the OLD picture's colours under the + // NEW game's key, and the new picture then wore them (the cache is cleared per payload, but a + // computation still in flight writes after the clear). const paletteCache = new Map(); // ── Palette (two dominant colors) ───────────────────────────────────────── @@ -81,17 +84,16 @@ export function createHeroController(deps: HeroDeps): HeroController { app.style.setProperty('--d2', palette.d2); } - // Computes (or reuses a cached) palette for an arbitrary image, keyed by an arbitrary cache key - // (per-hero: `${gameId}#${index}`). Applies it only if that image is STILL the one on screen, so a - // slow compute for a rotated-away image can't clobber the current palette. - function updatePaletteFor(url: string, cacheKey: string): void { - const cached = paletteCache.get(cacheKey); + // Computes (or reuses a cached) palette for an image. Applies it only if that image is STILL the one + // on screen, so a slow compute for a rotated-away image can't clobber the current palette. + function updatePaletteFor(url: string): void { + const cached = paletteCache.get(url); if (cached !== undefined) { applyPalette(cached); return; } void computePalette(url).then((palette) => { - paletteCache.set(cacheKey, palette); + paletteCache.set(url, palette); if (shownUrl === url) applyPalette(palette); }); } @@ -269,10 +271,9 @@ export function createHeroController(deps: HeroDeps): HeroController { function showHeroAt(index: number): void { const url = heroImages[index]; if (url === undefined) return; - const id = deps.getGameId(); requestImage(url, () => { if (url === wallpaperUrl) applyWallpaperPalette(); - else updatePaletteFor(url, `${id}#${index}`); + else updatePaletteFor(url); }); } @@ -307,16 +308,34 @@ export function createHeroController(deps: HeroDeps): HeroController { heroTimer = null; } + /** + * Whether a payload carries exactly the images already held. Main re-sends a game's heroes whenever the + * user commits to it (opening its screen asks for them immediately), and that replay is not a new game: + * resetting the cursor for it threw a rotated-to image back to the first one. An empty payload is never + * "the same" — what it means depends on the channel, which is applyAssets' business. + */ + function isSamePayload(incoming: readonly string[]): boolean { + return ( + incoming.length > 0 && + incoming.length === heroImages.length && + incoming.every((url, index) => url === heroImages[index]) + ); + } + // New hero payload: reset the cursor, paint the first image if a game is already on screen (channels are // independent — render may have landed first), and restart the rotation from fresh. `replaceWhenEmpty` // decides what an EMPTY payload means — see the branch below. function applyAssets(assets: HeroAssets | null, replaceWhenEmpty = false): void { - heroImages = assets?.images ?? []; + const incoming = assets?.images ?? []; + if (isSamePayload(incoming)) { + if (deps.hasGameOnScreen()) showHeroAt(heroIndex); + startRotation(); + return; + } + heroImages = incoming; heroIndex = 0; - // A fresh payload can carry the same per-game key `${id}#${index}` mapped to a DIFFERENT image — e.g. - // after the user reorders hero images on the Customize screen and saves. The palette cache is keyed by - // position, not content, so drop it here: the new first image must recompute --d1/--d2 rather than - // reuse the previous image's colors. (Intra-card rotation still fills and reuses the cache.) + // The cache is keyed by image, so a fresh payload cannot hand one picture another's colours; it is + // still dropped here so the map never outgrows one payload's worth of images (a data URL apiece). paletteCache.clear(); stopRotation(); if (deps.hasGameOnScreen()) { @@ -342,7 +361,7 @@ export function createHeroController(deps: HeroDeps): HeroController { function setWallpaper(url: string | null): void { wallpaperUrl = url; // Drop the cached wallpaper palette so a changed wallpaper recomputes its own --d1/--d2 (the cache is - // encapsulated in this closure — it can't be reset from app.ts; see plan F2-3). + // encapsulated in this closure — it can't be reset from app.ts). wallpaperPalette = undefined; } diff --git a/src/renderer/idle.ts b/src/renderer/idle.ts new file mode 100644 index 00000000..d2b82cad --- /dev/null +++ b/src/renderer/idle.ts @@ -0,0 +1,153 @@ +// The idle countdown and the mouse's sleep, split out of controls.ts. After IDLE_MS with no input the +// bar highlight goes dormant AND the mouse falls asleep; any input restarts the countdown. The gamepad +// puts the mouse to sleep at once (the user switched to the pad), a deliberate shove wakes it back up +// (mouse-sleep.ts), and while it is asleep every pointer gesture but a move is swallowed here, in the +// capture phase on window — so no surface has to gate them itself. +// +// The `mouse-asleep` class on is the contract the surfaces read (`classList.contains`) and the +// DOM tests build on: this module is its single writer. +import type { HoverGuard } from './hover-guard.js'; +import { createWakeMeter } from './mouse-sleep.js'; + +/** After this long with no input the highlight retires and the cursor hides — both "went idle" at once. */ +const IDLE_MS = 5_000; + +export interface IdleDeps { + readonly hover: Pick; + /** + * With a full-screen overlay up there is no bar highlight to retire and no carousel to hand back to: + * firing would strip the return point on More and light the strip up under the veil — so the countdown + * is not armed while this says so. + */ + isSuspended(): boolean; + /** The countdown ran out: the bar highlight goes dormant (the cursor is already hidden by then). */ + onIdle(): void; +} + +export interface IdleGuard { + /** (Re)starts the countdown. */ + arm(): void; + isMouseAsleep(): boolean; + /** Puts the mouse to sleep or wakes it: hides the cursor AND turns every pointer gesture on or off. */ + setMouseAsleep(asleep: boolean): void; + /** + * Gamepad/keyboard input = activity: the mouse goes to sleep at once (the user switched to the pad, so + * the pointer parked on screen stops counting as input at all), hover is disarmed, the idle countdown + * restarts. + */ + noteGamepadActivity(): void; + /** Real mouse movement, with the mouse already awake = activity: keep the cursor up, restart the idle. */ + noteMouseActivity(): void; + /** + * A real (non-synthetic) pointer move. Asleep, a move is not input — it only feeds the wake meter — so + * this answers false until the travel adds up to a shove; the move that wakes it (and every move while + * awake) counts as activity and answers true. + */ + pointerMoved(x: number, y: number, now: number): boolean; +} + +export function createIdleGuard(deps: IdleDeps): IdleGuard { + let idleTimer = 0; + // The launcher OPENS with the mouse asleep (index.html carries the class from the first frame, so there + // is no moment where a parked pointer can hover something before this file runs). Waking it takes a + // deliberate shove — see mouse-sleep.ts and the swallowing listener below. + let mouseAsleep = true; + const wakeMeter = createWakeMeter(); + + function setMouseAsleep(asleep: boolean): void { + if (mouseAsleep === asleep) return; + mouseAsleep = asleep; + document.documentElement.classList.toggle('mouse-asleep', asleep); + wakeMeter.reset(); + } + + function arm(): void { + if (idleTimer !== 0) window.clearTimeout(idleTimer); + if (deps.isSuspended()) return; + idleTimer = window.setTimeout(() => { + idleTimer = 0; + setMouseAsleep(true); + deps.onIdle(); + }, IDLE_MS); + } + + function noteGamepadActivity(): void { + setMouseAsleep(true); + // Explicitly, not just via setMouseAsleep: while the mouse is ALREADY asleep that call is a no-op, + // and the travel a bumped trackpad has quietly banked up has to die on every pad step regardless — + // otherwise a hand resting on the Deck adds up to a wake across a whole session of pressing buttons. + wakeMeter.reset(); + // Every keyboard/gamepad step re-arms the hover guard: last input wins. Without this, one real mouse + // move wakes hover for good, and from then on any element that slides under the still cursor — a + // scrolling list, a popup opening — can take the focus back off the key that just moved it. + deps.hover.arm(); + arm(); + } + + function noteMouseActivity(): void { + setMouseAsleep(false); + arm(); + } + + // Every OTHER thing a pointer can do, switched off in one place for as long as the mouse is asleep. + // + // Asleep means the mouse is OUT of the UI, not merely invisible: clicks, the wheel, right-click-as-back, + // the hover reads on every surface. Gating each of those where it lives would be a list to keep in sync, + // and one forgotten entry is a stutter nobody can reproduce — which is exactly how a resting cursor kept + // stealing the popup's focus. So the gestures die here, in the capture phase on window, before any + // surface sees them. Moves are the deliberate exception: they are the way back (see pointerMoved). + // + // Two things still get through. Untrusted events, because a synthetic .click() is our own code driving + // the UI rather than a mouse (file-picker.ts does that). And touch: a finger on the Deck's screen is a + // poke at one specific thing, never a pointer drifting under a resting hand, so it wakes the mouse and + // proceeds — the click Chromium synthesises after it then lands on a UI that is already awake. + const SLEPT_THROUGH: readonly string[] = [ + 'click', + 'dblclick', + 'auxclick', + 'contextmenu', + 'wheel', + 'mousedown', + 'mouseup', + 'mouseover', + 'mouseout', + 'mouseenter', + 'mouseleave', + 'pointerdown', + 'pointerup', + 'pointerover', + 'pointerout', + 'pointerenter', + 'pointerleave', + ]; + SLEPT_THROUGH.forEach((type) => { + window.addEventListener( + type, + (event) => { + if (!mouseAsleep || !event.isTrusted) return; + if (event instanceof PointerEvent && event.pointerType === 'touch') { + noteMouseActivity(); + return; + } + event.stopImmediatePropagation(); + // Not merely "don't route it": the default has to go too, or a sleeping wheel still scrolls the + // list under the cursor and a sleeping middle-click still opens Chromium's autoscroll. + if (event.cancelable) event.preventDefault(); + }, + { capture: true, passive: false }, + ); + }); + + return { + arm, + isMouseAsleep: () => mouseAsleep, + setMouseAsleep, + noteGamepadActivity, + noteMouseActivity, + pointerMoved: (x, y, now) => { + if (mouseAsleep && !wakeMeter.moved(x, y, now)) return false; + noteMouseActivity(); + return true; + }, + }; +} diff --git a/src/renderer/index.html b/src/renderer/index.html index 7088f363..c1c4de77 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -224,7 +224,7 @@ + display:none, so the fade in and out can run. -->