diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 35e553f9..1dea79bf 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -1,8 +1,8 @@ name: Build Linux # Build the Linux (SteamOS / Steam Deck) distributable in the cloud: the ubuntu runner has the -# toolchain to compile the native `drivelist` for the right ABI (impossible on macOS), and -# electron-builder produces a self-contained AppImage. +# toolchain to compile the native `drivelist` for the right ABI, and electron-builder produces a +# self-contained AppImage. # # Two ways to run it: # - workflow_dispatch (manual): builds the AppImage and uploads it as a downloadable run ARTIFACT diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml new file mode 100644 index 00000000..e9a43ff8 --- /dev/null +++ b/.github/workflows/build-macos.yml @@ -0,0 +1,98 @@ +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 +# 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 +# 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. +# +# Two ways to run it: +# - workflow_dispatch (manual): builds the dmg/zip and uploads them as run ARTIFACTS (does NOT publish a +# release) — use this to grab a build for testing. +# - pushing a v* tag: also PUBLISHES them into the draft GitHub Release for that version, matching +# build-windows.yml / build-linux.yml. + +on: + workflow_dispatch: + push: + tags: + - 'v*' + +jobs: + build: + # macos-14 is the Apple Silicon image — it produces the arm64 build natively, with no cross-compile. + runs-on: macos-14 + # Needed so electron-builder can upload assets to the GitHub Release (tag pushes only). + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v5 + + # Node 22.12+ is required by electron@43 and @electron/rebuild@4 (their `engines`). + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: '22' + cache: 'npm' + + # npm ci runs postinstall (electron-builder install-app-deps) — the macOS runner ships clang and + # python, so the native drivelist builds for the right ABI (it does compile on macOS; the claim to + # the contrary in the other two workflows was stale). + - 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 + + - name: Compile TypeScript + assets + run: npm run build + + # No `build:umu` step: umu-launcher/Proton is the Linux path, and macOS runs native games only. + + # Only when releasing (a v* tag): draft the release up front so electron-builder uploads into it. + # Same rationale/fail-fast as build-windows.yml (avoid split drafts / a silently-empty publish). + - name: Ensure draft release exists + if: startsWith(github.ref, 'refs/tags/v') + shell: bash + run: | + tag="v$(node -p "require('./package.json').version")" + isDraft=$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null || echo missing) + case "$isDraft" in + missing) gh release create "$tag" --draft --target "$GITHUB_SHA" --title "$tag" --notes "$tag" ;; + true) echo "Draft release $tag already exists — reusing it." ;; + false) echo "::error::Release $tag is already published. electron-builder refuses to upload assets into it. Delete the release (and its tag) and re-run." ; exit 1 ;; + esac + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Publish (upload dmg/zip to the draft release) ONLY on a v* tag; a manual run just builds locally + # (--publish never) and relies on the artifact upload below. + # + # CSC_IDENTITY_AUTO_DISCOVERY=false stops electron-builder from hunting the runner's keychain for a + # signing certificate. It does not disable signing: `mac.identity: '-'` in electron-builder.yml still + # applies the AD-HOC signature the app needs to launch on Apple Silicon at all. + - name: Package (dmg + zip) + run: npx electron-builder --mac --publish ${{ startsWith(github.ref, 'refs/tags/v') && 'always' || 'never' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CSC_IDENTITY_AUTO_DISCOVERY: false + + # Always expose the build as a run artifact so a manual build can be downloaded for testing. + - name: Upload dmg + uses: actions/upload-artifact@v7 + with: + name: playhook-macos + path: | + release/*.dmg + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 77c42f2a..1517ee1b 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -1,7 +1,7 @@ name: Build Windows -# Build the Windows distributable in the cloud: the windows runner has MSVC + Python, -# so the native `drivelist` compiles normally (which is impossible on macOS). +# Build the Windows distributable in the cloud: the windows runner has MSVC + Python, so the native +# `drivelist` compiles for the right ABI without a Windows machine of one's own. # Triggered manually (workflow_dispatch) or on pushing a v* tag. on: diff --git a/.gitignore b/.gitignore index 02873469..a994f5bb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -node_modules/ +node_modules dist/ release/ *.log diff --git a/CLAUDE.md b/CLAUDE.md index 5183ac0c..9dacc2a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,11 +4,27 @@ Conventions for extending Playhook safely. These were distilled from an architec was: **add features without breaking existing behaviour.** Follow them for new code; they are not a mandate to rewrite what already works. +## UI text + +- **Never type a literal `...` or `…` in user-facing text** (i18n strings, HTML fallback text, anything + rendered through the app's own font). The bundled font (M PLUS Rounded 1c) draws periods and the + ellipsis glyph CENTERED vertically — the CJK convention, not the Latin one — so they sit above the + baseline and read as a row of raised dots instead of trailing punctuation. `styles.css` carves those + two code points out of the font (see the `@font-face … unicode-range: U+002E, U+2026` overrides right + after the four real ones) so the fallback stack draws them properly wherever they DO appear — including + text this app does not author, like a game's own title — but that is a safety net, not a licence: new + copy should still be worded so nothing trails off, rather than leaning on the override. + ## Layers (do not blur) - **main** owns all game logic (fs, registry, process control, FFI). **renderer** is stateless UI. - 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 + `src/shared/` alongside `types.ts` and `i18n/` — not duplicated in each layer, and not placed under + `src/main/`: `tsconfig.renderer.json` does not include it and esbuild builds the renderer for the + 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). ## Error-handling convention @@ -68,32 +84,48 @@ happened once already, via `logger.ts` and `steam.ts`. ## Platform layer (OS-specific code) -Playhook runs on Windows and on the Steam Deck / Linux (Windows games via Proton/umu-launcher). **All -OS-specific behaviour lives behind the `Platform` bundle in `src/main/platform/`**, not scattered -`process.platform` checks. When you add code that differs per OS: +Playhook runs on **three** OSes: Windows, the Steam Deck / Linux (Windows games via Proton/umu-launcher) +and macOS. macOS is a deliberately narrower port — NATIVE mac games (a bare binary or a `.app` bundle) plus +Steam mode; a Windows `*.exe` does not run there (no Wine/CrossOver), install mode is unsupported, and the +build does not self-update. **All OS-specific behaviour lives behind the `Platform` bundle in +`src/main/platform/`**, not scattered `process.platform` checks. When you add code that differs per OS: - Add the capability to an interface in `platform/types.ts` (the bundle is `ProcessMonitor`, - `SteamLocator`, `GameProcessLauncher`, `SavePathResolver`, `PowerBackend`, `resolveInstallDir`). -- Implement it in **both** `platform/win32.ts` and `platform/linux.ts` (linux Proton helpers live in - `platform/*.linux.ts` / `umu.ts`). `createPlatform(process.platform)` selects the bundle once at - bootstrap; the rest of the code is platform-agnostic and receives it via DI (`ControllerDeps.platform`). -- **Never change Windows behaviour** when adding the Linux side — the win32 implementation must stay 1:1 - (the port's guiding invariant). Keep the OS-neutral fs/parse code (manifest, save-sync, `.acf`/VDF, - drive-watcher) shared — don't fork it. -- Card format is a **Windows dictionary** on both OSes (`%APPDATA%`, `*.exe`, `install.type`); on Linux it - is interpreted relative to the game's Wine prefix. A `game.json` must work unchanged on both platforms — - Linux-only manifest fields (`winetricks`, `umuGameId`) are ignored on Windows, never rejected. -- Extract the pure bits (path/env/argv construction, `/proc` parsing, prefix mapping) into electron-free - helpers and unit-test them (see `umu.ts`, `proc.ts`, `save-path.linux.ts`). -- **Build Linux paths with `path.posix`, never bare `path.join`.** `path.join` follows the OS the code - RUNS on, and CI runs the test suite on Windows too — so a Linux path built with `path.join` comes out as - `\home\deck\...` there and fails a test that (correctly) expects `/home/deck/...`. This has broken the - Windows job repeatedly. In any `*.linux.ts` module — and in any Linux-only feature elsewhere — use - `path.posix.join` / `path.posix.dirname` / `path.posix.basename`. Reference: `umu.ts` `prefixDir`, - `steam-userdata.linux.ts`. The win32 side keeps plain `path.join` (there it is right). + `SteamLocator`, `SteamShortcuts`, `GameProcessLauncher`, `SavePathResolver`, `PowerBackend`, + `RemovableMounter`, `resolveInstallDir`). +- 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 + (win32 / darwin / everything-else = linux); the rest of the code is platform-agnostic and receives it via + DI (`ControllerDeps.platform`). +- **Never change the behaviour of an OS you are not porting.** Adding the Linux side must leave win32 1:1; + adding macOS must leave BOTH win32 and linux 1:1 (the port's guiding invariant, and the one most easily + broken by accident — a visibility rule phrased as "only on Linux" silently takes a section away from + Windows too; phrase it as "not on the OS being added"). Keep the OS-neutral fs/parse code (manifest, + save-sync, `.acf`/VDF, drive-watcher) shared — don't fork it. +- Card format is a **Windows dictionary** on every OS (`%APPDATA%`, `*.exe`, `install.type`), interpreted + per platform: on Linux relative to the game's Wine prefix; on macOS translated into the mac profile + (`%APPDATA%`/`%LOCALAPPDATA%`/`%LOCALLOW%` → `~/Library/Application Support`, `%USERPROFILE%` → `~`, + `%DOCUMENTS%` → `~/Documents`) while a card whose `executable` is a `*.exe` simply refuses to launch + there. A `game.json` must work unchanged wherever it CAN work — Linux-only manifest fields (`winetricks`, + `umuGameId`) are ignored elsewhere, never rejected. `watchProcesses` names may omit the `.exe` suffix + (a native mac process has none); keep the `*.exe` spelling on a card meant to travel — the macOS matcher + normalizes the suffix away, so one spelling matches on all three. +- Extract the pure bits (path/env/argv construction, `/proc` and `ps` parsing, prefix mapping) into + electron-free helpers and unit-test them (see `umu.ts`, `proc.ts`, `save-path.linux.ts`, + `process-monitor.darwin.ts`, `save-path.darwin.ts`). +- **Build Linux AND macOS paths with `path.posix`, never bare `path.join`.** `path.join` follows the OS the + code RUNS on, and CI runs the test suite on Windows too — so a Linux path built with `path.join` comes + out as `\home\deck\...` there and fails a test that (correctly) expects `/home/deck/...`. This has broken + the Windows job repeatedly. The rule applies verbatim to macOS: `path.join('~/Library/Application + Support', …)` in a darwin module yields `\Library\…` on the Windows runner. In any `*.linux.ts` or + `*.darwin.ts` module — and in any OS-specific feature elsewhere — use `path.posix.join` / + `path.posix.dirname` / `path.posix.basename`. Reference: `umu.ts` `prefixDir`, `steam-userdata.linux.ts`, + `steam-locator.darwin.ts`. The win32 side keeps plain `path.join` (there it is right). Beware the silent variant: when a value is *derived* from a path (the Steam shortcut appid is a CRC32 of 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.ts` + Quick check before pushing: + `grep -rn "path\.\(join\|dirname\|basename\|resolve\)(" src/main/platform/*.{linux,darwin}.ts` ## Tests @@ -104,7 +136,32 @@ OS-specific behaviour lives behind the `Platform` bundle in `src/main/platform/` was) and test that. - Prefer covering the risky, data-touching functions: manifest validation/anti-traversal, stats merge, save-sync retry, argument quoting. -- **The suite runs on Windows AND Linux in CI, so a green local run proves nothing about path handling.** +- **DOM tests of the renderer's screen controllers live in `test/renderer/**`** and run under + **happy-dom** instead of plain Node (`environmentMatchGlobs` in `vitest.config.ts` — scoped by glob, so + every other suite keeps its Node environment and its POSIX path literals). A controller is testable + there because it is a factory taking a narrow `…Deps` seam: the fixture is the REAL + `src/renderer/index.html` (loaded by `test/renderer/helpers/fixture.ts`, so every id `req()` asks for + has to exist), the deps are faked (`helpers/fakes.ts`: audio, the screen APIs, the keyboard / file + 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). + 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` + is the exception: its `open()` is synchronous and the snapshot arrives through `applySettings()`. + - **rAF must be the harness in `helpers/raf.ts`, with a frame-BOUNDED `flush(n)`** — the marquees + reschedule themselves forever while element widths are zero, which they always are without layout. + - **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. + 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`. +- **The suite runs on Windows, Linux AND macOS in CI, so a green local run proves nothing about path + handling.** A test that asserts a Linux path against a literal (`expect(...).toBe('/home/deck/...')`) is correct and should stay — it is the *source* that must use `path.posix` (see the platform-layer rule above). Never "fix" such a failure by rewriting the expectation with `path.join`: that makes the test assert whatever @@ -112,9 +169,10 @@ OS-specific behaviour lives behind the `Platform` bundle in `src/main/platform/` ## Tooling (all run in CI before build) -- `npm run typecheck` — strict `tsc`, no `any`, no non-null `!`. +- `npm run typecheck` — strict `tsc`, no `any`, no non-null `!`. Covers `test/` as well as `src/`. - `npm run lint` — ESLint with type-aware rules (`no-floating-promises`, `no-misused-promises`, - `strict-boolean-expressions`). + `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). diff --git a/README.md b/README.md index 702d8e55..994641ae 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,11 @@

Playhook

Bring console vibes to your PC.

- Platform + Platform License: MIT Build Windows Build Linux + Build macOS

@@ -24,17 +25,19 @@ The card can carry the game itself, an **installer** for heavy games `appid` that Playhook installs, launches and uninstalls through your local Steam client ([Steam mode](#steam-mode-launch-and-install-steam-games)). -> **Cross-platform.** Playhook runs on Windows and on the Steam Deck / Linux (SteamOS). On Linux the -> same Windows game cards run through **Proton** (via [umu-launcher](https://github.com/Open-Wine-Components/umu-launcher)); -> native Linux/ELF games are not a target. macOS is not supported. See [Steam Deck](#steam-deck-linux--steamos). +> **Cross-platform.** Playhook runs on Windows, on the Steam Deck / Linux (SteamOS) and on macOS +> (Apple Silicon). On Linux the same Windows game cards run through **Proton** +> (via [umu-launcher](https://github.com/Open-Wine-Components/umu-launcher)); native Linux/ELF games are +> not a target. macOS is a **narrower** port: native mac games and Steam mode, but no Windows `.exe` and +> no install mode. See [Steam Deck](#steam-deck-linux--steamos) and [macOS](#macos). > **Security note.** The card is untrusted input — every path in the manifest is validated > against directory traversal and an allowlist before anything is read or written. See > [Preparing a card](#preparing-a-card-gamejson) for the exact rules.
- Playhook game card — Bloodborne -

The Playhook game card, running Bloodborne. Ready-made cards like this — game.json, hero art and music — live in the Playhook Collection, whose UI you can also try right in the browser.

+ Playhook home screen — the history carousel over the selected game's hero art +

The Playhook home screen: the history carousel, with the selected game's hero art behind it. Ready-made cards — game.json, hero art and music — live in the Playhook Collection, whose UI you can also try right in the browser.

--- @@ -42,13 +45,15 @@ The card can carry the game itself, an **installer** for heavy games ## Download (for users) Grab the latest build from the [**Releases**](https://github.com/sevenns/playhook/releases/latest) -page. Three are published: +page. Four are published: - **NSIS installer** (`.exe`, recommended on Windows) — installs the app, configures autostart reliably, and **updates itself** automatically. - **portable** (`.exe`) — runs without installation; no auto-update, autostart is best-effort. - **AppImage** — the Steam Deck / Linux build; it self-updates too. See [Steam Deck](#steam-deck-linux--steamos) for the setup. +- **dmg** (`-arm64.dmg`) — the macOS build, **Apple Silicon only**. It does **not** self-update: grab a + new dmg from this page when a version comes out. See [macOS](#macos). A couple of things to expect on first run **on Windows**: @@ -58,6 +63,18 @@ A couple of things to expect on first run **on Windows**: the latest [Visual C++ Redistributable (x64)](https://aka.ms/vs/17/release/vc_redist.x64.exe). (`.NET` is **not** required.) +And **on macOS**: + +- **Gatekeeper.** The build is not signed with an Apple Developer ID, so the first launch is blocked. + On macOS 15+ the old right-click → *Open* trick no longer works for an unsigned app: open the app, + let it be refused, then go to *System Settings → Privacy & Security* and press **Open Anyway** next to + the message about Playhook. (The command-line equivalent is + `xattr -dr com.apple.quarantine /Applications/Playhook.app`.) +- **Removable-volume access.** The first time Playhook looks at a card, macOS asks for permission to + read removable volumes. **Decline it and your cards become invisible** with no other symptom — the only + trace is a `permission denied` line in the log. Grant it again in *System Settings → Privacy & + Security → Files and Folders*. + ### Quick start 1. **Install** (or unzip the portable build) and run it — it sits quietly in the tray. @@ -67,8 +84,8 @@ A couple of things to expect on first run **on Windows**: 4. Press **A** on the gamepad (or click **Play**) — saves sync and the game launches. 5. **Close the game** — Playhook counts the time, updates stats, and syncs saves back to the card. -Don't want to hand-write a `game.json`? The tray has a **Configure game** editor that writes one onto -the inserted card for you — see [Settings and the card editor](#settings-and-the-card-editor). +Don't want to hand-write a `game.json`? Open a game's **More ⋯ → Customize** and edit it right in the +launcher — see [Settings and Customize](#settings-and-customize). --- @@ -97,9 +114,16 @@ state, offers: [Steam-mode](#steam-mode-launch-and-install-steam-games) cards (an uninstalled game has no Play button at all — you start from here); - **Force close** — while a game is running, kills it and still records the session and syncs saves; +- **Remove from history** — for a game you no longer have (its card is out and it is not a local game), + drops it from the carousel along with the art copied for it. Saves and playtime stay: put the card + back in and the game returns with its stats. Games you *can* play right now don't offer this item — + they are rebuilt from their card / library every time it is read; - **System** — a submenu with Shutdown / Reboot / Sleep (each behind a confirmation) and **Minimize Playhook**, which sends the window back to the tray. In Game Mode that last item is - **Close Playhook** (a full quit) instead, since there is no tray to minimize into. + **Close Playhook** (a full quit) instead, since there is no tray to minimize into. It belongs to the + launcher rather than to any one game, so it lives in the **carousel's** own More menu (System + Close), + and a game's menu is only about that game. With no carousel to go up to — a single-game card, or the + empty screen — it stays where it was: that menu is then the only one there is. Every confirmation and every error uses that same popup; close it with **B** or a click on **Close**. If a launch fails, the reason appears there and you can simply retry. @@ -112,7 +136,11 @@ have had once the card is out — pick one and you get its screen (title, stats, with no Play button: there is nothing to launch without the card. Flip through the row with **left/right** (hold to run through it), open a game with **A**, and step back -to the row with **B**. With a mouse: the wheel scrolls the row, a click selects a card, a second click +to the row with **B**. **Y** hands the highlight over to the **More** button beside the row and back +again — the row's own menu is the launcher's (System + Close) — and so does **right** on the last card, +though only as a separate press: holding right runs to the end of the row and stops there. From the +button, **left** and **B** both return to the cards. With a mouse: the wheel scrolls the row, a click +selects a card, a second click opens it, and a right-click steps back. The row is the top level — the game's screen sits one step inside it, which is where **More** ⋯ and its actions live. @@ -120,12 +148,102 @@ The row is ordered by how recently you **touched** a game — the later of "its played it" — so a card you put in yesterday and never got around to starting still sits near the front. The games on the currently inserted card come first, ordered by when you last played them. The history keeps 40 games; beyond that the least recently touched are dropped, and the games on the inserted card -are never evicted. +are never evicted. To drop one yourself, open it and use **Remove from history** in the More ⋯ menu. Use `gridImage` in `game.json` to control how a game looks in that row. It expects a **600x900** portrait cover — the same format Steam uses, so [SteamGridDB](https://www.steamgriddb.com/) is the easiest place to find one. Without it the card is cropped from the first `heroImage`. +### Local games (already installed on this PC) + +Not every game lives on a card. A game that is already installed on this machine can be added to the +launcher as a local game, and from then on it behaves like any other: its own hero art, carousel card, +music, stats, save sync and Play button — with or without a card inserted. **More ⋯ → Add game** adds +one from the launcher itself (pick "This PC" as the source); an existing one is edited the same way a +card game's is, through **More ⋯ → Customize**. + +Local games are stored in `%APPDATA%/playhook/pc-games/` (`~/.config/playhook/pc-games/` on Linux), +which is laid out exactly like a card: a `game.json`, an `assets/` folder for the art and music you +pick (they are **copied in**, so moving or deleting the originals doesn't break anything), and a +`saves/` folder for the save backups. The manifest is the same format, with one extra block and one +rule of its own: + +```jsonc +{ + "schemaVersion": 1, + "id": "hades", + "title": "Hades", + // The FULL path to the game on this PC. Only valid here — a card may never name an absolute path. + "pc": { "executable": "C:\\Games\\Hades\\Hades.exe" }, + "heroImage": ["assets/hades-hero.jpg"], + "gridImage": "assets/hades-grid.jpg", + // For a local game the save path may be absolute too (a card is limited to the %PREFIX% list). + "pcSavePath": "C:\\Games\\Hades\\Saves", + "watchProcesses": ["Hades.exe"] +} +``` + +- `pc` replaces `executable` and is mutually exclusive with `install` and `steam`; everything else + (`args`, `runAsAdmin`, `watchProcesses`, the timeouts, `winetricks`, `umuGameId`, the art and the + music) works exactly as it does for a card game. + +A local game can also be a **Steam game installed on this PC** — the second launch mode the library +accepts. Instead of `pc`, give it a `steam` block: + +```jsonc +{ + "schemaVersion": 1, + "id": "hades-steam", + "title": "Hades", + "steam": { "appid": 1145360 }, + // Required in steam mode: Playhook cannot see Steam's own process tree, so it watches for these + // image names to know the game is running. Take the name from steamapps/common// (or the + // game's SteamDB page) — it is the .exe even on the Deck, where the game runs under Proton. + "watchProcesses": ["Hades.exe"], + "heroImage": ["assets/hades-hero.jpg"], + // Optional. Under Proton the saves live inside Steam's own prefix, so use the %PREFIX% form here — + // Browse fills it in for you after the game has been run once. + "pcSavePath": "%APPDATA%/Hades" +} +``` + +- The button follows Steam: **Install** while the game isn't installed (it opens Steam's download), + **Play** once it is, and **Uninstall** hands the removal back to Steam. Installing or removing the + game in Steam directly is picked up on its own, card or no card. +- Everything Steam owns stays Steam's: there is no `install` block, no `runAsAdmin`, and no Wine prefix + of ours (the game runs in Steam's compatdata). +- `pc` and `steam` are mutually exclusive — a local game is one or the other. +- **Saves are backed up by Playhook itself** — there is no card to keep them on, so `saveOnCard` is not + allowed and the backup goes to `pc-games/saves//`. If you later insert a card carrying the same + game, the progress you made without it is copied onto the card on insertion. +- **Deleting the game from your disk doesn't delete it from Playhook.** The card stays in the carousel + with its art and stats, the status line reads *Game files not found*, and Play is hidden. Put the game + back at the same path and it is playable again, saves included. +- If a card carries a game with the **same `id`** as a local one, the card wins while it is inserted; + the local entry is hidden until the card is removed. +- Paths here are **not portable**: they are written in this machine's native form, since this library + never travels (a card's `game.json`, by contrast, must work on both Windows and the Deck). + +**Draft games.** A local game may be saved with no launch method at all — no `pc`, no `steam`. Use this +to fill in everything else (title, art, music, timings, `watchProcesses`) before you know how the game +will actually be started. A draft is visible in the carousel and stays fully editable through Customize, +but it has no dot, no Play button and no status line — the missing Play button says it. Save & Apply is +still available once you fill in `pc` or `steam`. + +**Moving a local game to a card.** **More ⋯ → Move to card…** (a local game only) copies a game's +metadata, art, music and save backup onto a card you pick, and removes it from the PC library once the +write succeeds — the game itself, however, is **not** copied: put its files on the card yourself first +(under whatever relative path you're about to give `executable`), or the move is refused with a message +saying so. On the Customize screen this shows up as the form growing new fields once you have picked a +target card (an `executable`/`install` block and, if you want save sync, `saveOnCard` + `pcSavePath`) — +fill those in and Save the same way you would for an ordinary card game. Nothing is written anywhere +until you do; backing out (Back, or the popup that closes the launcher screen) leaves both sides exactly +as they were. If the target card isn't the one currently inserted, the move still succeeds — a +notification says so, and the game shows up on the carousel once that card is. + +On the Steam Deck in **Game Mode** the launcher is started by a card being inserted, so local games are +reachable there only if you open Playhook's tile yourself. + The empty screen (no card inserted, no history) reuses the same layout over the wallpaper: "Insert a game card", no Play button, and **More** offering just the *System* submenu (where *Minimize Playhook* lives). @@ -133,15 +251,16 @@ When the launcher is hidden you can **hold Start + Back** on the gamepad to re-s can be turned off in Settings). It is intentionally ignored **while a game is running** — pulling the launcher over a running game only causes focus trouble. -Tray menu: **Show launcher**, **Configure game** (the built-in card editor), **Settings**, **Quit** — -plus **Add to Steam** / **Remove from Steam** on the Steam Deck. The log folder opens from -*Settings → Advanced → Open logs*. +Tray menu: **Show launcher**, **Open logs**, **Open games folder**, **Quit** — plus **Add to Steam** / +**Remove from Steam** on the Steam Deck. Settings and the manifest editor are screens of the launcher +itself, reached from **More ⋯**, so they work in Game Mode too. --- -## Settings and the card editor +## Settings and Customize -Both windows open from the tray, so they are **Desktop-Mode only** on the Steam Deck. +Both are SCREENS of the launcher, opened from the **More ⋯** menu — so they work with a gamepad, and on +the Steam Deck they work in **Game Mode** as well as on the desktop. ### Settings @@ -162,22 +281,32 @@ Both windows open from the tray, so they are **Desktop-Mode only** on the Steam Settings live in `settings.json` next to the rest of the app state (`%APPDATA%\playhook\` on Windows, `~/.config/playhook/` on Linux); a missing or corrupted file falls back to the defaults. -### Configure game (card editor) - -**Configure game** writes the `game.json` onto the inserted card, so you never have to edit JSON by -hand: - -- pick the card (any removable drive — a **blank** one can be initialized from scratch); -- a **form** with sections *Basics / Launch / Images / Saves / Audio / Advanced*, with Browse pickers - for the executable, the hero backgrounds (up to 3), the 600x900 carousel card image, the background - music and the save folders - (a picked PC save folder is converted back into a `%APPDATA%`-style prefix automatically, and a file - outside the card is rejected); -- a **JSON** tab with the raw manifest, live schema validation, error messages and a formatter — the - form and the JSON tab are two views of the same document; -- **Add game** / **Remove current** for a multi-game card; -- **Save & Apply** writes the file and reloads the launcher immediately (or on the card's next - insertion, if a game is running right now); **Reset** re-reads the card and drops your edits. +### Customize (the manifest editor) + +**More ⋯ → Customize** edits the `game.json` of the game you are looking at — the one on the inserted +card, or a local one — so you never have to edit JSON by hand. It is offered only for a game that is +available right now, because that is the only case where there is a file to reach. + +- a **form** with sections *Basics / Launch / Artwork / Saves / Audio / Advanced / Linux*, whose rows + follow the launch type you pick (a Steam game has no executable, an installer has no "move to PC" + checkbox) — and *Linux* is dropped for a game installed on a Windows PC, which is never run through + Proton; +- **Browse** for the executable, the installer, the hero backgrounds (up to 3), the 600x900 carousel + card image, the background music and the save folders — through the launcher's own file browser, which + a gamepad can drive (a picked PC save folder is converted back into a `%APPDATA%`-style prefix + automatically, and a file outside the card is rejected); +- an **on-screen keyboard** for every text field, with English, Russian and symbol layouts — the Deck's + own keyboard is not available to an app outside Steam. The caret goes anywhere in the value (click it, + or the ◀ ▶ keys, or the arrows on a real keyboard), **X held** keeps deleting, and **Paste** pulls the + system clipboard in (Ctrl+V too), filtered by whatever the field accepts; +- **live validation**: a problem is shown on the row that owns it, and Save stays unavailable until it + is gone. On a multi-game card, a problem in ANOTHER game is reported as a line of its own — and does + not block saving yours, since you cannot fix it from here anyway; +- **Save & Apply** writes the file and reloads the launcher immediately (or after you finish playing, if + the game is running right now); **Discard changes** re-reads the file; +- **Delete game** removes it from the manifest — never its files, and never a local game's save backups. + It is hidden while the game is running, and for the last game on a card (which would leave the card + with no manifest at all). --- @@ -196,7 +325,7 @@ The file holds **either one game object** (below) **or an array of them** — a games. The launcher opens on the history carousel and you switch by flipping through it; each game keeps its own stats, saves and install state (they are keyed by `id`). One bad entry doesn't sink the whole card — it is skipped (with a line in the log) and the rest still load; **duplicate `id`s are -rejected**, since the id keys the PC-side storage. The Configure editor shows the issue per game. +rejected**, since the id keys the PC-side storage. Customize reports the problem per game. ```jsonc { @@ -252,16 +381,19 @@ E:\ elevated via a UAC prompt (`ShellExecuteEx` `runas`) and monitors it by process HANDLE instead of `tasklist` (a non-elevated app can't see an elevated process). Opt-in on purpose — Playhook never silently escalates an untrusted card's exe. On the Steam Deck / Linux there is no elevation under - Proton, so `runAsAdmin: true` is a **no-op** (logged) rather than an error — the same card stays - valid on both platforms. + Proton, and macOS has no equivalent either, so `runAsAdmin: true` is a **no-op** (logged) rather than an + error there — the same card stays valid on all three platforms. - `watchProcesses` — for **launcher / wrapper** games, where `executable` spawns a launcher that starts the game in a **separate process** and then exits (so watching the spawned pid would wrongly report "closed" the instant the launcher quits). List the **game's own** process image names here: Playhook still spawns `executable`, but tracks the session by the **presence** of these names in `tasklist`. Playtime starts when a watched process appears and ends when all of them are gone. When omitted, behaviour is unchanged (the spawned pid is tracked directly — the default for a - self-contained `.exe`). Each entry is a bare `*.exe` name (no quotes, no path separators), matched - case-insensitively; 1–16 names. **Caveats:** + self-contained `.exe`). Each entry is a bare file name (no quotes, no path separators), matched + case-insensitively; 1–16 names. The `.exe` suffix is **optional** — a native macOS process has none — + but keep it on a card that travels: macOS matches with and without the suffix, so `game.exe` works on + all three OSes, whereas a suffix-less name is only reliable on macOS (see [macOS](#macos)). + **Caveats:** - **anticheat / elevation** — Steam / EAC / BattlEye often launch the game **elevated or as a service**, which a non-elevated `tasklist` can't see (R4) → Playhook reports "didn't start" and quietly returns without recording a session. This is a **common** case for launcher games, not a @@ -545,8 +677,9 @@ These are ignored on Windows, so a dual-platform card can carry them safely: ### Game Mode notes - **No tray in Game Mode.** Playhook is a single window that always shows an empty "Insert a game card" - screen when no card is present, and surfaces manifest errors on screen. **Settings and the card editor - (Configure) open from the tray, so they are Desktop-Mode only.** + screen when no card is present, and surfaces manifest errors on screen. **Settings and Customize are + screens of the launcher**, reached from **More ⋯**, so both work here — keyboard and file browser + included. - **Cards are mounted automatically** — on top of what the session mounts itself, Playhook sweeps for an inserted-but-unmounted removable card (see [Preparing a card for the Deck](#preparing-a-card-for-the-deck)), so insert and eject just work. @@ -570,19 +703,71 @@ These are ignored on Windows, so a dual-platform card can carry them safely: crash mid-install — this is a Wine/32-bit ceiling, not a Playhook bug, and retrying is a lottery. Use a **clean distributive**, or pre-extract the game on Windows and carry the ready folder — as a plain Executable card, or with `install.type: "copy"` if it should run from the Deck's internal drive. -- **Installers in general are unpredictable under Proton** (the Configure editor says so out loud when - you pick the Installer type). Prefer a plain Executable card or `install.type: "copy"` on the Deck. +- **Installers in general are unpredictable under Proton.** Prefer a plain Executable card or + `install.type: "copy"` on the Deck. - **First run needs network** (GE-Proton / Steam Runtime download). - **Save-sync for Windows dictionary paths** only happens once the game's prefix exists (first launch); before that there is simply nothing to sync. --- +## macOS + +Playhook runs on macOS (**Apple Silicon only** — there is no Intel or universal build). It is a narrower +port than the Linux one on purpose: there is no Wine/CrossOver here, so macOS is about the games your Mac +can actually run. + +**What works** + +- **Local mac games** — add them through *More ⋯ → Add game* → *This PC* and point Playhook at either a + plain executable or an **`.app` bundle** (the picker shows a bundle as a single item, the way Finder + does). Playhook resolves the real binary inside the bundle and tracks it by pid, so exit detection, + playtime and save-sync all work as they do on Windows. +- **[Steam mode](#steam-mode-launch-and-install-steam-games)** — install, play and uninstall through your + local Steam client, exactly as on the other two OSes. +- **Cards** carrying a `steam` block. Save-sync translates the card's Windows dictionary into the mac + profile: `%APPDATA%` / `%LOCALAPPDATA%` / `%LOCALLOW%` → `~/Library/Application Support`, + `%USERPROFILE%` → `~`, `%DOCUMENTS%` → `~/Documents`. +- The tray icon, the power menu, autostart (*System Settings → General → Login Items*) and the whole UI. + +**What does not** + +- **Windows games.** A card whose `executable` is a `*.exe` refuses to launch with a message saying so — + running it would need Wine/CrossOver, which is out of scope. +- **[Install mode](#install-mode-heavy-games-on-slow-media)** (`install.type: nsis | inno | custom | copy`) + — the whole block is unavailable; those cards are rejected when the card is read. +- **Auto-update.** Squirrel.Mac only updates a code-signed app bundle, and this build is unsigned, so + *Settings → Updates* reports updates as unavailable. Download a new dmg by hand when one is released. +- The global **Start+Back** summon chord (it uses XInput, a Windows API). The gamepad itself works + normally inside the launcher. + +**Things worth knowing** + +- **Gatekeeper, for Playhook itself** — see [Download](#download-for-users): first launch goes through + *System Settings → Privacy & Security → Open Anyway*. +- **Gatekeeper, for YOUR game** — a game binary downloaded from the internet carries a quarantine flag, + and macOS kills it the moment Playhook starts it, with no dialog at all. Playhook catches that and says + so; the fix is the same *Open Anyway* (open the game once from Finder), or + `xattr -dr com.apple.quarantine "/path/to/Game.app"`. Games installed by Steam are not affected. +- **Many Steam games have no mac build**, or ship an x86_64 one that runs under Rosetta 2. When a game has + no macOS depot, `steam://install` opens Steam and Steam refuses — Playhook simply stays on **Install**. +- **`watchProcesses` names.** A native mac process is not called `*.exe`, so the field accepts a bare name + too. Keep the `*.exe` spelling on a card you also use on Windows or the Deck — Playhook matches with and + without the suffix on macOS, so one spelling covers all three; a suffix-less name is for a mac-only + entry. If a Steam game is not detected as running, its mac binary is simply named differently: fix it in + *More ⋯ → Customize → watchProcesses*. +- **Save paths are a best-effort translation** (see above). If a game keeps its saves somewhere else, the + sync reports the folder as missing rather than syncing the wrong one — point `pcSavePath` at the real + folder through *Customize*. + +--- + ## Building from source (for developers) ### Requirements -- **Windows 10/11 x64**, or **Linux / SteamOS** (for the Steam Deck build — see below). +- **Windows 10/11 x64**, **Linux / SteamOS** (for the Steam Deck build — see below), or **macOS 13+ on + Apple Silicon** (for the mac build — see below). - **Node.js 20+** and npm (CI builds on **Node 22** — match it if in doubt). - **Native module build tools** — required to rebuild `drivelist` for Electron: - Visual Studio Build Tools with the "Desktop development with C++" component, @@ -646,6 +831,28 @@ npm run dist # build + electron-builder → release/*.AppImage for the Electron ABI by `electron-builder install-app-deps` on the Linux runner. `koffi` stays a dependency but is never called on Linux (all FFI is behind lazy `win32` guards). +#### macOS build + +Build the `.dmg` on an Apple Silicon Mac (or via the [Build macOS](.github/workflows/build-macos.yml) CI +workflow — `workflow_dispatch` produces an artifact, a `v*` tag publishes to the release): + +```bash +npm ci +npm run dist # build + electron-builder → release/*.dmg + *-mac.zip +``` + +There is no `build:umu` step (Proton is the Linux path). The `mac` block in +[`electron-builder.yml`](electron-builder.yml) targets dmg + zip for **arm64 only** — a universal build +would also need a universal `drivelist`. The app is **ad-hoc signed** (`identity: '-'`) and +`hardenedRuntime` is off: without the ad-hoc signature an app repacked on Apple Silicon refuses to launch +at all, and hardened runtime only pays off together with notarization, which needs a Developer ID this +project does not have. `koffi` stays in the bundle — `gamepad-global.ts` imports it on every OS. + +> ⚠️ Build from a real `npm ci` install. If `node_modules` is a **symlink** (e.g. a git worktree sharing +> the main clone's install), electron-builder cannot resolve transitive dependencies through it and +> silently packs an app that is missing `graceful-fs`, `js-yaml` and friends — it builds green and then +> dies on launch with a JavaScript error dialog. + --- ## Releasing & auto-update @@ -657,13 +864,14 @@ Release flow: 1. Bump `version` in [`package.json`](package.json) (e.g. `0.1.1` → `0.1.2`). 2. Commit, then push a matching tag: `git tag v0.1.2 && git push origin v0.1.2`. -3. The [Build Windows](.github/workflows/build-windows.yml) and [Build Linux](.github/workflows/build-linux.yml) - workflows build and upload the Windows installer + `latest.yml` and the Linux `.AppImage` + - `latest-linux.yml` to the same **draft** GitHub Release `v0.1.2`. +3. The [Build Windows](.github/workflows/build-windows.yml), [Build Linux](.github/workflows/build-linux.yml) + and [Build macOS](.github/workflows/build-macos.yml) workflows build and upload the Windows installer + + `latest.yml`, the Linux `.AppImage` + `latest-linux.yml` and the macOS `.dmg`/`.zip` + + `latest-mac.yml` to the same **draft** GitHub Release `v0.1.2`. 4. **Publish the draft release** on GitHub to make it live (and visible on the Releases page). 5. Each running app checks on startup and every 6h, downloads the update silently, and installs it on the **next quit** (it never interrupts a running game). See `[updater]` lines in the log. - The **NSIS** build and the **AppImage** both self-update; the portable `.exe` does not. + The **NSIS** build and the **AppImage** both self-update; the portable `.exe` and the **dmg** do not. 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 @@ -675,11 +883,15 @@ Notes: - The publish target (`owner` / `repo`) is set in [`electron-builder.yml`](electron-builder.yml) — update it if the GitHub repo is renamed. - No code signing: the very first install shows a Windows SmartScreen warning, but updates still - apply (unlike macOS, Windows auto-update works unsigned). + apply (unlike macOS, Windows auto-update works unsigned). That asymmetry is exactly why the macOS + target ships without auto-update — Squirrel.Mac refuses to update an unsigned bundle, so the mac build + reports updates as unavailable and the user re-downloads the dmg by hand. ### Autostart -On **Windows** the app registers itself via `app.setLoginItemSettings({ openAtLogin: true })`. +On **Windows** and **macOS** the app registers itself via +`app.setLoginItemSettings({ openAtLogin: true })` (on macOS it appears in *System Settings → General → +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. @@ -699,7 +911,8 @@ exists while you are in Game Mode; **Remove from Steam** deletes it. ## Logs 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); files older than +`%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**. It records card insertions, manifest validation, the stats reconcile / card-copy result, and @@ -761,9 +974,9 @@ 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) - platform/ # everything OS-specific (win32 / linux + Proton, umu) - preload/ # typed contextBridge bridges (launcher + settings) - renderer/ # UI + gamepad/keyboard input (launcher, settings, configure — no Node) + 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 test/ # vitest suites (plain Node, no electron) ``` @@ -778,16 +991,16 @@ 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 **and** Linux. +CI runs all three, on Windows, Linux **and** macOS. --- ## FAQ - **Do I have to write `game.json` myself?** No, twice over: the [**Playhook Collection**](https://sevenns.github.io/playhook-collection/) - hosts ready-made, verified manifests you can browse and drop straight onto a card, and the tray's - **Configure game** editor fills one in through a form (with a raw JSON tab and live validation for - when you do want to poke at it). Hand-write one only for a game the Collection doesn't cover yet + hosts ready-made, verified manifests you can browse and drop straight onto a card, and the launcher's + **More ⋯ → Customize** screen edits one through a form, with live validation. Hand-write one only for + a game the Collection doesn't cover yet (and consider [contributing it back](https://github.com/sevenns/playhook-collection)). - **Can one card hold several games?** Yes — put an **array** of game objects in `game.json` and switch between them in the history carousel (left/right, then **A**). @@ -795,7 +1008,10 @@ CI runs all three, on Windows **and** Linux. first run. Choose *More info → Run anyway*. Auto-update still works without signing. - **Does it run on the Steam Deck / Linux?** **Yes.** The same Windows game cards launch through Proton (via umu-launcher) on SteamOS/Linux — see [Steam Deck](#steam-deck-linux--steamos). Native Linux/ELF - games and macOS are not supported. + games are not a target. +- **Does it run on macOS?** **Yes, on Apple Silicon** — for what a Mac can actually run: native mac games + (including `.app` bundles) and [Steam mode](#steam-mode-launch-and-install-steam-games). Windows `.exe` + games and install mode do not work there, and the dmg does not self-update. See [macOS](#macos). - **Does it work without a gamepad?** Yes — mouse and keyboard both work. Keyboard: **WASD / arrow keys** to move, **Space / Enter** to activate, **Tab / Backspace** (or **Esc**) to go back. Mouse: click a card in the row to select it and click it again to open it, the **wheel** flips through the row, and @@ -811,6 +1027,21 @@ CI runs all three, on Windows **and** Linux. --- +## Credits + +"Find online" on the Add/Customize screen fetches a game's title, cover, backgrounds and music from +external sources. Nothing is fetched without an explicit press, and everything applied is downloaded and +stored next to the game, so a card keeps working offline. + +- Titles, descriptions, covers and backgrounds: the [Steam](https://store.steampowered.com/) store. +- Wallpapers, offered first among the backgrounds: [Wallhaven](https://wallhaven.cc/) — no key needed, + SFW only. +- More wallpapers, especially for recent releases: + [Wallpaper Cave](https://wallpapercave.com/) — no key needed. +- Alternative covers: [SteamGridDB](https://www.steamgriddb.com/) — optional, needs your own API key. +- Backgrounds for games sold there: [GOG](https://www.gog.com/). +- Soundtracks: [Khinsider](https://downloads.khinsider.com/). + ## License MIT — see [LICENSE](LICENSE). diff --git a/assets/github/playhook-bloodborne-example.jpg b/assets/github/playhook-bloodborne-example.jpg deleted file mode 100644 index c402eb1f..00000000 Binary files a/assets/github/playhook-bloodborne-example.jpg and /dev/null differ diff --git a/assets/github/playhook-example.jpg b/assets/github/playhook-example.jpg new file mode 100644 index 00000000..c3b3c22f Binary files /dev/null and b/assets/github/playhook-example.jpg differ diff --git a/assets/icon.icns b/assets/icon.icns new file mode 100644 index 00000000..3bf09238 Binary files /dev/null and b/assets/icon.icns differ diff --git a/assets/playhook-startup.mp3 b/assets/playhook-startup.mp3 new file mode 100644 index 00000000..0522a276 Binary files /dev/null and b/assets/playhook-startup.mp3 differ diff --git a/audio/ambience/dreamy.mp3 b/audio/ambience/dreamy.mp3 new file mode 100644 index 00000000..6b312415 Binary files /dev/null and b/audio/ambience/dreamy.mp3 differ diff --git a/audio/ambience/gamecube.mp3 b/audio/ambience/gamecube.mp3 new file mode 100644 index 00000000..d9fff276 Binary files /dev/null and b/audio/ambience/gamecube.mp3 differ diff --git a/audio/ambience/gleaming-void.mp3 b/audio/ambience/gleaming-void.mp3 deleted file mode 100644 index ef5d8a42..00000000 Binary files a/audio/ambience/gleaming-void.mp3 and /dev/null differ diff --git a/audio/ambience/playhook-abyss.mp3 b/audio/ambience/playhook-abyss.mp3 new file mode 100644 index 00000000..b40dc78e Binary files /dev/null and b/audio/ambience/playhook-abyss.mp3 differ diff --git a/audio/ambience/ps3.mp3 b/audio/ambience/ps3.mp3 new file mode 100644 index 00000000..0695ce80 Binary files /dev/null and b/audio/ambience/ps3.mp3 differ diff --git a/audio/ambience/ps4.mp3 b/audio/ambience/ps4.mp3 new file mode 100644 index 00000000..b348b48f Binary files /dev/null and b/audio/ambience/ps4.mp3 differ diff --git a/audio/ambience/sega-saturn.mp3 b/audio/ambience/sega-saturn.mp3 new file mode 100644 index 00000000..1874c770 Binary files /dev/null and b/audio/ambience/sega-saturn.mp3 differ diff --git a/audio/ambience/wii-u.mp3 b/audio/ambience/wii-u.mp3 new file mode 100644 index 00000000..73cebb13 Binary files /dev/null and b/audio/ambience/wii-u.mp3 differ diff --git a/audio/ambience/xbox.mp3 b/audio/ambience/xbox.mp3 new file mode 100644 index 00000000..049c40d3 Binary files /dev/null and b/audio/ambience/xbox.mp3 differ diff --git a/audio/ui/dark-souls/back.wav b/audio/ui/dark-souls/back.wav deleted file mode 100644 index f9ba6749..00000000 Binary files a/audio/ui/dark-souls/back.wav and /dev/null differ diff --git a/audio/ui/dark-souls/button.wav b/audio/ui/dark-souls/button.wav deleted file mode 100644 index 2bbfaa6f..00000000 Binary files a/audio/ui/dark-souls/button.wav and /dev/null differ diff --git a/audio/ui/dark-souls/move.wav b/audio/ui/dark-souls/move.wav deleted file mode 100644 index 7dc14370..00000000 Binary files a/audio/ui/dark-souls/move.wav and /dev/null differ diff --git a/audio/ui/dark-souls/play.wav b/audio/ui/dark-souls/play.wav deleted file mode 100644 index 4ef0ed57..00000000 Binary files a/audio/ui/dark-souls/play.wav and /dev/null differ diff --git a/audio/ui/dreamcast/back.wav b/audio/ui/dreamcast/back.wav new file mode 100644 index 00000000..ace6e593 Binary files /dev/null and b/audio/ui/dreamcast/back.wav differ diff --git a/audio/ui/dreamcast/button.wav b/audio/ui/dreamcast/button.wav new file mode 100644 index 00000000..68b4e00d Binary files /dev/null and b/audio/ui/dreamcast/button.wav differ diff --git a/audio/ui/dreamcast/limit.wav b/audio/ui/dreamcast/limit.wav new file mode 100644 index 00000000..66b41dd0 Binary files /dev/null and b/audio/ui/dreamcast/limit.wav differ diff --git a/audio/ui/dreamcast/move.wav b/audio/ui/dreamcast/move.wav new file mode 100644 index 00000000..66b41dd0 Binary files /dev/null and b/audio/ui/dreamcast/move.wav differ diff --git a/audio/ui/dreamcast/notify.wav b/audio/ui/dreamcast/notify.wav new file mode 100644 index 00000000..68b4e00d Binary files /dev/null and b/audio/ui/dreamcast/notify.wav differ diff --git a/audio/ui/dreamcast/play.wav b/audio/ui/dreamcast/play.wav new file mode 100644 index 00000000..68b4e00d Binary files /dev/null and b/audio/ui/dreamcast/play.wav differ diff --git a/audio/ui/dreamcast/popup-close.wav b/audio/ui/dreamcast/popup-close.wav new file mode 100644 index 00000000..ace6e593 Binary files /dev/null and b/audio/ui/dreamcast/popup-close.wav differ diff --git a/audio/ui/dreamcast/popup-open.wav b/audio/ui/dreamcast/popup-open.wav new file mode 100644 index 00000000..68b4e00d Binary files /dev/null and b/audio/ui/dreamcast/popup-open.wav differ diff --git a/audio/ui/dreamcast/typing.wav b/audio/ui/dreamcast/typing.wav new file mode 100644 index 00000000..66b41dd0 Binary files /dev/null and b/audio/ui/dreamcast/typing.wav differ diff --git a/audio/ui/dreamy/back.wav b/audio/ui/dreamy/back.wav new file mode 100644 index 00000000..2a413b0f Binary files /dev/null and b/audio/ui/dreamy/back.wav differ diff --git a/audio/ui/dreamy/button.wav b/audio/ui/dreamy/button.wav new file mode 100644 index 00000000..98dd8713 Binary files /dev/null and b/audio/ui/dreamy/button.wav differ diff --git a/audio/ui/dreamy/limit.wav b/audio/ui/dreamy/limit.wav new file mode 100644 index 00000000..aa2a727f Binary files /dev/null and b/audio/ui/dreamy/limit.wav differ diff --git a/audio/ui/dreamy/move.wav b/audio/ui/dreamy/move.wav new file mode 100644 index 00000000..1b2dfb1e Binary files /dev/null and b/audio/ui/dreamy/move.wav differ diff --git a/audio/ui/dreamy/notify.wav b/audio/ui/dreamy/notify.wav new file mode 100644 index 00000000..8a333b7f Binary files /dev/null and b/audio/ui/dreamy/notify.wav differ diff --git a/audio/ui/dreamy/play.wav b/audio/ui/dreamy/play.wav new file mode 100644 index 00000000..afd6ca9d Binary files /dev/null and b/audio/ui/dreamy/play.wav differ diff --git a/audio/ui/dreamy/popup-close.wav b/audio/ui/dreamy/popup-close.wav new file mode 100644 index 00000000..2a413b0f Binary files /dev/null and b/audio/ui/dreamy/popup-close.wav differ diff --git a/audio/ui/dreamy/popup-open.wav b/audio/ui/dreamy/popup-open.wav new file mode 100644 index 00000000..6fa99522 Binary files /dev/null and b/audio/ui/dreamy/popup-open.wav differ diff --git a/audio/ui/dreamy/typing.wav b/audio/ui/dreamy/typing.wav new file mode 100644 index 00000000..e8539d2e Binary files /dev/null and b/audio/ui/dreamy/typing.wav differ diff --git a/audio/ui/hl2/back.wav b/audio/ui/hl2/back.wav deleted file mode 100644 index cdbb4b92..00000000 Binary files a/audio/ui/hl2/back.wav and /dev/null differ diff --git a/audio/ui/hl2/button.wav b/audio/ui/hl2/button.wav deleted file mode 100644 index 070314ee..00000000 Binary files a/audio/ui/hl2/button.wav and /dev/null differ diff --git a/audio/ui/hl2/move.wav b/audio/ui/hl2/move.wav deleted file mode 100644 index 099cb942..00000000 Binary files a/audio/ui/hl2/move.wav and /dev/null differ diff --git a/audio/ui/hl2/play.wav b/audio/ui/hl2/play.wav deleted file mode 100644 index bab88249..00000000 Binary files a/audio/ui/hl2/play.wav and /dev/null differ diff --git a/audio/ui/playhook-abyss/back.wav b/audio/ui/playhook-abyss/back.wav new file mode 100644 index 00000000..ca67210b Binary files /dev/null and b/audio/ui/playhook-abyss/back.wav differ diff --git a/audio/ui/playhook-abyss/button.wav b/audio/ui/playhook-abyss/button.wav new file mode 100644 index 00000000..47367646 Binary files /dev/null and b/audio/ui/playhook-abyss/button.wav differ diff --git a/audio/ui/playhook-abyss/limit.wav b/audio/ui/playhook-abyss/limit.wav new file mode 100644 index 00000000..508091e9 Binary files /dev/null and b/audio/ui/playhook-abyss/limit.wav differ diff --git a/audio/ui/playhook-abyss/move.wav b/audio/ui/playhook-abyss/move.wav new file mode 100644 index 00000000..b8ba3ad9 Binary files /dev/null and b/audio/ui/playhook-abyss/move.wav differ diff --git a/audio/ui/playhook-abyss/notify.wav b/audio/ui/playhook-abyss/notify.wav new file mode 100644 index 00000000..f6e18ffb Binary files /dev/null and b/audio/ui/playhook-abyss/notify.wav differ diff --git a/audio/ui/playhook-abyss/play.wav b/audio/ui/playhook-abyss/play.wav new file mode 100644 index 00000000..adffd51a Binary files /dev/null and b/audio/ui/playhook-abyss/play.wav differ diff --git a/audio/ui/playhook-abyss/popup-close.wav b/audio/ui/playhook-abyss/popup-close.wav new file mode 100644 index 00000000..31769dd9 Binary files /dev/null and b/audio/ui/playhook-abyss/popup-close.wav differ diff --git a/audio/ui/playhook-abyss/popup-open.wav b/audio/ui/playhook-abyss/popup-open.wav new file mode 100644 index 00000000..77bc0610 Binary files /dev/null and b/audio/ui/playhook-abyss/popup-open.wav differ diff --git a/audio/ui/playhook-abyss/typing.wav b/audio/ui/playhook-abyss/typing.wav new file mode 100644 index 00000000..cc2daa30 Binary files /dev/null and b/audio/ui/playhook-abyss/typing.wav differ diff --git a/audio/ui/playhook-aurora/back.wav b/audio/ui/playhook-aurora/back.wav new file mode 100644 index 00000000..84b6aad1 Binary files /dev/null and b/audio/ui/playhook-aurora/back.wav differ diff --git a/audio/ui/playhook-aurora/button.wav b/audio/ui/playhook-aurora/button.wav new file mode 100644 index 00000000..616cde4c Binary files /dev/null and b/audio/ui/playhook-aurora/button.wav differ diff --git a/audio/ui/playhook-aurora/limit.wav b/audio/ui/playhook-aurora/limit.wav new file mode 100644 index 00000000..78cc66b5 Binary files /dev/null and b/audio/ui/playhook-aurora/limit.wav differ diff --git a/audio/ui/playhook-aurora/move.wav b/audio/ui/playhook-aurora/move.wav new file mode 100644 index 00000000..00acbea6 Binary files /dev/null and b/audio/ui/playhook-aurora/move.wav differ diff --git a/audio/ui/playhook-aurora/notify.wav b/audio/ui/playhook-aurora/notify.wav new file mode 100644 index 00000000..e7e43c70 Binary files /dev/null and b/audio/ui/playhook-aurora/notify.wav differ diff --git a/audio/ui/playhook-aurora/play.wav b/audio/ui/playhook-aurora/play.wav new file mode 100644 index 00000000..b0c4c178 Binary files /dev/null and b/audio/ui/playhook-aurora/play.wav differ diff --git a/audio/ui/playhook-aurora/popup-close.wav b/audio/ui/playhook-aurora/popup-close.wav new file mode 100644 index 00000000..a8f1b509 Binary files /dev/null and b/audio/ui/playhook-aurora/popup-close.wav differ diff --git a/audio/ui/playhook-aurora/popup-open.wav b/audio/ui/playhook-aurora/popup-open.wav new file mode 100644 index 00000000..f79b288f Binary files /dev/null and b/audio/ui/playhook-aurora/popup-open.wav differ diff --git a/audio/ui/playhook-aurora/typing.wav b/audio/ui/playhook-aurora/typing.wav new file mode 100644 index 00000000..84754807 Binary files /dev/null and b/audio/ui/playhook-aurora/typing.wav differ diff --git a/audio/ui/playhook-cartridge/back.wav b/audio/ui/playhook-cartridge/back.wav new file mode 100644 index 00000000..bc3af004 Binary files /dev/null and b/audio/ui/playhook-cartridge/back.wav differ diff --git a/audio/ui/playhook-cartridge/button.wav b/audio/ui/playhook-cartridge/button.wav new file mode 100644 index 00000000..6956efbb Binary files /dev/null and b/audio/ui/playhook-cartridge/button.wav differ diff --git a/audio/ui/playhook-cartridge/limit.wav b/audio/ui/playhook-cartridge/limit.wav new file mode 100644 index 00000000..02f2e2f0 Binary files /dev/null and b/audio/ui/playhook-cartridge/limit.wav differ diff --git a/audio/ui/playhook-cartridge/move.wav b/audio/ui/playhook-cartridge/move.wav new file mode 100644 index 00000000..19556aa2 Binary files /dev/null and b/audio/ui/playhook-cartridge/move.wav differ diff --git a/audio/ui/playhook-cartridge/notify.wav b/audio/ui/playhook-cartridge/notify.wav new file mode 100644 index 00000000..1bf67cef Binary files /dev/null and b/audio/ui/playhook-cartridge/notify.wav differ diff --git a/audio/ui/playhook-cartridge/play.wav b/audio/ui/playhook-cartridge/play.wav new file mode 100644 index 00000000..13a401e1 Binary files /dev/null and b/audio/ui/playhook-cartridge/play.wav differ diff --git a/audio/ui/playhook-cartridge/popup-close.wav b/audio/ui/playhook-cartridge/popup-close.wav new file mode 100644 index 00000000..33aec69e Binary files /dev/null and b/audio/ui/playhook-cartridge/popup-close.wav differ diff --git a/audio/ui/playhook-cartridge/popup-open.wav b/audio/ui/playhook-cartridge/popup-open.wav new file mode 100644 index 00000000..02bd9334 Binary files /dev/null and b/audio/ui/playhook-cartridge/popup-open.wav differ diff --git a/audio/ui/playhook-cartridge/typing.wav b/audio/ui/playhook-cartridge/typing.wav new file mode 100644 index 00000000..8430159d Binary files /dev/null and b/audio/ui/playhook-cartridge/typing.wav differ diff --git a/audio/ui/playhook-tactile/back.wav b/audio/ui/playhook-tactile/back.wav new file mode 100644 index 00000000..c785167a Binary files /dev/null and b/audio/ui/playhook-tactile/back.wav differ diff --git a/audio/ui/playhook-tactile/button.wav b/audio/ui/playhook-tactile/button.wav new file mode 100644 index 00000000..ca0bfd1b Binary files /dev/null and b/audio/ui/playhook-tactile/button.wav differ diff --git a/audio/ui/playhook-tactile/limit.wav b/audio/ui/playhook-tactile/limit.wav new file mode 100644 index 00000000..1a6b7e9f Binary files /dev/null and b/audio/ui/playhook-tactile/limit.wav differ diff --git a/audio/ui/playhook-tactile/move.wav b/audio/ui/playhook-tactile/move.wav new file mode 100644 index 00000000..23a19f3f Binary files /dev/null and b/audio/ui/playhook-tactile/move.wav differ diff --git a/audio/ui/playhook-tactile/notify.wav b/audio/ui/playhook-tactile/notify.wav new file mode 100644 index 00000000..a1d05a45 Binary files /dev/null and b/audio/ui/playhook-tactile/notify.wav differ diff --git a/audio/ui/playhook-tactile/play.wav b/audio/ui/playhook-tactile/play.wav new file mode 100644 index 00000000..9f7a30d5 Binary files /dev/null and b/audio/ui/playhook-tactile/play.wav differ diff --git a/audio/ui/playhook-tactile/popup-close.wav b/audio/ui/playhook-tactile/popup-close.wav new file mode 100644 index 00000000..5a9bb2bb Binary files /dev/null and b/audio/ui/playhook-tactile/popup-close.wav differ diff --git a/audio/ui/playhook-tactile/popup-open.wav b/audio/ui/playhook-tactile/popup-open.wav new file mode 100644 index 00000000..04e53939 Binary files /dev/null and b/audio/ui/playhook-tactile/popup-open.wav differ diff --git a/audio/ui/playhook-tactile/typing.wav b/audio/ui/playhook-tactile/typing.wav new file mode 100644 index 00000000..544358f9 Binary files /dev/null and b/audio/ui/playhook-tactile/typing.wav differ diff --git a/audio/ui/ps-2/back.wav b/audio/ui/ps-2/back.wav new file mode 100644 index 00000000..4b7d4287 Binary files /dev/null and b/audio/ui/ps-2/back.wav differ diff --git a/audio/ui/ps-2/button.wav b/audio/ui/ps-2/button.wav new file mode 100644 index 00000000..eb3b9acd Binary files /dev/null and b/audio/ui/ps-2/button.wav differ diff --git a/audio/ui/ps-2/limit.wav b/audio/ui/ps-2/limit.wav new file mode 100644 index 00000000..4d6befe0 Binary files /dev/null and b/audio/ui/ps-2/limit.wav differ diff --git a/audio/ui/ps-2/move.wav b/audio/ui/ps-2/move.wav new file mode 100644 index 00000000..7993b9b5 Binary files /dev/null and b/audio/ui/ps-2/move.wav differ diff --git a/audio/ui/ps-2/notify.wav b/audio/ui/ps-2/notify.wav new file mode 100644 index 00000000..d25362ab Binary files /dev/null and b/audio/ui/ps-2/notify.wav differ diff --git a/audio/ui/ps-2/play.wav b/audio/ui/ps-2/play.wav new file mode 100644 index 00000000..a51955fd Binary files /dev/null and b/audio/ui/ps-2/play.wav differ diff --git a/audio/ui/ps-2/popup-close.wav b/audio/ui/ps-2/popup-close.wav new file mode 100644 index 00000000..4b7d4287 Binary files /dev/null and b/audio/ui/ps-2/popup-close.wav differ diff --git a/audio/ui/ps-2/popup-open.wav b/audio/ui/ps-2/popup-open.wav new file mode 100644 index 00000000..615b94e5 Binary files /dev/null and b/audio/ui/ps-2/popup-open.wav differ diff --git a/audio/ui/ps-2/typing.wav b/audio/ui/ps-2/typing.wav new file mode 100644 index 00000000..eb3b9acd Binary files /dev/null and b/audio/ui/ps-2/typing.wav differ diff --git a/audio/ui/ps-3/back.wav b/audio/ui/ps-3/back.wav new file mode 100644 index 00000000..f0031525 Binary files /dev/null and b/audio/ui/ps-3/back.wav differ diff --git a/audio/ui/ps-3/button.wav b/audio/ui/ps-3/button.wav new file mode 100644 index 00000000..cd9729eb Binary files /dev/null and b/audio/ui/ps-3/button.wav differ diff --git a/audio/ui/ps-3/limit.wav b/audio/ui/ps-3/limit.wav new file mode 100644 index 00000000..b8fb8030 Binary files /dev/null and b/audio/ui/ps-3/limit.wav differ diff --git a/audio/ui/ps-3/move.wav b/audio/ui/ps-3/move.wav new file mode 100644 index 00000000..df97bff5 Binary files /dev/null and b/audio/ui/ps-3/move.wav differ diff --git a/audio/ui/ps-3/notify.wav b/audio/ui/ps-3/notify.wav new file mode 100644 index 00000000..ab0513ba Binary files /dev/null and b/audio/ui/ps-3/notify.wav differ diff --git a/audio/ui/ps-3/play.wav b/audio/ui/ps-3/play.wav new file mode 100644 index 00000000..0cd93018 Binary files /dev/null and b/audio/ui/ps-3/play.wav differ diff --git a/audio/ui/ps-3/popup-close.wav b/audio/ui/ps-3/popup-close.wav new file mode 100644 index 00000000..f0031525 Binary files /dev/null and b/audio/ui/ps-3/popup-close.wav differ diff --git a/audio/ui/ps-3/popup-open.wav b/audio/ui/ps-3/popup-open.wav new file mode 100644 index 00000000..cd9729eb Binary files /dev/null and b/audio/ui/ps-3/popup-open.wav differ diff --git a/audio/ui/ps-3/typing.wav b/audio/ui/ps-3/typing.wav new file mode 100644 index 00000000..cd9729eb Binary files /dev/null and b/audio/ui/ps-3/typing.wav differ diff --git a/audio/ui/ps-4/back.wav b/audio/ui/ps-4/back.wav new file mode 100644 index 00000000..7faa2add Binary files /dev/null and b/audio/ui/ps-4/back.wav differ diff --git a/audio/ui/ps-4/button.wav b/audio/ui/ps-4/button.wav new file mode 100644 index 00000000..2a064b14 Binary files /dev/null and b/audio/ui/ps-4/button.wav differ diff --git a/audio/ui/ps-4/limit.wav b/audio/ui/ps-4/limit.wav new file mode 100644 index 00000000..d43b976e Binary files /dev/null and b/audio/ui/ps-4/limit.wav differ diff --git a/audio/ui/ps-4/move.wav b/audio/ui/ps-4/move.wav new file mode 100644 index 00000000..d43b976e Binary files /dev/null and b/audio/ui/ps-4/move.wav differ diff --git a/audio/ui/ps-4/notify.wav b/audio/ui/ps-4/notify.wav new file mode 100644 index 00000000..aa80ed76 Binary files /dev/null and b/audio/ui/ps-4/notify.wav differ diff --git a/audio/ui/ps-4/play.wav b/audio/ui/ps-4/play.wav new file mode 100644 index 00000000..3e8af106 Binary files /dev/null and b/audio/ui/ps-4/play.wav differ diff --git a/audio/ui/ps-4/popup-close.wav b/audio/ui/ps-4/popup-close.wav new file mode 100644 index 00000000..7faa2add Binary files /dev/null and b/audio/ui/ps-4/popup-close.wav differ diff --git a/audio/ui/ps-4/popup-open.wav b/audio/ui/ps-4/popup-open.wav new file mode 100644 index 00000000..2a064b14 Binary files /dev/null and b/audio/ui/ps-4/popup-open.wav differ diff --git a/audio/ui/ps-4/typing.wav b/audio/ui/ps-4/typing.wav new file mode 100644 index 00000000..2a064b14 Binary files /dev/null and b/audio/ui/ps-4/typing.wav differ diff --git a/audio/ui/ps5/back.wav b/audio/ui/ps-5/back.wav similarity index 100% rename from audio/ui/ps5/back.wav rename to audio/ui/ps-5/back.wav diff --git a/audio/ui/ps5/button.wav b/audio/ui/ps-5/button.wav similarity index 100% rename from audio/ui/ps5/button.wav rename to audio/ui/ps-5/button.wav diff --git a/audio/ui/ps-5/limit.wav b/audio/ui/ps-5/limit.wav new file mode 100644 index 00000000..81f38be3 Binary files /dev/null and b/audio/ui/ps-5/limit.wav differ diff --git a/audio/ui/ps5/move.wav b/audio/ui/ps-5/move.wav similarity index 100% rename from audio/ui/ps5/move.wav rename to audio/ui/ps-5/move.wav diff --git a/audio/ui/ps-5/notify.wav b/audio/ui/ps-5/notify.wav new file mode 100644 index 00000000..d11f3b23 Binary files /dev/null and b/audio/ui/ps-5/notify.wav differ diff --git a/audio/ui/ps5/play.wav b/audio/ui/ps-5/play.wav similarity index 100% rename from audio/ui/ps5/play.wav rename to audio/ui/ps-5/play.wav diff --git a/audio/ui/ps-5/popup-close.wav b/audio/ui/ps-5/popup-close.wav new file mode 100644 index 00000000..3f06f0bd Binary files /dev/null and b/audio/ui/ps-5/popup-close.wav differ diff --git a/audio/ui/ps-5/popup-open.wav b/audio/ui/ps-5/popup-open.wav new file mode 100644 index 00000000..90212692 Binary files /dev/null and b/audio/ui/ps-5/popup-open.wav differ diff --git a/audio/ui/ps-5/typing.wav b/audio/ui/ps-5/typing.wav new file mode 100644 index 00000000..be1a2fb0 Binary files /dev/null and b/audio/ui/ps-5/typing.wav differ diff --git a/audio/ui/psp/limit.wav b/audio/ui/psp/limit.wav new file mode 100644 index 00000000..21d92710 Binary files /dev/null and b/audio/ui/psp/limit.wav differ diff --git a/audio/ui/psp/notify.wav b/audio/ui/psp/notify.wav new file mode 100644 index 00000000..04130195 Binary files /dev/null and b/audio/ui/psp/notify.wav differ diff --git a/audio/ui/psp/popup-close.wav b/audio/ui/psp/popup-close.wav new file mode 100644 index 00000000..74022954 Binary files /dev/null and b/audio/ui/psp/popup-close.wav differ diff --git a/audio/ui/psp/popup-open.wav b/audio/ui/psp/popup-open.wav new file mode 100644 index 00000000..04130195 Binary files /dev/null and b/audio/ui/psp/popup-open.wav differ diff --git a/audio/ui/psp/typing.wav b/audio/ui/psp/typing.wav new file mode 100644 index 00000000..0ad45b39 Binary files /dev/null and b/audio/ui/psp/typing.wav differ diff --git a/audio/ui/steam-big-picture/back.wav b/audio/ui/steam-big-picture/back.wav new file mode 100644 index 00000000..0665ecf2 Binary files /dev/null and b/audio/ui/steam-big-picture/back.wav differ diff --git a/audio/ui/steam-big-picture/button.wav b/audio/ui/steam-big-picture/button.wav new file mode 100644 index 00000000..4a5c61de Binary files /dev/null and b/audio/ui/steam-big-picture/button.wav differ diff --git a/audio/ui/steam-big-picture/limit.wav b/audio/ui/steam-big-picture/limit.wav new file mode 100644 index 00000000..4da37ea5 Binary files /dev/null and b/audio/ui/steam-big-picture/limit.wav differ diff --git a/audio/ui/steam-big-picture/move.wav b/audio/ui/steam-big-picture/move.wav new file mode 100644 index 00000000..a489fbc3 Binary files /dev/null and b/audio/ui/steam-big-picture/move.wav differ diff --git a/audio/ui/steam-big-picture/notify.wav b/audio/ui/steam-big-picture/notify.wav new file mode 100644 index 00000000..13dee3a4 Binary files /dev/null and b/audio/ui/steam-big-picture/notify.wav differ diff --git a/audio/ui/steam-big-picture/play.wav b/audio/ui/steam-big-picture/play.wav new file mode 100644 index 00000000..fc0dc48d Binary files /dev/null and b/audio/ui/steam-big-picture/play.wav differ diff --git a/audio/ui/steam-big-picture/popup-close.wav b/audio/ui/steam-big-picture/popup-close.wav new file mode 100644 index 00000000..532e9624 Binary files /dev/null and b/audio/ui/steam-big-picture/popup-close.wav differ diff --git a/audio/ui/steam-big-picture/popup-open.wav b/audio/ui/steam-big-picture/popup-open.wav new file mode 100644 index 00000000..6e76e7a6 Binary files /dev/null and b/audio/ui/steam-big-picture/popup-open.wav differ diff --git a/audio/ui/steam-big-picture/typing.wav b/audio/ui/steam-big-picture/typing.wav new file mode 100644 index 00000000..77d6dfd2 Binary files /dev/null and b/audio/ui/steam-big-picture/typing.wav differ diff --git a/audio/ui/steam-vr/limit.wav b/audio/ui/steam-vr/limit.wav new file mode 100644 index 00000000..2a83bf4f Binary files /dev/null and b/audio/ui/steam-vr/limit.wav differ diff --git a/audio/ui/steam-vr/notify.wav b/audio/ui/steam-vr/notify.wav new file mode 100644 index 00000000..992b9bb7 Binary files /dev/null and b/audio/ui/steam-vr/notify.wav differ diff --git a/audio/ui/steam-vr/popup-close.wav b/audio/ui/steam-vr/popup-close.wav new file mode 100644 index 00000000..70644f92 Binary files /dev/null and b/audio/ui/steam-vr/popup-close.wav differ diff --git a/audio/ui/steam-vr/popup-open.wav b/audio/ui/steam-vr/popup-open.wav new file mode 100644 index 00000000..7ca7b124 Binary files /dev/null and b/audio/ui/steam-vr/popup-open.wav differ diff --git a/audio/ui/steam-vr/typing.wav b/audio/ui/steam-vr/typing.wav new file mode 100644 index 00000000..38d10920 Binary files /dev/null and b/audio/ui/steam-vr/typing.wav differ diff --git a/audio/ui/switch-2/back.wav b/audio/ui/switch-2/back.wav new file mode 100644 index 00000000..d0925dfc Binary files /dev/null and b/audio/ui/switch-2/back.wav differ diff --git a/audio/ui/switch-2/button.wav b/audio/ui/switch-2/button.wav new file mode 100644 index 00000000..270a2a70 Binary files /dev/null and b/audio/ui/switch-2/button.wav differ diff --git a/audio/ui/switch-2/limit.wav b/audio/ui/switch-2/limit.wav new file mode 100644 index 00000000..1c028b51 Binary files /dev/null and b/audio/ui/switch-2/limit.wav differ diff --git a/audio/ui/switch-2/move.wav b/audio/ui/switch-2/move.wav new file mode 100644 index 00000000..2be7771f Binary files /dev/null and b/audio/ui/switch-2/move.wav differ diff --git a/audio/ui/switch-2/notify.wav b/audio/ui/switch-2/notify.wav new file mode 100644 index 00000000..94b192be Binary files /dev/null and b/audio/ui/switch-2/notify.wav differ diff --git a/audio/ui/switch-2/play.wav b/audio/ui/switch-2/play.wav new file mode 100644 index 00000000..f7ac46fb Binary files /dev/null and b/audio/ui/switch-2/play.wav differ diff --git a/audio/ui/switch-2/popup-close.wav b/audio/ui/switch-2/popup-close.wav new file mode 100644 index 00000000..4625728f Binary files /dev/null and b/audio/ui/switch-2/popup-close.wav differ diff --git a/audio/ui/switch-2/popup-open.wav b/audio/ui/switch-2/popup-open.wav new file mode 100644 index 00000000..442e07de Binary files /dev/null and b/audio/ui/switch-2/popup-open.wav differ diff --git a/audio/ui/switch-2/typing.wav b/audio/ui/switch-2/typing.wav new file mode 100644 index 00000000..be56e697 Binary files /dev/null and b/audio/ui/switch-2/typing.wav differ diff --git a/audio/ui/switch/limit.wav b/audio/ui/switch/limit.wav new file mode 100644 index 00000000..9aafc753 Binary files /dev/null and b/audio/ui/switch/limit.wav differ diff --git a/audio/ui/switch/notify.wav b/audio/ui/switch/notify.wav new file mode 100644 index 00000000..faf8f60b Binary files /dev/null and b/audio/ui/switch/notify.wav differ diff --git a/audio/ui/switch/popup-close.wav b/audio/ui/switch/popup-close.wav new file mode 100644 index 00000000..39762356 Binary files /dev/null and b/audio/ui/switch/popup-close.wav differ diff --git a/audio/ui/switch/popup-open.wav b/audio/ui/switch/popup-open.wav new file mode 100644 index 00000000..2071b5b7 Binary files /dev/null and b/audio/ui/switch/popup-open.wav differ diff --git a/audio/ui/switch/typing.wav b/audio/ui/switch/typing.wav new file mode 100644 index 00000000..60902305 Binary files /dev/null and b/audio/ui/switch/typing.wav differ diff --git a/audio/ui/tactile/back.wav b/audio/ui/tactile/back.wav deleted file mode 100644 index 48beffd7..00000000 Binary files a/audio/ui/tactile/back.wav and /dev/null differ diff --git a/audio/ui/tactile/button.wav b/audio/ui/tactile/button.wav deleted file mode 100644 index eb808d81..00000000 Binary files a/audio/ui/tactile/button.wav and /dev/null differ diff --git a/audio/ui/tactile/move.wav b/audio/ui/tactile/move.wav deleted file mode 100644 index 2979ac6f..00000000 Binary files a/audio/ui/tactile/move.wav and /dev/null differ diff --git a/audio/ui/tactile/play.wav b/audio/ui/tactile/play.wav deleted file mode 100644 index afc9fe32..00000000 Binary files a/audio/ui/tactile/play.wav and /dev/null differ diff --git a/audio/ui/winhanced/limit.wav b/audio/ui/winhanced/limit.wav new file mode 100644 index 00000000..630c992d Binary files /dev/null and b/audio/ui/winhanced/limit.wav differ diff --git a/audio/ui/winhanced/notify.wav b/audio/ui/winhanced/notify.wav new file mode 100644 index 00000000..0d505df3 Binary files /dev/null and b/audio/ui/winhanced/notify.wav differ diff --git a/audio/ui/winhanced/popup-close.wav b/audio/ui/winhanced/popup-close.wav new file mode 100644 index 00000000..b137efa1 Binary files /dev/null and b/audio/ui/winhanced/popup-close.wav differ diff --git a/audio/ui/winhanced/popup-open.wav b/audio/ui/winhanced/popup-open.wav new file mode 100644 index 00000000..630c992d Binary files /dev/null and b/audio/ui/winhanced/popup-open.wav differ diff --git a/audio/ui/winhanced/typing.wav b/audio/ui/winhanced/typing.wav new file mode 100644 index 00000000..630c992d Binary files /dev/null and b/audio/ui/winhanced/typing.wav differ diff --git a/audio/ui/xbox-360/back.wav b/audio/ui/xbox-360/back.wav new file mode 100644 index 00000000..39e8d8f2 Binary files /dev/null and b/audio/ui/xbox-360/back.wav differ diff --git a/audio/ui/xbox-360/button.wav b/audio/ui/xbox-360/button.wav new file mode 100644 index 00000000..09f1c039 Binary files /dev/null and b/audio/ui/xbox-360/button.wav differ diff --git a/audio/ui/xbox-360/limit.wav b/audio/ui/xbox-360/limit.wav new file mode 100644 index 00000000..fe4a40e5 Binary files /dev/null and b/audio/ui/xbox-360/limit.wav differ diff --git a/audio/ui/xbox-360/move.wav b/audio/ui/xbox-360/move.wav new file mode 100644 index 00000000..fe4a40e5 Binary files /dev/null and b/audio/ui/xbox-360/move.wav differ diff --git a/audio/ui/xbox-360/notify.wav b/audio/ui/xbox-360/notify.wav new file mode 100644 index 00000000..30dc633f Binary files /dev/null and b/audio/ui/xbox-360/notify.wav differ diff --git a/audio/ui/xbox-360/play.wav b/audio/ui/xbox-360/play.wav new file mode 100644 index 00000000..7fea24b0 Binary files /dev/null and b/audio/ui/xbox-360/play.wav differ diff --git a/audio/ui/xbox-360/popup-close.wav b/audio/ui/xbox-360/popup-close.wav new file mode 100644 index 00000000..15ed95e7 Binary files /dev/null and b/audio/ui/xbox-360/popup-close.wav differ diff --git a/audio/ui/xbox-360/popup-open.wav b/audio/ui/xbox-360/popup-open.wav new file mode 100644 index 00000000..6200d9f1 Binary files /dev/null and b/audio/ui/xbox-360/popup-open.wav differ diff --git a/audio/ui/xbox-360/typing.wav b/audio/ui/xbox-360/typing.wav new file mode 100644 index 00000000..09f1c039 Binary files /dev/null and b/audio/ui/xbox-360/typing.wav differ diff --git a/audio/ui/xbox/back.wav b/audio/ui/xbox/back.wav new file mode 100644 index 00000000..7cc2c726 Binary files /dev/null and b/audio/ui/xbox/back.wav differ diff --git a/audio/ui/xbox/button.wav b/audio/ui/xbox/button.wav new file mode 100644 index 00000000..9af4759f Binary files /dev/null and b/audio/ui/xbox/button.wav differ diff --git a/audio/ui/xbox/limit.wav b/audio/ui/xbox/limit.wav new file mode 100644 index 00000000..a6052996 Binary files /dev/null and b/audio/ui/xbox/limit.wav differ diff --git a/audio/ui/xbox/move.wav b/audio/ui/xbox/move.wav new file mode 100644 index 00000000..af6928f9 Binary files /dev/null and b/audio/ui/xbox/move.wav differ diff --git a/audio/ui/xbox/notify.wav b/audio/ui/xbox/notify.wav new file mode 100644 index 00000000..fe805c62 Binary files /dev/null and b/audio/ui/xbox/notify.wav differ diff --git a/audio/ui/xbox/play.wav b/audio/ui/xbox/play.wav new file mode 100644 index 00000000..cd262dec Binary files /dev/null and b/audio/ui/xbox/play.wav differ diff --git a/audio/ui/xbox/popup-close.wav b/audio/ui/xbox/popup-close.wav new file mode 100644 index 00000000..979954c3 Binary files /dev/null and b/audio/ui/xbox/popup-close.wav differ diff --git a/audio/ui/xbox/popup-open.wav b/audio/ui/xbox/popup-open.wav new file mode 100644 index 00000000..8c723235 Binary files /dev/null and b/audio/ui/xbox/popup-open.wav differ diff --git a/audio/ui/xbox/typing.wav b/audio/ui/xbox/typing.wav new file mode 100644 index 00000000..f08a8f83 Binary files /dev/null and b/audio/ui/xbox/typing.wav differ diff --git a/electron-builder.yml b/electron-builder.yml index a63d9871..4031a3d4 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -58,6 +58,49 @@ linux: - from: resources/umu to: umu +# macOS build (arm64 only — Д7: 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: -` +# 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 +# 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). +# +# 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 +# "optimize" it out of the macOS build. +mac: + icon: assets/icon.icns + category: public.app-category.games + identity: '-' + # Hardened runtime is electron-builder's default, and it is wrong for this build: it only buys anything + # together with notarization (which needs a Developer ID we do not have), while its library validation + # refuses to load the ad-hoc-signed native modules unpacked from the asar (drivelist, koffi) — the app + # would build cleanly and then fail at startup. + hardenedRuntime: false + target: + - target: dmg + arch: + - arm64 + - target: zip + arch: + - arm64 + extendInfo: + NSAppleEventsUsageDescription: >- + Playhook uses System Events to shut down or restart this Mac from its power menu. + NSRemovableVolumesUsageDescription: >- + Playhook reads game cards from removable drives to find the games on them. + # Auto-update channel (electron-updater): the app reads `latest.yml` + the installer from this repo's # GitHub Releases. Public repo → no token on the client. Update `owner`/`repo` if the GitHub repo is # renamed. Only the `nsis` target self-updates; the `portable` exe does not. diff --git a/eslint.config.mjs b/eslint.config.mjs index 4dd02969..16a76e69 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,8 +1,8 @@ // 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 // strict-boolean-expressions catches implicit nullable/number truthiness. eslint-config-prettier is -// applied last so no lint rule fights the formatter. Tests and build output are not linted (they live -// outside the typechecked src program). +// 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. // @ts-check import eslint from '@eslint/js'; import tseslint from 'typescript-eslint'; @@ -10,12 +10,12 @@ import prettier from 'eslint-config-prettier'; export default tseslint.config( { - ignores: ['dist/**', 'node_modules/**', 'release/**', 'scripts/**', 'test/**', '*.config.*'], + ignores: ['dist/**', 'node_modules/**', 'release/**', 'scripts/**', '*.config.*'], }, eslint.configs.recommended, ...tseslint.configs.recommendedTypeChecked, { - files: ['src/**/*.ts'], + files: ['src/**/*.ts', 'test/**/*.ts'], languageOptions: { parserOptions: { projectService: true, @@ -37,5 +37,17 @@ export default tseslint.config( ], }, }, + { + files: ['test/**/*.ts'], + rules: { + // A fake implementing an async interface has nothing to await, and an assertion on a fake's method + // references it unbound on purpose — both fire on every test double, and neither says anything. + '@typescript-eslint/require-await': 'off', + '@typescript-eslint/unbound-method': 'off', + // A fake's signature is dictated by the interface it stands in for, so a parameter it has no use + // for is marked with the leading underscore src already uses for the same thing (`_event`). + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + }, + }, prettier, ); diff --git a/node_modules b/node_modules deleted file mode 120000 index c3f92a75..00000000 --- a/node_modules +++ /dev/null @@ -1 +0,0 @@ -/Users/sevenns/Development/projects/personal/playhook/node_modules \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 53202180..f5489ed5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,22 +1,15 @@ { "name": "playhook", - "version": "0.7.0", + "version": "0.8.0-alpha.201", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "playhook", - "version": "0.7.0", + "version": "0.8.0-alpha.201", "hasInstallScript": true, "license": "MIT", "dependencies": { - "@codemirror/lang-json": "^6.0.2", - "@fluentui/tokens": "^1.0.0-alpha.23", - "@fluentui/web-components": "^3.0.1", - "@microsoft/fast-element": "^3.0.1", - "@microsoft/focusgroup-polyfill": "^1.5.0", - "codemirror": "^6.0.2", - "codemirror-json-schema": "^0.8.1", "drivelist": "^12.0.2", "electron-updater": "^6.8.9", "fs-extra": "^11.2.0", @@ -32,119 +25,13 @@ "esbuild": "^0.28.1", "eslint": "^9.39.4", "eslint-config-prettier": "^9.1.2", + "happy-dom": "^20.11.6", "prettier": "^3.9.4", "typescript": "^5.5.0", "typescript-eslint": "^8.62.1", "vitest": "^2.1.9" } }, - "node_modules/@codemirror/autocomplete": { - "version": "6.20.3", - "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", - "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", - "license": "MIT", - "dependencies": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.17.0", - "@lezer/common": "^1.0.0" - } - }, - "node_modules/@codemirror/commands": { - "version": "6.10.4", - "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz", - "integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==", - "license": "MIT", - "dependencies": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.7.0", - "@codemirror/view": "^6.27.0", - "@lezer/common": "^1.1.0" - } - }, - "node_modules/@codemirror/lang-json": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", - "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", - "license": "MIT", - "dependencies": { - "@codemirror/language": "^6.0.0", - "@lezer/json": "^1.0.0" - } - }, - "node_modules/@codemirror/lang-yaml": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz", - "integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "@codemirror/autocomplete": "^6.0.0", - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.2.0", - "@lezer/lr": "^1.0.0", - "@lezer/yaml": "^1.0.0" - } - }, - "node_modules/@codemirror/language": { - "version": "6.12.4", - "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", - "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", - "license": "MIT", - "dependencies": { - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.23.0", - "@lezer/common": "^1.5.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0", - "style-mod": "^4.0.0" - } - }, - "node_modules/@codemirror/lint": { - "version": "6.9.7", - "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", - "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", - "license": "MIT", - "dependencies": { - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.42.0", - "crelt": "^1.0.5" - } - }, - "node_modules/@codemirror/search": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz", - "integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==", - "license": "MIT", - "dependencies": { - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.37.0", - "crelt": "^1.0.5" - } - }, - "node_modules/@codemirror/state": { - "version": "6.7.0", - "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.0.tgz", - "integrity": "sha512-Zbl9NyscLMZkfXPQnNAIIAFftidrA1UbcJEIMp24C0Bukc2I5T8wJS0wsXYsnDOqCFJUeJ1BITGNs5CqPDSmSg==", - "license": "MIT", - "dependencies": { - "@marijn/find-cluster-break": "^1.0.0" - } - }, - "node_modules/@codemirror/view": { - "version": "6.43.4", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.4.tgz", - "integrity": "sha512-YImu23iyKfncJzT7sRy+rEqEhSc8RhOHqDxwy4WzXRKJwYm6iwf/9OJk5ctCAdZ6yi2ZqaGEvmf55fSVqMDrgg==", - "license": "MIT", - "dependencies": { - "@codemirror/state": "^6.7.0", - "crelt": "^1.0.6", - "style-mod": "^4.1.0", - "w3c-keyname": "^2.2.4" - } - }, "node_modules/@electron-internal/extract-zip": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.4.tgz", @@ -1078,32 +965,6 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@fluentui/tokens": { - "version": "1.0.0-alpha.23", - "resolved": "https://registry.npmjs.org/@fluentui/tokens/-/tokens-1.0.0-alpha.23.tgz", - "integrity": "sha512-uxrzF9Z+J10naP0pGS7zPmzSkspSS+3OJDmYIK3o1nkntQrgBXq3dBob4xSlTDm5aOQ0kw6EvB9wQgtlyy4eKQ==", - "license": "MIT", - "dependencies": { - "@swc/helpers": "^0.5.1" - } - }, - "node_modules/@fluentui/web-components": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@fluentui/web-components/-/web-components-3.0.1.tgz", - "integrity": "sha512-XkFK/q+ubX7u88kJ8vHmn/S8hdpeQ9c0F9JGBs60LgDbXcmbGzdKgkMxu5DRg+ACzaASLC3v20kIpHyBG0riBg==", - "license": "MIT", - "dependencies": { - "@fluentui/tokens": "^1.0.0-alpha.23", - "tslib": "^2.1.0" - }, - "engines": { - "node": "^22.0.0 || ^24.0.0" - }, - "peerDependencies": { - "@microsoft/fast-element": "^3.0.0", - "@microsoft/focusgroup-polyfill": "^1.5.0" - } - }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1414,53 +1275,6 @@ "url": "https://liberapay.com/Koromix" } }, - "node_modules/@lezer/common": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", - "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", - "license": "MIT" - }, - "node_modules/@lezer/highlight": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", - "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", - "license": "MIT", - "dependencies": { - "@lezer/common": "^1.3.0" - } - }, - "node_modules/@lezer/json": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", - "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", - "license": "MIT", - "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" - } - }, - "node_modules/@lezer/lr": { - "version": "1.4.10", - "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", - "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", - "license": "MIT", - "dependencies": { - "@lezer/common": "^1.0.0" - } - }, - "node_modules/@lezer/yaml": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz", - "integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==", - "license": "MIT", - "optional": true, - "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.4.0" - } - }, "node_modules/@malept/cross-spawn-promise": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", @@ -1516,24 +1330,6 @@ "node": ">=10" } }, - "node_modules/@marijn/find-cluster-break": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", - "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==", - "license": "MIT" - }, - "node_modules/@microsoft/fast-element": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@microsoft/fast-element/-/fast-element-3.0.1.tgz", - "integrity": "sha512-euVlL8v7EAnkYD9gf6xhnY+XgzCkD0hHwC0MVsUVyhnKfGGhrNyBeD5kYnBOCcJ9afL2jTQoOA+oOpLEty3s+A==", - "license": "MIT" - }, - "node_modules/@microsoft/focusgroup-polyfill": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@microsoft/focusgroup-polyfill/-/focusgroup-polyfill-1.5.0.tgz", - "integrity": "sha512-2tw+AISULD5xhP+wIqUheej5wZbwiuPaqjuCa8A8jLdq2ikklZBIvSDLLa6j7zbDbtV5BXnrYgSuyW1Bj5JA4A==", - "license": "MIT" - }, "node_modules/@noble/hashes": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", @@ -1949,101 +1745,6 @@ "win32" ] }, - "node_modules/@sagold/json-pointer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@sagold/json-pointer/-/json-pointer-5.1.2.tgz", - "integrity": "sha512-+wAhJZBXa6MNxRScg6tkqEbChEHMgVZAhTHVJ60Y7sbtXtu9XA49KfUkdWlS2x78D6H9nryiKePiYozumauPfA==", - "license": "MIT" - }, - "node_modules/@sagold/json-query": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/@sagold/json-query/-/json-query-6.2.0.tgz", - "integrity": "sha512-7bOIdUE6eHeoWtFm8TvHQHfTVSZuCs+3RpOKmZCDBIOrxpvF/rNFTeuvIyjHva/RR0yVS3kQtr+9TW72LQEZjA==", - "license": "MIT", - "dependencies": { - "@sagold/json-pointer": "^5.1.2", - "ebnf": "^1.9.1" - } - }, - "node_modules/@shikijs/core": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-1.29.2.tgz", - "integrity": "sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ==", - "license": "MIT", - "dependencies": { - "@shikijs/engine-javascript": "1.29.2", - "@shikijs/engine-oniguruma": "1.29.2", - "@shikijs/types": "1.29.2", - "@shikijs/vscode-textmate": "^10.0.1", - "@types/hast": "^3.0.4", - "hast-util-to-html": "^9.0.4" - } - }, - "node_modules/@shikijs/engine-javascript": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-1.29.2.tgz", - "integrity": "sha512-iNEZv4IrLYPv64Q6k7EPpOCE/nuvGiKl7zxdq0WFuRPF5PAE9PRo2JGq/d8crLusM59BRemJ4eOqrFrC4wiQ+A==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "1.29.2", - "@shikijs/vscode-textmate": "^10.0.1", - "oniguruma-to-es": "^2.2.0" - } - }, - "node_modules/@shikijs/engine-oniguruma": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-1.29.2.tgz", - "integrity": "sha512-7iiOx3SG8+g1MnlzZVDYiaeHe7Ez2Kf2HrJzdmGwkRisT7r4rak0e655AcM/tF9JG/kg5fMNYlLLKglbN7gBqA==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "1.29.2", - "@shikijs/vscode-textmate": "^10.0.1" - } - }, - "node_modules/@shikijs/langs": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-1.29.2.tgz", - "integrity": "sha512-FIBA7N3LZ+223U7cJDUYd5shmciFQlYkFXlkKVaHsCPgfVLiO+e12FmQE6Tf9vuyEsFe3dIl8qGWKXgEHL9wmQ==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "1.29.2" - } - }, - "node_modules/@shikijs/markdown-it": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/@shikijs/markdown-it/-/markdown-it-1.29.2.tgz", - "integrity": "sha512-RPHqGU8RGQZ2TGMnEqLnSyM9CjPSjb0f8bwSLnJgBmWPWguoygoaFyYkXG0kwMtBtChNYsqQz1C0fLcbo6dY8g==", - "license": "MIT", - "dependencies": { - "markdown-it": "^14.1.0", - "shiki": "1.29.2" - } - }, - "node_modules/@shikijs/themes": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-1.29.2.tgz", - "integrity": "sha512-i9TNZlsq4uoyqSbluIcZkmPL9Bfi3djVxRnofUHwvx/h6SRW3cwgBC5SML7vsDcWyukY0eCzVN980rqP6qNl9g==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "1.29.2" - } - }, - "node_modules/@shikijs/types": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-1.29.2.tgz", - "integrity": "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw==", - "license": "MIT", - "dependencies": { - "@shikijs/vscode-textmate": "^10.0.1", - "@types/hast": "^3.0.4" - } - }, - "node_modules/@shikijs/vscode-textmate": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", - "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", - "license": "MIT" - }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -2057,15 +1758,6 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, - "node_modules/@swc/helpers": { - "version": "0.5.23", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", - "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.8.0" - } - }, "node_modules/@szmarczak/http-timer": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", @@ -2120,15 +1812,6 @@ "@types/node": "*" } }, - "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, "node_modules/@types/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -2163,15 +1846,6 @@ "@types/node": "*" } }, - "node_modules/@types/mdast": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", - "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -2199,12 +1873,23 @@ "@types/node": "*" } }, - "node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.62.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz", @@ -2448,12 +2133,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.2.tgz", - "integrity": "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==", - "license": "ISC" - }, "node_modules/@vitest/expect": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", @@ -3000,12 +2679,6 @@ ], "license": "MIT" }, - "node_modules/best-effort-json-parser": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/best-effort-json-parser/-/best-effort-json-parser-1.5.1.tgz", - "integrity": "sha512-Snlb3OTA7lnOW1vZyPByO/cWl3wDElisGId4BIEhmPGOWJaEyGpXoRgaIFXDIRh59NmIiuTY2p0YPq89sfwjWw==", - "license": "BSD-2-Clause" - }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -3096,6 +2769,19 @@ "dev": true, "license": "MIT" }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/builder-util": { "version": "26.15.3", "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.15.3.tgz", @@ -3223,16 +2909,6 @@ "node": ">=6" } }, - "node_modules/ccount": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -3267,26 +2943,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/character-entities-html4": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", - "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-legacy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", - "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", @@ -3358,68 +3014,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/codemirror": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", - "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", - "license": "MIT", - "dependencies": { - "@codemirror/autocomplete": "^6.0.0", - "@codemirror/commands": "^6.0.0", - "@codemirror/language": "^6.0.0", - "@codemirror/lint": "^6.0.0", - "@codemirror/search": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0" - } - }, - "node_modules/codemirror-json-schema": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/codemirror-json-schema/-/codemirror-json-schema-0.8.1.tgz", - "integrity": "sha512-4lKPjW+nugNAmM5MsggJyn6TUxYdCCwAJIr9T4cZeTFPdkbBvPteCOGtDedrTOIeTC2ZFJtVg7VHIXnYU32t8w==", - "license": "MIT", - "dependencies": { - "@sagold/json-pointer": "^5.1.1", - "@shikijs/markdown-it": "^1.22.2", - "best-effort-json-parser": "^1.1.2", - "json-schema": "^0.4.0", - "json-schema-library": "^9.3.5", - "loglevel": "^1.9.1", - "markdown-it": "^14.1.0", - "shiki": "^1.22.2", - "yaml": "^2.3.4" - }, - "optionalDependencies": { - "@codemirror/autocomplete": "^6.16.2", - "@codemirror/lang-json": "^6.0.1", - "@codemirror/lang-yaml": "^6.1.1", - "codemirror-json5": "^1.0.3", - "json5": "^2.2.3" - }, - "peerDependencies": { - "@codemirror/language": "^6.10.2", - "@codemirror/lint": "^6.8.0", - "@codemirror/state": "^6.4.1", - "@codemirror/view": "^6.27.0", - "@lezer/common": "^1.2.1" - } - }, - "node_modules/codemirror-json5": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/codemirror-json5/-/codemirror-json5-1.0.3.tgz", - "integrity": "sha512-HmmoYO2huQxoaoG5ARKjqQc9mz7/qmNPvMbISVfIE2Gk1+4vZQg9X3G6g49MYM5IK00Ol3aijd7OKrySuOkA7Q==", - "license": "MIT", - "optional": true, - "dependencies": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", - "@lezer/common": "^1.0.0", - "@lezer/highlight": "^1.0.0", - "json5": "^2.2.1", - "lezer-json5": "^2.0.2" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3453,16 +3047,6 @@ "node": ">= 0.8" } }, - "node_modules/comma-separated-tokens": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", - "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/commander": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", @@ -3497,12 +3081,6 @@ "dev": true, "license": "MIT" }, - "node_modules/crelt": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", - "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", - "license": "MIT" - }, "node_modules/cross-dirname": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", @@ -3597,15 +3175,6 @@ "dev": true, "license": "MIT" }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/defer-to-connect": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", @@ -3664,15 +3233,6 @@ "node": ">=0.4.0" } }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3690,19 +3250,6 @@ "license": "MIT", "optional": true }, - "node_modules/devlop": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", - "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", - "license": "MIT", - "dependencies": { - "dequal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/dir-compare": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", @@ -3738,12 +3285,6 @@ "node": "*" } }, - "node_modules/discontinuous-range": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", - "integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==", - "license": "MIT" - }, "node_modules/dmg-builder": { "version": "26.15.3", "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.15.3.tgz", @@ -3875,15 +3416,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/ebnf": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ebnf/-/ebnf-1.9.1.tgz", - "integrity": "sha512-uW2UKSsuty9ANJ3YByIQE4ANkD8nqUPO7r6Fwcc1ADKPe9FRdcPpMl3VEput4JSvKBJ4J86npIC2MLP0pYkCuw==", - "license": "MIT", - "bin": { - "ebnf": "dist/bin.js" - } - }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -4125,12 +3657,6 @@ "dev": true, "license": "MIT" }, - "node_modules/emoji-regex-xs": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", - "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", - "license": "MIT" - }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -4141,9 +3667,10 @@ } }, "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -4528,16 +4055,11 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/fast-copy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz", - "integrity": "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==", - "license": "MIT" - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -4973,6 +4495,25 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/happy-dom": { + "version": "20.11.6", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.6.tgz", + "integrity": "sha512-Hldbg8AdAa5a5oDcZpjqnGitp7JB0hqWmfv/8qr+kft4vzSD8BHsbdRfzYvL/0QcbKcURC/yyoygbeDQarPvYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -5039,63 +4580,17 @@ "node": ">= 0.4" } }, - "node_modules/hast-util-to-html": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", - "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", - "license": "MIT", + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "ccount": "^2.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-whitespace": "^3.0.0", - "html-void-elements": "^3.0.0", - "mdast-util-to-hast": "^13.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "stringify-entities": "^4.0.0", - "zwitch": "^2.0.4" + "lru-cache": "^6.0.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-whitespace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", - "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hosted-git-info": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", - "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", - "dev": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/html-void-elements": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", - "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "engines": { + "node": ">=10" } }, "node_modules/http-cache-semantics": { @@ -5345,27 +4840,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-schema": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", - "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", - "license": "(AFL-2.1 OR BSD-3-Clause)" - }, - "node_modules/json-schema-library": { - "version": "9.3.5", - "resolved": "https://registry.npmjs.org/json-schema-library/-/json-schema-library-9.3.5.tgz", - "integrity": "sha512-5eBDx7cbfs+RjylsVO+N36b0GOPtv78rfqgf2uON+uaHUIC62h63Y8pkV2ovKbaL4ZpQcHp21968x5nx/dFwqQ==", - "license": "MIT", - "dependencies": { - "@sagold/json-pointer": "^5.1.2", - "@sagold/json-query": "^6.1.3", - "deepmerge": "^4.3.1", - "fast-copy": "^3.0.2", - "fast-deep-equal": "^3.1.3", - "smtp-address-parser": "1.0.10", - "valid-url": "^1.0.9" - } - }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -5392,7 +4866,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -5469,35 +4943,6 @@ "node": ">= 0.8.0" } }, - "node_modules/lezer-json5": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lezer-json5/-/lezer-json5-2.0.2.tgz", - "integrity": "sha512-NRmtBlKW/f8mA7xatKq8IUOq045t8GVHI4kZXrUtYYUdiVeGiO6zKGAV7/nUAnf5q+rYTY+SWX/gvQdFXMjNxQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "@lezer/lr": "^1.0.0" - } - }, - "node_modules/linkify-it": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", - "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/markdown-it" - } - ], - "license": "MIT", - "dependencies": { - "uc.micro": "^2.0.0" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -5541,19 +4986,6 @@ "dev": true, "license": "MIT" }, - "node_modules/loglevel": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", - "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - }, - "funding": { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/loglevel" - } - }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -5594,33 +5026,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/markdown-it": { - "version": "14.3.0", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz", - "integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/markdown-it" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1", - "entities": "^4.5.0", - "linkify-it": "^5.0.2", - "mdurl": "^2.0.0", - "punycode.js": "^2.3.1", - "uc.micro": "^2.1.0" - }, - "bin": { - "markdown-it": "bin/markdown-it.mjs" - } - }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", @@ -5645,122 +5050,6 @@ "node": ">= 0.4" } }, - "node_modules/mdast-util-to-hast": { - "version": "13.2.1", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", - "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@ungap/structured-clone": "^1.0.0", - "devlop": "^1.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "trim-lines": "^3.0.0", - "unist-util-position": "^5.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", - "license": "MIT" - }, - "node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-encode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", - "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-sanitize-uri": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", - "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-types": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", - "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, "node_modules/mime": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", @@ -5875,12 +5164,6 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, - "node_modules/moo": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.3.tgz", - "integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==", - "license": "BSD-3-Clause" - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5919,34 +5202,6 @@ "dev": true, "license": "MIT" }, - "node_modules/nearley": { - "version": "2.20.1", - "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz", - "integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==", - "license": "MIT", - "dependencies": { - "commander": "^2.19.0", - "moo": "^0.5.0", - "railroad-diagrams": "^1.0.0", - "randexp": "0.4.6" - }, - "bin": { - "nearley-railroad": "bin/nearley-railroad.js", - "nearley-test": "bin/nearley-test.js", - "nearley-unparse": "bin/nearley-unparse.js", - "nearleyc": "bin/nearleyc.js" - }, - "funding": { - "type": "individual", - "url": "https://nearley.js.org/#give-to-nearley" - } - }, - "node_modules/nearley/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT" - }, "node_modules/node-abi": { "version": "3.92.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", @@ -6105,17 +5360,6 @@ "wrappy": "1" } }, - "node_modules/oniguruma-to-es": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-2.3.0.tgz", - "integrity": "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g==", - "license": "MIT", - "dependencies": { - "emoji-regex-xs": "^1.0.0", - "regex": "^5.1.1", - "regex-recursion": "^5.1.1" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -6482,16 +5726,6 @@ "signal-exit": "^3.0.2" } }, - "node_modules/property-information": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", - "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -6512,15 +5746,6 @@ "node": ">=6" } }, - "node_modules/punycode.js": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", - "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/pvtsutils": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", @@ -6554,25 +5779,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/railroad-diagrams": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", - "integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==", - "license": "CC0-1.0" - }, - "node_modules/randexp": { - "version": "0.4.6", - "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz", - "integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==", - "license": "MIT", - "dependencies": { - "discontinuous-range": "1.0.0", - "ret": "~0.1.10" - }, - "engines": { - "node": ">=0.12" - } - }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -6615,31 +5821,6 @@ "node": ">= 6" } }, - "node_modules/regex": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/regex/-/regex-5.1.1.tgz", - "integrity": "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw==", - "license": "MIT", - "dependencies": { - "regex-utilities": "^2.3.0" - } - }, - "node_modules/regex-recursion": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-5.1.1.tgz", - "integrity": "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w==", - "license": "MIT", - "dependencies": { - "regex": "^5.1.1", - "regex-utilities": "^2.3.0" - } - }, - "node_modules/regex-utilities": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", - "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", - "license": "MIT" - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -6708,15 +5889,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ret": { - "version": "0.1.15", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", - "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", - "license": "MIT", - "engines": { - "node": ">=0.12" - } - }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -6905,22 +6077,6 @@ "node": ">=8" } }, - "node_modules/shiki": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-1.29.2.tgz", - "integrity": "sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg==", - "license": "MIT", - "dependencies": { - "@shikijs/core": "1.29.2", - "@shikijs/engine-javascript": "1.29.2", - "@shikijs/engine-oniguruma": "1.29.2", - "@shikijs/langs": "1.29.2", - "@shikijs/themes": "1.29.2", - "@shikijs/types": "1.29.2", - "@shikijs/vscode-textmate": "^10.0.1", - "@types/hast": "^3.0.4" - } - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -6993,18 +6149,6 @@ "node": ">=10" } }, - "node_modules/smtp-address-parser": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/smtp-address-parser/-/smtp-address-parser-1.0.10.tgz", - "integrity": "sha512-Osg9LmvGeAG/hyao4mldbflLOkkr3a+h4m1lwKCK5U8M6ZAr7tdXEz/+/vr752TSGE4MNUlUl9cIK2cB8cgzXg==", - "license": "MIT", - "dependencies": { - "nearley": "^2.20.1" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -7036,16 +6180,6 @@ "source-map": "^0.6.0" } }, - "node_modules/space-separated-tokens": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", - "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", @@ -7102,20 +6236,6 @@ "node": ">=8" } }, - "node_modules/stringify-entities": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", - "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", - "license": "MIT", - "dependencies": { - "character-entities-html4": "^2.0.0", - "character-entities-legacy": "^3.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -7138,12 +6258,6 @@ "node": ">=0.10.0" } }, - "node_modules/style-mod": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", - "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", - "license": "MIT" - }, "node_modules/sumchecker": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", @@ -7379,16 +6493,6 @@ "tmp": "^0.2.0" } }, - "node_modules/trim-lines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", - "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/truncate-utf8-bytes": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", @@ -7416,6 +6520,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, "license": "0BSD" }, "node_modules/tunnel-agent": { @@ -7495,12 +6600,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/uc.micro": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", - "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", - "license": "MIT" - }, "node_modules/undici": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", @@ -7519,74 +6618,6 @@ "dev": true, "license": "MIT" }, - "node_modules/unist-util-is": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", - "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-position": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", - "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", - "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit-parents": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", - "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -7648,39 +6679,6 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/valid-url": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/valid-url/-/valid-url-1.0.9.tgz", - "integrity": "sha512-QQDsV8OnSf5Uc30CKSwG9lnhMPe6exHtTXLRYX8uMwKENy640pU+2BgBL0LRbDh/eYRahNCS7aewCx0wf3NYVA==" - }, - "node_modules/vfile": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", - "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-message": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", - "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -8260,12 +7258,6 @@ } } }, - "node_modules/w3c-keyname": { - "version": "2.2.8", - "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", - "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", - "license": "MIT" - }, "node_modules/webcrypto-core": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", @@ -8280,6 +7272,16 @@ "tslib": "^2.8.1" } }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -8347,6 +7349,28 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", @@ -8374,21 +7398,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yargs": { "version": "17.7.3", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", @@ -8439,16 +7448,6 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } - }, - "node_modules/zwitch": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", - "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } } } } diff --git a/package.json b/package.json index 258312bb..8e78b598 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "playhook", - "version": "0.7.0", + "version": "0.8.0-alpha.201", "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", @@ -8,31 +8,22 @@ "license": "MIT", "scripts": { "typecheck": "tsc --noEmit -p tsconfig.json", - "lint": "eslint src", + "lint": "eslint src test", "format": "prettier --write .", "format:check": "prettier --check .", "test": "vitest run", "build:main": "tsc -p tsconfig.main.json", "build:renderer": "tsc -p tsconfig.renderer.json", "build:app": "esbuild src/renderer/app.ts --bundle --format=esm --platform=browser --outfile=dist/renderer/app.js", - "build:settings": "esbuild src/renderer/settings.ts --bundle --format=esm --platform=browser --outfile=dist/renderer/settings.js", - "build:configure": "esbuild src/renderer/configure.ts --bundle --format=esm --platform=browser --outfile=dist/renderer/configure.js", "build:assets": "node scripts/copy-assets.mjs", "build:umu": "node scripts/fetch-umu.mjs", - "build": "npm run build:renderer && npm run build:app && npm run build:settings && npm run build:configure && npm run build:main && npm run build:assets", + "build": "npm run build:renderer && npm run build:app && npm run build:main && npm run build:assets", "start": "npm run build && electron .", "rebuild": "electron-rebuild -f -w drivelist", "dist": "npm run build && electron-builder", "postinstall": "electron-builder install-app-deps" }, "dependencies": { - "@codemirror/lang-json": "^6.0.2", - "@fluentui/tokens": "^1.0.0-alpha.23", - "@fluentui/web-components": "^3.0.1", - "@microsoft/fast-element": "^3.0.1", - "@microsoft/focusgroup-polyfill": "^1.5.0", - "codemirror": "^6.0.2", - "codemirror-json-schema": "^0.8.1", "drivelist": "^12.0.2", "electron-updater": "^6.8.9", "fs-extra": "^11.2.0", @@ -48,6 +39,7 @@ "esbuild": "^0.28.1", "eslint": "^9.39.4", "eslint-config-prettier": "^9.1.2", + "happy-dom": "^20.11.6", "prettier": "^3.9.4", "typescript": "^5.5.0", "typescript-eslint": "^8.62.1", diff --git a/scripts/copy-assets.mjs b/scripts/copy-assets.mjs index b479743e..18626582 100644 --- a/scripts/copy-assets.mjs +++ b/scripts/copy-assets.mjs @@ -11,9 +11,8 @@ const srcRenderer = resolve(root, 'src/renderer'); const outDist = resolve(root, 'dist'); const outRenderer = resolve(outDist, 'renderer'); -// settings.js / configure.js are NOT here — esbuild emits them straight into dist/renderer (see -// build:settings / build:configure). -const files = ['index.html', 'styles.css', 'settings.html', 'settings.css', 'configure.html', 'configure.css']; +// app.js is NOT here — esbuild emits it straight into dist/renderer (see build:app). +const files = ['index.html', 'styles.css']; const dirs = ['fonts']; await mkdir(outRenderer, { recursive: true }); @@ -27,8 +26,7 @@ for (const name of dirs) { // App icons: copied from assets/ into dist so they ship inside the asar and are usable at runtime. // icon.ico — main app icon (BrowserWindow, tray on Windows; also referenced by electron-builder for // exe/installer). -// icon.png — app icon read by main and handed to the settings window's custom title bar as a data URL -// (its CSP allows img-src data: only); the Linux BrowserWindow/AppImage icon; the tray icon on Linux +// icon.png — the Linux BrowserWindow/AppImage icon; the tray icon on Linux // (a .ico yields an empty nativeImage there); and the Steam shortcut's tile icon + grid logo. // There are no separate icon-tray.* files any more — the tray uses these same two. const icons = ['icon.ico', 'icon.png']; @@ -60,11 +58,17 @@ await writeFile( // data-URI MIME in asset-reader.ts must match — keep the extension in sync with that constant. await cp(resolve(root, 'assets/playhook-wallpaper.jpg'), resolve(outDist, 'wallpaper.jpg')); +// The startup jingle, played once while the boot screen is up (its first seconds are the boot image's, +// the rest plays over the UI arriving — see the boot reveal in app.ts). Main reads it and hands it to the +// renderer as a data URL, so the MIME in asset-reader.ts must match this extension. +await cp(resolve(root, 'assets/playhook-startup.mp3'), resolve(outDist, 'startup.mp3')); + // Steam library artwork for the non-Steam shortcut (Game Mode tile). Copied out to the user's // `userdata//config/grid/` when the shortcut is added — see steam-artwork.ts for the naming. await cp(resolve(root, 'assets/steam'), resolve(outDist, 'steam'), { recursive: true }); console.log( `Copied ${files.length} file(s), ${dirs.length} dir(s), ${icons.length} icon(s), audio ` + - `(${soundSets.length} set(s), ${ambientTracks.length} ambience track(s)), wallpaper and Steam artwork to dist`, + `(${soundSets.length} set(s), ${ambientTracks.length} ambience track(s)), wallpaper, startup jingle ` + + 'and Steam artwork to dist', ); diff --git a/src/main/app-settings.ts b/src/main/app-settings.ts index 9ad495ca..b4724074 100644 --- a/src/main/app-settings.ts +++ b/src/main/app-settings.ts @@ -6,22 +6,19 @@ import path from 'node:path'; import fse from 'fs-extra'; import { z } from 'zod'; -import { - type AppSettings, - type AutoUpdateMode, - type LanguageMode, - type ThemeMode, -} from '../shared/types'; +import { type AppSettings, type AutoUpdateMode, type LanguageMode } from '../shared/types'; import { readJsonValidated, writeJsonAtomic } from './json-store'; -const settingsSchema = z.object({ +const settingsObject = z.object({ schemaVersion: z.literal(1), // `.default` so a partial/older settings.json missing this field (e.g. a half-written file that lost // `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'), - // `.default` makes an older settings.json (written before a field existed) migrate seamlessly: a file - // missing the field parses fine and keeps its other values. + // 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). @@ -33,35 +30,60 @@ const settingsSchema = z.object({ preventScreensaver: z.boolean().default(true), musicVolume: z.number().min(0).max(1).default(0.5), sfxVolume: z.number().min(0).max(1).default(1), - // File name of the custom Empty-screen wallpaper in userData, or null for the bundled default. - // `.default(null)` migrates an older settings.json without the field (no schemaVersion bump). - customWallpaper: z.string().nullable().default(null), - // Keep the empty "no card" screen visible instead of hiding to the tray. `.default(false)` keeps the - // original background-app behaviour for an older settings.json without the field. - alwaysShowEmptyScreen: z.boolean().default(false), + // Stay on screen with no card in instead of hiding to the tray. Defaults ON: the launcher grew its own + // reasons to be up without a card (the library, the local PC games, the settings), so vanishing to the + // tray the moment a card is pulled hides a UI that still has something to show. A file written under + // the old name (alwaysShowEmptyScreen) is carried over by the preprocess below; a file that already has + // the key keeps whatever the user chose, so this only changes what a FRESH install does. + keepOpenWithoutCard: z.boolean().default(true), // Disable trying silent mode for install-mode installers (they show their wizard instead). `.default(false)` // keeps the original silent behaviour for an older settings.json without the field. disableSilentInstall: z.boolean().default(false), // The appid of Playhook's own non-Steam shortcut (Steam Deck), UNSIGNED 32-bit, or null when no shortcut // is registered. This is the ONE persisted representation — the signed on-disk form and the 64-bit // rungameid are derived from it (see platform/steam-appid.ts). `.default(null)` migrates an older - // settings.json without the field, exactly as customWallpaper did (no schemaVersion bump). + // settings.json without the field (no schemaVersion bump). steamAppIdU32: z.number().int().nullable().default(null), // Game Mode auto-launch on card insertion (Steam Deck). `.default(true)` keeps the behaviour that // shipped before the toggle existed for an older settings.json. steamAutoLaunch: z.boolean().default(true), // Navigation sound set (folder under audio/ui/). A plain string, not an enum: sets are enumerated // dynamically from the bundle and validity (folder exists) is checked at read time in AssetReader. - // `.default('winhanced')` migrates an older settings.json without the field (no schemaVersion bump). - soundSet: z.string().default('winhanced'), + // `.default('playhook-abyss')` migrates an older settings.json without the field (no schemaVersion bump). + soundSet: z.string().default('playhook-abyss'), // Default background ambience (file name under audio/ambience/, extension included), or null for none. - // `.default(null)` migrates an older settings.json without the field (no schemaVersion bump). - ambientTrack: z.string().nullable().default(null), + // `.default(…)` migrates an older settings.json without the field (no schemaVersion bump); a track that + // is no longer bundled just doesn't play (AssetReader checks the file before reading it). + ambientTrack: z.string().nullable().default('playhook-abyss.mp3'), // Use only the global ambience, ignoring a card's own background music. `.default(false)` keeps the // "a card's music wins" behaviour for an older settings.json without the field. onlyGlobalAmbient: z.boolean().default(false), + // 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(''), }); +/** + * `alwaysShowEmptyScreen` was renamed to `keepOpenWithoutCard` when the screen it was named after went + * away (the launcher cards replaced it); the SETTING is the same one, so a file written by an older + * build must keep its value. Without this the `.default(true)` above would swallow the missing key + * without a trace and force the toggle ON for everyone who had deliberately turned it off. + * + * No schemaVersion bump — this is the same in-place style of migration `language`, `steamAppIdU32` and + * `soundSet` already use. + */ +const settingsSchema = z.preprocess((raw) => { + if (typeof raw !== 'object' || raw === null) return raw; + const record = raw as Record; + if (!('alwaysShowEmptyScreen' in record) || 'keepOpenWithoutCard' in record) return raw; + const migrated: Record = { + ...record, + keepOpenWithoutCard: record['alwaysShowEmptyScreen'], + }; + delete migrated['alwaysShowEmptyScreen']; + return migrated; +}, settingsObject); + // Default preserves the pre-settings behaviour (silent download + install on next quit), so the // first run / a missing file migrates seamlessly to what the app did before this window existed. export const DEFAULT_SETTINGS: AppSettings = { @@ -74,14 +96,14 @@ export const DEFAULT_SETTINGS: AppSettings = { preventScreensaver: true, musicVolume: 0.5, sfxVolume: 1, - customWallpaper: null, - alwaysShowEmptyScreen: false, + keepOpenWithoutCard: true, disableSilentInstall: false, steamAppIdU32: null, steamAutoLaunch: true, - soundSet: 'winhanced', - ambientTrack: null, + soundSet: 'playhook-abyss', + ambientTrack: 'playhook-abyss.mp3', onlyGlobalAmbient: false, + steamGridDbApiKey: '', }; export class AppSettingsStore { @@ -91,7 +113,23 @@ export class AppSettingsStore { // shared `${settingsPath}.tmp` file. Reads stay OFF the queue (a queued op reads directly — see enqueue). private tail: Promise = Promise.resolve(); - constructor(private readonly baseDir: string) { + /** + * @param baseDir where settings.json lives (the GUI passes app.getPath('userData')). + * @param onChange called with the new snapshot after EVERY successful write — write/patch/reset all + * funnel through persist(), so a new setter can never forget to notify. Optional: the Game Mode + * daemon builds a store with no listener at all. + */ + constructor( + private readonly baseDir: string, + private readonly onChange?: (next: AppSettings) => void, + /** + * Called when a write FAILS, for the one thing every caller would otherwise have to notice on its + * own: a settings change that silently did not stick. Each setter already logs its own failure, but + * the user is looking at a toggle that flipped back — or, for the language, at a UI that did not + * change at all — with nothing to explain it. Optional, like `onChange` (the daemon passes neither). + */ + private readonly onWriteFailed?: (cause: unknown) => void, + ) { this.settingsPath = path.join(baseDir, 'settings.json'); } @@ -109,15 +147,27 @@ export class AppSettingsStore { return result; } - /** Reads settings; returns the default when the file is missing or corrupted (a warn is logged on corruption). */ + /** + * 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. + */ async read(): Promise { - return readJsonValidated(this.settingsPath, settingsSchema, DEFAULT_SETTINGS); + const parsed = await readJsonValidated(this.settingsPath, settingsSchema, DEFAULT_SETTINGS); + return { ...parsed, theme: 'system' }; } /** The actual atomic write — called ONLY from inside a queued op, so it never enqueues (would deadlock). */ private async persist(next: AppSettings): Promise { - await fse.ensureDir(this.baseDir); - await writeJsonAtomic(this.settingsPath, next); + try { + await fse.ensureDir(this.baseDir); + await writeJsonAtomic(this.settingsPath, next); + } catch (cause) { + // Reported here rather than at each setter's own catch: every one of them fails the same way and + // for the same reason, and the user needs telling once, not per setting. + this.onWriteFailed?.(cause); + throw cause; + } + this.onChange?.(next); } write(next: AppSettings): Promise { @@ -140,10 +190,6 @@ export class AppSettingsStore { return this.patch({ autoUpdate: mode }); } - setTheme(mode: ThemeMode): Promise { - return this.patch({ theme: mode }); - } - setLanguage(mode: LanguageMode): Promise { return this.patch({ language: mode }); } diff --git a/src/main/asset-reader.ts b/src/main/asset-reader.ts index 7c65afa4..eec0e130 100644 --- a/src/main/asset-reader.ts +++ b/src/main/asset-reader.ts @@ -30,8 +30,8 @@ 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 Configure-game window's file picker builds its dialog filters - * from these (see game-config.ts pickPath) — keeping the "what can be a hero image / a sound" answer in + * 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 * lockstep with what this reader actually decodes. */ export const IMAGE_EXTENSIONS: readonly string[] = Object.keys(IMAGE_MIME).map((ext) => ext.slice(1)); @@ -39,11 +39,20 @@ export const AUDIO_EXTENSIONS: readonly string[] = Object.keys(AUDIO_MIME).map(( /** * Reads an image file into a base64 data URL (or undefined on any failure). A free function so both the - * AssetReader instance (hero delivery) and the Configure window's hero-preview handler share one path. + * AssetReader instance (hero delivery) and the Customize screen's thumbnail handler share one path. + * + * 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). */ export async function readImageDataUrl(filePath: string): Promise { + const mime = IMAGE_MIME[path.extname(filePath).toLowerCase()]; + if (mime === undefined) { + log.warn(`[image] refusing to read "${filePath}": not an image extension`); + return undefined; + } try { - const mime = IMAGE_MIME[path.extname(filePath).toLowerCase()] ?? 'application/octet-stream'; const buffer = await fse.readFile(filePath); return `data:${mime};base64,${buffer.toString('base64')}`; } catch (cause) { @@ -68,10 +77,22 @@ export async function readAudioDataUrl(filePath: string): Promise/). `navigate` is the odd // one out — its file is `move.wav` (the sets predate the SfxName vocabulary); the rest are 1:1. @@ -80,13 +101,45 @@ const SFX_SLOT_FILE: Readonly> = { navigate: 'move', button: 'button', back: 'back', + notify: 'notify', + limit: 'limit', + 'popup-open': 'popup-open', + 'popup-close': 'popup-close', + typing: 'typing', }; +/** + * The slots that fall back to the DEFAULT set's file when the chosen set doesn't carry it. The rule for + * every other slot is "missing file ⇒ silence" (see readSfxSet), and that is deliberate: borrowing a + * sound from another set mixes two sound identities. These are the documented exceptions — events that + * have to be audible in every set — and the sets that predate them have no file of their own yet (they + * are generated in sfxsmith, set by set): a notification that arrives silently is one the user misses, + * and a popup that opens, a dead end that hits, or a key that types without a sound reads as the app not + * responding. + */ +const SLOTS_FALLING_BACK_TO_DEFAULT_SET: ReadonlySet = new Set([ + 'notify', + 'limit', + 'popup-open', + 'popup-close', + 'typing', +]); + /** The file basename (no extension) for a UI sound slot inside a set folder. Pure — unit-tested. */ export function sfxFileName(name: SfxName): string { return SFX_SLOT_FILE[name]; } +/** + * The sets a slot's file is looked for in, in order: the chosen one, and — for the borrowing slots — the + * default set behind it. Every other slot gets a one-element list, which is what keeps "missing file ⇒ + * silence" true for them. Pure — unit-tested. + */ +export function sfxSetsForSlot(name: SfxName, set: string): readonly string[] { + if (!SLOTS_FALLING_BACK_TO_DEFAULT_SET.has(name) || set === DEFAULT_SOUND_SET) return [set]; + return [set, DEFAULT_SOUND_SET]; +} + // Absolute path to a set's folder. __dirname at runtime is dist/main; the sets live in dist/audio/ui. function soundSetDir(set: string): string { return path.join(__dirname, '../audio/ui', set); @@ -115,19 +168,13 @@ export function isValidAmbientTrack(track: string): boolean { // it, so it must match what copy-assets.mjs actually copies (assets/playhook-wallpaper.jpg). const WALLPAPER_PATH = path.join(__dirname, '../wallpaper.jpg'); -// Custom Empty-screen wallpaper: hard file-size cap (a bigger file is refused rather than downscaled — -// see plan F2.2). 8 MB as base64 is ~11 MB of string in the renderer, which is tolerable for a one-off. -const MAX_WALLPAPER_BYTES = 8 * 1024 * 1024; -// The custom wallpaper is stored in userData under this base name plus the source extension (kept so the -// data-URI MIME resolves correctly for jpg/png/webp/gif). -const CUSTOM_WALLPAPER_BASE = 'wallpaper-custom'; +// The startup jingle (bundled by copy-assets into dist/startup.mp3), played once while the boot screen +// is up. Same rule as the wallpaper: the extension decides the data-URI MIME, so it must match what +// copy-assets.mjs actually copies (assets/playhook-startup.mp3). +const STARTUP_SOUND_PATH = path.join(__dirname, '../startup.mp3'); -/** Dependencies for the custom Empty-screen wallpaper (kept electron-free: plain string + getter). */ +/** Dependencies of the reader (kept electron-free: plain getters). */ export interface AssetReaderDeps { - /** app.getPath('userData') — where the copied custom wallpaper file lives. */ - readonly userData: string; - /** The current custom wallpaper file name from settings (null = bundled default). Read live. */ - readonly getCustomWallpaperName: () => Promise; /** The current navigation sound set from settings (folder under audio/ui/). Read live. */ readonly getSoundSet: () => Promise; /** The current default ambience track from settings (file name, or null for none). Read live. */ @@ -136,33 +183,8 @@ export interface AssetReaderDeps { readonly getOnlyGlobalAmbient: () => Promise; } -/** - * Result of copying a picked file in as the custom wallpaper. On failure the `reason` is a code the - * caller (GameController) maps to a localized message — AssetReader stays translator-free. - */ -export type SetWallpaperResult = - | { readonly ok: true; readonly dataUrl: string; readonly fileName: string } - | { readonly ok: false; readonly reason: 'too-large' | 'not-image' | 'io' }; - -/** Sniffs the first bytes for a supported image signature (png / jpeg / gif / webp). */ -function isSupportedImage(buffer: Buffer): boolean { - if (buffer.length < 12) return false; - // PNG: 89 50 4E 47 0D 0A 1A 0A - if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return true; - // JPEG: FF D8 FF - if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return true; - // GIF: "GIF8" - if (buffer.subarray(0, 4).toString('latin1') === 'GIF8') return true; - // WEBP: "RIFF"????"WEBP" - if (buffer.subarray(0, 4).toString('latin1') === 'RIFF' && buffer.subarray(8, 12).toString('latin1') === 'WEBP') { - return true; - } - return false; -} - export class AssetReader { - // The EFFECTIVE Empty-screen wallpaper (custom if set & present, else bundled) as a data URL: - // undefined = not read yet, null = unavailable. Invalidated on any custom-wallpaper change. + // The bundled Empty-screen wallpaper as a data URL: undefined = not read yet, null = unavailable. private wallpaperDataUrl: string | null | undefined; constructor(private readonly deps: AssetReaderDeps) {} @@ -172,80 +194,22 @@ export class AssetReader { } /** - * The effective Empty-screen wallpaper as a data URL (read once and cached): the user's custom image - * when set and still present on disk, otherwise the bundled default. null if nothing can be read. Also - * used as the per-game hero fallback, so a custom wallpaper flows into both (see readHeroAssets). + * The bundled Empty-screen wallpaper as a data URL (read once and cached); null if it can't be read. + * Also used as the per-game hero fallback (see readHeroAssets). */ async readWallpaperDataUrl(): Promise { if (this.wallpaperDataUrl !== undefined) return this.wallpaperDataUrl; - const customName = await this.deps.getCustomWallpaperName(); - if (customName !== null) { - const customPath = path.join(this.deps.userData, customName); - if (await fse.pathExists(customPath)) { - const url = await this.readImageDataUrl(customPath); - if (url !== undefined) { - this.wallpaperDataUrl = url; - return url; - } - } - // The setting points at a missing/unreadable file → fall back to the bundled default (no crash). - log.warn(`[wallpaper] custom wallpaper "${customName}" missing/unreadable — using the bundled default`); - } const fallback = await this.readImageDataUrl(WALLPAPER_PATH); this.wallpaperDataUrl = fallback ?? null; return this.wallpaperDataUrl; } /** - * Copies a picked image in as the custom Empty-screen wallpaper: refuses a file over the size cap or - * one that doesn't sniff as a supported image, writes it into userData (raw bytes — no re-encode), - * removes any previous custom file, and invalidates the cache. Returns the new data URL + file name. + * The bundled startup jingle as a data URL; null if it can't be read (the launcher then boots silently). + * Not cached: it is asked for exactly once per window, on boot. */ - async setCustomWallpaper(sourcePath: string): Promise { - try { - const stat = await fse.stat(sourcePath); - if (stat.size > MAX_WALLPAPER_BYTES) return { ok: false, reason: 'too-large' }; - const buffer = await fse.readFile(sourcePath); - if (!isSupportedImage(buffer)) return { ok: false, reason: 'not-image' }; - const ext = path.extname(sourcePath).toLowerCase(); - const mime = IMAGE_MIME[ext]; - if (mime === undefined) return { ok: false, reason: 'not-image' }; - const fileName = `${CUSTOM_WALLPAPER_BASE}${ext}`; - // Drop any previous custom file first (a different extension would otherwise leak on disk), then - // write the new one — independent of the settings value, which the caller patches afterwards. - await this.removeCustomFiles(fileName); - await fse.ensureDir(this.deps.userData); - await fse.writeFile(path.join(this.deps.userData, fileName), buffer); - this.wallpaperDataUrl = undefined; // invalidate: the next read reflects the new custom image - return { ok: true, dataUrl: `data:${mime};base64,${buffer.toString('base64')}`, fileName }; - } catch (cause) { - log.warn('[wallpaper] failed to set custom wallpaper:', describe(cause)); - return { ok: false, reason: 'io' }; - } - } - - /** - * Removes the custom wallpaper file(s) and invalidates the cache, so the Empty screen falls back to - * the bundled default. Deletion is by the fixed base name (NOT the settings value), so it works even - * from the general Reset — which writes customWallpaper=null BEFORE this runs. Returns the default data - * URL (empty string when the bundle can't be read). - */ - async clearCustomWallpaper(): Promise<{ dataUrl: string }> { - await this.removeCustomFiles(); - this.wallpaperDataUrl = undefined; - const fallback = await this.readImageDataUrl(WALLPAPER_PATH); - this.wallpaperDataUrl = fallback ?? null; - return { dataUrl: fallback ?? '' }; - } - - /** Best-effort removal of every `wallpaper-custom.` in userData, optionally keeping one. */ - private async removeCustomFiles(keep?: string): Promise { - await Promise.all( - Object.keys(IMAGE_MIME) - .map((ext) => `${CUSTOM_WALLPAPER_BASE}${ext}`) - .filter((name) => name !== keep) - .map((name) => fse.remove(path.join(this.deps.userData, name)).catch(() => undefined)), - ); + async readStartupSoundDataUrl(): Promise { + return (await readAudioDataUrl(STARTUP_SOUND_PATH)) ?? null; } /** @@ -280,7 +244,8 @@ export class AssetReader { /** * The chosen set's UI sounds — every sound the app plays, on every screen (the card cannot supply its - * own). A slot whose file is missing within the set simply stays silent. + * own). A slot whose file is missing within the set simply stays silent, except for the one slot that + * borrows the default set's file (see SLOT_FALLS_BACK_TO_DEFAULT_SET). */ async readSfxSet(): Promise { const set = await this.effectiveSoundSet(); @@ -289,8 +254,13 @@ export class AssetReader { if (this.sfxSetCache?.set === set) return this.sfxSetCache.assets; const sounds: Record = {}; for (const name of SFX_NAMES) { - const url = await this.readAudioDataUrl(defaultSfxPath(set, name)); - if (url !== undefined) sounds[name] = url; + for (const candidate of sfxSetsForSlot(name, set)) { + const url = await this.readAudioDataUrl(defaultSfxPath(candidate, name)); + if (url !== undefined) { + sounds[name] = url; + break; + } + } } const assets: SfxSet = { sounds }; this.sfxSetCache = { set, assets }; @@ -299,12 +269,12 @@ export class AssetReader { private sfxSetCache: { set: string; assets: SfxSet } | undefined; /** - * The chosen navigation sound set if it is present, else the bundled default (winhanced). A missing set + * The chosen navigation sound set if it is present, else the bundled default. A missing set * is a user-facing misconfiguration, so it is logged; an individual slot missing WITHIN a set is not * (that slot just stays silent — sets are expected complete). * * Presence is probed by statting the set's move.wav — a FILE — not the set DIRECTORY: inside the packaged - * asar a directory stat is unreliable (it made every non-default set silently fall back to winhanced), + * asar a directory stat is unreliable (it made every non-default set silently fall back to the default), * whereas a file stat works through Electron's shim. Mirrors readAmbientDataUrl's file existence check. */ private async effectiveSoundSet(): Promise { diff --git a/src/main/config-paths.ts b/src/main/config-paths.ts new file mode 100644 index 00000000..0c1919b5 --- /dev/null +++ b/src/main/config-paths.ts @@ -0,0 +1,180 @@ +// 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. +// +// 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 +// a Linux system from either OS, which is not what a directory the user is browsing is. What DOES cross +// machines is the manifest value, and that is always emitted with forward slashes below. +import path from 'node:path'; +import type { ConfigPickKind, HostPlatform } from '../shared/types'; + +/** + * The running OS in the form the renderer is given it (see HostPlatform). Everything that is neither + * Windows nor macOS answers `linux`, matching createPlatform's own fallback — so the two cannot disagree + * about which bundle a screen is talking about. + */ +export function hostPlatform(platform: NodeJS.Platform = process.platform): HostPlatform { + if (platform === 'win32') return 'windows'; + if (platform === 'darwin') return 'macos'; + return 'linux'; +} + +/** + * What a picked path must BE for the field it was picked for. `null` = any extension (a local game is + * launched by whatever the user launches it with — a `.bat`, a shortcut, a native binary with no + * extension at all). The `.exe` requirement on CARD fields is not a platform check: a card is a Windows + * dictionary on both OSes, and this mirrors the filter the native dialog always applied. + */ +export function acceptsExtensions( + kind: ConfigPickKind, + platform: NodeJS.Platform = process.platform, +): readonly string[] | null { + switch (kind) { + case 'executable': + case 'installer': + return ['exe']; + case 'pc-executable': + return platform === 'win32' ? ['exe', 'bat', 'cmd', 'lnk'] : null; + case 'image': + case 'audio': + case 'directory': + case 'pc-save': + case 'pc-save-local': + return null; + } +} + +/** Whether this kind names a FOLDER (the rest name a file). */ +export function picksDirectory(kind: ConfigPickKind): boolean { + return kind === 'directory' || kind === 'pc-save' || kind === 'pc-save-local'; +} + +/** + * Whether a path is a macOS application bundle — a DIRECTORY that the user (and the launcher) treats as + * one executable file. Every `.app`-aware decision goes through this one predicate so the picker, the + * directory listing and the launcher cannot drift apart. + */ +export function isAppBundle(absolute: string): boolean { + return path.extname(absolute).toLowerCase() === '.app'; +} + +/** + * Whether the in-launcher picker should present a directory as a FILE: a macOS `.app` bundle being browsed + * for a local game's executable. Presenting it as a folder would let the user walk into `Contents/MacOS/` + * and pick the raw binary — which works, but is not what anyone means by "the game", and is not what the + * card-less PC library should store. Deciding it in main keeps the renderer's picker free of any OS branch. + * + * Scoped to `pc-executable` on purpose: for every other field a `.app` is an ordinary folder. + */ +export function listsAsFile( + absolute: string, + isDirectory: boolean, + kind: ConfigPickKind | undefined, + platform: NodeJS.Platform = process.platform, +): boolean { + return isDirectory && platform === 'darwin' && kind === 'pc-executable' && isAppBundle(absolute); +} + +/** Why a picked path was refused. The caller maps it to a localized message. */ +export type PickRejection = 'missing' | 'symlink' | 'needs-folder' | 'needs-file' | 'wrong-type'; + +/** What the caller's `lstat` found; null when there was nothing there at all. */ +export interface PickedStat { + readonly isSymbolicLink: boolean; + readonly isDirectory: boolean; + readonly isFile: boolean; +} + +/** + * Whether one picked path may be used for `kind`. A symlink is refused rather than followed: it names one + * thing and reads as another, which is the whole difficulty of trusting a path that did not come from a + * dialog. `extensions` is passed in so this module needs no asset-reader import. + * + * The one platform branch: on macOS a local game is usually a `.app` BUNDLE, which the filesystem reports + * as a directory. It is a launch target all the same (the launcher resolves the binary inside it), so it + * is accepted for `pc-executable` there — and only there, because a card stays a Windows dictionary. + */ +export function checkPickedType( + absolute: string, + kind: ConfigPickKind, + stat: PickedStat | null, + extensions: readonly string[] | null, + platform: NodeJS.Platform = process.platform, +): PickRejection | null { + if (stat === null) return 'missing'; + if (stat.isSymbolicLink) return 'symlink'; + if (picksDirectory(kind)) return stat.isDirectory ? null : 'needs-folder'; + if (platform === 'darwin' && kind === 'pc-executable' && stat.isDirectory && isAppBundle(absolute)) { + return null; + } + if (!stat.isFile) return 'needs-file'; + if (extensions === null) return null; + const extension = path.extname(absolute).replace(/^\./, '').toLowerCase(); + return extensions.includes(extension) ? null : 'wrong-type'; +} + +/** + * What a card-relative manifest field stores for an absolute path, with forward slashes — or null when the + * path escapes the root (a `..`-leading or absolute relative) or IS the root (an empty relative, which the + * manifest's `min(1)` would reject anyway). We never emit an escaping or empty manifest path. + */ +export function toCardRelative(root: string, absolute: string): string | null { + const relative = path.relative(root, absolute); + if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) return null; + return relative.split(path.sep).join('/'); +} + +/** The machine-specific folders the starting point may be drawn from. */ +export interface StartDirEnv { + readonly homeDir: string; + /** `app.getPath('appData')` — where a Windows-dictionary save path most often lives. */ + readonly appDataDir: string; + /** `app.getPath('downloads')` — where artwork and music for a local game almost always just landed. */ + readonly downloadsDir: string; + /** Whether `root` is a CARD (a PC-library root is not somewhere to browse for a file). */ + readonly rootIsCard: boolean; +} + +export interface StartDirRequest { + readonly root?: string; + readonly kind?: ConfigPickKind; + /** The field's current value, so a filled field reopens where it points. */ + readonly current?: string; + /** An ALREADY-RESOLVED absolute sub-directory the field is measured from (see toRelative's `base`). */ + readonly baseDir?: string; +} + +/** + * Where a field's picker opens when the screen has nowhere of its own to return to. + * + * The rule is "the directory this field's answer usually lives in", not "the root this manifest belongs + * to" — the two differ for exactly the fields that caused trouble: + * • a SAVE path is never on the card, even for a card game: the game writes to the PC, so it starts at + * `%APPDATA%` (on Windows, under the system drive; on Linux, the config root) whatever the source is; + * • ARTWORK and MUSIC for a local game were almost certainly downloaded a minute ago, so they start in + * Downloads rather than at the top of the home folder. + * A `%PREFIX%`-style value names no host directory, so it is skipped rather than resolved. + */ +export function startDirFor(request: StartDirRequest, env: StartDirEnv): string { + const { root, current, kind, baseDir } = request; + // A field measured from a sub-directory is browsed from there: outside it there is nothing this field + // can even express, so starting at the card root would open on paths it cannot store. + const from = baseDir ?? root; + if (current !== undefined && current !== '' && !current.startsWith('%')) { + const absolute = path.isAbsolute(current) + ? current + : from !== undefined + ? path.join(from, current) + : null; + if (absolute !== null) return path.dirname(absolute); + } + if (baseDir !== undefined) return baseDir; + if (kind === 'pc-save' || kind === 'pc-save-local') return env.appDataDir; + if (kind === 'pc-executable') return env.homeDir; + const isCard = root !== undefined && env.rootIsCard; + if (!isCard && (kind === 'image' || kind === 'audio')) return env.downloadsDir; + if (isCard && root !== undefined) return root; + return env.homeDir; +} diff --git a/src/main/configure-window.ts b/src/main/configure-window.ts deleted file mode 100644 index 0f38f867..00000000 --- a/src/main/configure-window.ts +++ /dev/null @@ -1,157 +0,0 @@ -// Configure-game window — a PLAIN desktop window (framed, resizable, not fullscreen/kiosk), opened from -// the tray. A near-exact sibling of SettingsWindow: hidden native title bar + Window Controls Overlay, -// its own preload (configure-preload → window.configureApi), lazy singleton create, X hides to the tray, -// allowClose() lets it really close on app quit / update install. It hosts the game.json editor on -// Fluent UI + CodeMirror, so it's a bit larger than the settings window (JSON needs the room). -// -// It's wired to GameConfigService: attachWindow() on show (so the drive poll runs only while visible and -// the renderer's first getDrives()/push both land), detachWindow() on hide/close (stop pushing/polling -// into a hidden/destroyed window). The WCO recolor channel is OWN (config:titlebar-overlay), not the -// settings one — setTitleBarOverlay must target THIS window's instance. -import path from 'node:path'; -import { BrowserWindow, Menu, ipcMain, nativeTheme } from 'electron'; -import { APP_NAME, IPC } from '../shared/types'; -import { type Translator } from '../shared/i18n/index'; -import { type GameConfigService } from './game-config'; -import { installHideOnClose, type HideOnCloseGuard } from './window-hide-guard'; - -const TITLE_BAR_HEIGHT = 48; - -// Native caption-button colors for the WCO — must match the custom title bar background in configure.css -// (Fluent colorNeutralBackground1) so the strip looks seamless; `symbolColor` is the glyph color. -const OVERLAY = { - dark: { color: '#292929', symbolColor: '#ffffff' }, - light: { color: '#ffffff', symbolColor: '#000000' }, -} as const; - -export class ConfigureWindow { - private window: BrowserWindow | null = null; - private closeGuard: HideOnCloseGuard | null = null; - // Whether the renderer's active tab is the raw JSON editor. Format only makes sense there, so the - // context menu shows it only when true (the renderer pushes this on every tab switch). - private jsonEditorActive = false; - - constructor( - private readonly gameConfig: GameConfigService, - private readonly getTranslator: () => Translator, - ) { - // The renderer computes the effective theme and asks us to recolor the native caption buttons. - // Registered once here (singleton); guarded on a live window. Its own channel — see the file header. - ipcMain.on(IPC.configTitleBarOverlay, (_event, dark: boolean) => this.applyOverlay(dark)); - ipcMain.on(IPC.configEditorActive, (_event, active: boolean) => { - this.jsonEditorActive = active === true; - }); - } - - /** The native window title (taskbar). Re-applied on a language change (the renderer also sets - * document.title, otherwise the HTML would override this in the taskbar). */ - private title(): string { - return `${APP_NAME} — ${this.getTranslator()('window.configureGame')}`; - } - - /** Re-titles a live window after a language change. */ - refreshTitle(): void { - const window = this.window; - if (window !== null && !window.isDestroyed()) window.setTitle(this.title()); - } - - private applyOverlay(dark: boolean): void { - const window = this.window; - if (window === null || window.isDestroyed()) return; - window.setTitleBarOverlay(dark ? OVERLAY.dark : OVERLAY.light); - } - - /** Opens the window, creating it lazily on first call; otherwise shows + focuses the existing one. */ - openOrFocus(): void { - if (this.window !== null && !this.window.isDestroyed()) { - if (!this.window.isVisible()) this.window.show(); - this.window.focus(); - // Re-attach: the window may have been detached on a previous hide/close. - this.gameConfig.attachWindow(this.window); - return; - } - this.create(); - } - - private create(): void { - const window = new BrowserWindow({ - // JSON editing needs more room than the settings form; 640×720 default, with a min that keeps the - // editor + issues panel usable. - width: 640, - height: 720, - minWidth: 560, - minHeight: 600, - show: false, - titleBarStyle: 'hidden', - titleBarOverlay: { - ...(nativeTheme.shouldUseDarkColors ? OVERLAY.dark : OVERLAY.light), - height: TITLE_BAR_HEIGHT, - }, - resizable: true, - fullscreen: false, - title: this.title(), - icon: path.join(__dirname, '../icon.ico'), - // Pre-paint background matched to the OS theme (the renderer applies the real Fluent theme on load) - // to avoid a dark/light flash before load. - backgroundColor: nativeTheme.shouldUseDarkColors ? '#1f1f1f' : '#ffffff', - webPreferences: { - preload: path.join(__dirname, '../preload/configure-preload.js'), - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - }, - }); - - // Right-click editing menu for the JSON editor — a sandboxed renderer has no context menu of its own, - // so main provides one. Clipboard items use native roles (enabled per the focused control's - // editFlags); Format / Reset are renderer actions, dispatched back over an IPC command channel. - window.webContents.on('context-menu', (_event, params) => { - // Clipboard items keep their native ROLE (behaviour) but get an explicit translated LABEL — without - // it Electron shows its English defaults on Windows. Menu is rebuilt per right-click, so a language - // change is picked up on its own. - const t = this.getTranslator(); - const template: Electron.MenuItemConstructorOptions[] = [ - { role: 'cut', label: t('menu.cut'), enabled: params.editFlags.canCut }, - { role: 'copy', label: t('menu.copy'), enabled: params.editFlags.canCopy }, - { role: 'paste', label: t('menu.paste'), enabled: params.editFlags.canPaste }, - { role: 'selectAll', label: t('menu.selectAll'), enabled: params.editFlags.canSelectAll }, - ]; - // Format applies only to the raw JSON editor → offer it only when that tab is active. Reset now lives - // as a visible button next to Save & Apply (both modes), so it's no longer in this menu. - if (this.jsonEditorActive) { - template.push( - { type: 'separator' }, - { label: t('menu.format'), click: () => window.webContents.send(IPC.configEditorCommand, 'format') }, - ); - } - Menu.buildFromTemplate(template).popup({ window }); - }); - - // X hides to the tray instead of quitting (like GameWindow/SettingsWindow); detach the poll on close. - this.closeGuard = installHideOnClose(window, () => this.gameConfig.detachWindow()); - - // Belt-and-suspenders: also detach when merely hidden, and (re)attach the poll when shown. - window.on('hide', () => this.gameConfig.detachWindow()); - window.on('show', () => this.gameConfig.attachWindow(window)); - - this.window = window; - // Attach BEFORE loadFile so the renderer can subscribe and request the snapshot as soon as it starts. - this.gameConfig.attachWindow(window); - - void window.loadFile(path.join(__dirname, '../renderer/configure.html')); - - window.once('ready-to-show', () => { - window.show(); - window.focus(); - }); - } - - get browserWindow(): BrowserWindow | null { - return this.window; - } - - /** Allows the window to actually close (app quit / update install). */ - allowClose(): void { - this.closeGuard?.allowClose(); - } -} diff --git a/src/main/daemon.ts b/src/main/daemon.ts index feebf929..10b4a693 100644 --- a/src/main/daemon.ts +++ b/src/main/daemon.ts @@ -25,6 +25,7 @@ import { toRunGameId } from './platform/steam-appid'; import { launchWhenSteamReady } from './daemon-launch'; import { systemEnv } from './appimage-env'; import { isSteamPipeReady } from './platform/steam-pipe.linux'; +import { createTranslator } from '../shared/i18n/index'; /** Electron's `app.getPath('userData')` on Linux, reproduced without Electron: `$XDG_CONFIG_HOME/playhook`. */ function userDataDir(home: string): string { @@ -104,6 +105,9 @@ export function startDaemon(): void { // Unused by the daemon (it launches nothing through Proton), but PlatformDeps requires it. Resolved // the same way main.ts does so the value is at least correct rather than a lie. umuRunPath: path.join(process.resourcesPath, 'umu', 'umu-run'), + // The daemon surfaces no platform refusal to a user (it only watches for a card and asks Steam to + // launch), and it is Linux-only, so a fixed English translator is enough here. + getTranslator: () => createTranslator('en'), }); const settings = new AppSettingsStore(userData); // Guards against overlapping launch attempts while one is being confirmed (the confirm window is diff --git a/src/main/drive-watcher.ts b/src/main/drive-watcher.ts index 9a3cf42a..24579ab5 100644 --- a/src/main/drive-watcher.ts +++ b/src/main/drive-watcher.ts @@ -8,6 +8,7 @@ import fse from 'fs-extra'; import { list } from 'drivelist'; import { MANIFEST_FILENAME, type DriveCandidate } from '../shared/types'; import { type Translator } from '../shared/i18n/index'; +import { log } from './logger'; const DEFAULT_INTERVAL_MS = 1000; @@ -70,6 +71,7 @@ export async function listDriveCandidates( const { label, signature } = await describeManifest(root, manifestPath, hasManifest, t); candidates.push({ root, + kind: 'card', label, signature, hasManifest, @@ -80,15 +82,34 @@ export async function listDriveCandidates( return candidates; } -/** What one read of a candidate's game.json yields: its display label and its content signature. */ -interface ManifestDescription { +/** + * 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. + */ +export async function listAllMountpoints(): Promise<readonly string[]> { + const drives = await list(); + const paths: string[] = []; + for (const drive of drives) { + if (drive.isVirtual === true) continue; + for (const mount of drive.mountpoints) { + if (typeof mount.path === 'string' && mount.path.length > 0) paths.push(mount.path); + } + } + return [...new Set(paths)].sort(); +} + +/** What one read of a candidate's game.json yields: what to say about it, and its content signature. */ +export interface ManifestDescription { /** - * "E:\ — Hollow Knight" (title from a single-game game.json), "E:\ — 3 games" (a multi-game card: the - * individual titles don't fit a one-line label, so it shows the count), "E:\ — invalid game.json" (file - * present but unparseable / no title), or "E:\ — blank drive" (no game.json). This is the primary - * signature on Windows, where drivelist does not populate a volume label. + * The content half of the label, shown after the candidate's name: "Hollow Knight" (title from a + * single-game game.json), "3 games" (several of them: the individual titles don't fit a one-line + * label, so it shows the count), "invalid game.json" (file present but unparseable / no title), or the + * caller's `blank` wording (no game.json). On Windows this is the primary way to tell two cards apart — + * drivelist does not populate a volume label. */ - readonly label: string; + readonly suffix: string; /** The card's identity — see DriveCandidate.signature / gameIdsSignature. */ readonly signature: string; } @@ -106,17 +127,21 @@ function gameIdsSignature(games: readonly unknown[]): string { return [...ids].sort().join('|'); } -/** Reads a candidate's game.json ONCE and derives both its display label and its content signature. */ -async function describeManifest( - root: string, +/** + * Reads a candidate's game.json ONCE and derives what its label says about its CONTENT ("Hollow Knight", + * "3 games", "invalid game.json", …) plus the content signature. Split from the label itself because the + * same description serves two kinds of candidate: a card, prefixed with its mountpoint, and the PC + * library, prefixed with "This PC" (see GameConfigService.candidates). `blank` is the wording for "there + * is no game.json" — a blank drive for a card, "no games yet" for the library. + */ +export async function describeManifestContent( manifestPath: string, hasManifest: boolean, t: Translator, + blank: string, ): Promise<ManifestDescription> { - // The `root — …` shape and the card title (untrusted) stay literal; only the descriptive suffix is - // translated. The picker re-pushes every 2s while visible, so a language change is picked up on its own. - if (!hasManifest) return { label: `${root} — ${t('drive.blank')}`, signature: '' }; - const invalid: ManifestDescription = { label: `${root} — ${t('drive.invalid')}`, signature: 'invalid' }; + 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 @@ -126,10 +151,10 @@ async function describeManifest( if (typeof first !== 'object' || first === null) return invalid; const signature = gameIdsSignature(games); // Several games → the count alone ("3 games"); naming just the first would misrepresent the card. - if (games.length > 1) return { label: `${root} — ${t.tp('drive.games', games.length)}`, signature }; + if (games.length > 1) return { suffix: t.tp('drive.games', games.length), signature }; if ('title' in first) { const title = first.title; - if (typeof title === 'string' && title.length > 0) return { label: `${root} — ${title}`, signature }; + if (typeof title === 'string' && title.length > 0) return { suffix: title, signature }; } return invalid; } catch { @@ -137,11 +162,31 @@ async function describeManifest( } } +/** The card flavour of the above: the mountpoint, a dash, and what the manifest says. */ +async function describeManifest( + root: string, + manifestPath: string, + hasManifest: boolean, + t: Translator, +): Promise<{ readonly label: string; readonly signature: string }> { + // The `root — …` shape and the card title (untrusted) stay literal; only the descriptive suffix is + // translated. The picker re-pushes every 2s while visible, so a language change is picked up on its own. + const { suffix, signature } = await describeManifestContent( + manifestPath, + hasManifest, + t, + t('drive.blank'), + ); + return { label: `${root} — ${suffix}`, signature }; +} + export class DriveWatcher { private timer: NodeJS.Timeout | null = null; private activeRoot: string | null = null; private scanning = false; private lastAutomountAt = 0; + /** Mountpoints already reported as permission-denied, so the breadcrumb is logged once, not per tick. */ + private readonly deniedMounts = new Set<string>(); private insertHandler: ((root: string) => void) | null = null; private removeHandler: ((root: string) => void) | null = null; @@ -242,8 +287,7 @@ export class DriveWatcher { // A disk may have several partitions/mountpoints — we iterate over all of them. for (const mount of drive.mountpoints) { if (typeof mount.path !== 'string' || mount.path.length === 0) continue; - const manifestPath = path.join(mount.path, MANIFEST_FILENAME); - if (await fse.pathExists(manifestPath)) { + if (await this.carriesManifest(mount.path)) { if (mount.path === this.activeRoot) return mount.path; // keep the active card firstFound ??= mount.path; } @@ -251,4 +295,27 @@ export class DriveWatcher { } return firstFound; } + + /** + * Whether this mountpoint carries a `game.json`. A PERMISSION error is not treated like an absent file: + * on macOS the first look inside a removable volume raises the "Removable Volumes" privacy prompt, and a + * user who declines it makes every card silently invisible — with `pathExists` swallowing the EPERM, + * this log line is the only trace of why. Logged once per mountpoint (the scan runs on a timer). + */ + private async carriesManifest(mount: string): Promise<boolean> { + try { + await fse.access(path.join(mount, MANIFEST_FILENAME)); + this.deniedMounts.delete(mount); // access was granted (or the prompt was answered) — arm it again + return true; + } catch (cause) { + const code = (cause as NodeJS.ErrnoException).code; + if ((code === 'EPERM' || code === 'EACCES') && !this.deniedMounts.has(mount)) { + this.deniedMounts.add(mount); + log.warn( + `[drive-watcher] permission denied reading "${mount}" (${code}) — on macOS, allow removable volumes in System Settings → Privacy & Security → Files and Folders`, + ); + } + return false; + } + } } diff --git a/src/main/game-config-add.ts b/src/main/game-config-add.ts new file mode 100644 index 00000000..2f87904f --- /dev/null +++ b/src/main/game-config-add.ts @@ -0,0 +1,62 @@ +// The electron-free half of adding a game through the launcher: what gameConfig:read-root answers for a +// root, and which games a write actually ADDED to one. Both are pure so they can be unit-tested — the +// service around them cannot be imported in vitest (ipcMain), which is the same reason launch-args.ts +// was carved out of game-launcher.ts. +import { + type ConfigRootReadResult, + type HostPlatform, + type ManifestSource, +} from '../shared/types'; + +/** The fixed half of a root read — everything that does not depend on whether a game.json is there. */ +export interface RootReadBase { + readonly root: string; + readonly source: ManifestSource; + readonly signature: string; + readonly platform: HostPlatform; +} + +/** + * What the Add-game screen is told about a root. `text` is null when the root carries NO game.json — + * a blank card, or a PC library with no local game yet — which is the normal case here rather than a + * failure, and is reported as `hasManifest: false` with an empty text. The screen turns that into an + * empty slot list; `'[]'` could not be used instead, because parsing it back rejects an empty games array. + */ +export function rootReadResult(base: RootReadBase, text: string | null): ConfigRootReadResult { + return { ok: true, ...base, hasManifest: text !== null, text: text ?? '' }; +} + +/** A game that appeared in a manifest — what a notification about the write needs to name it. */ +export interface AddedGame { + readonly id: string; + readonly title: string; +} + +/** + * The games in `text` whose ids were not in the root's signature before the write (see gameIdsSignature: + * the sorted ids joined with `|`). Only well-formed text reaches this — Save re-validates first — so a + * parse failure means "nothing can be said about it" rather than "everything is new". + * + * An `'invalid'` signature is treated the same way: the previous file could not be read, so its ids are + * unknown, and calling every game in the file new would announce games the user never added. + */ +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 added: AddedGame[] = []; + for (const game of games) { + if (typeof game !== 'object' || game === null) continue; + const id = 'id' in game && typeof game.id === 'string' ? game.id : ''; + if (id.length === 0 || before.has(id)) continue; + const title = 'title' in game && typeof game.title === 'string' ? game.title : ''; + added.push({ id, title: title.length > 0 ? title : id }); + } + return added; +} diff --git a/src/main/game-config.ts b/src/main/game-config.ts index 60e66cb0..7fc73c5f 100644 --- a/src/main/game-config.ts +++ b/src/main/game-config.ts @@ -1,156 +1,409 @@ -// Configure-game window backend (IPC handlers + drive polling). Owns everything the window needs: -// listing removable drives (incl. blank ones), reading/validating/saving a card's game.json and the -// manifest JSON Schema. Interface-DI (like UpdaterService/StatsService): the active-root accessor and the -// no-restart reload come from GameController, the theme from AppSettingsStore. +// 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. // // 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 -// listDriveCandidates() (removable, non-system), so a compromised renderer can't write game.json to -// an arbitrary filesystem location; -// • Save re-runs the static validation server-side (a race guard against the UI enabling it wrongly). +// listDriveCandidates() (removable, non-system) PLUS the app's own PC-library root, so a compromised +// renderer can't write game.json to an arbitrary filesystem location; +// • Save re-runs the static validation server-side (a race guard against the UI enabling it wrongly), +// and compares the media SIGNATURE it was read against — a card swapped into the same slot keeps the +// root valid while the file underneath is somebody else's. +// +// The PC library DELIBERATELY widens the first stance, and it is worth being explicit about: the renderer +// may write `<userData>/pc-games/game.json`, whose `pc.executable` is any binary on the machine, with +// arbitrary `args` and `runAsAdmin`. Before the PC library existed, it could only point at a file that +// physically sat on a removable drive. The feature does not exist without that — picking an arbitrary +// .exe IS the feature — and the widening is bounded: the set of writable ROOTS is still closed (this one +// path plus the removable candidates) and every write still goes through the same server-side validation. +// +// 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). import path from 'node:path'; import fs from 'node:fs/promises'; +import os from 'node:os'; import fse from 'fs-extra'; -import { app, dialog, ipcMain, shell, BrowserWindow, type WebContents } from 'electron'; +import { app, ipcMain } from 'electron'; import { IPC, MANIFEST_FILENAME, - type AppSettings, + type ConfigMoveResult, type ConfigPickKind, - type ConfigPickRequest, 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 GameMoveRequest, + type ListDirResult, + type ManifestSource, + type NotificationInput, + type ResolvedManifest, } from '../shared/types'; import { type Translator } from '../shared/i18n/index'; -import { type AppSettingsStore } from './app-settings'; import { AUDIO_EXTENSIONS, IMAGE_EXTENSIONS, readImageDataUrl } from './asset-reader'; -import { listDriveCandidates } from './drive-watcher'; -import { resolveInside, validateManifestText, manifestJsonSchema } from './manifest'; -import { writeFileAtomic } from './save-sync'; +import { + acceptsExtensions, + checkPickedType, + hostPlatform, + listsAsFile, + startDirFor, + toCardRelative, + type PickRejection, +} from './config-paths'; +import { describeManifestContent, listAllMountpoints, listDriveCandidates } from './drive-watcher'; +import { addedGamesOf, rootReadResult } from './game-config-add'; +import { + countGamesWithId, + expectedGameFilePath, + findGameInText, + planAssetCopies, + removeGameFromManifestText, +} from './game-move'; +import { type PcLibraryStore } from './pc-library'; +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'; -/** OS-dialog `properties` for a pick kind: a folder picker for `directory`/`pc-save`, multi-file for images. */ -function pickProperties(kind: ConfigPickKind): Electron.OpenDialogOptions['properties'] { - if (kind === 'directory' || kind === 'pc-save') return ['openDirectory']; - if (kind === 'image') return ['openFile', 'multiSelections']; - return ['openFile']; +/** + * Whether the editor's text is an EMPTY game list — the PC library's way of saying "the last local game + * was deleted". Only well-formed text reaches this (Save re-validates first), so a parse failure simply + * means "not the empty list". + */ +function isEmptyManifestList(text: string): boolean { + try { + const parsed: unknown = JSON.parse(text); + return Array.isArray(parsed) && parsed.length === 0; + } catch { + return false; + } } -/** Extension filters for a file pick, from the AssetReader single source of truth (dot-less names). - * The filter NAMES are shown by the OS as-is; kept in English like the wallpaper picker (ipc.ts). */ -function pickFilters(kind: ConfigPickKind): Electron.FileFilter[] { - switch (kind) { - case 'image': - return [{ name: 'Images', extensions: [...IMAGE_EXTENSIONS] }]; - case 'audio': - return [{ name: 'Audio', extensions: [...AUDIO_EXTENSIONS] }]; - case 'executable': - case 'installer': - return [{ name: 'Executable', extensions: ['exe'] }]; - case 'directory': - case 'pc-save': - return []; - } +/** + * How long a candidates() snapshot may be reused. Every call enumerates the machine's drives through the + * native `drivelist` — "slow on some readers", by this file's own admission — and then reads a game.json + * per candidate. That was a rare cost while only a window's picker paid it; the Customize screen puts + * `isAllowedRoot` behind a thumbnail per hero row and a listing per directory step, where it would be + * paid dozens of times a second. The snapshot is dropped early whenever the ACTIVE CARD changes (which is + * what a DriveWatcher insert/removal amounts to for this service) and after any save. + */ +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<MoveWarning, 'game-move-save-skipped' | 'game-move-duplicate'> +> = { + '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; } -// Blank-drive insertion is only visible via enumeration (DriveWatcher events fire for cards WITH a -// game.json only), so we poll while the window is visible. 2s is a fine cost for a foreground window. -const DRIVE_POLL_INTERVAL_MS = 2000; +/** 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'); + } +} export interface GameConfigDeps { - readonly settings: AppSettingsStore; /** 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 }>; + /** + * The PC library — a root of its own alongside the card's, so a local game is edited through the same + * screen a card's game is. See the threat-model note at the top of this file. + */ + readonly pcLibrary: PcLibraryStore; + /** Re-reads the PC library after a save (GameController.reloadPcLibrary) — the local reloadManifest. */ + 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 dialog) to a `%PREFIX%/…` manifest + * 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). + */ + readonly findGameSource: ( + id: string, + ) => { readonly root: string; readonly source: ManifestSource } | null; + /** + * Files a notification (NotificationsService.notify). Used for the one write whose result the user + * cannot see anywhere else: a game added to a card that is not the active one exists on disk and + * nowhere in the library. The inbox belongs to main, and so does the decision to post — the renderer + * 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<PcStore, 'removeSyncState'>; + /** 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<SavePathResolver, 'resolvePcSavePath'>; } export class GameConfigService { - private window: BrowserWindow | null = null; - private pollTimer: ReturnType<typeof setInterval> | null = null; - private polling = false; - constructor(private readonly deps: GameConfigDeps) {} - /** Registers all config:* invoke handlers once (the service is a singleton). */ + /** Registers all gameConfig:* invoke handlers once (the service is a singleton). */ init(): void { - ipcMain.handle(IPC.configDrivesRequest, (): Promise<readonly DriveCandidate[]> => - listDriveCandidates(this.deps.getActiveRoot(), this.deps.getTranslator()), - ); - ipcMain.handle(IPC.configRead, (_event, root: string): Promise<ConfigReadResult> => - this.readConfig(root), - ); - ipcMain.handle(IPC.configValidate, (_event, text: string): ConfigValidationResult => - validateManifestText(text, this.deps.getTranslator()), + ipcMain.handle(IPC.gameConfigRead, (_event, id: unknown): Promise<GameConfigReadResult> => + this.readGame(typeof id === 'string' ? id : ''), ); ipcMain.handle( - IPC.configSave, - ( - _event, - payload: { readonly root: string; readonly text: string }, - ): Promise<ConfigSaveResult> => this.save(payload.root, payload.text), + IPC.gameConfigValidate, + (_event, payload: { readonly root: string; readonly text: string }): ConfigValidationResult => + validateManifestText(payload.text, this.deps.getTranslator(), this.sourceOf(payload.root)), ); ipcMain.handle( - IPC.configPickPath, - (event, payload: ConfigPickRequest): Promise<ConfigPickResult> => - this.pickPath(event.sender, payload.root, payload.kind), + IPC.gameConfigSave, + (_event, payload: GameConfigSaveRequest): Promise<ConfigSaveResult> => + this.saveChecked(payload), ); ipcMain.handle( - IPC.configImagePreview, + IPC.gameConfigImagePreview, (_event, payload: { readonly root: string; readonly path: string }): Promise<string | null> => this.imagePreview(payload.root, payload.path), ); - // Fire-and-forget: open a whitelisted https URL (e.g. the SteamDB appid lookup) in the default browser. - ipcMain.on(IPC.configOpenExternal, (_event, url: unknown) => { - if (typeof url === 'string' && /^https:\/\//i.test(url)) { - void shell.openExternal(url).catch((cause) => log.warn('[game-config] openExternal failed:', describe(cause))); - } - }); - ipcMain.handle(IPC.configSchemaRequest, (): unknown => manifestJsonSchema()); - ipcMain.handle(IPC.configSettingsRequest, (): Promise<AppSettings> => - this.deps.settings.read(), + ipcMain.handle( + IPC.gameConfigAcceptPath, + (_event, payload: GameConfigAcceptRequest): Promise<ConfigPickResult> => + this.acceptPickedPaths(payload.root, payload.kind, payload.paths, payload.base), + ); + ipcMain.handle( + IPC.gameConfigListDir, + (_event, payload: GameConfigListDirRequest): Promise<ListDirResult> => this.listDir(payload), + ); + ipcMain.handle(IPC.gameConfigSources, (): Promise<readonly DriveCandidate[]> => + this.candidates(), + ); + ipcMain.handle(IPC.gameConfigReadRoot, (_event, root: unknown): Promise<ConfigRootReadResult> => + this.readRoot(typeof root === 'string' ? root : ''), + ); + ipcMain.handle( + IPC.gameConfigMoveToCard, + (_event, payload: GameMoveRequest): Promise<ConfigMoveResult> => this.moveToCard(payload), + ); + } + + // ── Drive + PC-library candidates ────────────────────────────────────────── + + /** + * Everything the picker may edit: the removable candidates, plus the PC library as one more entry. + * It is always listed and always "active" — it is this machine, it cannot be unplugged — and its + * `hasManifest: false` (no local game yet) lands the renderer in the SAME blank-drive branch a fresh + * card takes, so adding the first local game needs no new UI state at all. + */ + private candidatesCache: { + readonly at: number; + readonly activeRoot: string | null; + readonly value: readonly DriveCandidate[]; + } | null = null; + + private async candidates(): Promise<readonly DriveCandidate[]> { + const activeRoot = this.deps.getActiveRoot(); + const cached = this.candidatesCache; + if ( + cached !== null && + cached.activeRoot === activeRoot && + Date.now() - cached.at < CANDIDATES_TTL_MS + ) { + return cached.value; + } + const value = await this.readCandidates(); + this.candidatesCache = { at: Date.now(), activeRoot, value }; + return value; + } + + /** Drops the snapshot: our own write changed a manifest the labels/signatures are derived from. */ + private invalidateCandidates(): void { + this.candidatesCache = null; + } + + private async readCandidates(): Promise<readonly DriveCandidate[]> { + const t = this.deps.getTranslator(); + const drives = await listDriveCandidates(this.deps.getActiveRoot(), t); + const root = this.deps.pcLibrary.root; + const hasManifest = await this.deps.pcLibrary.hasManifest(); + // Described exactly like a card ("— Hades" / "— 3 games" / "— invalid game.json"), only prefixed with + // the library's name instead of a mountpoint: the count is as useful here as it is there. The + // signature comes from the same read, so an edit made elsewhere reloads the picker like a card swap. + const { suffix, signature } = await describeManifestContent( + path.join(root, MANIFEST_FILENAME), + hasManifest, + t, + t('drive.noGames'), ); - ipcMain.handle(IPC.configIconRequest, (): Promise<string> => this.readIconDataUrl()); - ipcMain.handle(IPC.configVersionRequest, (): string => app.getVersion()); + const pc: DriveCandidate = { + root, + kind: 'pc', + label: `${t('gameConfig.thisPc')} — ${suffix}`, + signature, + hasManifest, + isActive: true, + }; + return [...drives, pc]; } - // The window shows the app icon in its custom title bar. CSP there is `img-src data:`, so we hand the - // icon over as a data URL rather than a file path (mirrors UpdaterService.readIconDataUrl). Read once. - private iconDataUrl: string | null = null; - private async readIconDataUrl(): Promise<string> { - if (this.iconDataUrl !== null) return this.iconDataUrl; + /** Which manifest dialect `root` speaks — the PC library's, or a card's (see ManifestSource). */ + private sourceOf(root: string): ManifestSource { + return root === this.deps.pcLibrary.root ? 'pc' : 'card'; + } + + // ── Per-game access for the launcher's Customize screen ──────────────────── + + /** + * The manifest a game lives in, addressed by id. Returns the WHOLE file's text: a card may carry + * several games, and the screen edits its own slot in place so the neighbours survive verbatim — the + * ones that failed to resolve included, which are exactly the ones a naive rewrite would destroy. + */ + private async readGame(id: string): Promise<GameConfigReadResult> { + const t = this.deps.getTranslator(); + const found = this.deps.findGameSource(id); + if (found === null) return { ok: false, message: t('errors.gameNotFound') }; + const read = await this.readConfig(found.root); + if (!read.ok) return read; + return { + ok: true, + root: found.root, + source: found.source, + signature: await this.signatureOf(found.root), + text: read.text, + platform: hostPlatform(), + }; + } + + /** + * The manifest of one ROOT rather than of one game — what the Add-game screen reads once the user has + * chosen where the new game goes. `readGame` cannot answer this: it starts from an id, and the whole + * point here is that the root may not carry a single game yet. A missing game.json is a normal answer + * (`hasManifest: false`), not an error — only a file that exists and cannot be read is one. + */ + private async readRoot(root: string): Promise<ConfigRootReadResult> { + const t = this.deps.getTranslator(); + if (!(await this.isAllowedRoot(root))) { + return { ok: false, message: t('errors.driveUnavailable') }; + } + const base = { + root, + source: this.sourceOf(root), + signature: await this.signatureOf(root), + platform: hostPlatform(), + }; + const manifestPath = path.join(root, MANIFEST_FILENAME); + if (!(await fse.pathExists(manifestPath))) return rootReadResult(base, null); try { - const buffer = await fs.readFile(path.join(__dirname, '../icon.png')); - this.iconDataUrl = `data:image/png;base64,${buffer.toString('base64')}`; + return rootReadResult(base, await fse.readFile(manifestPath, 'utf8')); } catch (cause) { - log.error('[game-config] failed to read app icon:', cause); - this.iconDataUrl = ''; // empty → the renderer just hides the <img> + return { + ok: false, + message: t('errors.cannotReadManifest', { + file: MANIFEST_FILENAME, + cause: describe(cause), + }), + }; } - return this.iconDataUrl; } - /** Attaches the window and starts the visible-only drive poll (called on window show). */ - attachWindow(window: BrowserWindow): void { - this.window = window; - this.startPolling(); + /** + * 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 + * stay), so a second save after the first still goes through. + */ + private async signatureOf(root: string): Promise<string> { + const manifestPath = path.join(root, MANIFEST_FILENAME); + const { signature } = await describeManifestContent( + manifestPath, + await fse.pathExists(manifestPath), + this.deps.getTranslator(), + '', + ); + return signature; } - /** Detaches the window and stops the poll (called on window hide/close). */ - detachWindow(): void { - this.window = null; - this.stopPolling(); + /** Save with the swap guard in front of it — everything else is the shared save() path. */ + private async saveChecked(request: GameConfigSaveRequest): Promise<ConfigSaveResult> { + const t = this.deps.getTranslator(); + if (!(await this.isAllowedRoot(request.root))) { + return { saved: false, message: t('errors.driveUnavailable') }; + } + if ((await this.signatureOf(request.root)) !== request.signature) { + return { saved: false, message: t('errors.mediaChanged') }; + } + // The signature just checked IS the "before" picture of the file — sorted ids — so what a write adds + // can be told from it without reading the manifest a second time. + const result = await this.save(request.root, request.text, request.signature); + this.invalidateCandidates(); + return result; } // ── Reading / saving game.json ───────────────────────────────────────────── @@ -166,34 +419,47 @@ export class GameConfigService { } catch (cause) { return { ok: false, - message: t('errors.cannotReadManifest', { file: MANIFEST_FILENAME, cause: describe(cause) }), + message: t('errors.cannotReadManifest', { + file: MANIFEST_FILENAME, + cause: describe(cause), + }), }; } } - private async save(root: string, text: string): Promise<ConfigSaveResult> { + private async save( + root: string, + text: string, + signatureBefore: string, + ): Promise<ConfigSaveResult> { const t = this.deps.getTranslator(); - // 1. main never trusts the renderer's path — it must be a live removable candidate. + // 1. main never trusts the renderer's path — it must be a live removable candidate (or the PC library). if (!(await this.isAllowedRoot(root))) { return { saved: false, message: t('errors.driveUnavailable') }; } + const source = this.sourceOf(root); // 2. re-validate server-side (guards against a UI race that enabled Save with a stale verdict). - const validation = validateManifestText(text, t); + const validation = validateManifestText(text, t, source); if (!validation.ok) { const first = validation.issues[0]; return { saved: false, - message: first !== undefined ? `${first.path}: ${first.message}` : t('errors.configInvalid'), + message: + first !== undefined ? `${first.path}: ${first.message}` : t('errors.configInvalid'), }; } + if (source === 'pc') return this.savePcLibrary(text, t); // 3. atomic write — reuse the card-hardened writer (temp→move, EBUSY/EPERM retry, drive-root nuance). // Write the user's text verbatim so their formatting is preserved (no reserialize). try { - await writeFileAtomic(path.join(root, MANIFEST_FILENAME), text); + await writeFileAtomicEnsuringDir(path.join(root, MANIFEST_FILENAME), text); } catch (cause) { return { saved: false, - message: t('errors.cannotWriteManifest', { file: MANIFEST_FILENAME, cause: describe(cause) }), + message: t('errors.cannotWriteManifest', { + file: MANIFEST_FILENAME, + cause: describe(cause), + }), }; } // 4. apply. Active card → reload in place; any other (blank/second) card → DriveWatcher handles it @@ -204,67 +470,611 @@ export class GameConfigService { ? { saved: true, applied: 'applied' } : { saved: true, applied: 'failed', message: applied.message }; } + // A deferred write is the one outcome with nothing to show for it: the file is on the card, the card + // is not the active one, and the library will not mention the game until it becomes active. Say so, + // from here — the notification follows the WRITE, whoever asked for it and for whatever reason. + for (const added of addedGamesOf(signatureBefore, text)) { + this.deps.notify({ kind: 'game-added-deferred', gameTitle: added.title }); + } return { saved: true, applied: 'deferred' }; } - // ── File/folder picker for the Configure form (paths card-relative) ───────── + /** + * Saves the PC library's game.json and re-reads it into the running launcher. Unlike a card there is no + * "deferred" outcome: the library is always the app's own directory, so an edit either applies now or + * reports why it could not. + * + * An EMPTY list is not written as a file — it removes game.json entirely. That is how deleting the last + * local game is spelled (the renderer sends `[]`), and it keeps the "no manifest ⇒ blank form" state the + * picker relies on from being shadowed by a technically-present but empty file. + */ + private async savePcLibrary(text: string, t: Translator): Promise<ConfigSaveResult> { + const emptied = isEmptyManifestList(text); + try { + if (emptied) await this.deps.pcLibrary.removeManifest(); + else + await writeFileAtomicEnsuringDir( + path.join(this.deps.pcLibrary.root, MANIFEST_FILENAME), + text, + ); + } catch (cause) { + return { + saved: false, + message: t('errors.cannotWriteManifest', { + file: MANIFEST_FILENAME, + cause: describe(cause), + }), + }; + } + const applied = await this.deps.reloadPcLibrary(); + return applied.ok + ? { saved: true, applied: 'applied' } + : { saved: true, applied: 'failed', message: applied.message }; + } + + // ── 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<ConfigMoveResult> { + 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<void> => { + 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/<id> 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'; + if (request.toRoot === this.deps.getActiveRoot()) { + const reload = await this.deps.reloadManifest(request.toRoot); + applied = 'applied'; + 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/<id>`) as a fallback. Null when neither has anything to copy. */ + private async liveOrBackupSaveDir(manifest: ResolvedManifest): Promise<string | null> { + 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<void> { + 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), + ); + } + } /** - * Picks file(s)/a folder from the card via the native dialog (parented to the Configure window) and - * returns card-RELATIVE paths with forward slashes. Mirrors pickWallpaper's shape (ipc.ts) but adds the - * two manifest guarantees: the `root` is re-checked against the live candidates (never trusted), and - * every picked path is verified to stay INSIDE the root (path.relative without `..`/absolute) — a file - * chosen elsewhere is rejected rather than turned into a `..`-escape. For a `directory` pick the card - * root itself yields an empty relative, which the manifest's `min(1)` would reject, so it is refused too. + * 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 pickPath( - sender: WebContents, + private async rollbackCardWrite(toRoot: string, toTextBefore: string | null): Promise<boolean> { + 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( + text: string, + t: Translator, + ): Promise<{ readonly ok: true } | { readonly ok: false; readonly message: string }> { + const emptied = isEmptyManifestList(text); + try { + if (emptied) await this.deps.pcLibrary.removeManifest(); + else + await writeFileAtomicEnsuringDir( + path.join(this.deps.pcLibrary.root, MANIFEST_FILENAME), + text, + ); + } catch (cause) { + return { + ok: false, + message: t('errors.cannotWriteManifest', { + file: MANIFEST_FILENAME, + cause: describe(cause), + }), + }; + } + const reload = await this.deps.reloadPcLibrary(); + if (!reload.ok) { + log.warn(`[game-move] library write applied, but the reload failed: ${reload.message}`); + } + 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<ConfigPickResult> { const t = this.deps.getTranslator(); if (!(await this.isAllowedRoot(root))) { return { ok: false, message: t('errors.driveUnavailable') }; } - const parent = BrowserWindow.fromWebContents(sender); - // pcSavePath points at a PC folder OUTSIDE the card (env-prefixed), so it has its own dialog: no card - // root restriction, and the absolute result is converted back to a %PREFIX%/… form the validator accepts. + // 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 options: Electron.OpenDialogOptions = { properties: ['openDirectory'] }; - const picked = - parent !== null ? await dialog.showOpenDialog(parent, options) : await dialog.showOpenDialog(options); - const chosen = picked.filePaths[0]; - if (picked.canceled || chosen === undefined) return { ok: false, cancelled: true }; - const pcSavePath = this.deps.toManifestPcSavePath(chosen); - if (pcSavePath === null) return { ok: false, message: t('configure.pickPcSaveOutside') }; + const pcSavePath = this.deps.toManifestPcSavePath(first); + if (pcSavePath === null) return { ok: false, message: t('gameConfig.pickPcSaveOutside') }; return { ok: true, paths: [pcSavePath] }; } - const filters = pickFilters(kind); - const options: Electron.OpenDialogOptions = { - defaultPath: root, - properties: pickProperties(kind), - ...(filters.length > 0 ? { filters } : {}), - }; - const result = - parent !== null ? await dialog.showOpenDialog(parent, options) : await dialog.showOpenDialog(options); - if (result.canceled || result.filePaths.length === 0) return { ok: false, cancelled: true }; + // 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 result.filePaths) { - const relative = path.relative(root, absolute); - // Outside the card (a `..`-leading or absolute relative) — or the root itself for a folder pick - // (empty relative) — is rejected: we never emit an escaping or empty manifest path. - if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) { + for (const absolute of absolutePaths) { + const relative = toCardRelative(measureFrom, absolute); + if (relative === null) { return { ok: false, - message: t(kind === 'directory' ? 'configure.pickChooseSubfolder' : 'configure.pickOutsideCard'), + message: t( + kind === 'directory' ? 'gameConfig.pickChooseSubfolder' : 'gameConfig.pickOutsideCard', + ), }; } - relatives.push(relative.split(path.sep).join('/')); + 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<string | null> { + const t = this.deps.getTranslator(); + let stat: Parameters<typeof checkPickedType>[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<ListDirResult> { + 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<readonly DirRoot[]> { + 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. @@ -278,40 +1088,21 @@ export class GameConfigService { return url ?? null; } - /** True when `root` is a current removable/non-system mountpoint (anti-arbitrary-write check). */ - private async isAllowedRoot(root: string): Promise<boolean> { - const candidates = await listDriveCandidates(this.deps.getActiveRoot(), this.deps.getTranslator()); - return candidates.some((candidate) => candidate.root === root); - } - - // ── Drive polling (only while the window is visible) ─────────────────────── - - private startPolling(): void { - if (this.pollTimer !== null) return; - void this.pushDrives(); // an immediate snapshot so the picker doesn't wait a full interval - this.pollTimer = setInterval(() => void this.pushDrives(), DRIVE_POLL_INTERVAL_MS); - } - - private stopPolling(): void { - if (this.pollTimer !== null) { - clearInterval(this.pollTimer); - this.pollTimer = null; - } + /** + * True when `root` is a current removable/non-system mountpoint, or the app's own PC-library root + * (anti-arbitrary-write check — the closed set of roots this service will ever write to). + */ + /** + * The public face of `isAllowedRoot`, for the one other service that writes into a game's root: + * MetadataService puts a downloaded cover or track there, and it must answer the same question this + * service asks before every write rather than a second, slightly different one. + */ + isWritableRoot(root: string): Promise<boolean> { + return this.isAllowedRoot(root); } - private async pushDrives(): Promise<void> { - if (this.polling) return; // skip overlapping ticks (drivelist can be slow on some readers) - this.polling = true; - try { - const drives = await listDriveCandidates(this.deps.getActiveRoot(), this.deps.getTranslator()); - const window = this.window; - if (window !== null && !window.isDestroyed()) { - window.webContents.send(IPC.configDrivesUpdate, drives); - } - } catch (cause) { - log.warn('[game-config] drive poll failed:', describe(cause)); - } finally { - this.polling = false; - } + private async isAllowedRoot(root: string): Promise<boolean> { + const candidates = await this.candidates(); + return candidates.some((candidate) => candidate.root === root); } } diff --git a/src/main/game-move.ts b/src/main/game-move.ts new file mode 100644 index 00000000..c3a39743 --- /dev/null +++ b/src/main/game-move.ts @@ -0,0 +1,126 @@ +// 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. +import path from 'node:path'; +import { + movedGridAssetPath, + movedHeroAssetPath, + movedMusicAssetPath, +} from '../shared/asset-move-names'; +import { type ResolvedManifest } from '../shared/types'; + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Manifest TEXT with `id`'s game removed — used for BOTH halves of a move's rollback story: the PC + * 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). + */ +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 kept = items.filter((item) => !(isRecord(item) && item['id'] === id)); + if (kept.length === 0) return '[]\n'; + const value: unknown = kept.length === 1 ? kept[0] : kept; + return `${JSON.stringify(value, null, 2)}\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<string, unknown> | null { + let parsed: unknown; + try { + parsed = JSON.parse(text) as unknown; + } catch { + return null; + } + const items: readonly unknown[] = Array.isArray(parsed) ? parsed : [parsed]; + 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. + * + * `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 + * the time Save runs. Which field names the on-card file differs per mode: + * • installer (`install.type` other than `copy`) — `install.installer`. `executable` there is a path + * INSIDE the installed game (manifest.ts resolveInstall resolves it against the install dir, not the + * card), so checking it would reject a perfectly good move forever; + * • `copy` — `executable`, which in that mode is card-root-relative and includes the source directory + * prefix, so it names a real file on the card; + * • no install block — `executable`, card-relative as usual. + */ +export function expectedGameFilePath(raw: Record<string, unknown>): string | null { + if (raw['steam'] !== undefined) return null; + const install = raw['install']; + if (isRecord(install) && install['type'] !== 'copy') { + return typeof install['installer'] === 'string' ? install['installer'] : null; + } + return typeof raw['executable'] === 'string' ? raw['executable'] : null; +} + +/** 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]; + return items.filter((item) => isRecord(item) && item['id'] === id).length; +} + +/** One asset that needs copying: an absolute source (in the PC library) to an absolute destination (under + * the target card root), the destination named by the SAME deterministic function the renderer used to + * write the path into the target manifest text (see asset-move-names.ts). */ +export interface AssetCopyPlan { + readonly from: string; + readonly to: string; +} + +/** + * The asset copies a move needs, derived from the SOURCE game's resolved manifest (absolute paths) and its + * id. Order is stable (hero images in manifest order, then grid, then music) but not meaningful beyond + * that — each copy is independent. + */ +export function planAssetCopies( + manifest: Pick<ResolvedManifest, 'heroImagePaths' | 'gridImagePath' | 'backgroundMusicPath'>, + id: string, + targetRoot: string, +): readonly AssetCopyPlan[] { + const plans: AssetCopyPlan[] = []; + for (const [index, source] of (manifest.heroImagePaths ?? []).entries()) { + plans.push({ from: source, to: path.join(targetRoot, movedHeroAssetPath(id, index, source)) }); + } + if (manifest.gridImagePath !== undefined) { + plans.push({ + from: manifest.gridImagePath, + to: path.join(targetRoot, movedGridAssetPath(id, manifest.gridImagePath)), + }); + } + if (manifest.backgroundMusicPath !== undefined) { + plans.push({ + from: manifest.backgroundMusicPath, + to: path.join(targetRoot, movedMusicAssetPath(id, manifest.backgroundMusicPath)), + }); + } + return plans; +} diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 29afa828..d33a2fc3 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -4,7 +4,7 @@ // 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, BrowserWindow, dialog, ipcMain, type WebContents } from 'electron'; +import { app, clipboard, ipcMain } from 'electron'; import { IPC, type AppState, @@ -17,17 +17,17 @@ import { type ResolvedInstallerRun, type ResolvedCopyInstall, type LaunchTarget, + type ManifestSource, type ResolvedManifest, - type SfxName, type Stats, - type WallpaperResult, } 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 { 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 { type DriveWatcher } from './drive-watcher'; import { readManifests, findCaseInsensitiveName, type ManifestEnv } from './manifest'; @@ -49,6 +49,7 @@ 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'; @@ -62,9 +63,17 @@ export interface ControllerDeps { 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; /** @@ -80,7 +89,14 @@ export interface ControllerDeps { // 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. -const BROWSE_ASSETS_DEBOUNCE_MS = 250; +// +// 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; @@ -274,13 +290,28 @@ async function removeWithRetry(dir: string, signal?: AbortSignal): Promise<void> 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). `games` holds every resolved - // game; `selectedIndex` is the one currently selected/browsed. `current()` (below) derives the single - // "active" manifest that all the existing launch/kill/uninstall/save-sync/stats code reads, so those - // bodies stay untouched. Empty (`games=[]`) whenever no card / rejected. - private games: ResolvedManifest[] = []; - private selectedIndex = 0; + // 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; @@ -289,11 +320,13 @@ export class GameController { // 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.alwaysShowEmptyScreen (seeded at startup, toggled live from the settings - // window): when true the launcher stays on the empty "no card" screen instead of hiding to the tray. - private alwaysShowEmptyScreen = 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; private launchInFlight = false; - // A manifest reload from the Configure-game window is in flight. Unlike launchInFlight it does NOT + // 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 @@ -340,16 +373,22 @@ export class GameController { // — 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<typeof setTimeout> | 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<string, Stats>(); - // Reads card assets (hero/audio/wallpaper) into data URLs; owns the effective-wallpaper cache and the - // custom Empty-screen wallpaper (needs userData + the live custom-file name from settings via DI). + // 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({ - userData: app.getPath('userData'), - getCustomWallpaperName: async () => (await this.deps.settings.read()).customWallpaper, getSoundSet: async () => (await this.deps.settings.read()).soundSet, getAmbientTrack: async () => (await this.deps.settings.read()).ambientTrack, getOnlyGlobalAmbient: async () => (await this.deps.settings.read()).onlyGlobalAmbient, @@ -360,9 +399,20 @@ export class GameController { getManifest: () => this.current(), isLaunchInFlight: () => this.launchInFlight, getState: () => this.deps.state.get(), - isCardPresent: () => this.cardPresent, + isSourceAvailable: () => this.currentSourceAvailable(), enterReady: (info) => this.enterReady(info), - onInstallCompleted: () => this.playSfx('play'), + 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, }); @@ -406,23 +456,107 @@ export class GameController { 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))]; + } + + /** + * Ids that must survive a history eviction: everything on the card AND everything in the PC library — + * including a local game currently shadowed by the card (its record is the same one). Passing only one + * source's ids would let a full history evict the other source's games (see LibraryStore.gc). + */ + private protectedIds(): readonly string[] { + return [...this.cardGames, ...this.pcGames].map((manifest) => manifest.raw.id); + } + /** * The single "active" manifest — the selected game — that every existing consumer reads - * (launch/kill/uninstall/save-sync/stats). Read-only: the card's games live in `games`, the choice in - * `selectedIndex`. Null when there is no card / it was rejected. + * (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 { - return this.games[this.selectedIndex] ?? 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 { - this.games = []; - this.selectedIndex = 0; + // 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.steamBusyId = null; // the card is gone; whatever Steam is doing is no longer ours to guard - this.statsById.clear(); + 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); @@ -435,6 +569,18 @@ export class GameController { 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" @@ -469,21 +615,25 @@ export class GameController { 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<string | null> => { if (typeof id !== 'string') return Promise.resolve(null); return this.deps.library.readGridThumb(id); }); - ipcMain.on(IPC.libraryBrowse, (_event, id: unknown) => void this.onBrowseRequested(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.wallpaperRequest, (): Promise<string | null> => this.assets.readWallpaperDataUrl()); - // Custom Empty-screen wallpaper (invoked from the settings window; the handlers live here because they - // own the AssetReader + the game window — see plan F2.2 p.6). preview-request feeds the settings preview. - ipcMain.handle(IPC.wallpaperPick, (event): Promise<WallpaperResult> => this.pickWallpaper(event.sender)); - ipcMain.handle(IPC.wallpaperClear, (): Promise<{ dataUrl: string }> => this.clearWallpaper()); - ipcMain.handle(IPC.wallpaperPreviewRequest, async (): Promise<{ dataUrl: string }> => ({ - dataUrl: (await this.assets.readWallpaperDataUrl()) ?? '', - })); + ipcMain.handle( + IPC.startupSoundRequest, + (): Promise<string | null> => 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 @@ -497,7 +647,11 @@ export class GameController { void this.warmSfxSet(); void this.warmAmbient(); - void this.warmLibrary(); + // 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 @@ -555,10 +709,24 @@ export class GameController { 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 }); - // Poll for ANY steam game while the card is present: it catches install completion (Install→Play), + // 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. The .acf read is cheap, so a perpetual 5s poll for an inserted steam card is fine. - if (info.installVia === 'steam' && this.cardPresent) { + // 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(); @@ -581,10 +749,10 @@ export class GameController { /** * 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 Configure-window reload. + * 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 an Apply from the Configure window doesn't steal focus from the editor. Returns the + * 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( @@ -612,9 +780,13 @@ export class GameController { return { ok: false, message: result.message }; } const manifests = result.manifests; - // Keep the selection on a reload if it still points at a game; a real insert starts at the first. - this.selectedIndex = opts.focus || this.selectedIndex >= manifests.length ? 0 : this.selectedIndex; - this.games = 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}"`); @@ -631,7 +803,7 @@ export class GameController { // 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.statsById.clear(); + 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); @@ -649,39 +821,228 @@ export class GameController { } } - // Deliver 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. - this.refreshLibrary(); + // 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[this.selectedIndex] ?? manifests[0]; + 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.assets.readMusicDataUrl(selected)); + 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.browseTo(selected.raw.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) + .saveFromCard(manifests, this.protectedIds()) .then(() => this.refreshLibrary()) .catch((cause: unknown) => log.warn('[library] copying the card assets failed:', describe(cause))); return { ok: true }; } /** - * Applies an edited game.json to the ACTIVE card without restarting the app (Configure-game window). + * 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<void> { + 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(); + 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, this.protectedIds()) + .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<void> { + 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<void> { + 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; + return ( + kind === 'running' || + kind === 'installing' || + kind === 'uninstalling' || + this.steamBusyId !== null + ); + } + + /** Logs the local games the inserted card currently shadows (same id — the card wins, see `games`). */ + 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), so the editor keeps it. + * 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); @@ -703,14 +1064,18 @@ export class GameController { } private async flushPendingIfAny(manifest: ResolvedManifest): Promise<void> { - if (manifest.saveOnCardPath === undefined) return; + // 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, manifest.saveOnCardPath); + 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); @@ -773,14 +1138,22 @@ export class GameController { 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 "always show the no-card - // screen" on, keep the launcher up on the empty screen 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). + // 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 — always keep the empty "insert a card" screen up (forces alwaysShowEmptyScreen). + // Game Mode: no tray — the launcher always stays up (forces keepOpenWithoutCard). this.deps.window.showAndFocus(); - } else if (this.alwaysShowEmptyScreen) { + } else if (this.keepOpenWithoutCard) { if (this.deps.window.isShown()) this.deps.window.showAndFocus(); } else { this.deps.window.hide(); @@ -788,14 +1161,17 @@ export class GameController { } /** - * Applies the "always show the no-card screen" setting (seeded at startup, toggled live from the + * 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: show the empty screen 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. + * 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. */ - setAlwaysShowEmptyScreen(on: boolean): void { - this.alwaysShowEmptyScreen = on; - if (this.cardPresent || this.deps.state.get().kind !== 'idle') return; + 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. @@ -819,6 +1195,20 @@ export class GameController { 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 @@ -871,22 +1261,20 @@ export class GameController { if (typeof idRaw !== 'string') return; const snapshot = this.deps.state.get(); if (snapshot.kind !== 'ready' || this.locked || this.launchInFlight || this.reloadInFlight) return; - const index = this.games.findIndex((m) => m.raw.id === idRaw); - if (index === -1) { - log.warn(`[select] no game with id="${idRaw}" on the current card — ignoring`); + 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; } - const manifest = this.games[index]; - if (manifest === undefined) return; - this.selectedIndex = index; + 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.assets.readMusicDataUrl(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.browseTo(manifest.raw.id); + await this.browseToUnlessPinned(manifest.raw.id); } /** @@ -1066,10 +1454,9 @@ export class GameController { 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. - if (!this.cardPresent) { - this.clearCard(); - state.set({ kind: 'idle' }); - this.hideToTrayOrKeepEmpty(); + // 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" @@ -1113,7 +1500,7 @@ export class GameController { } // 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.cardPresent) { + if (info.installVia === 'steam' && info.requiresInstall && this.sourceAvailableFor(info.id)) { this.steamWatch.start(); } } @@ -1322,7 +1709,7 @@ export class GameController { // the carousel (an inserted-but-never-played game is not listed until now). The GC runs here too: // recordPlay is the one moment the ordering that decides eviction actually changes. await this.deps.library.noteLaunch(manifest.raw.id, updatedStats); - await this.deps.library.gc(this.games.map((m) => m.raw.id)); + await this.deps.library.gc(this.protectedIds()); // 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); @@ -1337,7 +1724,7 @@ export class GameController { // 7. done this.enterReady(updatedInfo); // Refresh what's on screen too: the play time / launch count the detail screen shows just changed. - await this.browseTo(manifest.raw.id); + await this.browseToUnlessPinned(manifest.raw.id); window.showAndFocus(); } catch (cause) { if (cause instanceof LaunchAbortedError) return; // application is shutting down @@ -1416,9 +1803,14 @@ export class GameController { const installedInfo = await this.buildGameInfo(manifest, currentStats); log.info(`[install] completed id=${manifest.raw.id} dir="${install.dir}"`); this.enterReady(installedInfo); - // Audible "install finished" cue — covers both an installer run and the `copy` type (both reach - // here only on a real completion, never on a plain card insert of an already-installed game). - this.playSfx('play'); + // 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 @@ -1563,10 +1955,8 @@ export class GameController { // 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.cardPresent) { - this.clearCard(); - state.set({ kind: 'idle' }); - this.hideToTrayOrKeepEmpty(); + if (!this.sourceAvailable(manifest)) { + this.cardGoneAfterSequence(); return; } @@ -1576,6 +1966,11 @@ export class GameController { 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 @@ -1618,11 +2013,9 @@ export class GameController { */ private abandonWatchedLaunch(game: GameInfo): void { log.info('[launch] watched game never appeared — returning without recording a session'); - if (!this.cardPresent) { + if (!this.currentSourceAvailable()) { this.steamWatch.stop(); - this.clearCard(); - this.deps.state.set({ kind: 'idle' }); - this.hideToTrayOrKeepEmpty(); + this.cardGoneAfterSequence(); return; } this.enterReady(game); @@ -1654,7 +2047,11 @@ export class GameController { containerExists: boolean, ): Promise<void> { const id = manifest.raw.id; - const baseline = containerExists ? await this.deps.store.readSyncState(id) : null; + // 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`); } @@ -1669,7 +2066,7 @@ export class GameController { log.info( `[save-sync] id=${id} direction=${result.direction}${result.usedFallback ? ' (fallback: no baseline)' : ''}`, ); - await this.deps.store.writeSyncState(id, result.state); + await this.deps.store.writeSyncState(id, result.state, slot); } private async performSyncOut(manifest: ResolvedManifest, stats: Stats): Promise<void> { @@ -1683,8 +2080,9 @@ export class GameController { 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. - if (!this.cardPresent) { + // 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); } @@ -1708,6 +2106,7 @@ export class GameController { // 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)); @@ -1716,7 +2115,27 @@ export class GameController { } } } - await this.deps.stats.copyToCard(manifest.root, manifest.raw.id, stats); + // 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<void> { + 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 ───────────────────────────────────────── @@ -1760,12 +2179,22 @@ export class GameController { // 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, @@ -1782,71 +2211,11 @@ export class GameController { ...(steamInstalling ? { steamInstalling: true } : {}), ...(steamPaused ? { steamPaused: true } : {}), ...(steamPausedProgress !== undefined ? { steamPausedProgress } : {}), + ...(unavailable ? { unavailable: true } : {}), + ...(unconfigured ? { unconfigured: true } : {}), }; } - // ── Custom Empty-screen wallpaper ──────────────────────────────────────── - - /** - * Picks an image via the OS file dialog (parented to the settings window), copies it in as the custom - * Empty-screen wallpaper, persists its file name, and pushes the new data URL to the launcher so the - * Empty screen updates live. Cancellation and validation failures come back as a Result-union. - */ - private async pickWallpaper(sender: WebContents): Promise<WallpaperResult> { - const parent = BrowserWindow.fromWebContents(sender); - const options: Electron.OpenDialogOptions = { - properties: ['openFile'], - filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'webp', 'gif'] }], - }; - const result = - parent !== null ? await dialog.showOpenDialog(parent, options) : await dialog.showOpenDialog(options); - const sourcePath = result.filePaths[0]; - if (result.canceled || sourcePath === undefined) return { ok: false, cancelled: true }; - const set = await this.assets.setCustomWallpaper(sourcePath); - if (!set.ok) return { ok: false, message: this.wallpaperErrorMessage(set.reason) }; - await this.deps.settings.patch({ customWallpaper: set.fileName }); - this.pushWallpaper(set.dataUrl); - return { ok: true, dataUrl: set.dataUrl }; - } - - /** Clears the custom wallpaper (settings + file), returns and pushes the default wallpaper data URL. */ - private async clearWallpaper(): Promise<{ dataUrl: string }> { - const { dataUrl } = await this.assets.clearCustomWallpaper(); - await this.deps.settings.patch({ customWallpaper: null }); - this.pushWallpaper(dataUrl); - return { dataUrl }; - } - - /** - * Removes the custom wallpaper file and pushes the default to the launcher, for the general settings - * Reset: reset() already wrote customWallpaper=null, but the FILE must still be deleted separately (see - * plan F2.2 p.7). Called from main via the UpdaterService onWallpaperReset callback. - */ - async resetCustomWallpaper(): Promise<void> { - const { dataUrl } = await this.assets.clearCustomWallpaper(); - this.pushWallpaper(dataUrl); - } - - /** Pushes the Empty-screen wallpaper data URL to the game window so it repaints the Empty screen live. */ - private pushWallpaper(dataUrl: string): void { - const browserWindow = this.deps.window.browserWindow; - if (browserWindow !== null && !browserWindow.isDestroyed()) { - browserWindow.webContents.send(IPC.wallpaperUpdate, dataUrl); - } - } - - /** Maps an AssetReader failure reason to a localized, user-facing message for the settings window. */ - private wallpaperErrorMessage(reason: 'too-large' | 'not-image' | 'io'): string { - switch (reason) { - case 'too-large': - return this.t('errors.wallpaperTooLarge'); - case 'not-image': - return this.t('errors.wallpaperNotImage'); - case 'io': - return this.t('errors.wallpaperFailed'); - } - } - // ── 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). */ @@ -1860,6 +2229,22 @@ export class GameController { // ── 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<string | null> { + 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; @@ -1881,7 +2266,7 @@ export class GameController { async refreshAudio(): Promise<void> { this.sfxSet = await this.assets.readSfxSet(); const manifest = this.current(); - this.setCardMusic(manifest === null ? null : await this.assets.readMusicDataUrl(manifest)); + 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). @@ -1927,13 +2312,6 @@ export class GameController { } } - /** Asks the game renderer to play a one-shot UI sound (main owns no <audio> — the renderer does). */ - private playSfx(name: SfxName): void { - const browserWindow = this.deps.window.browserWindow; - if (browserWindow !== null && !browserWindow.isDestroyed()) { - browserWindow.webContents.send(IPC.sfxPlay, name); - } - } // ── Carousel list (the card's games + the play history) ──────────────────── @@ -1957,20 +2335,26 @@ export class GameController { * background after the window is already up, and the carousel must not wait for it. */ 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); - const cardGames = byRecentlyPlayed( - this.games.map((manifest) => ({ - id: manifest.raw.id, - title: manifest.raw.title, - lastPlayedAt: - this.statsById.get(manifest.raw.id)?.lastPlayedAt ?? - this.deps.library.entry(manifest.raw.id)?.lastPlayedAt ?? - null, - })), - ); + // 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 = [ - ...cardGames.map((game) => { + ...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); @@ -1979,6 +2363,7 @@ export class GameController { title: game.title, active: true, ...(stored !== null ? { artRev: stored.savedAt } : {}), + ...(game.unconfigured === true ? { unconfigured: true as const } : {}), }; }), ...this.deps.library @@ -1991,7 +2376,24 @@ export class GameController { artRev: entry.savedAt, })), ]; - this.setLibrary(games.length > 0 ? { games } : null); + 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 unconfigured?: true }[] { + return byRecentlyPlayed( + manifests.map((manifest) => ({ + id: manifest.raw.id, + title: manifest.raw.title, + 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) ───────────────────────────────────────────── @@ -2035,9 +2437,39 @@ export class GameController { * 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): Promise<void> { + private async onBrowseRequested(idRaw: unknown, immediateRaw: unknown): Promise<void> { + // 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; - await this.browseTo(idRaw); + 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<void> { + 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(); } /** @@ -2047,13 +2479,13 @@ export class GameController { * 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): Promise<void> { + private async browseTo(id: string, immediate = false): Promise<void> { 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); + this.scheduleBrowseAssets(id, immediate); return; } const entry = this.deps.library.entry(id); @@ -2063,33 +2495,78 @@ export class GameController { } const stats = await this.deps.stats.read(id); this.pushBrowse({ id, title: entry.title, active: false, stats }); - this.scheduleBrowseAssets(id); + this.scheduleBrowseAssets(id, immediate); } - /** Debounced read+push of the browsed game's hero/music; a newer browse cancels the pending one. */ - private scheduleBrowseAssets(id: string): void { + /** + * 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<void> { + 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): Promise<void> { - // The selection moved on while we waited — the read would only fight the newer one. - if (this.currentBrowse?.id !== id) return; + private async pushBrowseAssets(id: string | null): Promise<void> { + 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) { - this.pushBrowseHero(await this.assets.readHeroAssets(manifest)); - this.pushBrowseMusic(await this.assets.readMusicDataUrl(manifest)); + 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. - this.pushBrowseHero(assets.hero ?? (await this.wallpaperHero())); - this.pushBrowseMusic(await this.browseMusicFor(id)); + 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. */ @@ -2098,6 +2575,40 @@ export class GameController { 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 @@ -2113,6 +2624,6 @@ export class GameController { this.pushBrowseMusic(null); return; } - await this.browseTo(next.id); + await this.browseToUnlessPinned(next.id); } } diff --git a/src/main/json-store.ts b/src/main/json-store.ts index 4a64ec43..6c4ec88b 100644 --- a/src/main/json-store.ts +++ b/src/main/json-store.ts @@ -5,10 +5,11 @@ // but fails to read or validate is a real anomaly (corruption / incompatible shape) that gets a // log.warn breadcrumb instead of a silent fallback that could mask damaged user data. import fs from 'node:fs/promises'; +import path from 'node:path'; import fse from 'fs-extra'; import type { z } from 'zod'; import { log } from './logger'; -import { withRetry } from './save-sync'; +import { delay } from './util'; function isMissingFile(cause: unknown): boolean { return ( @@ -50,24 +51,182 @@ export async function readJsonValidated<S extends z.ZodTypeAny>( * window where the file is ABSENT (ENOENT → a silent fallback to defaults on the next read). `fs.rename` * maps to MoveFileEx (MOVEFILE_REPLACE_EXISTING) on Windows — an atomic same-volume replace, so an * interrupted write leaves either the old or the new complete file, never a truncated/missing one. A - * transient EBUSY/EPERM (AV/indexer holding the target) is retried. Callers must ensure the parent + * transient EBUSY/EPERM (AV/indexer holding the target) is retried, and a target that refuses the + * replace outright falls back to an in-place write (see replaceInPlace). Callers must ensure the parent * directory exists (a drive-root parent already does; nested dirs need an ensureDir first). */ export async function writeJsonAtomic(filePath: string, value: unknown): Promise<void> { await writeFileAtomic(filePath, `${JSON.stringify(value, null, 2)}\n`); } +const BASE_BACKOFF_MS = 200; + +/** + * How long the replace is retried, per KIND of failure — the two need different patience. + * + * A busy file (an antivirus or an indexer holding the target mid-scan) clears up on its own, and waiting + * it out is the whole point: it gets the full five tries, ~6.2s, and nothing else can save the write if + * it does not. A PERMISSION refusal does not clear up on its own — a read-only attribute or an ACL + * without delete rights stays exactly as it is — so waiting buys nothing there and only makes the user + * watch a toggle hang on its way to working; three tries (~1.4s) is enough to rule out a passing lock + * that merely reported itself as EPERM, and then `replaceInPlace` takes over. + */ +const BUSY_ATTEMPTS = 5; +const PERMISSION_ATTEMPTS = 3; + +const RETRYABLE_CODES = new Set(['EBUSY', 'EPERM', 'EACCES', 'ENOTEMPTY']); + /** * The same temp-file → rename guarantee for arbitrary content (added for the binary `shortcuts.vdf` - * write, where a torn file costs the user every non-Steam shortcut they have). `writeJsonAtomic` is now a + * write, where a torn file costs the user every non-Steam shortcut they have). `writeJsonAtomic` is a * thin wrapper over this — see its doc comment for why the final step is a bare `fs.rename`. */ export async function writeFileAtomic(filePath: string, data: string | Buffer): Promise<void> { - const tmp = `${filePath}.tmp`; - if (typeof data === 'string') { - await fs.writeFile(tmp, data, 'utf8'); - } else { - await fs.writeFile(tmp, data); + const tmp = tmpNameFor(filePath); + try { + await writeRaw(tmp, data); + } catch (cause) { + // The temp could not even be STAGED — the directory itself refuses new files. Writing through the + // existing target is still worth a try: that needs permission on the file, not on its directory. + // A partially-written temp may still be lying there, so clear it either way. + await fs.rm(tmp, { force: true }).catch(() => undefined); + if (!isPermissionError(cause)) throw cause; + await writeThrough(filePath, data, cause); + return; + } + try { + await renameWithBackoff(tmp, filePath); + return; + } catch (cause) { + if (!isPermissionError(cause)) { + await fs.rm(tmp, { force: true }).catch(() => undefined); + throw cause; + } + await replaceInPlace(filePath, tmp, data, cause); + } +} + +/** + * `writeFileAtomic` plus the ONE thing it deliberately refuses to do: create the parent directory. Used by + * the writers whose target may be a path that does not exist yet (a card's save folder, a nested store) — + * card manifests and `stats.json` among them, which is what a second, weaker implementation of + * `writeFileAtomic` in save-sync.ts used to serve before this replaced it. + * + * The existence check is not redundant: on Windows, mkdir of a DRIVE ROOT (`E:\`, the card root that + * carries `stats.json`) throws EPERM even though it is plainly there, so an unconditional ensureDir would + * fail every card-root write. + */ +export async function writeFileAtomicEnsuringDir( + filePath: string, + data: string | Buffer, +): Promise<void> { + const dir = path.dirname(filePath); + if (!(await fse.pathExists(dir))) await fse.ensureDir(dir); + await writeFileAtomic(filePath, data); +} + +/** + * The atomic replace, retried with exponential backoff on the codes a transient holder produces. Local + * rather than save-sync's `withRetry` because the budget depends on WHICH code came back — see + * BUSY_ATTEMPTS / PERMISSION_ATTEMPTS. Anything not retryable is rethrown on the first try, unchanged. + */ +async function renameWithBackoff(tmp: string, filePath: string): Promise<void> { + for (let attempt = 0; ; attempt += 1) { + try { + await fs.rename(tmp, filePath); + return; + } catch (cause) { + const code = errorCode(cause); + const attempts = isPermissionError(cause) ? PERMISSION_ATTEMPTS : BUSY_ATTEMPTS; + if (code === undefined || !RETRYABLE_CODES.has(code) || attempt + 1 >= attempts) throw cause; + await delay(BASE_BACKOFF_MS * 2 ** attempt); + } + } +} + +let tmpCounter = 0; + +/** + * A temp name unique to THIS write. A shared `<file>.tmp` is shared mutable state between concurrent + * writers of the same file: both write the one temp, the first rename consumes it, and the second fails + * with ENOENT on a file that was never theirs — which is exactly how the history index lost five writes + * in a row while several games were being copied into it at once. The pid keeps two processes (the GUI + * and the Game Mode daemon share `%APPDATA%`) from colliding on the counter. + */ +function tmpNameFor(filePath: string): string { + tmpCounter += 1; + return `${filePath}.${process.pid}.${tmpCounter}.tmp`; +} + +function errorCode(cause: unknown): string | undefined { + if (!(cause instanceof Error) || !('code' in cause)) return undefined; + const code = (cause as { readonly code?: unknown }).code; + return typeof code === 'string' ? code : undefined; +} + +function isPermissionError(cause: unknown): boolean { + const code = errorCode(cause); + return code === 'EPERM' || code === 'EACCES'; +} + +async function writeRaw(target: string, data: string | Buffer): Promise<void> { + if (typeof data === 'string') await fs.writeFile(target, data, 'utf8'); + else await fs.writeFile(target, data); +} + +/** + * Last resort when the atomic replace is refused OUTRIGHT (not the transient EBUSY/EPERM withRetry + * already rides out): the rename needs DELETE on the existing target, which is a different right from + * "may write to it", and a file can be left without it by something other than this app — a read-only + * attribute, or an ACL inherited from an install under another account (a per-user install taking over a + * `%APPDATA%` file an all-users one created is the case that surfaced this). + * + * So: clear the read-only attribute and try the atomic path once more; failing that, write THROUGH the + * existing file, which needs only write access. That gives up atomicity — an interrupted write can leave + * the file torn — which is why it is reached only after the safe path has genuinely failed: a settings + * file that cannot be saved at all is a worse outcome than one with a small window of risk, and the + * caller is told either way (the original error is re-thrown if even this does not land). + */ +async function replaceInPlace( + filePath: string, + tmp: string, + data: string | Buffer, + original: unknown, +): Promise<void> { + try { + // On Windows this is the read-only ATTRIBUTE (the only bit chmod maps to there); on posix it restores + // owner/group write. Best-effort: a target that is missing or already writable just falls through. + await fs.chmod(filePath, 0o666).catch(() => undefined); + await fs.rename(tmp, filePath); + return; + } catch { + // fall through to the non-atomic write + } + try { + await writeThrough(filePath, data, original); + } finally { + await fs.rm(tmp, { force: true }).catch(() => undefined); + } +} + +/** + * Writes straight over `filePath`, giving up atomicity — an interrupted write can leave the file torn. + * Reached only once the safe path has genuinely failed: a settings file that cannot be saved AT ALL is a + * worse outcome than one with a small window of risk. `original` is re-thrown when even this does not + * land, so the caller still learns the real reason rather than a symptom of the recovery. + */ +async function writeThrough( + filePath: string, + data: string | Buffer, + original: unknown, +): Promise<void> { + try { + await writeRaw(filePath, data); + } catch { + throw original; } - await withRetry(() => fs.rename(tmp, filePath)); + log.warn( + `[store] "${filePath}" could not be replaced atomically; wrote it in place instead:`, + original, + ); } diff --git a/src/main/library-store.ts b/src/main/library-store.ts index 103fe0de..9fb3f19d 100644 --- a/src/main/library-store.ts +++ b/src/main/library-store.ts @@ -158,7 +158,10 @@ export class LibraryStore { * Best-effort throughout: the card can be yanked mid-copy, so a failed game is logged and skipped, and * the index is written only AFTER that game's files are in place (never a half-copied catalogue). */ - async saveFromCard(manifests: readonly ResolvedManifest[]): Promise<void> { + async saveFromCard( + manifests: readonly ResolvedManifest[], + protectedIds?: readonly string[], + ): Promise<void> { for (const manifest of manifests) { try { await this.saveOne(manifest); @@ -166,7 +169,10 @@ export class LibraryStore { log.warn(`[library] failed to copy assets for id=${manifest.raw.id}:`, describe(cause)); } } - await this.gc(manifests.map((m) => m.raw.id)); + // The eviction must spare every game that is available RIGHT NOW, not just the ones copied here: + // with two sources (the inserted card and the PC library) each call would otherwise leave the other + // source's games unprotected, and a full history could evict the very game on screen. + await this.gc(protectedIds ?? manifests.map((m) => m.raw.id)); } private async saveOne(manifest: ResolvedManifest): Promise<void> { @@ -331,6 +337,32 @@ export class LibraryStore { }); } + /** + * Drops ONE game from the history on the user's request: its record and the artwork copied for it. The + * same deletion the GC performs, minus the choosing — so a game the user is done with can go before the + * limit would have evicted it. + * + * Deliberately NOT touched: `stats/<id>.json` and the save backups. Those belong to the game, not to + * the catalogue — putting the card back in must bring the playtime and the saves back with it, and a + * menu item that quietly destroyed them would be a different (and far more dangerous) feature. + * + * Returns false when there was no such record — the caller has nothing to re-push then. + */ + async forget(id: string): Promise<boolean> { + if (this.entry(id) === null) return false; + this.index = removeEntry(this.index, id); + await this.writeIndex(); + try { + await fse.remove(this.gameDir(id)); + log.info(`[library] forgot id=${id} (removed from the history by the user)`); + } catch (cause) { + // As in the GC: the record is already gone, a leftover directory is cosmetic. The next copy of this + // game overwrites it anyway (saveOne removes the directory before re-filling it). + log.warn(`[library] failed to remove the directory of forgotten id=${id}:`, describe(cause)); + } + return true; + } + /** Trims the history to MAX_LIBRARY_ENTRIES, deleting the evicted games' directories. */ async gc(protectedIds: readonly string[] = []): Promise<void> { const { index, evicted } = evictBeyond(this.index, MAX_LIBRARY_ENTRIES, protectedIds); diff --git a/src/main/locale.ts b/src/main/locale.ts index cacf5f57..5bcbfd74 100644 --- a/src/main/locale.ts +++ b/src/main/locale.ts @@ -31,7 +31,7 @@ export function resolveLocale(mode: LanguageMode, candidates: readonly string[]) return 'en'; } -// zod 4 ships built-in message locales; switch them globally so structural manifest errors (Configure +// zod 4 ships built-in message locales; switch them globally so structural manifest errors (the // window / error popup) come out in the active language. The config is process-global (it also affects the // internal settings.json / stats.json schemas, harmlessly — their errors are not user-facing). function applyZodLocale(locale: Locale): void { diff --git a/src/main/logger.ts b/src/main/logger.ts index 4954a5cd..be7d59f5 100644 --- a/src/main/logger.ts +++ b/src/main/logger.ts @@ -36,7 +36,8 @@ export function setLogBaseDir(baseDir: string): void { /** * Electron's userData location, reproduced without Electron, for the case where a log line somehow beats - * setLogBaseDir. Matches app.getPath('userData') on both platforms so logs never split across two folders. + * setLogBaseDir. Matches app.getPath('userData') on every platform so logs never split across two folders + * (the folder name is the `name` from package.json — there is no productName override). */ function fallbackBaseDir(): string { if (process.platform === 'win32') { @@ -44,6 +45,11 @@ function fallbackBaseDir(): string { const base = appData !== undefined && appData !== '' ? appData : os.homedir(); return path.join(base, 'playhook'); } + if (process.platform === 'darwin') { + // macOS keeps app data in `~/Library/Application Support/<app>`, NOT in the XDG config root — using + // the linux fallback here would scatter the logs into a second, invisible folder. + return path.join(os.homedir(), 'Library', 'Application Support', 'playhook'); + } const xdg = process.env['XDG_CONFIG_HOME']; const base = xdg !== undefined && xdg !== '' ? xdg : path.join(os.homedir(), '.config'); return path.join(base, 'playhook'); diff --git a/src/main/main.ts b/src/main/main.ts index c1c4e239..ec8fee26 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -11,6 +11,7 @@ import { PcStore } from './pc-store'; import { AppSettingsStore } from './app-settings'; import { StatsService } from './stats'; import { LibraryStore } from './library-store'; +import { PcLibraryStore } from './pc-library'; import { DriveWatcher } from './drive-watcher'; import { GameController } from './ipc'; import { GlobalGamepad } from './gamepad-global'; @@ -18,9 +19,17 @@ import { createTray, buildTrayMenu, type TrayCallbacks, type TraySteamState } fr import { createSteamShortcutService } from './steam-shortcut'; import { installDaemonUnit, removeDaemonUnit } from './daemon-unit'; import { UpdaterService } from './updater'; -import { SettingsWindow } from './settings-window'; +import { NotificationsService } from './notifications'; +import { NotificationsStore } from './notifications-store'; import { GameConfigService } from './game-config'; -import { ConfigureWindow } from './configure-window'; +import { HttpClient } from './metadata/http'; +import { MetadataService } from './metadata/service'; +import { SteamProvider } from './metadata/steam'; +import { SteamGridDbProvider } from './metadata/steamgriddb'; +import { KhinsiderProvider } from './metadata/khinsider'; +import { GogProvider } from './metadata/gog'; +import { WallhavenProvider } from './metadata/wallhaven'; +import { WallpaperCaveProvider } from './metadata/wallpapercave'; import { LocaleService } from './locale'; import { createPowerService } from './power'; import { createKeepAwakeService, type KeepAwakeService } from './keep-awake'; @@ -44,19 +53,23 @@ const gameModeSession = isGamescopeSession(); let trayRef: Tray | null = null; let controllerRef: GameController | null = null; let windowRef: GameWindow | null = null; -let settingsWindowRef: SettingsWindow | null = null; -let configureWindowRef: ConfigureWindow | null = null; +// The inbox is built AFTER the settings store (it needs the window presence the store's own callback +// also reaches for), so the store reports a failed write through this rather than through a constructor +// argument that does not exist yet — the same late-binding `windowRef` above solves for the window. +let notificationsRef: NotificationsService | null = null; let globalGamepadRef: GlobalGamepad | null = null; let keepAwakeRef: KeepAwakeService | null = null; let quitting = false; // Whether the global Start+Back summon chord is active (mirrors AppSettings.summonHotkeyEnabled, toggled -// live from the settings window). Read inside the chord callback so a toggle takes effect immediately. +// live from the Settings screen). Read inside the chord callback so a toggle takes effect immediately. let summonHotkeyEnabled = true; function configureAutoLaunch(): void { // openAtLogin is reliable for an NSIS install; portable is best-effort. // No `--hidden` arg needed: the app always starts hidden and only shows on a valid card. - if (process.platform === 'win32') { + // setLoginItemSettings is implemented on Windows AND macOS (it writes a Login Item there), so both take + // the same route; only Linux needs the hand-written XDG entry below. + if (process.platform === 'win32' || process.platform === 'darwin') { app.setLoginItemSettings({ openAtLogin: true }); return; } @@ -96,7 +109,7 @@ function configureLinuxAutoLaunch(): void { } } -// Opens the log folder (settings window "Open logs" — moved here from the tray menu). +// Opens the log folder (the tray's "Open logs"). function openLogs(): void { void shell.openPath(path.dirname(logFilePath())); } @@ -123,27 +136,57 @@ function quit(): void { globalGamepadRef?.stop(); keepAwakeRef?.dispose(); windowRef?.allowClose(); - settingsWindowRef?.allowClose(); - configureWindowRef?.allowClose(); app.quit(); } +/** + * The macOS application menu: the App menu (whose Quit item carries Cmd+Q) and an Edit menu holding the + * clipboard roles. Roles only — every item is the system's own, so it is localized by macOS and needs no + * translator. Deliberately no View/Window/Help: nothing in this launcher answers to them. + */ +function macApplicationMenu(): Menu { + return Menu.buildFromTemplate([ + { role: 'appMenu' }, + { role: 'editMenu' }, + ]); +} + async function bootstrap(): Promise<void> { // FIRST, before any log line: logger.ts is deliberately electron-free (the Game Mode daemon loads it // under ELECTRON_RUN_AS_NODE, where importing electron fails), so it cannot ask app.getPath() itself. setLogBaseDir(app.getPath('userData')); - // No application menu (removes the File/Edit/View… bar entirely). - Menu.setApplicationMenu(null); + // No application menu (removes the File/Edit/View… bar entirely) — except on macOS, where the menu bar + // is also where the standard key equivalents live: with a null menu, Cmd+Q cannot quit the app and + // Cmd+C/V/X/A stop working inside the launcher's own text fields. A minimal App + Edit menu restores + // exactly those and nothing else, so the chrome stays as bare as it is on Windows and Linux. + Menu.setApplicationMenu(process.platform === 'darwin' ? macApplicationMenu() : null); log.info(`[main] starting v${app.getVersion()} — log file: "${logFilePath()}"`); const store = new PcStore(app.getPath('userData')); await store.init(); - const settings = new AppSettingsStore(app.getPath('userData')); + // Every write (a setter, a reset) funnels through the store's one persist() and is pushed straight to + // the launcher, so the Settings screen never has to derive state from a setter's own return value — + // and a setter added later cannot forget to notify. windowRef is used (not `window`, declared below) + // because this runs before the window exists; the guard covers that gap. + const settings = new AppSettingsStore( + app.getPath('userData'), + (next) => { + steamGridDbKey = next.steamGridDbApiKey; + const bw = windowRef?.browserWindow ?? null; + if (bw !== null && !bw.isDestroyed()) bw.webContents.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. + () => notificationsRef?.notifySettingsWriteFailed(), + ); const initialSettings = await settings.read(); summonHotkeyEnabled = initialSettings.summonHotkeyEnabled; + // The SteamGridDB key, kept current by the same onChange every other pushed setting rides on — the + // metadata provider reads it per request, so a key pasted mid-session applies to the very next search. + let steamGridDbKey = initialSettings.steamGridDbApiKey; // Resolve the effective UI locale ONCE at startup from the persisted mode (the system locale is not // watched live — a Windows display-language change requires a sign-out and app restart anyway). @@ -155,12 +198,50 @@ async function bootstrap(): Promise<void> { const window = new GameWindow(getTranslator); const stats = new StatsService(store); + // The notification inbox. Whether an arriving notification may make noise is a question about the + // whole app — is the window on screen, is it in front, is a game running — and all three facts are + // main's own, read live here. Whether the user has TOUCHED anything recently is deliberately NOT one + // of them: someone reading the launcher without pressing buttons is still looking at it. + const notifications = new NotificationsService({ + store: new NotificationsStore(app.getPath('userData')), + presence: () => { + const bw = windowRef?.browserWindow ?? null; + return { + windowVisible: window.isShown(), + windowFocused: bw !== null && !bw.isDestroyed() && bw.isFocused(), + gameRunning: state.get().kind === 'running', + }; + }, + push: (channel, payload) => { + const bw = windowRef?.browserWindow ?? null; + if (bw !== null && !bw.isDestroyed()) bw.webContents.send(channel, payload); + }, + }); + notificationsRef = notifications; // the settings store reports a failed write through it (see above) + await notifications.init(); + + // One summary plate for everything that piled up while a game was running. StateManager.subscribe + // hands the listener only the NEW state, so the previous kind is tracked here — the same shape the + // keep-awake recompute below uses. + let previousStateKind = state.get().kind; + state.subscribe((next) => { + const previous = previousStateKind; + previousStateKind = next.kind; + if (previous === 'running' && next.kind !== 'running') notifications.announceUnreadAfterGame(); + }); + // The launch history behind the carousel: copies of every inserted game's art/audio, so the launcher // has something to show with no card in. init() re-syncs its cached stats and runs the GC; a failure // there must not stop the app from starting (the carousel just falls back to the card's games). const library = new LibraryStore({ baseDir: app.getPath('userData'), readStats: (id) => stats.read(id) }); await library.init().catch((cause: unknown) => log.warn('[library] init failed:', cause)); + // The PC library: local games added from this machine's own disk, kept in `<userData>/pc-games` and + // read as a card that is always inserted (see pc-library.ts). Its skeleton is created up front so the + // the editor can offer "This PC" even before the first local game exists. + const pcLibrary = new PcLibraryStore({ baseDir: app.getPath('userData') }); + await pcLibrary.init().catch((cause: unknown) => log.warn('[pc-library] init failed:', cause)); + // Platform services (process monitor / Steam locator / launcher / save-path resolver / power) selected // once for the running OS. Every OS-specific behaviour flows through this bundle (see platform/index.ts). // The bundled umu-run zipapp (extraResources, linux only): packaged it lives under resourcesPath; in dev @@ -173,6 +254,7 @@ async function bootstrap(): Promise<void> { getDocuments: () => app.getPath('documents'), userData: app.getPath('userData'), umuRunPath, + getTranslator, }); // Game Mode only (Р10), as a safety net: the gamescope session normally mounts an inserted card itself, @@ -191,8 +273,10 @@ async function bootstrap(): Promise<void> { store, stats, library, + pcLibrary, watcher, settings, + notifications, platform, isGamescope: gameModeSession, getTranslator, @@ -221,11 +305,12 @@ async function bootstrap(): Promise<void> { // and setting sources push their own recompute). A second subscriber alongside the controller's replicator. state.subscribe(() => recomputeKeepAwake()); - // Update service + settings window. 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 both - // windows' close-guards synchronously before quitAndInstall. + // 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 + // synchronously before quitAndInstall. const updater = new UpdaterService({ settings, + notifications, isBusy: () => { const kind = state.get().kind; return kind !== 'idle' && kind !== 'ready' && kind !== 'error'; @@ -233,11 +318,7 @@ async function bootstrap(): Promise<void> { beforeInstall: () => { quitting = true; window.allowClose(); - settingsWindow.allowClose(); - configureWindow.allowClose(); }, - openLogs, - openGamesFolder, onSummonHotkeyChanged: (enabled) => { summonHotkeyEnabled = enabled; }, @@ -245,7 +326,7 @@ async function bootstrap(): Promise<void> { preventScreensaverEnabled = enabled; recomputeKeepAwake(); }, - onAlwaysShowEmptyScreenChanged: (enabled) => controller.setAlwaysShowEmptyScreen(enabled), + onKeepOpenWithoutCardChanged: (enabled) => controller.setKeepOpenWithoutCard(enabled), // 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), @@ -255,39 +336,78 @@ async function bootstrap(): Promise<void> { if (bw !== null && !bw.isDestroyed()) bw.webContents.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 window only persisted the new value. + // 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), - // A general "Reset to defaults" writes customWallpaper=null, but the copied file must be deleted - // separately — delegate to the controller (it owns the AssetReader + the game window push). - onWallpaperReset: () => controller.resetCustomWallpaper(), onLanguageChanged: (mode) => applyLanguage(mode), - // Push a theme change to the Configure window so an open one recolors live (the settings window - // applies it locally; the game window doesn't use the Fluent theme). No-op when it was never opened. - onThemeChanged: (mode) => { - const configureBw = configureWindow.browserWindow; - if (configureBw !== null && !configureBw.isDestroyed()) { - configureBw.webContents.send(IPC.configThemeUpdate, mode); - } - }, getTranslator, }); - const settingsWindow = new SettingsWindow(updater, getTranslator); - settingsWindowRef = settingsWindow; - // Configure-game window + its backend. getActiveRoot / reloadManifest come from the controller/watcher - // (interface-DI); the theme comes from the same settings store the settings window uses. + // Backend of the launcher's Customize screen. getActiveRoot / reloadManifest / findGameSource come + // from the controller and the watcher (interface-DI). const gameConfig = new GameConfigService({ - settings, getActiveRoot: () => watcher.getActiveRoot(), reloadManifest: (root) => controller.reloadManifest(root), + 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), + isBusy: () => controller.isBusy(), + pcStore: store, + savePathResolver: platform.savePathResolver, }); gameConfig.init(); - const configureWindow = new ConfigureWindow(gameConfig, getTranslator); - configureWindowRef = configureWindow; + + // Online metadata ("Find online" on the Add/Customize screen). Bootstrapped HERE and nowhere else: the + // whole subtree talks HTTP and must stay off the Game Mode daemon's import graph (see CLAUDE.md). + const metadataHttp = new HttpClient({ + fetch: (url, init) => globalThis.fetch(url, init), + userAgent: `Playhook/${app.getVersion()}`, + }); + // Named rather than inlined: the wallpaper sources ask it for the game's English name, which is the + // only spelling their searches understand (see wallhaven.ts). + const steamProvider = new SteamProvider({ + http: metadataHttp, + locale: () => localeService.current(), + }); + const metadata = new MetadataService({ + http: metadataHttp, + providers: [ + steamProvider, + new SteamGridDbProvider({ + http: metadataHttp, + // Read live from the store rather than captured: the user can paste a key in Settings at any + // point, and the next search must already use it. + apiKey: () => steamGridDbKey, + }), + // Wallpapers — the backgrounds this feature is really after. Keyless, and first in the gallery. + new WallhavenProvider({ + http: metadataHttp, + englishTitle: (ref) => steamProvider.englishTitle(ref), + }), + // The wide, scraped one: it covers the recent releases Wallhaven has nothing for. + new WallpaperCaveProvider({ + http: metadataHttp, + englishTitle: (ref) => steamProvider.englishTitle(ref), + }), + // Backgrounds for games Steam does not sell, from the store that does. + new GogProvider({ http: metadataHttp }), + // Music only, and only ever on an explicit press — see the note at the top of khinsider.ts. + new KhinsiderProvider({ http: metadataHttp }), + ], + cacheDir: path.join(app.getPath('userData'), 'metadata-cache'), + pcLibrary, + isAllowedRoot: (root) => gameConfig.isWritableRoot(root), + getTranslator, + }); + metadata.init(); + // A download interrupted by a crash or a quit has no owner any more, and nothing else reads these + // files — so the scratch directory starts every session empty. + void metadata.clearCache(); window.create( (shown) => { @@ -298,10 +418,22 @@ async function bootstrap(): Promise<void> { // close through and quit on window-all-closed (Р8, point 5). 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 + // once, right after the window exists: it survives the whole session (hiding to the tray does not + // destroy it), and every push re-checks isDestroyed(). + const launcherWindow = window.browserWindow; + if (launcherWindow !== null) updater.attachWindow(launcherWindow); + // The launcher came back to the front — release whatever piled up while it was away (a toast held + // because the window was hidden or behind something, and the summary after a game). Both events are + // needed: showing from the tray does not necessarily focus, and focusing does not re-show. + if (launcherWindow !== null) { + launcherWindow.on('show', () => notifications.onLauncherFronted()); + launcherWindow.on('focus', () => notifications.onLauncherFronted()); + } // Normally start hidden in the tray — the window appears only when a valid game card is detected // (GameController shows it on the 'ready' state). But if "always show the no-card screen" is enabled, // seed the controller with it now so it shows the empty screen at startup (reconciles: idle + no card). - controller.setAlwaysShowEmptyScreen(initialSettings.alwaysShowEmptyScreen); + controller.setKeepOpenWithoutCard(initialSettings.keepOpenWithoutCard); // Steam Deck Game Mode tile: writes Playhook into Steam's shortcuts.vdf as a non-Steam game. Available // only for a packaged AppImage on linux (the appid is derived from the launcher path, which a dev run @@ -355,8 +487,8 @@ async function bootstrap(): Promise<void> { const trayCallbacks: TrayCallbacks = { onShow: () => window.showAndFocus(), - onOpenConfigureGame: () => configureWindow.openOrFocus(), - onOpenSettings: () => settingsWindow.openOrFocus(), + onOpenLogs: () => openLogs(), + onOpenGamesFolder: () => openGamesFolder(), onToggleSteamShortcut: () => { void (steamShortcut.isRegistered() ? steamShortcut.remove() : steamShortcut.add()); }, @@ -379,13 +511,10 @@ async function bootstrap(): Promise<void> { .catch((cause: unknown) => log.warn('[steam-shortcut] reconcile failed:', cause)); } - // UI-locale wiring. Each window 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 hooks - // — the plain windows are created lazily, so there's nothing to hook; the invoke-seed covers startup - // instead. All three requests just return the current effective locale. + // 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 + // — the invoke-seed covers startup instead. ipcMain.handle(IPC.languageRequest, (): Locale => localeService.current()); - ipcMain.handle(IPC.settingsLanguageRequest, (): Locale => localeService.current()); - ipcMain.handle(IPC.configLanguageRequest, (): Locale => localeService.current()); // Power menu (Shutdown/Reboot/Sleep). Wired here, NOT in GameController, so the game controller stays // free of power concerns. The renderer confirms each action before sending; shutdown/reboot quit via @@ -406,26 +535,16 @@ async function bootstrap(): Promise<void> { // close-guards, disposes services). Only ever sent from the Game Mode power menu — Desktop keeps hiding. ipcMain.on(IPC.actionQuit, () => quit()); - // Applies a language change everywhere: re-resolve the locale, rebuild the tray menu, re-title the plain - // windows, and push the effective locale to every live webContents (game/settings/configure). Called - // from the settings set-language handler and from resetSettings (both via UpdaterService deps). + // Applies a language change everywhere: re-resolve the locale, rebuild the tray menu, re-title the + // and push the effective locale to the launcher. + // Called from the settings set-language handler and from resetSettings (both via UpdaterService deps). function applyLanguage(mode: typeof initialSettings.language): void { localeService.setMode(mode); const locale = localeService.current(); refreshTrayMenu(); - settingsWindow.refreshTitle(); - configureWindow.refreshTitle(); const gameBw = window.browserWindow; if (gameBw !== null && !gameBw.isDestroyed()) gameBw.webContents.send(IPC.languageUpdate, locale); - const settingsBw = settingsWindow.browserWindow; - if (settingsBw !== null && !settingsBw.isDestroyed()) { - settingsBw.webContents.send(IPC.settingsLanguageUpdate, locale); - } - const configureBw = configureWindow.browserWindow; - if (configureBw !== null && !configureBw.isDestroyed()) { - configureBw.webContents.send(IPC.configLanguageUpdate, locale); - } } // Global Start+Back hotkey: re-summon the launcher when it's hidden (e.g. minimized to the tray @@ -469,8 +588,6 @@ if (!gotSingleInstanceLock) { globalGamepadRef?.stop(); keepAwakeRef?.dispose(); windowRef?.allowClose(); - settingsWindowRef?.allowClose(); - configureWindowRef?.allowClose(); }); app diff --git a/src/main/manifest.ts b/src/main/manifest.ts index 07edb696..66edc172 100644 --- a/src/main/manifest.ts +++ b/src/main/manifest.ts @@ -1,4 +1,7 @@ -// Reading and validating the `game.json` manifest from the card. +// Reading and validating the `game.json` manifest from the card — and from the PC library, the second +// root that looks like an always-inserted card (see ManifestSource / readManifests `source`). The two +// differ in exactly one way: only a PC manifest may name an ABSOLUTE path, and only through its own `pc` +// block, so the card's "never leave the root" invariant below is untouched. // The card is UNTRUSTED input: beyond the zod schema we validate path SEMANTICS — // executable/heroImage/saveOnCard must live inside the card root (forbidding `..` // and absolute paths), pcSavePath — only from an allowlist of prefixes: @@ -12,6 +15,7 @@ import { MANIFEST_FILENAME, MAX_HERO_IMAGES, type GameManifest, + type ManifestSource, type ManifestValidationIssue, type ConfigValidationResult, type ResolvedManifest, @@ -76,6 +80,11 @@ const installSchema = z path: ['runAsAdmin'], }); +/** How long a stored description may be, per language. Longer is dropped rather than rejected. */ +const MAX_DESCRIPTION_CHARS = 4000; +/** How many genres are kept. Stores state a handful; a longer list is a sign of something else. */ +const MAX_GENRES = 20; + const manifestSchema = z .object({ schemaVersion: z.literal(1), @@ -94,15 +103,25 @@ const manifestSchema = z // Opt-in elevation: for .exe whose embedded manifest requires administrator (spawn would EACCES). runAsAdmin: z.boolean().default(false), // Optional game process image names for launcher/wrapper setups (see GameManifest.watchProcesses). - // Each name is a bare `*.exe` file: no quotes, no path separators — both a hard constraint against + // Each name is a bare FILE NAME: no quotes, no path separators — both a hard constraint against // injection into the `tasklist` argv (execFile is shell-less, but we validate strictly anyway) and a // 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 + // `*.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 + // one name matches on all three); a name without the suffix is for a mac-only record. On win32 the + // matcher is a substring scan over the tasklist CSV and `taskkill /IM` needs the exact image name, so + // a suffix-less name behaves poorly there — deliberately left as is (never change Windows behaviour). watchProcesses: z .array( z .string() - .regex(/^[A-Za-z0-9._ -]+\.exe$/i, 'manifest.watchProcessesName'), + .regex(/^[A-Za-z0-9._ -]+$/, 'manifest.watchProcessesName') + .refine((v) => v.trim() !== '', 'manifest.watchProcessesBlank') + .refine((v) => v !== '.' && v !== '..', 'manifest.watchProcessesDots'), ) .min(1) .max(16) @@ -120,6 +139,27 @@ const manifestSchema = z // wait ends early once they're gone). `.default(60)` so an older/partial file stays valid. killTimeoutSec: z.number().int().positive().default(60), backgroundMusic: z.string().min(1).optional(), + // Localized description (en/ru), written by the "Find online" flow and kept for a future UI that + // shows it. LENIENT on purpose: `.catch(undefined)` drops a malformed or oversized value instead of + // failing the whole manifest — a hand-written card with a wrong `description` must still be a + // playable game, exactly as an unknown key is tolerated today. Nothing reads it yet. + description: z + .object({ + en: z.string().max(MAX_DESCRIPTION_CHARS).optional(), + ru: z.string().max(MAX_DESCRIPTION_CHARS).optional(), + }) + .optional() + .catch(undefined), + // Stored for a library view that does not exist yet (genres to filter by, a date to sort by, the + // platforms a store claims). Lenient for the same reason `description` is: nothing reads them, so a + // hand-written oddity here must never be what stops a game from appearing. + genres: z.array(z.string().min(1)).max(MAX_GENRES).optional().catch(undefined), + releaseDate: z + .string() + .regex(/^\d{4}(-\d{2}(-\d{2})?)?$/) + .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 // 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 @@ -136,11 +176,45 @@ const manifestSchema = z // Steam mode: a pointer to a Steam app by appid (no game files on the card). Mutually exclusive with // install/executable and requires watchProcesses — enforced by the superRefine below. steam: z.object({ appid: z.number().int().positive() }).optional(), + // PC mode: the game already lives on this machine's disk, so `executable` is ABSOLUTE and lives in its + // own block — the only manifest field allowed to leave a root. Accepted solely for the PC library + // (readManifests `source: 'pc'`); a card carrying it is rejected. Mutually exclusive with + // steam/install/executable/saveOnCard (superRefine below). + pc: z.object({ executable: z.string().min(1) }).strict().optional(), }) // Exactly one launch method, with its invariants. Steam mode is a separate backend from install // mode, so we forbid the card installer/executable/elevation there and require watchProcesses // (steam:// returns instantly with no pid of its own — the game can only be tracked by process name). + // PC mode is the fourth launch method and the same exclusivity applies: it brings its own (absolute) + // executable, so a card `executable`, an installer, a Steam pointer or a card-side `saveOnCard` all + // contradict it. Whether the manifest is ALLOWED to be in PC mode at all is a question of where it was + // read from, not of its shape — readManifests decides that (see `source`). .superRefine((v, ctx) => { + if (v.pc !== undefined) { + if (v.steam !== undefined) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['steam'], message: 'manifest.pcWithSteam' }); + } + if (v.install !== undefined) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['install'], message: 'manifest.pcWithInstall' }); + } + if (v.executable !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['executable'], + message: 'manifest.pcWithExecutable', + }); + } + if (v.saveOnCard !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['saveOnCard'], + message: 'manifest.pcWithSaveOnCard', + }); + } + // The launch method is `pc.executable`, so the "non-steam ⇒ executable required" rule below must + // not fire — return instead of falling through to it. + return; + } if (v.steam !== undefined) { if (v.install !== undefined) { ctx.addIssue({ @@ -170,14 +244,10 @@ const manifestSchema = z message: 'manifest.watchProcessesRequired', }); } - } else if (v.executable === undefined) { - // Non-steam game: an executable is mandatory (its meaning depends on install mode — see readManifest). - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['executable'], - message: 'manifest.executableRequired', - }); } + // Whether a non-steam, non-pc game NEEDS `executable` depends on `source` (a card always does; the PC + // library allows a draft with no launch method configured yet), which this schema does not know — see + // resolveOne / pushGameSemanticIssues, which enforce it per source instead. }); // MAX_HERO_IMAGES (the card-format cap on hero backgrounds) lives in shared/types.ts: the Configure form @@ -460,6 +530,25 @@ async function resolveInstall( }; } +/** Options for readManifests. Optional as a whole so every existing card call site stays unchanged. */ +export interface ManifestReadOptions { + /** + * Which root is being read (default `'card'`). It decides both what the manifest MAY contain (only a + * PC-library manifest may carry the `pc` block — and must) and how a failure is graded: a card that + * yields no game is fatal, a PC library that yields none is simply empty. See ManifestSource. + */ + readonly source?: ManifestSource; +} + +/** 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 @@ -475,27 +564,38 @@ export async function readManifests( root: string, env: ManifestEnv, resolveInstallDir: InstallDirResolver, + opts: ManifestReadOptions = {}, ): Promise<ManifestsResult> { const { t } = env; + const source = opts.source ?? 'card'; const manifestPath = path.join(root, MANIFEST_FILENAME); let parsedJson: unknown; try { parsedJson = await fse.readJson(manifestPath); } catch (cause) { + // 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: [] }; return { ok: false, message: t('errors.cannotReadManifest', { file: MANIFEST_FILENAME, cause: describe(cause) }), }; } + // An empty `[]` means "the library has no games" — a valid state you reach by deleting the last local + // game in Configure. On a card the same value is still fatal (a card exists to carry games). + if (source === 'pc' && Array.isArray(parsedJson) && parsedJson.length === 0) { + return { ok: true, manifests: [] }; + } const normalized = normalizeManifestInput(parsedJson, t); if (!normalized.ok) return { ok: false, message: normalized.message }; const manifests: ResolvedManifest[] = []; let firstError: string | null = null; for (const [index, item] of normalized.items.entries()) { - const resolved = await resolveOne(item, root, env, resolveInstallDir); + const resolved = await resolveOne(item, root, env, resolveInstallDir, source); if (!resolved.ok) { if (firstError === null) firstError = resolved.message; log.warn(`[manifest] skipping game #${index}: ${resolved.message}`); @@ -504,8 +604,14 @@ export async function readManifests( manifests.push(resolved.manifest); } if (manifests.length === 0) { - // No game resolved → fatal, like a missing manifest. Keep the first (usually only) reason so a - // single-game card surfaces its precise error ("executable not found: …") exactly as before. + // No game resolved → fatal for a card, like a missing manifest. Keep the first (usually only) reason + // so a single-game card surfaces its precise error ("executable not found: …") exactly as before. + // The PC library is not fatal: it is app state, not removable media — a broken entry must not take + // the launcher's whole local library (and its history) down with it, so it warns and stays empty. + if (source === 'pc') { + if (firstError !== null) log.warn(`[manifest] PC library resolved no games: ${firstError}`); + return { ok: true, manifests: [] }; + } return { ok: false, message: firstError ?? t('manifest.invalid') }; } const seen = new Set<string>(); @@ -528,6 +634,7 @@ async function resolveOne( root: string, env: ManifestEnv, resolveInstallDir: InstallDirResolver, + source: ManifestSource, ): Promise<ManifestResult> { const { t } = env; const parsed = manifestSchema.safeParse(rawParsed); @@ -536,19 +643,67 @@ async function resolveOne( } const raw: GameManifest = parsed.data; - // Critical branch: the meaning of `executable` depends on the mode. Keep the three paths - // explicit so the normal flow is provably untouched. + // The `pc` block and the root it was read from must agree. This is THE check that keeps the card's + // security invariant intact — a card can never name an absolute path, whatever its game.json says. + if (source === 'card' && raw.pc !== undefined) { + return { ok: false, message: t('manifest.pcOnCard') }; + } + // The PC library keeps its OWN save backup (`saves/<id>`, substituted below), so a `saveOnCard` there + // names a folder that would be silently overwritten by that substitution. The schema catches it next to + // a `pc` block; this catches it next to a `steam` one, where the schema has no reason to. + if (source === 'pc' && raw.saveOnCard !== undefined) { + return { ok: false, message: t('manifest.pcWithSaveOnCard') }; + } + // PC-library dialect: `executable`/`install` describe a card-relative game, and the library has no card + // root to resolve one against. Without this, a PC-library entry naming a bare `executable` would fall + // into the "normal game" branch below and resolve it relative to pc-games/ — an unplanned fifth launch + // mode. The schema no longer requires ANY launch method (see the draft branch below), so this check is + // now the only thing keeping that combination out for source 'pc'. + if (source === 'pc' && raw.executable !== undefined) { + return { ok: false, message: t('manifest.executableOnPcLibrary') }; + } + if (source === 'pc' && raw.install !== undefined) { + return { ok: false, message: t('manifest.installOnPcLibrary') }; + } + // The card dialect still requires an explicit launch method — the schema used to enforce this for every + // source; relaxing it for the PC-library draft state (above) means the card's requirement has to be + // stated somewhere. Formally redundant with the raw.executable === undefined guards in the branches + // below (each launch-method branch checks it again for its own mode), kept as one documented statement. + if (source === 'card' && raw.steam === undefined && raw.executable === undefined) { + return { ok: false, message: t('manifest.executableRequired') }; + } + + // Critical branch: the meaning of `executable` depends on the mode. Keep the paths explicit so the + // normal flow is provably untouched. let executablePath: string; let cwd: string; let installResolved: ResolvedManifest['install']; let steamResolved: ResolvedManifest['steam']; - if (raw.steam !== undefined) { + let unconfigured: true | undefined; + if (raw.pc !== undefined) { + // PC mode: the executable is an ABSOLUTE path on this machine, stored in the native form of the OS + // that wrote it (the PC library never travels — see ManifestSource). Its existence is deliberately + // NOT checked: a deleted game keeps its library card and is reported `unavailable` instead, exactly + // as an install-mode game that isn't installed yet. + const normalized = path.normalize(raw.pc.executable); + if (!path.isAbsolute(normalized)) { + return { ok: false, message: t('manifest.pcExecutableAbsolute', { path: raw.pc.executable }) }; + } + executablePath = normalized; + cwd = path.dirname(normalized); + } else if (raw.steam !== undefined) { // Steam mode: there is no card executable to resolve. executablePath/cwd are placeholders ('') // that are NEVER read — every consumer branches on `steam` first (see ResolvedManifest). The // card-relative assets (heroImage/music/saveOnCard) are resolved below as usual. executablePath = ''; cwd = ''; 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). + executablePath = ''; + cwd = ''; + unconfigured = true; } else if (raw.install === undefined) { // Normal game: `executable` is card-relative and MUST exist on the card (unchanged behaviour). // The schema guarantees `executable` is present here (non-steam ⇒ required); guard defensively. @@ -641,11 +796,16 @@ async function resolveOne( // 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. - const problem = validatePcSavePathStatic(raw.pcSavePath, t); + const problem = validatePcSavePathStatic(raw.pcSavePath, t, source); if (problem !== null) { return { ok: false, message: problem }; } pcSavePath = raw.pcSavePath; + // A local game has no card to keep its saves on, so Playhook keeps them itself: the PC library's own + // `saves/<id>` plays the part of `saveOnCardPath`, which is what makes the ENTIRE existing save-sync + // (baseline, LWW, pending flush) work for it unchanged. Created on the first sync, not here — the + // resolver stays side-effect-free. + if (source === 'pc') saveOnCardPath = path.join(root, 'saves', raw.id); } let backgroundMusicPath: string | undefined; @@ -662,7 +822,9 @@ async function resolveOne( // Sync only makes sense if BOTH sides are set: the copy on the card and // the write location on the PC. If only one is set, the card was prepared incorrectly. - if ((pcSavePath === undefined) !== (saveOnCardPath === undefined)) { + // PC mode is exempt: `saveOnCard` is forbidden there and the backup side is supplied above, so a lone + // `pcSavePath` is the normal (and only) spelling. + if (source === 'card' && (pcSavePath === undefined) !== (saveOnCardPath === undefined)) { return { ok: false, message: t('manifest.savePairing'), @@ -672,6 +834,7 @@ async function resolveOne( const manifest: ResolvedManifest = { raw, root, + source, executablePath, cwd, ...(heroImagePaths !== undefined ? { heroImagePaths } : {}), @@ -681,6 +844,7 @@ async function resolveOne( ...(backgroundMusicPath !== undefined ? { backgroundMusicPath } : {}), ...(installResolved !== undefined ? { install: installResolved } : {}), ...(steamResolved !== undefined ? { steam: steamResolved } : {}), + ...(unconfigured === true ? { unconfigured: true as const } : {}), }; return { ok: true, manifest }; } @@ -703,10 +867,25 @@ const PCSAVE_PREFIXES = ['DOCUMENTS', 'LOCALLOW', ...ENV_PREFIXES] as const; * Validates the pcSavePath PREFIX and traversal WITHOUT resolving it against the real system (env-var * availability is a runtime/FS concern → left to readManifest's expandPcSavePath). Returns an error * message or null when statically fine. + * + * For `source: 'pc'` an ABSOLUTE native path is additionally accepted: a local game's saves typically sit + * next to its .exe (`C:\Games\Hades\Saves`) or on another drive, which no `%PREFIX%` can express. The + * `%PREFIX%` form keeps working there too (on Linux it still means "inside the game's Wine prefix"), and + * a card manifest is unaffected — its allowlist is what stops it naming an arbitrary folder. */ -function validatePcSavePathStatic(input: string, t: Translator): string | null { +function validatePcSavePathStatic( + input: string, + t: Translator, + source: ManifestSource = 'card', +): string | null { const match = /^%([A-Za-z]+)%[\\/]?(.*)$/.exec(input); - if (match === null) return t('manifest.pcSavePathPrefix', { prefixes: ALLOWED_PREFIXES_HELP }); + if (match === null) { + if (source === 'pc' && path.isAbsolute(path.normalize(input))) return null; + return t( + source === 'pc' ? 'manifest.pcSavePathPrefixOrAbsolute' : 'manifest.pcSavePathPrefix', + { prefixes: ALLOWED_PREFIXES_HELP }, + ); + } const prefix = (match[1] ?? '').toUpperCase(); if (!(PCSAVE_PREFIXES as readonly string[]).includes(prefix)) { return t('manifest.pcSavePathNotAllowed', { prefix, prefixes: ALLOWED_PREFIXES_HELP }); @@ -746,8 +925,38 @@ function pushGameSemanticIssues( raw: GameManifest, t: Translator, prefix: string, + source: ManifestSource, ): void { const field = (name: string): string => `${prefix}${name}`; + // The `pc` block and the edited root must agree — the editor's half of the check resolveOne makes. + if (source === 'card' && raw.pc !== undefined) { + issues.push({ path: field('pc'), message: t('manifest.pcOnCard') }); + } + // The card dialect still requires an explicit launch method — the editor's half of the check resolveOne + // makes (the schema no longer enforces this on its own, to allow the PC-library draft state). + if (source === 'card' && raw.steam === undefined && raw.executable === undefined) { + issues.push({ path: field('executable'), message: t('manifest.executableRequired') }); + } + if (source === 'pc') { + if (raw.pc !== undefined && !path.isAbsolute(path.normalize(raw.pc.executable))) { + issues.push({ + path: field('pc.executable'), + message: t('manifest.pcExecutableAbsolute', { path: raw.pc.executable }), + }); + } + // Mirrors resolveOne: a PC-library entry has no card root to resolve a card-relative launch method + // against. Draft (neither pc, steam, executable, nor install) is the only other shape allowed. + if (raw.executable !== undefined) { + issues.push({ path: field('executable'), message: t('manifest.executableOnPcLibrary') }); + } + if (raw.install !== undefined) { + issues.push({ path: field('install'), message: t('manifest.installOnPcLibrary') }); + } + // Mirrors resolveOne: the library supplies the backup side itself, so naming one is always an error. + if (raw.saveOnCard !== undefined) { + issues.push({ path: field('saveOnCard'), message: t('manifest.pcWithSaveOnCard') }); + } + } if (raw.executable !== undefined) pushIfEscapes(issues, field('executable'), raw.executable, t, 'executable'); if (raw.install !== undefined) { @@ -783,11 +992,12 @@ function pushGameSemanticIssues( pushIfEscapes(issues, field('backgroundMusic'), raw.backgroundMusic, t, 'backgroundMusic'); } if (raw.pcSavePath !== undefined) { - const message = validatePcSavePathStatic(raw.pcSavePath, t); + const message = validatePcSavePathStatic(raw.pcSavePath, t, source); if (message !== null) issues.push({ path: field('pcSavePath'), message }); } // Sync needs BOTH sides (mirrors readManifest): a lone side means the card was prepared incorrectly. - if ((raw.pcSavePath === undefined) !== (raw.saveOnCard === undefined)) { + // Not in PC mode, where `saveOnCard` is forbidden and the backup side is supplied by the app. + if (source === 'card' && (raw.pcSavePath === undefined) !== (raw.saveOnCard === undefined)) { issues.push({ path: field(raw.pcSavePath === undefined ? 'pcSavePath' : 'saveOnCard'), message: t('manifest.savePairing'), @@ -802,7 +1012,11 @@ 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. */ -export function validateManifestText(text: string, t: Translator): ConfigValidationResult { +export function validateManifestText( + text: string, + t: Translator, + source: ManifestSource = 'card', +): ConfigValidationResult { let parsed: unknown; try { parsed = JSON.parse(text) as unknown; @@ -814,7 +1028,10 @@ export function validateManifestText(text: string, t: Translator): ConfigValidat } if (Array.isArray(parsed)) { + // `[]` is how the PC library says "no local games left" (deleting the last one) — a valid save that + // makes main drop the file. A card still needs at least one game. if (parsed.length === 0) { + if (source === 'pc') return { ok: true }; return { ok: false, issues: [{ path: '(root)', message: t('manifest.emptyArray') }] }; } } else if (typeof parsed !== 'object' || parsed === null) { @@ -841,7 +1058,7 @@ export function validateManifestText(text: string, t: Translator): ConfigValidat return; // can't run semantic checks without parsed data } const raw = result.data; - pushGameSemanticIssues(issues, raw, t, prefix); + pushGameSemanticIssues(issues, raw, t, prefix, source); // Duplicate id across games (array only; a single object is trivially unique). ids key PC storage. if (isArray) { if (idIndex.has(raw.id)) { diff --git a/src/main/metadata/apply-target.ts b/src/main/metadata/apply-target.ts new file mode 100644 index 00000000..d312c95a --- /dev/null +++ b/src/main/metadata/apply-target.ts @@ -0,0 +1,79 @@ +// Where an applied download lands, and whether the request that asked for it is even shaped right. +// +// Kept pure and separate from the service for the usual reason: this is the part a mistake would be +// expensive in — the renderer names both the game id and the slot, and both end up in a file path. The +// checks run BEFORE anything is fetched or written, so a malformed request costs no network and no disk. +import { MAX_HERO_IMAGES, type MetadataApplySlot } from '../../shared/types'; +import { + movedGridAssetPath, + movedHeroAssetPath, + movedMusicAssetPath, +} from '../../shared/asset-move-names'; +import { type MediaKind } from './media-type'; + +/** The manifest's own id syntax (manifest.ts). Re-stated here because this runs before any parse. */ +const ID_PATTERN = /^[A-Za-z0-9._-]+$/; + +/** A request that passed validation, with the slot narrowed to what the writer branches on. */ +export interface ApplyTarget { + readonly gameId: string; + readonly slot: MetadataApplySlot; + /** Which family the download must sniff as for this slot — a cover cannot be an mp3. */ + readonly expectedKind: MediaKind; +} + +export type ApplyValidation = + | { readonly ok: true; readonly target: ApplyTarget } + | { readonly ok: false; readonly reason: 'bad-id' | 'bad-slot' }; + +/** Whether the slot is one of the three the manifest has fields for, with a hero index in range. */ +function checkSlot(slot: unknown): MetadataApplySlot | null { + if (slot === 'grid' || slot === 'music') return slot; + if (typeof slot !== 'object' || slot === null) return null; + const hero = (slot as { readonly hero?: unknown }).hero; + if (typeof hero !== 'number' || !Number.isInteger(hero)) return null; + if (hero < 0 || hero >= MAX_HERO_IMAGES) return null; + return { hero }; +} + +export function validateApply(gameId: unknown, slot: unknown): ApplyValidation { + if (typeof gameId !== 'string' || !ID_PATTERN.test(gameId) || gameId === '.' || gameId === '..') { + return { ok: false, reason: 'bad-id' }; + } + const checked = checkSlot(slot); + if (checked === null) return { ok: false, reason: 'bad-slot' }; + return { + ok: true, + target: { gameId, slot: checked, expectedKind: checked === 'music' ? 'audio' : 'image' }, + }; +} + +/** + * The manifest-relative path this slot's file takes, under the SAME deterministic names a move-to-card + * uses (`assets/<id>-grid.jpg` and friends). Reusing them is deliberate: a game whose art was fetched + * online and one whose art was carried over by a move end up with identically named files, so nothing + * downstream has to tell the two apart. + */ +export function applyRelativePath(target: ApplyTarget, extension: string): string { + // The move-to-card helpers take a SOURCE PATH and read the extension off it (a leading dot alone reads + // as a dotfile with no extension at all), so the extension is handed over as a whole file name. + const source = `asset.${extension}`; + if (target.slot === 'grid') return movedGridAssetPath(target.gameId, source); + if (target.slot === 'music') return movedMusicAssetPath(target.gameId, source); + return movedHeroAssetPath(target.gameId, target.slot.hero, source); +} + +/** + * The same slot's file under every OTHER allowed extension. Applying a `.png` cover over yesterday's + * `.jpg` one would otherwise leave the `.jpg` behind for good: the manifest stops naming it, and a card + * has no orphan collection of its own (unlike the PC library's gcOrphans). + */ +export function stalePathsFor( + target: ApplyTarget, + extension: string, + allowedExtensions: readonly string[], +): readonly string[] { + return allowedExtensions + .filter((candidate) => candidate !== extension) + .map((candidate) => applyRelativePath(target, candidate)); +} diff --git a/src/main/metadata/gog.ts b/src/main/metadata/gog.ts new file mode 100644 index 00000000..fb563fad --- /dev/null +++ b/src/main/metadata/gog.ts @@ -0,0 +1,283 @@ +// GOG — backgrounds for non-Steam games, without a key. +// +// It exists to soften a gap this feature would otherwise have: with backgrounds no longer taken from +// banner-shaped art, a game that Steam does not sell falls back to whatever the wallpaper source found +// for its title. GOG's catalogue covers a large part of a typical non-Steam library and needs no key. +// +// Two conveniences make this the cheapest provider here: the catalogue's search answer ALREADY carries +// each product's screenshots, so backgrounds cost a single request, and the picture URLs are templates +// whose size suffix is honest — `ggvgl_2x` really is 1920x1080, unlike Steam's bounding-box paths. +// +// What is deliberately NOT taken: the product's `images.background` (measured 2560x655 — a store banner, +// the same shape this feature already rejected) and GOG's vertical covers (~1:1.41, where the launcher's +// card is 1:1.5, and covers are already served by Steam and SteamGridDB). +// +// Unofficial, like Steam's storesearch: `embed.gog.com/games/ajax/filtered`, which older launchers used, +// now answers with an empty list, which is exactly why this uses `catalog.gog.com` instead. Every answer +// is zod-validated, and a shape that moved on makes the provider drop out of the results, nothing more. +// +// One thing about that endpoint shapes everything below: `like:` is NOT a title search. It matches +// descriptions and tags too, and there is no title-scoped form to ask for instead (`title:`/`name:` are +// ignored and answer with the whole catalogue). So its answer is filtered here — see titleMatches — and +// the cost is a real one: a one-word name searches badly there (`like:bastion`, `like:hades` come back +// with neither game), so those games get no GOG pictures. Better than a menu of other people's games. +import { z } from 'zod'; +import { + type ArtworkKind, + type GameCandidate, + type GameDetails, + type GamePlatform, + type MetadataResult, +} from '../../shared/types'; +import { + type ArtworkOffer, + type ArtworkOffers, + type ArtworkRequest, + type GameCandidateRef, + type MetadataProvider, +} from './provider'; +import { type HttpClient } from './http'; +import { searchableTitle } from './search-title'; + +const CATALOG_ORIGIN = 'https://catalog.gog.com/v1'; +/** A shortlist for the candidate menu — a menu wants a handful of names, not a catalogue page. */ +const SEARCH_LIMIT = 10; +/** The formatter the gallery's thumbnails use, and the one an applied background is downloaded at. */ +const THUMB_FORMATTER = 'ggvgm'; +const FULL_FORMATTER = 'ggvgl_2x'; + +/** `gog:<id>` — the candidate key. GOG product ids are strings, so this keeps them as they came. */ +export function gogCandidateKey(id: string): string { + return `gog:${id}`; +} + +/** `gog:<id>` back to a product id. Undefined for a key that belongs to another provider. */ +export function gogIdFromKey(key: string): string | undefined { + const match = /^gog:([A-Za-z0-9._-]+)$/.exec(key); + return match?.[1]; +} + +export function searchUrl(term: string): string { + const wanted = searchableTitle(term); + return `${CATALOG_ORIGIN}/catalog?query=${encodeURIComponent(`like:${wanted}`)}&limit=${SEARCH_LIMIT}`; +} + +/** + * Whether a product is actually the game that was asked for. + * + * The catalogue's `like:` is NOT a title search — it matches descriptions and tags as well, and says so + * loudly once you look (measured 2026-08-22): `like:cyberpunk` answers with Cyberpunk 2077 and then + * RoboCop, Deus Ex and Mirror's Edge, which merely carry the tag; `like:hades` answers with "The + * Pedestrian Soundtrack"; `like:Watch Dogs`, a game GOG does not sell at all, answers with seven + * unrelated titles. Left alone, those become candidates in a menu where every line claims to be the + * user's game. + * + * So the answer is filtered here, by the only thing that can be checked: every meaningful word of the + * query must appear in the product's title. Deliberately strict — a name that is missing a word is a + * DIFFERENT game ("Sniper Elite V2" for "Sniper Elite 5"), and this source exists to add backgrounds to + * a game the user already named, not to suggest games. + */ +export function titleMatches(title: string, query: string): boolean { + const words = queryWords(query); + if (words.length === 0) return true; + const normalized = normalizeForMatch(title); + return words.every((word) => normalized.includes(word)); +} + +/** The words a title has to carry. Articles are dropped: stores put them in and leave them out freely. */ +function queryWords(query: string): readonly string[] { + const skip = new Set(['the', 'a', 'an', 'of', 'and']); + return normalizeForMatch(query) + .split(' ') + .filter((word) => word.length > 0 && !skip.has(word)); +} + +/** Case, trademark marks and punctuation out — shallow, like the candidate merge's own normalization. */ +function normalizeForMatch(text: string): string { + return searchableTitle(text) + .toLowerCase() + .replaceAll(/[™®©]/g, '') + .replaceAll(/[^\p{Letter}\p{Number}]+/gu, ' ') + .trim(); +} + +/** + * A screenshot URL with its formatter filled in. The catalogue states these as templates carrying a + * `{formatter}` placeholder; a URL that arrives without one is used as it is rather than dropped. + */ +export function withFormatter(template: string, formatter: string): string { + return template.includes('{formatter}') + ? template.replaceAll('{formatter}', formatter) + : template; +} + +const searchSchema = z.object({ + products: z + .array( + z.object({ + id: z.string().min(1), + title: z.string().min(1), + screenshots: z.array(z.string().min(1)).default([]), + // Stated in the catalogue answer the search already makes — see GameDetails on why they are kept. + genres: z.array(z.object({ name: z.string().min(1) })).optional(), + releaseDate: z.string().optional(), + operatingSystems: z.array(z.string().min(1)).optional(), + }), + ) + .default([]), +}); + +type GogProduct = z.infer<typeof searchSchema>['products'][number]; + +/** One product's screenshots as offers: `ggvgm` for the grid, `ggvgl_2x` (a true 1920x1080) to apply. */ +export function toArtworkOffers(product: GogProduct): readonly ArtworkOffer[] { + return product.screenshots.map((template, index) => ({ + key: `gog:${product.id}:shot-${index}`, + kind: 'hero' as const, + provider: 'gog' as const, + width: 1920, + height: 1080, + thumbUrl: withFormatter(template, THUMB_FORMATTER), + fullUrl: withFormatter(template, FULL_FORMATTER), + })); +} + +/** GOG writes dates as `2017.02.24`; the manifest keeps ISO. Anything else is left out rather than guessed. */ +export function toIsoDate(stated: string | undefined): string | undefined { + if (stated === undefined) return undefined; + const parts = /^(\d{4})[.\-/](\d{2})[.\-/](\d{2})$/.exec(stated.trim()); + if (parts !== null) return `${parts[1]}-${parts[2]}-${parts[3]}`; + const year = /^(\d{4})$/.exec(stated.trim()); + return year === null ? undefined : (year[1] ?? undefined); +} + +/** GOG's `osx` is the same platform the Steam answer calls `mac`; anything unknown is dropped. */ +export function toPlatforms( + stated: readonly string[] | undefined, +): readonly GamePlatform[] | undefined { + if (stated === undefined) return undefined; + const known: Readonly<Record<string, GamePlatform>> = { + windows: 'windows', + osx: 'mac', + mac: 'mac', + linux: 'linux', + }; + const named = stated.flatMap((os) => { + const platform = known[os.toLowerCase()]; + return platform === undefined ? [] : [platform]; + }); + return named.length > 0 ? named : undefined; +} + +/** One catalogue product as GameDetails. GOG states no description in this answer, so none is returned. */ +export function toDetails(product: GogProduct): GameDetails { + const genres = (product.genres ?? []).map((genre) => genre.name); + const releaseDate = toIsoDate(product.releaseDate); + const platforms = toPlatforms(product.operatingSystems); + return { + ...(genres.length > 0 ? { genres } : {}), + ...(releaseDate === undefined ? {} : { releaseDate }), + ...(platforms === undefined ? {} : { platforms }), + }; +} + +export interface GogDeps { + readonly http: HttpClient; +} + +export class GogProvider implements MetadataProvider { + readonly id = 'gog' as const; + /** + * The screenshots a search already returned, by product id. Without this the gallery would repeat the + * search purely to reach pictures the provider has held in memory since the candidate was chosen. + */ + private readonly screenshots = new Map<string, readonly ArtworkOffer[]>(); + /** The genres/date/platforms the same search answer carried, by product id — see GameDetails. */ + private readonly detailsById = new Map<string, GameDetails>(); + + constructor(private readonly deps: GogDeps) {} + + async search( + query: string, + signal?: AbortSignal, + ): Promise<MetadataResult<readonly GameCandidate[]>> { + const answer = await this.deps.http.json( + searchUrl(query), + searchSchema, + signal === undefined ? undefined : { signal }, + ); + if (!answer.ok) return answer; + for (const product of answer.value.products) { + this.screenshots.set(product.id, toArtworkOffers(product)); + this.detailsById.set(product.id, toDetails(product)); + } + // Only the products whose NAME answers the query become candidates — see titleMatches. The rest stay + // in the caches above: a merged candidate can still reach them by id, which is how a game Steam + // named and GOG spells differently keeps its screenshots. + return { + ok: true, + value: answer.value.products + .filter((product) => titleMatches(product.title, query)) + .map((product) => ({ + key: gogCandidateKey(product.id), + title: product.title, + provider: this.id, + gogId: product.id, + })), + }; + } + + /** + * What the catalogue said about the game, out of the search answer already in hand. GOG states no + * description there, so this fills only the other fields — which is exactly what matters for a game + * Steam does not sell, where Steam can state nothing at all. + */ + async details(ref: GameCandidateRef, signal?: AbortSignal): Promise<MetadataResult<GameDetails>> { + const productId = ref.gogId ?? gogIdFromKey(ref.key); + if (productId === undefined) return { ok: true, value: {} }; + const cached = this.detailsById.get(productId); + if (cached !== undefined) return { ok: true, value: cached }; + const answer = await this.deps.http.json( + searchUrl(ref.title), + searchSchema, + signal === undefined ? undefined : { signal }, + ); + if (!answer.ok) return answer; + const product = answer.value.products.find((candidate) => candidate.id === productId); + if (product === undefined) return { ok: true, value: {} }; + const details = toDetails(product); + this.detailsById.set(productId, details); + return { ok: true, value: details }; + } + + /** + * Backgrounds only, and answered from what the search already brought back. A candidate this provider + * never saw (the user picked a Steam-only game) yields nothing — and costs no request to say so. + */ + async artwork( + ref: GameCandidateRef, + kind: ArtworkKind, + request: ArtworkRequest, + signal?: AbortSignal, + ): Promise<MetadataResult<ArtworkOffers>> { + const nothing = { ok: true, value: { offers: [], hasMore: false } } as const; + if (kind !== 'hero' || request.page > 0) return nothing; + const productId = ref.gogId ?? gogIdFromKey(ref.key); + if (productId === undefined) return nothing; + const cached = this.screenshots.get(productId); + if (cached !== undefined) return { ok: true, value: { offers: cached, hasMore: false } }; + // A candidate merged in from another source: the catalogue was searched under a title this product + // did not answer to, so its pictures are fetched now, by the title the candidate carries. + const answer = await this.deps.http.json( + searchUrl(ref.title), + searchSchema, + signal === undefined ? undefined : { signal }, + ); + if (!answer.ok) return answer; + const product = answer.value.products.find((candidate) => candidate.id === productId); + if (product === undefined) return nothing; + const offers = toArtworkOffers(product); + this.screenshots.set(product.id, offers); + return { ok: true, value: { offers, hasMore: false } }; + } +} diff --git a/src/main/metadata/http.ts b/src/main/metadata/http.ts new file mode 100644 index 00000000..59db1e6b --- /dev/null +++ b/src/main/metadata/http.ts @@ -0,0 +1,251 @@ +// The one place the app talks HTTP to the outside world (electron-updater aside). Everything the +// metadata providers fetch — JSON from Steam, HTML from Khinsider, image and audio bytes from a CDN — +// goes through this client, so the limits that make an untrusted download safe are stated ONCE: +// +// * a byte cap, enforced WHILE the body streams (a `Content-Length` from a stranger proves nothing); +// * a header timeout, so a dead host fails fast; +// * an IDLE timeout on the body rather than a total one — a hero image or a soundtrack track over the +// Deck's Wi-Fi legitimately takes a minute, and a total deadline would kill exactly those; +// * an AbortSignal on every call, because the user can leave the screen mid-download (Back). +// +// Failures are Result-unions, never throws: an offline host, a 404 for a game Steam never had art for, +// a rate limit — all of these are NORMAL outcomes of this module, and the caller must handle them +// explicitly (the untrusted-external-data convention in CLAUDE.md). +// +// `fetch` arrives through deps: the unit tests hand it a fake, so no test ever touches the network. +// Known limitation: the global (undici) fetch ignores the system proxy, unlike electron's net.fetch — +// accepted deliberately, the module stays electron-free and importable from a plain-Node test. +import { z } from 'zod'; +import { type MetadataResult } from '../../shared/types'; +import { describe } from '../util'; + +/** How long a host has to answer with STATUS AND HEADERS before the request is abandoned. */ +const HEADER_TIMEOUT_MS = 10_000; +/** How long the body may stall BETWEEN chunks. Resets on every chunk, so a slow-but-alive download lives. */ +const BODY_IDLE_TIMEOUT_MS = 30_000; +/** + * Cap for the text/JSON calls (art and audio pass their own). Four megabytes is generous for an API + * answer and NOT for a scraped page: Khinsider's album page for a gamerip with hundreds of tracks + * measures 8 MB (TUNIC), which used to come back as "larger than 4194304 bytes" and no soundtrack. A + * caller that scrapes HTML passes its own, larger cap — see `maxBytes` on HttpOptions. + */ +const MAX_TEXT_BYTES = 4 * 1024 * 1024; + +/** + * The subset of `fetch` this module uses, declared structurally rather than imported: the main-process + * tsconfig has no DOM lib (its `Response` comes from undici's types) while the shared program does, and + * a hand-written shape is assignable from both — and trivially faked in a test. + */ +export type FetchLike = (url: string, init?: FetchInit) => Promise<FetchResponse>; + +export interface FetchInit { + readonly method?: string; + readonly headers?: Record<string, string>; + readonly signal?: AbortSignal; +} + +export interface FetchResponse { + readonly ok: boolean; + readonly status: number; + readonly headers: { get(name: string): string | null }; + readonly body: FetchBody | null; +} + +export interface FetchBody { + getReader(): FetchBodyReader; +} + +export interface FetchBodyReader { + read(): Promise<{ readonly done: boolean; readonly value?: Uint8Array }>; + cancel(): Promise<void>; +} + +export interface HttpDeps { + readonly fetch: FetchLike; + /** Identifies the app to the sources it queries: `Playhook/<version>`. */ + readonly userAgent: string; +} + +/** Per-call knobs. `headers` carries a provider's auth (SteamGridDB's bearer key), `signal` the user's Back. */ +export interface HttpOptions { + readonly headers?: Record<string, string>; + readonly signal?: AbortSignal; + /** A cap of the caller's own for `text`/`json`, when the default is too small for what it fetches. */ + readonly maxBytes?: number; +} + +/** A downloaded body plus the one header the caller needs to name the file it writes. */ +export interface HttpBytes { + readonly bytes: Uint8Array; + /** Lower-cased, parameters stripped (`image/jpeg`), or undefined when the source did not say. */ + readonly contentType?: string; +} + +export class HttpClient { + constructor(private readonly deps: HttpDeps) {} + + /** Fetches a body and validates it against `schema`; a malformed answer is a failure, not a cast. */ + async json<T>( + url: string, + schema: z.ZodType<T>, + options?: HttpOptions, + ): Promise<MetadataResult<T>> { + const text = await this.text(url, options); + if (!text.ok) return text; + let parsed: unknown; + try { + parsed = JSON.parse(text.value); + } catch (cause) { + return { ok: false, message: `${url}: malformed JSON (${describe(cause)})` }; + } + const validated = schema.safeParse(parsed); + if (!validated.success) { + return { ok: false, message: `${url}: unexpected response shape` }; + } + return { ok: true, value: validated.data }; + } + + /** Fetches a body as UTF-8 text, capped at MAX_TEXT_BYTES (an API answer, or one scraped page). */ + async text(url: string, options?: HttpOptions): Promise<MetadataResult<string>> { + const result = await this.bytes(url, options?.maxBytes ?? MAX_TEXT_BYTES, options); + if (!result.ok) return result; + return { ok: true, value: new TextDecoder().decode(result.value.bytes) }; + } + + /** + * Downloads up to `maxBytes` of a body. The cap is enforced chunk by chunk and the read is aborted the + * moment it is exceeded — nothing oversized is ever held whole in memory, let alone written to disk. + */ + async bytes( + url: string, + maxBytes: number, + options?: HttpOptions, + ): Promise<MetadataResult<HttpBytes>> { + const controller = new AbortController(); + const unlink = linkAbort(options?.signal, controller); + let headerTimer: ReturnType<typeof setTimeout> | undefined = setTimeout( + () => controller.abort(), + HEADER_TIMEOUT_MS, + ); + try { + const response = await this.deps.fetch(url, { + headers: this.headers(options), + signal: controller.signal, + }); + clearTimeout(headerTimer); + headerTimer = undefined; + if (!response.ok) return { ok: false, message: `${url}: HTTP ${response.status}` }; + const body = response.body; + if (body === null) return { ok: false, message: `${url}: empty response body` }; + const collected = await readCapped(body, maxBytes, controller); + if (!collected.ok) return { ok: false, message: `${url}: ${collected.message}` }; + const contentType = normalizeContentType(response.headers.get('content-type')); + return { + ok: true, + value: + contentType === undefined + ? { bytes: collected.value } + : { bytes: collected.value, contentType }, + }; + } catch (cause) { + return { ok: false, message: `${url}: ${describe(cause)}` }; + } finally { + if (headerTimer !== undefined) clearTimeout(headerTimer); + unlink(); + } + } + + /** + * Whether a URL actually has something behind it, without downloading it. Steam's CDN answers 404 for + * art an old game never had, and a gallery must not offer a variant whose apply would then fail. + */ + async exists(url: string, options?: HttpOptions): Promise<boolean> { + const controller = new AbortController(); + const unlink = linkAbort(options?.signal, controller); + const timer = setTimeout(() => controller.abort(), HEADER_TIMEOUT_MS); + try { + const response = await this.deps.fetch(url, { + method: 'HEAD', + headers: this.headers(options), + signal: controller.signal, + }); + await response.body?.getReader().cancel(); + return response.ok; + } catch { + return false; + } finally { + clearTimeout(timer); + unlink(); + } + } + + private headers(options?: HttpOptions): Record<string, string> { + return { 'User-Agent': this.deps.userAgent, ...(options?.headers ?? {}) }; + } +} + +/** Mirrors an external abort onto the request's own controller; returns the detach function. */ +function linkAbort(external: AbortSignal | undefined, controller: AbortController): () => void { + if (external === undefined) return () => undefined; + if (external.aborted) { + controller.abort(); + return () => undefined; + } + const onAbort = (): void => controller.abort(); + external.addEventListener('abort', onAbort); + return () => external.removeEventListener('abort', onAbort); +} + +/** + * Streams a body into one buffer, refusing to grow past `maxBytes` and giving up when the source stops + * feeding for BODY_IDLE_TIMEOUT_MS. The idle timer is re-armed per chunk — that is the whole difference + * between "this download is slow" (fine) and "this download is dead" (not). + */ +async function readCapped( + body: FetchBody, + maxBytes: number, + controller: AbortController, +): Promise<MetadataResult<Uint8Array>> { + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + let idleTimer = setTimeout(() => controller.abort(), BODY_IDLE_TIMEOUT_MS); + try { + for (;;) { + const chunk = await reader.read(); + clearTimeout(idleTimer); + if (chunk.done === true) break; + const value = chunk.value; + if (value === undefined) continue; + total += value.byteLength; + if (total > maxBytes) { + controller.abort(); + return { ok: false, message: `larger than ${maxBytes} bytes` }; + } + chunks.push(value); + idleTimer = setTimeout(() => controller.abort(), BODY_IDLE_TIMEOUT_MS); + } + } catch (cause) { + return { ok: false, message: describe(cause) }; + } finally { + clearTimeout(idleTimer); + } + return { ok: true, value: concat(chunks, total) }; +} + +function concat(chunks: readonly Uint8Array[], total: number): Uint8Array { + const out = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.byteLength; + } + return out; +} + +/** `image/jpeg; charset=binary` → `image/jpeg`. Undefined when the header is absent or blank. */ +function normalizeContentType(raw: string | null): string | undefined { + if (raw === null) return undefined; + const value = raw.split(';')[0]?.trim().toLowerCase() ?? ''; + return value.length > 0 ? value : undefined; +} diff --git a/src/main/metadata/khinsider.ts b/src/main/metadata/khinsider.ts new file mode 100644 index 00000000..0b933598 --- /dev/null +++ b/src/main/metadata/khinsider.ts @@ -0,0 +1,211 @@ +// Khinsider — the soundtrack source, and the only per-game one that exists in practice. +// +// It has no API. This is a scraper, and it is written to be one honestly: every answer is parsed out of +// HTML with tolerant patterns, anything unrecognized becomes an ordinary "nothing found", and the whole +// provider is isolated behind the same interface the others implement. Deleting this file would cost the +// Music entry of the menu and nothing else — which is deliberate, because a scraper is one redesign away +// from breaking, and the copyright status of the material is a grey area we do not want to spread. +// +// For the same reason nothing here is ever fetched on its own: a search happens because the user pressed +// Music, a track downloads because the user pressed that track. +// +// The site's flow forces one hop more than the others: the album page lists tracks but NOT their audio +// URLs — those live on each track's own page, which is why `musicTrackUrl` exists at all. +import { type MetadataResult, type MusicAlbum } from '../../shared/types'; +import { type MetadataProvider, type MusicTrackOffer } from './provider'; +import { type HttpClient } from './http'; +import { searchableTitle } from './search-title'; + +const ORIGIN = 'https://downloads.khinsider.com'; +/** + * How large one scraped page may be. Far above the client's default, because these pages are not API + * answers: an album page lists EVERY track with its row, and a gamerip of a few hundred tracks comes to + * megabytes (TUNIC measures 8 MB). The default 4 MB turned exactly those albums — the big ones people + * actually look for a track in — into "larger than 4194304 bytes". + */ +const MAX_PAGE_BYTES = 24 * 1024 * 1024; +/** How many albums a search offers. The site returns everything it matched; a menu wants a shortlist. */ +const MAX_ALBUMS = 20; +/** + * How many tracks of one album are offered. A gamerip lists everything the game ships with — TUNIC's is + * 4244 rows — and every row becomes a button in the list. Past a few hundred that is a wall to scroll + * and a bill to build, and the tracks anyone picks a background theme from are near the top. + */ +const MAX_TRACKS = 300; + +/** + * The search URL for a term. The title is cleaned first (see search-title.ts): this site matches on + * words, so "Watch_Dogs™" comes back with twenty albums of other games that merely contain "Watch". + */ +export function searchUrl(term: string): string { + return `${ORIGIN}/search?search=${encodeURIComponent(searchableTitle(term))}`; +} + +export function albumUrl(albumKey: string): string { + return `${ORIGIN}/game-soundtracks/album/${albumKey}`; +} + +/** `khinsider:<album>:<file>` — the key a track is addressed by, and what its page URL is rebuilt from. */ +export function trackKey(albumKey: string, file: string): string { + return `khinsider:${albumKey}:${file}`; +} + +/** The five entities that actually occur in these titles. A full HTML decoder would be overkill here. */ +function decodeEntities(text: string): string { + return text + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll(''', "'") + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll(' ', ' '); +} + +function stripTags(html: string): string { + return decodeEntities(html.replaceAll(/<[^>]*>/g, '')) + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * The albums a search page lists. Matched by the album URL rather than by the surrounding table markup: + * the link shape is the part of this site that has stayed put, the table around it is not. + */ +export function parseAlbums(html: string): readonly MusicAlbum[] { + const albums: MusicAlbum[] = []; + const seen = new Set<string>(); + const pattern = /<a\s+href="\/game-soundtracks\/album\/([^"/?#]+)"[^>]*>([\s\S]*?)<\/a>/g; + for (const match of html.matchAll(pattern)) { + const key = match[1]; + const title = stripTags(match[2] ?? ''); + if (key === undefined || title.length === 0 || seen.has(key)) continue; + seen.add(key); + albums.push({ key, title }); + if (albums.length >= MAX_ALBUMS) break; + } + return albums; +} + +/** `4.44 MB` / `763 KB` as bytes. Undefined when the row states no size at all. */ +export function parseSize(text: string): number | undefined { + const match = /([\d.]+)\s*(KB|MB|GB)/i.exec(text); + if (match === null) return undefined; + const amount = Number(match[1]); + if (!Number.isFinite(amount)) return undefined; + const unit = (match[2] ?? '').toUpperCase(); + const factor = unit === 'GB' ? 1024 ** 3 : unit === 'MB' ? 1024 ** 2 : 1024; + return Math.round(amount * factor); +} + +/** + * One album page's tracks, read ROW BY ROW. + * + * The row is the unit because a track is spread across four cells — name, length, mp3 size, flac size — + * and every one of them is a link to the SAME file. A pattern that matched "a link, then a bit of text, + * then the end of the row" walked straight over the first three `</a>` to reach a `</tr>` close enough, + * and produced titles like "Gen Prop Int Filigreechestopen Singer Waterfall 2:20 4.05 MB 7.58 MB" + * (measured on TUNIC's gamerip). Splitting on the row first makes each cell's boundary matter. + * + * The size is the first figure the row states after the name, which is the mp3's — the file this app + * actually downloads. + */ +export function parseTracks(html: string, albumKey: string): readonly MusicTrackOffer[] { + const tracks: MusicTrackOffer[] = []; + const seen = new Set<string>(); + const escaped = albumKey.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const link = new RegExp( + `<a\\s+href="/game-soundtracks/album/${escaped}/([^"?#]+)"[^>]*>((?:(?!</a>)[\\s\\S])*)</a>`, + ); + for (const row of html.split(/<tr\b/i).slice(1)) { + const match = link.exec(row); + const file = match?.[1]; + if (file === undefined || seen.has(file)) continue; + const label = stripTags(match?.[2] ?? ''); + const title = label.length > 0 ? label : decodeURIComponent(file); + seen.add(file); + const sizeBytes = parseSize(stripTags(row.slice(match?.index ?? 0))); + tracks.push({ + key: trackKey(albumKey, file), + title, + ...(sizeBytes === undefined ? {} : { sizeBytes }), + pageUrl: `${ORIGIN}/game-soundtracks/album/${albumKey}/${file}`, + }); + if (tracks.length >= MAX_TRACKS) break; + } + return tracks; +} + +/** + * The direct audio URL on a track page. Both shapes the page has used are accepted — the download link + * and the inline player — and only the audio extensions this app can actually play are taken. + */ +export function parseAudioUrl(html: string): string | undefined { + const pattern = /(?:href|src)="(https?:\/\/[^"]+\.(?:mp3|flac|ogg|m4a))"/gi; + for (const match of html.matchAll(pattern)) { + const url = match[1]; + if (url !== undefined) return decodeEntities(url); + } + return undefined; +} + +/** `khinsider:<album>:<file>` back to its parts. Undefined for a key from another provider. */ +export function parseTrackKey( + key: string, +): { readonly album: string; readonly file: string } | undefined { + const match = /^khinsider:([^:]+):(.+)$/.exec(key); + const album = match?.[1]; + const file = match?.[2]; + return album === undefined || file === undefined ? undefined : { album, file }; +} + +export interface KhinsiderDeps { + readonly http: HttpClient; +} + +export class KhinsiderProvider implements MetadataProvider { + readonly id = 'khinsider' as const; + + constructor(private readonly deps: KhinsiderDeps) {} + + /** The request options every page here is fetched with — the user's Back, and the bigger cap. */ + private pageOptions(signal?: AbortSignal): { + readonly maxBytes: number; + readonly signal?: AbortSignal; + } { + return { maxBytes: MAX_PAGE_BYTES, ...(signal === undefined ? {} : { signal }) }; + } + + async musicSearch( + query: string, + signal?: AbortSignal, + ): Promise<MetadataResult<readonly MusicAlbum[]>> { + const page = await this.deps.http.text(searchUrl(query), this.pageOptions(signal)); + if (!page.ok) return page; + return { ok: true, value: parseAlbums(page.value) }; + } + + async musicTracks( + albumKey: string, + signal?: AbortSignal, + ): Promise<MetadataResult<readonly MusicTrackOffer[]>> { + const page = await this.deps.http.text(albumUrl(albumKey), this.pageOptions(signal)); + if (!page.ok) return page; + return { ok: true, value: parseTracks(page.value, albumKey) }; + } + + /** The extra hop: the album page names the track, the track's own page names the file. */ + async musicTrackUrl( + track: MusicTrackOffer, + signal?: AbortSignal, + ): Promise<MetadataResult<string>> { + if (parseTrackKey(track.key) === undefined) + return { ok: false, message: 'not a khinsider track' }; + const page = await this.deps.http.text(track.pageUrl, this.pageOptions(signal)); + if (!page.ok) return page; + const url = parseAudioUrl(page.value); + return url === undefined + ? { ok: false, message: `${track.pageUrl}: no audio link on the track page` } + : { ok: true, value: url }; + } +} diff --git a/src/main/metadata/media-type.ts b/src/main/metadata/media-type.ts new file mode 100644 index 00000000..2c564c3c --- /dev/null +++ b/src/main/metadata/media-type.ts @@ -0,0 +1,88 @@ +// What a downloaded file actually IS, decided from its own bytes. +// +// Everything else about a download is a claim by a stranger: the URL's extension, the Content-Type +// header, the name the source gave it. None of them is checked by anyone, and all of them end up naming +// a file inside the user's card or library — so the last word belongs to the magic bytes. A body that +// sniffs as nothing recognizable is refused rather than written under a guessed extension. +// +// Pure and dependency-free, so the table is unit-tested rather than exercised through a download. + +/** Which family a sniffed file belongs to — the same two the manifest's asset fields distinguish. */ +export type MediaKind = 'image' | 'audio'; + +export interface SniffedMedia { + readonly kind: MediaKind; + /** The canonical extension WITHOUT its dot, matching IMAGE_EXTENSIONS / AUDIO_EXTENSIONS. */ + readonly extension: string; +} + +/** One signature: bytes that must match at `offset`, and what they mean. */ +interface Signature { + readonly offset: number; + readonly bytes: readonly number[]; + readonly kind: MediaKind; + readonly extension: string; + /** A second marker further in — RIFF and ISO-BMFF containers hold more than one format. */ + readonly also?: { readonly offset: number; readonly bytes: readonly number[] }; +} + +const ASCII = (text: string): readonly number[] => [...text].map((char) => char.charCodeAt(0)); + +const SIGNATURES: readonly Signature[] = [ + { offset: 0, bytes: [0xff, 0xd8, 0xff], kind: 'image', extension: 'jpg' }, + { + offset: 0, + bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], + kind: 'image', + extension: 'png', + }, + { offset: 0, bytes: ASCII('GIF8'), kind: 'image', extension: 'gif' }, + { + offset: 0, + bytes: ASCII('RIFF'), + kind: 'image', + extension: 'webp', + also: { offset: 8, bytes: ASCII('WEBP') }, + }, + { + offset: 0, + bytes: ASCII('RIFF'), + kind: 'audio', + extension: 'wav', + also: { offset: 8, bytes: ASCII('WAVE') }, + }, + { offset: 0, bytes: ASCII('OggS'), kind: 'audio', extension: 'ogg' }, + { offset: 0, bytes: ASCII('fLaC'), kind: 'audio', extension: 'flac' }, + { offset: 0, bytes: ASCII('ID3'), kind: 'audio', extension: 'mp3' }, + { offset: 4, bytes: ASCII('ftyp'), kind: 'audio', extension: 'm4a' }, +]; + +function matches(bytes: Uint8Array, signature: Signature): boolean { + const at = (offset: number, expected: readonly number[]): boolean => + expected.every((byte, index) => bytes[offset + index] === byte); + if (!at(signature.offset, signature.bytes)) return false; + return signature.also === undefined || at(signature.also.offset, signature.also.bytes); +} + +/** + * An MPEG audio frame — an mp3 with no ID3 tag at all, which is common for a ripped soundtrack track. + * The sync word is eleven set bits, and the two bits after it must name a real MPEG version and layer + * (`01` is reserved in both), which is what keeps this from matching arbitrary binary noise. + */ +function isMpegFrame(bytes: Uint8Array): boolean { + const first = bytes[0]; + const second = bytes[1]; + if (first !== 0xff || second === undefined) return false; + if ((second & 0xe0) !== 0xe0) return false; + const version = (second & 0x18) >> 3; + const layer = (second & 0x06) >> 1; + return version !== 1 && layer !== 0; +} + +/** What these bytes are, or null when nothing recognizable — in which case nothing gets written. */ +export function sniffMedia(bytes: Uint8Array): SniffedMedia | null { + for (const signature of SIGNATURES) { + if (matches(bytes, signature)) return { kind: signature.kind, extension: signature.extension }; + } + return isMpegFrame(bytes) ? { kind: 'audio', extension: 'mp3' } : null; +} diff --git a/src/main/metadata/provider.ts b/src/main/metadata/provider.ts new file mode 100644 index 00000000..aa43ae84 --- /dev/null +++ b/src/main/metadata/provider.ts @@ -0,0 +1,120 @@ +// What a metadata source must look like from the service's side, and the internal shapes the sources +// speak in. +// +// A provider answers with OFFERS, not with the renderer-facing types from shared/types.ts: an offer +// still carries the http(s) URLs behind a picture or a track. Turning those into a data: URL (the only +// image source the renderer's CSP allows) is MetadataService's job, and keeping the URLs on this side +// of that seam is what lets the renderer address everything by an opaque key — it never learns a URL, +// and can therefore never ask main to download one of its own choosing. +// +// Every method is optional: Steam searches, offers art and knows descriptions; SteamGridDB searches and +// offers art; Khinsider only knows music. The service asks whoever answers. +import { type SizeFloor } from '../../shared/artwork-filter'; +import { + type ArtworkKind, + type GameCandidate, + type GameDetails, + type MetadataProviderId, + type MetadataResult, + type MusicAlbum, + type MusicTrack, +} from '../../shared/types'; + +/** + * What the service knows about a candidate when it asks a provider for more. Every reference the merge + * collected travels together: the gallery of one game is built from whichever sources recognized it, and + * a provider simply ignores a request that carries no reference of its own. + */ +export interface GameCandidateRef { + readonly key: string; + readonly title: string; + readonly steamAppId?: number; + readonly gogId?: string; +} + +/** One offered picture, with the URLs the renderer must never see. */ +export interface ArtworkOffer { + readonly key: string; + readonly kind: ArtworkKind; + readonly provider: MetadataProviderId; + readonly width?: number; + readonly height?: number; + /** Small picture shown in the gallery grid. */ + readonly thumbUrl: string; + /** What gets downloaded when the user picks this variant (and what the lightbox shows). */ + readonly fullUrl: string; +} + +/** + * What a gallery page is asking a source for: which page, and the size floor the user set in the + * sidebar. The floor is passed on rather than only applied afterwards — a source that can search by size + * (Wallhaven does) fills a page of 4K wallpapers where filtering its answer would leave three tiles. + */ +export interface ArtworkRequest { + readonly page: number; + readonly minSize: SizeFloor; +} + +/** + * One page of a source's pictures, and whether that source can serve another behind it. + * + * Stated by the SOURCE rather than guessed from the count: a full page means "more" for Wallhaven (whose + * answer names its last page) and means nothing for Steam (whose screenshots are simply all there is). + * The gallery's "load more" tile is only shown when something is actually there to load. + */ +export interface ArtworkOffers { + readonly offers: readonly ArtworkOffer[]; + readonly hasMore: boolean; +} + +/** One offered track. The audio URL is NOT here: Khinsider only reveals it on the track's own page. */ +export interface MusicTrackOffer { + readonly key: string; + readonly title: string; + readonly sizeBytes?: number; + /** The track page the audio URL is scraped from, when the source needs one more hop. */ + readonly pageUrl: string; +} + +export interface MetadataProvider { + readonly id: MetadataProviderId; + /** + * Whether this source can be used at all right now. Only SteamGridDB implements it: a missing key is + * not a failure to report, it is a source that quietly does not take part. + */ + available?(): boolean; + search?(query: string, signal?: AbortSignal): Promise<MetadataResult<readonly GameCandidate[]>>; + /** The candidate for an appid the caller already knows — the manifest's own `steam.appid`. */ + candidateByAppId?(appId: number, signal?: AbortSignal): Promise<MetadataResult<GameCandidate>>; + /** + * One page of what this source has for the game, 0-based. A source that serves everything it knows at + * once (Steam's screenshots, GOG's, a SteamGridDB list) answers page 0 with the lot and every later + * page with nothing — the service keeps what did not fit on screen and hands it out itself. + */ + artwork?( + ref: GameCandidateRef, + kind: ArtworkKind, + request: ArtworkRequest, + signal?: AbortSignal, + ): Promise<MetadataResult<ArtworkOffers>>; + musicSearch?(query: string, signal?: AbortSignal): Promise<MetadataResult<readonly MusicAlbum[]>>; + musicTracks?( + albumKey: string, + signal?: AbortSignal, + ): Promise<MetadataResult<readonly MusicTrackOffer[]>>; + /** The direct audio URL of one track — the extra hop Khinsider's markup forces. */ + musicTrackUrl?(track: MusicTrackOffer, signal?: AbortSignal): Promise<MetadataResult<string>>; + /** + * Everything about the game that is not a picture. Named for what it began as — the descriptions — and + * widened since: the same answers carry the genres, the date and the platforms, and dropping them + * would mean asking again later for a game the user has already moved past. + */ + details?(ref: GameCandidateRef, signal?: AbortSignal): Promise<MetadataResult<GameDetails>>; +} + +/** `MusicTrack` as the renderer sees it — the page URL dropped. */ +export function toMusicTrack(offer: MusicTrackOffer): MusicTrack { + return offer.sizeBytes === undefined + ? { key: offer.key, title: offer.title } + : { key: offer.key, title: offer.title, sizeBytes: offer.sizeBytes }; +} diff --git a/src/main/metadata/search-title.ts b/src/main/metadata/search-title.ts new file mode 100644 index 00000000..5ed3bf35 --- /dev/null +++ b/src/main/metadata/search-title.ts @@ -0,0 +1,29 @@ +// A game's title as a SEARCH TERM, for the sources that match on words. +// +// Steam sells "Watch_Dogs™", and that spelling is what every later request is built from — which is +// where three sources go blind at once (measured 2026-08-22, appid 243470): +// +// query Wallhaven Wallpaper Cave Khinsider +// Watch_Dogs™ 0 0 20 albums, none of them this game ("Day Watch"…) +// Watch_Dogs 0 59 the right ones +// Watch Dogs 24 157 the right ones +// +// So the trademark marks and the underscore are removed here rather than in each provider. Steam's own +// store search and GOG's catalogue are NOT run through this: both find the game by its trademarked name +// perfectly well, and the candidate the user picks should keep the title the store actually uses. +// +// Deliberately shallow, and deliberately NOT `normalizeTitle` from service.ts: that one exists to decide +// whether two sources mean the same game and must never conflate two different ones, so it strips every +// punctuation mark. This one still has to produce something a search box can be given. + +/** The marks publishers sprinkle into a name. None of them is ever part of what a site tagged a picture. */ +const TRADEMARKS = /[™®©℠]/gu; + +/** + * A title as a search term: trademark marks dropped, underscores read as the spaces they stand for, and + * the whitespace collapsed. Everything else — subtitles, colons, edition tails — is left to the callers, + * which trim their own way (see `searchTerms` in wallhaven.ts). + */ +export function searchableTitle(title: string): string { + return title.replace(TRADEMARKS, ' ').replaceAll('_', ' ').replace(/\s+/g, ' ').trim(); +} diff --git a/src/main/metadata/service.ts b/src/main/metadata/service.ts new file mode 100644 index 00000000..09df7746 --- /dev/null +++ b/src/main/metadata/service.ts @@ -0,0 +1,949 @@ +// The service behind the "Find online" flow: it owns the providers, the per-session caches, and the one +// place where something fetched from the internet becomes a file inside the user's card or library. +// +// Three responsibilities are worth naming, because they are what the renderer cannot be trusted with: +// +// • URLs never leave main. The renderer addresses a candidate, a picture or a track by an opaque key +// this service handed it; the key resolves against a bounded in-memory map here. So "download this" +// can only ever mean one of the URLs a provider offered during this session. +// • Bytes reach the renderer as data: URLs only — its CSP allows no other image or media source, the +// same rule the hero and the carousel art already live under. +// • A downloaded file is written under a name derived from ITS OWN BYTES (see media-type.ts) and a +// path derived from the manifest's deterministic asset names, never from anything the source said. +// +// Registered like GameConfigService: one init() that installs every metadata:* handler. +import path from 'node:path'; +import fse from 'fs-extra'; +import { ipcMain } from 'electron'; +import { QUALITY_FLOOR, includesSource, meetsQuality } from '../../shared/artwork-filter'; +import { + IPC, + type ArtworkFilter, + type ArtworkKind, + type ArtworkPage, + type ArtworkVariant, + type GameCandidate, + type LocalizedText, + type MetadataApplyRequest, + type MetadataApplyResult, + type GameDetails, + type GamePlatform, + type MetadataProviderId, + type MetadataResult, + type MusicAlbum, + type MusicTrack, +} from '../../shared/types'; +import { type Translator } from '../../shared/i18n/index'; +import { AUDIO_EXTENSIONS, IMAGE_EXTENSIONS } from '../asset-reader'; +import { resolveInside } from '../manifest'; +import { type PcLibraryStore } from '../pc-library'; +import { log } from '../logger'; +import { describe } from '../util'; +import { applyRelativePath, stalePathsFor, validateApply, type ApplyTarget } from './apply-target'; +import { sniffMedia, type MediaKind } from './media-type'; +import { type HttpClient } from './http'; +import { + type ArtworkOffer, + type ArtworkOffers, + type GameCandidateRef, + type MetadataProvider, + type MusicTrackOffer, + toMusicTrack, +} from './provider'; + +/** The same caps the manual import enforces (pc-library.ts) — a download is not a reason to relax them. */ +const MAX_BYTES: Readonly<Record<MediaKind, number>> = { + image: 32 * 1024 * 1024, + audio: 64 * 1024 * 1024, +}; +/** A gallery thumbnail. Generous for a 600x900 cover, small enough that a stray full-size file trips it. */ +const MAX_THUMB_BYTES = 8 * 1024 * 1024; +/** How many thumbnails are fetched at once — the concurrency the carousel's art cache settled on. */ +const THUMB_CONCURRENCY = 3; +/** How many keys each map remembers. Bounded for the reason card-art's LRU is: a session is unbounded. */ +const CACHE_LIMIT = 300; +/** + * How many pictures ONE source puts on ONE page of the gallery. SteamGridDB answers with everything the + * community uploaded, and a wallpaper site with more than anyone will look at — every one of which would + * be downloaded as a thumbnail and held in the renderer as a data: URL until the gallery closes. + * + * What no longer fits is KEPT (see the gallery pool below) instead of being dropped: the user asks for + * the next page and gets it, from memory when the source has already answered and with a fresh request + * when it has not. So this is a page size now, not a ceiling on what a game may be offered. + */ +const MAX_ARTWORK_PER_PROVIDER = 24; + +/** + * The order sources appear in the gallery, best-suited first. Fixed rather than "whoever answered + * first": the answers arrive in parallel, and a gallery that reshuffles itself between two visits to the + * same game is a gallery the user cannot navigate from memory. + * + * Both wallpaper sources lead for backgrounds because wallpapers are what a full-screen background + * actually wants — a picture composed to be looked at, rather than a gameplay frame with a HUD in it. + * Wallhaven goes first of the two as the clean and filterable one; Wallpaper Cave follows because its + * strength is coverage rather than quality. Sorting is by SOURCE, so Steam's own curated backdrop stays + * first within Steam's block rather than ahead of everything: interleaving individual offers would mean + * a second ranking to keep in step with this one. + */ +const PROVIDER_ORDER: readonly MetadataProviderId[] = [ + 'wallhaven', + 'wallpapercave', + 'steam', + 'steamgriddb', + 'gog', + 'khinsider', +]; +/** Where a full-size download lands before it is checked and moved into place. */ +const DOWNLOADS_DIRNAME = 'downloads'; + +/** + * A Map that forgets its oldest entry once it is full — the same shape (and the same reason) as the + * renderer's card-art cache. Re-reading a key refreshes it, so the keys a screen is actually using stay. + */ +class BoundedMap<T> { + private readonly entries = new Map<string, T>(); + + constructor(private readonly limit: number) {} + + get(key: string): T | undefined { + const value = this.entries.get(key); + if (value === undefined) return undefined; + this.entries.delete(key); + this.entries.set(key, value); + return value; + } + + set(key: string, value: T): void { + this.entries.delete(key); + this.entries.set(key, value); + if (this.entries.size <= this.limit) return; + const oldest = this.entries.keys().next(); + if (oldest.done !== true) this.entries.delete(oldest.value); + } +} + +/** + * One gallery in progress: which candidate it belongs to, what has already been shown, what was fetched + * but did not fit, and which page each source will be asked for next. + * + * Only one is kept — a gallery is a screen the user is looking at, and opening another one replaces it. + * The pool is what makes "load more" cheap: a source that answered with sixty pictures is not asked + * again until its sixty are on screen. + */ +interface GalleryPool { + readonly candidateKey: string; + readonly kind: ArtworkKind; + /** The filter this pool was built under — changing it in the sidebar starts a new gallery. */ + readonly filter: ArtworkFilter; + /** Fetched, not yet shown. Kept in source order so a later page keeps the gallery's grouping. */ + pending: readonly ArtworkOffer[]; + /** The page to ask a source for next. A source missing from here has nothing left to give. */ + readonly nextPage: Map<MetadataProviderId, number>; + /** Every key already handed to the renderer — two albums really do share a picture. */ + readonly shown: Set<string>; +} + +export interface MetadataDeps { + readonly http: HttpClient; + /** Every source, in the order their answers are merged. A provider answers only what it knows. */ + readonly providers: readonly MetadataProvider[]; + /** `<userData>/metadata-cache` — scratch space for downloads, cleared at startup. */ + readonly cacheDir: string; + readonly pcLibrary: PcLibraryStore; + /** The same "may this app write there?" check every gameConfig:* write runs (GameConfigService). */ + readonly isAllowedRoot: (root: string) => Promise<boolean>; + /** The current translator — the messages here are shown to the user as they are. */ + readonly getTranslator: () => Translator; +} + +export class MetadataService { + private readonly candidates = new BoundedMap<GameCandidate>(CACHE_LIMIT); + private readonly artwork = new BoundedMap<ArtworkOffer>(CACHE_LIMIT); + private readonly tracks = new BoundedMap<MusicTrackOffer>(CACHE_LIMIT); + private readonly thumbs = new BoundedMap<string>(CACHE_LIMIT); + /** + * Everything currently in flight. `metadata:cancel` aborts the lot: the user pressed Back, and every + * request that is still running belongs to the surface they just left. + */ + private readonly inFlight = new Set<AbortController>(); + /** The gallery the renderer is paging through, or null before the first page of one is asked for. */ + private gallery: GalleryPool | null = null; + + constructor(private readonly deps: MetadataDeps) {} + + /** Registers every metadata:* handler once (the service is a singleton, like GameConfigService). */ + init(): void { + ipcMain.handle( + IPC.metadataSearch, + (_event, query: unknown): Promise<MetadataResult<readonly GameCandidate[]>> => + this.search(typeof query === 'string' ? query : ''), + ); + ipcMain.handle( + IPC.metadataSteamCandidate, + (_event, appId: unknown): Promise<MetadataResult<GameCandidate>> => + this.steamCandidate(typeof appId === 'number' ? appId : 0), + ); + ipcMain.handle( + IPC.metadataArtwork, + ( + _event, + payload: { + readonly candidateKey: string; + readonly kind: ArtworkKind; + readonly page?: number; + readonly filter?: ArtworkFilter; + }, + ): Promise<MetadataResult<ArtworkPage>> => + this.artworkFor( + payload.candidateKey, + payload.kind, + typeof payload.page === 'number' && payload.page > 0 ? Math.floor(payload.page) : 0, + toFilter(payload.filter), + ), + ); + ipcMain.handle( + IPC.metadataMusicAlbums, + (_event, query: unknown): Promise<MetadataResult<readonly MusicAlbum[]>> => + this.musicAlbums(typeof query === 'string' ? query : ''), + ); + ipcMain.handle( + IPC.metadataMusicTracks, + (_event, albumKey: unknown): Promise<MetadataResult<readonly MusicTrack[]>> => + this.musicTracks(typeof albumKey === 'string' ? albumKey : ''), + ); + ipcMain.handle( + IPC.metadataTrackPreview, + (_event, trackKey: unknown): Promise<MetadataResult<string>> => + this.trackPreview(typeof trackKey === 'string' ? trackKey : ''), + ); + ipcMain.handle( + IPC.metadataDescriptions, + (_event, candidateKey: unknown): Promise<MetadataResult<GameDetails>> => + this.details(typeof candidateKey === 'string' ? candidateKey : ''), + ); + ipcMain.handle( + IPC.metadataApply, + (_event, request: MetadataApplyRequest): Promise<MetadataApplyResult> => this.apply(request), + ); + ipcMain.on(IPC.metadataCancel, () => this.cancelAll()); + } + + /** + * Empties the download scratch directory. Called at startup: a download interrupted by a crash (or by + * the user quitting mid-fetch) has no owner afterwards, and nothing else ever reads these files. + */ + async clearCache(): Promise<void> { + try { + await fse.remove(path.join(this.deps.cacheDir, DOWNLOADS_DIRNAME)); + } catch (cause) { + log.warn('[metadata] could not clear the download cache:', describe(cause)); + } + } + + /** Aborts everything in flight — the renderer's Back. */ + cancelAll(): void { + for (const controller of this.inFlight) controller.abort(); + this.inFlight.clear(); + } + + /** + * Every source's answer to one query, merged. A game that both Steam and SteamGridDB know appears + * ONCE: the Steam entry wins, because it is the one that carries an appid — and the appid is what the + * CDN art and the descriptions are addressed by. + */ + private async search(query: string): Promise<MetadataResult<readonly GameCandidate[]>> { + const term = query.trim(); + if (term.length === 0) return { ok: true, value: [] }; + const answers = await this.fromProviders((provider, signal) => provider.search?.(term, signal)); + if (answers.length === 0) return { ok: false, message: this.t('metadata.noSources') }; + const failures = answers.filter((answer) => !answer.ok); + const found = answers.flatMap((answer) => (answer.ok ? [...answer.value] : [])); + if (found.length === 0 && failures.length > 0) return failures[0] ?? { ok: true, value: [] }; + const merged = withoutSpareStore(mergeCandidates(found)); + for (const candidate of merged) this.candidates.set(candidate.key, candidate); + return { ok: true, value: merged }; + } + + /** + * The candidate for an appid the manifest already names. Cached like a searched one, so every later + * request (artwork, descriptions) is addressed exactly as it would be after a search. + */ + private async steamCandidate(appId: number): Promise<MetadataResult<GameCandidate>> { + const answers = await this.fromProviders((provider, signal) => + provider.candidateByAppId?.(appId, signal), + ); + const found = answers.find((answer) => answer.ok); + if (found === undefined || !found.ok) { + const failure = answers.find((answer) => !answer.ok); + return failure !== undefined && !failure.ok + ? failure + : { ok: false, message: this.t('metadata.noSources') }; + } + const enriched = await this.withOtherSources(found.value); + this.candidates.set(enriched.key, enriched); + return { ok: true, value: enriched }; + } + + /** + * Fills in the references the OTHER sources have for a game that arrived from one of them alone. + * + * Skipping the search is the whole point of the appid shortcut, but the search is also where the merge + * happens — so without this a Steam game reached that way would be offered Steam's backgrounds and + * nothing else, however many the other sources hold. The extra searches are best-effort: they run on + * one explicit press, and a source that fails simply contributes no reference. + */ + private async withOtherSources(candidate: GameCandidate): Promise<GameCandidate> { + const answers = await this.fromProviders((provider, signal) => + provider.id === candidate.provider ? undefined : provider.search?.(candidate.title, signal), + ); + return withMergedRefs( + candidate, + answers.flatMap((answer) => (answer.ok ? [...answer.value] : [])), + ); + } + + /** + * One page of the gallery for a candidate: every source's offers, with their thumbnails already + * downloaded and encoded. A thumbnail that cannot be fetched drops its variant rather than showing an + * empty tile — whatever is wrong with it would be wrong with the full-size download too. + * + * Page 0 starts a fresh gallery. Every later page is served from what the sources have already said + * where possible, and only the sources whose pool has run dry are asked for another page of their own. + */ + private async artworkFor( + candidateKey: string, + kind: ArtworkKind, + page: number, + filter: ArtworkFilter, + ): Promise<MetadataResult<ArtworkPage>> { + const ref = this.candidates.get(candidateKey); + if (ref === undefined) return { ok: false, message: this.t('metadata.staleSelection') }; + const pool = this.poolFor(candidateKey, kind, page, filter); + const asked = await this.askArtwork(pool, toCandidateRef(ref), kind); + const failure = asked.find((answer) => !answer.result.ok)?.result; + const fetched: ArtworkOffer[] = []; + for (const { id, result } of asked) { + if (!result.ok) { + // A source that failed is done for this gallery: pressing "load more" must not keep retrying a + // host that is refusing, and the other sources still have their pages. + pool.nextPage.delete(id); + continue; + } + if (result.value.hasMore) pool.nextPage.set(id, (pool.nextPage.get(id) ?? 0) + 1); + else pool.nextPage.delete(id); + fetched.push(...result.value.offers.filter((offer) => meetsQuality(offer, filter.quality))); + } + const available = orderByProvider(dedupe([...pool.pending, ...fetched], pool.shown)); + const shown = capArtworkPerProvider(available, MAX_ARTWORK_PER_PROVIDER); + const shownKeys = new Set(shown.map((offer) => offer.key)); + pool.pending = available.filter((offer) => !shownKeys.has(offer.key)); + for (const offer of shown) { + pool.shown.add(offer.key); + this.artwork.set(offer.key, offer); + } + const hasMore = pool.pending.length > 0 || pool.nextPage.size > 0; + if (shown.length === 0) { + if (!hasMore && failure !== undefined && !failure.ok) return failure; + return { ok: true, value: { variants: [], hasMore } }; + } + if (pool.pending.length > 0) { + log.info(`[metadata] ${pool.pending.length} more ${kind} variants held for the next page`); + } + return { ok: true, value: { variants: await this.withThumbnails(shown), hasMore } }; + } + + /** + * The pool this request belongs to. A page 0 — or a request for a gallery other than the one in hand — + * starts over; anything else continues where the previous page stopped. + */ + private poolFor( + candidateKey: string, + kind: ArtworkKind, + page: number, + filter: ArtworkFilter, + ): GalleryPool { + const current = this.gallery; + if ( + page > 0 && + current !== null && + current.candidateKey === candidateKey && + current.kind === kind && + sameFilter(current.filter, filter) + ) { + return current; + } + const fresh: GalleryPool = { + candidateKey, + kind, + filter, + pending: [], + nextPage: new Map( + this.deps.providers + .filter( + (provider) => + provider.artwork !== undefined && includesSource(filter.sources, provider.id), + ) + .map((provider) => [provider.id, 0]), + ), + shown: new Set<string>(), + }; + this.gallery = fresh; + return fresh; + } + + /** + * Asks the sources that still have something to give — and only them. A source whose pool already + * holds a page's worth is left alone: its pictures are on their way to the screen without a request. + */ + private async askArtwork( + pool: GalleryPool, + ref: GameCandidateRef, + kind: ArtworkKind, + ): Promise< + readonly { readonly id: MetadataProviderId; readonly result: MetadataResult<ArtworkOffers> }[] + > { + const held = new Map<MetadataProviderId, number>(); + for (const offer of pool.pending) held.set(offer.provider, (held.get(offer.provider) ?? 0) + 1); + return this.run(async (signal) => { + const asked = this.deps.providers.flatMap((provider) => { + const next = pool.nextPage.get(provider.id); + if (next === undefined) return []; + if ((held.get(provider.id) ?? 0) >= MAX_ARTWORK_PER_PROVIDER) return []; + const answer = provider.artwork?.( + ref, + kind, + { page: next, minSize: QUALITY_FLOOR[pool.filter.quality] }, + signal, + ); + return answer === undefined ? [] : [answer.then((result) => ({ id: provider.id, result }))]; + }); + return Promise.all(asked); + }); + } + + private async musicAlbums(query: string): Promise<MetadataResult<readonly MusicAlbum[]>> { + const term = query.trim(); + if (term.length === 0) return { ok: true, value: [] }; + const answers = await this.fromProviders((provider, signal) => + provider.musicSearch?.(term, signal), + ); + if (answers.length === 0) return { ok: false, message: this.t('metadata.noSources') }; + const failure = answers.find((answer) => !answer.ok); + const albums = answers.flatMap((answer) => (answer.ok ? [...answer.value] : [])); + if (albums.length === 0 && failure !== undefined && !failure.ok) return failure; + return { ok: true, value: albums }; + } + + private async musicTracks(albumKey: string): Promise<MetadataResult<readonly MusicTrack[]>> { + if (albumKey.length === 0) return { ok: true, value: [] }; + const answers = await this.fromProviders((provider, signal) => + provider.musicTracks?.(albumKey, signal), + ); + if (answers.length === 0) return { ok: false, message: this.t('metadata.noSources') }; + const failure = answers.find((answer) => !answer.ok); + const offers = answers.flatMap((answer) => (answer.ok ? [...answer.value] : [])); + if (offers.length === 0 && failure !== undefined && !failure.ok) return failure; + for (const offer of offers) this.tracks.set(offer.key, offer); + return { ok: true, value: offers.map(toMusicTrack) }; + } + + /** + * One track as a playable data: URL. This is a FULL download (there is no preview stream to be had), + * which is why the renderer shows a status line and can cancel it. + */ + private async trackPreview(trackKey: string): Promise<MetadataResult<string>> { + const resolved = await this.resolveTrackUrl(trackKey); + if (!resolved.ok) return resolved; + const bytes = await this.run((signal) => + this.deps.http.bytes(resolved.value, MAX_BYTES.audio, { signal }), + ); + if (!bytes.ok) { + log.warn(`[metadata] track preview failed: ${bytes.message}`); + return { ok: false, message: this.t('metadata.downloadFailed') }; + } + const sniffed = sniffMedia(bytes.value.bytes); + if (sniffed === null || sniffed.kind !== 'audio') { + return { ok: false, message: this.t('metadata.downloadFailed') }; + } + return { ok: true, value: toDataUrl(sniffed, bytes.value.bytes) }; + } + + /** + * Everything known about the game that is not a picture, merged across the sources that answered. + * Earlier answers win: the providers are asked in PROVIDER_ORDER, so Steam's genres stand and GOG only + * fills what Steam had nothing to say about — which for a game Steam does not sell is everything. + */ + private async details(candidateKey: string): Promise<MetadataResult<GameDetails>> { + const ref = this.candidates.get(candidateKey); + if (ref === undefined) return { ok: false, message: this.t('metadata.staleSelection') }; + const answers = await this.fromProviders((provider, signal) => + provider.details?.(toCandidateRef(ref), signal), + ); + const known = answers.flatMap((answer) => (answer.ok ? [answer.value] : [])); + if (known.length === 0) { + const failure = answers.find((answer) => !answer.ok); + return failure !== undefined && !failure.ok + ? failure + : { ok: false, message: this.t('metadata.noDescriptions') }; + } + return { ok: true, value: mergeDetails(known) }; + } + + /** + * Downloads the chosen variant and puts it into the game's root, answering with the MANIFEST-relative + * path the renderer writes into the form field. Nothing here trusts the request: the root is + * re-checked, the id and slot are validated before a byte is fetched, and the file's own bytes decide + * both its extension and whether it is written at all. + */ + private async apply(request: MetadataApplyRequest): Promise<MetadataApplyResult> { + const validation = validateApply(request.gameId, request.slot); + if (!validation.ok) return { ok: false, message: this.t('metadata.badRequest') }; + if (!(await this.deps.isAllowedRoot(request.root))) { + return { ok: false, message: this.t('errors.driveUnavailable') }; + } + const target = validation.target; + const source = await this.sourceUrlFor(request.variantKey, target.expectedKind); + if (!source.ok) return source; + const bytes = await this.run((signal) => + this.deps.http.bytes(source.value, MAX_BYTES[target.expectedKind], { signal }), + ); + if (!bytes.ok) { + log.warn(`[metadata] apply download failed: ${bytes.message}`); + return { ok: false, message: this.t('metadata.downloadFailed') }; + } + const sniffed = sniffMedia(bytes.value.bytes); + if (sniffed === null || sniffed.kind !== target.expectedKind) { + return { ok: false, message: this.t('metadata.unsupportedFile') }; + } + const allowed = target.expectedKind === 'image' ? IMAGE_EXTENSIONS : AUDIO_EXTENSIONS; + if (!allowed.includes(sniffed.extension)) { + return { ok: false, message: this.t('metadata.unsupportedFile') }; + } + try { + const relative = + request.root === this.deps.pcLibrary.root + ? await this.writeIntoLibrary(target, sniffed.extension, bytes.value.bytes) + : await this.writeIntoCard( + request.root, + target, + sniffed.extension, + bytes.value.bytes, + allowed, + ); + return relative === null + ? { ok: false, message: this.t('metadata.writeFailed') } + : { ok: true, path: relative }; + } catch (cause) { + log.warn('[metadata] applying a downloaded asset failed:', describe(cause)); + return { ok: false, message: this.t('metadata.writeFailed') }; + } + } + + /** + * The PC library's own import path, reused as-is: the bytes go through a scratch file so importAsset + * performs exactly the checks a manually picked file gets, and the library's existing GC keeps the + * folder tidy when a game stops referencing an older copy. + */ + private async writeIntoLibrary( + target: ApplyTarget, + extension: string, + bytes: Uint8Array, + ): Promise<string | null> { + const scratch = await this.writeScratch(target, extension, bytes); + try { + return await this.deps.pcLibrary.importAsset(scratch, target.expectedKind, [extension]); + } finally { + await fse.remove(scratch).catch((cause: unknown) => { + log.warn('[metadata] could not remove a scratch download:', describe(cause)); + }); + } + } + + /** + * A card has no importer of its own — the manual picker only ever checks that a path is ALREADY inside + * the card. So the copy is written here, under the same deterministic names a move-to-card uses, with + * the same refusals importAsset applies (no symlink at the destination) plus the cleanup a card cannot + * do for itself: the same slot's file under another extension is removed, or it would linger forever. + */ + private async writeIntoCard( + root: string, + target: ApplyTarget, + extension: string, + bytes: Uint8Array, + allowedExtensions: readonly string[], + ): Promise<string | null> { + const relative = applyRelativePath(target, extension); + const absolute = resolveInside(root, relative); + if (absolute === null) return null; + const stats = await fse.lstat(absolute).catch(() => null); + if (stats !== null && (stats.isSymbolicLink() || !stats.isFile())) { + log.warn(`[metadata] refusing to overwrite "${absolute}": not a regular file`); + return null; + } + await fse.ensureDir(path.dirname(absolute)); + await fse.writeFile(absolute, bytes); + for (const stale of stalePathsFor(target, extension, allowedExtensions)) { + const staleAbsolute = resolveInside(root, stale); + if (staleAbsolute === null) continue; + const staleStats = await fse.lstat(staleAbsolute).catch(() => null); + if (staleStats === null || !staleStats.isFile()) continue; + await fse.remove(staleAbsolute).catch((cause: unknown) => { + log.warn(`[metadata] could not remove the superseded "${stale}":`, describe(cause)); + }); + } + return relative; + } + + /** Writes the download to `<cacheDir>/downloads/<name>` so the library importer has a file to check. */ + private async writeScratch( + target: ApplyTarget, + extension: string, + bytes: Uint8Array, + ): Promise<string> { + const dir = path.join(this.deps.cacheDir, DOWNLOADS_DIRNAME); + await fse.ensureDir(dir); + const name = path.basename(applyRelativePath(target, extension)); + const scratch = path.join(dir, name); + await fse.writeFile(scratch, bytes); + return scratch; + } + + /** The URL behind a key — a picture's full size, or a track's audio (one more hop for the latter). */ + private async sourceUrlFor(variantKey: string, kind: MediaKind): Promise<MetadataResult<string>> { + if (kind === 'image') { + const offer = this.artwork.get(variantKey); + return offer === undefined + ? { ok: false, message: this.t('metadata.staleSelection') } + : { ok: true, value: offer.fullUrl }; + } + return this.resolveTrackUrl(variantKey); + } + + /** A track's audio URL: from the provider that owns it, since the list of tracks does not carry one. */ + private async resolveTrackUrl(trackKey: string): Promise<MetadataResult<string>> { + const offer = this.tracks.get(trackKey); + if (offer === undefined) return { ok: false, message: this.t('metadata.staleSelection') }; + const answers = await this.fromProviders((provider, signal) => + provider.musicTrackUrl?.(offer, signal), + ); + const url = answers.find((answer) => answer.ok); + if (url === undefined || !url.ok) { + const failure = answers.find((answer) => !answer.ok); + return failure !== undefined && !failure.ok + ? failure + : { ok: false, message: this.t('metadata.downloadFailed') }; + } + return url; + } + + /** Fetches each offer's thumbnail (three at a time) and drops the ones that do not arrive. */ + private async withThumbnails( + offers: readonly ArtworkOffer[], + ): Promise<readonly ArtworkVariant[]> { + const variants: (ArtworkVariant | null)[] = new Array<ArtworkVariant | null>( + offers.length, + ).fill(null); + let next = 0; + const worker = async (): Promise<void> => { + for (;;) { + const index = next; + next += 1; + const offer = offers[index]; + if (offer === undefined) return; + const thumb = await this.thumbnail(offer); + if (thumb !== null) variants[index] = toVariant(offer, thumb); + } + }; + await Promise.all( + Array.from({ length: Math.min(THUMB_CONCURRENCY, offers.length) }, () => worker()), + ); + return variants.filter((variant): variant is ArtworkVariant => variant !== null); + } + + private async thumbnail(offer: ArtworkOffer): Promise<string | null> { + const cached = this.thumbs.get(offer.key); + if (cached !== undefined) return cached; + const bytes = await this.run((signal) => + this.deps.http.bytes(offer.thumbUrl, MAX_THUMB_BYTES, { signal }), + ); + if (!bytes.ok) { + log.warn(`[metadata] thumbnail failed: ${bytes.message}`); + return null; + } + const sniffed = sniffMedia(bytes.value.bytes); + if (sniffed === null || sniffed.kind !== 'image') return null; + const dataUrl = toDataUrl(sniffed, bytes.value.bytes); + this.thumbs.set(offer.key, dataUrl); + return dataUrl; + } + + /** + * Asks every provider the same question in parallel and keeps the answers of those that HAVE one. + * A provider without the method (Khinsider knows no artwork) is simply absent from the result, which + * is how "nothing can answer this at all" stays distinguishable from "everything answered nothing". + */ + private async fromProviders<T>( + ask: ( + provider: MetadataProvider, + signal: AbortSignal, + ) => Promise<MetadataResult<T>> | undefined, + ): Promise<readonly MetadataResult<T>[]> { + 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; + }); + } + + /** Runs one piece of work under a fresh AbortController that `metadata:cancel` can reach. */ + private async run<T>(work: (signal: AbortSignal) => Promise<T>): Promise<T> { + const controller = new AbortController(); + this.inFlight.add(controller); + try { + return await work(controller.signal); + } finally { + this.inFlight.delete(controller); + } + } + + private t(key: Parameters<Translator>[0]): string { + return this.deps.getTranslator()(key); + } +} + +/** MIME by sniffed extension — narrow on purpose, so only what media-type.ts recognizes gets encoded. */ +const DATA_URL_MIME: Readonly<Record<string, string>> = { + jpg: 'image/jpeg', + png: 'image/png', + webp: 'image/webp', + gif: 'image/gif', + mp3: 'audio/mpeg', + ogg: 'audio/ogg', + wav: 'audio/wav', + flac: 'audio/flac', + m4a: 'audio/mp4', +}; + +function toDataUrl(media: { readonly extension: string }, bytes: Uint8Array): string { + const mime = DATA_URL_MIME[media.extension] ?? 'application/octet-stream'; + return `data:${mime};base64,${Buffer.from(bytes).toString('base64')}`; +} + +function toVariant(offer: ArtworkOffer, thumbDataUrl: string): ArtworkVariant { + return { + key: offer.key, + kind: offer.kind, + provider: offer.provider, + ...(offer.width === undefined ? {} : { width: offer.width }), + ...(offer.height === undefined ? {} : { height: offer.height }), + thumbDataUrl, + }; +} + +/** A filter as the renderer sent it, with anything unrecognized read as "no filter at all". */ +export function toFilter(stated: ArtworkFilter | undefined): ArtworkFilter { + const quality = stated?.quality; + return { + sources: Array.isArray(stated?.sources) ? stated.sources : [], + quality: quality !== undefined && quality in QUALITY_FLOOR ? quality : 'any', + }; +} + +/** Whether two filters ask for the same gallery — a difference means starting one over. */ +export function sameFilter(a: ArtworkFilter, b: ArtworkFilter): boolean { + return ( + a.quality === b.quality && + a.sources.length === b.sources.length && + a.sources.every((id, at) => b.sources[at] === id) + ); +} + +/** + * GOG's own lines, dropped whenever Steam recognized the game at all. + * + * Steam's search matches TITLES; GOG's catalogue matches descriptions and tags as well, and there is no + * title-scoped query to ask it for instead (see gog.ts). Its answer is already filtered by name, but + * that filter cannot tell "the same game, spelled differently" from "a game whose name happens to + * contain these words" — so when Steam has answered, the entries it did NOT merge with are noise in a + * menu where every line claims to be the user's game. + * + * What is NOT dropped is the reference: a GOG entry that merged into a Steam candidate lives on inside + * it as `gogId`, so the gallery still shows GOG's screenshots for a game both stores sell. This removes + * lines from a menu, never a source from a gallery. + */ +export function withoutSpareStore(candidates: readonly GameCandidate[]): readonly GameCandidate[] { + if (!candidates.some((candidate) => candidate.provider === 'steam')) return candidates; + return candidates.filter((candidate) => candidate.provider !== 'gog'); +} + +/** Offers this gallery has not shown yet, with repeats inside the batch collapsed by key. */ +export function dedupe( + offers: readonly ArtworkOffer[], + shown: ReadonlySet<string>, +): readonly ArtworkOffer[] { + const seen = new Set<string>(); + return offers.filter((offer) => { + if (shown.has(offer.key) || seen.has(offer.key)) return false; + seen.add(offer.key); + return true; + }); +} + +/** + * The first `limit` offers of EACH source, in the order they arrived. Per source rather than in total so + * one talkative provider cannot crowd the other out of the gallery: Steam contributes one or two entries, + * and a global cap would let SteamGridDB's list push them past it. + */ +export function capArtworkPerProvider( + offers: readonly ArtworkOffer[], + limit: number, +): readonly ArtworkOffer[] { + const counts = new Map<string, number>(); + return offers.filter((offer) => { + const seen = counts.get(offer.provider) ?? 0; + if (seen >= limit) return false; + counts.set(offer.provider, seen + 1); + return true; + }); +} + +/** + * The order the gallery lists sources in — see PROVIDER_ORDER. Offers from a source the list does not + * name (there is none today) sort last rather than disappearing. + */ +export function orderByProvider(offers: readonly ArtworkOffer[]): readonly ArtworkOffer[] { + const rank = (id: MetadataProviderId): number => { + const at = PROVIDER_ORDER.indexOf(id); + return at === -1 ? PROVIDER_ORDER.length : at; + }; + return [...offers].sort((a, b) => rank(a.provider) - rank(b.provider)); +} + +/** + * Several sources' facts about one game, folded into a single set. First answer wins per FIELD rather + * than per source: a game can be on Steam (which knows its genres) and on GOG (which may state a + * platform Steam does not), and taking one source wholesale would throw away the other's half. + */ +export function mergeDetails(known: readonly GameDetails[]): GameDetails { + const merged: { + description?: LocalizedText; + genres?: readonly string[]; + releaseDate?: string; + platforms?: readonly GamePlatform[]; + } = {}; + for (const entry of known) { + if (merged.description === undefined && entry.description !== undefined) { + merged.description = entry.description; + } + if (merged.genres === undefined && entry.genres !== undefined && entry.genres.length > 0) { + merged.genres = entry.genres; + } + if (merged.releaseDate === undefined && entry.releaseDate !== undefined) { + merged.releaseDate = entry.releaseDate; + } + if ( + merged.platforms === undefined && + entry.platforms !== undefined && + entry.platforms.length > 0 + ) { + merged.platforms = entry.platforms; + } + } + return merged; +} + +/** Everything a provider may need to recognize a merged candidate as one of its own. */ +export function toCandidateRef(candidate: GameCandidate): GameCandidateRef { + return { + key: candidate.key, + title: candidate.title, + ...(candidate.steamAppId === undefined ? {} : { steamAppId: candidate.steamAppId }), + ...(candidate.gogId === undefined ? {} : { gogId: candidate.gogId }), + }; +} + +/** + * A title reduced to what two sources can be expected to agree on: case, the trademark marks publishers + * sprinkle differently, and punctuation. Deliberately shallow — no subtitle stripping, no edition + * guessing, nothing that could make two DIFFERENT games look identical. + */ +export function normalizeTitle(title: string): string { + return title + .toLowerCase() + .replaceAll(/[™®©]/g, '') + .replaceAll(/[^\p{Letter}\p{Number}]+/gu, ' ') + .trim(); +} + +/** + * One entry per game, across every source. + * + * Two things happen here. Within a source, repeats collapse — the same Steam appid, the same key. + * Across sources, entries whose normalized titles match become ONE candidate carrying every reference + * seen (`steamAppId` + `gogId`), which is what lets a game's gallery combine Steam's screenshots with + * GOG's. A candidate that matches nothing simply stays on its own: a + * duplicate line in the menu costs the user one glance, whereas a wrong merge shows them another game's + * pictures under the name of theirs. + * + * The surviving title and key are the first source's in PROVIDER_ORDER — Steam's when it answered, + * because that entry is the one that can also reach the descriptions and the CDN cover. + */ +export function mergeCandidates(candidates: readonly GameCandidate[]): readonly GameCandidate[] { + const merged: GameCandidate[] = []; + const byTitle = new Map<string, number>(); + const seenKeys = new Set<string>(); + const seenAppIds = new Set<number>(); + const ranked = [...candidates].sort((a, b) => providerRank(a) - providerRank(b)); + for (const candidate of ranked) { + if (seenKeys.has(candidate.key)) continue; + seenKeys.add(candidate.key); + if (candidate.steamAppId !== undefined) { + if (seenAppIds.has(candidate.steamAppId)) continue; + seenAppIds.add(candidate.steamAppId); + } + const title = normalizeTitle(candidate.title); + const at = title === '' ? undefined : byTitle.get(title); + const existing = at === undefined ? undefined : merged[at]; + if (at === undefined || existing === undefined) { + if (title !== '') byTitle.set(title, merged.length); + merged.push(candidate); + continue; + } + // Only ACROSS sources. Two entries of one source that normalize alike are two entries: this + // database says they are different games, and it is the one that knows. + if (existing.provider === candidate.provider) { + merged.push(candidate); + continue; + } + merged[at] = { + ...existing, + ...(existing.steamAppId === undefined && candidate.steamAppId !== undefined + ? { steamAppId: candidate.steamAppId } + : {}), + ...(existing.gogId === undefined && candidate.gogId !== undefined + ? { gogId: candidate.gogId } + : {}), + }; + } + return merged; +} + +/** + * One candidate plus whatever the other sources called the same game, folded into a single entry that + * keeps the original's identity (its key is already in the renderer's hands) and gains their references. + * A search that matched nothing leaves the candidate exactly as it was. + */ +export function withMergedRefs( + candidate: GameCandidate, + others: readonly GameCandidate[], +): GameCandidate { + if (others.length === 0) return candidate; + const merged = mergeCandidates([candidate, ...others]); + return merged.find((entry) => entry.key === candidate.key) ?? candidate; +} + +/** Where a candidate's source sits in PROVIDER_ORDER — which entry leads a merge, and the menu. */ +function providerRank(candidate: GameCandidate): number { + const at = PROVIDER_ORDER.indexOf(candidate.provider); + return at === -1 ? PROVIDER_ORDER.length : at; +} diff --git a/src/main/metadata/steam.ts b/src/main/metadata/steam.ts new file mode 100644 index 00000000..1a438335 --- /dev/null +++ b/src/main/metadata/steam.ts @@ -0,0 +1,560 @@ +// Steam as a metadata source: the store's search endpoint for "which game is this", the CDN for its +// artwork, and appdetails for the descriptions. +// +// Both endpoints are UNOFFICIAL — no key, no documentation, no promise they will keep their shape. That +// is why every answer is validated with zod and every failure comes back as a Result: when Steam changes +// something, this feature degrades to "nothing found" and the launcher carries on. +// +// The COVER comes from the CDN, built from the appid: that is the only way to reach `library_600x900`, +// which appdetails does not name. +// +// The BACKGROUNDS do not come from the CDN at all. `library_hero.jpg` is a 3840x1240 banner made for the +// strip above a Steam library page (~3:1), and this launcher paints a full-screen background on a ~16:10 +// display — the banner loses about half its width to the crop and its centred composition falls apart. +// So backgrounds are taken from what appdetails names instead: `background_raw` (the store page's own +// art backdrop, ~16:9) first, then the screenshots. Screenshots are gameplay rather than art, which is a +// different KIND of picture — the gallery lets the user judge that, it is not ours to decide. +import { z } from 'zod'; +import { type Locale } from '../../shared/i18n/index'; +import { + type ArtworkKind, + type GameCandidate, + type GameDetails, + type GamePlatform, + type LocalizedText, + type MetadataResult, +} from '../../shared/types'; +import { + type ArtworkOffer, + type ArtworkOffers, + type ArtworkRequest, + type GameCandidateRef, + type MetadataProvider, +} from './provider'; +import { type HttpClient } from './http'; +import { log } from '../logger'; + +const STORE_ORIGIN = 'https://store.steampowered.com'; +const CDN_ORIGIN = 'https://cdn.cloudflare.steamstatic.com'; +/** Descriptions are stored in a manifest the user may open in a text editor — keep them readable. */ +const MAX_DESCRIPTION_CHARS = 2000; +/** How many appdetails answers the provider remembers. One session looks at a handful of games. */ +const MAX_CACHED_APPS = 50; + +/** + * The STORE REGION every request is made from, regardless of where the user is. + * + * `cc` decides what the store considers available, not what language it answers in — and a region where + * a game is not sold makes Steam behave as though the game did not exist: `storesearch` leaves it out of + * the results entirely, and `appdetails` answers `success: false` with no data at all. That is a + * catalogue hole, not a localization: a Russian region hides plenty of Western releases, and the launcher + * would show a user "nothing found" for a game they have installed and are looking at. + * + * The language is a separate parameter and keeps following the UI (see storeLanguage), so Russian titles + * and Russian descriptions are unaffected — verified against the endpoints: `l=russian&cc=US` returns + * both the Russian text and the games `cc=RU` hides. Prices and regional availability are the only things + * `cc` changes for real, and this feature reads neither. + */ +const STORE_COUNTRY = 'US'; + +/** The `l` a locale searches and reads with. Searching a Russian title with `l=english` finds nothing. */ +export function storeLanguage(locale: Locale): string { + return locale === 'ru' ? 'russian' : 'english'; +} + +/** `storesearch` — the store's own type-ahead, and the only keyless way to turn a title into an appid. */ +export function storeSearchUrl(term: string, locale: Locale): string { + return `${STORE_ORIGIN}/api/storesearch/?term=${encodeURIComponent(term)}&l=${storeLanguage(locale)}&cc=${STORE_COUNTRY}`; +} + +/** `appdetails` for ONE language — the caller asks twice (en + ru) to fill a LocalizedText. */ +export function appDetailsUrl(appId: number, locale: Locale): string { + return `${STORE_ORIGIN}/api/appdetails?appids=${appId}&l=${storeLanguage(locale)}&cc=${STORE_COUNTRY}`; +} + +/** The portrait cover (600x900) — the grid image the carousel wants. `2x` is the same art at 1200x1800. */ +export function libraryGridUrl(appId: number, doubled = false): string { + return `${CDN_ORIGIN}/steam/apps/${appId}/library_600x900${doubled ? '_2x' : ''}.jpg`; +} + +/** `steam:<appid>` — the candidate key the renderer round-trips back to us. */ +export function steamCandidateKey(appId: number): string { + return `steam:${appId}`; +} + +const searchSchema = z.object({ + items: z + .array( + z.object({ + id: z.number().int().positive(), + name: z.string().min(1), + type: z.string().optional(), + }), + ) + .default([]), +}); + +const appDetailsSchema = z.record( + z.string(), + z.object({ + success: z.boolean(), + data: z + .object({ + name: z.string().optional(), + short_description: z.string().optional(), + /** The store page's art backdrop. Optional — plenty of apps have none. */ + background_raw: z.string().optional(), + genres: z.array(z.object({ description: z.string().min(1) })).optional(), + release_date: z.object({ date: z.string().optional() }).optional(), + platforms: z + .object({ + windows: z.boolean().optional(), + mac: z.boolean().optional(), + linux: z.boolean().optional(), + }) + .optional(), + /** + * Gameplay screenshots. No dimensions are stated anywhere in the answer, and the `.1920x1080.` + * in a path is a BOUNDING BOX rather than a promise: an old game's shot comes back 1024x768. + * That is why the variants built from these carry no width/height at all. + */ + screenshots: z + .array( + z.object({ + id: z.number().int().nonnegative(), + path_thumbnail: z.string().optional(), + path_full: z.string().optional(), + }), + ) + .optional(), + }) + .optional(), + }), +); + +/** + * Steam's descriptions carry store markup (`<strong>`, `<br>`, entities). The manifest holds plain text, + * so tags are dropped, the common entities decoded and the whitespace collapsed — then the result is cut + * to MAX_DESCRIPTION_CHARS on a word boundary rather than mid-word. + */ +export function sanitizeDescription(raw: string): string { + const withoutTags = raw + .replaceAll(/<br\s*\/?>/gi, ' ') + .replaceAll(/<[^>]*>/g, '') + .replaceAll(' ', ' ') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'"); + const collapsed = withoutTags.replaceAll(/\s+/g, ' ').trim(); + if (collapsed.length <= MAX_DESCRIPTION_CHARS) return collapsed; + const cut = collapsed.slice(0, MAX_DESCRIPTION_CHARS); + const lastSpace = cut.lastIndexOf(' '); + return (lastSpace > MAX_DESCRIPTION_CHARS / 2 ? cut.slice(0, lastSpace) : cut).trim(); +} + +type AppDetailsAnswer = z.infer<typeof appDetailsSchema>; + +/** One screenshot as the gallery needs it: a thumbnail to show and a full size to apply. */ +export interface SteamScreenshot { + readonly id: number; + readonly thumb: string; + readonly full: string; +} + +/** The picture fields of one appdetails answer, reduced to what the gallery builds its variants from. */ +export interface SteamAppArt { + /** The store page's art backdrop, when the app has one. */ + readonly backdrop?: string; + readonly screenshots: readonly SteamScreenshot[]; + /** + * The game's name as the ENGLISH store spells it. Only ever filled from an `l=english` answer, because + * that is its whole purpose: the wallpaper source searches English words, and a localized name finds + * nothing there. Absent when the only answer seen so far came back in another language. + */ + readonly englishName?: string; +} + +/** + * The art of one app, out of an appdetails answer. A screenshot with no full-size path is dropped: it is + * the picture the user would end up applying, and half an entry is worse than none. A missing thumbnail + * falls back to the full size — the gallery then downloads more than it needs, which beats a blank tile. + */ +export function toAppArt( + answer: AppDetailsAnswer, + appId: number, + options?: { readonly english?: boolean }, +): SteamAppArt { + const entry = answer[String(appId)]; + const data = entry?.success === true ? entry.data : undefined; + const backdrop = data?.background_raw; + const screenshots = (data?.screenshots ?? []).flatMap((shot) => { + const full = shot.path_full; + if (full === undefined || full.length === 0) return []; + return [{ id: shot.id, thumb: shot.path_thumbnail ?? full, full }]; + }); + const name = options?.english === true ? data?.name : undefined; + return { + ...(backdrop !== undefined && backdrop.length > 0 ? { backdrop } : {}), + ...(name !== undefined && name.length > 0 ? { englishName: name } : {}), + screenshots, + }; +} + +/** The month names the English store writes dates with — the only spelling this has to understand. */ +const MONTHS: Readonly<Record<string, string>> = { + jan: '01', + feb: '02', + mar: '03', + apr: '04', + may: '05', + jun: '06', + jul: '07', + aug: '08', + sep: '09', + oct: '10', + nov: '11', + dec: '12', +}; + +/** + * Steam's release date as `YYYY-MM-DD`, or `YYYY` when that is all it states ("Coming soon" yields + * nothing at all). The store writes it in the language it was ASKED in, so this only ever runs on the + * English answer — a Russian "17 сен. 2020 г." is not worth a second month table. + * + * BOTH orders are read, because the store uses both: `cc=US` (what this app asks with) answers + * "Sep 17, 2020", while the day-first "17 Sep, 2020" is what other regions return — and getting that + * wrong is silent, since a date that does not parse simply is not stored. + */ +export function toIsoDate(stated: string | undefined): string | undefined { + if (stated === undefined) return undefined; + const text = stated.trim(); + const monthFirst = /^([A-Za-z]{3})[a-z]*\.?\s+(\d{1,2}),?\s+(\d{4})$/.exec(text); + const dayFirst = /^(\d{1,2})\s+([A-Za-z]{3})[a-z]*\.?,?\s+(\d{4})$/.exec(text); + const parts = + monthFirst !== null + ? { month: monthFirst[1], day: monthFirst[2], year: monthFirst[3] } + : dayFirst !== null + ? { month: dayFirst[2], day: dayFirst[1], year: dayFirst[3] } + : undefined; + const month = parts === undefined ? undefined : MONTHS[(parts.month ?? '').toLowerCase()]; + if (parts !== undefined && month !== undefined) { + return `${parts.year}-${month}-${(parts.day ?? '').padStart(2, '0')}`; + } + const bareMonth = /^([A-Za-z]{3})[a-z]*\.?\s+(\d{4})$/.exec(text); + const loneMonth = bareMonth === null ? undefined : MONTHS[(bareMonth[1] ?? '').toLowerCase()]; + if (bareMonth !== null && loneMonth !== undefined) return `${bareMonth[2]}-${loneMonth}`; + const year = /^(\d{4})$/.exec(text); + return year === null ? undefined : (year[1] ?? undefined); +} + +/** The platform flags as a list, in the order a store page lists them. */ +export function toPlatforms( + flags: { windows?: boolean; mac?: boolean; linux?: boolean } | undefined, +): readonly GamePlatform[] | undefined { + if (flags === undefined) return undefined; + const named: readonly GamePlatform[] = (['windows', 'mac', 'linux'] as const).filter( + (platform) => flags[platform] === true, + ); + return named.length > 0 ? named : undefined; +} + +/** Turns a validated storesearch answer into candidates, keeping only entries that are actual games. */ +export function toCandidates( + items: readonly { id: number; name: string; type?: string }[], +): readonly GameCandidate[] { + return items + .filter((item) => item.type === undefined || item.type === 'app' || item.type === 'game') + .map((item) => ({ + key: steamCandidateKey(item.id), + title: item.name, + provider: 'steam' as const, + steamAppId: item.id, + })); +} + +/** `steam:<appid>` back to an appid. Undefined for a key that belongs to another provider. */ +export function steamAppIdFromKey(key: string): number | undefined { + const match = /^steam:(\d+)$/.exec(key); + if (match === null) return undefined; + const appId = Number(match[1]); + return Number.isSafeInteger(appId) && appId > 0 ? appId : undefined; +} + +export interface SteamProviderDeps { + readonly http: HttpClient; + /** Read live: the user can switch the UI language while the app runs, and search follows it. */ + readonly locale: () => Locale; +} + +export class SteamProvider implements MetadataProvider { + readonly id = 'steam' as const; + /** + * What appdetails said about each app's pictures, so the gallery never asks twice. "This app names no + * background at all" is a real answer and is cached as one — re-asking would spend a rate limit to + * learn the same nothing. + */ + private readonly appArtwork = new Map<number, SteamAppArt>(); + + constructor(private readonly deps: SteamProviderDeps) {} + + async search( + query: string, + signal?: AbortSignal, + ): Promise<MetadataResult<readonly GameCandidate[]>> { + const url = storeSearchUrl(query, this.deps.locale()); + const answer = await this.deps.http.json( + url, + searchSchema, + signal === undefined ? undefined : { signal }, + ); + if (!answer.ok) return answer; + return { ok: true, value: toCandidates(answer.value.items) }; + } + + /** + * The candidate behind an appid the manifest already carries. appdetails is asked only for the NAME — + * a candidate needs one to show, and the alternative would be labelling it with a bare number. When + * that call fails the appid alone is still a perfectly usable candidate. + */ + async candidateByAppId( + appId: number, + signal?: AbortSignal, + ): Promise<MetadataResult<GameCandidate>> { + if (!Number.isSafeInteger(appId) || appId <= 0) + return { ok: false, message: 'not a Steam appid' }; + const options = signal === undefined ? undefined : { signal }; + const details = await this.deps.http.json( + appDetailsUrl(appId, this.deps.locale()), + appDetailsSchema, + options, + ); + const name = details.ok ? details.value[String(appId)]?.data?.name : undefined; + return { + ok: true, + value: { + key: steamCandidateKey(appId), + title: name !== undefined && name.length > 0 ? name : `Steam ${appId}`, + provider: this.id, + steamAppId: appId, + }, + }; + } + + /** + * The CDN art for one app. A variant is offered only once its FULL-size URL is known to exist: the + * 600x900 cover and the hero are simply absent for many older apps, and a gallery tile whose apply + * would 404 is worse than one tile fewer. + */ + /** + * Everything Steam holds for the game, on page 0. There is nothing to page through here: an app has + * the screenshots it has, and whatever did not fit on the gallery's first screen is kept by the + * service and handed out when the user asks for more. + */ + async artwork( + ref: GameCandidateRef, + kind: ArtworkKind, + request: ArtworkRequest, + signal?: AbortSignal, + ): Promise<MetadataResult<ArtworkOffers>> { + if (request.page > 0) return { ok: true, value: { offers: [], hasMore: false } }; + const appId = ref.steamAppId ?? steamAppIdFromKey(ref.key); + if (appId === undefined) return { ok: true, value: { offers: [], hasMore: false } }; + const offers = + kind === 'hero' ? await this.backgrounds(appId, signal) : await this.covers(appId, signal); + return { ok: true, value: { offers, hasMore: false } }; + } + + /** + * The two CDN covers, each offered only once its full-size file is known to exist: `library_600x900` + * is simply absent for many older apps, and a tile whose apply would 404 is worse than one tile fewer. + */ + private async covers(appId: number, signal?: AbortSignal): Promise<readonly ArtworkOffer[]> { + const options = signal === undefined ? undefined : { signal }; + const candidates: readonly ArtworkOffer[] = [ + { + key: `steam:${appId}:grid-2x`, + kind: 'grid', + provider: this.id, + width: 1200, + height: 1800, + thumbUrl: libraryGridUrl(appId), + fullUrl: libraryGridUrl(appId, true), + }, + { + key: `steam:${appId}:grid`, + kind: 'grid', + provider: this.id, + width: 600, + height: 900, + thumbUrl: libraryGridUrl(appId), + fullUrl: libraryGridUrl(appId), + }, + ]; + const present: ArtworkOffer[] = []; + for (const offer of candidates) { + if (await this.deps.http.exists(offer.fullUrl, options)) present.push(offer); + } + return present; + } + + /** + * The backgrounds appdetails names: the art backdrop first, then the screenshots in the order Steam + * lists them. No existence check is needed here — unlike the CDN templates, these URLs came FROM the + * answer, and a screenshot's thumbnail and full size are the same asset in two sizes, so the "the + * thumbnail is there but the full size 404s" mismatch the covers guard against cannot occur. + * + * An app with no screenshots and no backdrop — and a delisted one, where appdetails answers + * `success: false` — simply yields nothing, which the gallery states as "nothing found". + */ + private async backgrounds(appId: number, signal?: AbortSignal): Promise<readonly ArtworkOffer[]> { + const art = await this.appArt(appId, signal); + if (art === undefined) return []; + const offers: ArtworkOffer[] = []; + if (art.backdrop !== undefined) { + offers.push({ + key: `steam:${appId}:backdrop`, + kind: 'hero', + provider: this.id, + thumbUrl: art.backdrop, + fullUrl: art.backdrop, + }); + } + for (const shot of art.screenshots) { + offers.push({ + key: `steam:${appId}:shot-${shot.id}`, + kind: 'hero', + provider: this.id, + thumbUrl: shot.thumb, + fullUrl: shot.full, + }); + } + return offers; + } + + /** + * The art fields of one appdetails answer, cached for the session. The cache is what keeps this + * endpoint's rate limit (~200 calls / 5 minutes) comfortable: the gallery, a re-open of it and the + * descriptions all ask about the same game, and the descriptions fill this cache on their way through. + */ + private async appArt(appId: number, signal?: AbortSignal): Promise<SteamAppArt | undefined> { + const cached = this.appArtwork.get(appId); + if (cached !== undefined) return cached; + const details = await this.deps.http.json( + appDetailsUrl(appId, this.deps.locale()), + appDetailsSchema, + signal === undefined ? undefined : { signal }, + ); + if (!details.ok) { + log.warn(`[metadata] appdetails failed for ${appId}: ${details.message}`); + return undefined; // not cached: a failed call says nothing about the app + } + const art = this.rememberArt(appId, details.value, { english: this.deps.locale() === 'en' }); + // A store that answers `success: false` — a delisted app, or one the request's region does not sell — + // looks exactly like a successful call with nothing in it. Worth a line: it is the difference between + // "this game has no pictures" and "this store will not talk about this game". + if (art.backdrop === undefined && art.screenshots.length === 0) { + log.warn(`[metadata] appdetails returned no artwork for ${appId}`); + } + return art; + } + + /** + * Extracts the art fields from an answer already in hand and caches them, dropping the oldest. An + * entry that already holds an English name keeps it: a later answer in another language knows the + * pictures just as well, but its `name` is not the one the wallpaper search needs. + */ + private rememberArt( + appId: number, + answer: AppDetailsAnswer, + options?: { readonly english?: boolean }, + ): SteamAppArt { + const known = this.appArtwork.get(appId)?.englishName; + const fresh = toAppArt(answer, appId, options); + const art: SteamAppArt = + fresh.englishName === undefined && known !== undefined + ? { ...fresh, englishName: known } + : fresh; + this.appArtwork.delete(appId); + this.appArtwork.set(appId, art); + if (this.appArtwork.size > MAX_CACHED_APPS) { + const oldest = this.appArtwork.keys().next(); + if (oldest.done !== true) this.appArtwork.delete(oldest.value); + } + return art; + } + + /** + * The game's English name, if an English appdetails answer for it has been seen this session — which + * it has as soon as the user picks the candidate, since the descriptions are fetched right then. + * Handed to the wallpaper source, whose search only understands English words. + */ + englishTitle(ref: GameCandidateRef): string | undefined { + const appId = ref.steamAppId ?? steamAppIdFromKey(ref.key); + if (appId === undefined) return undefined; + return this.appArtwork.get(appId)?.englishName; + } + + /** + * The English and Russian short descriptions, fetched as two requests. Steam rate-limits appdetails at + * roughly 200 calls per five minutes, which is why this runs on an explicit pick and never in a sweep. + * A language Steam has nothing for is simply left out of the result. + */ + async details(ref: GameCandidateRef, signal?: AbortSignal): Promise<MetadataResult<GameDetails>> { + const appId = ref.steamAppId ?? steamAppIdFromKey(ref.key); + if (appId === undefined) return { ok: false, message: 'not a Steam app' }; + const options = signal === undefined ? undefined : { signal }; + const [en, ru] = await Promise.all([ + this.deps.http.json(appDetailsUrl(appId, 'en'), appDetailsSchema, options), + this.deps.http.json(appDetailsUrl(appId, 'ru'), appDetailsSchema, options), + ]); + if (!en.ok && !ru.ok) return en; + // The same answer carries the backdrop and the screenshots; keeping them here spares the gallery a + // second call for a game the user has just picked (details are fetched on exactly that press). + if (en.ok) this.rememberArt(appId, en.value, { english: true }); + const description: LocalizedText = { + ...(en.ok ? pickDescription(en.value, appId, 'en') : {}), + ...(ru.ok ? pickDescription(ru.value, appId, 'ru') : {}), + }; + // Genres, date and platforms come from the ENGLISH answer alone: a filter compares them, and a value + // that changes wording with the interface language cannot be compared with one stored last week. + const data = en.ok ? (en.value[String(appId)]?.data ?? undefined) : undefined; + return { ok: true, value: toDetails(description, data) }; + } +} + +/** What appdetails states about the game itself, as GameDetails. Absent fields are simply left out. */ +export function toDetails( + description: LocalizedText, + data: + | { + genres?: readonly { description: string }[]; + release_date?: { date?: string }; + platforms?: { windows?: boolean; mac?: boolean; linux?: boolean }; + } + | undefined, +): GameDetails { + const genres = (data?.genres ?? []) + .map((genre) => genre.description) + .filter((name) => name !== ''); + const releaseDate = toIsoDate(data?.release_date?.date); + const platforms = toPlatforms(data?.platforms); + const hasText = description.en !== undefined || description.ru !== undefined; + return { + ...(hasText ? { description } : {}), + ...(genres.length > 0 ? { genres } : {}), + ...(releaseDate === undefined ? {} : { releaseDate }), + ...(platforms === undefined ? {} : { platforms }), + }; +} + +/** The one entry appdetails answers with, reduced to `{ en: … }` / `{ ru: … }` (or nothing at all). */ +function pickDescription(answer: AppDetailsAnswer, appId: number, locale: Locale): LocalizedText { + const entry = answer[String(appId)]; + if (entry === undefined || !entry.success) return {}; + const raw = entry.data?.short_description ?? ''; + const text = sanitizeDescription(raw); + if (text.length === 0) return {}; + return locale === 'ru' ? { ru: text } : { en: text }; +} diff --git a/src/main/metadata/steamgriddb.ts b/src/main/metadata/steamgriddb.ts new file mode 100644 index 00000000..932cf461 --- /dev/null +++ b/src/main/metadata/steamgriddb.ts @@ -0,0 +1,169 @@ +// SteamGridDB — the community art database, and the only source of ALTERNATIVE COVERS (Steam offers +// exactly one, and none at all for a non-Steam game). +// +// Covers only, deliberately. This database's "heroes" are 1920x620 / 3840x1240 banners built for the +// strip above a Steam library page (~2.5:1 to 3:1); Playhook paints a full-screen background on a ~16:10 +// display, where such a banner loses half its width to the crop and its centred composition falls apart. +// Backgrounds come from sources whose pictures are made to be looked at whole — see wallhaven.ts, +// steam.ts and gog.ts. +// +// Unlike the Steam endpoints this one has a documented API v2 and REQUIRES a key. Playhook ships none: +// an open-source repository cannot carry a secret, so the key is the user's own, typed into Settings. +// With the field empty the provider reports itself unavailable and the whole feature degrades to +// Steam-only — no error, no prompt, just fewer variants. +import { z } from 'zod'; +import { type ArtworkKind, type GameCandidate, type MetadataResult } from '../../shared/types'; +import { + type ArtworkOffer, + type ArtworkOffers, + type ArtworkRequest, + type GameCandidateRef, + type MetadataProvider, +} from './provider'; +import { type HttpClient } from './http'; + +const API_ORIGIN = 'https://www.steamgriddb.com/api/v2'; +/** The launcher's cover geometry — anything else would be letterboxed in the carousel. */ +const GRID_DIMENSIONS = '600x900'; + +/** `sgdb:<gameId>` — the candidate key for a game this database knows but Steam's search did not. */ +export function sgdbCandidateKey(gameId: number): string { + return `sgdb:${gameId}`; +} + +/** `sgdb:<gameId>` back to a game id. Undefined for a key that belongs to another provider. */ +export function sgdbGameIdFromKey(key: string): number | undefined { + const match = /^sgdb:(\d+)$/.exec(key); + if (match === null) return undefined; + const id = Number(match[1]); + return Number.isSafeInteger(id) && id > 0 ? id : undefined; +} + +export function autocompleteUrl(term: string): string { + return `${API_ORIGIN}/search/autocomplete/${encodeURIComponent(term)}`; +} + +/** + * The covers endpoint accepts EITHER the database's own game id or a Steam appid, addressed by platform. + * Going straight at `steam/<appid>` for a Steam candidate saves the `/games/steam/<appid>` hop that + * would otherwise only translate one id into the other. + */ +export function coversUrl(ref: SgdbArtRef): string { + const target = ref.kind === 'steam' ? `steam/${ref.id}` : `game/${ref.id}`; + return `${API_ORIGIN}/grids/${target}?dimensions=${GRID_DIMENSIONS}`; +} + +/** Which id an art request is addressed by — the database's own, or a Steam appid. */ +export type SgdbArtRef = { readonly kind: 'steam' | 'game'; readonly id: number }; + +const searchSchema = z.object({ + success: z.boolean(), + data: z + .array( + z.object({ + id: z.number().int().positive(), + name: z.string().min(1), + }), + ) + .default([]), +}); + +const artworkSchema = z.object({ + success: z.boolean(), + data: z + .array( + z.object({ + id: z.number().int().positive(), + url: z.string().min(1), + thumb: z.string().min(1), + width: z.number().int().positive().optional(), + height: z.number().int().positive().optional(), + }), + ) + .default([]), +}); + +type SgdbArtItem = z.infer<typeof artworkSchema>['data'][number]; + +/** The database's cover rows as offers. The `id` is the art's, not the game's — one game has many. */ +export function toArtworkOffers(items: readonly SgdbArtItem[]): readonly ArtworkOffer[] { + return items.map((item) => ({ + key: `sgdb:art:${item.id}`, + kind: 'grid' as const, + provider: 'steamgriddb' as const, + ...(item.width === undefined ? {} : { width: item.width }), + ...(item.height === undefined ? {} : { height: item.height }), + thumbUrl: item.thumb, + fullUrl: item.url, + })); +} + +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. */ + readonly apiKey: () => string; +} + +export class SteamGridDbProvider implements MetadataProvider { + readonly id = 'steamgriddb' as const; + + constructor(private readonly deps: SteamGridDbDeps) {} + + /** Whether a key has been entered at all. The service skips this provider entirely when it has not. */ + available(): boolean { + return this.deps.apiKey().trim().length > 0; + } + + async search( + query: string, + signal?: AbortSignal, + ): Promise<MetadataResult<readonly GameCandidate[]>> { + 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; + return { + ok: true, + value: answer.value.data.map((item) => ({ + key: sgdbCandidateKey(item.id), + title: item.name, + provider: this.id, + })), + }; + } + + /** Covers only: this source's backgrounds are banners, which is not what a full-screen hero needs. */ + async artwork( + ref: GameCandidateRef, + kind: ArtworkKind, + request: ArtworkRequest, + signal?: AbortSignal, + ): Promise<MetadataResult<ArtworkOffers>> { + const nothing = { ok: true, value: { offers: [], hasMore: false } } as const; + if (kind !== 'grid' || request.page > 0) return nothing; + const options = this.options(signal); + if (options === undefined) return nothing; + 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; + return { ok: true, value: { offers: toArtworkOffers(answer.value.data), hasMore: false } }; + } + + /** A Steam candidate is addressed by its appid; anything else must carry an `sgdb:` key of its own. */ + private artRef(ref: GameCandidateRef): SgdbArtRef | undefined { + if (ref.steamAppId !== undefined) return { kind: 'steam', id: ref.steamAppId }; + const gameId = sgdbGameIdFromKey(ref.key); + return gameId === undefined ? undefined : { kind: 'game', id: gameId }; + } + + /** The authorized request options, or undefined when there is no key to authorize with. */ + private options( + signal?: AbortSignal, + ): { headers: Record<string, string>; signal?: AbortSignal } | undefined { + const key = this.deps.apiKey().trim(); + if (key.length === 0) return undefined; + const headers = { Authorization: `Bearer ${key}` }; + return signal === undefined ? { headers } : { headers, signal }; + } +} diff --git a/src/main/metadata/wallhaven.ts b/src/main/metadata/wallhaven.ts new file mode 100644 index 00000000..e5962f35 --- /dev/null +++ b/src/main/metadata/wallhaven.ts @@ -0,0 +1,319 @@ +// Wallhaven — the backgrounds source, and the only one here that serves the actual target content: +// wallpapers. Steam's screenshots and GOG's are gameplay frames with a HUD in them; these are pictures +// composed to be looked at full-screen, which is exactly what this launcher paints behind a game. +// +// It searches WALLPAPERS, not games, so it takes no part in finding candidates — there is no +// `wallhavenId` on a candidate and nothing to merge. All it answers is "backgrounds for this title". +// +// The API is official and documented (wallhaven.cc/help/api), keyless for SFW, and rate-limited at 45 +// requests per minute per IP — a gallery costs one to three of those. The thumbnail and full-size hosts +// are static and outside that limit, so downloading a page of thumbnails three at a time never nears it. +// +// Two things about its search decide the shape of this module: +// +// * it matches on WORDS, ANDed. A Russian title finds nothing at all (the tags are English), so the +// query is built from the English name — see `WallhavenDeps.englishTitle`; +// * extra words do not merely add noise, they empty the result: "The Witcher 3: Wild Hunt" answers with +// 674 wallpapers, "The Witcher 3: Wild Hunt - Complete Edition" with none. Steam titles are full of +// such edition tails, hence the fallback cascade in `searchTerms`. +import { z } from 'zod'; +import { type ArtworkKind, type MetadataResult } from '../../shared/types'; +import { + type ArtworkOffer, + type ArtworkOffers, + type ArtworkRequest, + type GameCandidateRef, + type MetadataProvider, +} from './provider'; +import { type HttpClient } from './http'; +import { type SizeFloor } from '../../shared/artwork-filter'; +import { searchableTitle } from './search-title'; + +const API_ORIGIN = 'https://wallhaven.cc/api/v1'; +/** + * general + anime, people OFF — the third category is mostly cosplay and portraits, which is not what + * "a background for this game" means. + */ +const CATEGORIES = '110'; +/** SFW only. This is the default and the only purity available without a key; NSFW is not offered. */ +const PURITY = '100'; +/** + * The floor for a wallpaper's size when the user asked for no particular one. The Deck's panel is + * 1280x800, so 1080p already carries half again the pixels it can show; asking for 1440p only narrowed + * the choice without looking any better on it. Bigger ones still come back — this is a minimum, not a + * target — and the gallery's own size filter raises it (see `atleast` in `searchUrl`). + */ +const MIN_RESOLUTION = { width: 1920, height: 1080 }; +/** + * Landscape, and landscape only — a portrait wallpaper would be cropped to a ribbon behind a 16:10 + * screen. Deliberately NOT a list of exact ratios: this endpoint matches those EXACTLY, so `16x9,16x10` + * threw away a 4096x2286 wallpaper for being 1.79 rather than 1.78, and with it most of what the less + * photographed games have. The gallery shows each tile whole (object-fit) and states its size, so a + * wider-than-usual wallpaper is something the user can see and judge, not something to hide from them. + */ +const RATIOS = 'landscape'; +/** + * Anything heavier than the image cap would be refused at apply time anyway (see MAX_BYTES in + * service.ts), and the search answer states each file's size — so an unusable offer is dropped before it + * ever becomes a tile. 4K PNGs routinely weigh 12-14 MB, so this is not a theoretical bound. + */ +const MAX_FILE_BYTES = 32 * 1024 * 1024; + +/** The edition tails publishers append. Cutting them is what turns an empty answer into a full one. */ +const EDITION_MARKERS: readonly string[] = [ + 'complete edition', + 'definitive edition', + 'enhanced edition', + 'final cut', + 'game of the year edition', + 'goty edition', + 'goty', + 'remastered', +]; + +/** + * `page` is 0-based here and 1-based there — the endpoint counts its pages from one. `minSize` is the + * gallery's size filter: asking the endpoint for it beats filtering its answer, which for a 4K floor + * would leave two or three tiles out of a page of twenty-four. + */ +export function searchUrl(term: string, page = 0, minSize?: SizeFloor): string { + const floor = + minSize === undefined || minSize.width < MIN_RESOLUTION.width ? MIN_RESOLUTION : minSize; + const query = new URLSearchParams({ + q: term, + categories: CATEGORIES, + purity: PURITY, + atleast: `${floor.width}x${floor.height}`, + ratios: RATIOS, + sorting: 'relevance', + page: String(page + 1), + }); + return `${API_ORIGIN}/search?${query.toString()}`; +} + +/** + * The queries to try, in order, until one of them finds something: the title as it stands, then the same + * title with its edition tail removed. + * + * Deliberately NOT the merge normalization from the candidate merge — that one must never conflate two + * different games, so it keeps every word. This one exists for the opposite reason: here an extra word + * is what makes a game's wallpapers invisible. + * + * Every term is a searchable title (see search-title.ts): "Watch_Dogs™" finds nothing here, and it is + * the spelling Steam hands over. + */ +export function searchTerms(title: string): readonly string[] { + const first = searchableTitle(title); + if (first === '') return []; + const terms = [first]; + for (const candidate of [ + withoutEditionTail(first), + beforeSubtitle(first), + withoutOwnerPrefix(first), + plainWords(first), + ]) { + if (candidate === '') continue; + if (terms.some((term) => term.toLowerCase() === candidate.toLowerCase())) continue; + terms.push(candidate); + } + return terms; +} + +/** + * A title with its edition tail removed. Two shapes cover what stores actually append: + * + * * everything after a dash — "Disco Elysium - The Final Cut", "The Witcher 3: Wild Hunt - Complete + * Edition". The dash is the store's own seam between the game and its edition, so it is the safest + * cut and it is tried first; + * * a trailing marker with no dash in front of it — "Dark Souls Remastered". + * + * Only the TAIL goes, and only when something is left: "Final Fantasy" survives "Final Cut" being a + * marker, and a subtitle that belongs to the name ("Wild Hunt") is not an edition and stays. + */ +export function withoutEditionTail(title: string): string { + const separator = Math.max(title.lastIndexOf(' - '), title.lastIndexOf(' – ')); + if (separator > 0) { + const head = title.slice(0, separator).trim(); + if (head !== '') return head; + } + const lower = title.toLowerCase(); + for (const marker of EDITION_MARKERS) { + const at = lower.lastIndexOf(marker); + if (at === -1 || at + marker.length !== lower.length) continue; + const head = title + .slice(0, at) + .replace(/[\s:–—-]+$/u, '') + .trim(); + if (head !== '') return head; + } + return title; +} + +/** + * The part before a colon — the last resort of the cascade. A subtitle is usually part of the name and + * finds wallpapers on its own, so this is only reached once the fuller queries have come back empty. + */ +export function beforeSubtitle(title: string): string { + const at = title.indexOf(': '); + return at > 0 ? title.slice(0, at).trim() : ''; +} + +/** + * A title with the publisher's possessive dropped: "Tom Clancy's Splinter Cell Chaos Theory" without + * the "Tom Clancy's". Measured 2026-08-23, and the reason this exists at all: the full title finds + * NOTHING on either wallpaper site, while "Splinter Cell Chaos Theory" finds 40 on Wallpaper Cave, and + * "Sid Meier's Civilization VI" (54) becomes "Civilization VI" (123). + * + * Safe because it is a CASCADE step, not a rewrite: a game whose possessive belongs to its own name + * ("Assassin's Creed Odyssey") is answered by the full title first and never reaches this one. Empty + * when there is no such prefix, which the caller reads as "nothing to add". + */ +export function withoutOwnerPrefix(title: string): string { + const match = /^[\p{Letter}\p{Number}.\s]{2,24}['’]s\s+(.+)$/u.exec(title); + return match?.[1]?.trim() ?? ''; +} + +/** + * A title stripped to letters, digits and spaces — the LAST resort of the cascade, and only that. + * + * Punctuation is not noise to these sites: measured 2026-08-22, Wallhaven answers "F.E.A.R." with 12 + * wallpapers and "F E A R" with none, "Marvel’s Spider-Man" with 24 and "Marvels Spider Man" with none. + * So stripping it up front would cost more than it saves. It earns its place at the END, where the + * alternative is nothing at all: an apostrophe sends Wallpaper Cave into a redirect loop (it answers 302 + * to the same URL forever), so "Assassin's Creed Odyssey" finds nothing there until this variant is + * tried — and then finds 124. + */ +export function plainWords(title: string): string { + return ( + title + // An apostrophe binds a word rather than separating one: "Assassin's" is one word, and turning it + // into "Assassin s" hands these sites a stray "s" to match on. + .replaceAll(/['’`´‛]/gu, '') + .replaceAll(/[^\p{Letter}\p{Number}]+/gu, ' ') + .replace(/\s+/g, ' ') + .trim() + ); +} + +/** Whether a title is written in the Latin alphabet — the only case it can stand in for the English one. */ +export function isLatinTitle(title: string): boolean { + return !/\p{Script=Cyrillic}|\p{Script=Han}|\p{Script=Hiragana}|\p{Script=Katakana}|\p{Script=Hangul}/u.test( + title, + ); +} + +const searchSchema = z.object({ + /** + * The endpoint states which page it just served and how many there are, which is what makes "load + * more" honest: the gallery offers another page only when one exists. Optional all the same — a + * missing `meta` costs the extra pages, not the answer. + */ + meta: z + .object({ + current_page: z.number().int().positive().optional(), + last_page: z.number().int().positive().optional(), + }) + .optional(), + data: z + .array( + z.object({ + id: z.string().min(1), + path: z.string().min(1), + file_size: z.number().nonnegative().optional(), + dimension_x: z.number().int().positive().optional(), + dimension_y: z.number().int().positive().optional(), + thumbs: z.object({ small: z.string().min(1).optional() }).optional(), + }), + ) + .default([]), +}); + +type Wallpaper = z.infer<typeof searchSchema>['data'][number]; +type SearchMeta = z.infer<typeof searchSchema>['meta']; + +/** Whether the answer says another page follows. Silence means no: an empty page is a worse offer. */ +export function hasMorePages(meta: SearchMeta): boolean { + const current = meta?.current_page; + const last = meta?.last_page; + return current !== undefined && last !== undefined && current < last; +} + +/** + * Wallpapers as offers, with the ones too heavy to apply left out. The thumbnail is Wallhaven's own + * (~24 KB, already the right size for the grid); unlike Steam's screenshots these state their real + * dimensions, so the tiles can show them. + */ +export function toArtworkOffers(wallpapers: readonly Wallpaper[]): readonly ArtworkOffer[] { + return wallpapers + .filter((paper) => paper.file_size === undefined || paper.file_size <= MAX_FILE_BYTES) + .map((paper) => ({ + key: `wallhaven:${paper.id}`, + kind: 'hero' as const, + provider: 'wallhaven' as const, + ...(paper.dimension_x === undefined ? {} : { width: paper.dimension_x }), + ...(paper.dimension_y === undefined ? {} : { height: paper.dimension_y }), + thumbUrl: paper.thumbs?.small ?? paper.path, + fullUrl: paper.path, + })); +} + +export interface WallhavenDeps { + readonly http: HttpClient; + /** + * The game's ENGLISH name, when something knows it — in practice Steam's appdetails answer, which the + * Steam provider already holds for the candidate the user picked. Without it a Russian UI would search + * Wallhaven for a Russian title and always find nothing. + */ + readonly englishTitle: (ref: GameCandidateRef) => string | undefined; +} + +export class WallhavenProvider implements MetadataProvider { + readonly id = 'wallhaven' as const; + /** The query that answered for a candidate, so "load more" pages through THAT search, not another. */ + private readonly answeredTerm = new Map<string, string>(); + + constructor(private readonly deps: WallhavenDeps) {} + + /** + * Backgrounds only, and only for a title this can search in English. Each term of the cascade is tried + * until one answers with something; an empty result is not a failure, just a game nobody has made a + * wallpaper for. + * + * A later page repeats the term that worked rather than walking the cascade again: the cascade exists + * to find A query that answers, and page 2 of a different query would be a different set of pictures + * appended to the same gallery. + */ + async artwork( + ref: GameCandidateRef, + kind: ArtworkKind, + request: ArtworkRequest, + signal?: AbortSignal, + ): Promise<MetadataResult<ArtworkOffers>> { + const nothing = { ok: true, value: { offers: [], hasMore: false } } as const; + if (kind !== 'hero') return nothing; + const title = this.deps.englishTitle(ref) ?? (isLatinTitle(ref.title) ? ref.title : undefined); + if (title === undefined) return nothing; + const options = signal === undefined ? undefined : { signal }; + const remembered = request.page > 0 ? this.answeredTerm.get(ref.key) : undefined; + let failure: MetadataResult<ArtworkOffers> | undefined; + for (const term of remembered === undefined ? searchTerms(title) : [remembered]) { + const answer = await this.deps.http.json( + searchUrl(term, request.page, request.minSize), + searchSchema, + options, + ); + if (!answer.ok) { + failure = answer; + continue; + } + const offers = toArtworkOffers(answer.value.data); + const hasMore = hasMorePages(answer.value.meta); + // A later page of the term already in use is this game's answer whether or not the filters left + // anything on it; on the first page an empty result means "try the next term". + if (offers.length === 0 && remembered === undefined) continue; + this.answeredTerm.set(ref.key, term); + return { ok: true, value: { offers, hasMore } }; + } + return failure ?? nothing; + } +} diff --git a/src/main/metadata/wallpapercave.ts b/src/main/metadata/wallpapercave.ts new file mode 100644 index 00000000..ff05aa05 --- /dev/null +++ b/src/main/metadata/wallpapercave.ts @@ -0,0 +1,372 @@ +// Wallpaper Cave — the second wallpaper source, and the one that covers what Wallhaven misses. +// +// Wallhaven is clean and filterable, but its coverage of recent releases is patchy: Atomfall had none +// there and 27 wallpapers here. This is an aggregator of user uploads, so it picks new games up fastest — +// and its role is exactly Khinsider's role in music: "dirty but wide", scraped, isolated, and deletable. +// Losing this file would cost some background choices and nothing else. +// +// Like Wallhaven it searches WALLPAPERS, not games: it takes no part in finding candidates and nothing +// merges. All it answers is "backgrounds for this title", from the English name (its albums and titles +// are English), reusing wallhaven.ts's fallback cascade for the rare query that finds nothing. +// +// There is no API. This is a scraper of two page shapes, and three of its facts decide the code: +// +// * a STRONG match answers 302 straight to the album page (`?q=atomfall` → `/atomfall-wallpapers`), and +// the http client follows redirects silently without reporting the final URL. So the same parse must +// accept both shapes: no album list but `<img class="wimg">` present means the album page is already +// in hand — the games with the best coverage are precisely the ones that redirect; +// * every wallpaper states its own `width`/`height` in the markup, so portrait shots are dropped and the +// tiles are sized without downloading a byte; +// * the thumbnail and the full size are ONE file (the `/download/…` endpoint serves the identical +// bytes), which is why an offer carries the same URL twice — the tile the gallery shows IS what +// applying it downloads again. +// +// File names follow no fixed shape — `/wp/wp123.webp`, `wp123.jpg`, `/wp/iee9mCb.jpg` — so URLs and +// extensions are taken from the markup only, never rebuilt from a template. +import { type ArtworkKind, type MetadataResult } from '../../shared/types'; +import { IMAGE_EXTENSIONS } from '../asset-reader'; +import { + type ArtworkOffer, + type ArtworkOffers, + type ArtworkRequest, + type GameCandidateRef, + type MetadataProvider, +} from './provider'; +import { type HttpClient } from './http'; +import { isLatinTitle, searchTerms } from './wallhaven'; + +const ORIGIN = 'https://wallpapercave.com'; +/** + * How many albums one page of the gallery opens. Each is a separate page fetch, and the ranked top of + * the list is where a game's own albums sit — the rest are other games that merely share a word. "Load + * more" opens the next three, which is what keeps a deep search free until it is asked for. + */ +const ALBUMS_PER_PAGE = 3; +/** + * Slug fragments that mark a phone album. Matched as SUBSTRINGS rather than as words on purpose: the + * live listing carries `android-`, `smartphone-`, `cellphone-` and `-4k-phone-`, and `phone` catches all + * of those at once where a word match would miss `cellphone`. This is a traffic saver, not the + * correctness net — that is the portrait filter below, which works per FILE. + */ +const MOBILE_MARKERS: readonly string[] = ['phone', 'android', 'iphone', 'mobile']; +/** + * Where "big enough" stops and "needlessly heavy" begins. Here the tile IS the full-size file, so a 4K + * PNG (measured: 9.1 MB) costs its full weight twice over — once on the Deck's Wi-Fi and once in the + * service's thumbnail cache, which is bounded by ENTRIES rather than by bytes. The Deck's panel is + * 1280x800, so nothing above this is visibly better; such files are offered last rather than dropped. + */ +const PREFERRED_MAX_AREA = 2560 * 1440; + +export function searchUrl(term: string): string { + return `${ORIGIN}/search?q=${encodeURIComponent(term)}`; +} + +export function albumUrl(pathname: string): string { + return `${ORIGIN}${pathname}`; +} + +/** One album as the search page lists it: its own path, its name, and how many wallpapers it holds. */ +export interface CaveAlbum { + readonly path: string; + readonly title: string; + readonly count: number; +} + +/** One picture as an album page states it — dimensions included, which is what makes the filters free. */ +export interface CaveWallpaper { + readonly url: string; + /** The last path segment. Two albums genuinely share files, and this is what dedupes them. */ + readonly file: string; + readonly width?: number; + readonly height?: number; +} + +/** The five entities these pages actually carry. A full HTML decoder would be overkill, as on Khinsider. */ +function decodeEntities(text: string): string { + return text + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll(''', "'") + .replaceAll(' ', ' '); +} + +/** + * One double-quoted attribute of a tag, or undefined. Deliberately takes the FIRST occurrence: album + * links carry `title` twice in a single tag, and reading both would offer the album twice. + */ +function attribute(tag: string, name: string): string | undefined { + const match = new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`, 'i').exec(tag); + return match?.[1] === undefined ? undefined : decodeEntities(match[1]); +} + +/** A `src` as an absolute URL, whichever of the three forms the markup used. */ +export function absoluteUrl(src: string): string { + if (/^https?:\/\//i.test(src)) return src; + if (src.startsWith('//')) return `https:${src}`; + return src.startsWith('/') ? `${ORIGIN}${src}` : `${ORIGIN}/${src}`; +} + +/** The extension of a URL's file name, lower-cased — the only thing that says whether it is a picture. */ +function extensionOf(url: string): string { + const file = url.split(/[?#]/)[0]?.split('/').pop() ?? ''; + const at = file.lastIndexOf('.'); + return at === -1 ? '' : file.slice(at + 1).toLowerCase(); +} + +function fileNameOf(url: string): string { + return url.split(/[?#]/)[0]?.split('/').pop() ?? url; +} + +/** + * The albums a search page lists. Matched on the link itself rather than on the cards around it: the + * `/{slug}-wallpapers` shape is the stable part of this markup, the layout is not. + * + * The count comes from the link's own `title` ("27 wallpapers in Atomfall"), which is accurate — it was + * checked against the pictures actually on the page. A link without one still becomes an album, it just + * ranks below those that state a number. + */ +export function parseAlbums(html: string): readonly CaveAlbum[] { + const albums: CaveAlbum[] = []; + const seen = new Set<string>(); + for (const match of html.matchAll(/<a\b[^>]*>/gi)) { + const tag = match[0]; + const href = attribute(tag, 'href'); + if (href === undefined || !/^\/[^"?#]+-wallpapers\/?$/.test(href)) continue; + const path = href.endsWith('/') ? href.slice(0, -1) : href; + if (seen.has(path)) continue; + seen.add(path); + const stated = /^\s*(\d+)\s+wallpapers?\s+in\s+(.+?)\s*$/i.exec(attribute(tag, 'title') ?? ''); + const count = Number(stated?.[1] ?? 0); + albums.push({ + path, + title: stated?.[2] ?? slugTitle(path), + count: Number.isFinite(count) ? count : 0, + }); + } + return albums; +} + +/** `/the-witcher-3-wallpapers` as a name, for the links that state no title of their own. */ +function slugTitle(path: string): string { + return path + .replace(/^\//, '') + .replace(/-wallpapers$/, '') + .replaceAll('-', ' '); +} + +/** + * Every wallpaper an album page holds. There is no lazy AJAX here — one page carries the lot — and each + * `<img class="wimg">` states its own size, so nothing has to be downloaded to know what it is. + */ +export function parseWallpapers(html: string): readonly CaveWallpaper[] { + const wallpapers: CaveWallpaper[] = []; + for (const match of html.matchAll(/<img\b[^>]*>/gi)) { + const tag = match[0]; + if (!/\bwimg\b/i.test(attribute(tag, 'class') ?? '')) continue; + const src = attribute(tag, 'src'); + if (src === undefined || src.length === 0) continue; + const url = absoluteUrl(src); + if (!IMAGE_EXTENSIONS.includes(extensionOf(url))) continue; + const width = toDimension(attribute(tag, 'width')); + const height = toDimension(attribute(tag, 'height')); + wallpapers.push({ + url, + file: fileNameOf(url), + ...(width === undefined ? {} : { width }), + ...(height === undefined ? {} : { height }), + }); + } + return wallpapers; +} + +function toDimension(stated: string | undefined): number | undefined { + if (stated === undefined) return undefined; + const value = Number.parseInt(stated, 10); + return Number.isFinite(value) && value > 0 ? value : undefined; +} + +/** Whether a slug says "for a phone" — see MOBILE_MARKERS on why this is a substring match. */ +export function isMobileAlbum(pathname: string): boolean { + const slug = pathname.toLowerCase(); + return MOBILE_MARKERS.some((marker) => slug.includes(marker)); +} + +/** + * The albums worth opening, best first: phone albums out, then by how well the name matches what was + * searched for, then by how much the album holds. Sorting is stable, so albums that tie keep the order + * the site listed them in — its own relevance ranking, which is better than nothing to fall back on. + */ +export function rankAlbums(albums: readonly CaveAlbum[], query: string): readonly CaveAlbum[] { + const wanted = normalizeTitle(query); + const score = (album: CaveAlbum): number => { + const title = normalizeTitle(album.title); + if (title === wanted) return 0; + if (title.startsWith(wanted) || wanted.startsWith(title)) return 1; + return title.includes(wanted) || wanted.includes(title) ? 2 : 3; + }; + return albums + .filter((album) => !isMobileAlbum(album.path)) + .map((album) => ({ album, rank: score(album) })) + .sort((a, b) => (a.rank === b.rank ? b.album.count - a.album.count : a.rank - b.rank)) + .map((entry) => entry.album); +} + +/** Case, marks and punctuation out — the shallow normalization the candidate merge uses, for titles. */ +function normalizeTitle(title: string): string { + return title + .toLowerCase() + .replaceAll(/[™®©]/g, '') + .replaceAll(/[^\p{Letter}\p{Number}]+/gu, ' ') + .trim(); +} + +/** + * Wallpapers as offers: portrait ones dropped (they would be cropped to a ribbon behind a 16:10 screen), + * repeats across albums collapsed by file name, and the rest ordered so the sizes that suit the Deck are + * taken first — see PREFERRED_MAX_AREA. `thumbUrl` and `fullUrl` are the same file, deliberately. + */ +export function toArtworkOffers(wallpapers: readonly CaveWallpaper[]): readonly ArtworkOffer[] { + const seen = new Set<string>(); + return [...wallpapers] + .filter( + (paper) => + paper.width === undefined || paper.height === undefined || paper.height <= paper.width, + ) + .filter((paper) => { + if (seen.has(paper.file)) return false; + seen.add(paper.file); + return true; + }) + .sort((a, b) => + sizeGroup(a) === sizeGroup(b) ? withinGroup(a, b) : sizeGroup(a) - sizeGroup(b), + ) + .map((paper) => ({ + key: `wallpapercave:${paper.file}`, + kind: 'hero' as const, + provider: 'wallpapercave' as const, + ...(paper.width === undefined ? {} : { width: paper.width }), + ...(paper.height === undefined ? {} : { height: paper.height }), + thumbUrl: paper.url, + fullUrl: paper.url, + })); +} + +/** 0 — a size worth showing first, 1 — a picture that states none, 2 — heavier than the Deck can use. */ +function sizeGroup(paper: CaveWallpaper): number { + const area = areaOf(paper); + if (area === undefined) return 1; + return area <= PREFERRED_MAX_AREA ? 0 : 2; +} + +/** Inside a group: the largest of the suitable ones first, and the smallest of the oversized ones. */ +function withinGroup(a: CaveWallpaper, b: CaveWallpaper): number { + const first = areaOf(a); + const second = areaOf(b); + if (first === undefined || second === undefined) return 0; + return sizeGroup(a) === 0 ? second - first : first - second; +} + +function areaOf(paper: CaveWallpaper): number | undefined { + return paper.width === undefined || paper.height === undefined + ? undefined + : paper.width * paper.height; +} + +export interface WallpaperCaveDeps { + readonly http: HttpClient; + /** The game's ENGLISH name, for the same reason Wallhaven needs one — see WallhavenDeps.englishTitle. */ + readonly englishTitle: (ref: GameCandidateRef) => string | undefined; +} + +/** What one search turned up, kept so that "load more" continues it instead of searching afresh. */ +interface CaveSearch { + readonly albums: readonly CaveAlbum[]; + /** Set only for the 302 case: the album page itself came back, and there is no list to page through. */ + readonly wallpapers: readonly CaveWallpaper[]; +} + +export class WallpaperCaveProvider implements MetadataProvider { + readonly id = 'wallpapercave' as const; + /** One search per candidate, by candidate key — see CaveSearch. */ + private readonly searches = new Map<string, CaveSearch>(); + + constructor(private readonly deps: WallpaperCaveDeps) {} + + /** + * Backgrounds only. The search here is forgiving — a full Steam title with its edition tail finds the + * right album — so the cascade from wallhaven.ts is a fallback rather than the normal path. + */ + async artwork( + ref: GameCandidateRef, + kind: ArtworkKind, + request: ArtworkRequest, + signal?: AbortSignal, + ): Promise<MetadataResult<ArtworkOffers>> { + const nothing = { ok: true, value: { offers: [], hasMore: false } } as const; + if (kind !== 'hero') return nothing; + const options = signal === undefined ? undefined : { signal }; + const remembered = request.page > 0 ? this.searches.get(ref.key) : undefined; + const found = remembered ?? (await this.findAlbums(ref, options)); + if (found === undefined) return nothing; + if (!('albums' in found)) return found; + this.searches.set(ref.key, found); + // The 302 case: one album, all of it already parsed out of the answer to the search itself. The + // service keeps whatever did not fit on screen, so there is nothing left for a later page to fetch. + if (found.albums.length === 0) { + return request.page > 0 + ? nothing + : { ok: true, value: { offers: toArtworkOffers(found.wallpapers), hasMore: false } }; + } + return this.fromAlbums(found.albums, request.page, options); + } + + /** + * The search behind a gallery: the first term of the cascade that answers with anything, as either a + * list of albums or — after the 302 a strong match earns — the album page itself. Undefined means the + * title cannot be searched at all; a failure travels back as one. + */ + private async findAlbums( + ref: GameCandidateRef, + options: { readonly signal: AbortSignal } | undefined, + ): Promise<CaveSearch | MetadataResult<ArtworkOffers> | undefined> { + const title = this.deps.englishTitle(ref) ?? (isLatinTitle(ref.title) ? ref.title : undefined); + if (title === undefined) return undefined; + let failure: MetadataResult<ArtworkOffers> | undefined; + for (const term of searchTerms(title)) { + const page = await this.deps.http.text(searchUrl(term), options); + if (!page.ok) { + failure = page; + continue; + } + const albums = rankAlbums(parseAlbums(page.value), term); + if (albums.length > 0) return { albums, wallpapers: [] }; + const wallpapers = parseWallpapers(page.value); + if (wallpapers.length > 0) return { albums: [], wallpapers }; + } + return failure; + } + + /** The pictures of one page's worth of albums, with "more" meaning "there are albums left to open". */ + private async fromAlbums( + albums: readonly CaveAlbum[], + page: number, + options: { readonly signal: AbortSignal } | undefined, + ): Promise<MetadataResult<ArtworkOffers>> { + const from = page * ALBUMS_PER_PAGE; + const hasMore = albums.length > from + ALBUMS_PER_PAGE; + const wallpapers: CaveWallpaper[] = []; + let failure: MetadataResult<ArtworkOffers> | undefined; + for (const album of albums.slice(from, from + ALBUMS_PER_PAGE)) { + const opened = await this.deps.http.text(albumUrl(album.path), options); + if (!opened.ok) { + failure = opened; + continue; + } + wallpapers.push(...parseWallpapers(opened.value)); + } + const offers = toArtworkOffers(wallpapers); + if (offers.length === 0 && failure !== undefined) return failure; + return { ok: true, value: { offers, hasMore } }; + } +} diff --git a/src/main/notifications-model.ts b/src/main/notifications-model.ts new file mode 100644 index 00000000..ec8c25b9 --- /dev/null +++ b/src/main/notifications-model.ts @@ -0,0 +1,77 @@ +// The notification inbox's rules — pure functions over an immutable list, plus the one decision that +// says whether an arriving notification may make noise. No fs and no electron (NotificationsStore owns +// the bytes, NotificationsService owns the wiring), so all of it is unit-testable — the same split +// library-index.ts makes for the carousel's history. +// +// This is an INBOX, not a journal: pressing a notification removes it (the spec's requirement), so the +// list only ever holds what the user has not dealt with yet. That is also why the cap below can be this +// small — "what did I install last week" is the play history's question, not this list's. +import { type AppNotification } from '../shared/types'; + +/** How many notifications the inbox keeps. Beyond this the OLDEST are dropped. */ +export const MAX_NOTIFICATIONS = 30; + +/** + * Appends one notification and evicts from the front once the list is over the cap. `items` is kept in + * ASCENDING `at` order (the newest at the end), which is the order the popup lists them in. + */ +export function addNotification( + items: readonly AppNotification[], + next: AppNotification, +): readonly AppNotification[] { + const appended = [...items, next]; + return appended.length <= MAX_NOTIFICATIONS + ? appended + : appended.slice(appended.length - MAX_NOTIFICATIONS); +} + +/** Drops one notification by id (the user pressed it, or it was acted on). Unknown id → unchanged. */ +export function dismissNotification( + items: readonly AppNotification[], + id: string, +): readonly AppNotification[] { + return items.filter((item) => item.id !== id); +} + +/** + * Marks the whole inbox read — what opening the popup means. An already-read entry keeps its identity, + * so a caller can tell "nothing changed" from the result and skip a pointless write and push. + */ +export function markRead(items: readonly AppNotification[]): readonly AppNotification[] { + return items.map((item) => (item.read ? item : { ...item, read: true })); +} + +export function unreadCount(items: readonly AppNotification[]): number { + return items.reduce((count, item) => (item.read ? count : count + 1), 0); +} + +/** + * Everything that decides whether a notification may make noise right now. All three facts are main's + * own: whether the user has TOUCHED anything recently is deliberately not among them — a launcher + * sitting in front of someone who has not pressed a button for a minute is still a launcher they are + * looking at, and treating that as absence held back notifications they would have seen. + */ +export interface PresenceInput { + /** The launcher window is on screen (not hidden to the tray, not minimized). */ + readonly windowVisible: boolean; + /** …and it is the foreground window (not sitting behind whatever the user is actually using). */ + readonly windowFocused: boolean; + readonly gameRunning: boolean; +} + +/** + * How a notification reaches the user: + * • `live` — the launcher is in front of them: a toast plus the sound, and seeing it IS reading it; + * • `deferred` — the window is hidden or behind something: the notification is filed unread and its + * toast waits until the launcher comes back to the front (coming back does not mark it read — only + * opening the popup does). This is the case a plate would otherwise be shown to nobody and marked + * read for it; + * • `muted` — a game is running: the launcher is behind it, so it stays quiet and settles up with a + * single "N unread" plate once the game exits. + */ +export type Delivery = 'live' | 'deferred' | 'muted'; + +export function deliveryFor(presence: PresenceInput): Delivery { + if (presence.gameRunning) return 'muted'; + return presence.windowVisible && presence.windowFocused ? 'live' : 'deferred'; +} diff --git a/src/main/notifications-store.ts b/src/main/notifications-store.ts new file mode 100644 index 00000000..64eb503a --- /dev/null +++ b/src/main/notifications-store.ts @@ -0,0 +1,160 @@ +// The notification inbox on disk — notifications.json in %APPDATA%/<app>/. Built to AppSettingsStore's +// shape: a zod schema whose every field carries a `.default(…)` (so an older or half-written file +// migrates instead of resetting the lot), a safe read through readJsonValidated, an atomic write, and a +// promise queue that serializes read-modify-writes — two installs finishing at once would otherwise both +// read the same list and the second would clobber the first. +// +// Electron-free on purpose (baseDir is injected rather than taken from app.getPath): it is what makes +// this testable, and it keeps the module off the daemon's forbidden-import radar. +import path from 'node:path'; +import fse from 'fs-extra'; +import { z } from 'zod'; +import { type AppNotification } from '../shared/types'; +import { readJsonValidated, writeJsonAtomic } from './json-store'; + +const notificationSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('update-ready'), + id: z.string(), + at: z.number(), + read: z.boolean(), + version: z.string(), + }), + z.object({ + kind: z.literal('game-installed'), + id: z.string(), + at: z.number(), + read: z.boolean(), + gameId: z.string(), + gameTitle: z.string(), + }), + z.object({ + kind: z.literal('game-uninstalled'), + id: z.string(), + at: z.number(), + read: z.boolean(), + gameId: z.string(), + gameTitle: z.string(), + }), + z.object({ + kind: z.literal('game-added-deferred'), + id: z.string(), + at: z.number(), + read: z.boolean(), + gameTitle: z.string(), + }), + z.object({ + kind: z.literal('game-moved-deferred'), + id: z.string(), + at: z.number(), + read: z.boolean(), + gameTitle: z.string(), + }), + z.object({ + kind: z.literal('game-move-save-skipped'), + id: z.string(), + at: z.number(), + read: z.boolean(), + gameTitle: z.string(), + }), + z.object({ + kind: z.literal('game-move-duplicate'), + id: z.string(), + at: z.number(), + read: z.boolean(), + gameTitle: z.string(), + }), + z.object({ + kind: z.literal('settings-write-failed'), + id: z.string(), + at: z.number(), + read: z.boolean(), + }), +]); + +const inboxSchema = z.object({ + schemaVersion: z.literal(1), + // `.readonly()` so the parsed shape matches NotificationsFile, whose list is immutable like every + // other snapshot passed around here (the model returns new arrays, it never edits one in place). + items: z.array(notificationSchema).readonly().default([]), + /** + * The last app version a "ready to install" notification was written for. PERSISTED, not in-memory: + * the update check runs every 6 hours, so a session that outlives one check would notify about the + * same downloaded version again after the user cleared the inbox. + */ + lastNotifiedUpdateVersion: z.string().nullable().default(null), +}); + +/** The whole file: the inbox plus the update-dedup marker. */ +export interface NotificationsFile { + readonly schemaVersion: 1; + readonly items: readonly AppNotification[]; + readonly lastNotifiedUpdateVersion: string | null; +} + +export const EMPTY_NOTIFICATIONS: NotificationsFile = { + schemaVersion: 1, + items: [], + lastNotifiedUpdateVersion: null, +}; + +export class NotificationsStore { + private readonly filePath: string; + // Serializes every WRITE, exactly as AppSettingsStore does: notifications are produced by + // fire-and-forget callers (an install finishing, an update landing) that never await each other. + private tail: Promise<void> = Promise.resolve(); + + /** @param baseDir where notifications.json lives (the GUI passes app.getPath('userData')). */ + constructor(private readonly baseDir: string) { + this.filePath = path.join(baseDir, 'notifications.json'); + } + + /** + * Runs `op` after the current write chain drains, then chains the next writer behind it. The caller + * gets `op`'s real result; the chain TAIL swallows rejections so one failed write can't poison the + * queue (the same arrangement AppSettingsStore documents). + */ + private enqueue<T>(op: () => Promise<T>): Promise<T> { + const result = this.tail.then(op); + this.tail = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + /** Reads the inbox; an empty one when the file is missing, unreadable or invalid (warned by the store). */ + read(): Promise<NotificationsFile> { + return readJsonValidated(this.filePath, inboxSchema, EMPTY_NOTIFICATIONS); + } + + /** The actual atomic write — called ONLY from inside a queued op, so it never enqueues (deadlock). */ + private async persist(next: NotificationsFile): Promise<void> { + await fse.ensureDir(this.baseDir); + await writeJsonAtomic(this.filePath, next); + } + + /** + * Read-modify-write as ONE queued op: `change` receives the file as it is on disk right now and + * returns what it should become. Concurrent callers therefore chain instead of racing, and each sees + * the previous one's result. + */ + update(change: (current: NotificationsFile) => NotificationsFile): Promise<NotificationsFile> { + return this.enqueue(async () => { + // The read is DIRECT (not enqueue): it already runs inside a queued op, and enqueuing it here + // would wait on the very op it runs inside. + const current = await this.read(); + const next = change(current); + await this.persist(next); + return next; + }); + } + + /** + * Resolves once every queued write has settled — awaited before quitAndInstall, so an inbox write is + * not torn in half by the process exit. Never rejects (the tail already swallows rejections). + */ + flush(): Promise<void> { + return this.tail; + } +} diff --git a/src/main/notifications.ts b/src/main/notifications.ts new file mode 100644 index 00000000..6ba1ff5c --- /dev/null +++ b/src/main/notifications.ts @@ -0,0 +1,227 @@ +// The notification service: the inbox's owner in main. Every source of events (an update landing, an +// install or an uninstall finishing) hands it a NotificationInput; it decides whether the launcher may +// make noise about it right now (see notifications-model.ts), files it, and pushes both the new list and +// — when there is one to show — the toast to the renderer. +// +// It registers its OWN IPC (like UpdaterService and GameConfigService do), rather than being wired +// through ControllerDeps: the project's rule is "one channel, exactly one registrar", not "every channel +// in the controller". +import { randomUUID } from 'node:crypto'; +import { ipcMain } from 'electron'; +import { IPC, type AppNotification, type NotificationInput } from '../shared/types'; +import { log } from './logger'; +import { + addNotification, + deliveryFor, + dismissNotification, + markRead, + unreadCount, + type PresenceInput, +} from './notifications-model'; +import { type NotificationsStore } from './notifications-store'; + +/** + * 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. + */ +const MAX_REPLAYED_TOASTS = 2; + +export interface NotificationsDeps { + readonly store: NotificationsStore; + /** Everything that decides whether the launcher may make noise right now (main owns the pieces). */ + presence(): PresenceInput; + /** Sends to the launcher window; a no-op while there is no window (or it is destroyed). */ + push(channel: string, payload: unknown): void; +} + +export class NotificationsService { + // The inbox, in memory and authoritative: the renderer renders from what is pushed here, and the file + // is this list written down. Loaded once in init(). + private items: readonly AppNotification[] = []; + private lastNotifiedUpdateVersion: string | null = null; + /** Whether this run already told the user their settings cannot be saved — see notifySettingsWriteFailed. */ + private settingsWriteFailureReported = false; + // Toasts that arrived while the user was away, waiting for them to come back. Held by ID rather than + // by value: an entry the user cleared in the meantime must not resurface as a plate. + private deferredIds: readonly string[] = []; + // A post-game "N unread" summary that could not be shown yet (the window was still hidden when the + // game exited). It rides the same queue, and wins over the individual plates. + private summaryPending = false; + + constructor(private readonly deps: NotificationsDeps) {} + + /** Registers the IPC and loads the inbox off disk. Called once at bootstrap. */ + async init(): Promise<void> { + this.registerIpc(); + const file = await this.deps.store.read(); + this.items = file.items; + this.lastNotifiedUpdateVersion = file.lastNotifiedUpdateVersion; + } + + /** The current inbox (oldest first) — the invoke seed and the payload of every push. */ + snapshot(): readonly AppNotification[] { + return this.items; + } + + /** + * Files one notification and delivers it per the presence rule: a plate now (`live`), a plate held + * until the user is back (`deferred`), or silence while a game runs (`muted` — the summary after the + * game covers it). Fire-and-forget: the sources are success paths of long sequences, and none of them + * has anything to do with a failed disk write. + */ + notify(input: NotificationInput): void { + const item: AppNotification = { id: randomUUID(), at: Date.now(), read: false, ...input }; + const delivery = deliveryFor(this.deps.presence()); + this.items = addNotification(this.items, item); + this.persist(); + this.pushList(); + log.info(`[notifications] ${item.kind} → ${delivery}`); + if (delivery === 'live') { + // The plate is shown, and that is ALL it does: a notification stays unread until the popup is + // opened. A toast is up for a few seconds and the user may be looking at the game they just + // installed rather than at the corner — treating it as read left the More item with no dot at + // exactly the moment there was something new to tell them about. + this.deps.push(IPC.notificationsToast, { kind: 'item', item }); + return; + } + if (delivery === 'deferred') this.deferredIds = [...this.deferredIds, item.id]; + } + + /** + * "An update is downloaded and will apply on the next restart." Deduplicated by version through the + * PERSISTED marker: the periodic check runs every 6 hours and keeps reporting the same downloaded + * version, so an in-memory guard would produce a fresh notification after every "Clear all". + */ + notifyUpdateReady(version: string): void { + if (this.lastNotifiedUpdateVersion === version) return; + this.lastNotifiedUpdateVersion = version; + this.notify({ kind: 'update-ready', version }); + } + + /** + * "Your settings could not be saved." Deduplicated for the RUN, in memory: whatever makes the file + * unwritable (a read-only attribute, an ACL from an install under another account) does not heal + * itself, so every later toggle fails the same way — and one dragged volume slider alone is dozens of + * writes. In-memory rather than persisted, unlike the update marker: the next launch may well be able + * to write again, and then there is nothing to say. + */ + notifySettingsWriteFailed(): void { + if (this.settingsWriteFailureReported) return; + this.settingsWriteFailureReported = true; + this.notify({ kind: 'settings-write-failed' }); + } + + /** + * The launcher is in front of the user again (it was shown, or it regained focus). That is what + * releases the toasts which arrived while it was away. Watching them go past does not mark them read + * — no plate ever does — so the dot beside the More item stays until the popup is opened. + */ + onLauncherFronted(): void { + this.releaseDeferred(); + } + + /** + * A game just ended. Report what piled up while it ran as ONE plate — but only if the launcher is + * actually in front of the user by now; on the desktop the window can still be hidden at this moment, + * and a plate nobody sees is a notification lost. + */ + announceUnreadAfterGame(): void { + if (unreadCount(this.items) === 0) return; + if (deliveryFor(this.deps.presence()) !== 'live') { + this.summaryPending = true; + return; + } + this.pushSummary(); + } + + /** The popup was opened: the whole inbox has been seen. The only gesture that clears an unread. */ + markRead(): void { + const next = markRead(this.items); + // markRead returns the SAME object for an entry it did not touch, so an all-identical result means + // nothing actually changed — no write, no push. + if (next.every((item, at) => item === this.items[at])) return; + this.items = next; + this.persist(); + this.pushList(); + } + + /** The user pressed a notification — pressing one is what removes it (this is an inbox, not a log). */ + dismiss(id: string): void { + const next = dismissNotification(this.items, id); + if (next.length === this.items.length) return; + this.items = next; + this.persist(); + this.pushList(); + } + + clearAll(): void { + if (this.items.length === 0) return; + this.items = []; + this.deferredIds = []; + this.summaryPending = false; + this.persist(); + this.pushList(); + } + + /** Drains in-flight writes — awaited before quitAndInstall, beside the settings store's own flush. */ + flush(): Promise<void> { + return this.deps.store.flush(); + } + + // ── IPC ────────────────────────────────────────────────────────────────── + + private registerIpc(): void { + ipcMain.handle(IPC.notificationsRequest, (): readonly AppNotification[] => this.items); + ipcMain.on(IPC.notificationsDismiss, (_event, id: unknown) => { + if (typeof id === 'string') this.dismiss(id); + }); + ipcMain.on(IPC.notificationsClear, () => this.clearAll()); + ipcMain.on(IPC.notificationsMarkRead, () => this.markRead()); + } + + // ── Delivery plumbing ──────────────────────────────────────────────────── + + private pushList(): void { + this.deps.push(IPC.notificationsUpdate, this.items); + } + + private pushSummary(): void { + const count = unreadCount(this.items); + if (count === 0) return; + this.deps.push(IPC.notificationsToast, { kind: 'unread-summary', count }); + } + + /** + * Plays back what arrived while the user was away. A handful of plates is a fair summary of "these + * three things happened"; more than that — or anything a finished game left behind — collapses into + * the single "N unread" plate instead. + */ + private releaseDeferred(): void { + const pending = this.deferredIds + .map((id) => this.items.find((item) => item.id === id)) + .filter((item): item is AppNotification => item !== undefined); + const summary = this.summaryPending; + if (pending.length === 0 && !summary) return; + this.deferredIds = []; + this.summaryPending = false; + if (summary || pending.length > MAX_REPLAYED_TOASTS) { + this.pushSummary(); + return; + } + for (const item of pending) { + this.deps.push(IPC.notificationsToast, { kind: 'item', item }); + } + } + + // Writes the in-memory inbox down. Fire-and-forget behind the store's queue: a failed write costs the + // user a notification across a restart, never the notification they are looking at right now. + private persist(): void { + void this.deps.store + .update((current) => ({ + ...current, + items: this.items, + lastNotifiedUpdateVersion: this.lastNotifiedUpdateVersion, + })) + .catch((cause: unknown) => log.warn('[notifications] failed to persist the inbox:', cause)); + } +} diff --git a/src/main/pc-library.ts b/src/main/pc-library.ts new file mode 100644 index 00000000..34bb593c --- /dev/null +++ b/src/main/pc-library.ts @@ -0,0 +1,191 @@ +// The PC library — games that already live on THIS machine's disk, kept in `<userData>/pc-games/`. +// +// The directory is laid out exactly like a card, and that is the whole design: `game.json` in the root, +// `assets/` for the copied art and music, `saves/<id>/` standing in for the card's save copy. Everything +// downstream (manifest reading, anti-traversal, AssetReader, the history carousel, save-sync) therefore +// treats it as a card that is always inserted, with no parallel pipeline to keep in step. The one +// difference is the manifest source: a local game names its executable by ABSOLUTE path (see +// ManifestSource / the `pc` block in manifest.ts). +// +// Electron-free by construction (`baseDir` is injected like AppSettingsStore's) — so it stays importable +// 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 { log } from './logger'; +import { describe } from './util'; + +export interface PcLibraryDeps { + /** The app data directory (`app.getPath('userData')` in main). The library lives under it. */ + readonly baseDir: string; +} + +/** What one read of the library yields. */ +export interface PcLibraryRead { + readonly manifests: readonly ResolvedManifest[]; + /** + * False when `game.json` exists but could not be read as a manifest at all. The library still reports + * itself empty (the launcher must start), but that emptiness is a symptom, not the truth — so the + * caller must NOT act on it destructively (see gcOrphans, which would otherwise wipe every asset of a + * library whose manifest merely lost a closing brace). + */ + readonly intact: boolean; +} + +/** Characters an imported asset's file name may keep. Everything else collapses into `-`. */ +const SAFE_ASSET_NAME = /[^A-Za-z0-9._-]+/g; + +/** + * What an import may be, and how big. Both 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 — the one place every import passes through (see the plan, Р5.1/Р5.2). + * + * The sizes are chosen with room to spare over what real artwork and music weigh: a 4K PNG cover is a few + * megabytes, a lossless album track tens of them. They exist to stop a disk image being copied into + * `<userData>` by a mistyped path, not to police the user's files. + */ +export type ImportKind = 'image' | 'audio'; +const MAX_IMPORT_BYTES: Readonly<Record<ImportKind, number>> = { + image: 32 * 1024 * 1024, + audio: 64 * 1024 * 1024, +}; + +export class PcLibraryStore { + /** The library root — a card root in every respect but its manifest source. */ + readonly root: string; + private readonly assetsDir: string; + + constructor(deps: PcLibraryDeps) { + this.root = path.join(deps.baseDir, PC_LIBRARY_DIRNAME); + this.assetsDir = path.join(this.root, 'assets'); + } + + /** Creates the library skeleton. `game.json` is NOT created: its absence means "no local games yet". */ + async init(): Promise<void> { + await fse.ensureDir(this.root); + await fse.ensureDir(this.assetsDir); + } + + /** + * Reads every local game. A structurally broken `game.json` (unparsable, or a top-level that is neither + * an object nor an array) is warned about and yields an EMPTY library rather than an error: unlike a + * card, this file is app state on the startup path — letting it fail would take the launcher's whole + * library, including the history, down with it. The user still sees the real reason on the Customize + * screen, whose validation reports it against the text. + */ + async read(env: ManifestEnv, resolveInstallDir: InstallDirResolver): Promise<PcLibraryRead> { + const result = await readManifests(this.root, env, resolveInstallDir, { source: 'pc' }); + if (!result.ok) { + log.warn(`[pc-library] ${MANIFEST_FILENAME} is unreadable, treating the library as empty: ${result.message}`); + return { manifests: [], intact: false }; + } + return { manifests: result.manifests, intact: true }; + } + + /** Whether a `game.json` exists at all — its absence is how "no local games yet" is spelled. */ + async hasManifest(): Promise<boolean> { + return fse.pathExists(this.manifestPath()); + } + + /** Removes `game.json` — how "the last local game was deleted" is spelled (an empty library). */ + async removeManifest(): Promise<void> { + await fse.remove(this.manifestPath()); + } + + /** + * Copies a picked image/audio file INTO the library and returns its root-relative path (forward + * slashes, ready for game.json). Copying rather than referencing is what keeps a local game's artwork + * alive after the user moves or deletes the original — and it keeps every asset path relative, so + * `resolveInside` and the AssetReader need no PC-specific branch at all. + * + * The name is sanitized (it comes from the user's filesystem) and de-duplicated with a `-2`, `-3`… + * suffix, so importing two different `hero.jpg` files never overwrites the first game's background. + * + * Three refusals before anything is copied — `kind` decides the allowed extensions, `lstat` rejects a + * symlink (it would copy whatever it points at, from anywhere), and the size cap keeps a mistyped path + * from filling `<userData>`. `allowedExtensions` comes from the caller so this module stays free of the + * asset-reader import (and of electron), per the daemon rule in CLAUDE.md. + */ + async importAsset( + absolutePath: string, + kind: ImportKind, + allowedExtensions: readonly string[], + ): Promise<string> { + const extension = path.extname(absolutePath).replace(/^\./, '').toLowerCase(); + if (!allowedExtensions.includes(extension)) { + throw new Error(`refusing to import "${absolutePath}": not a ${kind} extension`); + } + const stats = await fse.lstat(absolutePath); + if (stats.isSymbolicLink()) { + throw new Error(`refusing to import "${absolutePath}": symbolic link`); + } + if (!stats.isFile()) { + throw new Error(`refusing to import "${absolutePath}": not a regular file`); + } + if (stats.size > MAX_IMPORT_BYTES[kind]) { + throw new Error(`refusing to import "${absolutePath}": larger than ${MAX_IMPORT_BYTES[kind]} bytes`); + } + await fse.ensureDir(this.assetsDir); + const name = await this.uniqueAssetName(path.basename(absolutePath)); + await fse.copy(absolutePath, path.join(this.assetsDir, name), { overwrite: false, errorOnExist: true }); + return `assets/${name}`; + } + + /** This game's save backup directory — the local stand-in for `saveOnCard` (see manifest.ts). */ + savesDir(id: string): string { + return path.join(this.root, 'saves', id); + } + + /** + * Deletes assets no manifest references any more (art of a game the user removed). `saves/` is NEVER + * touched: a game deleted from the library — or from the disk — must keep its progress, which is the + * whole point of backing it up here. + */ + async gcOrphans(referenced: readonly string[]): Promise<void> { + const keep = new Set(referenced.map((relative) => path.basename(relative.replaceAll('\\', '/')))); + let names: readonly string[]; + try { + names = await fse.readdir(this.assetsDir); + } catch (cause) { + if (!isNotFound(cause)) log.warn('[pc-library] cannot list assets for cleanup:', describe(cause)); + return; + } + for (const name of names) { + if (keep.has(name)) continue; + try { + await fse.remove(path.join(this.assetsDir, name)); + } catch (cause) { + log.warn(`[pc-library] failed to remove the orphaned asset "${name}":`, describe(cause)); + } + } + } + + private manifestPath(): string { + return path.join(this.root, MANIFEST_FILENAME); + } + + /** A sanitized, collision-free file name inside `assets/`. */ + private async uniqueAssetName(original: string): Promise<string> { + const sanitized = original.replace(SAFE_ASSET_NAME, '-').replace(/^[-.]+/, ''); + const base = sanitized.length > 0 ? sanitized : 'asset'; + const extension = path.extname(base); + const stem = base.slice(0, base.length - extension.length); + let candidate = base; + for (let suffix = 2; await fse.pathExists(path.join(this.assetsDir, candidate)); suffix += 1) { + candidate = `${stem}-${suffix}${extension}`; + } + return candidate; + } +} + +/** 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 38e7e256..6e84bd75 100644 --- a/src/main/pc-store.ts +++ b/src/main/pc-store.ts @@ -7,10 +7,11 @@ import path from 'node:path'; import fse from 'fs-extra'; import { z } from 'zod'; -import { type Stats } from '../shared/types'; +import { type ResolvedManifest, type Stats } from '../shared/types'; import { readJsonValidated, writeJsonAtomic } from './json-store'; import { type SyncState } from './save-sync'; import { log } from './logger'; +import { describe } from './util'; // Exported so stats.ts can build the per-id card-stats map schema (v2) on top of the same single-game // shape — one source of truth for what a valid Stats record is. @@ -56,6 +57,26 @@ const syncStateSchema = z.object({ syncedAt: z.number(), }); +/** + * Which pairing a sync baseline describes: `card` — the inserted card ↔ the PC save folder (the original + * and only slot); `pc` — a local game's own backup in the PC library ↔ that same folder. See syncStatePath. + */ +export type SyncSlot = 'card' | 'pc'; + +/** + * Whether a game may RECEIVE a deferred PC→SD flush (the snapshot taken when a card was yanked mid-game). + * + * The `source` half is the load-bearing one. A local game has a `saveOnCardPath` too — its backup inside + * the PC library — so the plain "has a card side?" test would happily pour a snapshot meant for the real + * card into that backup and then clear the queue, silently destroying the progress the next card + * insertion was supposed to receive. A pending snapshot belongs to a CARD, and only a card may take it. + */ +export function acceptsPendingFlush( + manifest: Pick<ResolvedManifest, 'source' | 'saveOnCardPath'>, +): boolean { + return manifest.source === 'card' && manifest.saveOnCardPath !== undefined; +} + export class PcStore { private readonly statsDir: string; private readonly pendingDir: string; @@ -132,8 +153,24 @@ export class PcStore { await fse.remove(this.pendingEntryDir(id)); } - private syncStatePath(id: string): string { - return path.join(this.syncStateDir, `${id}.json`); + /** + * Where a game's last-sync baseline lives, per SLOT. A game can be synced against two different + * partners — the card it came on, and (for a local game) the backup Playhook keeps — and each pair has + * its own history: sharing one baseline would make every sync look like "the other side changed", + * i.e. a permanent false conflict resolved by LWW. + * + * The PC slot is a SUBDIRECTORY rather than a `<id>.pc.json` suffix on purpose: `id` may contain dots + * (manifest.ts), so a card game called `hades.pc` would otherwise collide with the PC slot of `hades`. + */ + private syncStatePath(id: string, slot: SyncSlot): string { + return slot === 'pc' + ? path.join(this.syncStateDir, 'pc', `${id}.json`) + : path.join(this.syncStateDir, `${id}.json`); + } + + /** Whether a CARD baseline exists for this game — i.e. whether a card carrying it was ever synced here. */ + async hasCardSyncState(id: string): Promise<boolean> { + return fse.pathExists(this.syncStatePath(id, 'card')); } /** @@ -141,10 +178,10 @@ export class PcStore { * an update → the caller falls back to the deterministic phase direction) or corrupted (logged, then * treated as absent so a damaged file can't wedge sync — the next successful sync rewrites it). */ - async readSyncState(id: string): Promise<SyncState | null> { + async readSyncState(id: string, slot: SyncSlot = 'card'): Promise<SyncState | null> { let raw: unknown; try { - raw = await fse.readJson(this.syncStatePath(id)); + 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') { @@ -160,8 +197,24 @@ export class PcStore { return parsed.data; } - async writeSyncState(id: string, state: SyncState): Promise<void> { - await fse.ensureDir(this.syncStateDir); - await writeJsonAtomic(this.syncStatePath(id), state); + async writeSyncState(id: string, state: SyncState, slot: SyncSlot = 'card'): Promise<void> { + const target = this.syncStatePath(id, slot); + await fse.ensureDir(path.dirname(target)); + await writeJsonAtomic(target, state); + } + + /** + * Drops a game's sync baseline. Used when a local game moves to a card (Р2.5): 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 + * for a game whose saves were never synced). + */ + async removeSyncState(id: string, slot: SyncSlot = 'card'): Promise<void> { + try { + await fse.remove(this.syncStatePath(id, slot)); + } catch (cause) { + log.warn(`[sync-state] failed to remove the "${slot}" baseline for "${id}":`, describe(cause)); + } } } diff --git a/src/main/platform/app-bundle.darwin.ts b/src/main/platform/app-bundle.darwin.ts new file mode 100644 index 00000000..65ca32aa --- /dev/null +++ b/src/main/platform/app-bundle.darwin.ts @@ -0,0 +1,89 @@ +// Resolving the real executable inside a macOS `.app` bundle (Д2). A `.app` is a DIRECTORY, so it cannot +// be spawned — the binary lives at `Contents/MacOS/<CFBundleExecutable>`, 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. +// +// Info.plist may be XML or Apple's binary plist format. The XML case is parsed here (one key is all we +// need); a binary one is converted with `plutil -convert xml1 -o -` first. The parsing/path helpers are +// pure so they are unit-tested; only readInfoPlist touches fs/execFile. +// +// Paths are built with `path.posix` (CLAUDE.md): a bundle path is a macOS path and the suite runs on the +// Windows CI runner too. +import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import fse from 'fs-extra'; + +const execFileAsync = promisify(execFile); + +/** Magic header of Apple's binary plist format — an XML plist starts with `<?xml`/`<!DOCTYPE` instead. */ +const BINARY_PLIST_MAGIC = 'bplist00'; + +/** Whether a path names a `.app` bundle (case-insensitive, as HFS+/APFS are case-preserving by default). */ +export function isAppBundlePath(target: string): boolean { + return /\.app\/*$/i.test(target); +} + +/** `<bundle>/Contents/Info.plist` — the bundle's metadata file. */ +export function infoPlistPath(bundle: string): string { + return path.posix.join(bundle, 'Contents', 'Info.plist'); +} + +/** `<bundle>/Contents/MacOS/<executable>` — where the bundle's real binary lives. */ +export function bundleExecutablePath(bundle: string, executable: string): string { + return path.posix.join(bundle, 'Contents', 'MacOS', executable); +} + +/** The five XML entities a plist string may carry. Anything else is already literal text. */ +function decodeXmlEntities(value: string): string { + return value + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&'); +} + +/** + * The `CFBundleExecutable` value of an XML plist, or null when the key is absent (or the value is empty). + * A single-key regexp rather than a full XML parse on purpose: the file is Apple's own, the key is a flat + * top-level string, and a dependency-free reader keeps this module pure and testable. Pure. + */ +export function parseCFBundleExecutable(plistXml: string): string | null { + const match = /<key>\s*CFBundleExecutable\s*<\/key>\s*<string>([^<]*)<\/string>/.exec(plistXml); + if (match === null) return null; + const value = decodeXmlEntities(match[1] ?? '').trim(); + return value === '' ? null : value; +} + +/** Reads Info.plist as XML text, converting a binary plist with `plutil` first. Throws when unreadable. */ +async function readInfoPlist(bundle: string): Promise<string> { + const plist = infoPlistPath(bundle); + const raw = await fse.readFile(plist); + if (!raw.subarray(0, BINARY_PLIST_MAGIC.length).toString('latin1').startsWith(BINARY_PLIST_MAGIC)) { + return raw.toString('utf8'); + } + // Binary plist: `plutil` ships with macOS and writes the XML form to stdout (`-o -`). + const { stdout } = await execFileAsync('plutil', ['-convert', 'xml1', '-o', '-', plist]); + return stdout; +} + +/** + * The absolute path of the binary a `.app` bundle launches, or null when the bundle carries no readable + * `CFBundleExecutable` or the named binary is missing. Null (not a throw) so the caller can turn it into + * its own user-facing error alongside the other launch refusals. + */ +export async function resolveAppBundleExecutable(bundle: string): Promise<string | null> { + let xml: string; + try { + xml = await readInfoPlist(bundle); + } catch { + return null; + } + const executable = parseCFBundleExecutable(xml); + if (executable === null) return null; + // The value is a file NAME inside Contents/MacOS; a separator in it would point outside the bundle. + if (executable.includes('/') || executable.includes('\\')) return null; + const resolved = bundleExecutablePath(bundle, executable); + return (await fse.pathExists(resolved)) ? resolved : null; +} diff --git a/src/main/platform/darwin.ts b/src/main/platform/darwin.ts new file mode 100644 index 00000000..c2bf152e --- /dev/null +++ b/src/main/platform/darwin.ts @@ -0,0 +1,120 @@ +// macOS implementations of the platform services. What macOS supports is a deliberate subset: NATIVE mac +// games (a bare binary or a `.app` bundle) and Steam mode. Windows games (no Wine/CrossOver) and install +// 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 +// 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) +// SteamShortcuts → unsupported (Game Mode is a Steam Deck thing) +// RemovableMounter → no-op: macOS automounts removable volumes into /Volumes (Д9) +// resolveInstallDir → null: install mode is unsupported, so no card can resolve one +import os from 'node:os'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import type { + Platform, + PlatformDeps, + PowerBackend, + RemovableMounter, + SteamShortcuts, +} from './types'; +import { createDarwinProcessMonitor } from './process-monitor.darwin'; +import { createDarwinSteamLocator } from './steam-locator.darwin'; +import { createDarwinGameLauncher } from './game-launcher.darwin'; +import { createDarwinSavePathResolver } from './save-path.darwin'; +import { describe } from '../util'; +import type { Translator } from '../../shared/i18n/index'; + +const execFileAsync = promisify(execFile); + +// ── PowerBackend (`pmset` + System Events) ─────────────────────────────────── +// Sleep goes through `pmset sleepnow`, which needs no root — it changes no settings, it only suspends. +// 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 +// (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. + +/** osascript's error for "not authorized to send Apple events" (the TCC prompt was declined). */ +const NOT_AUTHORIZED_ERROR = '-1743'; + +/** Whether an osascript failure is the TCC refusal rather than a genuine command failure. */ +function isAppleEventsRefusal(cause: unknown): boolean { + const message = describe(cause); + return message.includes(NOT_AUTHORIZED_ERROR) || message.includes('Not authorized to send Apple events'); +} + +function createPowerBackend(getTranslator: () => Translator): PowerBackend { + return { + supported: true, + async run(action): Promise<void> { + const verb = action === 'shutdown' ? 'shut down' : 'restart'; + try { + await execFileAsync('osascript', ['-e', `tell application "System Events" to ${verb}`]); + } catch (cause) { + if (isAppleEventsRefusal(cause)) { + throw new Error(getTranslator()('errors.macPowerNotPermitted')); + } + throw cause; + } + }, + async suspend(): Promise<void> { + await execFileAsync('pmset', ['sleepnow']); + }, + }; +} + +// ── SteamShortcuts (unsupported) ───────────────────────────────────────────── +// Registering Playhook as a non-Steam game exists for the Steam Deck's Game Mode; macOS has no such mode. +// `supported: false` hides the tray item entirely, so these refusals are never surfaced — they exist so the +// interface stays total (identical in intent to the win32 stub). + +function createSteamShortcuts(): SteamShortcuts { + const unsupported = { ok: false, message: 'Steam shortcuts are not supported on macOS' } as const; + return { + supported: false, + addShortcut: () => Promise.resolve(unsupported), + removeShortcut: () => Promise.resolve(unsupported), + hasShortcut: () => Promise.resolve(false), + findForeignShortcuts: () => Promise.resolve([]), + writeArtwork: () => Promise.resolve(), + removeArtwork: () => Promise.resolve(), + }; +} + +// ── RemovableMounter (no-op) ───────────────────────────────────────────────── +// Д9: 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 { + return { mountAll: () => Promise.resolve() }; +} + +/** Assembles the macOS platform bundle. The launcher shares the `ps` monitor (liveness + force-kill). */ +export function createDarwinPlatform(deps: PlatformDeps): Platform { + const processMonitor = createDarwinProcessMonitor(); + return { + processMonitor, + steamLocator: createDarwinSteamLocator(), + steamShortcuts: createSteamShortcuts(), + gameLauncher: createDarwinGameLauncher({ + monitor: processMonitor, + getTranslator: deps.getTranslator, + }), + savePathResolver: createDarwinSavePathResolver({ + home: os.homedir(), + documents: deps.getDocuments(), + }), + powerBackend: createPowerBackend(deps.getTranslator), + removableMounter: createRemovableMounter(), + // Install mode is out of scope on macOS (see the header): null makes readManifests reject an + // install-mode card with the existing "install mode is unavailable" message instead of half-resolving it. + resolveInstallDir: () => null, + }; +} diff --git a/src/main/platform/game-launcher.darwin.ts b/src/main/platform/game-launcher.darwin.ts new file mode 100644 index 00000000..40848155 --- /dev/null +++ b/src/main/platform/game-launcher.darwin.ts @@ -0,0 +1,135 @@ +// macOS GameProcessLauncher: run a NATIVE mac game — a bare mach-o binary or a `.app` bundle (Д2). +// +// A `.app` is a directory, so it cannot be spawned; the real binary at `Contents/MacOS/<CFBundleExecutable>` +// is resolved first and spawned directly. `open -a` would be the obvious alternative and is deliberately +// NOT used: it returns immediately and hands back the pid of `open`, not of the game, which would break the +// pid tracking the whole launch flow is built on. +// +// 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 +// 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 { Translator } from '../../shared/i18n/index'; +import { isAppBundlePath, resolveAppBundleExecutable } from './app-bundle.darwin'; +import { delay } from '../util'; +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 + * game is still alive when the window closes. + */ +const GATEKEEPER_PROBE_MS = 500; + +/** Dependencies the darwin launcher closes over (the shared ProcessMonitor + the live translator). */ +export interface DarwinGameLauncherDeps { + /** The `ps` ProcessMonitor — used for liveness and the force-kill tree. */ + readonly monitor: ProcessMonitor; + /** The live translator (read per call so a language change applies to the next refusal). */ + readonly getTranslator: () => Translator; +} + +/** Whether a launch target is a Windows executable (which macOS cannot run — see the header). */ +function isWindowsExecutable(target: string): boolean { + return /\.exe$/i.test(target); +} + +/** + * Spawns a native mac binary and wraps it as a GameProcess. `detached: false` + `unref()` mirrors the win32 + * normal path: the child is not tied to our event loop, and tracking happens by pid through the monitor. + * + * The one darwin-specific step is the Gatekeeper probe: the promise resolves only after a short window, so + * a binary that syspolicyd killed on sight becomes a targeted error instead of a launch that silently never + * starts (the caller would otherwise wait out `launchTimeoutSec` and report a generic timeout). + */ +function spawnGameProcess( + file: string, + args: readonly string[], + cwd: string, + deps: DarwinGameLauncherDeps, +): Promise<GameProcess> { + return new Promise<GameProcess>((resolve, reject) => { + const child = spawn(file, [...args], { cwd, detached: false, stdio: 'ignore' }); + child.once('error', reject); + child.once('spawn', () => { + if (typeof child.pid !== 'number') { + reject(new Error('process started without a pid')); + return; + } + const pid = child.pid; + child.removeListener('error', reject); + let exit: { readonly code: number | null; readonly signal: NodeJS.Signals | null } | null = null; + child.once('exit', (code, signal) => { + exit = { code, signal }; + }); + child.unref(); + void delay(GATEKEEPER_PROBE_MS).then(() => { + // A quarantined/unsigned binary is killed by syspolicyd within milliseconds and always by signal — + // an ordinary early exit (a game that crashed on its own) reports a code instead and is left to the + // normal "did not start" path, which is the honest description of it. + if (exit !== null && exit.signal !== null) { + log.warn(`[launch] "${file}" was killed on start by signal ${exit.signal} — Gatekeeper?`); + reject(new Error(deps.getTranslator()('errors.macGameBlocked', { path: file }))); + return; + } + resolve({ + pid, + isAlive: () => (exit !== null ? Promise.resolve(false) : deps.monitor.isPidAlive(pid)), + kill: async () => { + // Reused-pid guard, as on win32: `running` outlives the real process by the exit debounce, so + // only kill while the pid is still ours. + if (await deps.monitor.isPidAlive(pid)) await deps.monitor.killTree(pid); + }, + dispose: () => {}, + }); + }); + }); + }); +} + +/** Builds the macOS GameProcessLauncher. */ +export function createDarwinGameLauncher(deps: DarwinGameLauncherDeps): GameProcessLauncher { + /** The refusal shared by every install-mode entry point (install mode is unsupported on macOS). */ + const refuseInstall = (): never => { + throw new Error(deps.getTranslator()('errors.macInstallUnsupported')); + }; + return { + async launchGame(manifest): Promise<GameProcess> { + 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 + // legitimate two-platform card that sets runAsAdmin for its Windows side. + log.warn(`[launch] runAsAdmin ignored on macOS (no elevation) id=${manifest.raw.id}`); + } + const target = manifest.executablePath; + if (isWindowsExecutable(target)) { + throw new Error(t('errors.macWindowsGame')); + } + let file = target; + if (isAppBundlePath(target)) { + const resolved = await resolveAppBundleExecutable(target); + if (resolved === null) { + throw new Error(t('errors.macAppBundleUnreadable', { path: target })); + } + file = resolved; + log.info(`[launch] app bundle "${target}" → "${file}"`); + } + log.info(`[launch] spawn id=${manifest.raw.id} exe="${file}"`); + return spawnGameProcess(file, manifest.raw.args, manifest.cwd, deps); + }, + // Install mode is out of scope on macOS: Windows installers cannot run, and `resolveInstallDir` returns + // null so an install-mode card is already rejected at manifest-read. These stay total and explicit. + launchInstaller: () => refuseInstall(), + prepareInstallDir: () => refuseInstall(), + launchUninstaller: () => refuseInstall(), + // 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, + // No Wine prefix on macOS — a game runs directly and leaves nothing of ours to clean up. + prefixCleanupDir: () => Promise.resolve(null), + }; +} diff --git a/src/main/platform/index.ts b/src/main/platform/index.ts index 0579253c..ad052657 100644 --- a/src/main/platform/index.ts +++ b/src/main/platform/index.ts @@ -1,12 +1,14 @@ -// Platform factory: selects the win32 or linux service bundle by process.platform. Everything that is not -// Windows (linux/SteamOS, and macOS dev builds) gets the linux bundle — which in the foundation stage is -// the pre-port graceful degradation and is filled in with real Proton logic stage by stage. +// Platform factory: selects the win32, darwin or linux service bundle by process.platform. The three are +// real ports, not degradations — linux (SteamOS) runs Windows games through Proton, darwin runs NATIVE mac +// games and Steam mode (see platform/darwin.ts for what macOS deliberately does not do). Anything that is +// neither Windows nor macOS gets the linux bundle. // // This is the ONE place that branches on the OS at bootstrap; every consumer takes an injected service and -// stays platform-agnostic (see CLAUDE.md — the platform layer is now the convention for OS-specific code). +// stays platform-agnostic (see CLAUDE.md — the platform layer is the convention for OS-specific code). import type { Platform, PlatformDeps } from './types'; import { createWin32Platform } from './win32'; import { createLinuxPlatform } from './linux'; +import { createDarwinPlatform } from './darwin'; export type { Platform, @@ -27,5 +29,7 @@ export type { /** Builds the platform service bundle for the running OS. Bootstrapped once in main. */ export function createPlatform(platform: NodeJS.Platform, deps: PlatformDeps): Platform { - return platform === 'win32' ? createWin32Platform(deps) : createLinuxPlatform(deps); + if (platform === 'win32') return createWin32Platform(deps); + if (platform === 'darwin') return createDarwinPlatform(deps); + return createLinuxPlatform(deps); } diff --git a/src/main/platform/process-monitor.darwin.ts b/src/main/platform/process-monitor.darwin.ts new file mode 100644 index 00000000..2c413fc8 --- /dev/null +++ b/src/main/platform/process-monitor.darwin.ts @@ -0,0 +1,203 @@ +// macOS ProcessMonitor backed by `ps` (Д1). 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 +// 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. +// +// The pure parsing/matching helpers carry no fs/electron baggage and are unit-tested +// (test/process-monitor-darwin.test.ts); the `ps` calls and signals are exercised on a real mac. +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import type { ProcessMonitor, ProcessSnapshot } from './types'; +import { pathBasename } from './proc'; +import { delay } from '../util'; + +const execFileAsync = promisify(execFile); + +/** How long a SIGTERM is given to work before the tree is SIGKILLed (force-close is already the last resort). */ +const KILL_GRACE_MS = 2000; + +/** One process seen by `ps`: its pid and the basename of its executable (null when the line carried none). */ +export interface DarwinProcEntry { + readonly pid: number; + readonly imageName: string | null; +} + +/** One parent link from `ps -axo pid=,ppid=`. */ +export interface DarwinProcParent { + readonly pid: number; + readonly ppid: number; +} + +/** + * 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. + */ +export function normalizeImageName(name: string): string { + return pathBasename(name).toLowerCase().replace(/\.exe$/, ''); +} + +/** + * Parses `ps -axwwo pid=,comm=` output into entries. A line is `<spaces><pid> <command path>`; the command + * may contain spaces (`/Applications/My Game.app/Contents/MacOS/My Game`), so only the FIRST field is + * split off. Unparseable and empty lines are skipped. Pure. + */ +export function parsePsCommand(stdout: string): readonly DarwinProcEntry[] { + const entries: DarwinProcEntry[] = []; + for (const line of stdout.split('\n')) { + const match = /^\s*(\d+)\s+(.*)$/.exec(line); + if (match === null) continue; + const pid = Number.parseInt(match[1] ?? '', 10); + if (!Number.isFinite(pid)) continue; + const command = (match[2] ?? '').trim(); + entries.push({ pid, imageName: command === '' ? null : pathBasename(command) }); + } + return entries; +} + +/** Parses `ps -axo pid=,ppid=` output into parent links. Unparseable lines are skipped. Pure. */ +export function parsePsParents(stdout: string): readonly DarwinProcParent[] { + const links: DarwinProcParent[] = []; + for (const line of stdout.split('\n')) { + const match = /^\s*(\d+)\s+(\d+)\s*$/.exec(line); + if (match === null) continue; + const pid = Number.parseInt(match[1] ?? '', 10); + const ppid = Number.parseInt(match[2] ?? '', 10); + if (!Number.isFinite(pid) || !Number.isFinite(ppid)) continue; + links.push({ pid, ppid }); + } + return links; +} + +/** Builds a ProcessSnapshot over parsed `ps` entries: exact normalized-basename match, like linux. Pure. */ +export function snapshotFromEntries(entries: readonly DarwinProcEntry[]): ProcessSnapshot { + const names = new Set<string>(); + const pids = new Set<number>(); + for (const entry of entries) { + pids.add(entry.pid); + if (entry.imageName !== null) names.add(normalizeImageName(entry.imageName)); + } + return { + hasImageName: (name) => names.has(normalizeImageName(name)), + hasPid: (pid) => pids.has(pid), + }; +} + +/** + * The pid plus every descendant of it, walking the parent links breadth-first. `ps` gives no tree, so the + * whole "kill the tree" idea has to be rebuilt from the flat pid/ppid list. A cycle (impossible in a real + * process table, cheap to guard) cannot loop this: a pid is expanded at most once. Pure. + */ +export function descendantPids(root: number, links: readonly DarwinProcParent[]): readonly number[] { + const childrenOf = new Map<number, number[]>(); + for (const link of links) { + const siblings = childrenOf.get(link.ppid); + if (siblings === undefined) childrenOf.set(link.ppid, [link.pid]); + else siblings.push(link.pid); + } + const collected = new Set<number>([root]); + const queue = [root]; + while (queue.length > 0) { + const current = queue.shift() ?? 0; + for (const child of childrenOf.get(current) ?? []) { + if (collected.has(child)) continue; + collected.add(child); + queue.push(child); + } + } + return [...collected]; +} + +/** One `ps` pass over the process table. Any error → no entries (everything reads as absent — "error = dead"). */ +async function scanProcesses(): Promise<readonly DarwinProcEntry[]> { + try { + const { stdout } = await execFileAsync('ps', ['-axwwo', 'pid=,comm=']); + return parsePsCommand(stdout); + } catch { + return []; + } +} + +/** One `ps` pass over the pid→ppid links (only needed by killTree). Any error → no links. */ +async function scanParents(): Promise<readonly DarwinProcParent[]> { + try { + const { stdout } = await execFileAsync('ps', ['-axo', 'pid=,ppid=']); + return parsePsParents(stdout); + } catch { + return []; + } +} + +/** Sends one signal to each pid, swallowing "already gone" (ESRCH) and "not permitted" (EPERM). */ +function signalPids(pids: Iterable<number>, signal: NodeJS.Signals): void { + for (const pid of pids) { + try { + process.kill(pid, signal); + } catch { + // already dead (ESRCH) / not permitted (EPERM) → nothing to do. + } + } +} + +/** The macOS `ps`-backed ProcessMonitor. */ +export function createDarwinProcessMonitor(): ProcessMonitor { + const monitor: ProcessMonitor = { + async snapshot(): Promise<ProcessSnapshot> { + return snapshotFromEntries(await scanProcesses()); + }, + isPidAlive(pid): Promise<boolean> { + try { + // Signal 0 only probes existence/permission. ESRCH → dead; EPERM → alive but another user's. + process.kill(pid, 0); + return Promise.resolve(true); + } catch (cause) { + return Promise.resolve((cause as NodeJS.ErrnoException).code === 'EPERM'); + } + }, + async killTree(pid): Promise<void> { + const pids = descendantPids(pid, await scanParents()); + signalPids(pids, 'SIGTERM'); + await delay(KILL_GRACE_MS); + // Re-check rather than blindly SIGKILL: a game that honoured SIGTERM is already gone, and its pid + // may by then belong to something else. + const stillAlive = pids.filter((candidate) => { + try { + process.kill(candidate, 0); + return true; + } catch (cause) { + return (cause as NodeJS.ErrnoException).code === 'EPERM'; + } + }); + signalPids(stillAlive, 'SIGKILL'); + }, + async killByName(names): Promise<void> { + const wanted = new Set(names.map(normalizeImageName)); + if (wanted.size === 0) return; + const entries = await scanProcesses(); + const pids = entries + .filter((entry) => entry.imageName !== null && wanted.has(normalizeImageName(entry.imageName))) + .map((entry) => entry.pid); + if (pids.length === 0) return; + signalPids(pids, 'SIGTERM'); + await delay(KILL_GRACE_MS); + signalPids(pids, 'SIGKILL'); + }, + // Д1: 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<boolean> { + if (watchNames.length === 0) return false; + const snap = await monitor.snapshot(); + return watchNames.some((name) => snap.hasImageName(name)); + }, + killSteamGame(_appid, watchNames): Promise<void> { + return monitor.killByName(watchNames); + }, + }; + return monitor; +} diff --git a/src/main/platform/save-path.darwin.ts b/src/main/platform/save-path.darwin.ts new file mode 100644 index 00000000..9173b034 --- /dev/null +++ b/src/main/platform/save-path.darwin.ts @@ -0,0 +1,123 @@ +// macOS SavePathResolver (Д3): 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: +// +// %APPDATA% / %LOCALAPPDATA% / %LOCALLOW% → ~/Library/Application Support +// %USERPROFILE% → ~ +// %DOCUMENTS% → ~/Documents +// +// The three AppData prefixes collapsing onto one base is deliberate: Unity on macOS writes to +// `~/Library/Application Support/<Company>/<Product>`, 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 +// 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: +// `%APPDATA%` is the canonical answer for a folder the user picks under Application Support. A pcSavePath +// the user never touched is not re-derived through this (see the Configure flow), so an existing +// `%LOCALLOW%/…` card is never silently rewritten into `%APPDATA%/…` — on Windows those are two different +// folders. +// +// 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 { PcSaveLocation, SavePathResolver } from './types'; + +/** The home-relative bases the Windows prefixes map onto. Resolved once from the OS/Electron paths. */ +export interface DarwinSaveBases { + /** The user's home directory (`os.homedir()`). */ + readonly home: string; + /** The Documents known folder (`app.getPath('documents')`). */ + readonly documents: string; +} + +/** `~/Library/Application Support` — where every AppData-family prefix lands on macOS. */ +export function applicationSupportDir(home: string): string { + return path.posix.join(home, 'Library', 'Application Support'); +} + +/** + * The absolute base a Windows env-prefix maps to on macOS, or null for an unknown token. Pure. + * Kept as an explicit switch (not a table keyed by string) so an unknown prefix cannot silently resolve. + */ +export function darwinSaveBase(bases: DarwinSaveBases, prefix: string): string | null { + switch (prefix.toUpperCase()) { + case 'APPDATA': + case 'LOCALAPPDATA': + case 'LOCALLOW': + return applicationSupportDir(bases.home); + case 'USERPROFILE': + return bases.home; + case 'DOCUMENTS': + return bases.documents; + default: + return null; + } +} + +/** + * Maps a manifest `pcSavePath` (`%APPDATA%\rest`, …) to an ABSOLUTE macOS folder. Pure — no fs. Returns + * null for an unknown/absent prefix token or a `..`-traversal in the tail (both already rejected upstream + * by validatePcSavePathStatic, so null here is defensive). Both `\` and `/` separate the tail (a Windows + * manifest may use either), mirroring expandPcSavePath. + */ +export function resolveDarwinPcSavePath(bases: DarwinSaveBases, pcSavePath: string): string | null { + const match = /^%([A-Za-z]+)%[\\/]?(.*)$/.exec(pcSavePath); + if (match === null) return null; + const base = darwinSaveBase(bases, match[1] ?? ''); + if (base === null) return null; + const tail = (match[2] ?? '').split(/[\\/]+/).filter((segment) => segment.length > 0); + if (tail.includes('..')) return null; + return path.posix.join(base, ...tail); +} + +/** + * Reverse of resolveDarwinPcSavePath, for the Configure window's pcSavePath Browse: an ABSOLUTE mac folder + * → a `%PREFIX%/…` manifest string, or null when it lives under none of the bases (then it cannot be + * expressed and the picker rejects it). Pure. + * + * Matching is segment-wise (never string-prefix) and the LONGEST base wins, so the bare home + * (`%USERPROFILE%`) is the last resort. Application Support answers `%APPDATA%` — the canonical choice for + * the three prefixes that share that base (see the header). + */ +export function darwinToManifestPcSavePath(bases: DarwinSaveBases, absolute: string): string | null { + const candidates: ReadonlyArray<{ readonly token: string; readonly base: string }> = [ + { token: 'APPDATA', base: applicationSupportDir(bases.home) }, + { token: 'DOCUMENTS', base: bases.documents }, + { token: 'USERPROFILE', base: bases.home }, + ]; + const segmentsOf = (value: string): readonly string[] => + value.split(/[\\/]+/).filter((segment) => segment.length > 0); + const target = segmentsOf(absolute); + const byLongestBase = [...candidates].sort( + (a, b) => segmentsOf(b.base).length - segmentsOf(a.base).length, + ); + for (const { token, base } of byLongestBase) { + const baseSegments = segmentsOf(base); + if (baseSegments.length === 0 || target.length < baseSegments.length) continue; + const matches = baseSegments.every((segment, i) => target[i] === segment); + if (!matches) continue; + const rest = target.slice(baseSegments.length); + return rest.length === 0 ? `%${token}%` : `%${token}%/${rest.join('/')}`; + } + return null; +} + +/** + * The macOS SavePathResolver. `containerExists` is always true, exactly as on win32: the container here is + * the user's home, which exists for as long as the app runs. There is no prefix that an uninstall can wipe, + * so the pre-port change-detection semantics (an empty save folder DOES mean the saves were deleted) hold. + */ +export function createDarwinSavePathResolver(bases: DarwinSaveBases): SavePathResolver { + return { + resolvePcSavePath(manifest: ResolvedManifest, pcSavePath: string): Promise<PcSaveLocation | null> { + // A local mac game may point straight at a folder (`/Users/me/Library/…`) instead of a `%PREFIX%` + // token — it is already a host path, so there is nothing to translate (symmetric with win32). + if (manifest.source === 'pc' && path.posix.isAbsolute(pcSavePath)) { + return Promise.resolve({ path: path.posix.normalize(pcSavePath), containerExists: true }); + } + const resolved = resolveDarwinPcSavePath(bases, pcSavePath); + return Promise.resolve(resolved === null ? null : { path: resolved, containerExists: true }); + }, + toManifestPcSavePath: (absolute) => darwinToManifestPcSavePath(bases, absolute), + }; +} diff --git a/src/main/platform/save-path.linux.ts b/src/main/platform/save-path.linux.ts index c6f58521..0408bb3c 100644 --- a/src/main/platform/save-path.linux.ts +++ b/src/main/platform/save-path.linux.ts @@ -120,6 +120,13 @@ async function prefixForManifest( export function createLinuxSavePathResolver(deps: LinuxSavePathDeps): SavePathResolver { return { async resolvePcSavePath(manifest, pcSavePath): Promise<PcSaveLocation | null> { + // A local PC game may name an absolute folder instead of a `%PREFIX%` token. Here that is a HOST + // path (the user browsed to it on this machine), not a path inside the game's Wine prefix — so it + // is returned as-is and the host filesystem is its container. The `%PREFIX%` spelling still maps + // into the prefix below, which is what a Windows game writing to %APPDATA% needs. + if (manifest.source === 'pc' && path.posix.isAbsolute(pcSavePath)) { + return { path: path.posix.normalize(pcSavePath), containerExists: true }; + } 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 diff --git a/src/main/platform/steam-locator.darwin.ts b/src/main/platform/steam-locator.darwin.ts new file mode 100644 index 00000000..09c967a1 --- /dev/null +++ b/src/main/platform/steam-locator.darwin.ts @@ -0,0 +1,33 @@ +// macOS SteamLocator (Д8): 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. +// +// Paths are built with `path.posix` (CLAUDE.md): these describe a macOS filesystem, and the suite runs on +// the Windows CI runner too. +import os from 'node:os'; +import path from 'node:path'; +import fse from 'fs-extra'; +import type { SteamLocator } from './types'; + +/** The Steam-root candidates for a given home dir. Pure — unit-tested. */ +export function steamCandidateDirs(home: string): readonly string[] { + return [path.posix.join(home, 'Library', 'Application Support', 'Steam')]; +} + +/** A Steam root is valid iff it holds `steamapps/libraryfolders.vdf` (Steam's library index). Pure path. */ +export function libraryIndexPath(steamRoot: string): string { + return path.posix.join(steamRoot, 'steamapps', 'libraryfolders.vdf'); +} + +/** The macOS SteamLocator: the first candidate root whose library index exists, or null. */ +export function createDarwinSteamLocator(): SteamLocator { + return { + async locateSteam(): Promise<string | null> { + for (const dir of steamCandidateDirs(os.homedir())) { + if (await fse.pathExists(libraryIndexPath(dir))) return dir; + } + return null; + }, + }; +} diff --git a/src/main/platform/types.ts b/src/main/platform/types.ts index 92ebb344..159e1436 100644 --- a/src/main/platform/types.ts +++ b/src/main/platform/types.ts @@ -16,6 +16,7 @@ import type { import type { GameProcess } from '../game-launcher'; import type { PowerAction } from '../power'; import type { InstallDirResolver } from '../manifest'; +import type { Translator } from '../../shared/i18n/index'; /** * An atomic snapshot of the running processes (one OS call). The same snapshot answers BOTH "is a watched @@ -282,4 +283,10 @@ export interface PlatformDeps { /** Absolute path to the bundled umu-run zipapp (extraResources), run via system python3 on linux (Р1). * Unused on win32. */ readonly umuRunPath: string; + /** + * The live translator, for the few platform refusals that reach the user as text — the darwin bundle + * refuses a Windows `*.exe`, install mode and a Gatekeeper-blocked binary in their own words. Read per + * call (not captured), so a language change applies to the next message. + */ + readonly getTranslator: () => Translator; } diff --git a/src/main/platform/win32.ts b/src/main/platform/win32.ts index 146dbfe5..eec54bca 100644 --- a/src/main/platform/win32.ts +++ b/src/main/platform/win32.ts @@ -149,7 +149,13 @@ const noopTranslator = createTranslator('en'); function createSavePathResolver(deps: PlatformDeps): SavePathResolver { const env = (): ManifestEnv => ({ documents: deps.getDocuments(), t: noopTranslator }); return { - resolvePcSavePath: (_manifest, pcSavePath) => { + resolvePcSavePath: (manifest, pcSavePath) => { + // A local PC game may point straight at a folder (`C:\Games\Hades\Saves`) — see the pc-mode + // decision in manifest.ts: it is already a host path, so there is nothing to expand. The user + // profile owns it just as it owns an expanded %PREFIX%, hence containerExists: true. + if (manifest.source === 'pc' && path.isAbsolute(pcSavePath)) { + return Promise.resolve({ path: path.normalize(pcSavePath), containerExists: true }); + } const result = expandPcSavePath(pcSavePath, env()); // containerExists is always true here: the env-based location lives under the user profile, which // exists for as long as the app runs. There is no Wine prefix to wipe, so the pre-port change- diff --git a/src/main/save-sync.ts b/src/main/save-sync.ts index 404c7b5c..85a61b11 100644 --- a/src/main/save-sync.ts +++ b/src/main/save-sync.ts @@ -210,20 +210,11 @@ export async function syncByChange( const nextState: SyncState = direction === 'noop' ? { card: cardTree, pc: pcTree, syncedAt: Date.now() } - : { card: await snapshotTree(cardPath), pc: await snapshotTree(pcPath), syncedAt: Date.now() }; + : { + card: await snapshotTree(cardPath), + pc: await snapshotTree(pcPath), + syncedAt: Date.now(), + }; return { direction, conflict, usedFallback, state: nextState }; } - -/** Atomic (within a volume) write of a single file: temp → rename. Best-effort on the card. */ -export async function writeFileAtomic(targetPath: string, data: string): Promise<void> { - const tmp = `${targetPath}.tmp`; - const dir = path.dirname(targetPath); - // Only create the directory when it's genuinely missing. On Windows, mkdir of a DRIVE ROOT - // (e.g. "E:\", the card root for stats.json) throws EPERM even though it already exists — so an - // unconditional ensureDir would make every card-root write fail. The parent is always present - // for our targets (card root / existing save dir); create it only for nested paths that need it. - if (!(await fse.pathExists(dir))) await fse.ensureDir(dir); - await fse.writeFile(tmp, data, 'utf8'); - await withRetry(() => fse.move(tmp, targetPath, { overwrite: true })); -} diff --git a/src/main/settings-window.ts b/src/main/settings-window.ts deleted file mode 100644 index b2754f78..00000000 --- a/src/main/settings-window.ts +++ /dev/null @@ -1,137 +0,0 @@ -// Settings window — a PLAIN desktop window (framed, fixed size, not fullscreen / not kiosk), opened -// from the tray. Unlike GameWindow it carries NO game design: it hosts the "system settings" UI -// (app version + update management) on Fluent UI Web Components, with its own preload -// (settings-preload → window.settingsApi). Created lazily on first open; a repeat open just focuses -// the existing instance. Closing (X) hides it to the tray (like GameWindow), and allowClose() lets it -// really close on app quit / update install. -// -// The window is wired to UpdaterService: attachWindow() right after create() (so the renderer's first -// requestUpdateStatus() and any early push both land), detachWindow() on hide/close (so the updater -// never pushes into a hidden/destroyed window). -import path from 'node:path'; -import { BrowserWindow, ipcMain, nativeTheme } from 'electron'; -import { APP_NAME, IPC } from '../shared/types'; -import { type Translator } from '../shared/i18n/index'; -import { type UpdaterService } from './updater'; -import { installHideOnClose, type HideOnCloseGuard } from './window-hide-guard'; - -const TITLE_BAR_HEIGHT = 48; - -// Native caption-button (min/max/close) colors for the Window Controls Overlay. `color` must match the -// custom title bar's background in settings.css (Fluent colorNeutralBackground1) so the strip looks -// seamless; `symbolColor` is the glyph color. -const OVERLAY = { - dark: { color: '#292929', symbolColor: '#ffffff' }, - light: { color: '#ffffff', symbolColor: '#000000' }, -} as const; - -export class SettingsWindow { - private window: BrowserWindow | null = null; - private closeGuard: HideOnCloseGuard | null = null; - - constructor( - private readonly updater: UpdaterService, - private readonly getTranslator: () => Translator, - ) { - // The renderer computes the effective (system-resolved) theme and asks us to recolor the native - // caption buttons to match. Registered once here (SettingsWindow is a singleton); guarded on a live - // window. Not part of UpdaterService's settings IPC — this is pure window chrome. - ipcMain.on(IPC.titleBarOverlayUpdate, (_event, dark: boolean) => this.applyOverlay(dark)); - } - - /** The native window title (taskbar). Re-applied on a language change (the renderer also sets - * document.title, otherwise the HTML <title> would override this in the taskbar). */ - private title(): string { - return `${APP_NAME} — ${this.getTranslator()('window.settings')}`; - } - - /** Re-titles a live window after a language change. */ - refreshTitle(): void { - const window = this.window; - if (window !== null && !window.isDestroyed()) window.setTitle(this.title()); - } - - private applyOverlay(dark: boolean): void { - const window = this.window; - if (window === null || window.isDestroyed()) return; - window.setTitleBarOverlay(dark ? OVERLAY.dark : OVERLAY.light); - } - - /** Opens the window, creating it lazily on first call; otherwise shows + focuses the existing one. */ - openOrFocus(): void { - if (this.window !== null && !this.window.isDestroyed()) { - if (!this.window.isVisible()) this.window.show(); - this.window.focus(); - // Re-attach: the window may have been detached on a previous hide/close. - this.updater.attachWindow(this.window); - return; - } - this.create(); - } - - private create(): void { - const window = new BrowserWindow({ - // The default 520×600 is also the MINIMUM — the window is resizable, but can't shrink below the - // point where the settings layout gets cramped. - width: 520, - height: 600, - minWidth: 520, - minHeight: 600, - show: false, - // A plain desktop window — no game kiosk/fullscreen. autoHideMenuBar is not needed: main.ts - // already does Menu.setApplicationMenu(null) globally. - // Windows-11-Settings-style chrome: the native title bar is hidden and the app draws its own - // (icon + "Playhook (version)" on the left, see settings.html), while the native min/max/close - // buttons are kept via the Window Controls Overlay — recolored to the theme (initial guess from - // the OS; the renderer refines it once the effective theme is known). - titleBarStyle: 'hidden', - titleBarOverlay: { - ...(nativeTheme.shouldUseDarkColors ? OVERLAY.dark : OVERLAY.light), - height: TITLE_BAR_HEIGHT, - }, - resizable: true, - fullscreen: false, - title: this.title(), - icon: path.join(__dirname, '../icon.ico'), - // Pre-paint background matched to the OS theme (the renderer applies the real Fluent theme on - // load) — avoids a dark flash on a light system and vice-versa. `system` is the default theme. - backgroundColor: nativeTheme.shouldUseDarkColors ? '#1f1f1f' : '#ffffff', - webPreferences: { - preload: path.join(__dirname, '../preload/settings-preload.js'), - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - }, - }); - - // Closing the window with the X doesn't quit the app — hide it to the tray (like GameWindow). - // Whether it's a real close or a hide, stop the updater from pushing into this window. - this.closeGuard = installHideOnClose(window, () => this.updater.detachWindow()); - - // Belt-and-suspenders: also detach when the window is merely hidden. - window.on('hide', () => this.updater.detachWindow()); - window.on('show', () => this.updater.attachWindow(window)); - - this.window = window; - // Attach BEFORE loadFile so the renderer can subscribe and request the snapshot as soon as it - // starts, and any early push has a live window to reach. - this.updater.attachWindow(window); - - void window.loadFile(path.join(__dirname, '../renderer/settings.html')); - - // Show only once the content is ready, to avoid a white flash of the framed window. - window.once('ready-to-show', () => { - window.show(); - window.focus(); - }); - } - - get browserWindow(): BrowserWindow | null { - return this.window; - } - - /** Allows the window to actually close (app quit / update install). */ - allowClose(): void { - this.closeGuard?.allowClose(); - } -} diff --git a/src/main/stats.ts b/src/main/stats.ts index e6f3f349..3068d65d 100644 --- a/src/main/stats.ts +++ b/src/main/stats.ts @@ -14,7 +14,7 @@ import fse from 'fs-extra'; import { z } from 'zod'; import { CARD_STATS_FILENAME, type Stats } from '../shared/types'; import { parseStats, statsSchema, type PcStore } from './pc-store'; -import { writeFileAtomic } from './save-sync'; +import { writeFileAtomicEnsuringDir } from './json-store'; import { log } from './logger'; import { describe } from './util'; @@ -159,7 +159,7 @@ export class StatsService { const games: Record<string, Stats> = read.kind === 'map' ? { ...read.games } : {}; games[id] = stats; const map = { schemaVersion: 1 as const, games }; - await writeFileAtomic(target, JSON.stringify(map, null, 2)); + await writeFileAtomicEnsuringDir(target, JSON.stringify(map, null, 2)); log.info(`[stats] wrote card copy id=${id} → "${target}"`); } catch (cause) { log.error(`[stats] FAILED to write card copy id=${id} → "${target}":`, cause); diff --git a/src/main/steam-install-watch.ts b/src/main/steam-install-watch.ts index 4b1dae83..3d2a4ad3 100644 --- a/src/main/steam-install-watch.ts +++ b/src/main/steam-install-watch.ts @@ -21,6 +21,12 @@ const STEAM_INSTALL_WATCH_INTERVAL_MS = 5000; // 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). */ @@ -29,19 +35,23 @@ export interface SteamWatchDeps { isLaunchInFlight(): boolean; /** The current AppState snapshot. */ getState(): AppState; - /** Whether a card is currently present. */ - isCardPresent(): boolean; + /** 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) — plays the "install - * finished" cue. Not fired for an already-installed card (that never enters this poller). */ - onInstallCompleted(): 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 is on the ready screen with a card. + // Recursive setTimeout (no overlap). Non-null only while a Steam game whose source is available is on + // the ready screen. private timer: ReturnType<typeof setTimeout> | 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 @@ -118,6 +128,10 @@ export class SteamInstallWatch { 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; @@ -136,6 +150,7 @@ export class SteamInstallWatch { // .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; } } @@ -161,15 +176,19 @@ export class SteamInstallWatch { steamPausedProgress, steamUninstalling, }); - // Download just finished (Install→Play): play the "install finished" cue, like the installer/copy - // path. Guarded by the prev→now transition so it fires once, not on every post-install poll. - if (prev.requiresInstall && !requiresInstall) this.deps.onInstallCompleted(); + // 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 card (mirrors enterReady's start condition). + // 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.isCardPresent()) { + if (s.kind === 'ready' && s.game.installVia === 'steam' && this.deps.isSourceAvailable()) { this.start(); } } diff --git a/src/main/tray.ts b/src/main/tray.ts index 9dc0d6d6..0ae0f1e7 100644 --- a/src/main/tray.ts +++ b/src/main/tray.ts @@ -1,14 +1,18 @@ // Tray icon and context menu: "Show" / "Quit". +// Settings are NOT here: the launcher's own Settings screen (More → Settings) is the single entrance, +// so Game Mode — which has no tray at all — reaches them the same way the desktop does. // A background app lives in the tray; closing the window doesn't quit the program. import path from 'node:path'; -import { Tray, Menu, nativeImage } from 'electron'; +import { Tray, Menu, nativeImage, type NativeImage } from 'electron'; import { APP_NAME } from '../shared/types'; import { type Translator } from '../shared/i18n/index'; export interface TrayCallbacks { readonly onShow: () => void; - readonly onOpenConfigureGame: () => void; - readonly onOpenSettings: () => void; + /** Opens the log folder in the OS file manager (moved here from the settings window). */ + readonly onOpenLogs: () => void; + /** Opens the app-controlled games install folder (moved here from the settings window). */ + readonly onOpenGamesFolder: () => void; /** Add-to-Steam / Remove-from-Steam, per the current `registered` state (Steam Deck only). */ readonly onToggleSteamShortcut: () => void; readonly onQuit: () => void; @@ -46,20 +50,45 @@ export function buildTrayMenu(t: Translator, callbacks: TrayCallbacks, steam: Tr }, ] : []), - { label: t('tray.configureGame'), click: () => callbacks.onOpenConfigureGame() }, - { label: t('tray.settings'), click: () => callbacks.onOpenSettings() }, + { label: t('settings.openLogs'), click: () => callbacks.onOpenLogs() }, + { label: t('settings.openGames'), click: () => callbacks.onOpenGamesFolder() }, { type: 'separator' }, { label: t('tray.quit'), click: () => callbacks.onQuit() }, ]); } +/** The macOS menu bar is 22pt tall; a tray image is expected at that size, with a 2x representation. */ +const MENU_BAR_ICON_PT = 22; + +/** + * The menu-bar-sized version of the app icon for macOS. The shipped `icon.png` is 256×256 — handing that + * to `Tray` gives a soft, oversized item, because macOS scales whatever it is given down to the bar height. + * A 2x representation is attached alongside so a Retina display gets the sharp variant. + * + * Deliberately NOT a template image: `setTemplateImage(true)` renders only the alpha channel, which would + * turn the coloured logo into a black silhouette. If the colour ever reads badly against a dark menu bar, + * the fix is a dedicated monochrome `iconTemplate.png` asset, not flattening this one. + */ +function menuBarImage(source: NativeImage): NativeImage { + const image = source.resize({ width: MENU_BAR_ICON_PT, height: MENU_BAR_ICON_PT, quality: 'best' }); + const retina = source.resize({ + width: MENU_BAR_ICON_PT * 2, + height: MENU_BAR_ICON_PT * 2, + quality: 'best', + }); + image.addRepresentation({ scaleFactor: 2, dataURL: retina.toDataURL() }); + return image; +} + 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) needs a PNG — a .ico yields an empty - // image via nativeImage there (Р8). Falls back to an empty image if the file is missing. + // 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. const iconFile = process.platform === 'win32' ? '../icon.ico' : '../icon.png'; const iconPath = path.join(__dirname, iconFile); - const image = nativeImage.createFromPath(iconPath); + const loaded = nativeImage.createFromPath(iconPath); + const image = + !loaded.isEmpty() && process.platform === 'darwin' ? menuBarImage(loaded) : loaded; const tray = new Tray(image.isEmpty() ? nativeImage.createEmpty() : image); // Tooltip is the product name — not translated. diff --git a/src/main/updater.ts b/src/main/updater.ts index c92e753c..88ff8ba8 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -4,10 +4,10 @@ // so an update could never interrupt a running game — it applied when the user quit from the tray or // rebooted. Only the packaged nsis build self-updates; in dev (not packaged) this is a no-op. // -// This file is now a SERVICE (UpdaterService) driving the settings window: -// • It owns an UpdateStatus snapshot, returns it on request and pushes it to the settings window on +// This file is now a SERVICE (UpdaterService) driving the launcher's Settings screen: +// • It owns an UpdateStatus snapshot, returns it on request and pushes it to the launcher window on // every change (only while that window is attached and alive). -// • It supports a MANUAL path — check / download / install triggered from the settings UI. The +// • 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 @@ -21,9 +21,9 @@ // game, because quitAndInstall's app.quit() would also tear down a save-sync or a game install. // // Window-guard lifecycle: quitAndInstall() closes ALL app windows BEFORE emitting `before-quit` -// (AppUpdater docs), bypassing main.ts.quit(). Both GameWindow and SettingsWindow hold a +// (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 both windows' guards first. +// 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'; @@ -36,26 +36,39 @@ import { type AudioVolumes, type AutoUpdateMode, type LanguageMode, - type ThemeMode, 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 +/** + * 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 + * start a check whose install step is guaranteed to fail. + */ +function updatesSupported(): boolean { + return app.isPackaged && process.platform !== 'darwin'; +} + export interface UpdaterDeps { readonly settings: AppSettingsStore; + /** + * The launcher's notification inbox. Told when an update has finished DOWNLOADING — that is the + * actionable moment ("it will apply on the next restart"), whereas `available` is a couple of seconds + * of transit the user can do nothing with (autoDownload is on in every mode but `off`). Its writes are + * also drained before quitAndInstall, beside the settings store's. + */ + readonly notifications: NotificationsService; /** True while ANY in-flight operation runs (not only a running game) — blocks the manual install. */ readonly isBusy: () => boolean; /** Drops both windows' close-guards synchronously right before quitAndInstall. */ readonly beforeInstall: () => void; - /** Opens the log folder in the OS file manager (settings window "Open logs"). */ - readonly openLogs: () => void; - /** Opens the games install folder in the OS file manager (settings window "Open games folder"). */ - readonly openGamesFolder: () => 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). */ @@ -68,7 +81,7 @@ export interface UpdaterDeps { /** 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 onAlwaysShowEmptyScreenChanged: (enabled: boolean) => void; + 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). */ @@ -77,13 +90,9 @@ export interface UpdaterDeps { readonly onAudioScopeChanged: () => void; /** Applies a default-ambience change (re-reads the track + pushes it to the game window). */ readonly onAmbientChanged: (track: string | null) => void; - /** Deletes the custom Empty-screen wallpaper file and pushes the default (general Reset only). */ - readonly onWallpaperReset: () => Promise<void>; /** Applies a UI-language change (re-resolve locale, rebuild tray/titles, push to live windows). */ readonly onLanguageChanged: (mode: LanguageMode) => void; - /** Pushes a UI-theme change to the Configure window so an open one recolors live (no hide/show). */ - readonly onThemeChanged: (mode: ThemeMode) => void; - /** The current translator (for the install-busy soft error rendered in the settings window). */ + /** The current translator (for the install-busy soft error surfaced in the launcher). */ readonly getTranslator: () => Translator; } @@ -101,17 +110,29 @@ export class UpdaterService { * 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 window can + * 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. */ async init(): Promise<void> { 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 + // 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. + // + // The order matters: on macOS this holds for a DEV run too, so reporting `not-packaged` there would be + // the less true of two truths — and it would show a developer on a Mac a screen the user never sees + // (the auto-update mode rows), which is exactly the kind of false green this port has to avoid. + if (process.platform === 'darwin') { + this.status = { kind: 'unsupported', reason: 'platform' }; + log.info('[updater] disabled on macOS (unsigned build — Squirrel.Mac requires a signed bundle)'); + return; + } + if (!app.isPackaged) { - this.status = { kind: 'unsupported' }; - log.info( - '[updater] disabled (not packaged) — settings window still works (version/mode only)', - ); + this.status = { kind: 'unsupported', reason: 'not-packaged' }; + log.info('[updater] disabled (not packaged) — the Settings screen still works (version/mode only)'); return; } @@ -131,17 +152,16 @@ export class UpdaterService { if (settings.autoUpdate !== 'off') this.backgroundCheck(); } - /** Attaches the settings window so status changes are pushed to it. Sends the current snapshot now. */ + /** + * Attaches the launcher window so status changes are pushed to it, and sends the current snapshot now. + * Called ONCE at bootstrap: the launcher window is created at startup and lives for the whole session + * (hiding to the tray does not destroy it), and every push re-checks isDestroyed() anyway. + */ attachWindow(window: BrowserWindow): void { this.window = window; this.pushStatus(); } - /** Detaches the settings window (on hide/close) so nothing is pushed to a hidden/destroyed window. */ - detachWindow(): void { - this.window = null; - } - getStatus(): UpdateStatus { return this.status; } @@ -161,26 +181,17 @@ export class UpdaterService { .setAutoUpdate(mode) .then(() => { // Persist always, but only touch autoUpdater in a packaged build. - if (app.isPackaged) this.applyMode(mode); + if (updatesSupported()) this.applyMode(mode); }) .catch((cause: unknown) => log.error('[updater] failed to persist auto-update mode:', cause), ); }); - // The settings renderer applies the theme live in its own window; main persists it so the choice - // survives a restart AND pushes it to the Configure window (onThemeChanged) so an open Configure - // recolors live too — otherwise it only updated on its next hide/show. - ipcMain.on(IPC.settingsSetTheme, (_event, mode: ThemeMode) => { - void this.deps.settings - .setTheme(mode) - .then(() => this.deps.onThemeChanged(mode)) - .catch((cause: unknown) => log.error('[updater] failed to persist theme:', cause)); - }); ipcMain.on(IPC.settingsSetPrerelease, (_event, on: boolean) => { void this.deps.settings .patch({ allowPrerelease: on }) .then(() => { - if (app.isPackaged) autoUpdater.allowPrerelease = on; + if (updatesSupported()) autoUpdater.allowPrerelease = on; }) .catch((cause: unknown) => log.error('[updater] failed to persist prerelease flag:', cause), @@ -200,10 +211,10 @@ export class UpdaterService { log.error('[updater] failed to persist prevent-screensaver:', cause), ); }); - ipcMain.on(IPC.settingsSetAlwaysShowEmptyScreen, (_event, on: boolean) => { + ipcMain.on(IPC.settingsSetKeepOpenWithoutCard, (_event, on: boolean) => { void this.deps.settings - .patch({ alwaysShowEmptyScreen: on }) - .then(() => this.deps.onAlwaysShowEmptyScreenChanged(on)) + .patch({ keepOpenWithoutCard: on }) + .then(() => this.deps.onKeepOpenWithoutCardChanged(on)) .catch((cause: unknown) => log.error('[updater] failed to persist always-show-empty-screen:', cause), ); @@ -247,6 +258,13 @@ export class UpdaterService { .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) => { @@ -262,40 +280,29 @@ export class UpdaterService { return { music: settings.musicVolume, sfx: settings.sfxVolume }; }); ipcMain.handle(IPC.appVersionRequest, (): string => app.getVersion()); - ipcMain.handle(IPC.appIconRequest, (): Promise<string> => this.readIconDataUrl()); - ipcMain.handle(IPC.moveSoundRequest, (_event, set: unknown): Promise<string> => - this.readMoveSoundDataUrl(typeof set === 'string' ? set : DEFAULT_SOUND_SET), - ); ipcMain.handle(IPC.audioOptionsRequest, (): Promise<AudioOptions> => this.readAudioOptions()); - // Imperative maintenance actions — the logic (paths, shell) lives in main.ts callbacks; registered - // here only to keep every settings-window channel in one place (avoids a duplicate handler). - ipcMain.on(IPC.openLogs, () => this.deps.openLogs()); - ipcMain.on(IPC.openGamesFolder, () => this.deps.openGamesFolder()); } // Resets settings to defaults and re-applies every side effect (auto-update mode, prerelease flag, - // summon-hotkey toggle, game-renderer volumes). The settings renderer re-applies the theme in its own - // window from the returned AppSettings; the Configure window gets it via onThemeChanged (the reset may - // have flipped the theme to the default). Returns the defaults so the settings UI can re-render. + // 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<AppSettings> { const next = await this.deps.settings.reset(); - if (app.isPackaged) { + if (updatesSupported()) { autoUpdater.allowPrerelease = next.allowPrerelease; this.applyMode(next.autoUpdate); } this.deps.onSummonHotkeyChanged(next.summonHotkeyEnabled); this.deps.onPreventScreensaverChanged(next.preventScreensaver); - this.deps.onAlwaysShowEmptyScreenChanged(next.alwaysShowEmptyScreen); + 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); - // reset() already wrote customWallpaper=null; this deletes the copied file and pushes the default. - await this.deps.onWallpaperReset(); this.deps.onLanguageChanged(next.language); - this.deps.onThemeChanged(next.theme); return next; } @@ -311,49 +318,6 @@ export class UpdaterService { } } - // The settings window shows the app icon in its custom title bar. CSP there is `img-src data:`, so we - // hand the icon over as a data URL rather than a file path. Read once and cache. - private iconDataUrl: string | null = null; - private async readIconDataUrl(): Promise<string> { - if (this.iconDataUrl !== null) return this.iconDataUrl; - try { - const buffer = await fs.readFile(path.join(__dirname, '../icon.png')); - this.iconDataUrl = `data:image/png;base64,${buffer.toString('base64')}`; - } catch (cause) { - log.error('[updater] failed to read app icon:', cause); - this.iconDataUrl = ''; // empty → the renderer just hides the <img> - } - return this.iconDataUrl; - } - - // A set's "move" UI sound, handed to the settings window as a data URL (its CSP allows `media-src data:` - // only) so a volume slider can play a preview at the released level, in the currently-selected set. The - // set is passed in by the renderer (never read from settings here) so a just-changed dropdown previews - // the new set without racing the on-disk settings write. Cached per set; a missing set falls back to - // winhanced. Read once per set. - private readonly moveSoundBySet = new Map<string, string>(); - private async readMoveSoundDataUrl(set: string): Promise<string> { - const cached = this.moveSoundBySet.get(set); - if (cached !== undefined) return cached; - const read = async (name: string): Promise<Buffer> => - fs.readFile(path.join(__dirname, '../audio/ui', name, 'move.wav')); - let dataUrl = ''; - try { - let buffer: Buffer; - try { - buffer = await read(set); - } catch { - buffer = await read(DEFAULT_SOUND_SET); // the chosen set's move.wav is missing → preview the default - } - dataUrl = `data:audio/wav;base64,${buffer.toString('base64')}`; - } catch (cause) { - log.error('[updater] failed to read move sound:', cause); - dataUrl = ''; // empty → the renderer just skips the preview - } - this.moveSoundBySet.set(set, dataUrl); - return dataUrl; - } - // 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. @@ -401,6 +365,10 @@ export class UpdaterService { autoUpdater.on('update-downloaded', (info) => { log.info(`[updater] downloaded ${info.version}`); this.setStatus({ kind: 'downloaded', version: info.version }); + // The Settings screen only shows this to someone who is already IN the Settings screen; the + // notification is what reaches everyone else. Deduplicated by version inside the service — the + // periodic check keeps re-reporting the same downloaded build every 6 hours. + this.deps.notifications.notifyUpdateReady(info.version); }); autoUpdater.on('error', (err) => { log.error('[updater] error:', err); @@ -460,14 +428,14 @@ export class UpdaterService { } check(): void { - if (!app.isPackaged) return; // unsupported in dev — the IPC is registered but this is a no-op. + if (!updatesSupported()) return; // dev / macOS — the IPC is registered but this is a no-op. void autoUpdater .checkForUpdates() .catch((cause: unknown) => log.error('[updater] check failed:', cause)); } download(): void { - if (!app.isPackaged) return; + if (!updatesSupported()) return; void autoUpdater .downloadUpdate() .catch((cause: unknown) => log.error('[updater] download failed:', cause)); @@ -497,6 +465,9 @@ export class UpdaterService { // right before quitAndInstall (nothing awaited between them) — its contract of dropping the window // close-guards with no yield in the way is preserved. 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.deps.beforeInstall(); // drop both windows' close-guards synchronously first autoUpdater.quitAndInstall(); diff --git a/src/main/window.ts b/src/main/window.ts index d52b7238..f22e65d8 100644 --- a/src/main/window.ts +++ b/src/main/window.ts @@ -5,13 +5,36 @@ // We deliberately do NOT hold alwaysOnTop — a persistent topmost window traps focus and prevents // switching back to the game/Steam. A focused fullscreen window already hides the taskbar. import path from 'node:path'; -import { BrowserWindow, Menu, clipboard } from 'electron'; +import { app, BrowserWindow, Menu, clipboard } from 'electron'; import { IPC } from '../shared/types'; import { type Translator } from '../shared/i18n/index'; import { installHideOnClose, type HideOnCloseGuard } from './window-hide-guard'; import { forceForegroundWindow } from './foreground'; import { log } from './logger'; +/** + * macOS only: use the "simple" (pre-Lion) fullscreen rather than the native one. + * + * The native mode moves the window into a SPACE OF ITS OWN. That is right for a document app the user + * swipes between, and wrong for a launcher that hides to the tray: `hide()` empties the space but does not + * dismiss it, so the user is left staring at a black screen with the launcher apparently gone — and the + * window still reports isFullScreen() === true afterwards, because it still owns that space. + * + * The simple mode just resizes the window over the whole display (menu bar included — measured 1728×1117 + * at y=0, versus 1728×1084 at y=33 for the native one), which is what a kiosk launcher wants anyway, and + * leaves hide()/show() as instant as they are on Windows and Linux. + */ +const USES_SIMPLE_FULLSCREEN = process.platform === 'darwin'; + +/** Puts the window into whichever fullscreen mode this OS uses, if it is not in it already. */ +function enterFullScreen(window: BrowserWindow): void { + if (USES_SIMPLE_FULLSCREEN) { + if (!window.isSimpleFullScreen()) window.setSimpleFullScreen(true); + return; + } + if (!window.isFullScreen()) window.setFullScreen(true); +} + export class GameWindow { private window: BrowserWindow | null = null; private closeGuard: HideOnCloseGuard | null = null; @@ -38,8 +61,10 @@ export class GameWindow { // Frameless: no native title bar / window chrome. Closing is done via the in-app // Exit button or gamepad B (hides to tray); full quit is in the tray menu. frame: false, - // Fullscreen launcher: the window covers the whole screen (incl. taskbar) when shown. - fullscreen: true, + // Fullscreen launcher: the window covers the whole screen (incl. taskbar) when shown. On macOS + // that is the SIMPLE fullscreen — see USES_SIMPLE_FULLSCREEN for why the native one is unusable + // for a window that hides to the tray. + ...(USES_SIMPLE_FULLSCREEN ? { simpleFullscreen: true } : { fullscreen: true }), // Windows takes a multi-res .ico; Linux/mac need a PNG (a .ico renders as an empty icon there). icon: path.join(__dirname, process.platform === 'win32' ? '../icon.ico' : '../icon.png'), backgroundColor: '#101014', @@ -121,7 +146,7 @@ export class GameWindow { if (window === null) return; if (window.isMinimized()) window.restore(); if (!window.isVisible()) window.show(); - if (!window.isFullScreen()) window.setFullScreen(true); + enterFullScreen(window); window.focus(); if (forceForeground) { // The summon came from the gamepad global hook, which Windows doesn't treat as user input to our @@ -135,8 +160,18 @@ export class GameWindow { window.flashFrame(false); } + /** + * Puts the launcher away (the "Minimize Playhook" item, and every automatic hide). + * + * On macOS the window hide is followed by `app.hide()`: hiding the last window does NOT deactivate the + * application there, so without it Playhook stays the frontmost app with nothing on screen — the menu + * bar still says "Playhook" and keystrokes go nowhere. `app.hide()` is the OS's own "put this app away" + * (⌘H), so focus returns to whatever the user had behind us. The way back is unaffected: `show()` clears + * the hidden state on its own (verified — no `app.show()` needed). + */ hide(): void { this.window?.hide(); + if (process.platform === 'darwin') app.hide(); } /** diff --git a/src/preload/configure-preload.ts b/src/preload/configure-preload.ts deleted file mode 100644 index 7713ee96..00000000 --- a/src/preload/configure-preload.ts +++ /dev/null @@ -1,116 +0,0 @@ -// Typed main↔configure-renderer bridge (contextIsolation: true, nodeIntegration: false, sandbox: true). -// Separate from preload.ts / settings-preload.ts so the Configure-game window gets its own -// `window.configureApi`, isolated from the game `window.api` and the settings `window.settingsApi`. -// As in the other preloads, channels are inlined as string LITERALS (a sandboxed preload cannot require -// arbitrary files) and only `import type` from shared is allowed. `satisfies Partial<typeof IPC>` -// restores a compile-time guard over these literals; the ipc-channels unit test guards that this -// CHANNELS map equals its slice of the shared IPC source of truth (and doesn't overlap the others). -import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'; -import type { - AppSettings, - ConfigEditorCommand, - ConfigureApi, - ConfigPickKind, - ConfigPickResult, - ConfigReadResult, - ConfigSaveResult, - ConfigValidationResult, - DriveCandidate, - ThemeMode, -} from '../shared/types'; -import type { IPC } from '../shared/types'; -import type { Locale } from '../shared/i18n/index'; - -const CHANNELS = { - configDrivesRequest: 'config:drives-request', - configDrivesUpdate: 'config:drives-update', - configRead: 'config:read', - configValidate: 'config:validate', - configSave: 'config:save', - configSchemaRequest: 'config:schema-request', - configSettingsRequest: 'config:settings-request', - configIconRequest: 'config:icon', - configVersionRequest: 'config:version', - configEditorCommand: 'config:editor-command', - configEditorActive: 'config:editor-active', - configTitleBarOverlay: 'config:titlebar-overlay', - configLanguageRequest: 'config:language-request', - configLanguageUpdate: 'config:language-update', - configThemeUpdate: 'config:theme-update', - configPickPath: 'config:pick-path', - configImagePreview: 'config:image-preview', - configOpenExternal: 'config:open-external', -} as const satisfies Partial<typeof IPC>; - -const api: ConfigureApi = { - getDrives(): Promise<readonly DriveCandidate[]> { - return ipcRenderer.invoke(CHANNELS.configDrivesRequest) as Promise<readonly DriveCandidate[]>; - }, - onDrivesUpdate(callback: (drives: readonly DriveCandidate[]) => void): void { - ipcRenderer.on( - CHANNELS.configDrivesUpdate, - (_event: IpcRendererEvent, drives: readonly DriveCandidate[]) => { - callback(drives); - }, - ); - }, - readConfig(root: string): Promise<ConfigReadResult> { - return ipcRenderer.invoke(CHANNELS.configRead, root) as Promise<ConfigReadResult>; - }, - validateConfig(text: string): Promise<ConfigValidationResult> { - return ipcRenderer.invoke(CHANNELS.configValidate, text) as Promise<ConfigValidationResult>; - }, - saveConfig(root: string, text: string): Promise<ConfigSaveResult> { - return ipcRenderer.invoke(CHANNELS.configSave, { root, text }) as Promise<ConfigSaveResult>; - }, - pickPath(root: string, kind: ConfigPickKind): Promise<ConfigPickResult> { - return ipcRenderer.invoke(CHANNELS.configPickPath, { root, kind }) as Promise<ConfigPickResult>; - }, - getImagePreview(root: string, path: string): Promise<string | null> { - return ipcRenderer.invoke(CHANNELS.configImagePreview, { root, path }) as Promise<string | null>; - }, - openExternal(url: string): void { - ipcRenderer.send(CHANNELS.configOpenExternal, url); - }, - getSchema(): Promise<unknown> { - return ipcRenderer.invoke(CHANNELS.configSchemaRequest) as Promise<unknown>; - }, - getSettings(): Promise<AppSettings> { - return ipcRenderer.invoke(CHANNELS.configSettingsRequest) as Promise<AppSettings>; - }, - getAppIcon(): Promise<string> { - return ipcRenderer.invoke(CHANNELS.configIconRequest) as Promise<string>; - }, - getAppVersion(): Promise<string> { - return ipcRenderer.invoke(CHANNELS.configVersionRequest) as Promise<string>; - }, - onEditorCommand(callback: (command: ConfigEditorCommand) => void): void { - ipcRenderer.on( - CHANNELS.configEditorCommand, - (_event: IpcRendererEvent, command: ConfigEditorCommand) => { - callback(command); - }, - ); - }, - setJsonEditorActive(active: boolean): void { - ipcRenderer.send(CHANNELS.configEditorActive, active); - }, - setTitleBarDark(dark: boolean): void { - ipcRenderer.send(CHANNELS.configTitleBarOverlay, dark); - }, - getLanguage(): Promise<Locale> { - return ipcRenderer.invoke(CHANNELS.configLanguageRequest) as Promise<Locale>; - }, - onLanguageUpdate(callback: (locale: Locale) => void): void { - ipcRenderer.on(CHANNELS.configLanguageUpdate, (_event: IpcRendererEvent, locale: Locale) => { - callback(locale); - }); - }, - onThemeUpdate(callback: (mode: ThemeMode) => void): void { - ipcRenderer.on(CHANNELS.configThemeUpdate, (_event: IpcRendererEvent, mode: ThemeMode) => { - callback(mode); - }); - }, -}; - -contextBridge.exposeInMainWorld('configureApi', api); diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 117ff16e..5759083d 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -8,14 +8,42 @@ // unit test (shared/types.ts is the single source of truth). import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'; import type { + AppNotification, + AppSettings, AppState, + NotificationToast, SfxSet, + AudioOptions, AudioVolumes, + AutoUpdateMode, BrowseInfo, + ConfigMoveResult, + ConfigPickResult, + ConfigRootReadResult, + ConfigSaveResult, + ConfigValidationResult, + DriveCandidate, + GameConfigAcceptRequest, + GameConfigListDirRequest, + GameConfigReadResult, + GameConfigSaveRequest, GameLibrary, + GameMoveRequest, HeroAssets, + LanguageMode, + ListDirResult, + ArtworkKind, + ArtworkFilter, + ArtworkPage, + GameCandidate, + GameDetails, + MetadataApplyRequest, + MetadataApplyResult, + MetadataResult, + MusicAlbum, + MusicTrack, RendererApi, - SfxName, + UpdateStatus, } from '../shared/types'; import type { IPC } from '../shared/types'; import type { Locale } from '../shared/i18n/index'; @@ -38,7 +66,6 @@ const CHANNELS = { cardMusicRequest: 'card-music:request', ambientUpdate: 'ambient:update', ambientRequest: 'ambient:request', - sfxPlay: 'sfx:play', windowFocus: 'window:focus', heroUpdate: 'hero:update', heroRequest: 'hero:request', @@ -46,6 +73,7 @@ const CHANNELS = { libraryRequest: 'library:request', libraryGridRequest: 'library:grid-request', libraryBrowse: 'library:browse', + libraryForget: 'library:forget', browseUpdate: 'browse:update', browseRequest: 'browse:request', browseHero: 'browse:hero', @@ -54,11 +82,64 @@ const CHANNELS = { sfxSetRequest: 'sfx:set-request', actionSelect: 'action:select', wallpaperRequest: 'wallpaper:request', - wallpaperUpdate: 'wallpaper:update', + startupSoundRequest: 'audio:startup-request', volumeRequest: 'volume:request', volumeUpdate: 'volume:update', languageRequest: 'app:language-request', languageUpdate: 'app:language-update', + // Settings screen (these lived in settings-preload.ts until the window became a launcher screen). + updateStatusUpdate: 'update:status', + updateStatusRequest: 'update:request', + updateCheck: 'update:check', + updateDownload: 'update:download', + updateInstall: 'update:install', + settingsRequest: 'settings:request', + settingsUpdate: 'settings:update', + settingsSteamAvailable: 'settings:steam-available', + settingsReset: 'settings:reset', + settingsSetAutoUpdate: 'settings:set-auto-update', + settingsSetPrerelease: 'settings:set-prerelease', + settingsSetSummonHotkey: 'settings:set-summon-hotkey', + settingsSetPreventScreensaver: 'settings:set-prevent-screensaver', + settingsSetKeepOpenWithoutCard: 'settings:set-keep-open-without-card', + settingsSetDisableSilentInstall: 'settings:set-disable-silent-install', + settingsSetSteamAutoLaunch: 'settings:set-steam-auto-launch', + settingsSetMusicVolume: 'settings:set-music-volume', + settingsSetSfxVolume: 'settings:set-sfx-volume', + settingsSetSoundSet: 'settings:set-sound-set', + settingsSetAmbientTrack: 'settings:set-ambient-track', + settingsSetOnlyGlobalAmbient: 'settings:set-only-global-ambient', + settingsSetSteamGridDbKey: 'settings:set-steamgriddb-key', + settingsSetLanguage: 'settings:set-language', + appVersionRequest: 'app:version', + audioOptionsRequest: 'app:audio-options', + // Customize screen — per-game game.json editing, in the launcher's own namespace (see shared/types). + gameConfigRead: 'gameConfig:read', + gameConfigValidate: 'gameConfig:validate', + gameConfigSave: 'gameConfig:save', + gameConfigImagePreview: 'gameConfig:image-preview', + gameConfigAcceptPath: 'gameConfig:accept-path', + gameConfigListDir: 'gameConfig:list-dir', + gameConfigSources: 'gameConfig:sources', + gameConfigReadRoot: 'gameConfig:read-root', + gameConfigMoveToCard: 'gameConfig:move-to-card', + clipboardRead: 'clipboard:read', + metadataSearch: 'metadata:search', + metadataSteamCandidate: 'metadata:steam-candidate', + metadataArtwork: 'metadata:artwork', + metadataMusicAlbums: 'metadata:music-albums', + metadataMusicTracks: 'metadata:music-tracks', + metadataTrackPreview: 'metadata:track-preview', + metadataDescriptions: 'metadata:descriptions', + metadataApply: 'metadata:apply', + metadataCancel: 'metadata:cancel', + // Notifications — the inbox lives in main; these are its two surfaces in the renderer. + notificationsUpdate: 'notifications:update', + notificationsToast: 'notifications:toast', + notificationsRequest: 'notifications:request', + notificationsDismiss: 'notifications:dismiss', + notificationsClear: 'notifications:clear', + notificationsMarkRead: 'notifications:mark-read', } as const satisfies Partial<typeof IPC>; const api: RendererApi = { @@ -126,11 +207,6 @@ const api: RendererApi = { requestAmbient(): Promise<string | null> { return ipcRenderer.invoke(CHANNELS.ambientRequest) as Promise<string | null>; }, - onSfxPlay(callback: (name: SfxName) => void): void { - ipcRenderer.on(CHANNELS.sfxPlay, (_event: IpcRendererEvent, name: SfxName) => { - callback(name); - }); - }, onHeroUpdate(callback: (assets: HeroAssets | null) => void): void { ipcRenderer.on(CHANNELS.heroUpdate, (_event: IpcRendererEvent, assets: HeroAssets | null) => { callback(assets); @@ -140,9 +216,12 @@ const api: RendererApi = { return ipcRenderer.invoke(CHANNELS.heroRequest) as Promise<HeroAssets | null>; }, onLibraryUpdate(callback: (library: GameLibrary | null) => void): void { - ipcRenderer.on(CHANNELS.libraryUpdate, (_event: IpcRendererEvent, library: GameLibrary | null) => { - callback(library); - }); + ipcRenderer.on( + CHANNELS.libraryUpdate, + (_event: IpcRendererEvent, library: GameLibrary | null) => { + callback(library); + }, + ); }, requestLibrary(): Promise<GameLibrary | null> { return ipcRenderer.invoke(CHANNELS.libraryRequest) as Promise<GameLibrary | null>; @@ -150,8 +229,11 @@ const api: RendererApi = { requestGrid(id: string): Promise<string | null> { return ipcRenderer.invoke(CHANNELS.libraryGridRequest, id) as Promise<string | null>; }, - browseGame(id: string): void { - ipcRenderer.send(CHANNELS.libraryBrowse, id); + browseGame(id: string | null, immediate = false): void { + ipcRenderer.send(CHANNELS.libraryBrowse, id, immediate); + }, + forgetGame(id: string): void { + ipcRenderer.send(CHANNELS.libraryForget, id); }, onBrowseUpdate(callback: (browse: BrowseInfo | null) => void): void { ipcRenderer.on(CHANNELS.browseUpdate, (_event: IpcRendererEvent, browse: BrowseInfo | null) => { @@ -185,10 +267,8 @@ const api: RendererApi = { requestWallpaper(): Promise<string | null> { return ipcRenderer.invoke(CHANNELS.wallpaperRequest) as Promise<string | null>; }, - onWallpaperUpdate(callback: (url: string) => void): void { - ipcRenderer.on(CHANNELS.wallpaperUpdate, (_event: IpcRendererEvent, url: string) => { - callback(url); - }); + requestStartupSound(): Promise<string | null> { + return ipcRenderer.invoke(CHANNELS.startupSoundRequest) as Promise<string | null>; }, requestVolumes(): Promise<AudioVolumes> { return ipcRenderer.invoke(CHANNELS.volumeRequest) as Promise<AudioVolumes>; @@ -206,6 +286,200 @@ const api: RendererApi = { callback(locale); }); }, + getSettings(): Promise<AppSettings> { + return ipcRenderer.invoke(CHANNELS.settingsRequest) as Promise<AppSettings>; + }, + onSettingsUpdate(callback: (settings: AppSettings) => void): void { + ipcRenderer.on(CHANNELS.settingsUpdate, (_event: IpcRendererEvent, settings: AppSettings) => { + callback(settings); + }); + }, + isSteamAvailable(): Promise<boolean> { + return ipcRenderer.invoke(CHANNELS.settingsSteamAvailable) as Promise<boolean>; + }, + getAudioOptions(): Promise<AudioOptions> { + return ipcRenderer.invoke(CHANNELS.audioOptionsRequest) as Promise<AudioOptions>; + }, + getAppVersion(): Promise<string> { + return ipcRenderer.invoke(CHANNELS.appVersionRequest) as Promise<string>; + }, + setAutoUpdate(mode: AutoUpdateMode): void { + ipcRenderer.send(CHANNELS.settingsSetAutoUpdate, mode); + }, + setPrerelease(on: boolean): void { + ipcRenderer.send(CHANNELS.settingsSetPrerelease, on); + }, + setSummonHotkey(on: boolean): void { + ipcRenderer.send(CHANNELS.settingsSetSummonHotkey, on); + }, + setPreventScreensaver(on: boolean): void { + ipcRenderer.send(CHANNELS.settingsSetPreventScreensaver, on); + }, + setKeepOpenWithoutCard(on: boolean): void { + ipcRenderer.send(CHANNELS.settingsSetKeepOpenWithoutCard, on); + }, + setDisableSilentInstall(on: boolean): void { + ipcRenderer.send(CHANNELS.settingsSetDisableSilentInstall, on); + }, + setSteamAutoLaunch(on: boolean): void { + ipcRenderer.send(CHANNELS.settingsSetSteamAutoLaunch, on); + }, + setSoundSet(set: string): void { + ipcRenderer.send(CHANNELS.settingsSetSoundSet, set); + }, + setAmbientTrack(track: string | null): void { + ipcRenderer.send(CHANNELS.settingsSetAmbientTrack, track); + }, + setOnlyGlobalAmbient(on: boolean): void { + ipcRenderer.send(CHANNELS.settingsSetOnlyGlobalAmbient, on); + }, + setSteamGridDbKey(key: string): void { + ipcRenderer.send(CHANNELS.settingsSetSteamGridDbKey, key); + }, + setMusicVolume(volume: number): void { + ipcRenderer.send(CHANNELS.settingsSetMusicVolume, volume); + }, + setSfxVolume(volume: number): void { + ipcRenderer.send(CHANNELS.settingsSetSfxVolume, volume); + }, + setLanguage(mode: LanguageMode): void { + ipcRenderer.send(CHANNELS.settingsSetLanguage, mode); + }, + resetSettings(): Promise<AppSettings> { + return ipcRenderer.invoke(CHANNELS.settingsReset) as Promise<AppSettings>; + }, + onUpdateStatus(callback: (status: UpdateStatus) => void): void { + ipcRenderer.on( + CHANNELS.updateStatusUpdate, + (_event: IpcRendererEvent, status: UpdateStatus) => { + callback(status); + }, + ); + }, + requestUpdateStatus(): Promise<UpdateStatus> { + return ipcRenderer.invoke(CHANNELS.updateStatusRequest) as Promise<UpdateStatus>; + }, + checkForUpdates(): void { + ipcRenderer.send(CHANNELS.updateCheck); + }, + downloadUpdate(): void { + ipcRenderer.send(CHANNELS.updateDownload); + }, + installUpdate(): void { + ipcRenderer.send(CHANNELS.updateInstall); + }, + readGameConfig(id: string): Promise<GameConfigReadResult> { + return ipcRenderer.invoke(CHANNELS.gameConfigRead, id) as Promise<GameConfigReadResult>; + }, + validateGameConfig(root: string, text: string): Promise<ConfigValidationResult> { + return ipcRenderer.invoke(CHANNELS.gameConfigValidate, { + root, + text, + }) as Promise<ConfigValidationResult>; + }, + saveGameConfig(request: GameConfigSaveRequest): Promise<ConfigSaveResult> { + return ipcRenderer.invoke(CHANNELS.gameConfigSave, request) as Promise<ConfigSaveResult>; + }, + getGameConfigImage(root: string, path: string): Promise<string | null> { + return ipcRenderer.invoke(CHANNELS.gameConfigImagePreview, { root, path }) as Promise< + string | null + >; + }, + acceptGameConfigPaths(request: GameConfigAcceptRequest): Promise<ConfigPickResult> { + return ipcRenderer.invoke(CHANNELS.gameConfigAcceptPath, request) as Promise<ConfigPickResult>; + }, + listGameConfigDir(request: GameConfigListDirRequest): Promise<ListDirResult> { + return ipcRenderer.invoke(CHANNELS.gameConfigListDir, request) as Promise<ListDirResult>; + }, + listGameConfigSources(): Promise<readonly DriveCandidate[]> { + return ipcRenderer.invoke(CHANNELS.gameConfigSources) as Promise<readonly DriveCandidate[]>; + }, + readGameConfigRoot(root: string): Promise<ConfigRootReadResult> { + return ipcRenderer.invoke(CHANNELS.gameConfigReadRoot, root) as Promise<ConfigRootReadResult>; + }, + moveGameConfigToCard(request: GameMoveRequest): Promise<ConfigMoveResult> { + return ipcRenderer.invoke(CHANNELS.gameConfigMoveToCard, request) as Promise<ConfigMoveResult>; + }, + readClipboard(): Promise<string> { + return ipcRenderer.invoke(CHANNELS.clipboardRead) as Promise<string>; + }, + searchMetadata(query: string): Promise<MetadataResult<readonly GameCandidate[]>> { + return ipcRenderer.invoke(CHANNELS.metadataSearch, query) as Promise< + MetadataResult<readonly GameCandidate[]> + >; + }, + requestMetadataSteamCandidate(appId: number): Promise<MetadataResult<GameCandidate>> { + return ipcRenderer.invoke(CHANNELS.metadataSteamCandidate, appId) as Promise< + MetadataResult<GameCandidate> + >; + }, + requestMetadataArtwork( + candidateKey: string, + kind: ArtworkKind, + page: number, + filter: ArtworkFilter, + ): Promise<MetadataResult<ArtworkPage>> { + return ipcRenderer.invoke(CHANNELS.metadataArtwork, { + candidateKey, + kind, + page, + filter, + }) as Promise<MetadataResult<ArtworkPage>>; + }, + searchMetadataMusic(query: string): Promise<MetadataResult<readonly MusicAlbum[]>> { + return ipcRenderer.invoke(CHANNELS.metadataMusicAlbums, query) as Promise< + MetadataResult<readonly MusicAlbum[]> + >; + }, + requestMetadataTracks(albumKey: string): Promise<MetadataResult<readonly MusicTrack[]>> { + return ipcRenderer.invoke(CHANNELS.metadataMusicTracks, albumKey) as Promise< + MetadataResult<readonly MusicTrack[]> + >; + }, + requestMetadataTrackPreview(trackKey: string): Promise<MetadataResult<string>> { + return ipcRenderer.invoke(CHANNELS.metadataTrackPreview, trackKey) as Promise< + MetadataResult<string> + >; + }, + requestMetadataDescriptions(candidateKey: string): Promise<MetadataResult<GameDetails>> { + return ipcRenderer.invoke(CHANNELS.metadataDescriptions, candidateKey) as Promise< + MetadataResult<GameDetails> + >; + }, + applyMetadata(request: MetadataApplyRequest): Promise<MetadataApplyResult> { + return ipcRenderer.invoke(CHANNELS.metadataApply, request) as Promise<MetadataApplyResult>; + }, + cancelMetadata(): void { + ipcRenderer.send(CHANNELS.metadataCancel); + }, + onNotifications(callback: (items: readonly AppNotification[]) => void): void { + ipcRenderer.on( + CHANNELS.notificationsUpdate, + (_event: IpcRendererEvent, items: readonly AppNotification[]) => { + callback(items); + }, + ); + }, + onNotificationToast(callback: (toast: NotificationToast) => void): void { + ipcRenderer.on( + CHANNELS.notificationsToast, + (_event: IpcRendererEvent, toast: NotificationToast) => { + callback(toast); + }, + ); + }, + requestNotifications(): Promise<readonly AppNotification[]> { + return ipcRenderer.invoke(CHANNELS.notificationsRequest) as Promise<readonly AppNotification[]>; + }, + dismissNotification(id: string): void { + ipcRenderer.send(CHANNELS.notificationsDismiss, id); + }, + clearNotifications(): void { + ipcRenderer.send(CHANNELS.notificationsClear); + }, + markNotificationsRead(): void { + ipcRenderer.send(CHANNELS.notificationsMarkRead); + }, }; contextBridge.exposeInMainWorld('api', api); diff --git a/src/preload/settings-preload.ts b/src/preload/settings-preload.ts deleted file mode 100644 index c7504c47..00000000 --- a/src/preload/settings-preload.ts +++ /dev/null @@ -1,172 +0,0 @@ -// Typed main↔settings-renderer bridge (contextIsolation: true, nodeIntegration: false, sandbox: true). -// Separate from preload.ts so the settings window gets its own `window.settingsApi`, isolated from the -// game `window.api` contract. As in preload.ts, channels are inlined as string LITERALS rather -// than imported from shared: a sandboxed preload cannot require arbitrary files. Only `import type` -// from shared is allowed (types erase at compile time). `satisfies Partial<typeof IPC>` restores a -// compile-time guard over these literals: a wrong value (TS2322) or a typo'd key (TS2353) fails -// typecheck. Partial<> still cannot catch a *missing* channel — the ipc-channels unit test guards -// that this CHANNELS map equals its slice of the shared IPC source of truth. -import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'; -import type { - AppSettings, - AudioOptions, - AutoUpdateMode, - LanguageMode, - SettingsApi, - ThemeMode, - UpdateStatus, - WallpaperResult, -} from '../shared/types'; -import type { IPC } from '../shared/types'; -import type { Locale } from '../shared/i18n/index'; - -const CHANNELS = { - updateStatusUpdate: 'update:status', - updateStatusRequest: 'update:request', - updateCheck: 'update:check', - updateDownload: 'update:download', - updateInstall: 'update:install', - settingsRequest: 'settings:request', - settingsSetAutoUpdate: 'settings:set-auto-update', - settingsSetAlwaysShowEmptyScreen: 'settings:set-always-show-empty-screen', - settingsSetDisableSilentInstall: 'settings:set-disable-silent-install', - settingsSetSteamAutoLaunch: 'settings:set-steam-auto-launch', - settingsSteamAvailable: 'settings:steam-available', - settingsSetTheme: 'settings:set-theme', - settingsSetPrerelease: 'settings:set-prerelease', - settingsSetSummonHotkey: 'settings:set-summon-hotkey', - settingsSetPreventScreensaver: 'settings:set-prevent-screensaver', - settingsSetMusicVolume: 'settings:set-music-volume', - settingsSetSfxVolume: 'settings:set-sfx-volume', - settingsSetLanguage: 'settings:set-language', - settingsLanguageRequest: 'settings:language-request', - settingsLanguageUpdate: 'settings:language-update', - settingsReset: 'settings:reset', - titleBarOverlayUpdate: 'settings:titlebar-overlay', - appVersionRequest: 'app:version', - appIconRequest: 'app:icon', - moveSoundRequest: 'app:move-sound', - settingsSetSoundSet: 'settings:set-sound-set', - settingsSetAmbientTrack: 'settings:set-ambient-track', - settingsSetOnlyGlobalAmbient: 'settings:set-only-global-ambient', - audioOptionsRequest: 'app:audio-options', - openLogs: 'app:open-logs', - openGamesFolder: 'app:open-games-folder', - wallpaperPick: 'wallpaper:pick', - wallpaperClear: 'wallpaper:clear', - wallpaperPreviewRequest: 'wallpaper:preview-request', -} as const satisfies Partial<typeof IPC>; - -const api: SettingsApi = { - getAppVersion(): Promise<string> { - return ipcRenderer.invoke(CHANNELS.appVersionRequest) as Promise<string>; - }, - getAppIcon(): Promise<string> { - return ipcRenderer.invoke(CHANNELS.appIconRequest) as Promise<string>; - }, - getMoveSound(set: string): Promise<string> { - return ipcRenderer.invoke(CHANNELS.moveSoundRequest, set) as Promise<string>; - }, - getAudioOptions(): Promise<AudioOptions> { - return ipcRenderer.invoke(CHANNELS.audioOptionsRequest) as Promise<AudioOptions>; - }, - setSoundSet(set: string): void { - ipcRenderer.send(CHANNELS.settingsSetSoundSet, set); - }, - setAmbientTrack(track: string | null): void { - ipcRenderer.send(CHANNELS.settingsSetAmbientTrack, track); - }, - setOnlyGlobalAmbient(on: boolean): void { - ipcRenderer.send(CHANNELS.settingsSetOnlyGlobalAmbient, on); - }, - getSettings(): Promise<AppSettings> { - return ipcRenderer.invoke(CHANNELS.settingsRequest) as Promise<AppSettings>; - }, - setAutoUpdate(mode: AutoUpdateMode): void { - ipcRenderer.send(CHANNELS.settingsSetAutoUpdate, mode); - }, - setAlwaysShowEmptyScreen(on: boolean): void { - ipcRenderer.send(CHANNELS.settingsSetAlwaysShowEmptyScreen, on); - }, - setDisableSilentInstall(on: boolean): void { - ipcRenderer.send(CHANNELS.settingsSetDisableSilentInstall, on); - }, - setSteamAutoLaunch(on: boolean): void { - ipcRenderer.send(CHANNELS.settingsSetSteamAutoLaunch, on); - }, - isSteamAvailable(): Promise<boolean> { - return ipcRenderer.invoke(CHANNELS.settingsSteamAvailable) as Promise<boolean>; - }, - setTheme(mode: ThemeMode): void { - ipcRenderer.send(CHANNELS.settingsSetTheme, mode); - }, - setPrerelease(on: boolean): void { - ipcRenderer.send(CHANNELS.settingsSetPrerelease, on); - }, - setSummonHotkey(on: boolean): void { - ipcRenderer.send(CHANNELS.settingsSetSummonHotkey, on); - }, - setPreventScreensaver(on: boolean): void { - ipcRenderer.send(CHANNELS.settingsSetPreventScreensaver, on); - }, - setMusicVolume(volume: number): void { - ipcRenderer.send(CHANNELS.settingsSetMusicVolume, volume); - }, - setSfxVolume(volume: number): void { - ipcRenderer.send(CHANNELS.settingsSetSfxVolume, volume); - }, - setLanguage(mode: LanguageMode): void { - ipcRenderer.send(CHANNELS.settingsSetLanguage, mode); - }, - getLanguage(): Promise<Locale> { - return ipcRenderer.invoke(CHANNELS.settingsLanguageRequest) as Promise<Locale>; - }, - onLanguageUpdate(callback: (locale: Locale) => void): void { - ipcRenderer.on(CHANNELS.settingsLanguageUpdate, (_event: IpcRendererEvent, locale: Locale) => { - callback(locale); - }); - }, - reset(): Promise<AppSettings> { - return ipcRenderer.invoke(CHANNELS.settingsReset) as Promise<AppSettings>; - }, - setTitleBarDark(dark: boolean): void { - ipcRenderer.send(CHANNELS.titleBarOverlayUpdate, dark); - }, - openLogs(): void { - ipcRenderer.send(CHANNELS.openLogs); - }, - openGamesFolder(): void { - ipcRenderer.send(CHANNELS.openGamesFolder); - }, - pickWallpaper(): Promise<WallpaperResult> { - return ipcRenderer.invoke(CHANNELS.wallpaperPick) as Promise<WallpaperResult>; - }, - clearWallpaper(): Promise<{ dataUrl: string }> { - return ipcRenderer.invoke(CHANNELS.wallpaperClear) as Promise<{ dataUrl: string }>; - }, - requestWallpaperPreview(): Promise<{ dataUrl: string }> { - return ipcRenderer.invoke(CHANNELS.wallpaperPreviewRequest) as Promise<{ dataUrl: string }>; - }, - onUpdateStatus(callback: (status: UpdateStatus) => void): void { - ipcRenderer.on( - CHANNELS.updateStatusUpdate, - (_event: IpcRendererEvent, status: UpdateStatus) => { - callback(status); - }, - ); - }, - requestUpdateStatus(): Promise<UpdateStatus> { - return ipcRenderer.invoke(CHANNELS.updateStatusRequest) as Promise<UpdateStatus>; - }, - checkForUpdates(): void { - ipcRenderer.send(CHANNELS.updateCheck); - }, - downloadUpdate(): void { - ipcRenderer.send(CHANNELS.updateDownload); - }, - installUpdate(): void { - ipcRenderer.send(CHANNELS.updateInstall); - }, -}; - -contextBridge.exposeInMainWorld('settingsApi', api); diff --git a/src/renderer/app.ts b/src/renderer/app.ts index 40ded9dc..7dfd9167 100644 --- a/src/renderer/app.ts +++ b/src/renderer/app.ts @@ -5,14 +5,28 @@ // 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 { 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'; +import { + createTranslator, + type Locale, + type Translator, + type MessageKey, +} 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 { createControls } from './controls.js'; +import { createSettingsScreen, type SettingsScreenApi } from './settings-screen.js'; +import { createGameSettingsScreen, type GameSettingsScreenApi } from './game-settings-screen.js'; +import { createOsk } from './osk.js'; +import { createFilePicker } from './file-picker.js'; +import { createOnlinePicker } from './online-picker.js'; import { createCarousel } from './carousel.js'; -import { formatDate, formatPlaytime } from './format.js'; +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 { req } from './dom.js'; @@ -29,6 +43,29 @@ let currentState: AppState = { kind: 'idle' }; let currentBrowse: BrowseInfo | null = null; // 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 +// every step: at the repeat cadence that is a name flashing nine times a second next to a row that is +// still moving, and nobody can read it anyway. The end of the run brings it back. +let stripFlipping = false; +/** + * How long after a release the run is still treated as GOING. Letting go for a beat and pressing again + * is one continuous auto-move to the user (auto-repeat.ts chains the two), and everything that waits for + * the flip to end is expensive: the hero swap is a megabyte-sized cross-fade, the palette rides along + * with it, and the carousel fetches the covers around wherever it stopped. Firing all of that into every + * gap of a rapid press-release-press is exactly what made the background stutter. + * + * The window has to outlast the chain itself PLUS the first repeat of the new run — that is when the + * flip is reported as started again — or a swap would slip through on the boundary. + */ +const FLIP_SETTLE_MS = AUTO_CHAIN_MS + NAV_REPEAT_MS; +/** Pending "the run is really over" (see FLIP_SETTLE_MS); 0 when the strip is at rest or flipping. */ +let flipSettleTimer = 0; +// The games the strip currently holds, kept so a notification about one can be resolved to an entry — +// the carousel keeps the list too, but only the id/active pair is needed here (see openGameDetail). +let currentGames: readonly LibraryEntry[] = []; +// The notification inbox, exactly as main last pushed it. The popup list and the More item's unread dot +// are drawn from this and nothing else: main owns the inbox, the renderer only shows it. +let notificationItems: readonly AppNotification[] = []; // UI locale + translator (both refreshed on a language push). The HTML ships English fallback text, so // until the invoke-seed lands there is no blank flash — the seed then localizes and re-renders. let currentLocale: Locale = 'en'; @@ -37,10 +74,10 @@ const getTranslator = (): Translator => translator; const audio = createAudioController(); // ── Hero background + palette (own subsystem, see hero.ts) ─────────────────── -// The hero layers, cross-fade, renderer-local rotation, the empty/idle wallpaper screen and the +// The hero layers, cross-fade, renderer-local rotation, the idle wallpaper background and the // two-color palette live in hero.ts. It reaches back for just two things: whether a game is on screen // and the current game id (for the per-hero palette cache key). render() drives it via repaint/ -// startRotation/applyEmptyScreen; the hero:update channel feeds applyAssets; main's wallpaper feeds +// startRotation/applyIdleBackground; the hero:update channel feeds applyAssets; main's wallpaper feeds // setWallpaper. // Both hooks read the BROWSE model, not AppState: a history game is browsed while the state is `idle`, // where `gameOf` is undefined. Left on AppState, hasGameOnScreen would suppress the rotation AND the very @@ -49,27 +86,235 @@ const audio = createAudioController(); const hero = createHeroController({ hasGameOnScreen: () => currentBrowse !== null, getGameId: () => currentBrowse?.id ?? '', - getTranslator, }); +// ── Settings screen (the fourth surface, see settings-screen.ts) ───────────── +// The screen owns its rows and focus; everything it writes goes through this seam, which is main's +// settings:* channels one-to-one. The values it shows come back on settings:update (see the wiring +// below) — never from a setter's own return, so a reset and a live edit take the same path. +const settingsApi: SettingsScreenApi = { + setAutoUpdate: (mode) => window.api.setAutoUpdate(mode), + setPrerelease: (on) => window.api.setPrerelease(on), + setSummonHotkey: (on) => window.api.setSummonHotkey(on), + setPreventScreensaver: (on) => window.api.setPreventScreensaver(on), + setKeepOpenWithoutCard: (on) => window.api.setKeepOpenWithoutCard(on), + setDisableSilentInstall: (on) => window.api.setDisableSilentInstall(on), + setSteamAutoLaunch: (on) => window.api.setSteamAutoLaunch(on), + setSoundSet: (set) => window.api.setSoundSet(set), + setAmbientTrack: (track) => window.api.setAmbientTrack(track), + setOnlyGlobalAmbient: (on) => window.api.setOnlyGlobalAmbient(on), + setMusicVolume: (volume) => window.api.setMusicVolume(volume), + setSfxVolume: (volume) => window.api.setSfxVolume(volume), + setLanguage: (mode) => window.api.setLanguage(mode), + setSteamGridDbKey: (key) => window.api.setSteamGridDbKey(key), + resetSettings: () => { + void window.api.resetSettings(); + }, + checkForUpdates: () => window.api.checkForUpdates(), + downloadUpdate: () => window.api.downloadUpdate(), + installUpdate: () => window.api.installUpdate(), +}; + // ── Interaction layer (popups + focus + actions, see controls.ts) ──────────── // Owns the popups (Details / Power / Confirm / Error), the focus groups and the // actions they trigger, plus their wiring (clicks, hover, gamepad, Esc). render() drives it via // applyGameButtons/clearGameButtons/refresh; main's error goes to showError; the gamepad loop starts // with start(). The carousel seam below routes A/B/left/right when the strip is the active surface. +// The on-screen keyboard is built before the screens that use it: both Settings (the SteamGridDB key) +// and Customize (every text field) take it as a dependency, and only one of them is ever open. +const osk = createOsk({ + audio, + getTranslator, + readClipboard: () => window.api.readClipboard(), +}); + +const settingsScreen = createSettingsScreen({ + audio, + getTranslator, + api: settingsApi, + keyboard: osk, + // Read lazily for the same reason the carousel seam is: `controls` is created just below. + onClosed: () => controls.settingsClosed(), + onResetRequested: () => controls.confirmResetSettings(), +}); + +// ── Customize screen (the fifth surface, see game-settings-screen.ts) ──────── +// Its two sub-surfaces are built first because the screen takes them as dependencies: the keyboard +// (above) is the only way to type anything here, and the file browser the only way to name a path with a +// gamepad. +const gameSettingsApi: GameSettingsScreenApi = { + read: (id) => window.api.readGameConfig(id), + validate: (root, text) => window.api.validateGameConfig(root, text), + save: (request) => window.api.saveGameConfig(request), + imagePreview: (root, path) => window.api.getGameConfigImage(root, path), + sources: () => window.api.listGameConfigSources(), + readRoot: (root) => window.api.readGameConfigRoot(root), + forgetHistory: (id) => window.api.forgetGame(id), + moveToCard: (request) => window.api.moveGameConfigToCard(request), + acceptPath: (request) => window.api.acceptGameConfigPaths(request), + searchMetadata: (query) => window.api.searchMetadata(query), + requestSteamCandidate: (appId) => window.api.requestMetadataSteamCandidate(appId), + metadataDescriptions: (candidateKey) => window.api.requestMetadataDescriptions(candidateKey), + applyMetadata: (request) => window.api.applyMetadata(request), + cancelMetadata: () => window.api.cancelMetadata(), +}; +const filePicker = createFilePicker({ + audio, + getTranslator, + api: { + listDir: (request) => window.api.listGameConfigDir(request), + acceptPaths: (request) => window.api.acceptGameConfigPaths(request), + }, +}); +// "Find online" — one surface for the game, its cover, its backgrounds and its soundtrack. Its own seam +// keeps app.ts the only place window.api is touched; what it CANNOT do (write into the form, put files +// beside the game, open the keyboard) it asks the Customize screen for, which is read lazily below. +const onlinePicker = createOnlinePicker({ + audio, + getTranslator, + api: { + searchGames: (query) => window.api.searchMetadata(query), + steamCandidate: (appId) => window.api.requestMetadataSteamCandidate(appId), + artwork: (candidateKey, kind, page, filter) => + window.api.requestMetadataArtwork(candidateKey, kind, page, filter), + albums: (query) => window.api.searchMetadataMusic(query), + tracks: (albumKey) => window.api.requestMetadataTracks(albumKey), + preview: (trackKey) => window.api.requestMetadataTrackPreview(trackKey), + cancel: () => window.api.cancelMetadata(), + }, + editQuery: (initial, onDone) => gameSettingsScreen.askOnlineQuery(initial, onDone), + applyArtwork: (kind, keys, mode) => gameSettingsScreen.applyOnlineArtwork(kind, keys, mode), + applyTrack: (trackKey) => gameSettingsScreen.applyOnlineTrack(trackKey), + applyTitle: (title) => gameSettingsScreen.applyOnlineTitle(title), + onCandidate: (candidate) => gameSettingsScreen.onOnlineCandidate(candidate), + heroCount: () => gameSettingsScreen.heroCount(), + // The launcher's own two channels, both declared further down and both read lazily for the same reason + // the screen is: no message can arrive before the user has opened this surface. A confirmation is a + // plate that goes by itself; a failure is the error popup, which the user closes when they have read it. + notify: (text) => toast.show(text), + showError: (text) => controls.showError(text), + showBusy: (text, onStop) => controls.showBusy(text, onStop), + closeBusy: () => controls.closeBusy(), + confirmTitle: (title, onYes) => gameSettingsScreen.askOnlineTitle(title, onYes), +}); + +const gameSettingsScreen = createGameSettingsScreen({ + audio, + getTranslator, + api: gameSettingsApi, + keyboard: osk, + picker: filePicker, + onlinePicker, + // Read lazily for the same reason the carousel seam is: `controls` is created just below. + onClosed: () => { + controls.settingsClosed(); + // Cancelled out of "Add game" — back to the library it was started from. On a SUCCESSFUL add this + // still runs first (the screen closes before it reports the new game), and showAddedGame undoes it. + // + // Only when this screen was the TOP one, though. Reached through More → Customize it sits over a + // DETAIL screen, and that screen is what closing it returns to — the Library's turn comes later, + // when the detail screen itself is left (leaveDetail consumes the same flag). + if (carousel.screen() !== 'detail') restoreOrigin(); + }, + onConfirmRequested: (kind, options) => controls.confirmGameSettings(kind, options), + onAdded: (id) => showAddedGame(id), + // The same two channels the online surface speaks through, read lazily for the same reason: both are + // 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), +}); + +// ── The notification toast (see toast.ts) ──────────────────────────────────── +// Read lazily, for the same reason the carousel seam is: `controls` is created just below, and the two +// point at each other — the plate shares its corner with the popup column, so it waits while a popup is +// up and resumes when one closes. +// ── Library screen (the sixth surface, see library-screen.ts) ─────────────── +// Its artwork cache is created here rather than inside it so the bound stays visible where the rest of +// the renderer's memory decisions are: it is the library's alone, the carousel keeps its own. +const libraryArt = createCardArtCache({ requestGrid: (id) => window.api.requestGrid(id) }); +const libraryScreen = createLibraryScreen({ + audio, + getTranslator, + art: libraryArt, + getGames: () => currentGames, + // Read lazily for the same reason the carousel seam is: `controls` is created just below. + onOpenGame: (id) => openGameDetail(id, 'library'), + onAddGame: () => { + // The Customize screen closing is what brings the library back (see restoreOrigin) — cancelled or + // not. Set BEFORE the hand-over: openAddGame closes this screen on its way in. + returnTo = 'library'; + controls.openAddGame(); + }, + onClosed: () => { + controls.settingsClosed(); + // Opening this screen told main "nothing is on screen" (its card is a launcher card). Closing it + // hands the carousel back, so main has to hear what the row is standing on — which is not + // necessarily what it was: deleting a game from here moves the highlight onto its neighbour, and + // without this that neighbour would sit under the launcher's idle background and silence. + carousel.announce(); + }, +}); + +const toast = createToast({ + audio, + isBlocked: () => controls.isPopupOpen(), +}); + const controls = createControls({ getState: () => currentState, + getLocale: () => currentLocale, + getNotifications: () => notificationItems, + onPopupClosed: () => toast.resume(), + 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, getBrowse: () => currentBrowse, audio, getTranslator, + settings: settingsScreen, + gameSettings: gameSettingsScreen, + library: libraryScreen, + onFlipping: (flipping) => { + if (flipping) { + if (flipSettleTimer !== 0) { + window.clearTimeout(flipSettleTimer); + flipSettleTimer = 0; + } + stripFlipping = true; + hero.setFlipping(true); + carousel.setFlipping(true); + libraryScreen.setFlipping(true); + return; + } + if (flipSettleTimer !== 0) return; + flipSettleTimer = window.setTimeout(() => { + flipSettleTimer = 0; + stripFlipping = false; + hero.setFlipping(false); + carousel.setFlipping(false); + libraryScreen.setFlipping(false); + // The title stays hidden for the whole run (see textSwapPending) — this is where it comes back, on + // the game the row finally came to rest on. + render(currentState); + }, FLIP_SETTLE_MS); + }, // Read lazily: the carousel is created below (it needs `controls` for its own callbacks), so the seam // is a set of thunks rather than the object itself. carousel: { screen: () => carousel.screen(), move: (delta) => carousel.move(delta), activate: () => carousel.activate(), + onGame: () => carousel.selected()?.kind === 'game', leaveDetail: () => leaveDetail(), - exists: () => carousel.exists(), + setUnread: (unread) => carousel.setUnread(unread), }, }); @@ -101,19 +346,23 @@ const carousel = createCarousel({ requestedBrowseId = id; window.api.browseGame(id); }, + // A launcher card is selected: there is no game on screen at all. main answers with an empty browse on + // the same channels, so the title, the background and the music all clear through their usual path. + browseNone: () => { + requestedBrowseId = null; + window.api.browseGame(null); + }, + getTranslator, onScreenChange: () => { controls.refresh(); render(currentState); }, - onActivate: (entry) => { - // Entering a card is an ordinary button press — same cue as any other "open" action. + onActivate: (item) => { + // Entering a card is an ordinary button press — same cue as any other "open" action, and a launcher + // card is no different (the surface it opens then plays its own popup-open on top). audio.play('button'); - userChoseDetail = true; - // An active game must also become the CARD's selected game (main rebuilds its hero/audio/GameInfo). - // If that is refused — a launch or install is in flight — the detail screen is still correct: it is - // drawn from the browse model, so it shows the game you picked, just without an actionable Play. - if (entry.active && gameOf(currentState)?.id !== entry.id) window.api.selectGame(entry.id); - carousel.setScreen('detail'); + if (item.kind === 'game') openGameDetail(item.game.id); + else controls.openSystemCard(item.card.id); }, onNavigate: (delta) => { audio.play('navigate'); @@ -134,10 +383,109 @@ const carousel = createCarousel({ }, }); -/** Back out of a detail screen to the carousel (B). False when there is no carousel to return to. */ +/** + * Where the screen ABOVE this one goes back to. A detail screen — and the Customize screen in add mode — + * can be reached from the carousel or from the Library, and "back" has to mean the place it was actually + * entered from. One flag for both, because it is one question: the surface underneath is either standing + * open behind them or it is not. + */ +type ReturnTo = 'carousel' | 'library'; +let returnTo: ReturnTo = 'carousel'; + +/** Brings the Library back up if that is where the top screen was entered from. Consumes the flag. */ +function restoreOrigin(): void { + if (returnTo !== 'library') return; + returnTo = 'carousel'; + libraryScreen.restore(); + // …and undo what opening the game did to everything AROUND the screen. The detail screen took the + // game's wallpaper, its palette and its music with it; the library is a launcher surface and belongs + // over the launcher's own. The strip goes back to the Library card the screen was opened from (the + // only way in), and main is told nothing is on screen — the same thing selecting that card does. + carousel.focusSystem(); + requestedBrowseId = null; + window.api.browseGame(null); +} + +/** + * Open one game's detail screen. Lifted out of the carousel's activate callback, because it is now + * reached from three places: pressing a card, pressing a notification about that game, and the Library's + * grid — which is what `origin` records, so B and up come back to the grid rather than to the strip. + * + * A game that is not in the list has nowhere to open — its card is out and its history record was + * evicted — so the press simply does nothing rather than opening an empty screen. + */ +function openGameDetail(id: string, origin: ReturnTo = 'carousel'): void { + const entry = currentGames.find((game) => game.id === id); + if (entry === undefined) return; + returnTo = origin; + if (origin === 'library') { + // The play button morphs out of the card's artwork, and it reads the CAROUSEL's cache synchronously + // — so the cover the grid already decoded is handed over, or the screen would open on an empty plate. + const url = libraryScreen.artFor(id); + if (url !== null) carousel.primeArt(entry, url); + libraryScreen.close(true); + } + userChoseDetail = true; + // Committing to a game outranks the debounce main applies while flipping: ask for its hero and music + // NOW. Without this, opening a game straight out of a fast flip leaves the previous game's background + // and music on its screen until the debounce elapses. + requestedBrowseId = id; + window.api.browseGame(id, true); + // An active game must also become the CARD's selected game (main rebuilds its hero/audio/GameInfo). + // If that is refused — a launch or install is in flight — the detail screen is still correct: it is + // drawn from the browse model, so it shows the game you picked, just without an actionable Play. + if (entry.active && gameOf(currentState)?.id !== id) window.api.selectGame(id); + // A no-op when the strip is already on it (the carousel path), and the whole point when it is not. + carousel.focusGame(id); + // The row is a SHORTLIST (MAX_STRIP_GAMES), so a game reached from the Library or from a notification + // may have no card in it — and then the morph would wear whichever card happens to be selected, i.e. + // another game's cover. Name the source explicitly in that case; null is an honest empty plate. + const onStrip = carousel.selected(); + if (!(onStrip?.kind === 'game' && onStrip.game.id === id)) { + carousel.setDetailArt(libraryScreen.artFor(id)); + } + // Keeps the row out of sight for this screen — it has no hand-over to play here (see the rule in + // styles.css). Cleared by leaveDetail / showAddedGame, i.e. wherever the carousel becomes the screen. + if (origin === 'library') app.dataset['detailFrom'] = 'library'; + else delete app.dataset['detailFrom']; + carousel.setScreen('detail'); +} + +/** + * A game was just added AND applied: put the user in front of it — in the LIBRARY, standing on it. + * + * The carousel used to be the destination, and for a game that fits on it that was fine. But the row is + * a shortlist (MAX_STRIP_GAMES) ordered by recency, and a game that does not make it has no card there: + * the strip landed on whichever game was first while the background and the music belonged to the new + * one, which reads as the launcher having opened the wrong game. The library holds every game by + * construction, so it can always show the one that was just made. + */ +function showAddedGame(id: string): void { + // The Customize screen reported the new game AFTER announcing it closed, so onClosed has already put + // the library back up if that is where the user came from. Either way it is reopened ON the new game, + // and both happen in one task, so no frame is drawn in between and nothing flickers. + returnTo = 'carousel'; + delete app.dataset['detailFrom']; + userChoseDetail = false; + libraryScreen.close(true); + carousel.setScreen('carousel'); + // The library is a launcher surface: the strip stands on its card and main is told nothing is on + // screen, exactly as restoreOrigin does. Without this the new game's wallpaper and music would play + // under a screen that is not showing it. + carousel.focusSystem(); + requestedBrowseId = null; + window.api.browseGame(null); + libraryScreen.open({ focusId: id }); +} + +/** Back out of a detail screen to the carousel (B). False when the carousel is already the screen. */ function leaveDetail(): boolean { - if (!carousel.exists() || carousel.screen() !== 'detail') return false; + if (carousel.screen() !== 'detail') return false; userChoseDetail = false; + delete app.dataset['detailFrom']; + // BEFORE setScreen: switching the level fires onScreenChange, whose refresh() has to see the library + // already open — otherwise the focus is computed for a carousel that is about to be covered again. + restoreOrigin(); carousel.setScreen('carousel'); return true; } @@ -157,13 +505,37 @@ function infoItem(label: string, value: string): HTMLElement { return item; } +/** + * Fills the Details popup's stats panel. Rebuilt only when the panel is empty — otherwise the three rows + * are updated IN PLACE. Not a micro-optimization: the rows carry the popup's staggered entrance (see + * popup-item-in in styles.css), which replays whenever the nodes are recreated. render() runs on every + * state push and on every carousel step, so rebuilding here would restart that entrance mid-view and the + * stats would flicker while the user reads them. + */ function buildInfoPanel(stats: Stats): void { + const rows: readonly (readonly [string, string])[] = [ + [ + translator('launcher.info.lastPlayed'), + formatDate(stats.lastPlayedAt, translator, currentLocale), + ], + [translator('launcher.info.playtime'), formatPlaytime(stats.totalPlaySeconds, translator)], + [translator('launcher.info.launches'), String(stats.launchCount)], + ]; + const existing = [...infoPanel.children]; + if (existing.length === rows.length) { + existing.forEach((item, i) => { + const row = rows[i]; + if (row === undefined) return; + const [label, value] = row; + const labelEl = item.querySelector('.info-label'); + const valueEl = item.querySelector('.info-value'); + if (labelEl !== null) labelEl.textContent = label; + if (valueEl !== null) valueEl.textContent = value; + }); + return; + } while (infoPanel.firstChild !== null) infoPanel.removeChild(infoPanel.firstChild); - infoPanel.append( - infoItem(translator('launcher.info.lastPlayed'), formatDate(stats.lastPlayedAt, translator, currentLocale)), - infoItem(translator('launcher.info.playtime'), formatPlaytime(stats.totalPlaySeconds, translator)), - infoItem(translator('launcher.info.launches'), String(stats.launchCount)), - ); + infoPanel.append(...rows.map(([label, value]) => infoItem(label, value))); } // ── Title / status busy layout ────────────────────────────────────────────── @@ -257,7 +629,10 @@ 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 subject = gameOf(currentState)?.id; - if (currentBrowse !== null && subject !== undefined && subject !== currentBrowse.id) return ''; + // 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)}` @@ -296,29 +671,36 @@ function render(state: AppState): void { // `idle` while the history still has games to show, and while game A installs you may be looking at // game B. The single-game card case is unchanged by construction — there browse.id === state.game.id. const browse = currentBrowse; + // The title/status were hidden for a pending selection change (see onNavigate); this is where they come + // back — for whatever the row came to rest on, a game OR a launcher card. ABOVE the branch on purpose: + // done inside the `browse !== null` half, a launcher card's name was written and never revealed. + if (textSwapPending && !stripFlipping) { + textSwapPending = false; + // Next frame, so the browser sees the hidden state first and actually animates the fade back in + // (dropping the class in the same frame as the text would be coalesced into no transition at all). + // The status is revealed together with the title — applyStatus (below) has already put the new + // line in, or emptied it, by the time this frame runs. + requestAnimationFrame(() => { + titleEl.classList.remove('is-swapping'); + statusEl.classList.remove('is-swapping'); + }); + } if (browse !== null) { // Hero images travel on their own channels (hero:update / browse:hero), independent of state:update — // on a window reconnect render can arrive before the payload. Only paint when we already have images; // an empty list means "wait for the push" (it back-fills), rather than blanking the background. hero.repaint(); titleEl.textContent = browse.title; - if (textSwapPending) { - textSwapPending = false; - // Next frame, so the browser sees the hidden state first and actually animates the fade back in - // (dropping the class in the same frame as the text would be coalesced into no transition at all). - // The status is revealed together with the title — applyStatus (below) has already put the new - // line in, or emptied it, by the time this frame runs. - requestAnimationFrame(() => { - titleEl.classList.remove('is-swapping'); - statusEl.classList.remove('is-swapping'); - }); - } buildInfoPanel(browse.stats); controls.applyGameButtons(); } else { - // No card AND no history → the empty "Insert a game card" screen (wallpaper background). Clear any - // stale stats so the empty screen's Details menu (opened via More) shows just System + Close. - hero.applyEmptyScreen(); + // No game on screen: the carousel is standing on one of the launcher's own cards. It names itself in + // the title line, exactly where a game's name goes — except the power card, which the mockup leaves + // unnamed. The BACKGROUND is deliberately not touched here: it arrives on the debounced browse:hero + // channel (see onBrowseHero), so flipping past these cards doesn't make the wallpaper blink. + const item = carousel.selected(); + const titleKey = item !== undefined && item.kind === 'system' ? item.card.titleKey : null; + titleEl.textContent = titleKey === null ? '' : translator(titleKey); while (infoPanel.firstChild !== null) infoPanel.removeChild(infoPanel.firstChild); controls.clearGameButtons(); } @@ -346,9 +728,14 @@ function render(state: AppState): void { // below this holds on BOTH screens, because it decides how the card TRANSITIONS: with a Play it hands // its geometry to the button (a swap, then a morph); without one it has nothing to hand over and // shrinks away instead — and coming back, only the morph case waits for the button to grow (styles.css). + // (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); app.dataset['cardMorph'] = hasPlay ? 'on' : 'off'; @@ -362,6 +749,7 @@ function render(state: AppState): void { // 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); syncChatter(state); applyStatus(); @@ -371,7 +759,7 @@ function render(state: AppState): void { syncMusic(); // Empty-screen error (Р8, point 1): 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 empty screen via the + // 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. if (state.kind === 'error' && game === undefined && prev.kind !== 'error') { @@ -379,6 +767,137 @@ 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'); + }); +}); + +// 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). @@ -390,17 +909,41 @@ function applyLocale(locale: Locale): void { document.documentElement.lang = locale; localizeDocument(translator); render(currentState); + // The Settings screen builds its rows from JS, so localizeDocument doesn't reach them — and render() + // knows nothing about it. It keeps its focus and scroll position across the swap. + settingsScreen.relocalize(); + gameSettingsScreen.relocalize(); + libraryScreen.relocalize(); + // The notification list is built from JS too, and its text is ASSEMBLED from the kind rather than + // stored — which is the whole reason it is not stored: a language change rewrites it in place. + controls.applyNotifications(); } window.api.onLanguageUpdate(applyLocale); void window.api.getLanguage().then(applyLocale); window.api.onStateUpdate(render); -void window.api.requestState().then(render); +void window.api.requestState().then((state) => { + render(state); + noteBootSeed('state'); +}); // What is on screen (title / stats / active / GameInfo). Subscribe BEFORE the seed, like every other // channel here, so a push arriving in between isn't lost. function applyBrowse(browse: BrowseInfo | null): void { + // Whether there WAS a game on screen a moment ago — the detail screen below acts on the transition, not + // on the state (at startup there is no game on screen yet either, and that is not a game going away). + const hadGame = currentBrowse !== null; 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). + 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 + // is nothing left for it to show: step back to the row, which is what the user would otherwise be + // looking at a launcher card's "detail screen" instead of. `userChoseDetail` is cleared with it (that + // is what leaveDetail does) — the choice was about a game that no longer exists. + if (hadGame && browse === null && carousel.screen() === 'detail') leaveDetail(); // Main moved the screen onto a game we didn't ask for — inserting a card switches to ITS game — so the // strip must follow, or the title/background belong to one game while the highlighted card is another. // Guarded by the requested id: while flipping, a late answer must NOT drag the selection backwards. @@ -415,34 +958,72 @@ void window.api.requestBrowse().then((browse) => { applyBrowse(browse); // The seed carries the INFO only; asking main to browse the same game again replays its hero/music, so // a reloaded window doesn't come back with a blank background. - if (browse !== null) window.api.browseGame(browse.id); + if (browse !== null) { + window.api.browseGame(browse.id); + return; + } + // Nothing on screen — the user is parked on a launcher card. There is no game for main to replay, but + // the audio engine still has to be told: left at its default it would fall through to the inserted + // card's music and play a game's theme under a launcher card until the user moved off it. + audio.setBrowseMusic(null, true); + syncMusic(); }); // The browsed game's background: a channel of its own, so a history game can be shown without touching // the inserted card's hero:update payload (which stays valid for the card's selected game). -window.api.onBrowseHero((assets) => hero.applyBrowseAssets(assets)); +window.api.onBrowseHero((assets) => { + // Nothing on screen (a launcher card) → the idle wallpaper. It has to happen HERE rather than in + // render(): this channel is the debounced one, so the background changes when the row STOPS on a card, + // the same way it does between games. applyBrowseAssets(null) would not do — it paints only while a + // game is on screen (hero.ts), which is exactly what this case is not. + if (currentBrowse === null) hero.applyIdleBackground(); + else hero.applyBrowseAssets(assets); +}); // The browsed game's music. Music ONLY — the SFX set is never rebuilt by browsing, so flipping through // the carousel doesn't re-create the sound elements on every step. window.api.onBrowseMusic((url) => { - audio.setBrowseMusic(url); + // On the same (debounced) channel as the background, for the same reason: a launcher card means the + // ambience, and switching to it while merely flipping PAST the card would tear the music. Not in + // applyBrowse — that one rides the instant channel. + audio.setBrowseMusic(url, currentBrowse === null); syncMusic(); }); // A failed launch returns to 'ready' and sends the reason here to open the error popup. window.api.onError((messageText) => controls.showError(messageText)); -// Fallback wallpaper for the empty screen (data URL from main); apply if we're on it already. -void window.api.requestWallpaper().then((url) => { - hero.setWallpaper(url); - if (gameOf(currentState) === undefined) hero.applyEmptyScreen(); +// Settings screen data. Subscribe BEFORE the seeds (the pattern every channel here follows) so a push +// arriving in between isn't lost. The push is the ONLY source of values — a reset lands here too, so +// the screen never has to reconcile an invoke result with a push. +window.api.onSettingsUpdate((settings) => settingsScreen.applySettings(settings)); +window.api.onUpdateStatus((status) => settingsScreen.applyUpdateStatus(status)); +void window.api.getSettings().then((settings) => settingsScreen.applySettings(settings)); +void window.api.requestUpdateStatus().then((status) => settingsScreen.applyUpdateStatus(status)); +// The environment seeds: they never change during a session. +void Promise.all([ + window.api.isSteamAvailable(), + window.api.getAudioOptions(), + window.api.getAppVersion(), +]).then(([steamAvailable, audioOptions, appVersion]) => { + settingsScreen.applyEnv({ steamAvailable, audioOptions, appVersion }); }); -// Live custom-wallpaper updates (settings window changed the Empty-screen background). An empty string -// means "no custom / bundle unreadable" → treat as null. Repaint immediately if we're on the Empty screen. -window.api.onWallpaperUpdate((url) => { - hero.setWallpaper(url === '' ? null : url); - if (gameOf(currentState) === undefined) hero.applyEmptyScreen(); +// Fallback wallpaper for the idle background (data URL from main). It doubles as the session's OPENING +// backdrop: it paints on #hero-boot, above the hero layers, and holds the screen while the rest of the +// launcher loads underneath (see the boot reveal above). It ALSO goes on a hero layer, as it always has: +// 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(); }); // The card's music is delivered on its own channel (not in AppState); load it and keep music in sync. @@ -472,10 +1053,6 @@ void window.api.requestAmbient().then((url) => { window.api.onSfxSet((set) => audio.setSounds(set)); void window.api.requestSfxSet().then((set) => audio.setSounds(set)); -// One-shot UI sounds pushed from main (main has no <audio> — the renderer owns playback). Used for the -// "play" sound when an install/copy/Steam download completes, where the trigger lives in main. -window.api.onSfxPlay((name) => audio.play(name)); - // Audio volumes are app-wide (set in the settings window): seed them on startup and update live. const applyVolumes = (volumes: { music: number; sfx: number }): void => { audio.setMusicVolume(volumes.music); @@ -491,20 +1068,73 @@ window.api.onWindowFocus((focused) => controls.setGamepadPaused(!focused)); // Hero images are delivered on their own channel (not in AppState): the renderer rotates through them // locally, so we never re-send this large payload on every state transition. See hero.applyAssets. -window.api.onHeroUpdate((assets) => hero.applyAssets(assets)); -void window.api.requestHero().then((assets) => hero.applyAssets(assets)); +window.api.onHeroUpdate((assets) => { + hero.applyAssets(assets); + if (assets !== null) { + heroPayload = 'present'; + noteBackgroundSettled(); + } +}); +void window.api.requestHero().then((assets) => { + hero.applyAssets(assets); + heroPayload = assets === null ? 'none' : 'present'; + noteBackgroundSettled(); +}); // The carousel list (the inserted card's games + the play history, already ordered) arrives on its own // channel. Seed on startup (back-fill after a window reconnect), then live updates. function applyLibrary(games: readonly LibraryEntry[]): void { + currentGames = games; carousel.setGames(games); - // The carousel is the default level whenever there is more than one game to flip through — but never - // yank the user out of a detail screen they opened themselves (they may be watching an install run). - if (carousel.exists() && !userChoseDetail) carousel.setScreen('carousel'); + // The grid re-flows the same way the strip does — the screen may be open while a card goes in or out. + libraryScreen.setGames(games); + // main says nothing is on screen while the row is standing on a GAME: the user was parked on a launcher + // card and this window is a fresh one (a reload re-seeds the list, but which of the three cards it was + // is remembered nowhere), so the strip lands on games[0] with an idle background over it. Put it back on + // the launcher cards, without telling main anything — its cursor did not move. + // + // ONLY while we have not asked for a game ourselves. `currentBrowse` is null for the beat between the + // question and the answer too, and a list update landing inside it reads that beat as "parked on a + // launcher card" — which is exactly what a freshly added game does: main copies its assets in the + // background and refreshes the row when it is done, right while the browse answer for that game is + // still in flight. The strip was dragged off the very card the answer was about, and the game's + // background and colours then arrived under a launcher card. + const selected = carousel.selected(); + if (currentBrowse === null && requestedBrowseId === null && selected?.kind !== 'system') { + carousel.focusSystem(); + } + // The carousel is the default level — but never yank the user out of a detail screen they opened + // themselves (they may be watching an install run). + if (!userChoseDetail) carousel.setScreen('carousel'); render(currentState); } window.api.onLibraryUpdate((library) => applyLibrary(library?.games ?? [])); -void window.api.requestLibrary().then((library) => applyLibrary(library?.games ?? [])); +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'); +}); + +// The notification inbox and the plates main asks us to show. The list is the popup's only source of +// truth; the plate is a one-shot surface of our own (see toast.ts). Subscribe BEFORE the seed, like +// every other channel here, so a push arriving in between isn't lost. +function applyNotifications(items: readonly AppNotification[]): void { + notificationItems = items; + controls.applyNotifications(); +} +window.api.onNotifications(applyNotifications); +void window.api.requestNotifications().then(applyNotifications); + +window.api.onNotificationToast((incoming) => { + if (incoming.kind === 'unread-summary') { + toast.show(translator.tp('notifications.unread', incoming.count)); + return; + } + // A plate is never a read receipt: it is up for a few seconds and the user may be looking elsewhere, + // so the dot beside the More item has to outlive it. Only opening the popup clears the unread state. + toast.show(formatNotification(incoming.item, translator)); +}); // Game Mode (gamescope) is static for the process — seed it once so the power menu shows "Close Playhook" // (full quit) instead of the no-op "Minimize Playhook". diff --git a/src/renderer/audio.ts b/src/renderer/audio.ts index 7229cd3b..0c136854 100644 --- a/src/renderer/audio.ts +++ b/src/renderer/audio.ts @@ -7,6 +7,7 @@ // 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 { shouldPlayLimit } from './sfx-limit.js'; // Fallback volumes until the persisted ones arrive from main (music historically played at 0.5). const DEFAULT_MUSIC_VOLUME = 0.5; @@ -17,22 +18,55 @@ const FADE_MS = 800; // Volume within this of the target counts as "arrived" (float ramps never land exactly). const FADE_EPSILON = 0.001; +// Music is held while the startup jingle plays, so the release must be guaranteed — `ended` alone is +// not. Once the jingle's real length is known the gate opens that long after it began, plus this margin; +// until then (and if the metadata never arrives) the hard cap below is what frees the music. +const JINGLE_GRACE_MS = 400; +const JINGLE_MAX_MS = 20_000; + export interface AudioController { /** Sets the inserted card's own background music (data URL), or clears it when null. */ setCardMusic(url: string | null): void; /** - * Music of the game currently ON SCREEN (the carousel's browse channel), which wins over the card's own - * music: with the card pulled you are looking at a history game and must hear ITS theme. null falls back - * to the card music, then to the ambience. Music only — the SFX set is untouched, so flipping through - * the carousel never rebuilds the sound elements. + * What is ON SCREEN, musically — the carousel's browse channel, in ONE statement: + * • `url` — the browsed game's own music, which wins over the card's (with the card pulled you are + * looking at a history game and must hear ITS theme); null falls back to the card music, then to + * the ambience; + * • `idle` — there is no game on screen at all, the row is standing on one of the launcher's own + * cards. Then the ambience plays whatever is in the drive: a plain null would fall through to the + * CARD's music, and these cards are meant to sound like the launcher, not like the inserted game. + * + * The two are ONE call rather than two setters because they always arrive together, and applying them + * one at a time passes through a third source in between — idle off while the url is still null is the + * CARD's music — which starts a cross-fade the next call immediately interrupts. An interrupted + * cross-fade drops the outgoing element outright (see crossfadeTo), and that is heard as the music + * being cut off rather than faded. + * + * Music only — the SFX set is untouched, so flipping through the carousel never rebuilds the sound + * elements. */ - setBrowseMusic(url: string | null): void; + setBrowseMusic(url: string | null, idle: boolean): void; /** Sets the app-wide default ambience (data URL), or clears it when null. */ setAmbient(url: string | null): void; /** The bundled UI sound set — every sound the app plays, on every screen. */ setSounds(set: SfxSet | null): void; /** Plays a one-shot UI sound; a no-op when that slot isn't configured. */ play(name: SfxName): void; + /** + * Plays the `limit` dead-end sound, at most once per series of blocked attempts. Every call counts as + * an attempt (that is what keeps a held direction from re-arming the latch by idling); the sound only + * comes out when the latch is armed — see sfx-limit.ts. + */ + playLimit(): void; + /** Ends the current series of blocked attempts, so the next one sounds again. Called on release. */ + rearmLimit(): void; + /** + * Plays the bundled startup jingle, once. Resolves the moment playback actually STARTS — the boot + * sequence times the hand-over from the boot image to the UI off it, so the jingle's two halves line up + * with what is on screen (see the boot reveal in app.ts). Resolves right away when it can't play at + * all: a silent launcher must still boot. + */ + playStartup(url: string): Promise<void>; /** Starts/stops the background music+ambience to match the desired playing state. */ setMusicPlaying(shouldPlay: boolean): void; /** Sets the background-music/ambience volume (0..1), live. */ @@ -49,6 +83,22 @@ interface Player { export function createAudioController(): AudioController { const sfx = new Map<SfxName, HTMLAudioElement>(); + // The startup jingle, kept only so a late volumes seed can still reach it while it plays. + let startup: HTMLAudioElement | null = null; + // The `limit` latch: one sound per series of blocked attempts, armed by a release. App-wide on + // purpose, not per slot or per surface — a left edge and a dead LB 100 ms later are one dead end to + // the ear, and a doubled `limit` sounds worse than a swallowed second one. + let limitArmed = true; + let lastLimitAttemptAt = Number.NEGATIVE_INFINITY; + + const playSfx = (name: SfxName): void => { + const el = sfx.get(name); + if (el === undefined) return; + // Clone so rapid retriggers (fast navigation) overlap instead of cutting each other off. + const node = el.cloneNode() as HTMLAudioElement; + node.volume = sfxVolume; + void node.play().catch(() => undefined); + }; /** Builds the <audio> elements for one sound set into `target` (cleared first). */ const loadSounds = (target: Map<SfxName, HTMLAudioElement>, assets: SfxSet | null): void => { @@ -71,6 +121,8 @@ export function createAudioController(): AudioController { let browseMusic: string | null = null; let gameMusic: string | null = null; let ambient: string | null = null; + // No game on screen (a launcher card is selected) — see setBrowseMusic. + let browseIdle = false; // The currently-primary player (fading IN or steady) and, during a crossfade, the outgoing one (fading // OUT). `activeUrl` mirrors the effective source we've committed to — the idempotence key. @@ -78,9 +130,15 @@ export function createAudioController(): AudioController { let outgoing: Player | null = null; let activeUrl: string | null = null; - // The gate result (visible && !running). NOT a short-circuit: a repeated `true` re-issues play() on the - // live element (resurrecting an OS-muted one after sleep) without restarting the fade. + // The gate result (visible && !running, AND the startup jingle has finished). NOT a short-circuit: a + // repeated `true` re-issues play() on the live element (resurrecting an OS-muted one after sleep) + // without restarting the fade. let wantPlay = false; + // What app.ts asked for, before the jingle gate below is applied to it. + let musicWanted = false; + // The startup jingle is still sounding. Music waits it out rather than playing underneath it: the two + // are unrelated pieces of audio and the overlap is just mush. + let jinglePlaying = false; let musicVolume = DEFAULT_MUSIC_VOLUME; let sfxVolume = DEFAULT_SFX_VOLUME; @@ -181,8 +239,34 @@ export function createAudioController(): AudioController { ensureFade(); }; + /** + * Puts playback where the two gates say it should be: what app.ts asked for (visible && !running) and + * whether the startup jingle is still sounding. Called on every change to either, which is why it is + * NOT a short-circuit on an unchanged value: re-issuing play() on the live element is what resurrects + * one the OS muted while the machine slept. + */ + const applyPlayback = (): void => { + wantPlay = musicWanted && !jinglePlaying; + if (wantPlay) { + // Always (re-)issue play() on the live elements. Then ramp only if we're not already at the target + // (a cold start from 0 fades in; an already-full resume from the tray just plays, no volume dip). + if (active !== null) void active.el.play().catch(() => undefined); + if (outgoing !== null) void outgoing.el.play().catch(() => undefined); + const activeSettled = + active === null || Math.abs(active.el.volume - musicVolume) <= FADE_EPSILON; + if (!activeSettled || outgoing !== null) ensureFade(); + return; + } + stopFade(); + if (active !== null) active.el.pause(); + if (outgoing !== null) { + drop(outgoing); + outgoing = null; + } + }; + const applyEffective = (): void => { - const target = browseMusic ?? gameMusic ?? ambient; + const target = browseIdle ? ambient : (browseMusic ?? gameMusic ?? ambient); if (target === activeUrl) return; // idempotent: same effective source → never restart playback if (wantPlay) crossfadeTo(target); else hardSwap(target); @@ -195,10 +279,11 @@ export function createAudioController(): AudioController { applyEffective(); }, - setBrowseMusic(url: string | null): void { - if (url === browseMusic) return; + setBrowseMusic(url: string | null, idle: boolean): void { + if (url === browseMusic && idle === browseIdle) return; browseMusic = url; - applyEffective(); + browseIdle = idle; + applyEffective(); // both fields first, THEN one apply — see the interface note }, setAmbient(url: string | null): void { @@ -211,34 +296,24 @@ export function createAudioController(): AudioController { loadSounds(sfx, set); }, - play(name: SfxName): void { - const el = sfx.get(name); - if (el === undefined) return; - // Clone so rapid retriggers (fast navigation) overlap instead of cutting each other off. - const node = el.cloneNode() as HTMLAudioElement; - node.volume = sfxVolume; - void node.play().catch(() => undefined); + play: playSfx, + + playLimit(): void { + const now = performance.now(); + const sound = shouldPlayLimit(limitArmed, lastLimitAttemptAt, now); + lastLimitAttemptAt = now; + if (!sound) return; + limitArmed = false; + playSfx('limit'); + }, + + rearmLimit(): void { + limitArmed = true; }, setMusicPlaying(shouldPlay: boolean): void { - wantPlay = shouldPlay; - if (shouldPlay) { - // Always (re-)issue play() on the live elements — this is what resurrects an OS-muted element - // after sleep. Then ramp only if we're not already at the target (a cold start from 0 fades in; - // an already-full resume from the tray just plays, no volume dip). - if (active !== null) void active.el.play().catch(() => undefined); - if (outgoing !== null) void outgoing.el.play().catch(() => undefined); - const activeSettled = - active === null || Math.abs(active.el.volume - musicVolume) <= FADE_EPSILON; - if (!activeSettled || outgoing !== null) ensureFade(); - } else { - stopFade(); - if (active !== null) active.el.pause(); - if (outgoing !== null) { - drop(outgoing); - outgoing = null; - } - } + musicWanted = shouldPlay; + applyPlayback(); }, setMusicVolume(volume: number): void { @@ -248,9 +323,46 @@ export function createAudioController(): AudioController { if (fadeHandle === null && active !== null) active.el.volume = volume; }, + async playStartup(url: string): Promise<void> { + const el = new Audio(url); + el.volume = sfxVolume; + startup = el; + // Music (and ambience) waits for the jingle to finish, so hold it here and release it below. Not + // just at startup: if a track had already begun — the seeds can land first — this stops it, which + // at that point is a barely-started fade-in, not an audible cut. + jinglePlaying = true; + applyPlayback(); + let watchdog = 0; + const release = (): void => { + if (startup !== el) return; // superseded — whoever replaced it owns the gate now + if (watchdog !== 0) window.clearTimeout(watchdog); + startup = null; + jinglePlaying = false; + applyPlayback(); + }; + el.addEventListener('ended', release); + el.addEventListener('error', release); + // `ended` can never come (the output device disappears mid-play), and music that never returns is + // far worse than music that returns early — so the gate also opens on its own. + el.addEventListener('loadedmetadata', () => { + if (startup !== el || !Number.isFinite(el.duration)) return; + if (watchdog !== 0) window.clearTimeout(watchdog); + watchdog = window.setTimeout(release, el.duration * 1000 + JINGLE_GRACE_MS); + }); + watchdog = window.setTimeout(release, JINGLE_MAX_MS); + // The volumes seed arrives over IPC and may land AFTER this, hence startup being kept around for + // setSfxVolume below — the jingle follows the SFX slider like every other one-shot. + try { + await el.play(); + } catch { + // Playback refused (no output device, an autoplay policy): boot on in silence, music included. + release(); + } + }, setSfxVolume(volume: number): void { sfxVolume = volume; for (const el of sfx.values()) el.volume = volume; + if (startup !== null) startup.volume = volume; }, }; } diff --git a/src/renderer/auto-repeat.ts b/src/renderer/auto-repeat.ts new file mode 100644 index 00000000..2654135e --- /dev/null +++ b/src/renderer/auto-repeat.ts @@ -0,0 +1,46 @@ +// 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 +// 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). */ +export const HOLD_DELAY_MS = 175; + +/** The auto-move's own cadence once it has kicked in. Also what the strip's glide step is derived from. */ +export const NAV_REPEAT_MS = 110; + +/** + * How long a finished run stays "warm": a direction pressed within this window continues the previous + * auto-move instead of starting a new one, and so skips the initial delay. It is what makes swinging + * left→right mid-flight one uninterrupted glide rather than two runs with a stall between them — the + * stick passes through its centre on the way over, and the d-pad has a gap of its own, so the release + * that happens in between must not count as "the user stopped". Stopping for real outlasts this. + */ +export const AUTO_CHAIN_MS = 200; + +/** + * Whether a hold starting at `now` continues the run whose last repeat fired at `lastRepeatAt`. + * Pure — unit-tested. + */ +export function continuesRun(lastRepeatAt: number, now: number): boolean { + return now - lastRepeatAt < AUTO_CHAIN_MS; +} + +/** The shared "is the auto-move still warm" state — one per app, since there is one pair of hands. */ +export interface AutoRepeatChain { + /** Records an auto-move step, keeping the run warm. */ + noteRepeat(now: number): void; + /** Whether a hold starting now may skip the initial delay (see continuesRun). */ + continues(now: number): boolean; +} + +export function createAutoRepeatChain(): AutoRepeatChain { + let lastRepeatAt = Number.NEGATIVE_INFINITY; + return { + noteRepeat(now: number): void { + lastRepeatAt = now; + }, + continues(now: number): boolean { + return continuesRun(lastRepeatAt, now); + }, + }; +} diff --git a/src/renderer/card-art.ts b/src/renderer/card-art.ts new file mode 100644 index 00000000..778464f1 --- /dev/null +++ b/src/renderer/card-art.ts @@ -0,0 +1,139 @@ +/** + * The Library grid's artwork cache: a bounded LRU in front of `library:grid-request`, with a queue in + * front of the IPC. + * + * Both bounds exist for the same reason. A cover is generated by MAIN on the first request — decode, + * resize, encode, write (see LibraryStore.readGridThumb) — synchronously, on the very process that also + * serves every other IPC call of the launcher. The grid's window is up to ~54 cards, so a cold library + * would fire ~54 of those at once and stall main; and holding every cover a scroll ever touched would + * grow without limit on a library of hundreds. Hence: at most `concurrency` requests in flight, LIFO so + * the cards nearest the selection (queued last) are served first, and the oldest decoded URLs dropped. + * + * The carousel keeps its own, unbounded cache on purpose — its row holds ~40 entries at most and it works + * (see carousel.ts). This one is the library's alone. + */ +import type { LibraryEntry } from '../shared/types.js'; + +export interface CardArtDeps { + requestGrid(id: string): Promise<string | null>; +} + +export interface CardArtCache { + /** The cover, `null` when the game has none, `undefined` when it was never loaded (or was evicted). */ + get(key: string): string | null | undefined; + /** Claims the slot and asks for the cover through the queue; resolves null when there is none. */ + load(key: string, id: string): Promise<string | null>; + /** Drops the queued-but-not-yet-started requests whose keys are not in `keep`. */ + dropPending(keep: ReadonlySet<string>): void; + /** Keys the LRU threw out — the host clears their background-image. Exactly one subscriber. */ + onEvict(handler: (key: string) => void): void; +} + +/** + * How many decoded covers are held at once. Comfortably above the artwork window (~9 rows), so walking a + * row back and forth never re-requests, while a library of hundreds still has a ceiling. + */ +export const ART_CAPACITY = 120; + +/** How many covers main is asked for at once — it generates them synchronously (see the module note). */ +export const ART_CONCURRENCY = 3; + +/** Cache key of one game's artwork: the id, plus the revision that changes when main re-copies it. */ +export function artKey(game: LibraryEntry): string { + return `${game.id}@${game.artRev ?? ''}`; +} + +interface PendingRequest { + readonly id: string; + readonly promise: Promise<string | null>; + readonly settle: (url: string | null) => void; + started: boolean; +} + +export function createCardArtCache( + deps: CardArtDeps, + capacity = ART_CAPACITY, + concurrency = ART_CONCURRENCY, +): CardArtCache { + const entries = new Map<string, string | null>(); + const pending = new Map<string, PendingRequest>(); + const queue: string[] = []; + let inFlight = 0; + let evicted: (key: string) => void = () => {}; + + function touch(key: string, value: string | null): void { + entries.delete(key); + entries.set(key, value); + while (entries.size > capacity) { + const oldest = entries.keys().next(); + if (oldest.done === true) break; + entries.delete(oldest.value); + evicted(oldest.value); + } + } + + function pump(): void { + while (inFlight < concurrency) { + const key = queue.pop(); + if (key === undefined) return; + const request = pending.get(key); + if (request === undefined) continue; + request.started = true; + inFlight += 1; + void deps + .requestGrid(request.id) + .then((url) => { + touch(key, url); + request.settle(url); + }) + .catch(() => { + request.settle(null); + }) + .finally(() => { + inFlight -= 1; + pending.delete(key); + pump(); + }); + } + } + + function get(key: string): string | null | undefined { + if (!entries.has(key)) return undefined; + const value = entries.get(key) ?? null; + touch(key, value); + return value; + } + + function load(key: string, id: string): Promise<string | null> { + const cached = get(key); + if (cached !== undefined) return Promise.resolve(cached); + const queued = pending.get(key); + if (queued !== undefined) return queued.promise; + let settle: (url: string | null) => void = () => {}; + const promise = new Promise<string | null>((resolve) => { + settle = resolve; + }); + pending.set(key, { id, promise, settle, started: false }); + queue.push(key); + pump(); + return promise; + } + + function dropPending(keep: ReadonlySet<string>): void { + for (let i = queue.length - 1; i >= 0; i -= 1) { + const key = queue[i]; + if (key === undefined || keep.has(key)) continue; + queue.splice(i, 1); + const request = pending.get(key); + if (request === undefined) continue; + pending.delete(key); + request.settle(null); + } + } + + function onEvict(handler: (key: string) => void): void { + evicted = handler; + } + + return { get, load, dropPending, onEvict }; +} diff --git a/src/renderer/carousel-geometry.ts b/src/renderer/carousel-geometry.ts index ecfe4663..de73568a 100644 --- a/src/renderer/carousel-geometry.ts +++ b/src/renderer/carousel-geometry.ts @@ -2,8 +2,9 @@ // multiplies by `--px`, see styles.css). No DOM, so the maths is unit-testable. // // The strip does the moving, not the selection: the selected card always sits at the same anchor and the -// row slides under it. Cards to its LEFT keep the normal width, which is what makes the offset linear in -// the index — no per-index accumulation, no dependency on WHICH card is selected. +// row slides under it. Cards to its LEFT keep the normal width and the normal gap, which is what keeps +// the offset a straight multiple of the index — no per-index accumulation, no dependency on WHICH card +// is selected. The selected card's own breathing room (SEL_MARGIN) is the one constant added on top. /** Unselected card size (Figma "Home": Rectangle 11/12/13 are 90x135, bottom-aligned with the selected). */ export const CARD_W = 90; @@ -11,27 +12,67 @@ export const CARD_H = 135; /** Selected card size (it grows in place, anchored at its bottom-left corner). */ export const SEL_W = 136; export const SEL_H = 204; -/** Gap between cards. */ -export const GAP = 16; +/** Gap between two ordinary cards. MIRRORED by #carousel-strip's `gap` in styles.css. */ +export const GAP = 8; + +/** + * Gap on either side of the SELECTED card: it is the one thing being looked at, so it gets the room to + * be looked at. MIRRORED by the `margin` on `#carousel-strip .card.is-selected` in styles.css, which + * spells it as the DIFFERENCE below — flex lays one gap between every pair, and the selected card adds + * the rest with margins of its own. + */ +export const SEL_GAP = 24; + +/** What the selected card adds on each side, over the gap the row already has. */ +export const SEL_MARGIN = SEL_GAP - GAP; /** The distance one card advances the strip. */ export const STEP = CARD_W + GAP; +/** + * The canvas the focus body is drawn on, in design px: the whole row plus slack on every side. + * + * Sized from the COUNT rather than measured, for the same reason the offsets are: the row's own width + * is mid-transition half the time (the selected card is growing), and a canvas resized per frame would + * clear itself on every one. The widest the row can be is every card unselected but one, i.e. the + * selected card sitting at the last step. + */ +export function stripCanvas(count: number, margin = 26): { readonly width: number; readonly height: number } { + const cards = Math.max(count, 1); + return { + // The row's own width does not depend on WHICH card is selected: one card is wide, the rest are not, + // and the selected one's margins are there wherever it stands. + width: (cards - 1) * STEP + SEL_W + 2 * SEL_MARGIN + 2 * margin, + height: SEL_H + 2 * margin, + }; +} + /** * How far the strip is translated (design px, negative = leftwards) so that card `index` lands on the - * anchor. Linear by the invariant above; index 0 means "no shift". + * anchor. + * + * A straight multiple of the step, plus the selected card's own left margin: everything before it is an + * ordinary card at the ordinary gap, and the card itself then starts one margin further in. That extra + * is the SAME for every index — including 0, where the first card is pushed off the strip's origin by + * its margin like any other — so this stays one line and never accumulates. */ export function stripOffset(index: number): number { - return 0 - index * STEP; // written as a subtraction so index 0 yields +0, not the -0 of `-index * STEP` + return 0 - (index * STEP + SEL_MARGIN); // subtraction first, so index 0 yields a plain -SEL_MARGIN } /** * The left edge of card `index` once the strip is at `stripOffset(selected)` — relative to the strip's * own origin, i.e. to the anchor. Zero for the selected card (that IS the anchor), which is the invariant * the layout rests on: the selected card's left edge never moves, whichever card it is. + * + * The two sides are NOT mirror images, which is the whole reason this is spelled out rather than left as + * `(index - selected) * STEP`: to the left the row is ordinary cards at the ordinary gap, while to the + * right everything is pushed out by how much wider the selected card is AND by its two margins. */ export function cardLeft(index: number, selected: number): number { - return (index - selected) * STEP; + if (index === selected) return 0; + if (index < selected) return (index - selected) * STEP - SEL_MARGIN; + return SEL_W + SEL_GAP + (index - selected - 1) * STEP; } /** @@ -43,12 +84,39 @@ export function clampIndex(index: number, count: number): number { return Math.min(count - 1, Math.max(0, index)); } +/** + * How many cards the strip shows at once, counting the selected one. The row is anchored at the LEFT of + * the screen and grows rightwards, so a long history would otherwise run all the way to the right edge — + * a wall of covers with no shape. The window keeps the row short: the ones past it wait off-view and fade + * in as the selection moves onto them (isWithinWindow + `.is-beyond` in styles.css). + * + * Cards BEHIND the selection need no such rule: the strip slides left, so they leave the screen on their + * own (the one directly behind stays as a sliver — the hint that the row continues that way). + */ +export const VISIBLE_CARDS = 9; + +/** + * Whether card `index` is inside the shown window when `selected` is on the anchor — the selected card + * and the `size - 1` cards after it. Everything before the selection is left alone (see VISIBLE_CARDS). + */ +export function isWithinWindow(index: number, selected: number, size = VISIBLE_CARDS): boolean { + return index < selected + size; +} + +/** + * How many GAMES the strip carries at most. Home is a shortlist, not the whole library: with the four + * launcher cards after them the row tops out at 13 cards, and everything past that lives on the Library + * screen, which is built for it. The list arrives already ordered (card first, then the PC library, then + * history), so the cap keeps the most relevant ones — it never re-sorts. + */ +export const MAX_STRIP_GAMES = 9; + /** How many places of stagger the returning strip is allowed to spread over (see fanIndex). */ export const FAN_MAX = 4; -// The morph's duration, in milliseconds. MIRRORS the timing in styles.css — CSS cannot read this and JS +// The morph's duration, in milliseconds. MIRRORS `--morph` in styles.css — CSS cannot read this and JS // cannot set it, so the two must be edited together. -const MORPH_MS = 350; +const MORPH_MS = 240; /** * How long flipping through cards is refused for after coming back from the detail screen: exactly the @@ -58,6 +126,14 @@ const MORPH_MS = 350; */ export const RETURN_LOCK_MS = MORPH_MS; +/** + * How long the return's staggered fade-in runs in total: the morph, plus the last card's stagger, plus + * the fade itself. The renderer keeps `data-returning` on for exactly this long, which is what scopes the + * fan's transition-delay to the return — a card scrolling INTO the window while flipping must fade in at + * once, not wait out a delay meant for the hand-back. Mirrors styles.css (0.35s + 4 * 50ms + 0.4s). + */ +export const RETURN_FAN_MS = MORPH_MS + FAN_MAX * 50 + 400; + /** * The card's place in the "fan": how many steps from the selection it comes in, once the selected card is * back from the detail screen (styles.css multiplies this by the stagger). Capped, or the far end of a diff --git a/src/renderer/carousel.ts b/src/renderer/carousel.ts index e67f2041..f946f9dc 100644 --- a/src/renderer/carousel.ts +++ b/src/renderer/carousel.ts @@ -1,124 +1,303 @@ -// The history carousel: the launcher's top-level screen. A strip of game cards — the inserted card's -// games first (dotted: launchable right now), then what was played on this device before — sliding under -// a fixed anchor while the selection stays put. Pressing A on a card opens the existing bar screen for it -// (`detail`); B comes back here. +// The history carousel: the launcher's top-level screen. A strip of cards — the inserted card's games +// first (dotted: launchable right now), then what was played on this device before, then the launcher's +// own three cards (Notifications / Settings / System, see system-cards.ts) — sliding under a fixed anchor +// while the selection stays put. Pressing A on a game card opens the existing bar screen for it +// (`detail`); B comes back here. Pressing A on a launcher card opens that surface instead — the screen +// level does not change. // // Owns only the strip: the DOM of the cards, the selection, the artwork cache and the `data-screen` // attribute. What is SHOWN for the selected card (title, stats, background, music) is main's answer to -// `browseGame(id)` — this module never derives it. The geometry lives in carousel-geometry.ts (pure). +// `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 { Translator } from '../shared/i18n/index.js'; import { + MAX_STRIP_GAMES, + RETURN_FAN_MS, RETURN_LOCK_MS, clampIndex, fanIndex, isNearViewport, + isWithinWindow, + stripCanvas, stripOffset, } from './carousel-geometry.js'; -import { req } from './dom.js'; +import { SYSTEM_CARDS, type SystemCard } from './system-cards.js'; +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'; /** The two levels of the launcher screen (mirrors `#app[data-screen]`). */ export type Screen = 'carousel' | 'detail'; +/** + * One place in the row: a game from main's library, or one of the launcher's own cards. The row always + * holds the launcher cards, which is why there is no such thing as an empty carousel any more. + */ +export type CarouselItem = + | { readonly kind: 'game'; readonly game: LibraryEntry } + | { readonly kind: 'system'; readonly card: SystemCard }; + +/** + * What a `move` did. `at-end` is the one the caller acts on: the strip is against a hard stop, so the + * press has nowhere to go and says so (see controls.ts). It must stay distinct from `locked`, which is + * the return-morph still running and means "this press does nothing at all" — treating the two alike + * would sound a dead end on every press right after coming back. + */ +export type MoveResult = 'moved' | 'at-end' | 'locked'; + export interface CarouselDeps { /** Fetches one card's artwork as a data URL (main caches nothing; we cache by id here). */ requestGrid(id: string): Promise<string | null>; /** Tells main which game is on screen — it answers on the browse:* channels. Debounced by the caller. */ browseGame(id: string): void; + /** Tells main that no game is on screen: a launcher card is selected (main answers with empty browse). */ + browseNone(): void; /** The screen level changed (app.ts re-renders: the no-play layout and the focus model depend on it). */ onScreenChange(screen: Screen): void; - /** A card was activated (A / click on the selected card) — app.ts decides what entering detail means. */ - onActivate(entry: LibraryEntry): void; + /** A card was activated (A / click on the selected card) — app.ts decides what that means per kind. */ + onActivate(item: CarouselItem): void; /** The selection moved by `delta` cards (a nav sound / the background parallax belong to app.ts). */ onNavigate(delta: number): void; + /** The current translator (the launcher cards' aria-labels are the only text this module writes). */ + getTranslator(): Translator; } export interface Carousel { - /** New list from main (insert / removal / a finished session / an eviction). Keeps the selection BY ID. */ + /** New game list from main (insert / removal / a finished session / an eviction). Keeps the selection + * BY IDENTITY — including a launcher card, which no list update can take away. */ setGames(games: readonly LibraryEntry[]): void; /** Moves the selection by `delta` cards (no wrap-around — the ends are hard stops). */ - move(delta: number): void; + move(delta: number): MoveResult; /** * Puts the selection on `id` WITHOUT telling main about it — for the reverse direction, where main * decided what is on screen (a card was inserted, a game was picked) and the strip has to follow. * A no-op when the id isn't in the list. */ focusGame(id: string): void; + /** + * Puts the selection on the FIRST launcher card, again without telling main. Used when main's browse + * cursor says "nothing is on screen" while the row is standing on a game — a reconnected window, where + * the user was parked on a launcher card and which of the three it was is not remembered anywhere. + */ + focusSystem(): void; /** Activates the selected card (A / a click on it). */ activate(): void; /** The current screen level. */ screen(): Screen; - /** Switches level. Refused into `carousel` when there is no carousel to show (0 or 1 game). */ + /** Switches level. */ setScreen(screen: Screen): void; - /** Whether the carousel exists at all (>1 game — with one there is nothing to flip through). */ - exists(): boolean; - /** The selected entry, or undefined for an empty list. */ - selected(): LibraryEntry | undefined; + /** The selected item — a game or a launcher card. */ + selected(): CarouselItem | undefined; + /** + * Tells main what the row is standing on right now. The strip normally does this itself, on every + * move — this is for the times its selection changed while NOBODY was looking at it: a game deleted + * out of the Library takes its card with it, the cards behind close the gap, and the highlight ends up + * on a neighbour main was never told about. Left unsaid, that game's wallpaper, palette and music + * 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; + /** Whether the inbox holds anything unread — the Notifications card wears the same dot a game does. */ + setUnread(unread: boolean): void; + /** + * Replays the staggered fan the strip uses when it comes back from a detail screen. Called once at + * startup, the moment the loading wallpaper hands over: the cards are built and laid out while the + * boot screen still covers them, so without this their entrance would have already happened, unseen. + */ + playIntro(): void; + /** + * A direction is being HELD, i.e. the row is flipping on its own. Artwork loading pauses for the + * duration and resumes on release: each cover is a file read plus a base64 encode in main and a + * megabyte-ish string over IPC, and firing that per step is what makes a held flip stutter. The cards + * the flip ends on are the only ones anyone actually looks at. + */ + setFlipping(flipping: boolean): void; + /** + * Seeds this cache with a cover somebody else already decoded — the Library screen, when a game is + * opened from its grid. applyLayout reads the cache SYNCHRONOUSLY to dress the play button for the + * morph, so without the hand-over the detail screen would open on an empty plate and fill in a frame + * later. The key is the same `id@artRev` the row uses, so a stale revision simply misses. + */ + primeArt(game: LibraryEntry, url: string): void; + /** + * The artwork the play button morphs out of, for a detail screen the STRIP cannot speak for: the row + * carries at most MAX_STRIP_GAMES games, so a game opened from the Library (or from a notification) may + * have no card here at all — and the selected card's cover would then be another game's. Cleared on the + * way back to the carousel; `null` is "this game has none", which is not the same as no override. + */ + setDetailArt(url: string | null): void; +} + +/** The row's identity for one item — the key of the DOM node, and what a list update keeps the selection by. */ +function itemKey(item: CarouselItem): string { + return item.kind === 'game' ? `g:${item.game.id}` : `s:${item.card.id}`; } export function createCarousel(deps: CarouselDeps): Carousel { const app = req('app'); const strip = req('carousel-strip'); const playButton = req('play-button'); + // Live style object: read per frame for the body's colour, so the palette crossfade (--d2 is a + // registered property with its own transition) carries it without a single line of interpolation here. + const appStyle = getComputedStyle(app); - let games: readonly LibraryEntry[] = []; + const systemItems: readonly CarouselItem[] = SYSTEM_CARDS.map((card) => ({ + kind: 'system', + card, + })); + // The row: main's games, then the launcher's own cards. Never empty — which is what lets the carousel + // be the launcher's top level unconditionally, with no empty screen and no single-game special case. + let items: readonly CarouselItem[] = [...systemItems]; let index = 0; let screen: Screen = 'detail'; let busyId: string | null = null; + // 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 // play square. Moving the selection through that resizes and reorders a card mid-morph, which shows. // Timestamp (performance.now) until which a move is refused; 0 = the card stands at full size. let lockedUntil = 0; + // Pending clear of `data-returning` (see markReturning); null when the strip is not returning. + let returnTimer: number | null = null; + // A direction is being held (app.ts relays it) — artwork loading waits it out. See setFlipping. + let flipping = false; // Artwork, keyed by game id AND artwork revision. Decoded data URLs are heavy, so each is fetched at // most once; a game with no art at all is remembered as null so we don't ask again on every re-render. - // The revision is what keeps that cache honest: editing gridImage in Configure re-copies the assets, + // The revision is what keeps that cache honest: editing gridImage re-copies the assets, // main bumps `artRev`, and the new key misses the cache — no restart needed to see the new cover. const art = new Map<string, string | null>(); + // The morph source set from outside for the current detail screen; undefined when the row speaks for + // itself (see setDetailArt). + let detailArt: string | null | undefined = undefined; + // The card nodes, by itemKey — the launcher cards share the row with the games, so a raw game id would + // not be unique enough to address a node by. const cards = new Map<string, HTMLElement>(); + // A focusGame() that named a game the list does not hold YET. The browse cursor and the carousel list + // are seeded over two independent channels, in either order, so on startup the "put the strip on the + // game main is showing" request routinely arrives first — and used to be dropped on the floor, leaving + // the strip on games[0] while the title, the background and the music belonged to another game. + // Honoured by the next setGames, then forgotten; a real move by the user outranks it (see move()). + let pendingFocusId: string | null = null; + // Where the body was last sent, so a repaint that did not move the selection (a dot, a busy game, a + // language change) does not make it squeeze. null until the first layout — the body is placed then, + // not moved. + let jellyIndex: number | null = null; const artKey = (game: LibraryEntry): string => `${game.id}@${game.artRev ?? ''}`; - function exists(): boolean { - return games.length > 1; + function selected(): CarouselItem | undefined { + return items[index]; } - function selected(): LibraryEntry | undefined { - return games[index]; + /** + * Whether a card shows its dot. For a game it marks "this one is playable right now" — it is on the + * inserted card or in the local library — unconditionally: the mark belongs to the game, and holding it + * back until the row also holds history entries made a card silently change meaning as the history grew. + * A busy game keeps it too, where the pulsing dot is the only sign of an install/run happening + * elsewhere in the list. On the Notifications card the same dot means what it meant beside the old menu + * item: something is unread. + */ + 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; } /** - * Whether a card shows the "on the inserted card" dot. It only earns its place when it TELLS the two - * kinds of entry apart — with no history in the row every card would wear one — or when that game is - * busy, where the pulsing dot is the only sign of an install/run happening elsewhere in the list. + * The box the focus body hugs: the SELECTED card's own rectangle, in the strip's coordinates. + * + * Measured off the node rather than derived from the index, and measured EVERY frame (focus-jelly.ts + * asks for it), because the card is still growing from 90x135 to 136x204 while the row slides — a + * box computed once would have the body wrapping a size the card no longer has. */ - function showsDot(game: LibraryEntry, hasHistory: boolean): boolean { - return (game.active && hasHistory) || game.id === busyId; + function jellyTarget(): ReturnType<typeof jellyBoxOf> | null { + const current = selected(); + const card = current === undefined ? undefined : cards.get(itemKey(current)); + if (card === undefined) return null; + const unit = pxUnit(); + const parsed = Number.parseFloat(getComputedStyle(card).borderTopLeftRadius); + const radius = Number.isFinite(parsed) ? parsed : 0; + const pad = JELLY.margin * unit; // the canvas starts up and to the left of the strip's own origin + return jellyBoxOf( + card.offsetLeft + pad, + card.offsetTop + pad, + card.offsetWidth, + card.offsetHeight, + radius, + unit, + ); + } + + const jellyCanvas = reqCanvas('carousel-jelly'); + const jelly = createFocusJelly(jellyCanvas, { + target: jellyTarget, + colour: () => { + const value = appStyle.getPropertyValue('--d2').trim(); + return value.length > 0 ? value : FALLBACK_COLOUR; + }, + unit: pxUnit, + }); + + /** Fits the canvas around the whole row — it must cover wherever the body may be, plus its overhang. */ + function sizeJelly(): void { + const unit = pxUnit(); + const size = stripCanvas(items.length); + jelly.resize(size.width * unit, size.height * unit); + } + + /** Squeezes the body through its trip to a new card. A repaint that moved nothing leaves it alone. */ + function nudgeJelly(next: number): void { + const previous = jellyIndex; + jellyIndex = next; + if (previous === null) { + jelly.bump(true); // the first layout PLACES the body; nothing has travelled + return; + } + if (previous !== next) jelly.bump(); } /** The strip's translation + the per-card selected/active/busy state. Cheap; safe to call often. */ function applyLayout(): void { strip.style.setProperty('--strip-offset', String(stripOffset(index))); + nudgeJelly(index); const current = selected(); - const hasHistory = games.some((game) => !game.active); - games.forEach((game, position) => { - const card = cards.get(game.id); + const currentKey = current === undefined ? null : itemKey(current); + items.forEach((item, position) => { + const key = itemKey(item); + const card = cards.get(key); if (card === undefined) return; - card.classList.toggle('is-selected', game.id === current?.id); - card.classList.toggle('is-busy', game.id === busyId); - card.classList.toggle('shows-dot', showsDot(game, hasHistory)); + card.classList.toggle('is-selected', key === currentKey); + card.classList.toggle('is-busy', item.kind === 'game' && item.game.id === busyId); + 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 + // selection reaches it instead of popping into existence at the row's end. + card.classList.toggle('is-beyond', !isWithinWindow(position, index)); // Its place in the fan the strip returns in (styles.css turns this into a transition-delay). card.style.setProperty('--fan', String(fanIndex(position, index))); }); // The morph's source image: #play-button wears the selected card's artwork so the swap into `detail` - // is invisible (see the morph block in styles.css). - const url = current === undefined ? null : (art.get(artKey(current)) ?? null); + // is invisible (see the morph block in styles.css). A launcher card has none — and no detail screen + // to morph into either. + const url = + detailArt !== undefined + ? detailArt + : current === undefined || current.kind === 'system' + ? null + : (art.get(artKey(current.game)) ?? null); playButton.style.setProperty('--card-art', url === null ? 'none' : `url("${url}")`); } /** Loads the artwork of the cards near the selection (a 40-game history must not decode 40 covers). */ function loadNearbyArt(): void { - games.forEach((game, i) => { + if (flipping) return; // see setFlipping — the row is mid-flight, nobody is reading these cards yet + // The position is the one in the WHOLE row (isNearViewport measures against the selection), while only + // the games have anything to fetch. + items.forEach((item, i) => { + if (item.kind !== 'game') return; + const game = item.game; const key = artKey(game); if (!isNearViewport(i, index) || art.has(key)) return; art.set(key, null); // claim the slot first: the request is async and re-renders are frequent @@ -126,38 +305,47 @@ export function createCarousel(deps: CarouselDeps): Carousel { if (url === null) return; art.set(key, url); paintArt(game.id, url); - if (game.id === selected()?.id) applyLayout(); // refresh the morph source + const current = selected(); + if (current?.kind === 'game' && current.game.id === game.id) applyLayout(); // refresh the morph source }); }); } function paintArt(id: string, url: string): void { - const card = cards.get(id); + const card = cards.get(`g:${id}`); if (card === undefined) return; card.style.backgroundImage = `url("${url}")`; card.classList.add('has-art'); } - function buildCard(game: LibraryEntry): HTMLElement { + function buildCard(item: CarouselItem): HTMLElement { const card = document.createElement('div'); - card.className = 'card'; - card.dataset['gameId'] = game.id; - // Whether this card gets the "on the inserted card" dot is decided per render by showsDot - // (applyLayout) — it depends on the rest of the row, not on this game alone. - const label = document.createElement('span'); - label.className = 'card-label'; - // Card data is untrusted (it comes from game.json) — textContent, never innerHTML. - label.textContent = game.title; + card.className = item.kind === 'system' ? 'card is-system' : 'card'; + // Whether this card gets its dot is decided per render by showsDot (applyLayout) — for a game it + // depends on the rest of the row, not on that game alone. const dot = document.createElement('span'); dot.className = 'card-dot'; - card.append(label, dot); - const url = art.get(artKey(game)); - if (url !== undefined && url !== null) { - card.style.backgroundImage = `url("${url}")`; - card.classList.add('has-art'); + if (item.kind === 'system') { + // The label is written by localizeDocument on every language change; the attribute below is what it + // reads, and the initial value is set here so the card is named from the frame it is built in. + card.dataset['i18nAriaLabel'] = item.card.ariaKey; + card.setAttribute('aria-label', deps.getTranslator()(item.card.ariaKey)); + card.append(systemCardIcon(item.card.id), dot); + } else { + const label = document.createElement('span'); + label.className = 'card-label'; + // Card data is untrusted (it comes from game.json) — textContent, never innerHTML. + label.textContent = item.game.title; + card.append(label, dot); + const url = art.get(artKey(item.game)); + if (url !== undefined && url !== null) { + card.style.backgroundImage = `url("${url}")`; + card.classList.add('has-art'); + } } + const key = itemKey(item); card.addEventListener('click', () => { - const position = games.findIndex((g) => g.id === game.id); + const position = items.findIndex((candidate) => itemKey(candidate) === key); if (position === -1) return; // Click on the selected card = enter it; click on another = select it (two-step, like a d-pad). if (position === index) { @@ -176,31 +364,89 @@ export function createCarousel(deps: CarouselDeps): Carousel { function rebuild(): void { cards.clear(); - const nodes = games.map((game) => { - const card = buildCard(game); - cards.set(game.id, card); + const nodes = items.map((item) => { + const card = buildCard(item); + cards.set(itemKey(item), card); return card; }); - strip.replaceChildren(...nodes); + // The canvas goes back in FIRST: a rebuild replaces every child, and it is a child of the strip too. + strip.replaceChildren(jellyCanvas, ...nodes); + sizeJelly(); } - /** Tells main what is on screen now. */ + /** + * Applies a new order to the row WITHOUT the cards jumping into place: FLIP. `apply` rebuilds the nodes + * in the new order (the browser lays that out instantly, which is the jump), then every card that was + * already on screen is shoved back to where it used to be and released in the same frame — the CSS + * transform transition carries it from there to its new slot. + * + * Positions are read as `offsetLeft`, i.e. LAYOUT coordinates relative to the strip. Viewport rects + * would be wrong here: the strip carries its own sliding transform, and half the time it is mid-flight, + * so its motion would be folded into the measurement and every card would overshoot by that much. + */ + function reorderSmoothly(apply: () => void): void { + const before = new Map<string, number>(); + for (const [key, card] of cards) before.set(key, card.offsetLeft); + apply(); + const shifted: HTMLElement[] = []; + for (const [key, card] of cards) { + const from = before.get(key); + if (from === undefined) continue; // new to the row: it belongs where it is, and fades in there + const dx = from - card.offsetLeft; + if (Math.abs(dx) < 1) continue; + card.style.transition = 'none'; + card.style.transform = `translateX(${dx}px)`; + shifted.push(card); + } + if (shifted.length === 0) return; + void strip.offsetWidth; // ONE reflow for the whole row, so every card starts its travel together + for (const card of shifted) { + card.style.transition = ''; + card.style.transform = ''; + } + } + + /** Tells main what is on screen now — a game, or nothing at all on a launcher card. */ function announceSelection(): void { const current = selected(); if (current === undefined) return; - deps.browseGame(current.id); + if (current.kind === 'game') deps.browseGame(current.game.id); + else deps.browseNone(); } function setScreen(next: Screen): void { - // With 0 or 1 game there is nothing to flip through: the launcher stays on the plain bar screen (Р7). - const effective: Screen = next === 'carousel' && !exists() ? 'detail' : next; - if (effective === screen) return; - screen = effective; - app.dataset['screen'] = effective; + if (next === screen) return; + screen = next; + app.dataset['screen'] = next; + // The override belongs to ONE detail screen (see setDetailArt); back on the row the strip speaks for + // itself again. + if (next === 'carousel') detailArt = undefined; // Coming back, the strip is unusable until the selected card is back at full size (RETURN_LOCK_MS); // leaving, nothing is locked — the detail screen has its own focus model. - lockedUntil = effective === 'carousel' ? performance.now() + RETURN_LOCK_MS : 0; - deps.onScreenChange(effective); + lockedUntil = next === 'carousel' ? performance.now() + RETURN_LOCK_MS : 0; + markReturning(next === 'carousel'); + deps.onScreenChange(next); + } + + /** + * Flags the staggered hand-back fade for as long as it runs (see RETURN_FAN_MS). CSS keys the fan's + * transition-delay on it, so a card that scrolls into the window while merely FLIPPING fades in + * immediately — the stagger belongs to the return, not to every appearance. + */ + function markReturning(returning: boolean): void { + if (returnTimer !== null) { + window.clearTimeout(returnTimer); + returnTimer = null; + } + if (!returning) { + delete app.dataset['returning']; + return; + } + app.dataset['returning'] = 'true'; + returnTimer = window.setTimeout(() => { + returnTimer = null; + delete app.dataset['returning']; + }, RETURN_FAN_MS); } /** Whether the selected card is still growing back to full size, i.e. must not be flipped through yet. */ @@ -214,55 +460,139 @@ export function createCarousel(deps: CarouselDeps): Carousel { deps.onActivate(current); } - function move(delta: number): void { - if (isLocked()) return; - const next = clampIndex(index + delta, games.length); - if (next === index) return; // at an end — no move, no sound + function move(delta: number): MoveResult { + if (isLocked()) return 'locked'; + // The user is steering now: a seed request still waiting for its list must not yank the strip later. + pendingFocusId = null; + const next = clampIndex(index + delta, items.length); + if (next === index) return 'at-end'; // no move — the caller decides what a stop means, sound included const moved = next - index; index = next; deps.onNavigate(moved); applyLayout(); loadNearbyArt(); announceSelection(); + return 'moved'; } - // The launcher starts on the plain bar screen; the first list with more than one game promotes it. + // The launcher starts on the plain bar screen; the first list promotes it to the carousel (applyLibrary). app.dataset['screen'] = screen; + sizeJelly(); + jelly.setActive(true); + // --px is tied to the window's height, so a resize moves the row in real px and the canvas has to + // follow. The body's own coordinates are re-read every frame, so nothing else needs saying. + new ResizeObserver(() => sizeJelly()).observe(app); return { focusGame(id: string): void { - const position = games.findIndex((game) => game.id === id); + const position = items.findIndex((item) => item.kind === 'game' && item.game.id === id); + if (position === -1) { + pendingFocusId = id; // the list carrying it is still in flight — see the field + return; + } + pendingFocusId = null; + if (position === index) return; + index = position; + applyLayout(); + loadNearbyArt(); + }, + focusSystem(): void { + const position = items.findIndex((item) => item.kind === 'system'); if (position === -1 || position === index) return; + pendingFocusId = null; index = position; applyLayout(); loadNearbyArt(); }, - setGames(list: readonly LibraryEntry[]): void { - // The selection is remembered BY ID, not by position: the list is re-ordered whenever a card is - // inserted or a session ends, and a positional cursor would silently land on a different game. - const currentId = selected()?.id; - games = list; + setGames(all: readonly LibraryEntry[]): void { + // Home shows a shortlist — the rest of the library has a screen of its own now (see + // MAX_STRIP_GAMES). Everything below still speaks of `list` because that IS the row's list. + const list = all.slice(0, MAX_STRIP_GAMES); + // The selection is remembered BY IDENTITY, not by position: the list is re-ordered whenever a card + // is inserted or a session ends, and a positional cursor would silently land on a different game. + // A launcher card survives every update by construction — it is in every list this builds. + // A pending focus request wins over the current selection — it is the newer instruction of the two. + const currentKey = pendingFocusId !== null ? `g:${pendingFocusId}` : (selected() === undefined ? undefined : itemKey(selected() as CarouselItem)); + items = [...list.map((game): CarouselItem => ({ kind: 'game', game })), ...systemItems]; + // Cleared against the FULL list: once main has sent the game, the request has been answered one + // way or the other. A game past the cap simply has no card here to put the selection on, and + // leaving the request pending would re-aim every later update at a card that never comes. + if (pendingFocusId !== null && all.some((game) => game.id === pendingFocusId)) { + pendingFocusId = null; + } index = clampIndex( - currentId === undefined + currentKey === undefined ? 0 : Math.max( 0, - games.findIndex((game) => game.id === currentId), + items.findIndex((item) => itemKey(item) === currentKey), ), - games.length, + items.length, ); - rebuild(); - applyLayout(); + // Both together: applyLayout is what resizes the selected card, so measuring between the two would + // compare against a width the row is about to change. + reorderSmoothly(() => { + rebuild(); + applyLayout(); + }); loadNearbyArt(); - // A list that shrank to a single game (or none) has no carousel left to stand on. - if (!exists() && screen === 'carousel') setScreen('detail'); }, move, activate, screen: () => screen, setScreen, - exists, selected, + setUnread(next: boolean): void { + if (unread === next) return; + unread = next; + applyLayout(); + }, + primeArt(game: LibraryEntry, url: string): void { + art.set(artKey(game), url); + }, + setDetailArt(url: string | null): void { + detailArt = url; + applyLayout(); + }, + setFlipping(next: boolean): void { + if (flipping === next) return; + flipping = next; + // The attribute switches the strip and the cards onto the glide timing (see --flip-step in + // styles.css): a held direction slides at one even speed instead of restarting an eased morph + // three times a second. + if (flipping) app.dataset['flipping'] = 'on'; + else delete app.dataset['flipping']; + // Released: pick up the covers of wherever the row came to rest. + if (!flipping) loadNearbyArt(); + }, + playIntro(): void { + if (screen !== 'carousel') return; + // Pull the cards back to zero and flush BEFORE arming the fan, rather than trusting them to still + // be hidden. By the time the boot screen hands over, the strip has been through setGames and + // setScreen — either of which may already have run (and finished) a return of its own, leaving the + // row fully faded in. Starting the fan from that state is a no-op: an opacity that never changes + // has nothing to transition, which is exactly the "the carousel is just there" it was meant to fix. + // Suppressing the transition for that reset is not optional: the cards carry a DELAYED opacity + // transition, so a plain `opacity = 0` would animate its way there (350ms later) instead of taking + // effect now — leaving nothing to fade in from. The reflow makes the 0 the transition's start value. + for (const card of cards.values()) { + card.style.transition = 'none'; + card.style.opacity = '0'; + } + void strip.offsetWidth; + markReturning(true); + // Same fan, one difference: the selected card fades in with the rest. On a real hand-back it swaps + // in opaque because it takes over from a pixel-identical play button — at startup there is no button + // to take over from, and an opaque card appearing mid-wave is the one thing that breaks it. + app.dataset['returning'] = 'intro'; + for (const card of cards.values()) { + card.style.removeProperty('transition'); + card.style.removeProperty('opacity'); + } + }, + announce(): void { + announceSelection(); + }, setBusyGame(id: string | null): void { if (id === busyId) return; busyId = id; diff --git a/src/renderer/configure-form-model.ts b/src/renderer/configure-form-model.ts index 83f00720..de8a2614 100644 --- a/src/renderer/configure-form-model.ts +++ b/src/renderer/configure-form-model.ts @@ -12,8 +12,13 @@ // the original error, Save stays blocked, and nothing is lost. Granularity is the top-level // key (a bad `install.type` marks the whole `install` block corrupt). -/** The three mutually-exclusive launch methods (mirrors the manifest superRefine). */ -export type LaunchMode = 'executable' | 'installer' | 'steam'; +/** + * The launch methods (mirrors the manifest superRefine). `pc` - a game already installed on this + * machine, addressed by absolute path - is only valid in the PC library. `none` is the PC-library draft + * state (no launch method chosen yet) - also only valid there; the form gets both constraints from the + * selected root, not from the text (see `launchModesFor`/`draftModeFor` in game-settings-model.ts). + */ +export type LaunchMode = 'executable' | 'installer' | 'steam' | 'pc' | 'none'; /** * Derives a manifest `id` from a game's display name for the Configure form: accents stripped, lowercased, @@ -58,6 +63,12 @@ export interface SteamModel { readonly rest: Readonly<Record<string, unknown>>; } +/** The `pc` block as form state: the absolute path to a game on this machine (PC library only). */ +export interface PcModel { + readonly executable: string; + readonly rest: Readonly<Record<string, unknown>>; +} + /** * 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 @@ -100,6 +111,8 @@ export interface ManifestFormModel { */ readonly copyInstall: InstallModel; readonly steam: SteamModel; + /** The `pc` block — the absolute executable of a local game (PC-library mode only). */ + readonly pc: PcModel; } export type ParseFormResult = @@ -138,6 +151,7 @@ export const KNOWN_MANIFEST_KEYS: readonly string[] = [ 'umuGameId', 'install', 'steam', + 'pc', ]; const KNOWN_KEY_SET = new Set(KNOWN_MANIFEST_KEYS); @@ -163,11 +177,19 @@ function emptySteam(): SteamModel { return { appid: '', rest: {} }; } -/** A pristine, all-empty form model (executable mode) — used for a blank drive and the empty baseline of - * the template-replace confirm (plan R8). */ -export function emptyFormModel(): ManifestFormModel { +function emptyPc(): PcModel { + return { executable: '', rest: {} }; +} + +/** + * 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 + * only mode valid there, so defaulting to `executable` would hand the user a form whose every save is + * rejected. + */ +export function emptyFormModel(launchMode: LaunchMode = 'executable'): ManifestFormModel { return { - launchMode: 'executable', + launchMode, id: '', title: '', executable: '', @@ -187,6 +209,7 @@ export function emptyFormModel(): ManifestFormModel { copyToPc: false, copyInstall: emptyCopyInstall(), steam: emptySteam(), + pc: emptyPc(), }; } @@ -227,6 +250,21 @@ function parseInstall(source: Record<string, unknown>): InstallModel | null { return { installer, type, runAsAdmin, args, winetricks, rest }; } +/** Parses a `pc` object; null = executable had the wrong type (→ the block is corrupt). */ +function parsePc(source: Record<string, unknown>): PcModel | null { + let executable = ''; + const rest: Record<string, unknown> = {}; + for (const [key, value] of Object.entries(source)) { + if (key === 'executable') { + if (typeof value !== 'string') return null; + executable = value; + } else { + rest[key] = value; + } + } + return { executable, rest }; +} + /** Parses a `steam` object; null = appid had the wrong type (→ the block is corrupt). */ function parseSteam(source: Record<string, unknown>): SteamModel | null { let appid = ''; @@ -366,16 +404,28 @@ function valueToFormResult(parsed: unknown): ParseFormResult { else corrupt['steam'] = value; } - // Launch mode: steam > install > executable (plan R5). Presence (not validity) decides — a corrupt + let pc = emptyPc(); + if (has('pc')) { + const value = source['pc']; + const parsedPc = isRecord(value) ? parsePc(value) : null; + if (parsedPc !== null) pc = parsedPc; + else corrupt['pc'] = value; + } + + // Launch mode: pc > steam > install > executable (plan R5). 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). - const launchMode: LaunchMode = has('steam') - ? 'steam' - : has('install') && !copyToPc - ? 'installer' - : 'executable'; - const mixed = has('steam') && (has('install') || has('executable')); + const launchMode: LaunchMode = has('pc') + ? 'pc' + : has('steam') + ? 'steam' + : has('install') && !copyToPc + ? 'installer' + : 'executable'; + const mixed = + (has('steam') && (has('install') || has('executable'))) || + (has('pc') && (has('steam') || has('install') || has('executable'))); const model: ManifestFormModel = { launchMode, @@ -398,6 +448,7 @@ function valueToFormResult(parsed: unknown): ParseFormResult { copyToPc, copyInstall, steam, + pc, }; return { ok: true, model, rest, corrupt, mixed }; } @@ -444,6 +495,13 @@ function buildInstall(install: InstallModel): Record<string, unknown> { return out; } +function buildPc(pc: PcModel): Record<string, unknown> { + const out: Record<string, unknown> = {}; + if (pc.executable !== '') out.executable = pc.executable; + for (const [key, value] of Object.entries(pc.rest)) out[key] = value; + return out; +} + function buildSteam(steam: SteamModel): Record<string, unknown> { const out: Record<string, unknown> = {}; const appid = numericValue(steam.appid); @@ -482,8 +540,29 @@ function buildManifestObject( if (model.id !== '') out.id = model.id; if (model.title !== '') out.title = model.title; - if (model.launchMode === 'steam') { + if (model.launchMode === 'pc') { + // A local game: the absolute executable in its own block, plus the launch options an ordinary game + // has. No card-relative `executable` and no install block — the schema forbids both here. + out.pc = buildPc(model.pc); + const args = nonEmpty(model.args); + if (args.length > 0) out.args = args; + if (model.runAsAdmin) out.runAsAdmin = true; + const winetricks = nonEmpty(model.winetricks); + if (winetricks.length > 0) out.winetricks = winetricks; + if (model.umuGameId !== '') out.umuGameId = model.umuGameId; + } else if (model.launchMode === 'steam') { out.steam = buildSteam(model.steam); + } else if (model.launchMode === 'none') { + // PC-library draft: no launch block is written at all (that IS the draft state — see manifest.ts + // resolveOne). Only the launch-adjacent fields that don't belong to any specific block are kept, so + // filling them in before a method is chosen survives Save instead of silently vanishing (every other + // branch here is the only place that writes them). + const args = nonEmpty(model.args); + if (args.length > 0) out.args = args; + if (model.runAsAdmin) out.runAsAdmin = true; + const winetricks = nonEmpty(model.winetricks); + if (winetricks.length > 0) out.winetricks = winetricks; + if (model.umuGameId !== '') out.umuGameId = model.umuGameId; } else { if (model.executable !== '') out.executable = model.executable; const args = nonEmpty(model.args); @@ -534,17 +613,44 @@ function buildManifestObject( // time; these two pure functions wrap/unwrap the array so the form's per-game model is reused verbatim. /** One game's serializable form state (model + preserved unknown/corrupt keys). */ -export interface GameFormState { +export interface FormGameSlot { readonly model: ManifestFormModel; readonly rest: Readonly<Record<string, unknown>>; readonly corrupt: Readonly<Record<string, unknown>>; } +/** + * A slot the form cannot represent at all (a non-object element — `textToGames` returns `ok:false` for + * it), kept as the VERBATIM parsed value so it can be written back untouched. + * + * It exists because of a case the per-game editor makes reachable and the old window never did: a card + * 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). + */ +export interface RawGameSlot { + readonly raw: unknown; +} + +export type GameFormState = FormGameSlot | RawGameSlot; + +/** Whether a slot is the verbatim kind (the form has nothing to show for it). */ +export function isRawSlot(slot: GameFormState): slot is RawGameSlot { + return 'raw' in slot; +} + export type ParseGamesResult = | { readonly ok: true; /** One parse result per game (each may individually be ok:false — a non-object element). */ readonly games: readonly ParseFormResult[]; + /** + * The parsed JSON value of each game, index-aligned with `games`. It is what a slot the form cannot + * represent is written back from (see RawGameSlot) — without it, an unrepresentable neighbour could + * only be dropped. + */ + readonly values: readonly unknown[]; /** Whether the source was an array (>1 games serialize back as an array; see gamesToText). */ readonly isArray: boolean; } @@ -564,21 +670,68 @@ export function textToGames(text: string): ParseGamesResult { } if (Array.isArray(parsed)) { if (parsed.length === 0) return { ok: false, message: 'the games array must not be empty' }; - return { ok: true, isArray: true, games: parsed.map(valueToFormResult) }; + const values: readonly unknown[] = parsed; + return { ok: true, isArray: true, games: values.map(valueToFormResult), values }; } if (isRecord(parsed)) { - return { ok: true, isArray: false, games: [valueToFormResult(parsed)] }; + return { ok: true, isArray: false, games: [valueToFormResult(parsed)], values: [parsed] }; } return { ok: false, message: 'game.json must be a game object or a non-empty array of games' }; } +export type NewGameSlotsResult = + | { + readonly ok: true; + readonly slots: readonly GameFormState[]; + /** Where the new game sits — always last, so the neighbours keep the indices they were read at. */ + readonly index: number; + } + | { readonly ok: false; readonly message: string }; + +/** + * The slot list a screen ADDING (or moving in) a game starts from: everything the root already carries, + * plus one slot at the end for `model` — a blank one for a new game, or a carried-over one for a move + * (see `carryFormToCard`). `text` is null when the root has no game.json at all (a blank card, a PC + * library with no local game yet) — the normal case here, and the reason this cannot simply be + * `textToGames`, which rejects an empty games array. + * + * The inserted game is a real slot rather than a special case beside the list on purpose: `slotIndex` is + * what the validator's `games.<i>.<field>` paths are matched against, so a game held outside the list + * would have its own problems reported as somebody else's — and Save would go green on an empty form. + */ +export function slotsWithInsertedGame( + text: string | null, + model: ManifestFormModel, +): NewGameSlotsResult { + const inserted: GameFormState = { model, rest: {}, corrupt: {} }; + if (text === null || text.trim() === '') return { ok: true, slots: [inserted], index: 0 }; + const parsed = textToGames(text); + if (!parsed.ok) return { ok: false, message: parsed.message }; + const existing: GameFormState[] = parsed.games.map((game, index) => + game.ok + ? { model: game.model, rest: game.rest, corrupt: game.corrupt } + : { raw: parsed.values[index] }, + ); + return { ok: true, slots: [...existing, inserted], index: existing.length }; +} + +/** `slotsWithInsertedGame` with a blank slot — the Add-game screen's starting point. */ +export function slotsWithNewGame(text: string | null, launchMode: LaunchMode): NewGameSlotsResult { + return slotsWithInsertedGame(text, emptyFormModel(launchMode)); +} + /** * 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). An empty - * list is not expected (a card always has ≥1 game); it falls back to a single empty object for safety. + * shape, maximal backwards compatibility), more than one → an array (see the plan, decision 2). + * + * 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). */ export function gamesToText(games: readonly GameFormState[]): string { - const objects = games.map((g) => buildManifestObject(g.model, g.rest, g.corrupt)); + if (games.length === 0) return '[]\n'; + const objects = games.map((slot) => + isRawSlot(slot) ? slot.raw : buildManifestObject(slot.model, slot.rest, slot.corrupt), + ); const value: unknown = objects.length === 1 ? objects[0] : objects; return `${JSON.stringify(value, null, 2)}\n`; } diff --git a/src/renderer/configure-form-view.ts b/src/renderer/configure-form-view.ts deleted file mode 100644 index ce04d9fe..00000000 --- a/src/renderer/configure-form-view.ts +++ /dev/null @@ -1,1361 +0,0 @@ -// The interactive Configure form: builds the field DOM inside #form-view, binds it to a ManifestFormModel -// and converts to/from game.json TEXT via the pure configure-form-model (the single source of truth stays -// the text — see plan R2). This module owns only the FORM's DOM + state (rest/corrupt kept across the -// round-trip); the shared shell (drive picker, tabs, Save/Reset, status, issues panel, JSON editor) and -// the validation/dirty/save wiring live in configure.ts. -// -// The form is split into SECTIONS (Basics/Launch/Hero/Saves/Audio/Advanced); configure.ts renders a tab -// bar and shows one section panel at a time via showSection(). Fluent components used here -// (switch/text-input/dropdown/listbox/option/button) are registered in configure.ts. Labels come from the -// translator and are re-applied on a language change via applyLabels(). -import { - formModelToText, - slugifyId, - textToFormModel, - type InstallType, - type InstallerFamily, - type LaunchMode, - type ManifestFormModel, - type ParseFormResult, -} from './configure-form-model.js'; -import { MAX_HERO_IMAGES } from '../shared/types.js'; -import type { ConfigPickKind, ConfigPickResult, ManifestValidationIssue } from '../shared/types'; -import type { Translator } from '../shared/i18n/index'; -import type { MessageKey } from '../shared/i18n/en'; - -type ValueEl = HTMLElement & { value?: string }; -type CheckedEl = HTMLElement & { checked?: boolean }; - -function getValue(el: ValueEl): string { - return typeof el.value === 'string' ? el.value : ''; -} -function getChecked(el: CheckedEl): boolean { - return el.checked === true; -} - -/** Where the SteamDB appid lookup opens (the #7 helper link). */ -const STEAMDB_URL = 'https://steamdb.info/'; - -/** Where the card-image helper link opens — the 600x900 covers the carousel card expects. */ -const STEAMGRIDDB_URL = 'https://www.steamgriddb.com/'; - -/** The form's section ids (each a tab / a panel shown one at a time). */ -export type SectionId = 'basics' | 'launch' | 'hero' | 'saves' | 'audio' | 'advanced'; - -/** Section descriptors, consumed by configure.ts to build the tab bar (label = section heading). */ -export const FORM_SECTIONS: ReadonlyArray<{ readonly id: SectionId; readonly labelKey: MessageKey }> = [ - { id: 'basics', labelKey: 'configure.sectionBasics' }, - { id: 'launch', labelKey: 'configure.sectionLaunch' }, - { id: 'hero', labelKey: 'configure.sectionHero' }, - { id: 'saves', labelKey: 'configure.sectionSaves' }, - { id: 'audio', labelKey: 'configure.sectionAudio' }, - { id: 'advanced', labelKey: 'configure.sectionAdvanced' }, -]; - -export interface FormViewDeps { - /** The form container (#form-view). */ - readonly root: HTMLElement; - /** Live translator (re-read on a language push). */ - readonly translator: () => Translator; - /** A field changed → the owner re-serializes, validates and marks dirty. */ - readonly onChange: () => void; - /** Pick file(s)/a folder for a Browse… button (root is closed over in configure.ts). */ - readonly pickPath: (kind: ConfigPickKind) => Promise<ConfigPickResult>; - /** Read a card-relative image into a data URL for a hero thumbnail (null when unreadable). */ - readonly imagePreview: (relative: string) => Promise<string | null>; - /** Open an external https URL (the appid helper link). */ - readonly openExternal: (url: string) => void; - /** Surface a picker rejection message in the status line. */ - readonly onPickError: (message: string) => void; -} - -/** All error slots the form can address; an issue path maps onto one of these keys (else it is unmapped - * and returned to the owner for the #issues panel). */ -type FieldKey = - | 'id' - | 'title' - | 'executable' - | 'args' - | 'runAsAdmin' - | 'watchProcesses' - | 'heroImage' - | 'gridImage' - | 'saveOnCard' - | 'pcSavePath' - | 'backgroundMusic' - | 'winetricks' - | 'umuGameId' - | 'launchTimeoutSec' - | 'killTimeoutSec' - | 'steam.appid' - // The "move game to PC" switch and its source-directory field. They emit the same `install.installer` - // path as Installer mode does, but need slots of their OWN: sharing 'install.installer' would make the - // two modes' error slots overwrite each other in `errorEls`, and an error routed to the hidden - // Installer section would block Save with nothing on screen to explain why (see fieldKeyForPath). - | 'copyToPc' - | 'copySource' - | 'install.installer' - | 'install.type' - | 'install.runAsAdmin' - | 'install.args' - | 'install.winetricks'; - -/** A dynamic string list (args / watchProcesses / heroImage / install.args): a stack of rows + Add. */ -interface DynamicList { - readonly wrapper: HTMLElement; - values(): string[]; - setValues(values: readonly string[]): void; - setDisabled(disabled: boolean): void; -} - -/** An audio field with a Default/Custom selector (Default → empty → omitted from game.json). */ -interface AudioField { - readonly wrapper: HTMLElement; - readonly input: ValueEl; - setValue(value: string): void; - setDisabled(disabled: boolean): void; -} - -export class FormView { - private readonly deps: FormViewDeps; - - // Per-field controls. - private readonly idInput: ValueEl; - private readonly titleInput: ValueEl; - private readonly launchType: ValueEl; - private readonly executableInput: ValueEl; - private readonly runAsAdminSwitch: CheckedEl; - private readonly copyToPcSwitch: CheckedEl; - private readonly copySourceInput: ValueEl; - private readonly installInstallerInput: ValueEl; - private readonly installType: ValueEl; - private readonly installRunAsAdminSwitch: CheckedEl; - private readonly appidInput: ValueEl; - private readonly gridImageInput: ValueEl; - private readonly saveOnCardInput: ValueEl; - private readonly pcSavePathInput: ValueEl; - private readonly music: AudioField; - private readonly launchTimeoutInput: ValueEl; - private readonly killTimeoutInput: ValueEl; - - private readonly argsList: DynamicList; - private readonly watchList: DynamicList; - private readonly heroList: DynamicList; - private readonly installArgsList: DynamicList; - private readonly installWinetricksList: DynamicList; - private readonly gameWinetricksList: DynamicList; - private readonly umuGameIdInput: ValueEl; - - // Section wrappers toggled by the launch mode. - private readonly execSection: HTMLElement; - private readonly installSection: HTMLElement; - private readonly steamSection: HTMLElement; - /** The "move game to PC" switch field — Executable mode only (installer mode shares execSection for the - * executable/args/runAsAdmin fields, but "move to PC" is an Executable-only concept — `install.type: - * copy`), so it is hidden outside Executable mode. */ - private readonly copyToPcField: HTMLElement; - /** The copy-source field — inside execSection, shown only while the "move to PC" switch is on. */ - private readonly copySourceField: HTMLElement; - /** Note under `executable`: says what the path is relative to (it changes with the copy switch). */ - private readonly executableNote: HTMLElement; - /** Experimental-mode warning — sits under the launch-type dropdown, shown only in Installer mode. */ - private readonly installerExperimental: HTMLElement; - - private readonly mixedBanner: HTMLElement; - private readonly sectionPanels = new Map<SectionId, HTMLElement>(); - - // Error / label / container registries. - private readonly errorEls = new Map<FieldKey, HTMLElement>(); - private readonly containers = new Map<string, HTMLElement>(); // by corrupt key (top-level) - private readonly labelRefs: Array<{ el: HTMLElement; key: MessageKey }> = []; - private readonly optionRefs: Array<{ el: HTMLElement; key: MessageKey }> = []; - private readonly placeholderRefs: Array<{ el: ValueEl; key: MessageKey }> = []; - - // View state. - private launchMode: LaunchMode = 'executable'; - // Whether the user has taken manual control of the `id` field. While false, the title auto-fills the id - // (slugified). Set on a manual id edit, re-armed when the id is cleared, seeded on load from whether the - // card already carries an id. - private idTouched = false; - private rest: Readonly<Record<string, unknown>> = {}; - // Unknown keys nested inside the install/steam blocks: the form has no field for them, so they - // must be remembered from load() and put back in readModel() (else serialize() drops them — the blocks' - // zod is strip-mode, so the loss would be silent). Mirrors the top-level `rest` round-trip. - private installRest: Readonly<Record<string, unknown>> = {}; - /** The copy slot's own unknown keys — kept apart from installRest, like the two slots themselves. */ - private copyInstallRest: Readonly<Record<string, unknown>> = {}; - /** The copy slot's `winetricks`: valid for copy (the prefix IS provisioned) but with no control of its - * own — the form only offers the source directory. Remembered so a hand-written value survives Save. */ - private copyInstallWinetricks: readonly string[] = []; - private steamRest: Readonly<Record<string, unknown>> = {}; - private corrupt: Record<string, unknown> = {}; - private mixed = false; - - constructor(deps: FormViewDeps) { - this.deps = deps; - - this.mixedBanner = document.createElement('div'); - this.mixedBanner.id = 'mixed-banner'; - this.mixedBanner.hidden = true; - - // ── Basics ────────────────────────────────────────────────────────────── - // Editing the id by hand marks it "touched" so the title no longer overwrites it; clearing it re-arms - // the auto-fill. A programmatic set (syncIdFromTitle) does NOT fire 'input', so it never self-marks. - this.idInput = this.textInput('id', () => { - this.idTouched = getValue(this.idInput) !== ''; - }); - // The title drives the id (slugified) until the user takes over the id field — see syncIdFromTitle. - this.titleInput = this.textInput('title', () => this.syncIdFromTitle()); - const idHint = document.createElement('div'); - idHint.className = 'field-hint'; - this.labelRefs.push({ el: idHint, key: 'configure.idHint' }); - const idField = this.field('configure.fieldId', 'id', this.idInput); - idField.append(idHint); - const schemaLine = document.createElement('div'); - schemaLine.className = 'field-static'; - this.labelRefs.push({ el: schemaLine, key: 'configure.schemaVersion' }); - // Name first: it's what the author types, and the id auto-derives from it (slug), so it reads top-down. - this.addSection('basics', [ - this.field('configure.fieldTitle', 'title', this.titleInput), - idField, - schemaLine, - ]); - - // ── Launch ────────────────────────────────────────────────────────────── - this.launchType = this.dropdown([ - ['executable', 'configure.launchExecutable'], - ['installer', 'configure.launchInstaller'], - ['steam', 'Steam'], // brand — literal, not a dictionary key - ]); - this.launchType.addEventListener('change', () => this.onLaunchTypeChange()); - - this.executableInput = this.textInput('executable'); - this.argsList = this.dynamicList('args', 'configure.fieldArgs', { reorder: true }); - this.runAsAdminSwitch = this.switchControl('runAsAdmin'); - this.copyToPcSwitch = this.switchControl('copyToPc'); - this.copyToPcSwitch.addEventListener('change', () => this.onCopyToPcChange()); - this.copySourceInput = this.textInput('copySource'); - this.copySourceField = this.fieldWithBrowse( - 'configure.fieldCopySource', - 'copySource', - this.copySourceInput, - 'directory', - ); - const copySourceHint = document.createElement('div'); - copySourceHint.className = 'field-hint'; - this.labelRefs.push({ el: copySourceHint, key: 'configure.copySourceHint' }); - this.copySourceField.append(copySourceHint); - // The executable's meaning depends on the switch (card root vs the copied directory), so its note - // lives with the switch's field and is re-worded in updateCopyToPcState. - const execField = this.fieldWithBrowse( - 'configure.fieldExecutable', - 'executable', - this.executableInput, - 'executable', - (picked) => this.executableFromPick(picked), - ); - this.executableNote = document.createElement('div'); - this.executableNote.className = 'field-hint'; - execField.append(this.executableNote); - this.copyToPcField = this.switchField('configure.fieldCopyToPc', 'copyToPc', this.copyToPcSwitch); - this.execSection = this.group([ - execField, - this.argsList.wrapper, - this.switchField('configure.fieldRunAsAdmin', 'runAsAdmin', this.runAsAdminSwitch), - this.copyToPcField, - this.copySourceField, - ]); - - this.installInstallerInput = this.textInput('install'); - this.installType = this.dropdown([ - ['nsis', 'NSIS'], - ['inno', 'Inno'], - ['custom', 'Custom'], - ]); - this.installType.addEventListener('change', () => this.onInstallTypeChange()); - this.installRunAsAdminSwitch = this.switchControl('install'); - this.installArgsList = this.dynamicList('install', 'configure.fieldInstallArgs', { reorder: true }); - const installArgsHint = document.createElement('div'); - installArgsHint.className = 'field-hint'; - this.labelRefs.push({ el: installArgsHint, key: 'configure.installArgsDirHint' }); - this.installArgsList.wrapper.append(installArgsHint); - // Always shown, regardless of the CURRENT platform: Configure edits a CARD, and cards are typically - // authored on Windows and played on the Deck — hiding this on Windows would hide it from exactly the - // person who needs to read it. - const installerLinuxWarning = document.createElement('div'); - installerLinuxWarning.className = 'field-hint'; - this.labelRefs.push({ el: installerLinuxWarning, key: 'configure.installerLinuxWarning' }); - // Experimental-mode banner: lives directly UNDER the launch-type dropdown (not inside a section — see - // addSection below), shown ONLY in Installer mode. execSection sits between the dropdown and - // installSection (it holds the shared executable/args fields), so putting the warning in installSection - // would push it below "Move game to PC" instead of under the dropdown. Amber `field-warning` sets it - // apart from the grey hints. - this.installerExperimental = document.createElement('div'); - this.installerExperimental.className = 'field-hint field-warning'; - this.labelRefs.push({ el: this.installerExperimental, key: 'configure.installerExperimental' }); - this.installSection = this.group([ - this.fieldWithBrowse( - 'configure.fieldInstaller', - 'install.installer', - this.installInstallerInput, - 'installer', - ), - this.field('configure.fieldInstallType', 'install.type', this.installType), - this.switchField('configure.fieldRunAsAdmin', 'install.runAsAdmin', this.installRunAsAdminSwitch), - this.installArgsList.wrapper, - installerLinuxWarning, - ]); - - this.appidInput = this.numberInput('steam'); - const appidHelp = document.createElement('a'); - appidHelp.className = 'help-link'; - appidHelp.href = '#'; - this.labelRefs.push({ el: appidHelp, key: 'configure.appidHelp' }); - appidHelp.addEventListener('click', (event) => { - event.preventDefault(); - this.deps.openExternal(STEAMDB_URL); - }); - const appidField = this.field('configure.fieldAppid', 'steam.appid', this.appidInput); - appidField.append(appidHelp); - this.steamSection = this.group([appidField]); - - this.watchList = this.dynamicList('watchProcesses', 'configure.fieldWatchProcesses'); - const watchHint = document.createElement('div'); - watchHint.className = 'field-hint'; - this.labelRefs.push({ el: watchHint, key: 'configure.watchProcessesHint' }); - this.watchList.wrapper.append(watchHint); - - this.addSection('launch', [ - this.field('configure.launchType', null, this.launchType), - this.installerExperimental, - this.execSection, - this.installSection, - this.steamSection, - this.watchList.wrapper, - ]); - - // ── Images: hero backgrounds (with thumbnails) + the carousel card ─────── - this.heroList = this.dynamicList('heroImage', 'configure.fieldHeroImages', { - browseKind: 'image', - browseLabelKey: 'configure.addFile', // the bottom "Add…" button (multi-select adds rows) - replaceKind: 'image', // each row gets a "Replace…" button to swap its file - preview: true, - reorder: true, - noAdd: true, - // Card-format cap (MAX_HERO_IMAGES): the picker stops adding past it, so the form can't produce a - // manifest the editor's own validator would then reject. - maxItems: MAX_HERO_IMAGES, - }); - const heroHint = document.createElement('div'); - heroHint.className = 'field-hint'; - this.labelRefs.push({ el: heroHint, key: 'configure.heroImagesHint' }); - this.heroList.wrapper.append(heroHint); - - // The carousel card: a single card-relative image, optional (see gridImageHint). - this.gridImageInput = this.textInput('gridImage'); - const gridField = this.fieldWithBrowse( - 'configure.fieldGridImage', - 'gridImage', - this.gridImageInput, - 'image', - ); - const gridHint = document.createElement('div'); - gridHint.className = 'field-hint'; - this.labelRefs.push({ el: gridHint, key: 'configure.gridImageHint' }); - const gridHelp = document.createElement('a'); - gridHelp.className = 'help-link'; - gridHelp.href = '#'; - this.labelRefs.push({ el: gridHelp, key: 'configure.gridImageHelp' }); - gridHelp.addEventListener('click', (event) => { - event.preventDefault(); - this.deps.openExternal(STEAMGRIDDB_URL); - }); - gridField.append(gridHint, gridHelp); - this.addSection('hero', [this.heroList.wrapper, gridField]); - - // ── Saves ─────────────────────────────────────────────────────────────── - this.saveOnCardInput = this.textInput('saveOnCard'); - this.pcSavePathInput = this.textInput('pcSavePath'); - this.placeholderRefs.push({ el: this.pcSavePathInput, key: 'configure.pcSavePathPlaceholder' }); - this.addSection('saves', [ - this.fieldWithBrowse('configure.fieldSaveOnCard', 'saveOnCard', this.saveOnCardInput, 'directory'), - // pcSavePath is an env-prefixed template (%APPDATA%\…). Its Browse picks a PC folder and main - // converts it back to a %PREFIX%/… value (plan R6 was reversed per user request #3). - this.fieldWithBrowse('configure.fieldPcSavePath', 'pcSavePath', this.pcSavePathInput, 'pc-save'), - ]); - - // ── Audio (Default/Custom) — the card's own background music. UI sounds are NOT here: they always - // come from the bundled set chosen in Settings → Audio. - this.music = this.musicField('configure.fieldBackgroundMusic', 'backgroundMusic', 'backgroundMusic', 'configure.musicNoneHint'); - this.addSection('audio', [this.music.wrapper]); - - // ── Advanced ────────────────────────────────────────────────────────────── - this.launchTimeoutInput = this.numberInput('launchTimeoutSec'); - this.killTimeoutInput = this.numberInput('killTimeoutSec'); - // Custom winetricks verbs (Linux/Proton, Р7b): added ON TOP of the app's baseline set, empty by - // default. `game` verbs are provisioned before the game launches; `installer` verbs before the - // installer runs. Share the install block's corrupt-clear (errorKey keeps a distinct error slot). - this.gameWinetricksList = this.dynamicList('winetricks', 'configure.fieldWinetricks', { reorder: true }); - const gameWinetricksHint = document.createElement('div'); - gameWinetricksHint.className = 'field-hint'; - this.labelRefs.push({ el: gameWinetricksHint, key: 'configure.winetricksHint' }); - this.gameWinetricksList.wrapper.append(gameWinetricksHint); - this.installWinetricksList = this.dynamicList('install', 'configure.fieldInstallWinetricks', { - reorder: true, - errorKey: 'install.winetricks', - }); - const installWinetricksHint = document.createElement('div'); - installWinetricksHint.className = 'field-hint'; - this.labelRefs.push({ el: installWinetricksHint, key: 'configure.installWinetricksHint' }); - this.installWinetricksList.wrapper.append(installWinetricksHint); - // umu GAMEID (Р7i): Steam appid or custom UMU_ID for the game's protonfix. A hint clarifies the value. - this.umuGameIdInput = this.textInput('umuGameId'); - const umuGameIdField = this.field('configure.fieldUmuGameId', 'umuGameId', this.umuGameIdInput); - const umuGameIdHint = document.createElement('div'); - umuGameIdHint.className = 'field-hint'; - this.labelRefs.push({ el: umuGameIdHint, key: 'configure.umuGameIdHint' }); - umuGameIdField.append(umuGameIdHint); - this.addSection('advanced', [ - this.field('configure.fieldLaunchTimeout', 'launchTimeoutSec', this.launchTimeoutInput), - this.field('configure.fieldKillTimeout', 'killTimeoutSec', this.killTimeoutInput), - this.gameWinetricksList.wrapper, - this.installWinetricksList.wrapper, - umuGameIdField, - ]); - - this.applyLabels(); - this.updateSectionVisibility(); - this.showSection('basics'); - } - - // ── Public API ──────────────────────────────────────────────────────────── - - /** Shows one section panel (the tab bar in configure.ts drives this); hides the rest. */ - showSection(id: SectionId): void { - for (const [sectionId, panel] of this.sectionPanels) panel.hidden = sectionId !== id; - } - - /** Populates the form from a parsed manifest (or its constituents), remembering rest/corrupt. */ - load( - model: ManifestFormModel, - rest: Readonly<Record<string, unknown>>, - corrupt: Readonly<Record<string, unknown>>, - mixed: boolean, - ): void { - this.rest = rest; - this.installRest = model.install.rest; - this.copyInstallRest = model.copyInstall.rest; - this.steamRest = model.steam.rest; - this.corrupt = { ...corrupt }; - this.launchMode = model.launchMode; - this.launchType.value = model.launchMode; - - this.setScalar('id', this.idInput, model.id); - this.setScalar('title', this.titleInput, model.title); - // A card that already carries an id owns it — don't let a title edit clobber it. A blank card re-arms - // the title→id auto-fill. - this.idTouched = model.id !== ''; - this.setScalar('executable', this.executableInput, model.executable); - this.setScalarChecked('runAsAdmin', this.runAsAdminSwitch, model.runAsAdmin); - this.setScalar('gridImage', this.gridImageInput, model.gridImage); - this.setScalar('saveOnCard', this.saveOnCardInput, model.saveOnCard); - this.setScalar('pcSavePath', this.pcSavePathInput, model.pcSavePath); - this.setScalar('launchTimeoutSec', this.launchTimeoutInput, model.launchTimeoutSec); - this.setScalar('killTimeoutSec', this.killTimeoutInput, model.killTimeoutSec); - - this.setList('args', this.argsList, model.args); - this.setList('watchProcesses', this.watchList, model.watchProcesses); - this.setList('heroImage', this.heroList, model.heroImage); - this.setList('winetricks', this.gameWinetricksList, model.winetricks); - this.setScalar('umuGameId', this.umuGameIdInput, model.umuGameId); - - // install block (corrupt = whole block). - const installCorrupt = 'install' in this.corrupt; - this.installInstallerInput.value = installCorrupt ? '' : model.install.installer; - this.installType.value = model.install.type; - this.installRunAsAdminSwitch.checked = installCorrupt ? false : model.install.runAsAdmin; - this.installArgsList.setValues(installCorrupt ? [] : model.install.args); - this.installWinetricksList.setValues(installCorrupt ? [] : model.install.winetricks); - this.updateInstallRunAsAdminState(model.install.type); - - // copy slot (the "move game to PC" checkbox — its own independent install block). A corrupt install - // block belongs to Installer mode (the parse could not tell it was a copy one), hence the same guard. - this.copyToPcSwitch.checked = installCorrupt ? false : model.copyToPc; - this.copySourceInput.value = installCorrupt ? '' : model.copyInstall.installer; - this.copyInstallWinetricks = model.copyInstall.winetricks; - - // steam block (corrupt = whole block). - this.setScalar('steam', this.appidInput, model.steam.appid); - - // audio (backgroundMusic is its own key). - this.music.setValue('backgroundMusic' in this.corrupt ? '' : model.backgroundMusic); - - this.mixed = mixed; - this.updateSectionVisibility(); - this.renderMixedBanner(); - this.renderCorruptNotes(); - } - - /** Reads the current field values into a model and serializes to manifest text. */ - serialize(): string { - return formModelToText(this.readModel(), this.rest, this.corrupt); - } - - /** Maps validation issues onto inline field errors; returns the issues that did NOT map (for #issues). */ - setFieldErrors(issues: readonly ManifestValidationIssue[] | null): readonly ManifestValidationIssue[] { - for (const el of this.errorEls.values()) el.textContent = ''; - if (issues === null) return []; - const unmapped: ManifestValidationIssue[] = []; - for (const issue of issues) { - const key = fieldKeyForPath(issue.path, this.isCopyMode()); - const el = key !== null ? this.errorEls.get(key) : undefined; - if (el === undefined) { - unmapped.push(issue); - continue; - } - el.textContent = - el.textContent !== null && el.textContent !== '' ? `${el.textContent}; ${issue.message}` : issue.message; - } - return unmapped; - } - - /** Enables/disables every control (blocked when the card is extracted). */ - setDisabled(disabled: boolean): void { - const controls: HTMLElement[] = [ - this.idInput, - this.titleInput, - this.launchType, - this.executableInput, - this.runAsAdminSwitch, - this.copyToPcSwitch, - this.copySourceInput, - this.installInstallerInput, - this.installType, - this.installRunAsAdminSwitch, - this.appidInput, - this.gridImageInput, - this.saveOnCardInput, - this.pcSavePathInput, - this.umuGameIdInput, - this.launchTimeoutInput, - this.killTimeoutInput, - ...[...this.deps.root.querySelectorAll('.field-row fluent-button')].map((e) => e as HTMLElement), - ]; - for (const el of controls) setElDisabled(el, disabled); - for (const list of [ - this.argsList, - this.watchList, - this.heroList, - this.installArgsList, - this.installWinetricksList, - this.gameWinetricksList, - ]) { - list.setDisabled(disabled); - } - for (const audio of [this.music]) { - audio.setDisabled(disabled); - } - // Keep the custom-installer rule even while enabling. - if (!disabled) this.updateInstallRunAsAdminState(toInstallType(getValue(this.installType))); - } - - /** Re-applies translator labels/placeholders (called on a language push). */ - relabel(): void { - this.applyLabels(); - this.renderMixedBanner(); - this.renderCorruptNotes(); - } - - // ── Model read/write helpers ──────────────────────────────────────────────── - - private readModel(): ManifestFormModel { - return { - launchMode: this.launchMode, - id: getValue(this.idInput), - title: getValue(this.titleInput), - executable: getValue(this.executableInput), - args: this.argsList.values(), - runAsAdmin: getChecked(this.runAsAdminSwitch), - watchProcesses: this.watchList.values(), - winetricks: this.gameWinetricksList.values(), - umuGameId: getValue(this.umuGameIdInput), - heroImage: this.heroList.values(), - gridImage: getValue(this.gridImageInput), - saveOnCard: getValue(this.saveOnCardInput), - pcSavePath: getValue(this.pcSavePathInput), - launchTimeoutSec: getValue(this.launchTimeoutInput), - killTimeoutSec: getValue(this.killTimeoutInput), - backgroundMusic: getValue(this.music.input), - install: { - installer: getValue(this.installInstallerInput), - type: toInstallType(getValue(this.installType)), - runAsAdmin: getChecked(this.installRunAsAdminSwitch), - args: this.installArgsList.values(), - winetricks: this.installWinetricksList.values(), - rest: this.installRest, - }, - copyToPc: getChecked(this.copyToPcSwitch), - // The copy slot has only the one control; `type` is pinned and the rest of the block round-trips - // from what was loaded (its args/runAsAdmin are schema-forbidden, so they stay at the defaults). - copyInstall: { - installer: getValue(this.copySourceInput), - type: 'copy', - runAsAdmin: false, - args: [], - winetricks: this.copyInstallWinetricks, - rest: this.copyInstallRest, - }, - steam: { appid: getValue(this.appidInput), rest: this.steamRest }, - }; - } - - private setScalar(key: string, input: ValueEl, value: string): void { - input.value = key in this.corrupt ? '' : value; - } - private setScalarChecked(key: string, input: CheckedEl, value: boolean): void { - input.checked = key in this.corrupt ? false : value; - } - private setList(key: string, list: DynamicList, values: readonly string[]): void { - list.setValues(key in this.corrupt ? [] : values); - } - - // ── Corrupt / rest state ──────────────────────────────────────────────────── - - // Clears a top-level corrupt key once the user edits that field (its value now comes from the model), - // drops its "invalid value" note and re-runs onChange (via the caller). - private clearCorrupt(key: string): void { - if (key in this.corrupt) { - const next = { ...this.corrupt }; - delete next[key]; - this.corrupt = next; - this.renderCorruptNotes(); - } - } - - // Renders the "field contains an invalid value" note under every still-corrupt field. - private renderCorruptNotes(): void { - const t = this.deps.translator(); - for (const [key, container] of this.containers) { - const existing = container.querySelector('.corrupt-note'); - const corrupt = key in this.corrupt; - if (corrupt && existing === null) { - const note = document.createElement('div'); - note.className = 'field-hint corrupt-note'; - note.textContent = t('configure.corruptField'); - container.append(note); - } else if (corrupt && existing !== null) { - existing.textContent = t('configure.corruptField'); - } else if (!corrupt && existing !== null) { - existing.remove(); - } - } - } - - private renderMixedBanner(): void { - this.mixedBanner.hidden = !this.mixed; - if (this.mixed) { - this.mixedBanner.textContent = this.deps.translator()('configure.mixedLaunchModes', { - mode: this.launchModeLabel(this.launchMode), - }); - } - } - - // The active mode's display label ("Steam" is a brand literal, the other two are translated). - private launchModeLabel(mode: LaunchMode): string { - if (mode === 'steam') return 'Steam'; - return this.deps.translator()(mode === 'installer' ? 'configure.launchInstaller' : 'configure.launchExecutable'); - } - - // ── Section visibility (launch mode) ───────────────────────────────────────── - - private onLaunchTypeChange(): void { - const value = getValue(this.launchType); - if (value === 'executable' || value === 'installer' || value === 'steam') { - this.launchMode = value; - this.updateSectionVisibility(); - this.deps.onChange(); - } - } - - private onInstallTypeChange(): void { - this.clearCorrupt('install'); - this.updateInstallRunAsAdminState(toInstallType(getValue(this.installType))); - this.deps.onChange(); - } - - /** True while the form is in Executable mode with "move game to PC" on (manifest `install.type: copy`). */ - private isCopyMode(): boolean { - return this.launchMode === 'executable' && getChecked(this.copyToPcSwitch); - } - - private onCopyToPcChange(): void { - this.clearCorrupt('install'); - this.updateCopyToPcState(); - this.deps.onChange(); - } - - // Shows the source field and re-words the executable's note: with the switch on, `executable` is - // resolved inside the COPIED directory, not from the card root. The stored value is deliberately left - // untouched — silently rewriting a path the user typed would be worse than telling them it changed - // meaning (Browse does trim, where the intent is unambiguous — see executableFromPick). - // Fills the id from the current title (slugified), unless the user has taken over the id field. A - // programmatic `.value` set doesn't fire 'input', so this never marks the id as touched. Empty slug - // (e.g. an all-Cyrillic name) leaves the id empty — the schema forbids non-latin ids, so the user types - // one by hand. - private syncIdFromTitle(): void { - if (this.idTouched) return; - this.idInput.value = slugifyId(getValue(this.titleInput)); - this.clearCorrupt('id'); - } - - private updateCopyToPcState(): void { - const copy = this.isCopyMode(); - this.copySourceField.hidden = !copy; - this.executableNote.textContent = this.deps.translator()( - copy ? 'configure.copyExecutableNote' : 'configure.executableNote', - ); - } - - /** - * Р14: the picker always returns paths relative to the CARD ROOT, but in copy mode `executable` is - * relative to the copied directory — so a raw pick would silently produce a broken path - * (`Games/Witcher/bin/witcher.exe` where the game will only have `bin/witcher.exe`). Trim the source - * prefix; a pick from outside the source is a mistake we refuse rather than mangle. - * Outside copy mode the pick passes through unchanged. - */ - private executableFromPick(picked: string): string | null { - if (!this.isCopyMode()) return picked; - const source = getValue(this.copySourceInput).replace(/\/+$/, ''); - if (source === '') return picked; // no source yet → nothing to trim against - const prefix = `${source}/`; - if (!picked.startsWith(prefix)) { - this.deps.onPickError(this.deps.translator()('configure.copySourceOutside', { source })); - return null; - } - return picked.slice(prefix.length); - } - - // Custom installer hands argv control to the card → the validator forbids running it elevated, so the - // switch is disabled (and forced off) for `custom` (mirrors the manifest refine). - private updateInstallRunAsAdminState(type: InstallType): void { - const custom = type === 'custom'; - if (custom) this.installRunAsAdminSwitch.checked = false; - setElDisabled(this.installRunAsAdminSwitch, custom); - } - - private updateSectionVisibility(): void { - // execSection carries the executable/args/runAsAdmin fields shared by Executable AND Installer modes, - // so it's hidden only in Steam mode. "Move game to PC" inside it is Executable-only, hidden otherwise. - this.execSection.hidden = this.launchMode === 'steam'; - this.copyToPcField.hidden = this.launchMode !== 'executable'; - this.installSection.hidden = this.launchMode !== 'installer'; - this.installerExperimental.hidden = this.launchMode !== 'installer'; - this.steamSection.hidden = this.launchMode !== 'steam'; - this.updateCopyToPcState(); - } - - // ── DOM builders ──────────────────────────────────────────────────────────── - - private addSection(id: SectionId, children: readonly HTMLElement[]): void { - const panel = document.createElement('section'); - panel.className = 'form-section'; - panel.append(...children); - this.sectionPanels.set(id, panel); - this.deps.root.append(panel); - } - - private group(children: readonly HTMLElement[]): HTMLElement { - const group = document.createElement('div'); - group.className = 'field-group'; - group.append(...children); - return group; - } - - // A labelled control. `errorKey` (when set) registers the inline error slot and the field container so a - // corrupt note can be shown. - private field(labelKey: MessageKey, errorKey: FieldKey | null, control: HTMLElement): HTMLElement { - const wrapper = document.createElement('div'); - wrapper.className = 'field'; - wrapper.append(this.fieldLabel(labelKey), control); - if (errorKey !== null) { - wrapper.append(this.errorSlot(errorKey)); - this.registerContainer(errorKey, wrapper); - } - return wrapper; - } - - // A switch field laid out like the Settings window: the label sits to the LEFT of the switch, via - // fluent-field (label-position="after" + slotted switch/label). An error slot sits below. - private switchField(labelKey: MessageKey, errorKey: FieldKey, control: CheckedEl): HTMLElement { - const wrapper = document.createElement('div'); - wrapper.className = 'field'; - const field = document.createElement('fluent-field'); - field.setAttribute('label-position', 'after'); - const id = `sw-${errorKey.replace(/\W/g, '-')}`; - control.setAttribute('slot', 'input'); - control.id = id; - const label = document.createElement('label'); - label.setAttribute('slot', 'label'); - label.setAttribute('for', id); - this.labelRefs.push({ el: label, key: labelKey }); - field.append(control, label); - wrapper.append(field, this.errorSlot(errorKey)); - this.registerContainer(errorKey, wrapper); - return wrapper; - } - - // A labelled control with a trailing Browse… button that fills it from a picked path. - // `transform` post-processes the picked path before it lands in the control; returning null rejects the - // pick (the transform having reported why) and leaves the current value alone. - private fieldWithBrowse( - labelKey: MessageKey, - errorKey: FieldKey, - control: ValueEl, - kind: ConfigPickKind, - transform?: (picked: string) => string | null, - ): HTMLElement { - const wrapper = document.createElement('div'); - wrapper.className = 'field'; - const row = document.createElement('div'); - row.className = 'field-row'; - const browse = this.textButton('configure.browse', async () => { - const result = await this.deps.pickPath(kind); - if (result.ok) { - const picked = result.paths[0] ?? getValue(control); - const next = transform === undefined ? picked : transform(picked); - if (next === null) return; - control.value = next; - this.clearCorrupt(topLevelOf(errorKey)); - this.deps.onChange(); - } else if (!('cancelled' in result)) { - this.deps.onPickError(result.message); - } - }); - row.append(control, browse); - wrapper.append(this.fieldLabel(labelKey), row, this.errorSlot(errorKey)); - this.registerContainer(errorKey, wrapper); - return wrapper; - } - - // The background-music field: a text-input + Browse + Clear. An empty value omits the key from - // game.json (the game plays no music of its own), and a hint says so. - private musicField( - labelKey: MessageKey, - errorKey: FieldKey, - corruptKey: string, - hintKey: MessageKey, - ): AudioField { - const wrapper = document.createElement('div'); - wrapper.className = 'field'; - - // The value IS the state: a path means "play this", empty means "no music of its own". There is no - // Default/Custom selector — an empty field says the same thing with one control fewer, and the trash - // button is how you get back to it. - const row = document.createElement('div'); - row.className = 'field-row'; - const input = document.createElement('fluent-text-input') as ValueEl; - input.setAttribute('type', 'text'); - const browse = this.textButton('configure.browse', async () => { - const result = await this.deps.pickPath('audio'); - if (result.ok) { - input.value = result.paths[0] ?? getValue(input); - this.clearCorrupt(corruptKey); - refresh(); - this.deps.onChange(); - } else if (!('cancelled' in result)) { - this.deps.onPickError(result.message); - } - }); - // The same trash button the hero/args rows use — clearing a path is the same gesture, so it looks - // the same instead of inventing a second vocabulary for it. `icon-danger` is part of that button, - // not decoration: it carries the destructive hover/pressed tokens (see configure.css). - const clear = this.iconButton('configure.remove', trashIcon(), () => { - if (getValue(input) === '') return; // nothing to clear — not a change, don't mark the form dirty - input.value = ''; - this.clearCorrupt(corruptKey); - refresh(); - this.deps.onChange(); - }); - clear.classList.add('icon-danger'); - row.append(input, browse, clear); - - const hint = document.createElement('div'); - hint.className = 'field-hint'; - this.labelRefs.push({ el: hint, key: hintKey }); - - wrapper.append(this.fieldLabel(labelKey), row, hint, this.errorSlot(errorKey)); - this.registerContainer(errorKey, wrapper); - - let formDisabled = false; - const refresh = (): void => { - const empty = getValue(input) === ''; - hint.hidden = !empty; - // The trash button is dead while there is nothing to clear — the form-wide disable still wins. - setElDisabled(clear, formDisabled || empty); - }; - input.addEventListener('input', () => { - this.clearCorrupt(corruptKey); - refresh(); - this.deps.onChange(); - }); - refresh(); - - return { - wrapper, - input, - setValue: (value) => { - input.value = value; - refresh(); - }, - setDisabled: (disabled) => { - formDisabled = disabled; - for (const el of [input, browse]) setElDisabled(el, disabled); - refresh(); - }, - }; - } - - private dynamicList( - corruptKey: string, - labelKey: MessageKey, - opts: { - readonly browseKind?: ConfigPickKind; - readonly browseLabelKey?: MessageKey; - readonly replaceKind?: ConfigPickKind; - readonly preview?: boolean; - readonly reorder?: boolean; - readonly noAdd?: boolean; - /** Hard cap on the number of rows (heroImage — see MAX_HERO_IMAGES). Adding past it is a no-op and - * the Add/Browse buttons go disabled, so the form can't build a manifest its own validator rejects. */ - readonly maxItems?: number; - /** Error slot / container key, when it must differ from `corruptKey` (e.g. a second list sharing the - * `install` block's corrupt-clear but needing its own error slot). Defaults to the corruptKey rule. */ - readonly errorKey?: FieldKey; - } = {}, - ): DynamicList { - const wrapper = document.createElement('div'); - wrapper.className = 'field'; - const rows = document.createElement('div'); - rows.className = 'list-rows'; - const buttonRow = document.createElement('div'); - buttonRow.className = 'button-row'; - - // Drag-and-drop reordering (native HTML5 DnD, no dependency): a grip handle per row starts the drag, - // and dragover on the container live-repositions the dragged row. The handle is hidden with a single - // row (nothing to reorder). onChange fires once on dragend. - let draggingRow: HTMLElement | null = null; - const refreshHandles = (): void => { - if (opts.reorder !== true) return; - const many = rows.children.length > 1; - for (const handle of rows.querySelectorAll<HTMLElement>('.drag-handle')) handle.hidden = !many; - }; - if (opts.reorder === true) { - rows.addEventListener('dragover', (event) => { - if (draggingRow === null) return; - event.preventDefault(); - const after = dragAfterElement(rows, event.clientY, draggingRow); - if (after === null) rows.append(draggingRow); - else if (after !== draggingRow) rows.insertBefore(draggingRow, after); - }); - rows.addEventListener('drop', (event) => event.preventDefault()); - } - - // Row cap (opts.maxItems): the Add/Browse buttons go disabled once it is reached, and addRow itself - // refuses — a multi-select Browse can hand us more paths than there is room for. - const capReached = (): boolean => - opts.maxItems !== undefined && rows.children.length >= opts.maxItems; - const refreshCap = (): void => { - if (opts.maxItems === undefined) return; - const full = capReached(); - for (const el of buttonRow.querySelectorAll('fluent-button')) setElDisabled(el as HTMLElement, full); - }; - - // `fromSource` rows come from the manifest text and are ALWAYS shown, cap or not: hiding a 4th hero - // image would silently drop it on the next Save. The validator flags it instead (manifest.heroTooMany), - // Save stays blocked, and the user removes a row. Only a user-initiated add respects the cap. - const addRow = (value: string, fromSource = false): void => { - if (!fromSource && capReached()) return; - const row = document.createElement('div'); - row.className = 'list-row'; - // Drag handle (grip) — the row is reordered by dragging this, not the whole row (so the text field - // stays selectable). Hidden when there's a single row (see refreshHandles). - if (opts.reorder === true) { - const handle = document.createElement('span'); - handle.className = 'drag-handle'; - handle.textContent = '⠿'; - handle.setAttribute('draggable', 'true'); - handle.setAttribute('role', 'button'); - handle.setAttribute('data-i18n-aria-label-key', 'configure.dragReorder'); - handle.addEventListener('dragstart', (event) => { - draggingRow = row; - row.classList.add('dragging'); - event.dataTransfer?.setData('text/plain', ''); // some browsers need data to start a drag - if (event.dataTransfer !== null) event.dataTransfer.effectAllowed = 'move'; - }); - handle.addEventListener('dragend', () => { - row.classList.remove('dragging'); - draggingRow = null; - refreshHandles(); - this.deps.onChange(); - }); - row.append(handle); - } - const input = document.createElement('fluent-text-input') as ValueEl; - input.setAttribute('type', 'text'); - input.value = value; - - let thumb: HTMLImageElement | null = null; - const refreshThumb = (): void => { - const el = thumb; - if (el === null) return; - const current = getValue(input); - if (current === '') { - el.hidden = true; - el.removeAttribute('src'); - return; - } - void this.deps.imagePreview(current).then((url) => { - if (url !== null) { - el.src = url; - el.hidden = false; - } else { - el.hidden = true; - el.removeAttribute('src'); - } - }); - }; - if (opts.preview === true) { - thumb = document.createElement('img'); - thumb.className = 'hero-thumb'; - thumb.alt = ''; - thumb.hidden = true; - // Click the thumbnail → open a full-size lightbox of the same (already-loaded) data URL. - thumb.addEventListener('click', () => { - const src = thumb?.getAttribute('src'); - if (src !== null && src !== undefined && src !== '') this.openImagePreview(src); - }); - row.append(thumb); - } - - input.addEventListener('input', () => { - this.clearCorrupt(corruptKey); - this.deps.onChange(); - }); - if (opts.preview === true) input.addEventListener('change', () => refreshThumb()); - row.append(input); - // Per-row "Replace…" — pick a file and swap THIS row's value (hero images). - if (opts.replaceKind !== undefined) { - const kind = opts.replaceKind; - const replace = this.textButton('configure.replace', async () => { - const result = await this.deps.pickPath(kind); - if (result.ok) { - const picked = result.paths[0]; - if (picked !== undefined) { - input.value = picked; - this.clearCorrupt(corruptKey); - refreshThumb(); - this.deps.onChange(); - } - } else if (!('cancelled' in result)) { - this.deps.onPickError(result.message); - } - }); - row.append(replace); - } - const remove = this.iconButton('configure.remove', trashIcon(), () => { - row.remove(); - refreshHandles(); - refreshCap(); - this.clearCorrupt(corruptKey); - this.deps.onChange(); - }); - remove.classList.add('icon-danger'); - row.append(remove); - rows.append(row); - refreshHandles(); - refreshCap(); - refreshThumb(); - // Rows are built AFTER the constructor's applyLabels(), so label their fresh elements (the Replace… - // text, drag-handle/remove aria) now — otherwise they stay blank until the next language change. - this.applyLabels(); - }; - - // Hero images are added via Browse… (multi-select) — no manual "Add" row there (opts.noAdd). - if (opts.noAdd !== true) { - const addBtn = this.textButton('configure.add', () => { - addRow(''); - this.deps.onChange(); - }); - buttonRow.append(addBtn); - } - if (opts.browseKind !== undefined) { - const kind = opts.browseKind; - const browse = this.textButton(opts.browseLabelKey ?? 'configure.browse', async () => { - const result = await this.deps.pickPath(kind); - if (result.ok) { - for (const p of result.paths) addRow(p); - this.clearCorrupt(corruptKey); - this.deps.onChange(); - } else if (!('cancelled' in result)) { - this.deps.onPickError(result.message); - } - }); - buttonRow.append(browse); - } - - const errorKey: FieldKey = - opts.errorKey ?? (corruptKey === 'install' ? 'install.args' : (corruptKey as FieldKey)); - wrapper.append(this.fieldLabel(labelKey), rows, buttonRow, this.errorSlot(errorKey)); - this.registerContainer(errorKey, wrapper); - - return { - wrapper, - values: () => [...rows.querySelectorAll('fluent-text-input')].map((el) => getValue(el as ValueEl)), - setValues: (values) => { - rows.replaceChildren(); - for (const value of values) addRow(value, true); - refreshCap(); - }, - setDisabled: (disabled) => { - for (const el of buttonRow.querySelectorAll('fluent-button')) setElDisabled(el as HTMLElement, disabled); - for (const el of rows.querySelectorAll('fluent-text-input, fluent-button')) { - setElDisabled(el as HTMLElement, disabled); - } - // Disable dragging while blocked; re-assert single-row handle visibility when re-enabling. - for (const handle of rows.querySelectorAll<HTMLElement>('.drag-handle')) { - handle.setAttribute('draggable', disabled ? 'false' : 'true'); - } - if (!disabled) { - refreshHandles(); - refreshCap(); // the cap outlives the block: a full list keeps its Add/Browse disabled - } - }, - }; - } - - private textInput(corruptKey: string, beforeChange?: () => void): ValueEl { - const input = document.createElement('fluent-text-input') as ValueEl; - input.setAttribute('type', 'text'); - input.addEventListener('input', () => { - this.clearCorrupt(corruptKey); - // Runs BEFORE onChange so a derived field (e.g. id from the title) is already in the DOM when the - // form re-serializes — otherwise the serialized value would lag one keystroke behind. - beforeChange?.(); - this.deps.onChange(); - }); - return input; - } - - private numberInput(corruptKey: string): ValueEl { - const input = this.textInput(corruptKey); - // `type="number"` is not part of Fluent v3's TextInputType — use text + numeric inputmode (plan R7). - input.setAttribute('inputmode', 'numeric'); - return input; - } - - private switchControl(corruptKey: string): CheckedEl { - const control = document.createElement('fluent-switch') as CheckedEl; - control.addEventListener('change', () => { - this.clearCorrupt(corruptKey); - this.deps.onChange(); - }); - return control; - } - - private dropdown(options: ReadonlyArray<readonly [string, string]>): ValueEl { - const dropdown = document.createElement('fluent-dropdown') as ValueEl; - const listbox = document.createElement('fluent-listbox'); - for (const [value, label] of options) { - const option = document.createElement('fluent-option'); - (option as ValueEl).value = value; - if (isMessageKey(label)) this.optionRefs.push({ el: option, key: label }); - else option.textContent = label; // literal (brand) label - listbox.append(option); - } - dropdown.append(listbox); - return dropdown; - } - - private fieldLabel(labelKey: MessageKey): HTMLElement { - const label = document.createElement('span'); - label.className = 'field-label'; - this.labelRefs.push({ el: label, key: labelKey }); - return label; - } - - private errorSlot(errorKey: FieldKey): HTMLElement { - const error = document.createElement('div'); - error.className = 'field-error'; - this.errorEls.set(errorKey, error); - return error; - } - - private textButton(labelKey: MessageKey, onClick: () => void | Promise<void>): HTMLElement { - const button = document.createElement('fluent-button'); - this.labelRefs.push({ el: button, key: labelKey }); - button.addEventListener('click', () => void onClick()); - return button; - } - - // Opens a full-size lightbox of a hero image (the data URL already loaded in the thumbnail). Click - // anywhere (or Escape) closes it. Self-contained — CSP allows img-src data:. - private openImagePreview(url: string): void { - const veil = document.createElement('div'); - veil.className = 'image-preview-veil'; - const image = document.createElement('img'); - image.className = 'image-preview-img'; - image.alt = ''; - image.src = url; - veil.append(image); - const close = (): void => { - veil.remove(); - document.removeEventListener('keydown', onKey); - }; - const onKey = (event: KeyboardEvent): void => { - if (event.key === 'Escape') close(); - }; - veil.addEventListener('click', close); - document.addEventListener('keydown', onKey); - document.body.append(veil); - } - - private iconButton(labelKey: MessageKey, content: string | Node, onClick: () => void): HTMLElement { - const button = document.createElement('fluent-button'); - button.className = 'icon-button'; - button.setAttribute('appearance', 'transparent'); // chrome-less: just the icon - if (typeof content === 'string') button.textContent = content; - else button.append(content); - button.setAttribute('data-i18n-aria-label-key', labelKey); // aria label re-applied in applyLabels - button.addEventListener('click', onClick); - return button; - } - - private registerContainer(errorKey: FieldKey, wrapper: HTMLElement): void { - const top = topLevelOf(errorKey); - if (!this.containers.has(top)) this.containers.set(top, wrapper); - } - - private applyLabels(): void { - const t = this.deps.translator(); - for (const { el, key } of this.labelRefs) el.textContent = t(key); - for (const { el, key } of this.optionRefs) el.textContent = t(key); - for (const { el, key } of this.placeholderRefs) el.setAttribute('placeholder', t(key)); - for (const button of this.deps.root.querySelectorAll('[data-i18n-aria-label-key]')) { - const key = button.getAttribute('data-i18n-aria-label-key'); - if (key !== null) button.setAttribute('aria-label', t(key as MessageKey)); - } - } -} - -// ── Free helpers ────────────────────────────────────────────────────────────── - -function setElDisabled(el: HTMLElement, disabled: boolean): void { - if (disabled) el.setAttribute('disabled', ''); - else el.removeAttribute('disabled'); -} - -const SVG_NS = 'http://www.w3.org/2000/svg'; -// Trash/bin icon (from the supplied SVG) for the remove-row button. Built via DOM (no innerHTML); the -// stroke colour is set in CSS (.trash-icon path → #A80000). -const TRASH_PATHS = [ - 'M3 6H21M5 6V20C5 21.1046 5.89543 22 7 22H17C18.1046 22 19 21.1046 19 20V6M8 6V4C8 2.89543 8.89543 2 10 2H14C15.1046 2 16 2.89543 16 4V6', - 'M14 11V17', - 'M10 11V17', -]; -function trashIcon(): SVGSVGElement { - const svg = document.createElementNS(SVG_NS, 'svg'); - svg.setAttribute('class', 'trash-icon'); - svg.setAttribute('viewBox', '0 0 24 24'); - svg.setAttribute('fill', 'none'); - svg.setAttribute('aria-hidden', 'true'); - for (const d of TRASH_PATHS) { - const path = document.createElementNS(SVG_NS, 'path'); - path.setAttribute('d', d); - path.setAttribute('stroke-width', '2'); - path.setAttribute('stroke-linecap', 'round'); - path.setAttribute('stroke-linejoin', 'round'); - svg.append(path); - } - return svg; -} - -/** During a drag, finds the row the dragged one should be inserted BEFORE for a given pointer Y (the first - * row whose vertical midpoint is below the cursor), or null to append at the end. Skips the dragged row. */ -function dragAfterElement(container: HTMLElement, y: number, dragging: HTMLElement): HTMLElement | null { - const rows = [...container.children].filter((el): el is HTMLElement => el instanceof HTMLElement && el !== dragging); - for (const row of rows) { - const box = row.getBoundingClientRect(); - if (y < box.top + box.height / 2) return row; - } - return null; -} - -/** Narrows the install-type dropdown value to the enum (defaults to nsis for any unexpected value). */ -// The dropdown only ever offers the three installer families — `copy` is driven by its own checkbox and -// must never surface here, hence the narrowed return type (an unknown value falls back to nsis, as before). -function toInstallType(value: string): InstallerFamily { - return value === 'inno' || value === 'custom' ? value : 'nsis'; -} - -/** The top-level manifest key a field error belongs to (for corrupt-note grouping). */ -function topLevelOf(key: FieldKey): string { - // The copy fields live in the `install` block too — editing them must clear ITS corrupt state. - if (key === 'copySource' || key === 'copyToPc') return 'install'; - if (key.startsWith('install.')) return 'install'; - if (key === 'steam.appid') return 'steam'; - return key; -} - -/** - * Maps a validation issue path onto a form field error slot (prefix match), or null when unmapped. - * - * `copyToPc` is needed because the SAME manifest path means different things per mode: with the checkbox - * on, `install.installer` is the copy-source field in the Executable section, and its errors (an empty - * source, a path escaping the card root) must appear there — the Installer section is hidden, so an - * error routed to it would block Save with no visible cause. - */ -function fieldKeyForPath(path: string, copyToPc: boolean): FieldKey | null { - switch (path) { - case 'id': - case 'title': - case 'executable': - case 'args': - case 'runAsAdmin': - case 'watchProcesses': - case 'gridImage': - case 'saveOnCard': - case 'pcSavePath': - case 'backgroundMusic': - case 'umuGameId': - case 'launchTimeoutSec': - case 'killTimeoutSec': - return path; - default: - break; - } - if (path === 'heroImage' || path.startsWith('heroImage.')) return 'heroImage'; - if (path === 'winetricks' || path.startsWith('winetricks.')) return 'winetricks'; - if (path === 'steam' || path.startsWith('steam.')) return 'steam.appid'; - if (path === 'install' || path === 'install.installer') { - return copyToPc ? 'copySource' : 'install.installer'; - } - // In copy mode the remaining install.* fields have no control of their own (the type is implicit, and - // args/runAsAdmin are rejected by the schema) — leave them unmapped so they surface in the #issues - // panel rather than in a slot the user cannot see. - if (path.startsWith('install.')) { - if (copyToPc) return null; - if (path === 'install.type') return 'install.type'; - if (path === 'install.runAsAdmin') return 'install.runAsAdmin'; - if (path.startsWith('install.winetricks')) return 'install.winetricks'; - if (path.startsWith('install.args')) return 'install.args'; - } - return null; -} - -const MESSAGE_KEY_PREFIXES = ['configure.', 'common.', 'window.']; -function isMessageKey(label: string): label is MessageKey { - return MESSAGE_KEY_PREFIXES.some((p) => label.startsWith(p)); -} - -/** Re-exported so configure.ts parses text without importing the model module twice. */ -export { textToFormModel, type ParseFormResult }; diff --git a/src/renderer/configure.css b/src/renderer/configure.css deleted file mode 100644 index cc540ced..00000000 --- a/src/renderer/configure.css +++ /dev/null @@ -1,408 +0,0 @@ -/* Configure-game window layout. Like settings.css, component look (colors, typography, focus) comes from - Fluent v3 + the theme applied at runtime via setTheme() — which publishes the theme tokens as GLOBAL - CSS custom properties. The CodeMirror editor is themed off the very same tokens (see configure.ts - cmTheme), so it follows light/dark automatically. Segoe UI is the system font Fluent uses. */ - -html, -body { - margin: 0; - height: 100%; -} - -body { - display: flex; - flex-direction: column; - height: 100%; - background: var(--colorNeutralBackground1, #1f1f1f); - color: var(--colorNeutralForeground1, #ffffff); - font-family: 'Segoe UI', system-ui, sans-serif; -} - -/* Custom title bar (native one is hidden). Draggable; right padding keeps the title clear of the native - min/max/close overlay. Background matches the page and the overlay `color` set in configure-window.ts. */ -#titlebar { - flex: none; - height: 48px; - display: flex; - align-items: center; - gap: 8px; - padding: 0 16px; - padding-right: 148px; - background: var(--colorNeutralBackground1, #1f1f1f); - -webkit-app-region: drag; - user-select: none; -} - -#titlebar-icon { - width: 18px; - height: 18px; - flex: none; -} - -#titlebar-title { - font-size: 12px; - font-weight: 600; - color: var(--colorNeutralForeground1, #ffffff); -} - -#titlebar-subtitle { - font-size: 12px; - color: var(--colorNeutralForeground2, #adadad); -} - -#configure { - flex: 1 1 auto; - min-height: 0; /* let the editor shrink inside the flex column instead of overflowing */ - display: flex; - flex-direction: column; - gap: 16px; - padding: 12px 20px 20px; -} - -.section { - flex: none; - display: flex; - flex-direction: column; - gap: 8px; -} - -.section-label { - font-size: 12px; - font-weight: 600; - color: var(--colorNeutralForeground2, #adadad); -} - -fluent-dropdown { - align-self: flex-start; - min-inline-size: 260px; - /* Suppress the accent underline the control animates in on focus/open (.control::after) — visual noise. - Safe: in single-select mode the token only feeds that underline. */ - --colorCompoundBrandStroke: transparent; -} - -#drive-empty { - color: var(--colorNeutralForeground2, #adadad); - font-size: 13px; -} - -.button-row { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -/* Edit tabs: one row of buttons ([Basics]…[JSON]). The active one is marked accent from the renderer - (appearance="accent"). Wraps on a narrow window. */ -#edit-tabs { - flex: none; - display: flex; - flex-wrap: wrap; - gap: 4px; -} - -/* Interactive form: grows to fill the remaining height and scrolls vertically inside the flex column. - Only one section panel is shown at a time (driven by the tab bar). */ -#form-view { - flex: 1 1 auto; - min-height: 0; - overflow-y: auto; - display: flex; - flex-direction: column; - gap: 16px; - padding-right: 4px; /* keep the scrollbar off the controls */ -} - -/* A single section panel (Basics/Launch/…): stacks its fields. */ -.form-section { - display: flex; - flex-direction: column; - gap: 16px; -} - -/* Compact icon buttons in list rows (remove). */ -.icon-button { - flex: none; - min-width: 32px; -} - -/* Danger (destructive) action, the Fluent way: a transparent icon button whose icon uses the danger - foreground token and whose hover/pressed use the danger background tokens (Fluent web-components has no - built-in "destructive" button appearance, so we express it through the status-danger design tokens). */ -.icon-danger { - --colorTransparentBackgroundHover: var(--colorStatusDangerBackground1); - --colorTransparentBackgroundPressed: var(--colorStatusDangerBackground2); -} - -/* Trash/bin icon: outline only, danger-red. fill:none must be set here — Fluent's - ::slotted(svg){fill:currentColor} overrides the SVG's fill="none" attribute (making the interior follow - the theme foreground); a direct fill:none on the paths wins. */ -.trash-icon { - width: 16px; - height: 16px; -} - -.trash-icon path { - fill: none; - stroke: var(--colorStatusDangerForeground1, #d13438); -} - -/* Drag handle (grip) for reordering list rows via native drag-and-drop. */ -.drag-handle { - flex: none; - cursor: grab; - user-select: none; - padding: 0 6px; - font-size: 16px; - line-height: 1; - color: var(--colorNeutralForeground3, #8a8a8a); -} - -.drag-handle:active { - cursor: grabbing; -} - -.drag-handle[draggable='false'] { - cursor: default; - opacity: 0.4; -} - -/* The row being dragged is dimmed for feedback. */ -.list-row.dragging { - opacity: 0.5; -} - -/* Hero image thumbnail next to each path row (data: URL only — CSP-safe). Clickable → full-size lightbox. */ -.hero-thumb { - flex: none; - width: 64px; - height: 36px; - object-fit: cover; - border-radius: 4px; - border: 1px solid var(--colorNeutralStroke2, #444); - cursor: pointer; -} - -/* Full-size hero preview lightbox (click anywhere / Esc to close). */ -.image-preview-veil { - position: fixed; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - padding: 32px; - background: rgba(0, 0, 0, 0.75); - z-index: 200; - cursor: pointer; -} - -.image-preview-img { - max-width: 100%; - max-height: 100%; - object-fit: contain; - border-radius: 6px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); -} - -/* The "find the appid" helper link under the Steam appid field. */ -.help-link { - font-size: 12px; - color: var(--colorBrandForegroundLink, #479ef5); - cursor: pointer; - width: fit-content; -} - -/* A launch-mode section wrapper (executable / installer / steam blocks) — stacks its fields. */ -.field-group { - display: flex; - flex-direction: column; - gap: 16px; -} - -/* One labelled control: a stacked label + input(s) + inline error. */ -.field { - display: flex; - flex-direction: column; - gap: 4px; -} - -.field-label { - font-size: 12px; - color: var(--colorNeutralForeground2, #adadad); -} - -/* A control paired with a trailing Browse… button on the same row. */ -.field-row { - display: flex; - align-items: center; - gap: 8px; -} - -.field-row fluent-text-input { - flex: 1 1 auto; -} - -/* A read-only informational line (e.g. "Schema version: 1"). */ -.field-static { - font-size: 13px; - color: var(--colorNeutralForeground2, #adadad); -} - -/* Inline per-field validation error (mirrors the issues panel colour). Hidden until it has text. */ -.field-error { - font-size: 12px; - color: var(--colorStatusDangerForeground1, #f87171); -} - -.field-error:empty { - display: none; -} - -/* A per-field hint (the corrupt-value note, the {dir} note, the watchProcesses format note). */ -.field-hint { - font-size: 12px; - color: var(--colorNeutralForeground3, #8a8a8a); -} - -/* A hint that is a WARNING (e.g. an experimental mode) — amber, to stand out from the grey hints. */ -.field-hint.field-warning { - color: var(--colorStatusWarningForeground1, #e6b800); -} - -/* Dynamic string lists (args, watchProcesses, heroImage, install.args): a stack of rows + an Add button. */ -.list-rows { - display: flex; - flex-direction: column; - gap: 6px; -} - -.list-row { - display: flex; - align-items: center; - gap: 8px; -} - -.list-row fluent-text-input { - flex: 1 1 auto; -} - -/* Warning banner shown when the loaded manifest carries blocks for more than one launch mode. */ -#mixed-banner { - flex: none; - padding: 8px 12px; - border-radius: 4px; - font-size: 12px; - color: var(--colorStatusWarningForeground1, #e6b800); - background: var(--colorNeutralBackground2, #2a2a2a); -} - -/* Editor: grows to fill the remaining height; CodeMirror fills the box. */ -#editor-section { - flex: 1 1 auto; - min-height: 0; - display: flex; - flex-direction: column; - gap: 6px; -} - -#editor { - flex: 1 1 auto; - min-height: 160px; - border: 1px solid var(--colorNeutralStroke2, #444); - border-radius: 4px; - overflow: hidden; -} - -#editor .cm-editor { - height: 100%; -} - -#editor .cm-scroller { - font-family: 'Cascadia Code', 'Consolas', ui-monospace, monospace; - font-size: 13px; -} - -/* Text selection highlight. CodeMirror's baseTheme colours the selection with a very specific focused - selector that a plain EditorView.theme rule (and even !important via style-mod) failed to override — so - we win it from a real stylesheet: the #editor id gives high specificity and !important beats CM's - non-important rules. A fixed semi-transparent blue reads clearly on both light and dark themes; the - layer sits behind the glyphs, so the text stays readable. Covers both drawn selection and native - ::selection (whichever CodeMirror uses). */ -#editor .cm-selectionLayer .cm-selectionBackground, -#editor .cm-selectionBackground, -#editor .cm-content ::selection { - background-color: rgba(51, 144, 236, 0.45) !important; -} - -/* Issues panel: a short scrollable list under the editor. */ -#issues { - flex: none; - max-height: 120px; - overflow-y: auto; - font-size: 12px; - line-height: 1.5; -} - -#issues.valid { - color: var(--colorStatusSuccessForeground1, #6ccb5f); -} - -#issues .issue { - color: var(--colorStatusDangerForeground1, #f87171); -} - -#issues .issue-path { - font-weight: 600; -} - -#issues .warning { - color: var(--colorStatusWarningForeground1, #e6b800); -} - -.actions { - flex: none; - display: flex; - align-items: center; - gap: 8px; -} - -#status { - flex: 1 1 auto; - font-size: 12px; - color: var(--colorNeutralForeground2, #adadad); -} - -/* Confirm modal (custom, in the spirit of the game window's .popup — reliable across Fluent v3 betas). */ -#confirm-veil { - position: fixed; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - background: rgba(0, 0, 0, 0.5); - z-index: 100; -} - -#confirm-panel { - min-width: 300px; - max-width: 80%; - display: flex; - flex-direction: column; - gap: 16px; - padding: 20px; - border-radius: 8px; - background: var(--colorNeutralBackground1, #1f1f1f); - border: 1px solid var(--colorNeutralStroke2, #444); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); -} - -#confirm-message { - font-size: 14px; -} - -#confirm-panel .button-row { - justify-content: flex-end; -} - -[hidden] { - display: none !important; -} diff --git a/src/renderer/configure.html b/src/renderer/configure.html deleted file mode 100644 index b05ba482..00000000 --- a/src/renderer/configure.html +++ /dev/null @@ -1,93 +0,0 @@ -<!doctype html> -<html lang="en"> - <head> - <meta charset="UTF-8" /> - <!-- CSP mirrors settings.html. The bundle (configure.js, incl. CodeMirror) is same-origin (file://) - → script-src 'self' is enough; CodeMirror 6 uses no eval/new Function, so no 'unsafe-eval'. - style-src 'unsafe-inline' is required for CodeMirror's dynamically-injected theme stylesheets - (and Fluent's inline styles) — the settings pattern already allows it, so no policy is loosened. --> - <meta - http-equiv="Content-Security-Policy" - content="default-src 'none'; img-src data:; media-src data:; style-src 'self' 'unsafe-inline'; font-src 'self'; script-src 'self';" - /> - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - <title>Playhook — Configure game - - - - -
- - Playhook - -
- -
- -
- - - - - -
- - - - - -
- - - - - - - - -
- - -
- Save & Apply - Reset - -
-
- - - - - - - diff --git a/src/renderer/configure.ts b/src/renderer/configure.ts deleted file mode 100644 index aaa289e2..00000000 --- a/src/renderer/configure.ts +++ /dev/null @@ -1,1030 +0,0 @@ -// Configure-game window renderer (Fluent UI Web Components v3 + CodeMirror 6). Edits/initializes a -// card's game.json: pick a card, edit raw JSON (with schema-aware completion, hover docs and inline -// linting), Save & Apply without restarting the app. main owns all fs/validation — this is stateless UI. -// -// Fluent import channel mirrors settings.ts: each `*/define.js` side-effect import registers just the -// element we use, and setTheme comes from the same `@fluentui/web-components` index. -import '@fluentui/web-components/text/define.js'; -import '@fluentui/web-components/button/define.js'; -import '@fluentui/web-components/dropdown/define.js'; -import '@fluentui/web-components/listbox/define.js'; -import '@fluentui/web-components/option/define.js'; -// Additional components used by the interactive form (patterns from settings.ts): field, switch, text-input. -import '@fluentui/web-components/field/define.js'; -import '@fluentui/web-components/switch/define.js'; -import '@fluentui/web-components/text-input/define.js'; -import '@fluentui/web-components/tablist/define.js'; -import '@fluentui/web-components/tab/define.js'; -import { setTheme } from '@fluentui/web-components'; -import { webDarkTheme, webLightTheme } from '@fluentui/tokens'; -import { EditorView, basicSetup } from 'codemirror'; -import { Compartment, type Extension } from '@codemirror/state'; -import { json } from '@codemirror/lang-json'; -import { jsonSchema } from 'codemirror-json-schema'; -import type { - ConfigPickKind, - DriveCandidate, - ManifestValidationIssue, - ThemeMode, -} from '../shared/types'; -import { createTranslator, type Locale, type Translator } from '../shared/i18n/index.js'; -import { localizeDocument } from './i18n-dom.js'; -import { FormView, FORM_SECTIONS, type SectionId } from './configure-form-view.js'; -import { - emptyFormModel, - textToFormModel, - textToGames, - gamesToText, - type ManifestFormModel, -} from './configure-form-model.js'; - -// The active edit tab: one of the form sections, or the raw JSON editor (advanced). The window is a -// hide-on-close singleton, so this module-level state survives hiding (plan R1/D7 — no AppSettings needed). -type EditTab = SectionId | 'json'; - -// Translator, refreshed on a language push. The HTML ships English fallback (no blank flash). -let translator: Translator = createTranslator('en'); - -// ── Theme (copied from settings.ts) ──────────────────────────────────────────── -const darkQuery = window.matchMedia('(prefers-color-scheme: dark)'); -let systemListener: (() => void) | null = null; -let currentDark = darkQuery.matches; - -function isDark(mode: ThemeMode): boolean { - return mode === 'dark' || (mode === 'system' && darkQuery.matches); -} - -function paint(dark: boolean): void { - currentDark = dark; - setTheme(dark ? webDarkTheme : webLightTheme); - document.documentElement.style.colorScheme = dark ? 'dark' : 'light'; - window.configureApi.setTitleBarDark(dark); - // Re-flip CodeMirror's internal dark selectors (the token-based colors update on their own via CSS vars). - if (view !== null) view.dispatch({ effects: themeCompartment.reconfigure(cmTheme(dark)) }); -} - -function applyTheme(mode: ThemeMode): void { - paint(isDark(mode)); - if (systemListener !== null) { - darkQuery.removeEventListener('change', systemListener); - systemListener = null; - } - if (mode === 'system') { - systemListener = () => paint(darkQuery.matches); - darkQuery.addEventListener('change', systemListener); - } -} - -// ── DOM helpers ───────────────────────────────────────────────────────────── -function req(id: string): T { - const el = document.getElementById(id); - if (el === null) throw new Error(`#${id} not found`); - return el as T; -} - -function setDisabled(el: HTMLElement, disabled: boolean): void { - if (disabled) el.setAttribute('disabled', ''); - else el.removeAttribute('disabled'); -} - -function readGroupValue(el: HTMLElement): string | null { - const raw = (el as HTMLElement & { value?: unknown }).value; - return typeof raw === 'string' && raw.length > 0 ? raw : null; -} - -// Sets the dropdown's selected option by value. Setting `value` re-runs the component's selectOption, -// which also refreshes the collapsed control text (needed after a relabel). A programmatic set does NOT -// emit a `change` event, so this never re-enters wireDropdown. -function setDropdownValue(group: HTMLElement, value: string): void { - (group as HTMLElement & { value?: string | null }).value = value; -} - -// A user selection emits a single `change` event carrying the new value — no click delegation needed -// (unlike the old radio-group, whose label clicks didn't update `value`). `apply` must be idempotent. -function wireDropdown(group: HTMLElement, apply: (value: string) => void): void { - group.addEventListener('change', () => { - const value = readGroupValue(group); - if (value !== null) apply(value); - }); -} - -const titlebarIcon = req('titlebar-icon'); -const titlebarSubtitle = req('titlebar-subtitle'); -const driveGroup = req('drive-group'); -const driveListbox = req('drive-listbox'); -const driveEmpty = req('drive-empty'); -const gameSection = req('game-section'); -const gameGroup = req('game-group'); -const gameListbox = req('game-listbox'); -const gameAddBtn = req('game-add'); -const gameRemoveBtn = req('game-remove'); -const editorEl = req('editor'); -const editorSection = req('editor-section'); -const formViewEl = req('form-view'); -const editTabsEl = req('edit-tabs'); -const issuesEl = req('issues'); -const saveBtn = req('save'); -const resetBtn = req('reset'); -const statusEl = req('status'); -const confirmVeil = req('confirm-veil'); -const confirmMessage = req('confirm-message'); -const confirmOk = req('confirm-ok'); -const confirmCancel = req('confirm-cancel'); - -// ── CodeMirror editor ───────────────────────────────────────────────────────── -const themeCompartment = new Compartment(); -const editableCompartment = new Compartment(); -let view: EditorView | null = null; - -// Editor colors sourced from Fluent tokens (global CSS vars published by setTheme) so the editor tracks -// the window theme automatically; the { dark } flag only toggles CodeMirror's own dark selectors. -function cmTheme(dark: boolean): Extension { - return EditorView.theme( - { - '&': { - backgroundColor: 'var(--colorNeutralBackground1)', - color: 'var(--colorNeutralForeground1)', - height: '100%', - }, - '.cm-content': { caretColor: 'var(--colorNeutralForeground1)' }, - '.cm-gutters': { - backgroundColor: 'var(--colorNeutralBackground2)', - color: 'var(--colorNeutralForeground3)', - border: 'none', - }, - '.cm-activeLine': { backgroundColor: 'var(--colorNeutralBackground1Hover)' }, - '.cm-activeLineGutter': { backgroundColor: 'var(--colorNeutralBackground2Hover)' }, - // NB: the selection-highlight color is set in configure.css (#editor + !important), NOT here — - // CodeMirror's baseTheme uses a very specific focused selector that a plain theme rule can't beat. - '&.cm-focused': { outline: 'none' }, - }, - { dark }, - ); -} - -// True while a PROGRAMMATIC editor write is in flight (a load, a mode-sync, a format). CodeMirror fires -// docChanged synchronously inside dispatch, so this guard stops those writes from being mistaken for a -// user edit and raising a false dirty (plan R4). User typing still flips dirty (the guard is false then). -let suppressDocChanged = false; - -function onDocChanged(): void { - if (suppressDocChanged) return; - dirty = true; - scheduleValidate(); -} - -function buildEditor(doc: string, schemaExt: Extension): void { - view = new EditorView({ - doc, - parent: editorEl, - extensions: [ - basicSetup, - schemaExt, - themeCompartment.of(cmTheme(currentDark)), - editableCompartment.of(EditorView.editable.of(true)), - EditorView.updateListener.of((update) => { - if (update.docChanged) onDocChanged(); - }), - ], - }); -} - -function getEditorText(): string { - return view?.state.doc.toString() ?? ''; -} - -// Replaces the whole editor document WITHOUT marking dirty (programmatic write — the caller owns dirty). -function dispatchText(text: string): void { - if (view === null) return; - suppressDocChanged = true; - view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } }); - suppressDocChanged = false; -} - -function setEditable(editable: boolean): void { - if (view === null) return; - view.dispatch({ effects: editableCompartment.reconfigure(EditorView.editable.of(editable)) }); -} - -// ── App state ───────────────────────────────────────────────────────────────── -let drives: readonly DriveCandidate[] = []; -let selectedRoot: string | null = null; -let dirty = false; -let loadedId: string | null = null; // id of the last loaded/saved manifest (for the id-change warning) -// Descriptor (root|label|hasManifest) of the drive whose config is currently loaded. The label carries the -// game.json title, so a change means the media at this root was SWAPPED (a card change in the same reader -// keeps the drive letter) — the trigger to reload. null before the first load. -let loadedDriveKey: string | null = null; -let lastValidOk = false; -let blocked = false; // the selected card vanished → editing/saving disabled -let activeTab: EditTab = 'basics'; // a form section is the default (plan R1); 'json' is advanced - -// ── Multi-game state (a card can carry several games) ─────────────────────────── -// One game.json can hold a single game object (legacy) OR an array of games. The form edits ONE game at a -// time; `games` mirrors the whole file (each slot's model + preserved unknown/corrupt keys + its loaded -// id for the id-change warning), and `activeGameIndex` is the one shown in the form. The ACTIVE game's -// live edits live in `formView`; commitActiveGame() flushes them back into its slot before any whole-file -// operation (serialize, switch game, add/remove). -interface GameSlot { - model: ManifestFormModel; - rest: Readonly>; - corrupt: Readonly>; - mixed: boolean; - loadedId: string | null; -} -let games: GameSlot[] = []; -let activeGameIndex = 0; - -// Created in init() (after all handlers are defined). Owns the interactive form's DOM + state. -let formView: FormView; -// The native fluent-tablist + its fluent-tab children ([Basics]…[Advanced][JSON]), built in init(). -type TablistEl = HTMLElement & { activeid?: string }; -let tablist: TablistEl; -const tabButtons = new Map(); -// Guards the tablist `change` event against our own programmatic activeid writes (so committing/reverting -// a tab doesn't re-enter the switch logic). -let applyingTab = false; -// Tab order: the form sections, JSON last. -const TAB_ORDER: readonly EditTab[] = [...FORM_SECTIONS.map((s) => s.id), 'json']; - -function tabDomId(tab: EditTab): string { - return `tab-${tab}`; -} -function tabFromDomId(id: string | undefined): EditTab | null { - if (id === undefined) return null; - return TAB_ORDER.find((tab) => tabDomId(tab) === id) ?? null; -} - -/** True when the raw JSON editor tab is active (vs. a form section tab). */ -function jsonActive(): boolean { - return activeTab === 'json'; -} - -// The manifest text of the currently active editor (form → the whole games array/object serialized, json -// → raw editor text). In form mode the active game's live edits are flushed into its slot first. -function activeText(): string { - if (jsonActive()) return getEditorText(); - commitActiveGame(); - if (games.length === 0) return formView.serialize(); // blank drive: a single empty object - return gamesToText(games); -} - -// ── Validation + issues panel ────────────────────────────────────────────────── -let validateTimer: number | null = null; -function scheduleValidate(): void { - if (validateTimer !== null) window.clearTimeout(validateTimer); - validateTimer = window.setTimeout(() => void runValidate(), 400); -} - -function parseId(text: string): string | null { - try { - const parsed: unknown = JSON.parse(text); - if (typeof parsed === 'object' && parsed !== null && 'id' in parsed) { - const id = parsed.id; - return typeof id === 'string' ? id : null; - } - } catch { - // not parseable → no id - } - return null; -} - -async function runValidate(): Promise { - const text = activeText(); - const result = await window.configureApi.validateConfig(text); - lastValidOk = result.ok; - if (result.ok) { - formView.setFieldErrors(null); - renderIssues(null, text); - } else if (!jsonActive()) { - // Split issues by game. For a single object (games.length ≤ 1) the paths are bare, mapped straight to - // fields as before. For an array, only the ACTIVE game's issues get their `games..` prefix stripped - // and mapped inline; other games' issues (and any root issue) go to the panel, labelled by game. - const forActive: ManifestValidationIssue[] = []; - const forPanel: ManifestValidationIssue[] = []; - const activePrefix = `games.${activeGameIndex}.`; - for (const issue of result.issues) { - if (games.length <= 1) { - forActive.push(issue); - } else if (issue.path.startsWith(activePrefix)) { - forActive.push({ path: issue.path.slice(activePrefix.length), message: issue.message }); - } else if (issue.path.startsWith('games.')) { - forPanel.push(labelOtherGameIssue(issue)); - } else { - forPanel.push(issue); // a root-level issue (empty array, duplicate id at root, syntax) - } - } - const unmapped = formView.setFieldErrors(forActive); - renderIssues([...unmapped, ...forPanel], text); - } else { - formView.setFieldErrors(null); - renderIssues(result.issues, text); - } - updateSaveEnabled(); -} - -/** Relabels an issue on a NON-active game for the #issues panel: "Game 3 (Celeste): heroImage is …". */ -function labelOtherGameIssue(issue: ManifestValidationIssue): ManifestValidationIssue { - const match = /^games\.(\d+)\.(.*)$/.exec(issue.path); - if (match === null) return issue; - const index = Number(match[1]); - const slot = games[index]; - const title = slot !== undefined ? gameLabel(slot) : String(index + 1); - return { - path: issue.path, - message: translator('configure.otherGameIssue', { index: index + 1, title, message: issue.message }), - }; -} - -function renderIssues(issues: readonly ManifestValidationIssue[] | null, text: string): void { - issuesEl.replaceChildren(); - if (issues === null) { - issuesEl.classList.add('valid'); - const ok = document.createElement('div'); - ok.textContent = translator('configure.configValid'); - issuesEl.append(ok); - // Changing `id` moves the game's PC stats to a fresh key → a "new" game with zero playtime. In form - // mode the warning is about the ACTIVE game (its live id vs the id it loaded with); in JSON mode we - // parse the id from the (single-object) text, as before. - let id: string | null; - if (jsonActive()) { - id = parseId(text); - } else { - const slot = games[activeGameIndex]; - id = slot !== undefined ? (slot.model.id !== '' ? slot.model.id : null) : parseId(text); - } - if (loadedId !== null && id !== null && id !== loadedId) { - const warn = document.createElement('div'); - warn.className = 'warning'; - warn.textContent = translator('configure.idChangedWarning', { from: loadedId, to: id }); - issuesEl.append(warn); - } - return; - } - issuesEl.classList.remove('valid'); - for (const issue of issues) { - const row = document.createElement('div'); - row.className = 'issue'; - const path = document.createElement('span'); - path.className = 'issue-path'; - path.textContent = issue.path; - row.append(path, document.createTextNode(`: ${issue.message}`)); - issuesEl.append(row); - } -} - -function updateSaveEnabled(): void { - setDisabled(saveBtn, blocked || selectedRoot === null || !lastValidOk); - // Reset re-reads the card and is useful even for an invalid config (to discard edits), so it is NOT - // gated by validity — only by having a card that isn't gone. - setDisabled(resetBtn, blocked || selectedRoot === null); -} - -// ── Status line ───────────────────────────────────────────────────────────── -function setStatus(text: string): void { - statusEl.textContent = text; -} - -// ── Confirm modal ───────────────────────────────────────────────────────────── -let confirmResolve: ((ok: boolean) => void) | null = null; -// Every confirmation asks a yes/no question; the Yes/No button labels are fixed (localized from -// common.yes / common.no via localizeDocument), so callers pass only the message — no per-call verb. -function confirmDialog(message: string): Promise { - confirmMessage.textContent = message; - confirmVeil.hidden = false; - confirmOk.focus(); - return new Promise((resolve) => { - confirmResolve = resolve; - }); -} -function closeConfirm(ok: boolean): void { - confirmVeil.hidden = true; - const resolve = confirmResolve; - confirmResolve = null; - resolve?.(ok); -} -confirmOk.addEventListener('click', () => closeConfirm(true)); -confirmCancel.addEventListener('click', () => closeConfirm(false)); -confirmVeil.addEventListener('click', (event) => { - if (event.target === confirmVeil) closeConfirm(false); // click on the veil = Cancel -}); - -// ── Drive picker ───────────────────────────────────────────────────────────── -// The list is pushed every 2s while the window is visible. Rebuilding the option DOM on every push would -// drop the current selection (fresh elements start unselected), so we ONLY rebuild when the list actually -// changed (signature compare) and reconcile the selection on every push without touching the DOM. -let lastDrivesSig = ''; - -function drivesSignature(list: readonly DriveCandidate[]): string { - return list - .map((d) => `${d.root}|${d.label}|${d.hasManifest ? 1 : 0}|${d.isActive ? 1 : 0}`) - .join('¦'); -} - -// Per-drive descriptor used to detect a media swap at the SAME root: root + the card's CONTENT signature -// (its game ids) + hasManifest. The signature is used rather than the display label because the label can -// be a bare count ("3 games") that two different cards share — and the drive letter never changes, so the -// label alone would miss the swap. isActive is intentionally excluded — it can flap without the card's -// content changing, and shouldn't force a config reload. -function driveKey(candidate: DriveCandidate): string { - return `${candidate.root}|${candidate.signature}|${candidate.hasManifest ? 1 : 0}`; -} - -function renderDrives(list: readonly DriveCandidate[]): void { - drives = list; - - if (list.length === 0) { - lastDrivesSig = ''; - driveListbox.replaceChildren(); - driveEmpty.hidden = false; - setDisabled(driveGroup, true); - // The selected card is gone (or none was ever present) → block editing, keep the text. - onSelectedGone(); - return; - } - driveEmpty.hidden = true; - // Re-enable the picker in case a previous empty tick disabled it (a disabled dropdown can't be reopened). - setDisabled(driveGroup, false); - - const sig = drivesSignature(list); - const rebuilt = sig !== lastDrivesSig; - if (rebuilt) { - lastDrivesSig = sig; - rebuildOptions(list); - } - reconcileSelection(list); - if (rebuilt) { - // The dropdown's listbox processes freshly-appended options on its own async init, which can drop a - // value set during this synchronous render. Re-assert on the next frame (after it settles) so the - // selection shows immediately. Only after a rebuild (first open / genuine change), not on every poll. - requestAnimationFrame(() => { - if (selectedRoot !== null) setDropdownValue(driveGroup, selectedRoot); - }); - } -} - -function rebuildOptions(list: readonly DriveCandidate[]): void { - const options = list.map((candidate) => { - const option = document.createElement('fluent-option'); - (option as HTMLElement & { value?: string }).value = candidate.root; - option.textContent = candidate.label; - return option; - }); - driveListbox.replaceChildren(...options); -} - -function reconcileSelection(list: readonly DriveCandidate[]): void { - const current = selectedRoot !== null ? list.find((d) => d.root === selectedRoot) : undefined; - if (current !== undefined && selectedRoot !== null) { - setDropdownValue(driveGroup, selectedRoot); - // The drive letter is unchanged, but the MEDIA behind it may have been swapped (a card change in the - // same reader keeps the root). driveKey carries the game.json title, so a changed descriptor means a - // different card → reload its config instead of leaving the previous card's stale content on screen. - // An UNCHANGED descriptor that merely vanished and came back just unblocks, preserving edits (a flaky - // reader / the same card re-seated). - if (driveKey(current) !== loadedDriveKey) { - if (blocked) unblock(); - void loadDrive(selectedRoot); - } else if (blocked) { - unblock(); - } - return; - } - if (selectedRoot !== null) { - // The selected card disappeared → block, but do NOT wipe the editor. - onSelectedGone(); - } - // No valid selection yet → prefer the active card, else the first candidate, and LOAD it. - const active = list.find((d) => d.isActive); - const pick = active ?? list[0]; - if (pick !== undefined) { - setDropdownValue(driveGroup, pick.root); - void selectDrive(pick.root, false); - } -} - -function onSelectedGone(): void { - blocked = true; - setEditable(false); - setModeTabsDisabled(true); - applyFormDisabled(); - applyGameControlsDisabled(); - updateSaveEnabled(); - setStatus(translator('configure.cardGone')); -} - -function unblock(): void { - blocked = false; - setEditable(true); - setModeTabsDisabled(false); - applyFormDisabled(); - applyGameControlsDisabled(); - updateSaveEnabled(); - setStatus(''); -} - -function setModeTabsDisabled(disabled: boolean): void { - for (const btn of tabButtons.values()) setDisabled(btn, disabled); -} - -// The form fields are disabled only when the card is gone (blocked). A blank drive gets an empty, EDITABLE -// game slot to fill in; Save stays blocked by validation until the required fields are there. -function applyFormDisabled(): void { - formView.setDisabled(blocked); -} - -// Switches the active card. `confirmDirty` guards against losing unsaved edits (skipped for the initial -// auto-selection). Loads the card's game.json (or clears the editor for a blank drive). `switching` guards -// against re-entry while a confirm dialog is awaited (a fresh selection racing an in-flight one). -let switching = false; -async function selectDrive(root: string, confirmDirty: boolean): Promise { - if (root === selectedRoot && !blocked) return; - if (switching) return; - switching = true; - try { - if (confirmDirty && dirty) { - const ok = await confirmDialog(translator('configure.confirmSwitch')); - if (!ok) { - if (selectedRoot !== null) setDropdownValue(driveGroup, selectedRoot); // revert the selection - return; - } - } - selectedRoot = root; - if (blocked) unblock(); - await loadDrive(root); - } finally { - switching = false; - } -} - -async function loadDrive(root: string): Promise { - const candidate = drives.find((d) => d.root === root); - if (candidate === undefined) return; - // Record the descriptor we're loading up front (synchronously), so the next poll tick sees an unchanged - // key and doesn't re-trigger this load while readConfig is still in flight. - loadedDriveKey = driveKey(candidate); - if (!candidate.hasManifest) { - // Blank drive: start with ONE empty, editable game the user fills in (there are no templates any more — - // the form itself is the authoring surface). Save stays blocked by validation until the required fields - // are present. loadedId null → no id-change warning. - loadedId = null; - dirty = false; - games = [{ model: emptyFormModel(), rest: {}, corrupt: {}, mixed: false, loadedId: null }]; - activeGameIndex = 0; - dispatchText(gamesToText(games)); - rebuildGameSelector(); - loadActiveIntoForm(); - applyFormDisabled(); - showTab('basics'); // straight into the form — the first fields to fill are there - setStatus(translator('configure.blankDrive')); - void runValidate(); - return; - } - const result = await window.configureApi.readConfig(root); - if (!result.ok) { - // Not blank but unreadable: ConfigReadResult can't tell "file missing" from other failures (plan D2), - // so we do NOT substitute an empty game here — just report it and leave the form empty. - loadedId = null; - games = []; - activeGameIndex = 0; - dispatchText(''); - formView.load(emptyFormModel(), {}, {}, false); - rebuildGameSelector(); - applyFormDisabled(); - setStatus(translator('configure.couldNotRead', { message: result.message })); - void runValidate(); - return; - } - loadedId = parseId(result.text); - dirty = false; - loadText(result.text); - setStatus(''); -} - -// Loads manifest text into BOTH editors (raw JSON into CodeMirror, parsed games into the form) so either -// tab is correct on switch. A single object OR an array of games is accepted; if the text doesn't parse -// (or any game element isn't an object) the form can't represent it → the JSON tab is auto-activated with -// the error surfaced by validation (plan R4 / cases). -function loadText(text: string): void { - dispatchText(text); - if (loadGamesFromText(text)) { - showTab(activeTab); - } else { - // Unrepresentable (syntax error / non-object element) → the form can't show it; go to the JSON tab. - games = []; - activeGameIndex = 0; - formView.load(emptyFormModel(), {}, {}, false); - rebuildGameSelector(); - applyFormDisabled(); - showTab('json'); - } - void runValidate(); -} - -// ── Multi-game helpers ────────────────────────────────────────────────────────── - -/** Parses whole-file text into the `games` slots and loads the first into the form. Returns false when the - * text can't be represented as a form (syntax error, top-level not object/array, or a non-object element). - * `keepIndex` preserves the active game (a JSON→form switch); otherwise it resets to the first game. */ -function loadGamesFromText(text: string, keepIndex = false): boolean { - const parsed = textToGames(text); - if (!parsed.ok || !parsed.games.every((g) => g.ok)) return false; - const slots: GameSlot[] = []; - for (const g of parsed.games) { - if (!g.ok) return false; // narrowed away by the every() above; keeps TS happy - const loadedGameId = g.model.id !== '' ? g.model.id : null; - slots.push({ model: g.model, rest: g.rest, corrupt: g.corrupt, mixed: g.mixed, loadedId: loadedGameId }); - } - games = slots; - activeGameIndex = keepIndex ? Math.min(activeGameIndex, games.length - 1) : 0; - rebuildGameSelector(); - loadActiveIntoForm(); - applyFormDisabled(); - return true; -} - -/** Flushes the active game's live form edits back into its slot (serialize → re-parse). The form always - * serializes to valid single-game JSON, so the parse succeeds; on the off chance it doesn't, the slot is - * left as-is. */ -function commitActiveGame(): void { - const slot = games[activeGameIndex]; - if (slot === undefined) return; - const parsed = textToFormModel(formView.serialize()); - if (parsed.ok) { - slot.model = parsed.model; - slot.rest = parsed.rest; - slot.corrupt = parsed.corrupt; - } -} - -/** Loads the active game slot into the form. */ -function loadActiveIntoForm(): void { - const slot = games[activeGameIndex]; - if (slot === undefined) { - formView.load(emptyFormModel(), {}, {}, false); - return; - } - loadedId = slot.loadedId; - formView.load(slot.model, slot.rest, slot.corrupt, slot.mixed); -} - -/** A short human label for a game slot (its title, or a placeholder when untitled). */ -function gameLabel(slot: GameSlot): string { - const title = slot.model.title.trim(); - return title !== '' ? title : translator('configure.untitledGame'); -} - -/** Rebuilds the game dropdown ("index / count · title") and toggles the row/controls. */ -function rebuildGameSelector(): void { - // The Game row belongs to the section tabs (it picks which game they edit): shown only when the form can - // represent the file (≥1 slot) AND a form section is active — hidden on the raw JSON tab. - const show = games.length >= 1 && !jsonActive(); - gameSection.hidden = !show; - if (!show) return; - const options = games.map((slot, i) => { - const option = document.createElement('fluent-option'); - (option as HTMLElement & { value?: string }).value = String(i); - option.textContent = translator('configure.gameOption', { - index: i + 1, - count: games.length, - title: gameLabel(slot), - }); - return option; - }); - gameListbox.replaceChildren(...options); - setDropdownValue(gameGroup, String(activeGameIndex)); - requestAnimationFrame(() => setDropdownValue(gameGroup, String(activeGameIndex))); - applyGameControlsDisabled(); -} - -/** Enables/disables the Game controls: disabled when blocked or on the JSON tab; Remove also needs - * ≥2 games (a card must keep at least one). */ -function applyGameControlsDisabled(): void { - const disabled = blocked || jsonActive(); - setDisabled(gameGroup, disabled); - setDisabled(gameAddBtn, disabled); - setDisabled(gameRemoveBtn, disabled || games.length <= 1); -} - -/** Switches the active game (dropdown): flush the current one, load the picked one, keep the section tab. */ -function switchGame(index: number): void { - if (index === activeGameIndex || index < 0 || index >= games.length) return; - commitActiveGame(); - activeGameIndex = index; - loadActiveIntoForm(); - applyFormDisabled(); - setDropdownValue(gameGroup, String(index)); - void runValidate(); -} - -/** A default id not yet used by any game (my-game, my-game-2, …), so the new game doesn't collide. */ -function uniqueDefaultId(): string { - const used = new Set(games.map((g) => g.model.id)); - if (!used.has('my-game')) return 'my-game'; - for (let n = 2; ; n += 1) { - const candidate = `my-game-${n}`; - if (!used.has(candidate)) return candidate; - } -} - -/** Adds a new, empty game (with a unique default id so the duplicate-id check doesn't fire), switches to - * it and opens Basics. The user fills it in from the form. */ -function onAddGame(): void { - if (blocked || jsonActive()) return; - commitActiveGame(); - const model: ManifestFormModel = { ...emptyFormModel(), id: uniqueDefaultId() }; - games.push({ model, rest: {}, corrupt: {}, mixed: false, loadedId: null }); - activeGameIndex = games.length - 1; - rebuildGameSelector(); - loadActiveIntoForm(); - applyFormDisabled(); - dirty = true; - showTab('basics'); - void runValidate(); -} - -/** Removes the current game (confirm) and switches to a neighbour. Refused for the last game (a card can't - * be empty). */ -async function onRemoveGame(): Promise { - if (blocked || jsonActive() || games.length <= 1) return; - const ok = await confirmDialog(translator('configure.confirmRemoveGame')); - if (!ok) return; - games.splice(activeGameIndex, 1); - activeGameIndex = Math.min(activeGameIndex, games.length - 1); - rebuildGameSelector(); - loadActiveIntoForm(); - applyFormDisabled(); - dirty = true; - void runValidate(); -} - -// ── Format ───────────────────────────────────────────────────────────────────── -// Pretty-prints the editor JSON (2-space indent) — the in-app "prettier" for fixing indentation. Only -// works on syntactically valid JSON; otherwise it asks the user to fix the errors first. -function onFormat(): void { - // Format only makes sense for the raw JSON editor — in form mode it is a no-op (plan R8). - if (!jsonActive()) return; - if (blocked || view === null) return; - const text = getEditorText(); - let parsed: unknown; - try { - parsed = JSON.parse(text); - } catch { - setStatus(translator('configure.fixSyntax')); - return; - } - const formatted = JSON.stringify(parsed, null, 2); - if (formatted === text) return; // already tidy — nothing to do (and don't flag it dirty) - dispatchText(formatted); - dirty = true; - void runValidate(); - setStatus(''); -} - -// ── Save & Apply / Reset ──────────────────────────────────────────────────────── -async function onSave(): Promise { - if (selectedRoot === null || blocked || !lastValidOk) return; - const root = selectedRoot; - const text = activeText(); - setDisabled(saveBtn, true); - setStatus(translator('configure.saving')); - const result = await window.configureApi.saveConfig(root, text); - if (!result.saved) { - setStatus(translator('configure.notSaved', { message: result.message })); - updateSaveEnabled(); - return; - } - dirty = false; - // The card now holds what we saved → each game's loaded id becomes its current id (so the id-change - // warning re-arms from the saved baseline). In JSON mode we don't have per-game slots — fall back to - // parsing the single-object id, as before. - if (jsonActive() || games.length === 0) { - loadedId = parseId(text); - } else { - for (const slot of games) slot.loadedId = slot.model.id !== '' ? slot.model.id : null; - loadedId = games[activeGameIndex]?.loadedId ?? null; - } - switch (result.applied) { - case 'applied': - setStatus(translator('configure.applied')); - break; - case 'deferred': - setStatus(translator('configure.deferred')); - break; - case 'failed': - setStatus( - translator('configure.savedRejected', { - message: result.message ?? translator('configure.unknownReason'), - }), - ); - break; - } - updateSaveEnabled(); -} - -// Reverts unsaved edits by re-reading game.json from the card (labelled "Reset"). -async function onReset(): Promise { - if (selectedRoot === null || blocked) return; - if (dirty) { - const ok = await confirmDialog(translator('configure.confirmReset')); - if (!ok) return; - } - await loadDrive(selectedRoot); -} - -saveBtn.addEventListener('click', () => void onSave()); - -// Format / Reset are driven from the editor's native right-click menu (configure-window.ts) via IPC. -window.configureApi.onEditorCommand((command) => { - if (command === 'format') onFormat(); -}); - -// The dropdown updates its own display on user selection; selectDrive reverts it if a dirty-switch -// confirm is declined. -wireDropdown(driveGroup, (root) => { - void selectDrive(root, true); -}); - -// Game picker (multi-game cards): switch the edited game, or add/remove one. -wireDropdown(gameGroup, (value) => { - const index = Number(value); - if (Number.isInteger(index)) switchGame(index); -}); -gameAddBtn.addEventListener('click', () => onAddGame()); -gameRemoveBtn.addEventListener('click', () => void onRemoveGame()); - -// ── Edit tabs ([Basics][Launch][Hero][Saves][Audio][Advanced][JSON]) ── -// A native fluent-tablist: it renders the tab strip (accent indicator, keyboard nav, ARIA) and fires -// `change` with the new activeid. We manage the panels ourselves in showTab. Labels come from the -// translator (re-applied on a language change via applyLocale → relabelTabs). -function buildEditTabs(): void { - applyingTab = true; // ignore the tablist's own auto-select `change` during construction - tablist = document.createElement('fluent-tablist'); - for (const tab of TAB_ORDER) { - const el = document.createElement('fluent-tab'); - el.setAttribute('slot', 'tab'); - el.id = tabDomId(tab); - tabButtons.set(tab, el); - tablist.append(el); - } - tablist.addEventListener('change', () => { - if (applyingTab) return; // our own programmatic activeid write - const target = tabFromDomId(tablist.activeid); - if (target !== null && target !== activeTab) switchTab(target); - }); - editTabsEl.append(tablist); - relabelTabs(); - applyingTab = false; -} - -function relabelTabs(): void { - for (const section of FORM_SECTIONS) { - tabButtons.get(section.id)?.replaceChildren(translator(section.labelKey)); - } - tabButtons.get('json')?.replaceChildren(translator('configure.tabJson')); -} - -// Points the tablist strip at `tab` without triggering the change→switch logic (a commit or a revert). -function setStripActive(tab: EditTab): void { - applyingTab = true; - tablist.activeid = tabDomId(tab); - applyingTab = false; -} - -// Reflects `activeTab` onto the DOM: the right panel (a form section / the JSON editor) plus the tab -// strip. Does NOT convert content — that is switchTab's job on a form↔json crossing. -function showTab(target: EditTab): void { - activeTab = target; - const isSection = target !== 'json'; - formViewEl.hidden = !isSection; - editorSection.hidden = target !== 'json'; - if (isSection) formView.showSection(target); - setStripActive(target); - // The Game row follows the section tabs (shown only on a form section). - rebuildGameSelector(); - // Gate the native "Format" context-menu item: it only applies to the JSON editor. - window.configureApi.setJsonEditorActive(target === 'json'); -} - -// Switches tabs, converting content across the form↔json boundary. Form → JSON always works (the model -// serializes). JSON → a form tab only when the text parses as JSON (else a status hint and the strip -// reverts — schema errors are fine, the form exists to fix them, plan R4). Everything else is free (same -// model). Switching never marks dirty (programmatic editor writes are guarded). -function switchTab(target: EditTab): void { - if (blocked) { - setStripActive(activeTab); - return; - } - if (target === 'json') { - // Form → JSON: write the WHOLE file (all games) into the editor. - dispatchText(activeText()); - formView.setFieldErrors(null); - showTab('json'); - setStatus(''); - void runValidate(); - return; - } - if (jsonActive()) { - // JSON → form: the editor may hold an object or an array; the form can only show it if every element - // is an object (else stay on JSON with a hint, as before). Resets to the first game. - if (!loadGamesFromText(getEditorText())) { - setStatus(translator('configure.fixSyntaxSwitch')); - setStripActive(activeTab); // undo the user's tab click - return; - } - applyFormDisabled(); - setStatus(''); - showTab(target); - void runValidate(); - return; - } - // Section ↔ section: same model, just swap the visible panel. - showTab(target); -} - -resetBtn.addEventListener('click', () => void onReset()); - -// ── Init ───────────────────────────────────────────────────────────────────── -applyTheme('system'); // best-guess before settings load, to avoid a flash - -// The theme is chosen in the settings window and persisted, and pushed live to this window via -// onThemeUpdate (see init). This visibilitychange re-fetch stays as a cheap fallback: the window is a -// hidden/shown singleton, so re-reading the persisted theme on show covers any push missed while hidden. -async function refreshTheme(): Promise { - const settings = await window.configureApi.getSettings(); - applyTheme(settings.theme); -} -document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'visible') void refreshTheme(); -}); - -// The app version, cached so a language change can re-render the "(version) — Configure game" suffix. -let appVersion = ''; -function renderTitlebarSubtitle(): void { - titlebarSubtitle.textContent = translator('configure.titlebarVersion', { version: appVersion }); -} - -// A language push (or the initial seed): rebuild the translator, re-localize the static DOM, re-title the -// window (so the HTML doesn't override the taskbar caption) and refresh the subtitle. The -// ephemeral status line and issues panel are NOT re-rendered retroactively — they update on the next event -// (drive labels re-push every 2s; a re-validate happens on the next edit). -function applyLocale(locale: Locale): void { - translator = createTranslator(locale); - document.documentElement.lang = locale; - // "Playhook" is the product name — not translated. - document.title = `Playhook — ${translator('window.configureGame')}`; - localizeDocument(translator); - renderTitlebarSubtitle(); - formView.relabel(); - relabelTabs(); - rebuildGameSelector(); // the game options carry translated "index / count · title" labels -} - -async function init(): Promise<void> { - window.configureApi.onDrivesUpdate(renderDrives); - window.configureApi.onLanguageUpdate(applyLocale); - // Live theme push from main (the theme changed in the settings window): applyTheme is idempotent, so - // this coexists with the visibilitychange re-fetch below (which stays as a cheap fallback). - window.configureApi.onThemeUpdate(applyTheme); - const [settings, schema, drivesList, icon, version, locale] = await Promise.all([ - window.configureApi.getSettings(), - window.configureApi.getSchema(), - window.configureApi.getDrives(), - window.configureApi.getAppIcon(), - window.configureApi.getAppVersion(), - window.configureApi.getLanguage(), - ]); - applyTheme(settings.theme); - if (icon !== '') titlebarIcon.src = icon; - else titlebarIcon.hidden = true; - appVersion = version; - // Schema-aware editor when the JSON Schema is available; plain JSON otherwise (graceful degradation — - // syntax highlighting + parse-linting still work, just without field completion/hover). - let schemaExt: Extension; - try { - schemaExt = jsonSchema(schema as Parameters<typeof jsonSchema>[0]); - } catch { - schemaExt = json(); - } - buildEditor('', schemaExt); - // Build the interactive form and the tab bar, then show the first section before a drive loads into it. - formView = new FormView({ - root: formViewEl, - translator: () => translator, - onChange: () => { - dirty = true; - scheduleValidate(); - }, - pickPath: (kind: ConfigPickKind) => window.configureApi.pickPath(selectedRoot ?? '', kind), - imagePreview: (relative: string) => window.configureApi.getImagePreview(selectedRoot ?? '', relative), - openExternal: (url: string) => window.configureApi.openExternal(url), - onPickError: (message) => setStatus(message), - }); - buildEditTabs(); - showTab('basics'); - renderDrives(drivesList); - // Seed the locale last so it localizes the freshly-populated DOM and title-bar suffix in one pass. - applyLocale(locale); -} - -void init(); diff --git a/src/renderer/controls.ts b/src/renderer/controls.ts index b9a5264f..a7afa3de 100644 --- a/src/renderer/controls.ts +++ b/src/renderer/controls.ts @@ -10,19 +10,53 @@ // 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 { AppState, BrowseInfo, GameInfo } from '../shared/types'; -import type { Translator } from '../shared/i18n/index.js'; -import { NAV_REPEAT_MS, createGamepadController } from './gamepad.js'; +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'; +import { createGamepadController } from './gamepad.js'; +import { createWakeMeter } from './mouse-sleep.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'; // The current popup view (mutually exclusive; 'none' = closed). Mirrors the data-view on #popup. -type PopupView = 'none' | 'details' | 'power' | 'confirm' | 'error'; +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' | 'shutdown' | 'reboot' | 'sleep'; +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'; // 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 { @@ -38,8 +72,85 @@ export interface ControlsDeps { 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 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; } /** @@ -49,32 +160,70 @@ export interface ControlsDeps { export interface CarouselNav { /** 'carousel' (the strip) or 'detail' (the bar screen). */ screen(): 'carousel' | 'detail'; - /** Moves the selection by `delta` cards. */ - move(delta: number): void; + /** 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; - /** Steps back from a detail screen to the strip; false when there is no carousel to return to. */ + /** + * 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 a carousel exists at all (>1 game) — gates the Details menu's "Library" item. */ - exists(): boolean; + /** Whether the inbox holds anything unread — the Notifications CARD wears the dot now. */ + setUnread(unread: boolean): void; } export interface Controls { /** Refreshes the game-dependent menu item (Install/Uninstall text + visibility) from the current state. */ applyGameButtons(): void; + /** The Settings screen closed itself — restore the bar highlight on the More button it came from. */ + settingsClosed(): void; + /** The Settings screen asked to reset — opens the shared confirm popup (No returns to Settings). */ + confirmResetSettings(): void; + /** The Customize screen asked one of its questions — opens the same shared confirm popup. */ + confirmGameSettings( + kind: + | 'reset' + | 'delete' + | 'delete-history' + | 'discard' + | 'switch-source' + | 'cancel-move' + | 'replace-title', + options?: { readonly title?: string }, + ): void; + /** + * Work in progress, in the same column: a message and a Stop. `closeBusy` takes it away when the work + * answers — a progress popup nobody dismissed must not outlive the thing it describes. + */ + showBusy(message: string, onStop: () => void): void; + closeBusy(): void; + /** + * Opens the surface one of the carousel's launcher cards stands for. The card plays the press sound + * itself (app.ts), so nothing here does — the surface's own popup-open follows it. + */ + openSystemCard(id: SystemCardId): void; + /** "Add game", from the Library's column — the launcher's only route to creating a game. */ + openAddGame(): void; /** Clears the game-dependent menu item for the idle/no-game screen. */ clearGameButtons(): 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). */ showError(message: string): void; - /** Seeds whether this is a Game Mode (gamescope) session — flips the power menu's primary item from - * "Minimize Playhook" (hide to tray) to "Close Playhook" (full quit). Called once at startup. */ + /** Seeds whether this is a Game Mode (gamescope) session — drops "Minimize Playhook" from the power + * menu, since there is no tray to minimize into there. Called once at startup. */ setGameMode(gameMode: boolean): void; /** Starts the gamepad polling loop. */ start(): void; /** Pause/resume acting on gamepad input (paused while the launcher is backgrounded — a game on top). */ setGamepadPaused(paused: boolean): void; + /** A fresh inbox arrived: repaint the More item's dot and, if the list is up, the list. */ + applyNotifications(): void; + /** Whether the popup is up. The toast shares its corner and waits rather than covering it. */ + isPopupOpen(): boolean; } /** @@ -91,6 +240,17 @@ function isOverSelectableText(target: EventTarget | null): boolean { export function createControls(deps: ControlsDeps): Controls { const { audio } = deps; + + // The glide step the strip animates one held move over (styles.css reads it as --flip-step). Slightly + // LONGER than the repeat itself, on purpose: the keyboard's repeats arrive on the OS clock and are only + // throttled to NAV_REPEAT_MS here, so their real spacing wanders above it. A step that outlasts the gap + // overlaps the next one and the row never stalls between them; an exact match would leave tiny holes. + const FLIP_STEP_MS = Math.round(NAV_REPEAT_MS * 1.3); + document.documentElement.style.setProperty('--flip-step', `${FLIP_STEP_MS}ms`); + + // 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(); const state = (): AppState => deps.getState(); const t = (): Translator => deps.getTranslator(); @@ -118,6 +278,27 @@ export function createControls(deps: ControlsDeps): Controls { // Seeded once at startup (setGameMode); false until then — the power menu isn't reachable that early. let gameMode = false; + /** + * 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). + * + * 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 + * stack rather than to this list. + */ + const overlays = { + active: (): NavSurface | null => { + if (deps.settings.isOpen()) return deps.settings; + if (deps.gameSettings.isOpen()) return deps.gameSettings; + if (deps.library.isOpen()) return deps.library; + return null; + }, + isAnyOpen: (): boolean => + deps.settings.isOpen() || deps.gameSettings.isOpen() || deps.library.isOpen(), + }; + // Bar buttons. const playButton = req<HTMLButtonElement>('play-button'); const moreButton = req<HTMLButtonElement>('more-button'); @@ -126,46 +307,118 @@ export function createControls(deps: ControlsDeps): Controls { const popup = req('popup'); const popupVeil = reqQuery<HTMLElement>('#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 menuShutdown = req<HTMLButtonElement>('menu-shutdown'); const menuInstallToggle = req<HTMLButtonElement>('menu-install-toggle'); const menuKill = req<HTMLButtonElement>('menu-kill'); - const menuLibrary = req<HTMLButtonElement>('menu-library'); + const menuHome = req<HTMLButtonElement>('menu-home'); + const menuCustomize = req<HTMLButtonElement>('menu-customize'); + const menuForget = req<HTMLButtonElement>('menu-forget'); const menuClose = req<HTMLButtonElement>('menu-close'); const powerShutdown = req<HTMLButtonElement>('power-shutdown'); const powerReboot = req<HTMLButtonElement>('power-reboot'); const powerSleep = req<HTMLButtonElement>('power-sleep'); const powerMinimize = req<HTMLButtonElement>('power-minimize'); + const powerQuit = req<HTMLButtonElement>('power-quit'); const powerClose = req<HTMLButtonElement>('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<HTMLButtonElement>('notifications-clear'); + const notificationsClose = req<HTMLButtonElement>('notifications-close'); const confirmYes = req<HTMLButtonElement>('confirm-yes'); const confirmNo = req<HTMLButtonElement>('confirm-no'); const errorClose = req<HTMLButtonElement>('error-close'); + const busyStopButton = req<HTMLButtonElement>('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' = 'details'; + 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; // ── 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<PopupView, 'none'>): 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'); } - function closePopup(): void { + /** + * 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 } @@ -174,15 +427,218 @@ export function createControls(deps: ControlsDeps): Controls { // 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) - applyMenuLibrary(); // keep the "Library" item fresh (only when there is a carousel to go back to) + 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; } - // Power submenu (from Details → Shutdown): Shutdown / Reboot / Sleep. Each opens a Yes/No confirm. + /** 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 === '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 @@ -208,7 +664,8 @@ export function createControls(deps: ControlsDeps): Controls { 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'; + if (mode === 'uninstall' && game.prefixCleanupOnly === true) + popup.dataset['uninstallVia'] = 'prefix'; else delete popup.dataset['uninstallVia']; if (isSteam) { confirmMessage.textContent = t()( @@ -230,6 +687,67 @@ export function createControls(deps: ControlsDeps): Controls { 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 === '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). @@ -256,6 +774,26 @@ export function createControls(deps: ControlsDeps): Controls { 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; @@ -269,18 +807,34 @@ export function createControls(deps: ControlsDeps): Controls { 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': - audio.play('back'); closePopup(); break; default: @@ -288,10 +842,66 @@ export function createControls(deps: ControlsDeps): Controls { } } + // ── 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 + * aside first — data-overlay holds one value at a time — and app.ts remembers to bring it back when the + * Customize screen closes. + */ + function openAddGame(): void { + deps.library.close(true); + deps.gameSettings.openNew(); + applyFocus(); + } + + function openCustomize(): void { + const browse = deps.getBrowse(); + if (browse === null || !browse.active) return; // the item's own rule, re-checked at the press + deps.gameSettings.open(browse.id); + 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 + * hidden and the row is the surface: there the highlight simply clears. + */ + function settingsClosed(): void { + const items = mainFocusables(); + const more = items.indexOf(moreButton); + 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). @@ -301,7 +911,9 @@ export function createControls(deps: ControlsDeps): Controls { const show = showInstall || showUninstall; menuInstallToggle.classList.toggle('is-hidden', !show); if (show) { - menuInstallToggle.textContent = t()(showInstall ? 'launcher.menu.install' : 'launcher.menu.uninstall'); + 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'; } @@ -312,31 +924,57 @@ export function createControls(deps: ControlsDeps): Controls { // 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 = s.kind === 'running' && s.killing !== true; + 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: Library (back to the history carousel) ──────────────────────── + // ── 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 applyMenuLibrary(): void { - const show = deps.carousel.exists() && deps.carousel.screen() === 'detail'; - menuLibrary.classList.toggle('is-hidden', !show); - if (show) menuLibrary.textContent = t()('launcher.menu.library'); + 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'); } - // The power menu's primary item. Desktop/Windows: "Minimize Playhook" (hide to tray). Game Mode: "Close - // Playhook" — a full quit, since there is no tray to minimize into (mirrors how closing the window quits - // in Game Mode). Label from JS (no data-i18n) so a language change relabels it at render time and it - // stays out of the i18n HTML test. - function applyPowerPrimary(): void { - powerMinimize.textContent = t()(gameMode ? 'launcher.menu.quit' : 'launcher.menu.minimize'); + // ── 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. + // ── Menu item: Customize (the per-game manifest editor) ────────────────────── + // The MIRROR of "Remove from history": that one is for a game we no longer have, this one for a game we + // do — `active` is main's word for "on the card or in the PC library", and it is exactly the condition + // under which a game.json to edit exists at all. The two are mutually exclusive by construction, so + // they never appear together. + function applyMenuCustomize(): void { + if (menuFrozen()) return; + const browse = deps.getBrowse(); + const show = onGameScreen() && browse !== null && browse.active; + 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); + } // ── Main bar focus (gamepad / mouse) ───────────────────────────────────────── @@ -347,21 +985,29 @@ 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 cursor: after 5s with no input the bar - // highlight goes dormant AND the cursor hides. Any input restarts the countdown; the gamepad hides the - // cursor at once (the user switched to the pad), a real mouse move shows it (see the note* helpers). + // 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; - let cursorHidden = false; + // 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 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]; + 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]; @@ -373,14 +1019,23 @@ export function createControls(deps: ControlsDeps): Controls { } // Main focus is meaningful on every DETAIL screen (the More button is always present there) with the - // popup closed. On the carousel the bar buttons are hidden, so the highlight has nothing to sit on — - // the selection lives in the strip instead. + // 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 { - return popupView === 'none' && deps.carousel.screen() === 'detail'; + if (popupView !== 'none') 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'; } function applyFocus(): void { const items = mainFocusables(); + // The carousel's empty bar: clamping against a length of 0 would push the index to -1 and quietly + // move the focus to Play the next time a detail screen is entered — wherever it had been left. + if (items.length === 0) { + ALL_MAIN_BUTTONS.forEach((btn) => btn.classList.remove('is-focused')); + return; + } focusIndex = Math.min(items.length - 1, Math.max(0, focusIndex)); const active = focusActive() && focusRevealed; ALL_MAIN_BUTTONS.forEach((btn) => { @@ -397,22 +1052,30 @@ export function createControls(deps: ControlsDeps): Controls { // default "Play" label fits better than an action it won't perform). const s = state(); const returnToGame = s.kind === 'running' && s.killing !== true; - playButton.setAttribute('aria-label', t()(returnToGame ? 'launcher.aria.returnToGame' : 'launcher.aria.play')); + playButton.setAttribute( + 'aria-label', + t()(returnToGame ? 'launcher.aria.returnToGame' : 'launcher.aria.play'), + ); } - function setCursorHidden(hidden: boolean): void { - if (cursorHidden === hidden) return; - cursorHidden = hidden; - document.documentElement.classList.toggle('cursor-hidden', hidden); + /** 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; - setCursorHidden(true); + setMouseAsleep(true); if (focusRevealed && focusActive()) { focusRevealed = false; applyFocus(); @@ -420,19 +1083,46 @@ export function createControls(deps: ControlsDeps): Controls { }, IDLE_MS); } - // Gamepad input = activity: hide the cursor at once (the user switched to the pad) + restart the idle. + // 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 { - setCursorHidden(true); + 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 = activity: show the cursor + restart the idle. + // Real mouse movement, with the mouse already awake = activity: keep the cursor up, restart the idle. function noteMouseActivity(): void { - setCursorHidden(false); + setMouseAsleep(false); armIdleTimer(); } - function moveFocus(delta: number): void { + 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 // WAKES the highlight at the current button — it doesn't move — so control returns without a jump. @@ -444,7 +1134,10 @@ export function createControls(deps: ControlsDeps): Controls { } const items = mainFocusables(); const next = Math.min(items.length - 1, Math.max(0, focusIndex + delta)); - if (next === focusIndex) return; // already at the edge — no move, no sound + if (next === focusIndex) { + if (!repeat) audio.playLimit(); // already at the edge: no move, and the dead end says so + return; + } focusIndex = next; audio.play('navigate'); applyFocus(); @@ -454,36 +1147,60 @@ export function createControls(deps: ControlsDeps): Controls { // 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[] = [ - menuShutdown, menuInstallToggle, menuKill, - menuLibrary, + 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': { - const items: HTMLButtonElement[] = [menuShutdown]; + // 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 (!menuLibrary.classList.contains('is-hidden')) items.push(menuLibrary); + 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 'power': - return [powerShutdown, powerReboot, powerSleep, powerMinimize, powerClose]; + 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: @@ -499,8 +1216,25 @@ export function createControls(deps: ControlsDeps): Controls { const items = stackFocusables(); stackIndex = Math.min(items.length - 1, Math.max(0, stackIndex)); const focused = stackActive() ? items[stackIndex] : undefined; - for (const btn of ALL_STACK_BUTTONS) btn.classList.toggle('is-focused', btn === focused); - if (focused !== undefined) focused.scrollIntoView({ block: 'nearest' }); + // 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 { @@ -515,7 +1249,10 @@ export function createControls(deps: ControlsDeps): Controls { // 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) return; + if (next === stackIndex) { + audio.playLimit(); + return; + } stackIndex = next; audio.play('navigate'); applyStackFocus(); @@ -529,11 +1266,15 @@ export function createControls(deps: ControlsDeps): Controls { // ── User-initiated actions ─────────────────────────────────────────────────── function triggerPlay(): void { - if (!focusActive()) return; + 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; + 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) { @@ -542,26 +1283,26 @@ export function createControls(deps: ControlsDeps): Controls { return; } // Steam uninstall in progress (gear) → nothing useful to do, ignore the press. - if (game?.steamUninstalling === true) return; + 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; + 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; + if (phaseOf(state()) !== 'ready' && state().kind !== 'running') return audio.playLimit(); audio.play('play'); window.api.requestLaunch(); } function triggerMore(): void { - audio.play('button'); - openDetails(); + openDetails(); // the panel's own popup-open is the sound of this press } function activateFocused(): void { // Nothing is selected while the highlight is dormant — the user must wake it (d-pad / hover) first. - if (!focusActive() || !focusRevealed) return; + if (!focusActive()) return; // the bar is not the surface driving the press + if (!focusRevealed) return audio.playLimit(); // A on a dormant highlight presses nothing const btn = mainFocusables()[focusIndex]; if (btn === undefined) return; pressFlash(btn); @@ -571,21 +1312,40 @@ export function createControls(deps: ControlsDeps): Controls { // 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 === menuShutdown) { - audio.play('button'); - openPower(); - } else if (btn === menuInstallToggle) { + 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 === menuLibrary) { + } 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. - audio.play('back'); closePopup(); deps.carousel.leaveDetail(); - } else if (btn === menuClose || btn === errorClose || btn === powerClose) { + } 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(); @@ -599,17 +1359,29 @@ export function createControls(deps: ControlsDeps): Controls { audio.play('button'); openConfirm('sleep'); } else if (btn === powerMinimize) { - // Desktop/Windows: hide to the tray (same as the empty-screen Hide button). Game Mode: quit the app - // ("Close Playhook") — there is no tray, so hide is a no-op there. No confirm either way — hide is - // non-destructive, and a quit is as recoverable as relaunching from the Steam library. Close the - // popup first so a re-summoned launcher shows a clean bar, not this menu. - audio.play('back'); + // 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(); - if (gameMode) window.api.requestQuit(); - else window.api.requestHide(); + 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; + } back(); // cancel → returns to Details / Power } } @@ -625,6 +1397,14 @@ export function createControls(deps: ControlsDeps): Controls { // "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) { @@ -640,6 +1420,11 @@ export function createControls(deps: ControlsDeps): Controls { 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(); @@ -652,6 +1437,41 @@ export function createControls(deps: ControlsDeps): Controls { 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 '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; + } } } @@ -670,20 +1490,56 @@ export function createControls(deps: ControlsDeps): Controls { }); }); - // One window-level mouse handler, guarded against SYNTHETIC moves (Chromium fires mousemove with - // unchanged coordinates when an element shifts under a still pointer — e.g. the busy title-slide — and - // that must not undo a gamepad cursor-hide). A real move shows the cursor, counts as activity, and — - // when it's over a bar button — wakes/moves the bar focus so A activates what's highlighted. + // 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<HTMLButtonElement>('.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. + // + // Chromium fires mouse events at unchanged coordinates whenever the element UNDER a still pointer + // changes: a busy title sliding past, or — the case that bit us — a popup opening with its buttons + // landing right where the cursor happens to rest. As `mouseenter` handlers, the stack buttons took + // that for a hover and moved the focus off the item the popup had just focused; the next gamepad press + // moved it back. That was the "it jumps and returns" stutter, and it needed nothing but a resting + // mouse to reproduce — no blur, no dropped frame. + // + // Reading hover from mousemove with a coordinate check instead means the focus follows the pointer + // only when the pointer actually moves. let lastMouseX = -1; let lastMouseY = -1; window.addEventListener('mousemove', (event) => { if (event.clientX === lastMouseX && event.clientY === lastMouseY) return; // synthetic — ignore lastMouseX = event.clientX; lastMouseY = 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; + 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<HTMLButtonElement>('.text-button') ?? null; + if (button === null) return; + const idx = stackFocusables().indexOf(button); + if (idx === -1 || idx === stackIndex) return; + stackIndex = idx; + applyStackFocus(); + return; + } if (!focusActive()) return; - const target = - event.target instanceof Element ? event.target.closest<HTMLButtonElement>('#play-button, #more-button') : null; + const target = element?.closest<HTMLButtonElement>('#play-button, #more-button') ?? null; if (target === null) return; const idx = mainFocusables().indexOf(target); if (idx === -1) return; @@ -693,14 +1549,54 @@ export function createControls(deps: ControlsDeps): Controls { applyFocus(); } }); - ALL_STACK_BUTTONS.forEach((btn) => { - btn.addEventListener('mouseenter', () => { - if (!stackActive()) return; - const idx = stackFocusables().indexOf(btn); - if (idx === -1) return; - stackIndex = idx; - applyStackFocus(); - }); + + // 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 @@ -713,42 +1609,224 @@ export function createControls(deps: ControlsDeps): Controls { // 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'; + /** 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(); + + // ── Held directions ──────────────────────────────────────────────────────── + // A repeat press means a direction is being held. It ends on an explicit release — the pad reports one + // (onDirectionsReleased), the keyboard has keyup — but neither is guaranteed to arrive: the window can + // lose focus mid-hold and swallow the keyup, and a pad can be unplugged. So a watchdog closes it too, + // renewed on every repeat; at the repeat cadence (NAV_REPEAT_MS) this silence can only mean a stop. + const FLIP_WATCHDOG_MS = 400; + let flipping = false; + let flipWatchdog = 0; + + function noteFlip(): void { + if (flipWatchdog !== 0) window.clearTimeout(flipWatchdog); + flipWatchdog = window.setTimeout(endFlip, FLIP_WATCHDOG_MS); + if (flipping) return; + flipping = true; + deps.onFlipping(true); + } - function navLeft(): void { + function endFlip(): void { + if (flipWatchdog !== 0) { + window.clearTimeout(flipWatchdog); + flipWatchdog = 0; + } + if (!flipping) return; + flipping = false; + deps.onFlipping(false); + } + + /** + * Everything that ends when the input is let go: the flip spell, and the `limit` latch — a series of + * blocked attempts ends on release, so the next dead end sounds again (see sfx-limit.ts). Both halves + * of the release detection (the pad's onDirectionsReleased, the keyboard's keyup) come through here. + */ + function endInput(): void { + endFlip(); + audio.rearmLimit(); + } + + function navLeft(repeat = false): void { noteGamepadActivity(); - if (onCarousel()) deps.carousel.move(-1); - else if (popupView === 'none') moveFocus(-1); + 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(); + return; + } + // BEFORE stripActive(): left/right are the slider's own gesture (and the dropdown's fast path), and + // holding one on the Settings screen must never flip through the carousel underneath. + const overlay = overlays.active(); + if (overlay !== null) { + overlay.navLeft(repeat); + return; + } + if (stripActive()) { + const moved = deps.carousel.move(-1); + if (!repeat && moved === 'at-end') audio.playLimit(); + return; + } + moveFocus(-1, repeat); } - function navRight(): void { + function navRight(repeat = false): void { noteGamepadActivity(); - if (onCarousel()) deps.carousel.move(1); - else if (popupView === 'none') moveFocus(1); + 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; + if (overlay !== null) { + overlay.navRight(repeat); + return; + } + if (stripActive()) { + // The row ends at the last launcher card and there is nothing beyond it: a stop is a dead end and + // says so. A HELD right stays silent — one gesture running down a long history must not end in a + // sound. `locked` is the return-morph, where nothing happens at all. + if (deps.carousel.move(1) === 'at-end' && !repeat) audio.playLimit(); + return; + } + if (popupView === 'none') moveFocus(1, repeat); } - function navUp(): void { + // 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(); - if (popupView !== 'none') moveStackFocus(-1); + if (repeat) noteFlip(); + if (popupView !== 'none') { + // 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 + // there is no edge to stop at — a hold simply keeps going until it is released. + moveStackFocus(-1); + return; + } + const overlay = overlays.active(); + if (overlay !== null) { + overlay.navUp(repeat); + return; + } + // Nothing sits above the bar on the detail screen, so up leaves it: the strip the game was picked + // from is literally where it came from, and it re-enters exactly there. Held (repeat) presses are + // dropped — one hold must not walk out of the screen the moment the user pauses on it. Only when no + // popup is up: there the direction belongs to the menu, which is handled above. + if (repeat) return; + if (deps.carousel.leaveDetail()) audio.play('back'); + else audio.playLimit(); // on the strip there is nothing above the cards to step up to } - function navDown(): void { + function navDown(repeat = false): void { noteGamepadActivity(); - if (popupView !== 'none') moveStackFocus(1); + if (repeat) noteFlip(); + if (popupView !== 'none') { + moveStackFocus(1); // see navUp — a held direction runs the stack, same as any other list + return; + } + const overlay = overlays.active(); + if (overlay !== null) { + overlay.navDown(repeat); + return; + } + // The other half of the vertical pair: down opens the selected GAME (what A does), up on the detail + // screen comes back out. The strip only — with the focus on More, down has no card to open, and + // inside a popup the direction belongs to the menu (handled above). Held presses are dropped, as + // everywhere a direction crosses a screen boundary. + // + // A launcher card is not opened this way. Down means "go into this game", and the launcher cards are + // surfaces rather than games — Settings and the Library have their own way in (A), and opening one by + // brushing the stick downwards is how a flip along the row ends up in a screen nobody asked for. + if (repeat || !stripActive()) return; + if (!deps.carousel.onGame()) { + audio.playLimit(); + return; + } + deps.carousel.activate(); } function navActivate(): void { noteGamepadActivity(); if (popupView !== 'none') activateStack(); - else if (onCarousel()) deps.carousel.activate(); + else if (overlays.active() !== null) overlays.active()?.navActivate(); + else if (stripActive()) deps.carousel.activate(); else activateFocused(); } function navBack(): void { noteGamepadActivity(); - // Deepest level first: a popup closes, then a detail screen steps back to the carousel. On the - // carousel itself B does nothing — it is the top level. + // 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(); return; } + const overlay = overlays.active(); + if (overlay !== null) { + overlay.navBack(); + return; + } + if (deps.carousel.screen() === 'carousel') { + // The strip is the top level and the only surface on this screen: there is nothing above home to go + // back to, and nowhere else to hand the focus, so B is an honest dead end here. + audio.playLimit(); + return; + } if (deps.carousel.leaveDetail()) audio.play('back'); } + /** + * Y belongs to the OVERLAYS alone (the keyboard's Shift). It used to hand the focus to the More button — + * on the carousel, where More no longer exists, and on a detail screen, where left/right already walk + * between Play and More. Everywhere else it is an honest dead end. + */ + function navY(): void { + noteGamepadActivity(); + const overlay = overlays.active(); + if (overlay !== null) { + if (overlay.navTertiary === undefined) audio.playLimit(); + else overlay.navTertiary(); + return; + } + audio.playLimit(); + } + + /** + * X and the shoulders: overlay-only, and only when the surface on top claims them. Everywhere else the + * button has no meaning here — the carousel, a detail screen, the popup — and the honest answer to that + * is the dead-end sound, not silence. Routed in ONE place, so a surface that never claims them (and any + * added later) is covered without a stub of its own; the NavSurface contract stays "unclaimed means + * unchanged" (nav-surface.ts). + */ + function navSecondary(repeat = false): void { + const claimed = popupView === 'none' && overlays.active()?.navSecondary !== undefined; + if (!claimed) { + if (!repeat) audio.playLimit(); + return; + } + overlays.active()?.navSecondary?.(repeat); + } + + function navShoulder(direction: -1 | 1): void { + const claimed = popupView === 'none' && overlays.active()?.navShoulder !== undefined; + if (!claimed) { + audio.playLimit(); + return; + } + overlays.active()?.navShoulder?.(direction); + } + + function navCommit(): void { + const claimed = popupView === 'none' && overlays.active()?.navCommit !== undefined; + if (!claimed) { + audio.playLimit(); + return; + } + overlays.active()?.navCommit?.(); + } + // The wheel flips through the carousel. Throttled: one notch of a mouse wheel is one event, but a // trackpad emits a stream of them, which would fly past a dozen cards per gesture. const WHEEL_THROTTLE_MS = 120; @@ -756,6 +1834,10 @@ export function createControls(deps: ControlsDeps): Controls { window.addEventListener( 'wheel', (event) => { + // onCarousel() stays true under the Settings screen — without this the wheel would flip through the + // strip behind the veil. Inside the screen the wheel scrolls its own list natively. + if (deps.isBooting()) return; // the row is behind the boot screen — see whileAwake + if (overlays.isAnyOpen()) return; if (!onCarousel()) return; noteMouseActivity(); const delta = event.deltaY !== 0 ? event.deltaY : event.deltaX; @@ -779,28 +1861,58 @@ export function createControls(deps: ControlsDeps): Controls { // context-menu event main listens for, which is exactly how this broke copying the path. if (isOverSelectableText(event.target)) return; event.preventDefault(); + if (deps.isBooting()) return; // the same fence the pad and the keyboard sit behind (see whileAwake) 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(); }); - const gamepad = createGamepadController({ - onLeft: navLeft, - onRight: navRight, - onUp: navUp, - onDown: navDown, - onA: navActivate, - onB: navBack, - }); + /** + * Wraps a primitive so it does nothing while the boot screen is up (see ControlsDeps.isBooting). Applied + * at the two DISPATCH points — the pad's handler map and the keyboard's keydown — rather than inside + * each primitive, so a surface added later is covered by construction. The mouse is fenced off in CSS + * (`#app[data-boot]` is pointer-events:none), and the wheel / right-click, which listen on the window + * and never touch that rule, check the flag themselves. + */ + function whileAwake<A extends readonly unknown[]>( + fn: (...args: A) => void, + ): (...args: A) => void { + return (...args: A): void => { + if (deps.isBooting()) return; + fn(...args); + }; + } + + const gamepad = createGamepadController( + { + onLeft: whileAwake(navLeft), + onRight: whileAwake(navRight), + onUp: whileAwake(navUp), + onDown: whileAwake(navDown), + onA: whileAwake(navActivate), + onB: whileAwake(navBack), + onY: whileAwake(navY), + onX: whileAwake(navSecondary), + onShoulderLeft: whileAwake(() => navShoulder(-1)), + onShoulderRight: whileAwake(() => navShoulder(1)), + onTriggerRight: whileAwake(navCommit), + // NOT gated: a direction held across the reveal must still be able to end its run — this only tidies + // the flip spell and re-arms the `limit` latch, it drives nothing. + onDirectionsReleased: endInput, + }, + 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<Record<string, () => void>> = { + const KEY_NAV: Readonly<Record<string, (repeat: boolean) => void>> = { a: navLeft, arrowleft: navLeft, d: navRight, @@ -815,24 +1927,77 @@ export function createControls(deps: ControlsDeps): Controls { backspace: navBack, escape: navBack, }; - // Left/right are the exception to the edge model: holding them flips through the carousel, matching the - // gamepad's hold-to-repeat. The OS auto-repeat supplies the events (its own initial delay is close - // enough to the pad's), but its rate is far too fast for a carousel, so it is throttled to the same - // NAV_REPEAT_MS cadence. Every other key stays one action per press. - const REPEATABLE_KEYS = new Set(['a', 'arrowleft', 'd', 'arrowright']); - let lastKeyRepeatAt = 0; + // 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) { - if (!REPEATABLE_KEYS.has(key)) return; - const now = performance.now(); - if (now - lastKeyRepeatAt < NAV_REPEAT_MS) return; - lastKeyRepeatAt = now; - } - handler(); + 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 { @@ -841,19 +2006,24 @@ export function createControls(deps: ControlsDeps): Controls { // running→syncing-out self-exit must drop Force close; a ready→ready update doesn't close the popup). applyMenuInstallToggle(); applyMenuKill(); - applyMenuLibrary(); + 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'); - applyMenuLibrary(); // the carousel can still be there with no game on screen (history only) + 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) } function refresh(): void { - // The popup lives on every screen now (empty included — More there offers System + Close). Only a + // 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. @@ -869,21 +2039,48 @@ export function createControls(deps: ControlsDeps): Controls { const active = phaseOf(state()) === 'busy' || steamBusy(state()); if (active && !wasActive) focusRevealed = false; wasActive = active; - applyPowerPrimary(); // re-label on a language change (refresh runs after applyLocale → render) + applyPowerItems(); applyFocus(); applyStackFocus(); applyPlayAria(); } - return { applyGameButtons, 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', + ); + }, + showBusy: openBusy, + closeBusy: () => { + if (popupView !== 'busy') return; + busyStop = null; + closePopup(); + }, + openSystemCard, + openAddGame, refresh, showError: openError, setGameMode: (value: boolean) => { gameMode = value; - applyPowerPrimary(); + applyPowerItems(); }, start: () => { gamepad.start(); @@ -891,5 +2088,10 @@ export function createControls(deps: ControlsDeps): Controls { }, /** 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', }; } diff --git a/src/renderer/dom.ts b/src/renderer/dom.ts index 400a8563..56340707 100644 --- a/src/renderer/dom.ts +++ b/src/renderer/dom.ts @@ -12,3 +12,13 @@ export function reqQuery<T extends HTMLElement>(selector: string): T { if (el === null) throw new Error(`${selector} not found`); return el; } + +/** + * A canvas by id, checked rather than cast: `req` would happily hand back a div typed as a canvas, and + * the first getContext call would then be the thing that failed — a frame late and far from the cause. + */ +export function reqCanvas(id: string): HTMLCanvasElement { + const el = req<HTMLElement>(id); + if (!(el instanceof HTMLCanvasElement)) throw new Error(`#${id} is not a canvas`); + return el; +} diff --git a/src/renderer/entrance.ts b/src/renderer/entrance.ts new file mode 100644 index 00000000..5894316e --- /dev/null +++ b/src/renderer/entrance.ts @@ -0,0 +1,57 @@ +// The one-shot entrance every list on this launcher plays: its rows arrive from below, in the order they +// are read (@keyframes popup-item-in, staggered by --row-index / --osk-row). +// +// It marks the ROWS, not their container, and that is the whole point. The obvious shape — a class on the +// box for as long as the animation lasts, with a descendant selector under it — turns the class into a +// WINDOW: anything built while it is up matches too, and starts the entrance from zero. Every one of +// these lists rebuilds its contents far more often than it opens (the keyboard rebuilds all of its keys +// on every Shift, and again on every character typed with Shift on; the settings panes rebuild on a +// deferred preview and on the validator coming back), and a rebuild landing inside that window replayed +// the whole entrance — the surface visibly re-arriving a beat after it had already arrived. Marking the +// rows that exist AT THE MOMENT OF ARMING is immune to it: rows made later were not part of the arrival +// and simply appear. +// +// The mark is dropped on a timer rather than on `animationend`: with the stagger, the last row's event is +// the only one that means "all done", and a row removed mid-flight never fires one at all. + +export interface Entrance { + /** Marks the rows present right now so they animate in once. Anything built later stays put. */ + play(): void; + /** Drops the marks (the surface is closing, or its rows are being replaced wholesale). */ + cancel(): void; +} + +/** + * @param box the container to look for rows in + * @param selector the rows themselves — must match the `.is-entering` rule in styles.css + * @param ms how long the whole staggered entrance takes, after which the marks come off + */ +export function createEntrance(box: HTMLElement, selector: string, ms: number): Entrance { + let timer = 0; + + const rows = (): readonly HTMLElement[] => [...box.querySelectorAll<HTMLElement>(selector)]; + + const clear = (): void => { + for (const row of rows()) row.classList.remove('is-entering'); + }; + + return { + play: (): void => { + if (timer !== 0) window.clearTimeout(timer); + // Off and on around a forced reflow: these nodes are often reused across visits, and re-adding a + // class the element already carries plays nothing at all. + clear(); + void box.offsetWidth; + for (const row of rows()) row.classList.add('is-entering'); + timer = window.setTimeout(() => { + timer = 0; + clear(); + }, ms); + }, + cancel: (): void => { + if (timer !== 0) window.clearTimeout(timer); + timer = 0; + clear(); + }, + }; +} diff --git a/src/renderer/file-picker.ts b/src/renderer/file-picker.ts new file mode 100644 index 00000000..2fa25660 --- /dev/null +++ b/src/renderer/file-picker.ts @@ -0,0 +1,524 @@ +// The in-launcher file browser — a surface of the Customize screen, and the replacement for a native +// dialog that cannot be used here at all: `dialog.showOpenDialog` over a fullscreen/kiosk window takes no +// gamepad input, and in Game Mode it is simply a dead end. +// +// 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). +// +// 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 +// a folder or picks a file, B goes up a level and — at the top — leaves. +import type { + ConfigPickKind, + ConfigPickResult, + DirEntry, + DirRoot, + ListDirResult, +} from '../shared/types'; +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'; + +/** What the picker asks main. A seam, so app.ts owns the window.api wiring. */ +export interface FilePickerApi { + listDir(request: { + readonly path?: string; + readonly root?: string; + readonly kind?: ConfigPickKind; + readonly current?: string; + readonly base?: string; + }): Promise<ListDirResult>; + acceptPaths(request: { + readonly root: string; + readonly kind: ConfigPickKind; + readonly paths: readonly string[]; + readonly base?: string; + }): Promise<ConfigPickResult>; +} + +export interface FilePickerDeps { + readonly audio: AudioController; + getTranslator(): Translator; + readonly api: FilePickerApi; +} + +/** Which column the focus is in. */ +type Column = 'roots' | 'entries'; + +export function createFilePicker(deps: FilePickerDeps): FilePickerSurface { + const root = req('file-picker'); + const titleEl = req('picker-title'); + const pathEl = req('picker-path'); + const rootsEl = req('picker-roots'); + const entriesEl = req('picker-entries'); + const legendEl = req('picker-legend'); + + const t = (): Translator => deps.getTranslator(); + const entriesScroller = createScroller(entriesEl); + const hover = createHoverGuard(); + + let open = false; + let request: { + readonly root: string; + readonly kind: ConfigPickKind; + readonly multi: boolean; + readonly base?: string; + readonly onDone: (result: ConfigPickResult) => void; + } | null = null; + + let here = ''; + let parent: string | null = null; + let entries: readonly DirEntry[] = []; + let roots: readonly DirRoot[] = []; + /** Multi-select (hero images): the files ticked so far, in the order they were ticked. */ + let picked: string[] = []; + + let column: Column = 'entries'; + let entryIndex = 0; + let rootIndex = 0; + let rootButtons: HTMLButtonElement[] = []; + let entryButtons: HTMLButtonElement[] = []; + // Where each half of the entries column was left, so the Y toggle returns rather than resets. + let lastActionIndex = 0; + let lastTreeIndex = 0; + /** + * Where the last visit ENDED, per field kind, kept for the lifetime of the screen: picking three hero + * images one after another must not start at the top of the filesystem each time. + * + * Per KIND, not one shared value — otherwise browsing the card for an executable would leave the PC + * save-path picker opening on the card, which is nowhere near where a save folder lives. + */ + const lastVisited = new Map<ConfigPickKind, string>(); + /** + * Which entry was focused in each directory visited this session, by name. Stepping into the third + * folder and back must put the cursor on the THIRD folder — landing on the first every time turns + * "look inside a few of these" into counting rows over and over. + */ + const focusMemory = new Map<string, string>(); + + function wantsDirectory(): boolean { + const kind = request?.kind; + return kind === 'directory' || kind === 'pc-save' || kind === 'pc-save-local'; + } + + function paintRoots(): void { + rootButtons = roots.map((entry, index) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'picker-item is-dir'; + button.textContent = entry.label; + button.addEventListener('click', () => { + column = 'roots'; + rootIndex = index; + applyFocus(); + void goTo(entry.path); + }); + return button; + }); + rootsEl.replaceChildren(...rootButtons); + } + + function paintEntries(): void { + pathEl.textContent = here; + const items: HTMLButtonElement[] = []; + // 2: the way OUT, as a row. Backing out with B walks up one directory at a time, which after six + // steps into a Steam library is six presses to change your mind — this is one. + const cancelItem = document.createElement('button'); + cancelItem.type = 'button'; + cancelItem.className = 'picker-item is-action'; + cancelItem.textContent = t()('picker.cancel'); + cancelItem.addEventListener('click', () => cancel()); + items.push(cancelItem); + // "Up one level" is a row of its own rather than only a button gesture: a mouse user has no B, and + // it is the move the browser is used for most. It carries no folder glyph — it is an ACTION, and + // reading as one of the folders in the tree is exactly how it gets confused with one. + if (parent !== null) { + const up = document.createElement('button'); + up.type = 'button'; + up.className = 'picker-item is-action'; + up.textContent = t()('picker.up'); + up.addEventListener('click', () => { + if (parent !== null) void goTo(parent); + }); + items.push(up); + } + // A folder field can pick the folder it is standing IN — there is no other way to name it. + if (wantsDirectory()) { + const useThis = document.createElement('button'); + useThis.type = 'button'; + useThis.className = 'picker-item is-action'; + useThis.textContent = t()('picker.useThisFolder'); + useThis.addEventListener('click', () => void accept([here])); + items.push(useThis); + } + for (const entry of entries) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = `picker-item is-${entry.kind}`; + button.textContent = entry.name; + const full = join(here, entry.name); + button.classList.toggle('is-picked', picked.includes(full)); + button.addEventListener('click', () => void activatePath(entry, full)); + items.push(button); + } + if (items.length === 0) { + const empty = document.createElement('div'); + empty.className = 'picker-empty'; + empty.textContent = t()('picker.empty'); + entriesEl.replaceChildren(empty); + entryButtons = []; + return; + } + entryButtons = items; + // The tree starts below the actions, and a rule of its OWN says so. Putting the border on the last + // action button squared its corner and pushed its label off centre — and that button is "Use this + // folder", the primary action of a folder pick. + const nodes: HTMLElement[] = [...items]; + if (entries.length > 0) { + const divider = document.createElement('div'); + divider.className = 'picker-divider'; + nodes.splice(leadingRows(), 0, divider); + } + entriesEl.replaceChildren(...nodes); + } + + /** How many leading rows are not directory entries (Cancel, Up, Use this folder). */ + function leadingRows(): number { + return 1 + (parent !== null ? 1 : 0) + (wantsDirectory() ? 1 : 0); + } + + /** Whether the focus is currently on one of those action rows rather than in the tree. */ + function onActionRow(): boolean { + return entryIndex < leadingRows(); + } + + /** Joins a directory and a name in whatever separator the directory already uses. */ + function join(directory: string, name: string): string { + const separator = directory.includes('\\') && !directory.includes('/') ? '\\' : '/'; + return directory.endsWith(separator) + ? `${directory}${name}` + : `${directory}${separator}${name}`; + } + + function applyFocus(): void { + rootButtons.forEach((button, index) => + button.classList.toggle('is-focused', column === 'roots' && index === rootIndex), + ); + entryButtons.forEach((button, index) => + button.classList.toggle('is-focused', column === 'entries' && index === entryIndex), + ); + if (column === 'entries') { + const focused = entryButtons[entryIndex]; + if (focused !== undefined) entriesScroller.reveal(focused); + } else { + rootButtons[rootIndex]?.scrollIntoView({ block: 'nearest' }); + } + } + + /** Navigates, remembering where the cursor stood in the directory being left (see focusMemory). */ + async function goTo(path: string): Promise<void> { + rememberFocus(); + await go(path); + } + + function rememberFocus(): void { + const focused = focusedEntry(); + if (here !== '' && focused !== null) focusMemory.set(here, focused.entry.name); + } + + async function go(path: string | undefined): Promise<void> { + const at = request; + if (at === null) return; + const remembered = lastVisited.get(at.kind); + const result = await deps.api.listDir( + path === undefined + ? { + root: at.root, + kind: at.kind, + ...(at.base !== undefined ? { base: at.base } : {}), + ...(remembered !== undefined ? { path: remembered } : {}), + } + : { path, root: at.root, kind: at.kind }, + ); + roots = result.roots; + paintRoots(); + if (!result.ok) { + pathEl.textContent = result.message; + entries = []; + entryButtons = []; + entriesEl.replaceChildren(); + return; + } + here = result.path; + lastVisited.set(at.kind, result.path); + parent = result.parent; + entries = result.entries; + column = 'entries'; + paintEntries(); + // The focus goes back to whatever was focused here last time (coming up out of a folder lands ON + // that folder), and otherwise to the first real ENTRY — past the Cancel / Up / Use-this-folder rows, + // since a picker that opens on its own exit button is one you have to walk down before using. + const rememberedName = focusMemory.get(here); + const rememberedAt = + rememberedName === undefined + ? -1 + : entries.findIndex((entry) => entry.name === rememberedName); + entryIndex = + rememberedAt !== -1 + ? leadingRows() + rememberedAt + : entries.length > 0 + ? leadingRows() + : Math.max(0, entryButtons.length - 1); + // A new directory has new rows: both halves of the Y toggle start where this listing put the focus. + lastTreeIndex = entries.length > 0 ? entryIndex : leadingRows(); + lastActionIndex = leadingRows() - 1; + applyFocus(); + entriesScroller.to(0, true); + } + + async function activatePath(entry: DirEntry, full: string): Promise<void> { + if (entry.kind === 'dir') { + deps.audio.play('button'); + await goTo(full); + return; + } + if (wantsDirectory()) { + deps.audio.playLimit(); // a folder field has no use for a file + return; + } + if (request?.multi === true) { + // X ticks and unticks; A on a file in multi mode ticks it and finishes, which is the one-image case. + deps.audio.play('button'); + await accept([...picked.filter((item) => item !== full), full]); + return; + } + deps.audio.play('button'); + await accept([full]); + } + + function togglePick(full: string): void { + if (picked.includes(full)) picked = picked.filter((item) => item !== full); + else picked.push(full); + deps.audio.play('navigate'); + paintEntries(); + applyFocus(); + } + + /** Hands the absolute path(s) to main, which turns them into what the manifest field stores. */ + async function accept(paths: readonly string[]): Promise<void> { + const at = request; + if (at === null) return; + const result = await deps.api.acceptPaths({ + root: at.root, + kind: at.kind, + paths, + ...(at.base !== undefined ? { base: at.base } : {}), + }); + if (!result.ok && !('cancelled' in result)) { + // A rejection is not an exit: the user is standing in the folder they picked from, and the message + // tells them what to pick instead. + pathEl.textContent = result.message; + deps.audio.playLimit(); // main refused this path: the press could not do what it asked + return; + } + hide(); + at.onDone(result); + } + + function hide(): void { + if (!open) return; + deps.audio.play('popup-close'); + open = false; + root.classList.remove('is-open'); + root.setAttribute('aria-hidden', 'true'); + } + + function cancel(): void { + const at = request; + hide(); + at?.onDone({ ok: false, cancelled: true }); + } + + function move(delta: number): void { + hover.arm(); + if (column === 'roots') { + const next = clampIndex(rootIndex, delta, rootButtons.length); + if (next === rootIndex) { + deps.audio.playLimit(); // the end of the roots column + return; + } + rootIndex = next; + } else { + const next = clampIndex(entryIndex, delta, entryButtons.length); + if (next === entryIndex) { + deps.audio.playLimit(); // the end of the tree column + return; + } + entryIndex = next; + } + deps.audio.play('navigate'); + applyFocus(); + } + + function focusedEntry(): { readonly entry: DirEntry; readonly full: string } | null { + const entry = entries[entryIndex - leadingRows()]; + if (entry === undefined) return null; + return { entry, full: join(here, entry.name) }; + } + + root.querySelector<HTMLElement>('.picker-veil')?.addEventListener('click', () => { + cancel(); + }); + + window.addEventListener( + 'mousemove', + (event) => { + hover.track(event.clientX, event.clientY); + if (!open) return; + if (document.documentElement.classList.contains('mouse-asleep')) return; + if (!hover.awake(event.clientX, event.clientY)) return; + const target = event.target; + if (!(target instanceof Element)) return; + const button = target.closest<HTMLButtonElement>('.picker-item'); + if (button === null) return; + const inEntries = entryButtons.indexOf(button); + if (inEntries !== -1) { + if (column === 'entries' && inEntries === entryIndex) return; + column = 'entries'; + entryIndex = inEntries; + applyFocus(); + return; + } + const inRoots = rootButtons.indexOf(button); + if (inRoots === -1) return; + if (column === 'roots' && inRoots === rootIndex) return; + column = 'roots'; + rootIndex = inRoots; + applyFocus(); + }, + { passive: true }, + ); + + function updateChrome(): void { + legendEl.textContent = t()(request?.multi === true ? 'picker.legendMulti' : 'picker.legend'); + } + + return { + isOpen: () => open, + open: (next) => { + request = { + root: next.root, + kind: next.kind, + multi: next.multi, + ...(next.base !== undefined ? { base: next.base } : {}), + onDone: next.onDone, + }; + picked = []; + open = true; + deps.audio.play('popup-open'); + titleEl.textContent = t()('picker.title'); + updateChrome(); + root.classList.add('is-open'); + root.setAttribute('aria-hidden', 'false'); + hover.arm(); + // No explicit path: main picks the starting point from the field and its current value, unless this + // field has already been browsed once this session (see lastVisited). + void go(lastVisited.get(next.kind)); + }, + navUp: () => move(-1), + navDown: () => move(1), + navLeft: () => { + hover.arm(); + if (column === 'entries' && rootButtons.length > 0) { + column = 'roots'; + deps.audio.play('navigate'); + applyFocus(); + } + }, + navRight: () => { + hover.arm(); + if (column === 'roots' && entryButtons.length > 0) { + column = 'entries'; + deps.audio.play('navigate'); + applyFocus(); + } + }, + navActivate: () => { + hover.arm(); + if (column === 'roots') { + const target = roots[rootIndex]; + if (target === undefined) return; + deps.audio.play('button'); + void goTo(target.path); + return; + } + const button = entryButtons[entryIndex]; + if (button === undefined) return; + button.click(); + }, + /** + * Back goes UP a level, and at the top of the filesystem it does NOTHING. + * + * Deliberately not "up, then out": those are different actions, and spending the last of a run of + * back-presses on closing the browser undoes several folders of work with a keystroke you did not + * mean that way. Leaving is the Cancel row instead — always the first row, and one Y away. + */ + navBack: () => { + hover.arm(); + if (parent === null) { + deps.audio.playLimit(); // the top of the filesystem: there is no level above it + return; + } + deps.audio.play('back'); + void goTo(parent); + }, + /** + * Y jumps between the tree and the action rows above it (Cancel / Up / Use this folder), remembering + * which row each half was left on, so it is a round trip rather than a jump to the top. + */ + navTertiary: () => { + if (entryButtons.length === 0) return; + hover.arm(); + const lead = leadingRows(); + if (onActionRow()) { + lastActionIndex = entryIndex; + if (entries.length === 0) return; // nothing to jump INTO + entryIndex = Math.max(lead, Math.min(lastTreeIndex, entryButtons.length - 1)); + } else { + lastTreeIndex = entryIndex; + entryIndex = Math.min(lastActionIndex, lead - 1); + } + column = 'entries'; + deps.audio.play('navigate'); + applyFocus(); + }, + /** X ticks a file in multi mode — the one gesture a single-select browser has no need for. */ + navSecondary: () => { + // Ticking is a multi-select gesture, and only a file can be ticked — anywhere else X does nothing. + if (request?.multi !== true || column !== 'entries') { + deps.audio.playLimit(); + return; + } + const focused = focusedEntry(); + if (focused === null || focused.entry.kind !== 'file') { + deps.audio.playLimit(); + return; + } + togglePick(focused.full); + }, + relocalize: () => { + if (!open) return; + titleEl.textContent = t()('picker.title'); + updateChrome(); + paintRoots(); + paintEntries(); + applyFocus(); + }, + }; +} diff --git a/src/renderer/focus-jelly.ts b/src/renderer/focus-jelly.ts new file mode 100644 index 00000000..9c3dda4a --- /dev/null +++ b/src/renderer/focus-jelly.ts @@ -0,0 +1,345 @@ +// The focus indicator: a soft body lying UNDER the selected cover, breathing where it stands and +// flowing to the next one when the selection moves. It replaces the pulsing ring the carousel and the +// Library grid used to draw around the selected card — a ring has to blink out on one card and in on the +// next, while one body per surface simply travels. +// +// The maths that decides WHERE it is stays pure and testable (outlinePoint / pinchScale / jellyBoxOf); +// only createFocusJelly touches a canvas. Design pixels go in, real pixels come out: every caller works +// in the 1920x1080 mockup grid and passes its `--px` along (see screen-scroller.pxUnit). + +/** A box the jelly wraps, in REAL px — the coordinate system of the canvas it is drawn on. */ +export interface JellyBox { + readonly x: number; + readonly y: number; + readonly w: number; + readonly h: number; + /** Corner radius, so the body keeps the cover's own shape rather than a generic blob's. */ + readonly r: number; +} + +/** + * How the body behaves. Every number here was settled by eye in a standalone sandbox — the values are + * the ones that read as "jelly" rather than as a box being tweened, and they are collected in one place + * because tuning them means tuning them TOGETHER. + */ +export const JELLY = { + /** How long a move takes, ms. The springs below lag behind it on purpose; this is the target's pace. */ + moveMs: 60, + /** How far the body squeezes at the half-way point, as a fraction of its box. 1 = no squeeze. */ + pinch: 0.83, + /** Spring constant pulling each point to its place on the target. */ + stiffness: 8, + /** How much of a point's speed survives each frame's damping — higher damps harder. */ + damping: 0.35, + /** Amplitude of the idle breathing, design px (the harmonics below reach about twice this). */ + wobble: 4.5, + /** How far the body stands out past the cover on every side, design px. */ + inset: 8, + /** Points around the contour. Enough that the four corners each get their own, see outlinePoint. */ + points: 36, + /** Slack around the drawn body the canvas must keep, design px (breathing and the glow reach out). */ + margin: 26, +} as const; + +/** The box for a measured card: its own rectangle, pushed out by the stand-off on every side. */ +export function jellyBoxOf( + left: number, + top: number, + width: number, + height: number, + radius: number, + unit: number, +): JellyBox { + const pad = JELLY.inset * unit; + return { + x: left - pad, + y: top - pad, + w: width + pad * 2, + h: height + pad * 2, + // The equidistant curve of a rounded rectangle is a rounded rectangle whose radius grew by the same + // distance — which is what makes the body echo the cover instead of merely alluding to it. + r: radius + pad, + }; +} + +/** + * A point on the contour at `t` ∈ [0, 1), walked by ARC LENGTH rather than by angle. + * + * The distinction is the whole reason this is a function and not a formula inline: spread by angle, all + * four corners would share barely one point between them and the spline through them would round the + * cover's shape away — the exact thing the body is supposed to keep. + */ +export function outlinePoint(box: JellyBox, t: number): readonly [number, number] { + const r = Math.min(box.r, box.w / 2, box.h / 2); + const sx = box.w - 2 * r; + const sy = box.h - 2 * r; + const arc = (Math.PI / 2) * r; + const right = box.x + box.w; + const bottom = box.y + box.h; + const wrapped = ((t % 1) + 1) % 1; + let d = wrapped * (2 * sx + 2 * sy + 4 * arc); + + // Clockwise from the top-left corner's end: top edge, then each corner as a quarter turn about its + // own centre, then the edge that follows it. `r === 0` cannot divide by zero here — a zero radius + // makes `arc` zero too, so every corner branch is skipped. + if (d < sx) return [box.x + r + d, box.y]; + d -= sx; + if (d < arc) { + const a = d / r; + return [right - r + Math.sin(a) * r, box.y + r - Math.cos(a) * r]; + } + d -= arc; + if (d < sy) return [right, box.y + r + d]; + d -= sy; + if (d < arc) { + const a = d / r; + return [right - r + Math.cos(a) * r, bottom - r + Math.sin(a) * r]; + } + d -= arc; + if (d < sx) return [right - r - d, bottom]; + d -= sx; + if (d < arc) { + const a = d / r; + return [box.x + r - Math.sin(a) * r, bottom - r + Math.cos(a) * r]; + } + d -= arc; + if (d < sy) return [box.x, bottom - r - d]; + d -= sy; + const a = d / r; + return [box.x + r - Math.cos(a) * r, box.y + r - Math.sin(a) * r]; +} + +/** + * How much the body is squeezed `progress` of the way through a move: 1 at both ends, `floor` at the + * half-way point. A bell rather than a shrink-then-grow pair of ramps — split into phases the movement + * reads as three glued steps instead of one gesture. + */ +export function pinchScale(progress: number, floor: number = JELLY.pinch): number { + const p = Math.min(1, Math.max(0, progress)); + return 1 - (1 - floor) * Math.sin(Math.PI * p); +} + +/** What the body is painted with before a palette arrives — the same seed :root carries for --d2. */ +export const FALLBACK_COLOUR = '#836e95'; + +export interface FocusJellyDeps { + /** + * Where the body belongs right now, asked once per frame — so it keeps hugging a cover that is still + * growing, and follows a grid that is still scrolling. `null` means "stay where you are": the caller + * fades the canvas out instead, which leaves the body in place for the way back. + */ + target(): JellyBox | null; + /** The fill, normally the computed `--d2`. Read per frame, so the palette crossfade carries the body. */ + colour(): string; + /** One design pixel in real px — the breathing and the glow are specified in design px. */ + unit(): number; +} + +export interface FocusJelly { + /** The selection moved: squeeze through the trip. `instant` puts the body there with no travel at all. */ + bump(instant?: boolean): void; + /** Whether frames are drawn at all. A hidden surface must not keep a canvas animating on a handheld. */ + setActive(active: boolean): void; + /** The canvas' size in CSS px (the backing store is scaled by the device ratio). */ + resize(width: number, height: number): void; +} + +interface Point { + x: number; + y: number; + vx: number; + vy: number; +} + +export function createFocusJelly(canvas: HTMLCanvasElement, deps: FocusJellyDeps): FocusJelly { + const ctx = canvas.getContext('2d'); + const pts: Point[] = Array.from({ length: JELLY.points }, () => ({ x: 0, y: 0, vx: 0, vy: 0 })); + + let active = false; + let frame = 0; + let seeded = false; + let box: JellyBox | null = null; + let moveStart = -1; + let cssW = 0; + let cssH = 0; + + function seed(to: JellyBox): void { + for (let i = 0; i < pts.length; i += 1) { + const [x, y] = outlinePoint(to, i / pts.length); + const p = pts[i]; + if (p === undefined) continue; + p.x = x; + p.y = y; + p.vx = 0; + p.vy = 0; + } + seeded = true; + } + + /** One frame of the springs. Each point is pulled to its own place on the target, not to the centre. */ + function step(dt: number, now: number, to: JellyBox): void { + const unit = deps.unit(); + const stiff = JELLY.stiffness / 1000; + const keep = 1 - JELLY.damping; + const amp = JELLY.wobble * unit; + const cx = to.x + to.w / 2; + const cy = to.y + to.h / 2; + + // Which way the body is heading, from where its points sit against where they are wanted. + let mx = 0; + let my = 0; + for (const p of pts) { + mx += p.x; + my += p.y; + } + mx = cx - mx / pts.length; + my = cy - my / pts.length; + const mlen = Math.hypot(mx, my); + const dirx = mlen === 0 ? 0 : mx / mlen; + const diry = mlen === 0 ? 0 : my / mlen; + + for (let i = 0; i < pts.length; i += 1) { + const p = pts[i]; + if (p === undefined) continue; + const [bx, by] = outlinePoint(to, i / pts.length); + // Idle breathing, pushed along the outward normal so the body swells and sags rather than sliding + // about. Three harmonics AROUND the contour, at 2, 3 and 5 waves per turn: whole numbers, or the + // wave would not meet itself where the contour closes. Low ones, and that is the point — per-point + // randomness would make a burr rather than a blob, while these stay smooth between neighbours and + // still never line up, so no two corners bulge alike and the shape keeps drifting. + const turn = (i / pts.length) * Math.PI * 2; + const wob = + amp * + (Math.sin(2 * turn + now * 0.00055) + + 0.62 * Math.sin(3 * turn - now * 0.00041 + 1.7) + + 0.44 * Math.sin(5 * turn + now * 0.00068 + 4.1)); + const nx = (bx - cx) / (to.w / 2); + const ny = (by - cy) / (to.h / 2); + const nl = Math.hypot(nx, ny); + const ox = nl === 0 ? 0 : nx / nl; + const oy = nl === 0 ? 0 : ny / nl; + const tx = bx + ox * wob; + const ty = by + oy * wob; + + // The edge FACING the move is stiffer, so it arrives first and the trailing edge is left to catch + // up — which is what stretches the body along its travel instead of sliding it as one piece. + const lead = ox * dirx + oy * diry; + const k = stiff * (1 + 0.75 * lead) * (1000 / Math.max(JELLY.moveMs, 120)) * 60; + + p.vx = (p.vx + (tx - p.x) * k * dt) * Math.pow(keep, dt * 60); + p.vy = (p.vy + (ty - p.y) * k * dt) * Math.pow(keep, dt * 60); + p.x += p.vx * dt * 60; + p.y += p.vy * dt * 60; + } + } + + function draw(now: number, to: JellyBox): void { + if (ctx === null) return; + const dpr = Math.min(window.devicePixelRatio, 2); + const w = Math.max(1, Math.round(cssW * dpr)); + const h = Math.max(1, Math.round(cssH * dpr)); + if (canvas.width !== w || canvas.height !== h) { + canvas.width = w; + canvas.height = h; + } + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, cssW, cssH); + + const squeeze = + moveStart < 0 ? 1 : pinchScale((now - moveStart) / Math.max(JELLY.moveMs, 60)); + if (moveStart >= 0 && now - moveStart >= Math.max(JELLY.moveMs, 60)) moveStart = -1; + + const cx = to.x + to.w / 2; + const cy = to.y + to.h / 2; + const at = (i: number): readonly [number, number] => { + const p = pts[((i % pts.length) + pts.length) % pts.length]; + if (p === undefined) return [cx, cy]; + return squeeze === 1 + ? [p.x, p.y] + : [cx + (p.x - cx) * squeeze, cy + (p.y - cy) * squeeze]; + }; + + // Catmull-Rom as cubic Béziers: the curve passes THROUGH the points. Quadratics through the + // midpoints would clip every corner, shrinking the body inside the cover it is meant to sit under. + ctx.beginPath(); + const [sx, sy] = at(0); + ctx.moveTo(sx, sy); + for (let i = 0; i < pts.length; i += 1) { + const [x0, y0] = at(i - 1); + const [x1, y1] = at(i); + const [x2, y2] = at(i + 1); + const [x3, y3] = at(i + 2); + ctx.bezierCurveTo( + x1 + (x2 - x0) / 6, + y1 + (y2 - y0) / 6, + x2 - (x3 - x1) / 6, + y2 - (y3 - y1) / 6, + x2, + y2, + ); + } + ctx.closePath(); + + // Flat fill, no shadow. A canvas glow was the first thing tried here and it had to go for two + // reasons: its blur is cut off square wherever the canvas ends — visible as a hard edge along the + // strip and, in the grid, along the pane, where the scroller leaves only 20 design px of headroom + // above the first row — and a wide shadowBlur is among the most expensive things a 2D context can + // do every frame, which on a handheld is the last place to spend it. + ctx.fillStyle = deps.colour(); + ctx.fill(); + } + + function tick(now: number): void { + frame = 0; + if (!active) return; + frame = window.requestAnimationFrame(tick); + // Nothing is drawn while the surface is hidden — a detail screen over the row, the Library veil on + // top of it. The check costs a style resolve; a frame of canvas work costs a great deal more, and + // on a handheld the difference is battery. The loop keeps ticking so the body is already in place + // the moment the surface comes back. + if (!canvas.checkVisibility({ opacityProperty: true, visibilityProperty: true })) return; + const to = deps.target() ?? box; + if (to !== null) { + box = to; + if (!seeded) seed(to); + step(1 / 60, now, to); + draw(now, to); + } + } + + return { + bump(instant = false): void { + const to = deps.target(); + if (instant) { + moveStart = -1; + if (to !== null) { + box = to; + seed(to); + } else { + seeded = false; + } + return; + } + moveStart = performance.now(); + }, + setActive(next: boolean): void { + if (active === next) return; + active = next; + if (active) { + if (frame === 0) frame = window.requestAnimationFrame(tick); + return; + } + if (frame !== 0) { + window.cancelAnimationFrame(frame); + frame = 0; + } + }, + resize(width: number, height: number): void { + if (cssW === width && cssH === height) return; // called from layout paths; most calls change nothing + cssW = width; + cssH = height; + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + }, + }; +} diff --git a/src/renderer/format.ts b/src/renderer/format.ts index 753d3d3c..6b2a6ffb 100644 --- a/src/renderer/format.ts +++ b/src/renderer/format.ts @@ -1,5 +1,7 @@ -// Pure display formatters for the game info panel (split out of app.ts). The translator and -// locale are passed in (kept pure): plural units go through `tp`, dates through toLocaleString. +// 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 { Locale, Translator } from '../shared/i18n/index.js'; export function formatPlaytime(totalSeconds: number, t: Translator): string { @@ -14,5 +16,66 @@ export function formatDate(iso: string | null, t: Translator, locale: Locale): s if (iso === null) return t('format.never'); const date = new Date(iso); if (Number.isNaN(date.getTime())) return t('format.unknown'); - return date.toLocaleString(locale === 'ru' ? 'ru-RU' : 'en-GB'); + return date.toLocaleString(intlLocale(locale)); +} + +function intlLocale(locale: Locale): string { + return locale === 'ru' ? 'ru-RU' : 'en-GB'; +} + +/** + * What one notification SAYS. Built here rather than stored with the notification, because the UI + * language changes live and a stored string would be frozen at the language of the moment it was + * written. The switch is exhaustive over the union, so a new kind fails the typecheck rather than + * rendering as nothing. + */ +export function formatNotification(item: AppNotification, t: Translator): string { + switch (item.kind) { + case 'update-ready': + return t('notifications.updateReady', { version: item.version }); + case 'game-installed': + return t('notifications.gameInstalled', { title: item.gameTitle }); + case 'game-uninstalled': + return t('notifications.gameUninstalled', { title: item.gameTitle }); + case 'game-added-deferred': + return t('notifications.gameAddedDeferred', { title: item.gameTitle }); + case 'game-moved-deferred': + return t('notifications.gameMovedDeferred', { title: item.gameTitle }); + case 'game-move-save-skipped': + return t('notifications.gameMoveSaveSkipped', { title: item.gameTitle }); + case 'game-move-duplicate': + return t('notifications.gameMoveDuplicate', { title: item.gameTitle }); + case 'settings-write-failed': + return t('notifications.settingsWriteFailed'); + } +} + +/** + * WHEN a notification arrived, as the list shows it: the time alone for today, a named "yesterday" for + * the day before, and a plain date for anything older. `now` is a parameter rather than Date.now() so + * the rule is testable — and "today" means the same CALENDAR day, not "less than 24 hours ago", which + * is what a reader means by it. + * + * formatDate above is no substitute: it takes an ISO string (this carries epoch ms) and always prints + * the full date and time, which is far too much beside a one-line notification. + */ +export function formatNotificationTime( + at: number, + now: number, + t: Translator, + locale: Locale, +): string { + const loc = intlLocale(locale); + const date = new Date(at); + const time = date.toLocaleTimeString(loc, { hour: '2-digit', minute: '2-digit' }); + const days = calendarDaysBetween(date, new Date(now)); + if (days === 0) return time; + if (days === 1) return t('notifications.yesterday', { time }); + return date.toLocaleDateString(loc); +} + +/** Whole calendar days from `then` to `now`, in LOCAL time (both are normalized to local midnight). */ +function calendarDaysBetween(then: Date, now: Date): number { + const startOfDay = (d: Date): number => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); + return Math.round((startOfDay(now) - startOfDay(then)) / 86_400_000); } diff --git a/src/renderer/game-settings-model.ts b/src/renderer/game-settings-model.ts new file mode 100644 index 00000000..abe62bda --- /dev/null +++ b/src/renderer/game-settings-model.ts @@ -0,0 +1,831 @@ +// Pure (DOM-free, electron-free) declaration of the launcher's Customize screen: one game's form state +// plus its environment in, a list of sections and rows out. The same split settings-form-model.ts +// established — everything that decides WHAT is on screen (order, visibility, which launch modes this +// source allows, which field a Browse belongs to) is testable in vitest, while the DOM and the navigation +// stay in the view and the controller. +// +// The form state itself is NOT redefined here: it is `ManifestFormModel` from configure-form-model.ts, +// the same pure text ⇄ model bridge the Configure window used, with its `rest` / `corrupt` escape hatches +// intact. This module only decides how that state is PRESENTED. +import { + MAX_HERO_IMAGES, + type ConfigPickKind, + type HostPlatform, + type ManifestSource, +} from '../shared/types'; +import type { MessageKey } from '../shared/i18n/index'; +import { + movedGridAssetPath, + movedHeroAssetPath, + movedMusicAssetPath, +} from '../shared/asset-move-names'; +import { emptyFormModel } from './configure-form-model'; +import type { InstallType, LaunchMode, ManifestFormModel } from './configure-form-model'; +import type { + RowLabel, + CoreActionRow, + CoreListRow, + CoreNoteRow, + CoreNumberRow, + CoreOption, + CorePathRow, + CoreSelectRow, + CoreStaticRow, + CoreTextRow, + CoreToggleRow, +} from './row-view-core'; + +/** + * Every row of this screen, named by the manifest path it edits (or by what it is, for the ones that edit + * nothing). The dotted names are deliberate: they are the same paths the validator reports issues under, + * so mapping an issue onto a row is a lookup rather than a translation table. + */ +export type GameRowId = + | 'source' + | 'title' + | 'id' + | 'launchMode' + | 'pc.executable' + | 'executable' + | 'args' + | 'runAsAdmin' + | 'copyToPc' + | 'copyInstall.installer' + | 'install.installer' + | 'install.type' + | 'install.runAsAdmin' + | 'install.args' + | 'install.winetricks' + | 'steam.appid' + | 'watchProcesses' + | 'heroImage' + | 'gridImage' + | 'saveOnCard' + | 'pcSavePath' + | 'backgroundMusic' + | 'launchTimeoutSec' + | 'killTimeoutSec' + | 'winetricks' + | 'umuGameId' + | 'find-online' + | 'note.mixed' + | 'note.idChanged' + | 'note.otherIssues' + | 'note.cannotSave' + | 'note.status' + | 'save' + | 'reset' + | 'move-to-card' + | 'delete' + | 'close'; + +export type GameSettingsRow = + | CoreTextRow<GameRowId> + | CoreNumberRow<GameRowId> + | CorePathRow<GameRowId> + | CoreListRow<GameRowId> + | CoreSelectRow<GameRowId> + | CoreStaticRow<GameRowId> + | CoreNoteRow<GameRowId> + | CoreActionRow<GameRowId> + | CoreToggleRow<GameRowId>; + +export interface GameSettingsSection { + readonly titleKey?: MessageKey; + readonly rows: readonly GameSettingsRow[]; +} + +export interface GameSettingsModel { + readonly sections: readonly GameSettingsSection[]; + /** Shown beside the screen title — the game's own name, so the screen says whose settings these are. */ + readonly title: string; + /** + * Where the manifest came from, for the header line beside the title. It used to be a row of its own, + * which put a read-only fact in the middle of the editable ones; up in the header it answers "whose + * file am I editing?" at a glance, which is the only question it was ever there to answer. + */ + readonly source: RowLabel; +} + +/** Everything the model needs beyond the form state itself. */ +export interface GameSettingsEnv { + /** + * What the screen is doing with this form: editing a game that exists (`edit`), or creating one + * (`add`). An explicit discriminant rather than "sources is non-empty": the mode decides the heading, + * the Save wording, whether Discard/Delete exist and whether the source row is there at all, and + * 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 + * 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). */ + readonly sources: readonly CoreOption[]; + /** + * The label of the chosen source, for the header line. Null in edit mode, where the header falls back + * to the root itself — a candidate's label ("E:\\ — 3 games") only exists while the list is loaded. + */ + readonly sourceLabel: string | null; + /** Which dialect this manifest speaks — it decides the launch modes on offer. */ + readonly source: ManifestSource; + /** Which OS the launcher runs on — see the Linux section below, and HostPlatform. */ + readonly platform: HostPlatform; + /** The root the manifest was read from, shown as the game's origin (a mountpoint, or "This PC"). */ + readonly root: string; + /** + * The id the game was READ with. A change orphans everything keyed by it on this PC — the play stats, + * the save backups, the history record — so the screen warns before the save rather than after. + */ + readonly loadedId: string; + /** The source file carried blocks for more than one launch mode; saving drops the others. */ + readonly mixed: boolean; + /** This game's validation problems, by the field path the validator reported (already localized). */ + readonly issues: ReadonlyMap<string, string>; + /** Problems in OTHER games of a multi-game file, already worded for display (see the plan, Э4). */ + readonly otherIssues: readonly string[]; + /** A status line under the actions: what the last save did, or why Save is unavailable. */ + readonly status: string | null; + /** Whether Save may run at all (the validator is happy about OUR slot). */ + readonly canSave: boolean; + /** Whether there is anything to save or discard. */ + readonly dirty: boolean; + /** + * Whether Delete is offered: hidden while the game is running/installing (the launcher would be left + * holding a manifest the file no longer has) and for the LAST game on a card (which would leave the + * card without a manifest at all — the rule the Configure window already enforced). + */ + readonly canDelete: boolean; + /** + * Whether "Move to card…" may be started right now: a local (PC-library) game, in edit mode, not busy + * (same guard as Delete), and no move already pending — computed by the screen, which alone knows + * about PendingMove. + */ + readonly canMove: boolean; +} + +/** + * 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). + */ +export function launchModesFor(source: ManifestSource): readonly LaunchMode[] { + return source === 'pc' ? ['pc', 'steam', 'none'] : ['executable', 'installer', 'steam']; +} + +/** The mode a blank form of this source starts in — the only one that would validate. A fresh local + * game defaults to `pc`, not the `none` draft state — leaving it unconfigured is something the user + * chooses, not the form's starting point. */ +export function defaultLaunchMode(source: ManifestSource): LaunchMode { + return source === 'pc' ? 'pc' : 'executable'; +} + +/** + * The mode a freshly-parsed EXISTING manifest should actually show, correcting for what + * `textToFormModel` cannot know (it has no `source`): given a PC-library manifest with none of the four + * launch blocks, it defaults `launchMode` to `'executable'` — indistinguishable, from the form alone, + * from a genuinely blank CARD form (where `'executable'` is exactly right). The screen calls this right + * after `textToFormModel`/`textToGames` and uses its result instead of `model.launchMode`. + */ +export function draftModeFor(model: ManifestFormModel, source: ManifestSource): LaunchMode { + const hasAnyLaunchBlock = + model.pc.executable !== '' || + model.steam.appid !== '' || + model.executable !== '' || + model.install.installer !== '' || + model.copyToPc || + model.copyInstall.installer !== ''; + return source === 'pc' && !hasAnyLaunchBlock ? 'none' : model.launchMode; +} + +const LAUNCH_MODE_LABEL: Readonly<Record<LaunchMode, MessageKey>> = { + executable: 'gameSettings.modeExecutable', + installer: 'gameSettings.modeInstaller', + steam: 'gameSettings.modeSteam', + pc: 'gameSettings.modePc', + none: 'gameSettings.modeNone', +}; + +const INSTALL_TYPE_OPTIONS: readonly CoreOption[] = [ + { value: 'nsis', labelKey: 'gameSettings.installNsis' }, + { value: 'inno', labelKey: 'gameSettings.installInno' }, + { value: 'custom', labelKey: 'gameSettings.installCustom' }, +]; + +/** + * Which Browse a row opens, if any. Derived rather than stored on the row because two of them depend on + * the CURRENT launch mode: a local game's save folder is an ordinary host directory (`pc-save-local`), + * while a local Steam game's lives inside Steam's Proton prefix and only the `%PREFIX%` form can name it + * (`pc-save`) — see ConfigPickKind. + */ +export function pickKindFor( + id: GameRowId, + mode: LaunchMode, + source: ManifestSource, +): ConfigPickKind | null { + switch (id) { + case 'executable': + return 'executable'; + case 'pc.executable': + return 'pc-executable'; + case 'install.installer': + return 'installer'; + case 'copyInstall.installer': + return 'directory'; + case 'heroImage': + case 'gridImage': + return 'image'; + case 'backgroundMusic': + return 'audio'; + case 'saveOnCard': + return 'directory'; + case 'pcSavePath': + return source === 'pc' && mode === 'pc' ? 'pc-save-local' : 'pc-save'; + default: + return null; + } +} + +/** + * The manifest paths a row owns, for mapping a validator issue onto it. + * + * Matched by prefix as well as exactly, because the validator names the exact spot INSIDE a value and a + * row owns the whole value: a bad process name comes back as `watchProcesses.0`, one bad launch argument + * as `install.args.2`. The row that holds the list is where the user goes to fix either of them, so an + * issue one level in has to land on it — otherwise the screen refuses to save over a problem it never + * points at. + */ +function issueOf( + issues: ReadonlyMap<string, string>, + ...paths: readonly string[] +): string | undefined { + for (const path of paths) { + const message = issues.get(path); + if (message !== undefined) return message; + } + // Second pass, so an exact owner always wins over one that merely contains the path. + for (const path of paths) { + const inside = `${path}.`; + for (const [candidate, message] of issues) { + if (candidate.startsWith(inside)) return message; + } + } + return undefined; +} + +/** + * The whole screen as data. A row that does not apply to the current launch mode is ABSENT rather than + * disabled — the same rule the Settings model follows, and the same one the Configure form followed with + * its hidden sections. The state behind a hidden row is not lost: it lives on in the form model until + * serialization (see ManifestFormModel), so switching modes and back restores what was typed. + */ +export function buildGameSettingsModel( + form: ManifestFormModel, + env: GameSettingsEnv, +): GameSettingsModel { + const mode = form.launchMode; + const isPcSource = env.source === 'pc'; + const error = (...paths: readonly string[]): { readonly error?: string } => { + const message = issueOf(env.issues, ...paths); + return message === undefined ? {} : { error: message }; + }; + + const basics: GameSettingsRow[] = []; + // Add mode asks WHERE first: every path below is read against that root, and the launch modes on offer + // come from it. It is a row rather than a wizard step — the screen already knows how to show a select, + // and reading order is enough to say "this one first". + if (env.mode === 'add') { + basics.push({ + kind: 'select', + id: 'source', + label: { key: 'gameSettings.source' }, + value: env.root, + options: env.sources, + hint: { key: 'gameSettings.sourceHint' }, + }); + } + basics.push({ + kind: 'text', + id: 'title', + label: { key: 'gameSettings.title' }, + value: form.title, + placeholder: { key: 'gameSettings.notSet' }, + ...error('title'), + }); + // 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. + // Renaming stays available as an ordinary edit, before or after the move. + if (!env.move) { + basics.push({ + kind: 'text', + id: 'id', + label: { key: 'gameSettings.id' }, + value: form.id, + placeholder: { key: 'gameSettings.notSet' }, + ...error('id'), + }); + // The id is the key of everything this PC remembers about the game; changing it orphans all of it. + if (env.loadedId !== '' && form.id !== env.loadedId) { + basics.push({ + kind: 'note', + id: 'note.idChanged', + text: { key: 'gameSettings.idChangedWarning' }, + tone: 'warning', + }); + } + } + + const launch: GameSettingsRow[] = []; + if (env.mixed) { + launch.push({ + kind: 'note', + id: 'note.mixed', + text: { key: 'gameSettings.mixedLaunchModes' }, + tone: 'warning', + }); + } + launch.push({ + kind: 'select', + id: 'launchMode', + label: { key: 'gameSettings.launchMode' }, + value: mode, + options: launchModesFor(env.source).map((candidate) => ({ + value: candidate, + labelKey: LAUNCH_MODE_LABEL[candidate], + })), + ...(mode === 'none' ? { hint: { key: 'gameSettings.modeNoneHint' as const } } : {}), + }); + if (mode === 'pc') { + launch.push({ + kind: 'path', + id: 'pc.executable', + label: { key: 'gameSettings.pcExecutable' }, + value: form.pc.executable, + placeholder: { key: 'gameSettings.notSet' }, + ...error('pc.executable', 'pc'), + }); + } + if (mode === 'executable' || mode === 'installer') { + // What `executable` is RELATIVE TO depends on whether anything gets installed or copied first, and + // saying "relative to the card root" in the other two cases is simply false: with an install block + // present — an installer, or the copy checkbox — the manifest resolves it under the install + // directory (manifest.ts, `<installDir>/<executable>`), which holds what was installed or copied. + const executableHint: MessageKey = + mode === 'installer' + ? 'gameSettings.executableInstallHint' + : form.copyToPc + ? 'gameSettings.executableCopyHint' + : 'gameSettings.executableHint'; + launch.push({ + kind: 'path', + id: 'executable', + label: { key: 'gameSettings.executable' }, + value: form.executable, + placeholder: { key: 'gameSettings.notSet' }, + hint: { key: executableHint }, + ...error('executable'), + }); + } + if (mode !== 'steam') { + launch.push({ + kind: 'list', + id: 'args', + label: { key: 'gameSettings.args' }, + items: form.args, + max: 0, + placeholder: { key: 'gameSettings.listEmpty' }, + ...error('args'), + }); + } + // runAsAdmin elevates a specific executable — meaningless before one is chosen, unlike `args`, which + // the user may legitimately want to pre-fill ahead of picking a launch method. + if (mode !== 'steam' && mode !== 'none') { + launch.push({ + kind: 'toggle', + id: 'runAsAdmin', + label: { key: 'gameSettings.runAsAdmin' }, + value: form.runAsAdmin, + ...error('runAsAdmin'), + }); + } + if (mode === 'executable') { + launch.push({ + kind: 'toggle', + id: 'copyToPc', + label: { key: 'gameSettings.copyToPc' }, + value: form.copyToPc, + hint: { key: 'gameSettings.copyToPcHint' }, + }); + if (form.copyToPc) { + launch.push({ + kind: 'path', + id: 'copyInstall.installer', + label: { key: 'gameSettings.copyDirectory' }, + value: form.copyInstall.installer, + placeholder: { key: 'gameSettings.notSet' }, + hint: { key: 'gameSettings.copyDirectoryHint' }, + ...error('install.installer', 'install'), + }); + } + } + if (mode === 'installer') { + launch.push({ + kind: 'path', + id: 'install.installer', + label: { key: 'gameSettings.installer' }, + value: form.install.installer, + placeholder: { key: 'gameSettings.notSet' }, + ...error('install.installer'), + }); + launch.push({ + kind: 'select', + id: 'install.type', + label: { key: 'gameSettings.installType' }, + value: form.install.type, + options: INSTALL_TYPE_OPTIONS, + ...error('install.type'), + }); + // A `custom` installer is run by the user, not by us, so elevation is not ours to ask for: the + // manifest's own superRefine rejects the pair, and the form must not be able to produce it. + launch.push({ + kind: 'toggle', + id: 'install.runAsAdmin', + label: { key: 'gameSettings.installRunAsAdmin' }, + value: form.install.type === 'custom' ? false : form.install.runAsAdmin, + disabled: form.install.type === 'custom', + ...(form.install.type === 'custom' + ? { hint: { key: 'gameSettings.installCustomHint' } } + : {}), + ...error('install.runAsAdmin'), + } satisfies GameSettingsRow); + launch.push({ + kind: 'list', + id: 'install.args', + label: { key: 'gameSettings.installArgs' }, + items: form.install.args, + max: 0, + placeholder: { key: 'gameSettings.listEmpty' }, + ...error('install.args'), + }); + } + if (mode === 'steam') { + launch.push({ + kind: 'number', + id: 'steam.appid', + label: { key: 'gameSettings.steamAppid' }, + value: form.steam.appid, + placeholder: { key: 'gameSettings.notSet' }, + step: 1, + min: 1, + max: Number.MAX_SAFE_INTEGER, + hint: { key: 'gameSettings.steamAppidHint' }, + ...error('steam.appid', 'steam'), + }); + } + launch.push({ + kind: 'list', + id: 'watchProcesses', + label: { key: 'gameSettings.watchProcesses' }, + items: form.watchProcesses, + max: 0, + placeholder: { key: 'gameSettings.listEmpty' }, + hint: { key: 'gameSettings.watchProcessesHint' }, + ...error('watchProcesses'), + }); + + const images: GameSettingsRow[] = [ + { + kind: 'list', + id: 'heroImage', + label: { key: 'gameSettings.heroImage' }, + items: form.heroImage, + max: MAX_HERO_IMAGES, + placeholder: { key: 'gameSettings.listEmpty' }, + preview: 'wide', + ...error('heroImage'), + }, + { + kind: 'path', + id: 'gridImage', + label: { key: 'gameSettings.gridImage' }, + value: form.gridImage, + placeholder: { key: 'gameSettings.gridImageAuto' }, + preview: 'portrait', + ...error('gridImage'), + }, + ]; + + // Where the game writes first, then where that gets copied — the order the progress itself travels in. + const saves: GameSettingsRow[] = [ + { + kind: 'path', + id: 'pcSavePath', + label: { key: 'gameSettings.pcSavePath' }, + value: form.pcSavePath, + placeholder: { key: 'gameSettings.notSet' }, + ...error('pcSavePath'), + }, + ]; + if (!isPcSource) { + saves.push({ + kind: 'path', + id: 'saveOnCard', + label: { key: 'gameSettings.saveOnCard' }, + value: form.saveOnCard, + placeholder: { key: 'gameSettings.notSet' }, + hint: { key: 'gameSettings.saveOnCardHint' }, + ...error('saveOnCard'), + }); + } + + const advanced: GameSettingsRow[] = [ + { + kind: 'number', + id: 'launchTimeoutSec', + label: { key: 'gameSettings.launchTimeout' }, + value: form.launchTimeoutSec, + placeholder: { key: 'gameSettings.defaultSeconds30' }, + step: 5, + min: 1, + max: 3600, + hint: { key: 'gameSettings.launchTimeoutHint' }, + ...error('launchTimeoutSec'), + }, + { + kind: 'number', + id: 'killTimeoutSec', + label: { key: 'gameSettings.killTimeout' }, + value: form.killTimeoutSec, + placeholder: { key: 'gameSettings.defaultSeconds60' }, + step: 5, + min: 1, + max: 3600, + hint: { key: 'gameSettings.killTimeoutHint' }, + ...error('killTimeoutSec'), + }, + ]; + + // The Proton fields, in a section of their own rather than mixed into Advanced: they are a different + // subject, and on a PC-library game outside Linux they are not even a subject — see `platform` in the env. + const linux: GameSettingsRow[] = [ + { + kind: 'list', + id: 'winetricks', + label: { key: 'gameSettings.winetricks' }, + items: form.winetricks, + max: 0, + placeholder: { key: 'gameSettings.listEmpty' }, + hint: { key: 'gameSettings.winetricksHint' }, + ...error('winetricks'), + }, + ]; + if (mode === 'installer') { + linux.push({ + kind: 'list', + id: 'install.winetricks', + label: { key: 'gameSettings.installWinetricks' }, + items: form.install.winetricks, + max: 0, + placeholder: { key: 'gameSettings.listEmpty' }, + ...error('install.winetricks'), + }); + } + linux.push({ + kind: 'text', + id: 'umuGameId', + label: { key: 'gameSettings.umuGameId' }, + value: form.umuGameId, + placeholder: { key: 'gameSettings.umuGameIdAuto' }, + hint: { key: 'gameSettings.umuGameIdHint' }, + ...error('umuGameId'), + }); + /** + * A game installed on THIS PC is run through Proton only when this PC is the Deck, so outside Linux a + * local game has no Linux side at all. A CARD keeps the section on every OS — its fields describe a + * future launch on the Deck, which is exactly why they are editable from a Windows (and now a macOS) + * desktop. Phrased as "hide it only for a non-Linux pc-source" so adding macOS could not quietly take + * the section away from Windows cards. + */ + const showsLinux = !(isPcSource && env.platform !== 'linux'); + + const actions: GameSettingsRow[] = []; + // A multi-game file's OTHER games are named but not editable from here — the user still has to know + // the file is not clean, because that is what a red status after Save would otherwise be about. + for (const message of env.otherIssues) { + actions.push({ kind: 'note', id: 'note.otherIssues', text: { text: message }, tone: 'error' }); + } + if (env.status !== null) { + actions.push({ kind: 'note', id: 'note.status', text: { text: env.status }, tone: 'info' }); + } + // Why Save is inert, said next to the button. Without this the button is simply dead, and the reason + // may well be a row that has scrolled off the top of a thirty-field form. + if (!env.canSave && env.dirty) { + actions.push({ + kind: 'note', + id: 'note.cannotSave', + text: { key: 'gameSettings.cannotSave' }, + tone: 'error', + }); + } + // Above Save, in the column rather than in Basics: the flow fills the title, the cover, the backgrounds + // and the music — four fields across three sections — so it belongs to the GAME, not to the section + // whose first field it happens to touch. + actions.push({ + kind: 'action', + id: 'find-online', + label: { key: 'metadata.findOnline' }, + }); + actions.push({ + kind: 'action', + id: 'save', + label: { + key: env.move + ? 'gameSettings.moveToCard' + : env.mode === 'add' + ? 'gameSettings.add' + : 'gameSettings.save', + }, + disabled: !env.canSave || !env.dirty, + }); + // "Discard edits" re-reads the manifest to get the game back as it was — in add mode there is no such + // game, and mid-move it would drop the very thing being set up (Back/cancel-move is that path instead). + if (env.mode === 'edit' && !env.move) { + actions.push({ + kind: 'action', + id: 'reset', + label: { key: 'gameSettings.reset' }, + disabled: !env.dirty, + }); + } + if (env.canMove) { + actions.push({ + kind: 'action', + id: 'move-to-card', + label: { key: 'gameSettings.moveToCard' }, + }); + } + if (env.canDelete) { + actions.push({ + kind: 'action', + id: 'delete', + label: { key: 'gameSettings.delete' }, + danger: true, + }); + } + actions.push({ kind: 'action', id: 'close', label: { key: 'launcher.menu.close' } }); + + return { + title: form.title, + // The candidate's own label when there is one ("E:\\ — 3 games"), so the header says the same thing + // the source row does; a bare mountpoint is what it falls back to, as in edit mode. + source: + env.sourceLabel !== null + ? { text: env.sourceLabel } + : isPcSource + ? { key: 'gameConfig.thisPc' } + : { text: env.root }, + sections: [ + { titleKey: 'gameSettings.sectionBasics', rows: basics }, + { titleKey: 'gameSettings.sectionLaunch', rows: launch }, + { titleKey: 'gameSettings.sectionImages', rows: images }, + { titleKey: 'gameSettings.sectionSaves', rows: saves }, + { + titleKey: 'gameSettings.sectionAudio', + rows: [ + { + kind: 'path', + id: 'backgroundMusic', + label: { key: 'gameSettings.backgroundMusic' }, + value: form.backgroundMusic, + placeholder: { key: 'gameSettings.musicNone' }, + ...error('backgroundMusic'), + }, + ], + }, + { titleKey: 'gameSettings.sectionAdvanced', rows: advanced }, + ...(showsLinux ? [{ titleKey: 'gameSettings.sectionLinux' as const, rows: linux }] : []), + // No title: the last section is the screen's action stack, like the Settings screen's. + { rows: actions }, + ], + }; +} + +/** 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 }; +} + +/** Applies a new installer family, forcing off the elevation `custom` may not carry. */ +export function withInstallType(form: ManifestFormModel, type: InstallType): ManifestFormModel { + const runAsAdmin = type === 'custom' ? false : form.install.runAsAdmin; + return { ...form, install: { ...form.install, type, runAsAdmin } }; +} + +/** + * Whether anything in the form would be LOST by moving the new game to another source — the paths and the + * install block below. It is what decides whether switching the source asks first: a form where only the + * name has been typed has nothing to lose, and a confirm there is a question about nothing. + */ +export function hasSourceBoundValues(form: ManifestFormModel): boolean { + return ( + form.executable !== '' || + form.pc.executable !== '' || + form.install.installer !== '' || + form.install.args.length > 0 || + form.install.winetricks.length > 0 || + form.install.runAsAdmin || + form.copyToPc || + form.copyInstall.installer !== '' || + form.heroImage.length > 0 || + form.gridImage !== '' || + form.backgroundMusic !== '' || + form.saveOnCard !== '' || + form.pcSavePath !== '' + ); +} + +/** + * Moves a half-filled ADD form to another source. What survives is what the source has no say over — the + * name, the arguments, the timeouts, the Steam appid, the Linux fields; what goes is everything measured + * against the old root or meaningless on the new one: + * + * • the executables and the artwork/music paths — relative to a root that is not this one any more + * (a card path on this PC, or the reverse, points at nothing); + * • the whole install block and "move to PC" — an installer is something a CARD carries; + * • `saveOnCard`, which the PC library forbids outright, and `pcSavePath`, which is a `%PREFIX%/…` + * string for a card game and an absolute host path for a local one; + * • the launch mode, but ONLY when the new source does not allow it — in practice `steam` is the one + * mode that survives the move in either direction. + * + * Wiping the lot would be simpler, and would mean re-typing the title on a gamepad keyboard because a + * radio button was changed. + */ +/** + * Moves a LOADED PC-library form onto a card (Р2.2 — 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 + * destination (see asset-move-names.ts) — main copies the actual files under those same names, so the + * renderer never has to wait for a copy to finish before it can show a valid target manifest. + * + * What is dropped: `pc.executable` (an absolute path is forbidden on a card), `saveOnCard`/install/ + * copyToPc (meaningless coming FROM a source with none of them). `launchMode`: `steam` survives (an + * appid names a game, not a place on disk); `pc`/`none` becomes `executable` — the user fills in the + * card-relative path themselves, which is the whole point of the screen staying open after the target + * is chosen. + * + * `pcSavePath` is the one field that is CONDITIONALLY kept: on a card it must be a `%PREFIX%/…` string, + * while the PC library also accepts a bare absolute path (a local game's saves typically sit right next + * to its .exe). A `%PREFIX%` value is already exactly what the card format wants, so it survives as-is; + * an absolute one is dropped here (this function is pure — it cannot ask main to convert it), but the + * screen follows up with that conversion once a target is actually chosen — see `adoptMoveTarget`. + */ +export function carryFormToCard(form: ManifestFormModel): ManifestFormModel { + return { + ...emptyFormModel(form.launchMode === 'steam' ? 'steam' : 'executable'), + id: form.id, + title: form.title, + args: form.args, + runAsAdmin: form.runAsAdmin, + watchProcesses: form.watchProcesses, + // Sliced to the same cap the manifest reader applies (manifest.ts drops everything past + // MAX_HERO_IMAGES): main copies the files by the RESOLVED manifest, so a fourth entry carried into the + // target text would name a file nothing ever puts there. + heroImage: form.heroImage + .slice(0, MAX_HERO_IMAGES) + .map((source, index) => movedHeroAssetPath(form.id, index, source)), + gridImage: form.gridImage === '' ? '' : movedGridAssetPath(form.id, form.gridImage), + backgroundMusic: + form.backgroundMusic === '' ? '' : movedMusicAssetPath(form.id, form.backgroundMusic), + pcSavePath: form.pcSavePath.startsWith('%') ? form.pcSavePath : '', + launchTimeoutSec: form.launchTimeoutSec, + killTimeoutSec: form.killTimeoutSec, + winetricks: form.winetricks, + umuGameId: form.umuGameId, + steam: form.steam, + }; +} + +export function carryFormAcrossSources( + form: ManifestFormModel, + next: ManifestSource, +): ManifestFormModel { + const blank = emptyFormModel(defaultLaunchMode(next)); + const launchMode = launchModesFor(next).includes(form.launchMode) + ? form.launchMode + : defaultLaunchMode(next); + return { + ...blank, + launchMode, + id: form.id, + title: form.title, + args: form.args, + runAsAdmin: form.runAsAdmin, + watchProcesses: form.watchProcesses, + launchTimeoutSec: form.launchTimeoutSec, + killTimeoutSec: form.killTimeoutSec, + winetricks: form.winetricks, + umuGameId: form.umuGameId, + steam: form.steam, + }; +} diff --git a/src/renderer/game-settings-screen.ts b/src/renderer/game-settings-screen.ts new file mode 100644 index 00000000..a621552b --- /dev/null +++ b/src/renderer/game-settings-screen.ts @@ -0,0 +1,2705 @@ +// The Customize screen's controller — the launcher's per-game manifest editor, and the fifth surface of +// the UI. It owns the loaded manifest, the form state, the row focus and the stack of surfaces that open +// on top of it, and exposes the SAME six navigation primitives everything else does, so controls.ts only +// has to route to it. +// +// 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 +// raw slot, so saving one game never destroys another. +// +// • SAVING IS EXPLICIT. Unlike Settings, a value change writes nothing: every keystroke would mean a +// write to removable media plus a manifest reload, and an intermediate invalid state cannot be written +// at all. Save & Apply is gated on the validator; Reset re-reads from disk; leaving while dirty asks. +// +// • ONE data-overlay, A STACK OF SURFACES. `#app[data-overlay='game-settings']` is a single attribute +// value carrying every CSS rule that makes this screen visible, so the keyboard and the file picker +// are NOT overlays of their own (switching the value would extinguish the screen under them). They are +// 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, + ConfigValidationResult, + DriveCandidate, + GameConfigAcceptRequest, + GameConfigReadResult, + GameConfigSaveRequest, + GameMoveRequest, + GameCandidate, + GameDetails, + HostPlatform, + ManifestSource, + MetadataApplyRequest, + MetadataApplyResult, + MetadataApplySlot, + MetadataResult, +} from '../shared/types'; +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 { 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 type { ApplyOutcome, OnlinePickerSurface } from './online-picker.js'; +import { + emptyFormModel, + gamesToText, + isRawSlot, + slotsWithInsertedGame, + slotsWithNewGame, + textToGames, + type GameFormState, + type InstallType, + type LaunchMode, + type ManifestFormModel, +} from './configure-form-model.js'; +import { + buildGameSettingsModel, + carryFormAcrossSources, + carryFormToCard, + defaultLaunchMode, + draftModeFor, + hasSourceBoundValues, + pickKindFor, + withInstallType, + withLaunchMode, + type GameRowId, + type GameSettingsModel, + type GameSettingsRow, +} from './game-settings-model.js'; +import { + applyThumbnails, + isFocusable, + patchGameRow, + relocalizeGameRow, + relocalizeGameSections, + renderGameSettings, + screenHeading, + 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; + +/** What the screen sends to main. A seam, so app.ts owns the window.api wiring. */ +export interface GameSettingsScreenApi { + read(id: string): Promise<GameConfigReadResult>; + validate(root: string, text: string): Promise<ConfigValidationResult>; + save(request: GameConfigSaveRequest): Promise<ConfigSaveResult>; + imagePreview(root: string, path: string): Promise<string | null>; + /** Where a new game may be added — the cards plus the PC library (add mode only). */ + sources(): Promise<readonly DriveCandidate[]>; + /** One root's manifest, for adding a game to it — it may carry no game yet (add mode only). */ + readRoot(root: string): Promise<ConfigRootReadResult>; + /** + * Drops the game's HISTORY record — its card in the carousel and the artwork copied to this PC. Only + * 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). */ + moveToCard(request: GameMoveRequest): Promise<ConfigMoveResult>; + /** 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 + * game onto a card, without making the user re-pick the same folder. */ + acceptPath(request: GameConfigAcceptRequest): Promise<ConfigPickResult>; + + // ── "Find online" (see main/metadata/) ── + /** Search every online source for a game by title. */ + searchMetadata(query: string): Promise<MetadataResult<readonly GameCandidate[]>>; + /** The candidate behind a Steam appid the manifest already names — no search needed. */ + requestSteamCandidate(appId: number): Promise<MetadataResult<GameCandidate>>; + /** The candidate's descriptions, genres, release date and platforms — carried into the manifest + * through the form's `rest` (see GameDetails). */ + metadataDescriptions(candidateKey: string): Promise<MetadataResult<GameDetails>>; + /** Downloads the chosen variant into the game's root; answers with the manifest-relative path. */ + applyMetadata(request: MetadataApplyRequest): Promise<MetadataApplyResult>; + /** Aborts whatever main is still fetching (the user left the flow). */ + cancelMetadata(): void; +} + +/** + * The questions this screen asks through the launcher's shared confirm popup. Deleting is TWO of them: + * `delete` removes the game from the manifest and leaves its card in the history, `delete-history` takes + * the card too. Which one arrives back is the user's answer to the second question — see controls.ts. + */ +export type GameSettingsConfirm = + | 'reset' + | 'delete' + | 'delete-history' + | 'discard' + | 'switch-source' + | 'cancel-move' + // 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: { + 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; + 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). */ + readonly keyboard: TextEntrySurface; + /** The in-launcher file browser — the native dialog cannot be driven in Game Mode (Р5). */ + readonly picker: FilePickerSurface; + /** The online artwork gallery — the surface "Find online" picks a cover or a background in. */ + readonly onlinePicker: OnlinePickerSurface; + /** The screen closed itself (B / Esc / veil) — controls.ts restores the bar focus. */ + onClosed(): void; + /** Asks the shared confirm popup; the answer arrives back through confirmAccepted. */ + /** + * Opens the launcher's confirm popup for one of this screen's questions. `options.title` is the name + * the question QUOTES — the popup builds its other messages from the open game, but "replace the + * 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; + /** A game was added AND applied: the launcher's library has it now, so the carousel goes to it. */ + onAdded(id: string): void; + /** + * The launcher's own two channels, used for everything this screen has to SAY. A confirmation is the + * notification plate (top-right, goes by itself); a failure is the error popup, which waits to be + * closed — the same split the "Find online" surface makes, and for the same reason: a save that failed + * must not scroll away with the form. + */ + notify(text: string): void; + showError(text: string): void; +} + +export interface GameSettingsScreen extends NavSurface { + /** Opens the screen for one game, reading its manifest. */ + open(id: string): void; + /** 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). */ + applyBrowse(browse: BrowseInfo | null): void; + /** The confirm popup answered yes for `kind`. */ + confirmAccepted(kind: GameSettingsConfirm): void; + /** Whether there are unsaved edits (controls.ts wording for the leave confirm). */ + isDirty(): boolean; + /** Whether the loaded game is a LOCAL one — its save backups outlive a deletion, and the confirm says so. */ + deletesLocalGame(): boolean; + + // What the "Find online" surface cannot do for itself: this screen owns the form, the files that land + // beside the game, and the on-screen keyboard. The surface asks; these answer. + + /** Opens the keyboard for a new search query. */ + askOnlineQuery(initial: string, onDone: (query: string) => void): void; + /** + * Asks the launcher's confirm popup whether the store's spelling may replace the Title field. Routed + * through this screen because that popup answers to `confirmAccepted`, which is this screen's channel. + */ + askOnlineTitle(title: string, onYes: () => void): void; + /** Downloads the chosen pictures into the game and writes their paths into the form. */ + applyOnlineArtwork( + kind: 'grid' | 'hero', + variantKeys: readonly string[], + mode: 'replace' | 'append', + ): Promise<ApplyOutcome>; + applyOnlineTrack(trackKey: string): Promise<ApplyOutcome>; + applyOnlineTitle(title: string): void; + /** The user named the game — its description, genres and dates are fetched from here. */ + onOnlineCandidate(candidate: GameCandidate): void; + /** How many backgrounds the form already holds — what makes "add or replace" a question at all. */ + 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'); + const veil = screen.querySelector<HTMLElement>('.settings-veil'); + const listEl = req('game-settings-list'); + const navEl = req('game-settings-nav'); + const statusEl = req('game-settings-status'); + const headingEl = req('game-settings-heading'); + /** The screen's own name — "Customize" or "Add game". See the note on the element in index.html. */ + const titleEl = req('game-settings-title'); + const menuEl = req('game-settings-options'); + const menuListEl = req('game-settings-options-list'); + const menuVeil = menuEl.querySelector<HTMLElement>('.settings-options-veil'); + const lightboxEl = req('lightbox'); + const lightboxImage = req<HTMLImageElement>('lightbox-image'); + const lightboxCaption = req('lightbox-caption'); + const sourceEl = req('game-settings-source'); + + const t = (): Translator => deps.getTranslator(); + + let open = false; + let gameId = ''; + /** + * What this visit is doing: editing the game named by `gameId`, or creating one. It decides the + * heading, the Save wording, which actions the column offers, and — in a dozen small places below — + * which half of a branch runs. Explicit, because "no gameId" is true of a screen that is still loading. + */ + let mode: 'edit' | 'add' = 'edit'; + /** Where a new game may go. Loaded once per add visit; empty in edit mode. */ + let sources: readonly DriveCandidate[] = []; + /** The root a pending "switch the source?" confirm is about — applied when the answer comes back. */ + let pendingSource: string | null = null; + /** + * The root an adoptRoot is currently reading, and the guard that keeps a late answer from overwriting a + * newer one. Stepping the source row with the D-pad can start a second read before the first lands, and + * the two would otherwise race — the slower one wins and the form ends up describing another root. + */ + let adoptingRoot: string | null = null; + let adoptToken = 0; + // Where the manifest came from, and the media signature it was read against (the swap guard, Р6.2). + let origin: { + readonly root: string; + readonly source: ManifestSource; + readonly signature: string; + /** Read alongside the manifest — main answers it, the renderer never asks the OS itself. */ + readonly platform: HostPlatform; + } | null = null; + // Every game in the file. Ours is `slots[slotIndex]`; the others are only ever carried through. + let slots: GameFormState[] = []; + 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` / + * `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. + */ + interface PendingMove { + readonly target: DriveCandidate; + readonly targetSlots: readonly GameFormState[]; + readonly targetIndex: number; + readonly targetSignature: string; + readonly targetBaselineOtherIssues: ReadonlySet<string>; + /** + * Destination (card) path → source (PC-library) path, for the hero/grid images `carryFormToCard` + * carried over unedited. The files those destination paths name are NOT on the card yet — main only + * copies them once Save actually commits the move — so a thumbnail/lightbox for one of them has to + * read the PC-library copy, which is the only place the bytes exist right now. A path the user + * replaced via Browse after choosing the target is deliberately absent here: it already names a real + * file on the target card (browseInto only ever offers paths that are already there). + */ + readonly sourceAssetPaths: ReadonlyMap<string, string>; + } + let pendingMove: PendingMove | null = null; + let form: ManifestFormModel = emptyFormModel(); + let rest: Readonly<Record<string, unknown>> = {}; + let corrupt: Readonly<Record<string, unknown>> = {}; + let mixed = false; + let loadedId = ''; + /** The text as it was read. Dirty is "what we would write differs from this". */ + let baseline = ''; + /** Set when OUR slot cannot be represented at all — the screen shows the reason and two ways out. */ + let unreadable: string | null = null; + + let issues: ReadonlyMap<string, string> = new Map(); + let otherIssues: readonly string[] = []; + /** + * 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). + */ + let baselineOtherIssues: ReadonlySet<string> = new Set(); + let ownIssues = false; + 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; + + 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 section column. It carries this screen's actions too — Save, Discard edits, Delete, Close — + * which is the whole point: they used to sit under six sections of form, so committing an edit meant + * scrolling past every field you had just finished with. + */ + const sidebar = createSidebar(navEl, { + audio: deps.audio, + onSection: (id, entered) => { + sectionKey = id as MessageKey; + if (entered) { + enterPane(); + return; + } + schedulePreview(); + }, + onAction: (id) => runAction(id as GameRowId), + }); + + // ── Form state ───────────────────────────────────────────────────────────── + + /** + * The whole file as it would be written right now — the PC library's, or (while a move is pending) the + * TARGET card's, with `form` inserted at the slot the move claimed. See PendingMove. + */ + function currentText(): string { + if (pendingMove !== null) { + const next = [...pendingMove.targetSlots]; + next[pendingMove.targetIndex] = { model: form, rest, corrupt }; + return gamesToText(next); + } + if (slotIndex < 0) return baseline; + const next = [...slots]; + next[slotIndex] = { model: form, rest, corrupt }; + return gamesToText(next); + } + + function dirty(): boolean { + // A pending move is itself the change — there is no "back to how it was" text to compare against + // (the comparison would be against the PC library's baseline, which a move never touches). + if (pendingMove !== null) return true; + return unreadable === null && currentText() !== baseline; + } + + /** + * Deleting a game is allowed for a local one always, and for a card game only while it is not the last + * (a card with no manifest is a card the launcher cannot see). Never while the game is busy: the file + * would lose a game the running launcher still holds a manifest for. Never mid-move either — Delete acts + * on the PC library, which a pending move has not written to yet, and the two actions racing is not a + * combination worth supporting. + */ + function canDelete(): boolean { + if (pendingMove !== null) return false; + if (origin === null || deps.isBusy()) return false; + return origin.source === 'pc' ? true : slots.length >= 2; + } + + /** Whether "Move to card…" (the action row above Delete) may run right now — a card has nowhere to + * move TO that would mean anything, so this is local games only; same busy guard as Delete. */ + function canMove(): boolean { + if (pendingMove !== null) return false; + if (origin === null || deps.isBusy()) return false; + return origin.source === 'pc'; + } + + function canSave(): boolean { + const move = pendingMove; + if (move !== null) { + if (unreadable !== null) return false; + if (ownIssues) return false; + return otherIssues.every((issue) => move.targetBaselineOtherIssues.has(issue)); + } + if (origin === null || unreadable !== null) return false; + if (ownIssues) return false; + // A problem in someone else's slot that was NOT there when we opened is one we introduced. + return otherIssues.every((issue) => baselineOtherIssues.has(issue)); + } + + /** The source row's options: one per candidate root, labelled the way the picker labels them. */ + function sourceOptions(): readonly CoreOption[] { + return sources.map((candidate) => ({ value: candidate.root, label: candidate.label })); + } + + function currentModel(): GameSettingsModel | null { + if (origin === null) return null; + const move = pendingMove; + const at = move !== null ? move.target.root : origin.root; + return buildGameSettingsModel(form, { + mode, + move: move !== null, + sources: mode === 'add' ? sourceOptions() : [], + sourceLabel: + move !== null + ? 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. + source: move !== null ? 'card' : origin.source, + platform: origin.platform, + root: at, + loadedId, + mixed, + issues, + otherIssues, + status, + canSave: canSave(), + dirty: dirty(), + // A game that does not exist yet cannot be deleted — and for the PC library canDelete() says yes + // to anything, so without this the column would offer "Delete game" on the Add screen. + canDelete: mode === 'edit' && canDelete(), + canMove: mode === 'edit' && canMove(), + }); + } + + /** Applies a new form state: repaint, re-validate, and refresh whatever thumbnails changed. */ + function updateForm(next: ManifestFormModel): void { + form = next; + render(); + scheduleValidate(); + } + + // ── 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 => + visibleRows(m) + .map((row) => `${row.kind}:${row.id}`) + .join('|'); + 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 = []; + } + + /** How long the staggered row entrance runs — the marks come off once it is over. */ + const ENTRANCE_MS = 700; + /** The stagger stops counting here: past a handful of rows the wave is a wait, not a wave. */ + const ENTRANCE_STEPS = 8; + /** 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 + * confuses the two skips the rebuild and keeps whatever the last visit left in the DOM. + */ + let columnSignature: string | null = null; + + function renderColumn(from: GameSettingsModel): void { + const entries = columnEntries(from); + const signature = entries + .map((entry) => `${entry.id}:${entry.label}:${entry.disabled === true ? '1' : '0'}`) + .join('|'); + // Save's enabled state follows every keystroke, so this runs constantly — rebuilding the buttons + // each time would drop the hover state and flicker under the cursor for no reason. + if (signature === columnSignature) return; + columnSignature = signature; + const selectedBefore = sidebar.selected()?.id; + sidebar.render(entries); + // The column can rebuild WITHOUT the entry the cursor was standing on — an action that stops applying + // the moment it is pressed ("Move to card…", which leaves the column as soon as a move begins). The + // 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); + } + } + + 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 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; + }), + ); + } + + 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(); + const next = currentModel(); + titleEl.textContent = t()( + mode === 'add' ? 'gameSettings.addTitle' : 'gameSettings.screenTitle', + ); + headingEl.textContent = screenHeading(next); + // Source first, then the game — it reads as a location and its contents ("E:\ · Hades"). The + // parentheses went with the swap: a parenthetical is an aside, and an aside cannot come first. + sourceEl.textContent = next === null ? '' : `${rowLabelText(next.source, t())} ·`; + if (unreadable !== null) { + renderUnreadable(); + return; + } + if (next === null) { + model = null; + renderMessage(t()('gameSettings.loading')); + return; + } + const previous = model; + model = next; + // The column carries Save's own enabled state, so it follows every edit — unlike the Settings + // screen's, whose entries only change when a section appears. + renderColumn(next); + renderStatus(next); + if (previous !== null && sameComposition(previous, next) && rendered.length > 0) { + const rows = visibleRows(next); + const artworkChanged = artworkSignature(previous) !== artworkSignature(next); + rendered.forEach((row, index) => { + const nextRow = rows[index]; + if (nextRow !== undefined) patchGameRow(row, nextRow, t()); + }); + // A patch keeps the DOM, thumbnails included — so the strip has to be re-read whenever the paths + // behind it moved. Without this, adding a background to an existing list left the previous strip + // on screen (the row composition had not changed, so nothing rebuilt). + if (artworkChanged) void refreshThumbnails(); + return; + } + renderPane(); + } + + /** The rows the pane currently shows — one section's worth. */ + function visibleRows(from: GameSettingsModel): readonly GameSettingsRow[] { + return currentSection(from)?.rows ?? []; + } + + function renderPane(): void { + const from = model; + if (from === null) return; + const section = currentSection(from); + if (section === undefined) return; + sectionKey = section.titleKey; + paneKey = 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) => + row.el.style.setProperty('--row-index', String(Math.min(at, ENTRANCE_STEPS))), + ); + entrance.play(); + focusIndex = nearestFocusable(focusIndex, 1); + 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 + * back to no longer exists. So the screen offers the two things that ARE possible from here. + */ + function renderUnreadable(): void { + model = null; + listEl.replaceChildren(); + const section = document.createElement('div'); + section.className = 'settings-section'; + const title = document.createElement('div'); + title.className = 'settings-section-title'; + title.textContent = t()('gameSettings.slotUnreadable', { message: unreadable ?? '' }); + section.append(title); + listEl.append(section); + rendered = []; + } + + /** + * 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<string, string | null>(); + + /** 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<string | null> { + const key = `${root} ${path}`; + const cached = thumbnails.get(key); + if (cached !== undefined) return cached; + const url = 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. */ + async function refreshThumbnails(): Promise<void> { + 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. + const thumbnailAt = ( + path: string, + ): { readonly root: string; readonly relative: string } | null => 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<HTMLElement>('.settings-option-clip'); + if (first !== null && first !== undefined && first.clientWidth === 0) { + requestAnimationFrame(() => updateMenuMarquee()); + return; + } + for (const button of menuButtons) { + const clip = button.querySelector<HTMLElement>('.settings-option-clip'); + const text = button.querySelector<HTMLElement>('.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(); + } + + // ── 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; + } + } + + 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; + } + } + + 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; + } + } + + function setSelect(id: GameRowId, value: string): void { + if (id === 'source') { + requestSource(value); + return; + } + if (id === 'launchMode') { + updateForm(withLaunchMode(form, value as LaunchMode)); + return; + } + if (id === 'install.type') updateForm(withInstallType(form, value as InstallType)); + } + + /** + * Steps the source row by `delta`, wrapping like every other select. Measured from the root being + * ADOPTED when a read is still in flight, so a quick second press moves on rather than re-asking for + * the same neighbour. A step that would cost something still raises the confirm — and the popup takes + * the input while it is up, so a HELD direction cannot stack a queue of questions behind it. + */ + function cycleSource(delta: number): void { + if (sources.length === 0) return; + const current = adoptingRoot ?? origin?.root ?? ''; + const at = sources.findIndex((candidate) => candidate.root === current); + const next = sources[wrapIndex(at === -1 ? 0 : at, delta, sources.length)]; + if (next === undefined || next.root === current) return; + deps.audio.play('navigate'); + requestSource(next.root); + } + + /** + * Moves the new game to another root, asking first only when the move would COST something: the paths + * and the install block are read against a root, so they cannot travel with it (see + * carryFormAcrossSources). With nothing but a name typed there is nothing to warn about. + */ + function requestSource(root: string): void { + if (root === (adoptingRoot ?? origin?.root)) return; + if (hasSourceBoundValues(form)) { + pendingSource = root; + deps.onConfirmRequested('switch-source'); + return; + } + 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<GameSettingsRow, { kind: 'select' }>): void { + const options: readonly CoreOption[] = row.options; + pushMenu({ + kind: 'select', + title: '', + focus: Math.max( + 0, + options.findIndex((option) => option.value === row.value), + ), + entries: options.map((option) => ({ + label: optionLabel(option, t()), + current: option.value === row.value, + run: () => { + closeMenus(); + setSelect(row.id, option.value); + }, + })), + }); + } + + function cycleSelect(row: Extract<GameSettingsRow, { kind: 'select' }>, delta: number): void { + // The source steps through the CANDIDATES, not through the row's own value: a step starts a root read, + // and until it lands the row still shows the previous root — so stepping again would keep landing on + // the same neighbour instead of walking down the list. + if (row.id === 'source') { + cycleSource(delta); + return; + } + if (row.options.length === 0) return; + const current = row.options.findIndex((option) => option.value === row.value); + const next = wrapIndex(current === -1 ? 0 : current, delta, row.options.length); + const option = row.options[next]; + if (option === undefined) return; + deps.audio.play('navigate'); + setSelect(row.id, option.value); + } + + function stepNumber(row: Extract<GameSettingsRow, { kind: 'number' }>, delta: number): void { + const parsed = Number.parseInt(row.value, 10); + const base = Number.isFinite(parsed) ? parsed : 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 + return; + } + deps.audio.play('navigate'); + setField(row.id, String(next)); + } + + function openKeyboardFor(row: Extract<GameSettingsRow, { kind: 'text' | 'number' }>): void { + deps.keyboard.open({ + value: row.value, + mode: row.kind === 'number' ? 'number' : row.id === 'id' ? 'id' : 'text', + title: rowTitle(row), + onDone: (value) => setField(row.id, value), + }); + } + + function rowTitle(row: GameSettingsRow): string { + if (row.kind === 'note') return ''; + if (row.kind === 'action') return ''; + return 'key' in row.label ? t()(row.label.key) : row.label.text; + } + + /** A path row's own little menu: browse for a new value, or clear the one it has. */ + function openPathMenu(row: Extract<GameSettingsRow, { kind: 'path' }>): void { + const entries: MenuEntry[] = []; + if (row.value !== '' && row.preview !== undefined) { + entries.push({ + label: t()('gameSettings.viewImage'), + run: () => void showImage(row.value), + }); + } + entries.push({ + label: t()('gameSettings.browse'), + run: () => browseInto(row.id, row.value, false), + }); + if (row.value !== '') { + entries.push({ + label: t()('gameSettings.clear'), + run: () => { + closeMenus(); + setField(row.id, ''); + }, + }); + } + pushMenu(asMenu({ title: rowTitle(row), entries })); + } + + /** + * Which root a card-relative path should be read FROM: normally wherever the form is currently pointed + * at, but a hero/grid image carried over by a pending move is an exception — main has not copied it to + * the target card yet (that only happens on Save), so it has to be read from the PC library, where the + * bytes still are. See PendingMove.sourceAssetPaths. + */ + function assetPreviewRoot( + relative: string, + ): { readonly root: string; readonly relative: string } | null { + const move = pendingMove; + if (move !== null) { + const source = move.sourceAssetPaths.get(relative); + if (source !== undefined) + return origin === null ? null : { root: origin.root, relative: source }; + return { root: move.target.root, relative }; + } + return origin === null ? null : { root: origin.root, relative }; + } + + /** Opens the artwork at full size. Nothing but a look — B (or the veil) closes it. */ + async function showImage(relative: string): Promise<void> { + if (relative === '') return; + const at = assetPreviewRoot(relative); + if (at === null) return; + const url = await deps.api.imagePreview(at.root, at.relative); + if (url === null) return; // a preview that could not be read never became a surface — and never sounds + openLightbox(url, 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. + * + * The menu it was opened FROM stays underneath. Closing it up front made backing out of the browser + * land on the form instead of on the popup the user was in — one press undoing two levels, which is + * not what back means anywhere else here. The menu is dismissed only once a value has actually been + * chosen, because then there is nothing left to go back to. + */ + function browseInto( + id: GameRowId, + current: string, + multi: boolean, + onPicked?: (paths: readonly string[]) => void, + ): void { + const move = pendingMove; + const at = + move !== null + ? { root: move.target.root, source: 'card' as const } + : origin !== null + ? { root: origin.root, source: origin.source } + : null; + if (at === null) return; + const kind = pickKindFor(id, form.launchMode, at.source); + if (kind === null) return; + deps.picker.open({ + root: at.root, + kind, + current, + multi, + ...(baseFor(id) !== null ? { base: baseFor(id) ?? '' } : {}), + onDone: (result) => { + 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(); + return; + } + closeMenus({ silent: true }); // the browser's own popup-close already covered this gesture + if (onPicked !== undefined) { + onPicked(result.paths); + return; + } + const first = result.paths[0]; + if (first !== undefined) setField(id, first); + }, + }); + } + + /** + * The sub-directory a field's paths are relative to, when it is not the root itself. + * + * Only "move game to PC" has one, and it is not cosmetic: with the checkbox on, the manifest resolves + * `executable` under the INSTALL directory, which receives the contents of the game folder named + * below it (manifest.ts, `<installDir>/<executable>`). A card-relative path would carry that folder's + * own name as a prefix and point one level too deep — so the browser both starts there and measures + * from there. + */ + function baseFor(id: GameRowId): string | null { + if (id !== 'executable') return null; + if (form.launchMode !== 'executable' || !form.copyToPc) return null; + return form.copyInstall.installer === '' ? null : form.copyInstall.installer; + } + + // ── List editing (its own level of the column menu) ───────────────────────── + + function openListMenu(row: Extract<GameSettingsRow, { kind: 'list' }>): 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); + } + + /** + * Asks main to judge the WHOLE file, then splits the verdict in two: the problems inside our slot + * (mapped onto rows) and the ones in the other games (a summary line). The split is why the issue paths + * matter — the validator reports a multi-game file's paths as `games.<i>.<field>`. + */ + async function runValidate(): Promise<void> { + const move = pendingMove; + const root = move !== null ? move.target.root : origin?.root; + if (root === undefined || 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); + if (token !== validateToken) return; // a newer edit already asked + const own = new Map<string, string>(); + 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; + ownIssues = own.size > 0; + otherIssues = others; + render(); + } + + /** "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 + * error popup instead (see `notify` / `showError` above). + */ + function setStatus(next: string | null): void { + status = next; + render(); + } + + /** Said and done: the plate takes it, and the form's own line is cleared of whatever was in flight. */ + function notifyDone(text: string): void { + setStatus(null); + deps.notify(text); + } + + /** Something went wrong: the popup holds it until the user closes it. */ + function failWith(text: string): void { + setStatus(null); + deps.showError(text); + } + + // ── Load / save / delete ─────────────────────────────────────────────────── + + async function load(id: string): Promise<void> { + gameId = id; + origin = null; + unreadable = null; + status = null; + issues = new Map(); + otherIssues = []; + ownIssues = false; + render(); + const result = await deps.api.read(id); + if (!open || gameId !== id) return; // closed (or moved on) while main was reading + if (!result.ok) { + origin = null; + unreadable = result.message; + render(); + return; + } + deps.audio.play('button'); // the screen is entered like a button, not like a popup + origin = { + root: result.root, + source: result.source, + signature: result.signature, + platform: result.platform, + }; + adoptText(result.text); + await runValidate(); + baselineOtherIssues = new Set(otherIssues); + } + + /** + * "Move to card…" (Р2.1): 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. + */ + async function beginMove(): Promise<void> { + if (origin === null || origin.source !== 'pc') return; + const forGame = gameId; + const list = await deps.api.sources(); + // Closed, reopened for another game / in add mode, or a target was already picked meanwhile. + if (!open || mode !== 'edit' || gameId !== forGame || pendingMove !== null) return; + const cards = list.filter((candidate) => candidate.kind === 'card'); + if (cards.length === 0) { + failWith(t()('gameSettings.moveNoCards')); + return; + } + pushMenu( + asMenu({ + title: t()('gameSettings.moveToCardTitle'), + entries: cards.map((candidate) => ({ + label: candidate.label, + run: () => { + closeMenus(); + void adoptMoveTarget(candidate); + }, + })), + }), + ); + } + + /** + * Reads the chosen target card and inserts the moved game (see `carryFormToCard`) as a slot of its own — + * exactly what `adoptRoot` does for a brand new ADD game, except the inserted model carries a REAL + * game's data across instead of starting blank. Nothing is written here; see PendingMove. + */ + async function adoptMoveTarget(candidate: DriveCandidate): Promise<void> { + if (pendingMove !== null) return; // a target is already chosen — this answer is a stale second one + const token = ++adoptToken; + const forGame = gameId; + setStatus(null); + const result = await deps.api.readRoot(candidate.root); + // The same guards `adoptRoot` uses, and for the same reason: this answer describes a place the user + // may have left — the screen could have been closed, reopened for another game, or reopened in add + // mode, and applying a move target to any of those writes the wrong file. + if (!open || mode !== 'edit' || gameId !== forGame || token !== adoptToken) return; + if (!result.ok) { + failWith(result.message); + return; + } + const originalPcSavePath = form.pcSavePath; + const carried = carryFormToCard(form); + const parsed = slotsWithInsertedGame(result.hasManifest ? result.text : null, carried); + if (!parsed.ok) { + failWith(parsed.message); + return; + } + // dest → source, by matching position in the two arrays carryFormToCard read and wrote — see + // PendingMove.sourceAssetPaths. + const sourceAssetPaths = new Map<string, string>(); + form.heroImage.forEach((source, index) => { + const dest = carried.heroImage[index]; + if (dest !== undefined) sourceAssetPaths.set(dest, source); + }); + if (carried.gridImage !== '') sourceAssetPaths.set(carried.gridImage, form.gridImage); + pendingMove = { + target: candidate, + targetSlots: parsed.slots, + targetIndex: parsed.index, + targetSignature: result.signature, + targetBaselineOtherIssues: new Set(), + sourceAssetPaths, + }; + form = carried; + rest = {}; + corrupt = {}; + focusIndex = 0; + model = null; + render(); + await runValidate(); + // canSave() must judge against issues that were ALREADY there when the target was read, exactly like + // baselineOtherIssues for a normal edit — a bad neighbour on the target card is not ours to fix either. + if (pendingMove !== null) { + pendingMove = { ...pendingMove, targetBaselineOtherIssues: new Set(otherIssues) }; + } + // Best-effort backfill: an absolute pcSavePath (the common shape for a local, non-Steam game — see + // %PREFIX%/%APPDATA% handling in manifest.ts) was dropped by carryFormToCard because a card cannot + // store one. Converting it into the %PREFIX% form a card DOES accept needs main (the same conversion + // the picker itself makes), so it happens here, after the target is already adopted, instead of + // blocking on it — a folder that no longer exists or sits outside every known base just leaves the + // field for the user to fill in, exactly as it did before this backfill existed. + if (form.pcSavePath === '' && originalPcSavePath !== '') { + const converted = await deps.api.acceptPath({ + root: candidate.root, + kind: 'pc-save', + paths: [originalPcSavePath], + }); + if ( + open && + mode === 'edit' && + gameId === forGame && + token === adoptToken && + pendingMove !== null + ) { + const first = converted.ok ? converted.paths[0] : undefined; + if (first !== undefined) updateForm({ ...form, pcSavePath: first }); + } + } + } + + /** + * Add mode's counterpart of `load`: the roots a game may be added to, and then the one it starts on — + * the active card if a card is inserted, this PC otherwise. The card is where the user's attention + * already is (they just plugged it in); the library is the one root that is always there. + */ + async function loadSources(): Promise<void> { + const list = await deps.api.sources(); + if (!open || mode !== 'add') return; // closed (or reopened for a game) while main was listing + sources = list; + const card = list.find((candidate) => candidate.kind === 'card' && candidate.isActive); + const first = card ?? list.find((candidate) => candidate.kind === 'pc') ?? list[0]; + if (first === undefined) { + failWith(t()('errors.driveUnavailable')); + return; + } + await adoptRoot(first.root); + } + + /** + * Points the add form at one root: reads what that root already carries, appends the new game as a slot + * of its own (see slotsWithNewGame) and re-validates. On a SWITCH the half-filled form travels with it, + * minus everything that was measured against the old root. + */ + async function adoptRoot(root: string): Promise<void> { + const carried = origin === null ? null : form; + const token = ++adoptToken; + adoptingRoot = root; + setStatus(null); + const result = await deps.api.readRoot(root); + // A newer step already asked for another root — this answer describes a place the user has left. + if (!open || mode !== 'add' || token !== adoptToken) return; + adoptingRoot = null; + if (!result.ok) { + failWith(result.message); + return; + } + origin = { + root: result.root, + source: result.source, + signature: result.signature, + platform: result.platform, + }; + const blankMode = defaultLaunchMode(result.source); + const parsed = slotsWithNewGame(result.hasManifest ? result.text : null, blankMode); + if (!parsed.ok) { + unreadable = parsed.message; + render(); + return; + } + slots = [...parsed.slots]; + slotIndex = parsed.index; + form = + carried === null ? emptyFormModel(blankMode) : carryFormAcrossSources(carried, result.source); + rest = {}; + corrupt = {}; + mixed = false; + loadedId = ''; + unreadable = null; + // 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; + model = null; + render(); + await runValidate(); + baselineOtherIssues = new Set(otherIssues); + } + + /** Parses a whole file into slots and picks OURS out by id. */ + function adoptText(text: string): void { + baseline = text; + const parsed = textToGames(text); + if (!parsed.ok) { + unreadable = parsed.message; + render(); + return; + } + slots = parsed.games.map((game, index) => + game.ok + ? { model: game.model, rest: game.rest, corrupt: game.corrupt } + : { raw: parsed.values[index] }, + ); + slotIndex = parsed.games.findIndex((game) => game.ok && game.model.id === gameId); + const ours = slotIndex === -1 ? undefined : parsed.games[slotIndex]; + if (ours === undefined || !ours.ok) { + // The file no longer describes the game the carousel showed — main's list and this file disagree, + // which is a state to report rather than to guess at. + unreadable = t()('gameSettings.slotNotFound', { id: gameId }); + render(); + return; + } + // textToGames has no `source`, so a PC-library draft (no launch block at all) parses indistinguishably + // from a blank card form and defaults to 'executable' — draftModeFor corrects that with the source the + // screen actually has. + form = + origin === null + ? ours.model + : { ...ours.model, launchMode: draftModeFor(ours.model, origin.source) }; + rest = ours.rest; + corrupt = ours.corrupt; + mixed = ours.mixed; + loadedId = ours.model.id; + unreadable = null; + focusIndex = 0; + model = null; // force a full rebuild — the composition is entirely new + render(); + } + + async function runSave(): Promise<void> { + const at = origin; + if (at === null || !canSave()) return; + const text = currentText(); + setStatus(t()('gameSettings.saving')); + const result = await deps.api.save({ root: at.root, signature: at.signature, text }); + if (!result.saved) { + failWith(result.message); + return; + } + 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). + if (result.applied === 'applied') notifyDone(t()('gameSettings.savedApplied')); + else if (result.applied === 'deferred') notifyDone(t()('gameSettings.savedDeferred')); + else notifyDone(t()('gameSettings.savedNotApplied')); + render(); + } + + /** + * 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, + * 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. + */ + async function runMove(): Promise<void> { + const move = pendingMove; + if (move === null || origin === null || !canSave()) return; + const text = currentText(); + const movedId = form.id; + setStatus(t()('gameSettings.saving')); + const result = await deps.api.moveToCard({ + id: movedId, + // The id the manifest was READ with — what main addresses the PC-library side by. `form.id` is an + // editable field and must never be what decides which local game gets removed. + fromId: loadedId, + fromRoot: origin.root, + fromSignature: origin.signature, + toRoot: move.target.root, + toSignature: move.targetSignature, + toText: text, + }); + if (!result.moved) { + failWith(result.message); + return; + } + close(); + if (result.applied === 'applied') deps.onAdded(movedId); + } + + /** + * The Add button. The write is the same one Save makes — the difference is what happens after it, and + * that follows what main could DO with the file: + * + * • `applied` — the manifest was re-read, so the game exists in the library now: leave the screen and + * take the carousel to it; + * • `deferred` — it went to a card that is not the active one, so there is nothing to go to. The + * screen still closes (keeping the user on a form about a finished job says nothing), and main + * posts the notification that says where the game went; + * • `failed` — written, but the reload was refused. That is an error to read, so the screen stays. + */ + async function runAdd(): Promise<void> { + const at = origin; + if (at === null || !canSave()) return; + const text = currentText(); + const addedId = form.id; + setStatus(t()('gameSettings.saving')); + const result = await deps.api.save({ root: at.root, signature: at.signature, text }); + if (!result.saved) { + failWith(result.message); + return; + } + baseline = text; + if (result.applied === 'failed') { + failWith(result.message ?? t()('gameSettings.savedNotApplied')); + await resyncAfterWrite(at.root, addedId); + return; + } + close(); + if (result.applied === 'applied') deps.onAdded(addedId); + } + + /** + * Re-reads the root after a write the launcher could not apply, so a second Add is possible at all: the + * root's signature carries the new id now, and the swap guard would refuse a retry against the one the + * screen opened with. The game that was just written comes back with the others and is dropped from + * them — it is the slot the form is still editing, and keeping both would write it twice. + */ + async function resyncAfterWrite(root: string, writtenId: string): Promise<void> { + const token = ++adoptToken; + const result = await deps.api.readRoot(root); + // The same guard adoptRoot uses: the user may have moved the game to another root meanwhile, and + // this answer is about the one they left. + if (!open || mode !== 'add' || token !== adoptToken || !result.ok) return; + const parsed = slotsWithNewGame(result.hasManifest ? result.text : null, form.launchMode); + if (!parsed.ok) return; + const others = parsed.slots.filter( + (slot, index) => index !== parsed.index && (isRawSlot(slot) || slot.model.id !== writtenId), + ); + origin = { + root: result.root, + source: result.source, + signature: result.signature, + platform: result.platform, + }; + slots = [...others, { model: form, rest, corrupt }]; + slotIndex = others.length; + baseline = currentText(); + render(); + await runValidate(); + } + + /** + * Deleting is IMMEDIATE, unlike the old window's "remove the slot and save later": a confirmed deletion + * that leaves the game on screen until some later Save reads as a bug. The slot is cut from the text as + * READ, so unsaved edits are discarded with it — which the confirm says out loud. + */ + async function runDelete(forgetHistory: boolean): Promise<void> { + const at = origin; + if (at === null || slotIndex < 0) return; + const remaining = slots.filter((_, index) => index !== slotIndex); + const text = gamesToText(remaining); + const result = await deps.api.save({ root: at.root, signature: at.signature, text }); + if (!result.saved) { + failWith(result.message); + return; + } + baseline = text; + // Only now: main refuses to forget a game it can still see in a manifest, and the save resolves once + // that manifest has been re-read — so this is the first moment the request can be honoured. + if (forgetHistory) deps.api.forgetHistory(gameId); + close(); + } + + function runReset(): void { + adoptText(baseline); + status = null; + 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; + const root = move !== null ? move.target.root : (origin?.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 } : {}), + }); + } + + /** + * 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/<id>-hero-<n>.<ext>`), 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<ApplyOutcome> { + 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<void> { + 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<ApplyOutcome> { + 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 (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'; + 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); + surface.navUp(); + } + + function navDown(): void { + 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); + 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(); + if (surface === 'lightbox') { + if (!repeat) deps.audio.playLimit(); + return; + } + 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(); + return; + } + if (surface === 'form') { + navHorizontal(-1); + return; + } + surface.navLeft(repeat); + } + + function navRight(): void { + hover.arm(); + const surface = activeSurface(); + 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); + return; + } + surface.navRight(); + } + + function activateRow(target: RenderedGameRow): void { + const row = target.row; + switch (row.kind) { + case 'toggle': + if (row.disabled === true) { + deps.audio.playLimit(); // the row is shown, but this game cannot have it switched + return; + } + deps.audio.play('button'); + pressFlash(target.el); + toggleField(row.id); + return; + // Every row below opens a surface of its own (a menu, the keyboard, the picker), and each of them + // plays `popup-open` as it appears. The `button` here is the ROW being pressed: two sounds for the + // gesture, the same pair a launcher card plays when it opens its surface. + case 'select': + deps.audio.play('button'); + pressFlash(target.el); + openSelectMenu(row); + return; + case 'text': + case 'number': + deps.audio.play('button'); + pressFlash(target.el); + openKeyboardFor(row); + return; + case 'path': + deps.audio.play('button'); + pressFlash(target.el); + openPathMenu(row); + return; + case 'list': + deps.audio.play('button'); + pressFlash(target.el); + openListMenu(row); + return; + case 'action': + // Actions live in the column now; a row of this kind should never reach the pane. + return; + default: + return; + } + } + + /** The screen's actions, now that they live in the column rather than at the end of the form. */ + function runAction(id: GameRowId): void { + switch (id) { + case 'find-online': + deps.audio.play('button'); + startFindOnline(); + return; + case 'save': + deps.audio.play('button'); + if (mode === 'add') void runAdd(); + else if (pendingMove !== null) void runMove(); + else void runSave(); + return; + case 'reset': + // Neither action exists in add mode's column — but the column is not the only way in (a stale + // model, a click), and both would act on a game that does not exist. + if (mode === 'add') return deps.audio.playLimit(); + deps.audio.play('button'); + deps.onConfirmRequested('reset'); + return; + case 'move-to-card': + if (mode === 'add' || pendingMove !== null) return deps.audio.playLimit(); + deps.audio.play('button'); // beginMove's own popup-open follows, like every other menu it opens + void beginMove(); + return; + case 'delete': + if (mode === 'add') return deps.audio.playLimit(); + deps.audio.play('button'); + deps.onConfirmRequested('delete'); + return; + case 'close': + // The same question B asks from the column: leaving with unsaved edits is confirmed first. + leaveScreen(); + return; + default: + return; + } + } + + function navActivate(): void { + hover.arm(); + const surface = activeSurface(); + if (surface === 'lightbox') { + closeImage(); + return; + } + if (surface === 'menu') { + const level = menuTop(); + const entry = level?.entries[level.focus]; + if (entry === undefined) return; + runEntry(entry); + return; + } + if (surface === 'form') { + if (sidebar.hasFocus()) { + sidebar.activate(); + return; + } + const target = rendered[focusIndex]; + if (target !== undefined) activateRow(target); + return; + } + surface.navActivate(); + } + + function navBack(): void { + hover.arm(); + const surface = activeSurface(); + if (surface === 'lightbox') { + closeImage(); + return; + } + if (surface === 'menu') { + popMenu(); + return; + } + if (surface !== 'form') { + surface.navBack(); + return; + } + // Out of the pane, back to the column; out of the column, off the screen — which is where the + // unsaved-edits question belongs, since the column is the only way out. Only the step INSIDE the + // screen keeps `back`; leaving it is a popup closing, and close() says so. + if (!sidebar.hasFocus()) { + deps.audio.play('back'); + leavePane(); + return; + } + leaveScreen(); + } + + /** Leaves the screen, asking first when there is anything to lose. */ + function leaveScreen(): void { + // A pending move is its own question — "Yes" drops it and stays on the screen, unlike 'discard', + // whose "Yes" closes it outright (see PendingMove / cancelMove). + if (pendingMove !== null) { + deps.onConfirmRequested('cancel-move'); + return; + } + if (dirty()) { + deps.onConfirmRequested('discard'); + return; + } + close(); + } + + /** Drops a pending move and returns the form to the PC library's baseline — same path as Reset. */ + function cancelMove(): void { + pendingMove = null; + runReset(); + } + + function close(): void { + if (!open) return; + open = false; + // Anything still in flight belongs to the visit that is ending: a slow readRoot answering after the + // screen was reopened for ANOTHER game would otherwise pass its own guard (`token === adoptToken`) and + // drop that game into a move it never asked for. Bumping the token here retires every pending answer. + 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 }); + 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; + } + delete app.dataset['overlay']; + screen.setAttribute('aria-hidden', 'true'); + deps.onClosed(); + } + + // ── Mouse ────────────────────────────────────────────────────────────────── + + listEl.addEventListener('click', (event) => { + const target = event.target; + if (!(target instanceof Element)) return; + // 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'] ?? ''); + return; + } + const rowEl = target.closest<HTMLElement>('.setting-row'); + if (rowEl === null) return; + const index = rendered.findIndex((row) => row.el === rowEl); + const entry = rendered[index]; + if (entry === undefined || !isFocusable(entry.row)) return; + sidebar.setFocused(false); + focusIndex = index; + applyRowFocus(); + const chevronEl = target.closest<HTMLElement>('.setting-chevron'); + if (chevronEl !== null) { + const delta = chevronEl.dataset['chevron'] === 'prev' ? -1 : 1; + if (entry.row.kind === 'select') cycleSelect(entry.row, delta); + else if (entry.row.kind === 'number') stepNumber(entry.row, delta); + return; + } + activateRow(entry); + }); + + lightboxEl.querySelector<HTMLElement>('.lightbox-veil')?.addEventListener('click', () => { + closeImage(); + }); + + veil?.addEventListener('click', () => navBack()); + menuVeil?.addEventListener('click', () => { + popMenu(); + }); + + window.addEventListener( + 'mousemove', + (event) => { + hover.track(event.clientX, event.clientY); + if (!open) return; + if (document.documentElement.classList.contains('mouse-asleep')) return; + 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<HTMLButtonElement>('.settings-option'); + if (button === null) return; + const index = menuButtons.indexOf(button); + if (index === -1 || index === level.focus) return; + level.focus = index; + applyMenuFocus(); + return; + } + const rowEl = target.closest<HTMLElement>('.setting-row'); + if (rowEl === null) return; + const index = rendered.findIndex((row) => row.el === rowEl); + const entry = rendered[index]; + if (index === -1 || entry === undefined || !isFocusable(entry.row)) return; + if (index === focusIndex && !sidebar.hasFocus()) return; + sidebar.setFocused(false); + focusIndex = index; + applyRowFocus(); + }, + { passive: true }, + ); + + /** + * Everything a fresh visit starts from, whichever way the screen was opened. Extracted because + * `openNew` must repeat ALL of it — a visit that inherited half the previous one's state is the kind of + * bug that only shows up on the second open. + */ + function resetScreenState(): void { + open = true; + app.dataset['overlay'] = 'game-settings'; + screen.setAttribute('aria-hidden', 'false'); + 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; + // 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 + // empty in the model and empty in the guard, so nothing ever rewrote the DOM. + columnSignature = null; + statusSignature = null; + hover.arm(); + thumbnails.clear(); + listScroller.to(0, true); + focusIndex = 0; + model = null; + rendered = []; + slots = []; + slotIndex = -1; + pendingMove = null; + baseline = ''; + baselineOtherIssues = new Set(); + sources = []; + pendingSource = null; + adoptingRoot = null; + form = emptyFormModel(defaultLaunchMode('card')); + } + + return { + isOpen: () => open, + open: (id: string) => { + if (open) return; + mode = 'edit'; + resetScreenState(); + void load(id); // the sound waits for the read to land — an unreadable game never became a screen + }, + openNew: () => { + if (open) return; + mode = 'add'; + deps.audio.play('button'); // add mode has no read to fail: the empty form is there at once + gameId = ''; + origin = null; + unreadable = null; + status = null; + issues = new Map(); + otherIssues = []; + ownIssues = false; + resetScreenState(); + render(); + void loadSources(); + }, + close, + navUp, + navDown, + navLeft, + navRight, + navActivate, + navBack, + isDirty: dirty, + deletesLocalGame: () => origin?.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), + 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); + }, + 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 + // are handed down the stack. + // The form, the menu and the lightbox claim none of them, and neither does a nested surface that + // left the method out — one place to say so, the same way controls.ts does it one level up. + navSecondary: (repeat = false) => { + const surface = activeSurface(); + if (surface === 'menu') { + const level = menuTop(); + if (level?.secondary === undefined) { + if (!repeat) deps.audio.playLimit(); + return; + } + if (!repeat) level.secondary(level.focus); + return; + } + if (typeof surface === 'string' || surface.navSecondary === undefined) { + if (!repeat) deps.audio.playLimit(); + return; + } + surface.navSecondary(repeat); + }, + navTertiary: () => { + const surface = activeSurface(); + if (typeof surface === 'string' || surface.navTertiary === undefined) { + deps.audio.playLimit(); + return; + } + surface.navTertiary(); + }, + navShoulder: (direction) => { + const surface = activeSurface(); + if (typeof surface === 'string' || surface.navShoulder === undefined) { + deps.audio.playLimit(); + return; + } + surface.navShoulder(direction); + }, + navCommit: () => { + const surface = activeSurface(); + if (typeof surface === 'string' || surface.navCommit === undefined) { + deps.audio.playLimit(); + return; + } + surface.navCommit(); + }, + applyBrowse: (browse) => { + if (!open) return; + // Add mode has no game of its own, so every browse push would match `gameId === ''` and close the + // screen the moment anything at all changed in the carousel. + if (mode === 'add') 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. + if (browse !== null && browse.id === gameId && browse.active) return; + close(); + }, + // Spelled out one kind at a time: a catch-all `else close()` would silently turn any confirm added + // later into "leave the screen", and nothing in the types would object. + confirmAccepted: (kind) => { + if (kind === 'reset') runReset(); + else if (kind === 'delete') void runDelete(false); + else if (kind === 'delete-history') void runDelete(true); + else if (kind === 'discard') close(); + else if (kind === 'switch-source') { + const root = pendingSource; + 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?.(); + } + }, + relocalize: () => { + if (model !== null) { + const section = currentSection(model); + if (section !== undefined) { + relocalizeGameSections(listEl, { ...model, sections: [section] }, t()); + } + for (const row of 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()( + mode === 'add' ? 'gameSettings.addTitle' : 'gameSettings.screenTitle', + ); + headingEl.textContent = screenHeading(model); + sourceEl.textContent = `${rowLabelText(model.source, t())} ·`; + // The column and the status strip ARE labels — rebuilt, not patched. + renderColumn(model); + renderStatus(model); + } else { + render(); + } + deps.keyboard.relocalize(); + 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(); + }, + }; +} diff --git a/src/renderer/game-settings-view.ts b/src/renderer/game-settings-view.ts new file mode 100644 index 00000000..60d3e25a --- /dev/null +++ b/src/renderer/game-settings-view.ts @@ -0,0 +1,151 @@ +// DOM rendering for the launcher's Customize screen. The same shape settings-form-view.ts has — a model +// in, a flat array of rendered rows out, addressed BY INDEX by the controller — and for the same reason: +// a re-render on every keystroke would restart every transition and lose the scroll position, so a value +// change PATCHES the row that changed and only a change in the row COMPOSITION (a new launch mode, a +// warning appearing) rebuilds the list. +// +// 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 { + buildCoreRow, + div, + patchCoreRow, + relocalizeCoreRow, + rowLabelText, + type PreviewAspect, +} from './row-view-core'; + +/** One rendered row: the model row it came from plus the nodes the controller updates. */ +export interface RenderedGameRow { + row: GameSettingsRow; + readonly el: HTMLElement; + readonly valueEl: HTMLElement | null; + readonly buttonEl: HTMLButtonElement | null; + /** Never used here — no row of this screen is a slider; present so a row IS a CoreRendered. */ + readonly fillEl: null; + /** The thumbnail strip of an artwork row, filled asynchronously (gameConfig:image-preview). */ + readonly previewEl: HTMLElement | null; +} + +export interface RenderedGameScreen { + readonly rows: readonly RenderedGameRow[]; +} + +/** Whether a row can hold the focus. A note is text on the screen, not a control. */ +export function isFocusable(row: GameSettingsRow): boolean { + return row.kind !== 'note' && row.kind !== 'static'; +} + +function buildRow(row: GameSettingsRow, t: Translator): RenderedGameRow { + const core = buildCoreRow(row, t); + core.el.dataset['row'] = row.id; + if (!isFocusable(row)) core.el.classList.add('is-inert'); + let previewEl: HTMLElement | null = null; + if ((row.kind === 'path' || row.kind === 'list') && row.preview !== undefined) { + previewEl = div('setting-thumbs'); + core.el.append(previewEl); + } + return { + row, + el: core.el, + valueEl: core.valueEl, + buttonEl: core.buttonEl, + fillEl: null, + previewEl, + }; +} + +/** + * Renders the whole model into `container` (replacing its content) and returns the rows in screen order. + * Section titles are not focusable, so they are absent from the returned list by construction — but the + * inert rows (statics, notes) ARE in it, so an index still addresses the row the model built. + */ +export function renderGameSettings( + container: HTMLElement, + model: GameSettingsModel, + t: Translator, +): RenderedGameScreen { + const rows: RenderedGameRow[] = []; + const sections = model.sections.map((section) => { + const sectionEl = div('settings-section'); + if (section.titleKey !== undefined) { + sectionEl.append(div('settings-section-title', t(section.titleKey))); + } + for (const row of section.rows) { + const rendered = buildRow(row, t); + rows.push(rendered); + sectionEl.append(rendered.el); + } + return sectionEl; + }); + container.replaceChildren(...sections); + return { rows }; +} + +/** Applies a new model row onto an already-rendered one. Same `kind` only. */ +export function patchGameRow(rendered: RenderedGameRow, row: GameSettingsRow, t: Translator): void { + rendered.row = row; + patchCoreRow(rendered, row, t); +} + +/** Re-applies the SECTION titles for a new translator (the rows carry their own labels). */ +export function relocalizeGameSections( + container: HTMLElement, + model: GameSettingsModel, + t: Translator, +): void { + const sections = [...container.querySelectorAll<HTMLElement>('.settings-section')]; + model.sections.forEach((section, index) => { + const title = sections[index]?.querySelector<HTMLElement>('.settings-section-title'); + if (title === null || title === undefined || section.titleKey === undefined) return; + title.textContent = t(section.titleKey); + }); +} + +export function relocalizeGameRow(rendered: RenderedGameRow, t: Translator): void { + relocalizeCoreRow(rendered, rendered.row, t); +} + +/** + * Fills an artwork row's thumbnail strip with already-decoded data URLs (null = nothing readable). The + * strip is drawn in the artwork's own shape (`aspect`), and each thumbnail remembers the manifest path + * it came from so a click can open that picture full size. + */ +export function applyThumbnails( + rendered: RenderedGameRow, + urls: readonly (string | null)[], + aspect: PreviewAspect, + paths: readonly string[], +): void { + const box = rendered.previewEl; + if (box === null) return; + const thumbs: HTMLImageElement[] = []; + urls.forEach((url, index) => { + if (url === null) return; + const image = document.createElement('img'); + image.className = 'setting-thumb'; + image.src = url; + image.alt = ''; + image.dataset['path'] = paths[index] ?? ''; + thumbs.push(image); + }); + box.classList.toggle('is-portrait', aspect === 'portrait'); + box.replaceChildren(...thumbs); + box.classList.toggle('is-hidden', thumbs.length === 0); +} + +/** + * The GAME's own name, shown beside the source in the header — empty while there is none (a game being + * added has no title until it is typed, and nothing to read is better than a placeholder). + * + * It does NOT fall back to what the screen is called: that is a separate element (`.settings-title`, + * mode-aware and set by the controller), and having this one repeat it printed the same words twice. + */ +export function screenHeading(model: GameSettingsModel | null): string { + return model?.title ?? ''; +} + +/** Exported for the controller's own re-localization pass of an expanded dropdown. */ +export { rowLabelText }; diff --git a/src/renderer/gamepad.ts b/src/renderer/gamepad.ts index 285f9c2a..30897ce2 100644 --- a/src/renderer/gamepad.ts +++ b/src/renderer/gamepad.ts @@ -1,9 +1,11 @@ // Gamepad polling in the renderer. // HTML5 Gamepad API + requestAnimationFrame loop, standard mapping. // Navigation: D-pad Left/Right (buttons[14]/[15]) or left-stick X (axes[0]) for the bar; D-pad -// Up/Down (buttons[12]/[13]) or left-stick Y (axes[1]) for the vertical popup stacks. -// A = buttons[0] (activate focused control), B = buttons[1] (back / close popup). +// Up/Down (buttons[12]/[13]) or left-stick Y (axes[1]) for the vertical stacks and the Settings list. +// A = buttons[0] (activate focused control), B = buttons[1] (back / close popup), +// Y = buttons[3] (hand the focus between the carousel strip and the bar — see controls.ts). // We fire on the press EDGE (false→true) so one press / one stick tilt = one action. +import { HOLD_DELAY_MS, NAV_REPEAT_MS, type AutoRepeatChain } from './auto-repeat.js'; export interface GamepadController { start(): void; @@ -15,35 +17,93 @@ export interface GamepadController { } export interface GamepadHandlers { - readonly onLeft: () => void; - readonly onRight: () => void; - readonly onUp: () => void; - readonly onDown: () => void; + readonly onLeft: (repeat: boolean) => void; + /** `repeat` marks a press produced by the hold auto-repeat rather than by a fresh press — the two mean + * different things where a stop is also a step (see navRight in controls.ts). */ + readonly onRight: (repeat: boolean) => void; + readonly onUp: (repeat: boolean) => void; + readonly onDown: (repeat: boolean) => void; readonly onA: () => void; readonly onB: () => void; + readonly onY: () => void; + /** X, and the two shoulder buttons. Claimed only by the on-screen keyboard (Backspace / layout + * switching); everywhere else the handler is a no-op, so the buttons stay unassigned as before. + * X repeats while HELD, like a direction: what it deletes there is one character, and holding it is + * how anyone clears a field. `repeat` marks those, so the consumer can tell a hold from a press. */ + readonly onX: (repeat: boolean) => void; + readonly onShoulderLeft: () => void; + readonly onShoulderRight: () => void; + /** RT — "commit"; claimed by the on-screen keyboard as Done. */ + readonly onTriggerRight: () => void; + /** Every direction has just gone up — the edge, fired once, not on every idle frame. What ends a hold + * for consumers that treat holding as a state rather than as a stream of presses. */ + readonly onDirectionsReleased: () => void; } -const BTN = { a: 0, b: 1, dpadUp: 12, dpadDown: 13, dpadLeft: 14, dpadRight: 15 } as const; +const BTN = { + a: 0, + b: 1, + x: 2, + y: 3, + shoulderLeft: 4, + shoulderRight: 5, + triggerRight: 7, + dpadUp: 12, + dpadDown: 13, + dpadLeft: 14, + dpadRight: 15, +} as const; const STICK_X_AXIS = 0; const STICK_Y_AXIS = 1; const STICK_DEADZONE = 0.5; +/** + * How long a direction stays deaf to the STICK after the opposite one is released. A thumbstick springs + * back through centre and overshoots past the deadzone on the far side, which the edge detector reads as + * a deliberate press the other way — one step down, then an instant step back up. The d-pad is exempt: + * it has no spring, and gating it would eat honest quick reversals. + */ +const STICK_SETTLE_MS = 140; -/** How long left/right must be HELD before the auto-repeat kicks in (a normal press stays one move). */ -const HOLD_DELAY_MS = 350; -/** The auto-repeat's own cadence once it has kicked in. Shared with the keyboard, whose OS repeat rate is - * far faster than anything usable here — see controls.ts. */ -export const NAV_REPEAT_MS = 110; +// The hold-to-repeat tempo lives in auto-repeat.ts — the keyboard runs on the same numbers (controls.ts). -export function createGamepadController(handlers: GamepadHandlers): GamepadController { +export function createGamepadController( + handlers: GamepadHandlers, + chain: AutoRepeatChain, +): GamepadController { let rafId = 0; let running = false; let paused = false; - const prev = { left: false, right: false, up: false, down: false, a: false, b: false }; - // Auto-repeat bookkeeping for the horizontal pair only: holding left/right flips through the carousel, - // where running down a 40-game history one press at a time is the thing to avoid. Up/down live in the - // popup stacks, which are short — a repeat there would just overshoot. - const heldSince = { left: 0, right: 0 }; - const lastFire = { left: 0, right: 0 }; + const prev = { + left: false, + right: false, + up: false, + down: false, + a: false, + b: false, + x: false, + y: false, + shoulderLeft: false, + shoulderRight: false, + triggerRight: false, + }; + // Auto-repeat bookkeeping. Horizontal: holding left/right flips through the carousel, where running + // down a 40-game history one press at a time is the thing to avoid. Vertical: the same for the long + // Settings list — the repeat is DELIVERED for up/down too, and the consumer decides whether it applies + // (controls.ts drops it outside the Settings screen, where the popup stacks are short and cyclic). + // `x` rides along here for the same reason, though it is a button rather than a direction — the timing + // is the timing of a hold, and there is no sense in having two of those. + const heldSince = { left: 0, right: 0, up: 0, down: 0, x: 0 }; + const lastFire = { left: 0, right: 0, up: 0, down: 0, x: 0 }; + // The stick's own previous state per direction, and the moment each one was RELEASED (the edge, not + // every idle frame — timing it from "currently centred" would leave both directions of an axis + // permanently gating each other). The clock STICK_SETTLE_MS runs from for the opposite direction. + const stickPrev = { left: false, right: false, up: false, down: false }; + const stickReleasedAt = { + left: Number.NEGATIVE_INFINITY, + right: Number.NEGATIVE_INFINITY, + up: Number.NEGATIVE_INFINITY, + down: Number.NEGATIVE_INFINITY, + }; const isDown = (index: number): boolean => { for (const pad of navigator.getGamepads()) { @@ -71,48 +131,97 @@ export function createGamepadController(handlers: GamepadHandlers): GamepadContr * `heldSince === 0` means "not counting yet": that is the released state, and also what a pause leaves * behind, so a direction held across a resume starts its delay from scratch and doesn't burst. */ - const stepHeld = (dir: 'left' | 'right', down: boolean, fire: () => void): void => { + const stepHeld = ( + dir: 'left' | 'right' | 'up' | 'down' | 'x', + down: boolean, + fire: (repeat: boolean) => void, + ): void => { if (!down) { heldSince[dir] = 0; return; } const now = performance.now(); if (!prev[dir] || heldSince[dir] === 0) { - heldSince[dir] = now; + // A direction taken up while the previous auto-move is still warm CONTINUES it: the clock is + // back-dated by the whole delay, so the run picks up at the repeat cadence instead of stalling. + // X is left out — Backspace has nothing to do with the row the hands were just flipping through. + const chained = dir !== 'x' && prev[dir] === false && chain.continues(now); + heldSince[dir] = chained ? now - HOLD_DELAY_MS : now; lastFire[dir] = now; - if (!prev[dir]) fire(); // an edge; resuming onto a held direction is not one + if (!prev[dir]) fire(false); // an edge; resuming onto a held direction is not one return; } if (now - heldSince[dir] < HOLD_DELAY_MS || now - lastFire[dir] < NAV_REPEAT_MS) return; lastFire[dir] = now; - fire(); + if (dir !== 'x') chain.noteRepeat(now); + fire(true); + }; + + type Dir = 'left' | 'right' | 'up' | 'down'; + const OPPOSITE: Readonly<Record<Dir, Dir>> = { + left: 'right', + right: 'left', + up: 'down', + down: 'up', + }; + + /** + * Whether `dir` is pressed, with the spring-back guard applied: a STICK deflection is ignored while the + * opposite direction's own release is still settling. A d-pad press always counts. + */ + const pressed = (dir: Dir, dpad: boolean, stick: boolean, now: number): boolean => { + if (stickPrev[dir] && !stick) stickReleasedAt[dir] = now; // the release EDGE starts the clock + stickPrev[dir] = stick; + if (dpad) return true; + if (!stick) return false; + return now - stickReleasedAt[OPPOSITE[dir]] >= STICK_SETTLE_MS; }; const poll = (): void => { if (!running) return; const x = axis(STICK_X_AXIS); const y = axis(STICK_Y_AXIS); - const left = isDown(BTN.dpadLeft) || x < -STICK_DEADZONE; - const right = isDown(BTN.dpadRight) || x > STICK_DEADZONE; + const now = performance.now(); + const left = pressed('left', isDown(BTN.dpadLeft), x < -STICK_DEADZONE, now); + const right = pressed('right', isDown(BTN.dpadRight), x > STICK_DEADZONE, now); // Standard mapping: stick Y is +down / -up. - const up = isDown(BTN.dpadUp) || y < -STICK_DEADZONE; - const down = isDown(BTN.dpadDown) || y > STICK_DEADZONE; + const up = pressed('up', isDown(BTN.dpadUp), y < -STICK_DEADZONE, now); + const down = pressed('down', isDown(BTN.dpadDown), y > STICK_DEADZONE, now); const a = isDown(BTN.a); const b = isDown(BTN.b); + const yButton = isDown(BTN.y); + const xButton = isDown(BTN.x); + const shoulderLeft = isDown(BTN.shoulderLeft); + const shoulderRight = isDown(BTN.shoulderRight); + const triggerRight = isDown(BTN.triggerRight); // While paused (launcher backgrounded), read inputs but don't act — prev is still updated below, so a // button held across resume won't fire a phantom edge. if (!paused) { stepHeld('left', left, handlers.onLeft); stepHeld('right', right, handlers.onRight); - if (up && !prev.up) handlers.onUp(); - if (down && !prev.down) handlers.onDown(); + stepHeld('up', up, handlers.onUp); + stepHeld('down', down, handlers.onDown); if (a && !prev.a) handlers.onA(); if (b && !prev.b) handlers.onB(); + if (yButton && !prev.y) handlers.onY(); + stepHeld('x', xButton, handlers.onX); + if (shoulderLeft && !prev.shoulderLeft) handlers.onShoulderLeft(); + if (shoulderRight && !prev.shoulderRight) handlers.onShoulderRight(); + if (triggerRight && !prev.triggerRight) handlers.onTriggerRight(); } else { // Paused: forget any hold in progress, so resuming can't drop straight into a repeat burst. heldSince.left = 0; heldSince.right = 0; + heldSince.up = 0; + heldSince.down = 0; + heldSince.x = 0; + } + + // The release edge, reported whether or not we are acting on input: a pause must not leave a consumer + // believing a direction is still held (the launcher is backgrounded — nothing is being flipped). + if ((prev.left || prev.right || prev.up || prev.down) && !(left || right || up || down)) { + handlers.onDirectionsReleased(); } prev.left = left; @@ -121,6 +230,11 @@ export function createGamepadController(handlers: GamepadHandlers): GamepadContr prev.down = down; prev.a = a; prev.b = b; + prev.y = yButton; + prev.x = xButton; + prev.shoulderLeft = shoulderLeft; + prev.shoulderRight = shoulderRight; + prev.triggerRight = triggerRight; rafId = requestAnimationFrame(poll); }; diff --git a/src/renderer/hero.ts b/src/renderer/hero.ts index b75094e2..21b976cf 100644 --- a/src/renderer/hero.ts +++ b/src/renderer/hero.ts @@ -1,10 +1,9 @@ // Hero background subsystem (split out of app.ts). Owns everything about "what image is on // screen and its colors": the two cross-fading hero layers, the shown-url gate, the renderer-local hero -// rotation, the empty/idle wallpaper screen, and the two-color palette (compute + cache + apply). These +// 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 { Translator } from '../shared/i18n/index.js'; import { computePalette, type Palette } from './dominant-color.js'; import { req } from './dom.js'; @@ -16,8 +15,6 @@ export interface HeroDeps { hasGameOnScreen(): boolean; /** The current game's id (for the per-hero palette cache key); '' when none. */ getGameId(): string; - /** The current translator (read live so the empty-screen title follows the language). */ - getTranslator(): Translator; } export interface HeroController { @@ -31,18 +28,41 @@ export interface HeroController { /** New hero payload for the BROWSED game. Same thing, except an empty payload REPLACES the background * (with the wallpaper) instead of leaving the previous game's image up. */ applyBrowseAssets(assets: HeroAssets | null): void; - /** The empty / idle screen: fallback wallpaper background, its palette, "Insert a game card" title. */ - applyEmptyScreen(): void; + /** + * The idle background: the fallback wallpaper and its palette, for a screen with no game on it — the + * carousel standing on one of the launcher's own cards. The TITLE is not touched here: what is written + * there belongs to render() (a launcher card names itself in the same line a game does). + */ + applyIdleBackground(): void; + /** + * Paints the fallback wallpaper as the FIRST background of the session — without claiming the screen + * is empty (no title change): a launcher opening onto a card has nothing to show until its hero data + * URL arrives, and a blank window in the meantime is worse than the wallpaper the game's own hero then + * cross-fades over. No-op once anything is on screen. + */ + showWallpaperBackdrop(): void; /** Stores the fallback wallpaper data URL (delivered by main); does not repaint on its own. */ setWallpaper(url: string | null): void; /** Parallax offset in DESIGN px: the background drifts with the carousel (see #hero in styles.css). */ setParallax(designPx: number): void; + /** + * Whether a direction is being HELD, i.e. the strip is flipping on its own. While it is, the image on + * screen stays exactly where it is — whatever heroes arrive meanwhile are remembered, not painted — + * and the last one lands as soon as the key/stick is let go. Interruptible: the request that arrives + * during the hold is the one that gets shown. + */ + setFlipping(flipping: boolean): void; + /** + * The COMPUTED transform (a matrix) of the layer currently on screen — its bg-pan caught mid-drift. + * The boot backdrop converges on it as it dissolves, so the handover has no offset to give away; see + * the boot reveal in app.ts. + */ + currentLayerTransform(): string; } export function createHeroController(deps: HeroDeps): HeroController { const app = req('app'); const heroPanEl = req('hero-pan'); - const titleEl = req('title'); // Fallback wallpaper (data URL from main) for the empty / idle screen, and its cached palette. let wallpaperUrl: string | null = null; @@ -109,10 +129,90 @@ export function createHeroController(deps: HeroDeps): HeroController { // don't trigger a needless cross-fade / pan re-randomize when the image hasn't actually changed. let shownUrl: string | null = null; - // Cross-fades to a new image on the idle layer, then swaps roles. No-op when the url is unchanged - // (keeps the running pan going). null → no image (blank background). - function showImage(url: string | null): void { - if (url === shownUrl) return; + /** Matches the .hero-layer opacity transition in styles.css — how long a cross-fade owns both layers. */ + const CROSSFADE_MS = 700; + /** + * How long the requested image must stand before it is painted. Deliberately longer than the nav + * repeat (NAV_REPEAT_MS in gamepad.ts), so a HELD left/right never paints a background at all: the + * strip flips, and the hero lands once, on wherever the user stopped. + */ + const SETTLE_MS = 120; + + // What the launcher WANTS on screen, versus what is on it (shownUrl). They differ while a swap waits — + // see requestImage. The palette travels with the image rather than being applied at request time: the + // colors and the picture must never disagree, which is what a straight apply would do while flipping. + let desiredUrl: string | null = null; + let desiredPaint: (() => void) | null = null; + let swapTimer: number | null = null; + let lastSwapAt = Number.NEGATIVE_INFINITY; + // A direction is being held (controls.ts tells us). SETTLE_MS alone almost covers this — the repeat is + // faster than it — but "almost" depends on the OS keyboard repeat rate, which is the user's setting, + // not ours. The held state says it outright: no swap at all until the flip stops. + let flipping = false; + + /** + * Asks for an image (and the palette that goes with it). The swap is deferred twice over: until the + * request has stood still for SETTLE_MS, and until the previous cross-fade has finished. Painting into + * a layer that is still fading is what made a fast card change snap — the incoming layer is visible by + * then, so swapping its background-image replaces the picture instantly, with no fade at all. + */ + function requestImage(url: string | null, paintPalette: () => void): void { + if (url === desiredUrl) { + // The same image asked for again (a re-render, a language change). No cross-fade — but the palette + // may still need re-applying, unless the swap to it hasn't happened yet, where it is the swap's job. + if (shownUrl === desiredUrl) paintPalette(); + else desiredPaint = paintPalette; + return; + } + desiredUrl = url; + desiredPaint = paintPalette; + // The session's FIRST image has nothing to cross-fade with and nobody waiting to see it settle. + if (shownUrl === null && swapTimer === null && !flipping) runSwap(); + else armSwap(); + } + + function armSwap(): void { + if (swapTimer !== null) { + window.clearTimeout(swapTimer); + swapTimer = null; + } + // Held: the swap is re-armed by setFlipping when the direction is released, with whatever the last + // request turned out to be. + if (flipping) return; + const waitForFade = lastSwapAt + CROSSFADE_MS - performance.now(); + swapTimer = window.setTimeout(runSwap, Math.max(SETTLE_MS, waitForFade)); + } + + function setFlipping(next: boolean): void { + if (flipping === next) return; + flipping = next; + if (flipping) { + if (swapTimer !== null) { + window.clearTimeout(swapTimer); + swapTimer = null; + } + return; + } + if (desiredUrl !== shownUrl) armSwap(); + } + + function runSwap(): void { + if (swapTimer !== null) { + window.clearTimeout(swapTimer); + swapTimer = null; + } + const paint = desiredPaint; + desiredPaint = null; + if (desiredUrl !== shownUrl) { + lastSwapAt = performance.now(); + swapLayers(desiredUrl); + } + paint?.(); + } + + // Cross-fades to a new image on the idle layer, then swaps roles. Only ever called from runSwap, which + // owns the timing; null → no image (blank background). + function swapLayers(url: string | null): void { shownUrl = url; // The incoming (idle) layer gets the new image + a fresh random pan direction (drift left vs right). idleLayer.style.backgroundImage = url !== null ? `url("${url}")` : 'none'; @@ -131,17 +231,30 @@ export function createHeroController(deps: HeroDeps): HeroController { idleLayer = previousActive; } - // The empty / idle screen (no game): the fallback wallpaper as background, its dominant colors as - // the palette, and "Insert a game card" as the title. Reuses the main screen's bottom bar layout. - function applyEmptyScreen(): void { - titleEl.textContent = deps.getTranslator()('launcher.emptyTitle'); + // The idle background (no game on screen): the fallback wallpaper, with its dominant colors as the + // palette. Reuses the main screen's bottom bar layout; the title line is render()'s business. + function applyIdleBackground(): void { + // Nothing is on screen, so the heroes held for whatever WAS are no longer about anything. Dropping + // them here is what keeps them from coming back: the browse cursor arrives on the instant channel + // and the pictures on the debounced one, so a game reached after an empty screen (the Library, a + // launcher card) would be painted with the PREVIOUS game's background for as long as that debounce + // lasts — about a second of another game's artwork under this game's name. + heroImages = []; + heroIndex = 0; + stopRotation(); if (wallpaperUrl === null) { - showImage(null); - applyPalette(null); + requestImage(null, () => applyPalette(null)); return; } - showImage(wallpaperUrl); - applyWallpaperPalette(); + requestImage(wallpaperUrl, applyWallpaperPalette); + } + + // The wallpaper as the opening backdrop: the same paint as the idle background, under a gate — it only + // ever paints into a screen that has nothing on it yet, so it can never override a hero that arrived + // first. One source of behaviour, two entry conditions. + function showWallpaperBackdrop(): void { + if (shownUrl !== null || desiredUrl !== null) return; + applyIdleBackground(); } // ── Hero rotation (renderer-local, GTA-5 cadence) ────────────────────────── @@ -156,13 +269,11 @@ export function createHeroController(deps: HeroDeps): HeroController { function showHeroAt(index: number): void { const url = heroImages[index]; if (url === undefined) return; - showImage(url); - if (url === wallpaperUrl) { - applyWallpaperPalette(); - return; - } const id = deps.getGameId(); - updatePaletteFor(url, `${id}#${index}`); + requestImage(url, () => { + if (url === wallpaperUrl) applyWallpaperPalette(); + else updatePaletteFor(url, `${id}#${index}`); + }); } // Rotation runs only with >1 image, the window visible, and a game on screen (symmetric to the music @@ -203,7 +314,7 @@ export function createHeroController(deps: HeroDeps): HeroController { heroImages = assets?.images ?? []; 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 in the Configure window and saves. The palette cache is keyed by + // 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.) paletteCache.clear(); @@ -217,10 +328,8 @@ export function createHeroController(deps: HeroDeps): HeroController { // means something else entirely ("no card any more"), and there the old image may stay until the // browse cursor lands somewhere — hence the flag rather than one rule for both. else if (replaceWhenEmpty) { - if (wallpaperUrl !== null) { - showImage(wallpaperUrl); - applyWallpaperPalette(); - } else showImage(null); + if (wallpaperUrl !== null) requestImage(wallpaperUrl, applyWallpaperPalette); + else requestImage(null, () => applyPalette(null)); } } startRotation(); @@ -242,13 +351,20 @@ export function createHeroController(deps: HeroDeps): HeroController { heroPanEl.style.setProperty('--hero-parallax', `calc(${designPx} * var(--px))`); } + function currentLayerTransform(): string { + return getComputedStyle(activeLayer).transform; + } + return { repaint, startRotation, applyAssets, applyBrowseAssets: (assets) => applyAssets(assets, true), - applyEmptyScreen, + applyIdleBackground, + showWallpaperBackdrop, setWallpaper, setParallax, + setFlipping, + currentLayerTransform, }; } diff --git a/src/renderer/hover-guard.ts b/src/renderer/hover-guard.ts new file mode 100644 index 00000000..033cfaad --- /dev/null +++ b/src/renderer/hover-guard.ts @@ -0,0 +1,46 @@ +// The "did the MOUSE move, or did the UI move under it?" guard, shared by every surface that opens on +// top of another one (Settings, Customize, the file picker, the on-screen keyboard). +// +// A surface that opens under a resting cursor makes Chromium fire pointer events at it: the element +// moved, not the mouse. Taken as hover, that drags the focus off whatever the surface just focused (the +// current value, the bottom button) — the "it opened and blinked" stutter, reproducible by simply parking +// the mouse where an item will appear. Comparing against the previous event's coordinates is not enough +// on its own: the guard has to already KNOW where the pointer is, which is why `track` runs from a +// window-level listener that keeps going while everything is closed. +// +// So opening ARMS the guard at the pointer's current position, and hover stays asleep until the mouse has +// actually travelled HOVER_WAKE_PX from there. + +/** How far the pointer must travel before hover may take the focus again. */ +const HOVER_WAKE_PX = 6; + +export interface HoverGuard { + /** Records where the pointer is (call from a window-level mousemove, even while closed). */ + track(x: number, y: number): void; + /** Called whenever a surface opens or a key/pad step lands: hover sleeps until the pointer leaves. */ + arm(): void; + /** Whether this move is the user's, rather than the UI arriving under a still pointer. */ + awake(x: number, y: number): boolean; +} + +export function createHoverGuard(): HoverGuard { + let pointerX = -1; + let pointerY = -1; + let armedAt: { readonly x: number; readonly y: number } | null = null; + + return { + track: (x, y) => { + pointerX = x; + pointerY = y; + }, + arm: () => { + armedAt = { x: pointerX, y: pointerY }; + }, + awake: (x, y) => { + if (armedAt === null) return true; + if (Math.hypot(x - armedAt.x, y - armedAt.y) < HOVER_WAKE_PX) return false; + armedAt = null; + return true; + }, + }; +} diff --git a/src/renderer/index-math.ts b/src/renderer/index-math.ts new file mode 100644 index 00000000..b8367cec --- /dev/null +++ b/src/renderer/index-math.ts @@ -0,0 +1,15 @@ +// The two ways a focus index moves, as pure functions: clamped (a list, where the ends are walls) and +// wrapped (a popup stack or an expanded dropdown, where they meet). Shared by the launcher's screens so +// "does it stop or does it cycle?" is one decision per surface rather than a formula rewritten per file. + +/** Steps `index` by `delta`, stopping at either end. An empty list stays at 0. */ +export function clampIndex(index: number, delta: number, length: number): number { + if (length <= 0) return 0; + return Math.min(length - 1, Math.max(0, index + delta)); +} + +/** Steps `index` by `delta`, wrapping around both ends. An empty list stays at 0. */ +export function wrapIndex(index: number, delta: number, length: number): number { + if (length <= 0) return 0; + return (((index + delta) % length) + length) % length; +} diff --git a/src/renderer/index.html b/src/renderer/index.html index bf889cb9..7088f363 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -1,5 +1,8 @@ <!DOCTYPE html> -<html lang="en"> +<!-- mouse-asleep: the launcher opens with the mouse switched off — a pointer parked over the UI is not + input until it is deliberately shoved (mouse-sleep.ts). Set here, not from JS, so a resting cursor + cannot hover anything in the frames before controls.ts runs. --> +<html lang="en" class="mouse-asleep"> <head> <meta charset="UTF-8" /> <meta @@ -11,7 +14,10 @@ <link rel="stylesheet" href="./styles.css" /> </head> <body> - <main id="app" data-phase="idle"> + <!-- data-boot: the launcher opens on the background alone and reveals its UI once the first state + + hero (and the palette computed from it) have landed — see the boot reveal in app.ts. Set here, + not from JS, so there is never a frame of a fully-drawn UI before the script runs. --> + <main id="app" data-phase="idle" data-boot="loading"> <!-- Hero background: two stacked layers behind all UI (z-index only on the container, see styles.css). JS writes the image + pan into the idle layer and cross-fades it in, GTA-5-style, rotating through multiple hero images (and the idle wallpaper) — see app.ts. --> @@ -23,18 +29,30 @@ <div class="hero-layer"></div> <div class="hero-layer"></div> </div> + <!-- The boot backdrop: the bundled wallpaper on a layer of ITS OWN, on top of the hero layers + for the opening seconds. The game's hero is painted underneath it the moment it arrives, so + when this one dissolves there is a settled background behind it — nothing has to move back + into place. See the boot reveal in app.ts. --> + <div id="hero-boot"></div> </div> - <!-- History carousel (the `carousel` screen): a horizontal strip of game cards sliding under a - fixed anchor — the selected card stays put, the row moves. The inserted card's games come first + <!-- History carousel (the `carousel` screen): a horizontal strip of cards sliding under a fixed + anchor — the selected card stays put, the row moves. The inserted card's games come first (marked with a dot: they can be launched right now), then the games played on this device - before. Cards are built from JS (carousel.ts); artwork is fetched per card, on demand. --> + before, and last the launcher's own cards — Notifications / Settings / System (system-cards.ts). + Every card is built from JS (carousel.ts); artwork is fetched per game card, on demand. --> <div id="carousel" aria-hidden="true"> - <div id="carousel-strip"></div> + <div id="carousel-strip"> + <!-- The focus indicator: a soft body UNDER the covers, drawn on a canvas that rides inside the + strip, so it inherits the row's slide and its fades (focus-jelly.ts). --> + <canvas id="carousel-jelly" aria-hidden="true"></canvas> + </div> </div> <!-- Bottom bar (same layout for every screen): play / title / status + the More button on the - right. The empty (no-card) screen shows no Play; More there opens System + Close only. --> + right. On the carousel neither is shown — Play is the selected card's stand-in for the morph + and More is hidden (styles.css): the title line names the selected card, and the launcher's + own actions are cards in the row. --> <footer id="bottom-bar"> <div class="bar-content"> <!-- Play is a rounded square now; aria is STATIC ("Play") — the install action moved into the @@ -63,7 +81,8 @@ <div id="title" class="title"></div> <!-- More button: same rect-button component (invert-on-hover), three dots. Opens the Details - menu popup. Hidden on the empty screen; fades out while busy (like the old Info button). --> + menu popup — the GAME's menu, so it lives on the detail screen only (hidden on the carousel + by a rule in styles.css). --> <button id="more-button" class="rect-button" type="button" aria-label="More" data-i18n-aria-label="launcher.aria.more"> <svg class="icon-more" viewBox="0 0 56 16" aria-hidden="true"> <path d="M8 0C12.4183 0 16 3.58172 16 8C16 12.4183 12.4183 16 8 16C3.58172 16 0 12.4183 0 8C0 3.58172 3.58172 0 8 0Z" /> @@ -76,11 +95,14 @@ </footer> <!-- Unified popup: ONE frosted veil + ONE right-side column (550px), whose content and action stack - switch by data-view (details / power / confirm / error) — a mirror of the popup state machine in - controls.ts. A single veil (vs one per section) avoids the cross-fade flicker of two identical + switch by data-view (details / power / notifications / confirm / error) — a mirror of the popup + state machine in controls.ts. `details` is a game's menu (reached from More on the detail + screen); `power` and `notifications` are opened straight from their carousel cards. A single veil (vs one per section) avoids the cross-fade flicker of two identical blur veils when moving Details ↔ Power: the veil stays, only the content changes. Default focus in every stack is the BOTTOM button (Close / No) — see controls.ts. --> <section id="popup" class="popup" aria-hidden="true" data-view="details"> + <!-- Two layers, on purpose: the tint fades, the frost switches on in one frame (styles.css). --> + <div class="popup-blur"></div> <div class="popup-veil"></div> <div class="popup-column"> <!-- Top content — only the block for the active view shows (CSS). --> @@ -108,8 +130,18 @@ <span class="note-prefix" data-i18n="launcher.confirm.uninstallPrefixNote" >The game stays on the card - only the prefix is removed (saves inside it too).</span > + <!-- Deleting a game from its manifest. Text from JS: a LOCAL game's save backups outlive + the deletion (gcOrphans sweeps artwork, never saves/), and saying so is the whole + point of the note — so the two wordings differ by source. --> + <span class="note-delete" id="delete-note"></span> </div> </div> + <!-- busy: work in progress that the user may stop — a download about to become a file next + to a game. Its own view rather than a plate inside the surface that started it, so that + everything this launcher says lives in one column. --> + <div class="popup-busy"> + <div class="popup-message" id="busy-message"></div> + </div> <!-- error: title + detail (arbitrary error text from main). --> <div class="popup-error"> <div class="popup-message" data-i18n="launcher.errorTitle">Something went wrong</div> @@ -121,39 +153,243 @@ Each group is a vertical stack (align-items: flex-end): a longer localized label grows to the LEFT, the right edge stays fixed. --> <div class="popup-actions"> - <div class="actions-group" data-group="details"> - <button id="menu-shutdown" class="text-button" type="button" data-i18n="launcher.menu.system">System</button> + <!-- ORDER MATTERS, and it is not thematic: the items that APPEAR AND DISAPPEAR with the + game's phase (install/uninstall, force close, remove from history) are at the TOP, and + the ones that are always there sit in a fixed block just above Close. The stack is + bottom-focused and bottom-anchored, so anything volatile near the bottom shifts the + stable items under the user's thumb between visits — which is how "Customize" got + mis-pressed as "Uninstall". --> + <div class="actions-group" data-group="details" id="menu-stack"> <!-- Install/Uninstall — one button; its text + visibility come from JS (executable games have no install block, so the button is hidden entirely). --> <button id="menu-install-toggle" class="text-button" type="button"></button> <!-- Force-close the running game — its text + visibility come from JS (shown only while a game is running), so no data-i18n (mirrors the install toggle, keeps the i18n HTML test happy). --> <button id="menu-kill" class="text-button" type="button"></button> + <!-- Drop this game from the history — shown only for a game that is NOT on the card and not + in the PC library (those are playable, not history). Text from JS, like its neighbours. --> + <button id="menu-forget" class="text-button" type="button"></button> <!-- Back to the history carousel — the mouse counterpart of the B button (shown only on a detail screen that HAS a carousel to return to). Text from JS, like its neighbours. --> - <button id="menu-library" class="text-button" type="button"></button> + <button id="menu-home" class="text-button" type="button"></button> + <!-- Customize — the per-game manifest editor, offered only for a game that is on the card + or in the local library (an `active` game is the only one whose file we can reach). --> + <button id="menu-customize" class="text-button" type="button"></button> <button id="menu-close" class="text-button" type="button" data-i18n="launcher.menu.close">Close</button> </div> <div class="actions-group" data-group="power"> <button id="power-shutdown" class="text-button" type="button" data-i18n="launcher.menu.shutdown">Shutdown</button> <button id="power-reboot" class="text-button" type="button" data-i18n="launcher.menu.reboot">Reboot</button> <button id="power-sleep" class="text-button" type="button" data-i18n="launcher.menu.sleep">Sleep</button> - <!-- Label set from JS (applyPowerPrimary): "Minimize Playhook" on Desktop/Windows, "Close - Playhook" (full quit) in Game Mode where there is no tray to minimize into. No data-i18n - so it stays out of the i18n HTML test and a language change relabels it at render time. --> - <button id="power-minimize" class="text-button" type="button">Minimize Playhook</button> + <!-- Hidden in Game Mode (applyPowerItems): there is no tray to minimize into there, so the + quit below is the only way out. --> + <button id="power-minimize" class="text-button" type="button" data-i18n="launcher.menu.minimize"> + Minimize Playhook + </button> + <button id="power-quit" class="text-button" type="button" data-i18n="launcher.menu.quit">Close Playhook</button> <button id="power-close" class="text-button" type="button" data-i18n="launcher.menu.close">Close</button> </div> + <!-- Notifications: the list is built from JS (the inbox arrives on notifications:update), + the two buttons under it are fixed. Pressing an entry removes it and performs its + action; "Clear all" empties the inbox and leaves the popup open on its empty state. --> + <div class="actions-group" data-group="notifications"> + <div id="notification-list" class="notification-list"></div> + <button id="notifications-clear" class="text-button" type="button" data-i18n="notifications.clearAll">Clear all</button> + <button id="notifications-close" class="text-button" type="button" data-i18n="launcher.menu.close">Close</button> + </div> <div class="actions-group" data-group="confirm"> <button id="confirm-yes" class="text-button" type="button" data-i18n="common.yes">Yes</button> <button id="confirm-no" class="text-button" type="button" data-i18n="common.no">No</button> </div> + <div class="actions-group" data-group="busy"> + <button id="busy-stop" class="text-button" type="button" data-i18n="common.stop">Stop</button> + </div> <div class="actions-group" data-group="error"> <button id="error-close" class="text-button" type="button" data-i18n="launcher.menu.close">Close</button> </div> </div> </div> </section> + + <!-- Notification toast: a plate in the top-right corner for 3s. It lives INSIDE #app on purpose — + that is where hero.ts writes the live --d1/--d2 palette, so the plate is painted in the current + game's colours and inherits their 1s crossfade. Outside #app the variables would still + resolve, but to the static :root fallbacks, and the colour would freeze. + Two nested elements, the same trick #hero / #hero-pan uses: the outer owns the entrance and + exit (translateX + opacity), the inner the pulse (scale) — one node cannot animate two + transforms independently. Not interactive (pointer-events: none): there is nothing to press, + notifications are clickable only inside the popup. --> + <div id="toast" class="toast" aria-hidden="true"></div> + + <!-- Settings: the fourth surface (not a carousel screen — the strip keeps its position underneath). + Openness is published as #app[data-overlay="settings"]; the veil + the column are built from + JS (settings-screen.ts) into #settings-list. Hidden state is opacity + pointer-events, never + display:none — see the plan §3.1. --> + <section id="settings" class="settings" aria-hidden="true"> + <div class="settings-veil"></div> + <div class="settings-column"> + <div class="settings-header"> + <div class="settings-title" data-i18n="window.settings">Settings</div> + <div class="settings-version" id="settings-version"></div> + </div> + <!-- TWO columns: the screen's sections (and the actions that end it) on the left, the rows of + the selected section on the right. One long scroll put Reset and Close behind the whole + form — see screen-sidebar.ts. --> + <div class="settings-body"> + <div class="settings-nav" id="settings-nav"></div> + <div class="settings-list" id="settings-list"></div> + </div> + </div> + <!-- The dropdown's frost lives OUTSIDE the fading container: its own opacity animation would + otherwise fade the blur along with everything else, which is the cost we are avoiding. --> + <div class="settings-options-blur"></div> + <!-- Expanded dropdown: a list of the focused select's options, on top of the screen. --> + <div class="settings-options" id="settings-options" aria-hidden="true"> + <div class="settings-options-veil"></div> + <div class="settings-options-list" id="settings-options-list"></div> + </div> + </section> + + <!-- Customize: the per-game manifest editor, the fifth surface. Structurally a twin of #settings + (same .settings-* skeleton, same veil + column), because it IS the same kind of screen — only + its rows and its data differ. Its own openness value is #app[data-overlay="game-settings"]; + every CSS rule that makes an overlay visible keys off the ATTRIBUTE, not off one value, so + adding this screen needed no second copy of them (see styles.css). + The keyboard and the file browser below live INSIDE it: data-overlay holds one value at a time, + so a surface with a value of its own would extinguish the screen underneath it. --> + <section id="game-settings" class="settings" aria-hidden="true"> + <div class="settings-veil"></div> + <div class="settings-column"> + <div class="settings-header"> + <!-- Set from JS (game-settings-screen.ts render/relocalize), not via data-i18n: this screen + has TWO names — "Customize" for a game that exists, "Add game" while one is being + created — and localizeDocument would overwrite the mode-aware value on every language + push. The English text here is only the pre-seed fallback, as elsewhere. --> + <div class="settings-title" id="game-settings-title">Customize</div> + <!-- The game's own name, from JS: it says WHOSE settings these are, which matters the moment + a card carries more than one game — followed, in the same breath, by where its manifest + lives. ONE group, so the two read as a phrase ("Hades (E:\)") instead of drifting to + opposite ends of a space-between header. The source used to be a row in the form, which + put a read-only fact among the editable ones. --> + <div class="settings-heading"> + <span class="settings-source" id="game-settings-source"></span> + <span class="settings-version" id="game-settings-heading"></span> + </div> + </div> + <div class="settings-body"> + <div class="settings-nav" id="game-settings-nav"></div> + <div class="settings-list" id="game-settings-list"></div> + </div> + <!-- Save's own feedback and the validation summary: they belong to the SCREEN, not to any one + section, so they sit under both columns and stay readable wherever the focus is. --> + <div class="settings-status" id="game-settings-status"></div> + </div> + <div class="settings-options-blur"></div> + <!-- One column menu for three jobs: an expanded dropdown, a path row's Browse/Clear, and the list + editor (whose levels stack inside it). --> + <div class="settings-options" id="game-settings-options" aria-hidden="true"> + <div class="settings-options-veil"></div> + <div class="settings-options-list" id="game-settings-options-list"></div> + </div> + + <!-- File browser. The native dialog cannot be driven with a gamepad over a fullscreen window, and + in Game Mode it is a dead end — so browsing lives here, read-only, with the acceptance checks + in main (see the plan, Р5). --> + <div class="picker" id="file-picker" aria-hidden="true"> + <div class="picker-veil"></div> + <div class="picker-panel"> + <div class="picker-title" id="picker-title"></div> + <div class="picker-path" id="picker-path"></div> + <div class="picker-body"> + <div class="picker-roots" id="picker-roots"></div> + <div class="picker-entries" id="picker-entries"></div> + </div> + <div class="picker-legend" id="picker-legend"></div> + </div> + </div> + + <!-- The artwork gallery of "Find online": what the online sources offer for this game, as a + grid of thumbnails. Every picture arrives as a data: URL main downloaded and encoded — the + renderer has no network of its own (see the CSP above). --> + <!-- "Find online": one surface for the game, its cover, its backgrounds and its soundtrack. + The left column carries the game, the section, that section's filters and its actions; the + right one carries whatever the section is about. Both are built in JS, since their rows + depend on which section is open. --> + <div class="picker metadata-picker" id="online-picker" aria-hidden="true"> + <div class="picker-veil"></div> + <div class="picker-panel"> + <div class="picker-title" id="online-picker-title"></div> + <div class="picker-body"> + <div class="metadata-side" id="online-picker-side"></div> + <div class="picker-entries online-content" id="online-picker-content"></div> + </div> + </div> + </div> + + <!-- Artwork at full size. A row's thumbnail is 96px wide — enough to see that a background is + set, nowhere near enough to see WHICH one, which is the question the user actually has. --> + <div class="lightbox" id="lightbox" aria-hidden="true"> + <div class="lightbox-veil"></div> + <img class="lightbox-image" id="lightbox-image" alt="" /> + <div class="lightbox-caption" id="lightbox-caption"></div> + </div> + </section> + + <!-- Library: the whole game list as a grid of covers, the sixth surface. Structurally another twin + of #settings (veil + column + sidebar), but its pane is a scroller full of cards rather than a + list of rows, and it runs to the RIGHT EDGE of the screen so the gap after the sidebar and the + gap before the edge read as equal. Its openness value is #app[data-overlay="library"]. + #library-empty sits OUTSIDE the scroller on purpose: screen-scroller measures the pane's last + child, and a hidden node at the tail would zero the bottom fade. --> + <section id="library" class="settings" aria-hidden="true"> + <div class="settings-veil"></div> + <div class="settings-column"> + <div class="settings-header"> + <div class="settings-title" data-i18n="launcher.card.library">Library</div> + </div> + <div class="settings-body"> + <div class="settings-nav" id="library-nav"></div> + <div class="library-pane"> + <!-- The grid's focus body, the strip's twin. It sits OUTSIDE the scroller on purpose: a + canvas as tall as a library of hundreds would cost tens of megabytes, so this one is + the size of the viewport and the scroll offset is folded into the coordinates. --> + <canvas id="library-jelly" class="is-hidden" aria-hidden="true"></canvas> + <div class="library-scroll" id="library-scroll"> + <div class="library-grid" id="library-grid"></div> + </div> + <div class="library-empty" id="library-empty" aria-hidden="true"></div> + </div> + </div> + </div> + </section> + + <!-- On-screen keyboard. There is no <input> anywhere else in this UI (the CSS forbids a caret at + all), and the Steam Deck's system keyboard is only available to games launched FROM Steam — so + on a gamepad this is the only way to type. + + It sits OUTSIDE every screen, unlike the file browser and the artwork gallery inside Customize: TWO + screens open it now (Customize for every text field, Settings for the SteamGridDB key), and a + screen is invisible unless data-overlay names it — so a keyboard living inside Customize was + opened, sounded and focused while staying at opacity 0 over the Settings screen. Its own + z-index puts it above both. --> + <div class="osk" id="osk" aria-hidden="true"> + <div class="osk-veil"></div> + <div class="osk-panel"> + <div class="osk-title" id="osk-title"></div> + <!-- The value is drawn in TWO halves with the caret between them, so the caret can sit + anywhere in the text and a click can be resolved against the half it landed in. The + halves live inside one inline wrapper so a long value still wraps as one paragraph. --> + <div class="osk-field" id="osk-field"> + <span class="osk-line" + ><span id="osk-value"></span><span class="osk-caret" id="osk-caret"></span + ><span id="osk-value-after"></span + ></span> + </div> + <div class="osk-keys" id="osk-keys"></div> + <div class="osk-legend" id="osk-legend"></div> + </div> + </div> + </main> <script type="module" src="./app.js"></script> </body> diff --git a/src/renderer/library-grid.ts b/src/renderer/library-grid.ts new file mode 100644 index 00000000..ab46b064 --- /dev/null +++ b/src/renderer/library-grid.ts @@ -0,0 +1,107 @@ +/** + * Pure geometry and stepping rules of the Library grid, in DESIGN pixels (the same grid + * carousel-geometry.ts works in — the renderer multiplies by `--px`, see styles.css). No DOM, so the + * maths is unit-testable. + * + * The grid does NOT move under the selection the way the carousel's strip does: the cards stand still and + * the highlight walks them, so every step is a pure index move plus a verdict for the caller (sound, or a + * hand-over to the sidebar). + */ +import { clampIndex } from './index-math.js'; +import type { LibraryEntry } from '../shared/types.js'; + +/** Card size of the grid (Figma "Library"), and the gap between cards. */ +export const LIB_CARD_W = 200; +export const LIB_CARD_H = 300; +export const LIB_GAP = 24; + +/** How much the selected card grows in place. MIRRORS `--card-scale` on `.card.is-selected` in styles.css. */ +export const LIB_CARD_SCALE = 1.06; + +/** How many rows around the selected one keep their artwork loaded (see isNearInGrid). */ +export const LIB_ART_ROWS = 4; + +/** + * What a step did: it moved the selection, it hit a wall (the caller sounds `limit`), or it walked off the + * left edge and the sidebar takes the focus. + */ +export type GridMove = 'moved' | 'at-end' | 'to-sidebar'; + +export type GridDir = 'left' | 'right' | 'up' | 'down'; + +export interface GridStep { + readonly index: number; + readonly result: GridMove; +} + +/** Which section of the library is shown. */ +export type LibraryFilter = 'all' | 'playable'; + +/** + * How many columns fit into `innerWidth` design px. The card size is fixed and the count follows from the + * screen, because `--px` is tied to the HEIGHT: a 16:9 screen is 1920 design px wide and a Steam Deck's + * 16:10 one only 1728, so the same layout yields 6 columns there and 5 here. Never below 1. + */ +export function gridColumns(innerWidth: number, cardW = LIB_CARD_W, gap = LIB_GAP): number { + const fits = Math.floor((innerWidth + gap) / (cardW + gap)); + return Math.max(1, fits); +} + +/** Which row card `index` sits in. */ +export function rowOf(index: number, cols: number): number { + if (cols <= 0) return 0; + return Math.floor(index / cols); +} + +/** + * Where one press lands. Left off the first column hands over to the sidebar; every other edge is a dead + * end that stops rather than wrapping onto the neighbouring row (the mockup's rule: a row is a row). Down + * from the last full row lands on the last card, so a ragged final row still catches the focus. + */ +export function gridStep(index: number, dir: GridDir, count: number, cols: number): GridStep { + if (count <= 0 || cols <= 0) return { index: 0, result: 'at-end' }; + const current = clampIndex(index, 0, count); + const column = current % cols; + const row = rowOf(current, cols); + const lastRow = rowOf(count - 1, cols); + if (dir === 'left') { + if (column === 0) return { index: current, result: 'to-sidebar' }; + return { index: current - 1, result: 'moved' }; + } + if (dir === 'right') { + if (column === cols - 1 || current + 1 >= count) return { index: current, result: 'at-end' }; + return { index: current + 1, result: 'moved' }; + } + if (dir === 'up') { + if (row === 0) return { index: current, result: 'at-end' }; + return { index: current - cols, result: 'moved' }; + } + if (row === lastRow) return { index: current, result: 'at-end' }; + return { index: Math.min(current + cols, count - 1), result: 'moved' }; +} + +/** + * Whether card `index` is close enough to the selection to be worth holding its artwork. Counted in ROWS, + * not in cards: the grid scrolls vertically, so a window of rows is what the viewport actually walks + * through. Bounded, or a library of hundreds would decode every cover it ever passed. + */ +export function isNearInGrid( + index: number, + selected: number, + cols: number, + radiusRows = LIB_ART_ROWS, +): boolean { + return Math.abs(rowOf(index, cols) - rowOf(selected, cols)) <= radiusRows; +} + +/** + * The games of one section, in main's order — the renderer never re-sorts (see orderForCarousel). + * "Ready to play" is the games on the inserted card; "All" is everything, history included. + */ +export function filterLibrary( + games: readonly LibraryEntry[], + filter: LibraryFilter, +): readonly LibraryEntry[] { + if (filter === 'all') return games; + return games.filter((game) => game.active && game.unconfigured !== true); +} diff --git a/src/renderer/library-screen.ts b/src/renderer/library-screen.ts new file mode 100644 index 00000000..b3f217f7 --- /dev/null +++ b/src/renderer/library-screen.ts @@ -0,0 +1,810 @@ +// The Library screen's controller: the sixth surface of the launcher. It shows the WHOLE game list as a +// grid of covers — everything main sent, history included — where the carousel only ever shows a row of +// it, and it is the one place a game can be added from. +// +// Structurally it is the Settings screen's twin (the same veil + column + sidebar, the same six +// primitives for controls.ts to route into) with one difference that shapes everything here: its pane +// holds hundreds of covers rather than a dozen rows. That is why the maths of a step lives in +// library-grid.ts, the artwork behind a bounded cache with a request queue in card-art.ts, and this +// module only paints what the two decide. +import type { LibraryEntry } from '../shared/types'; +import type { MessageKey, Translator } from '../shared/i18n/index.js'; +import { type AudioController } from './audio.js'; +import { artKey, type CardArtCache } from './card-art.js'; +import { req, reqCanvas } from './dom.js'; +import { FALLBACK_COLOUR, createFocusJelly, jellyBoxOf, type JellyBox } from './focus-jelly.js'; +import { clampIndex } from './index-math.js'; +import { + filterLibrary, + LIB_CARD_SCALE, + gridColumns, + gridStep, + isNearInGrid, + type GridDir, + type LibraryFilter, +} from './library-grid.js'; +import type { NavSurface } from './nav-surface.js'; +import { createScroller, pxUnit } from './screen-scroller.js'; +import { createSidebar, type SidebarEntry } from './screen-sidebar.js'; + +/** + * How long the grid takes to scroll one row on a SINGLE press — the morph's own duration, so the glide + * and the card's growth are one movement. A held direction overrides it with --flip-step (see step()). + */ +const SINGLE_STEP_MS = 240; +/** Fallback for --flip-step, should the property not be readable yet (controls.ts writes it at startup). */ +const FLIP_STEP_FALLBACK_MS = 143; +/** How long a card that left the section fades for before its node goes (mirrors .is-leaving in CSS). */ +const LEAVE_MS = 220; +/** How long the staggered arrival of a section runs before the marks come off (mirrors .is-entering). */ +const ENTRANCE_MS = 700; +/** The stagger stops counting here: past a dozen cards the wave is a wait, not a wave. */ +const ENTRANCE_STEPS = 11; +/** How long the grid waits before drawing the section the column moved onto (see previewTimer). */ +const PREVIEW_MS = 120; + +export interface LibraryScreenDeps { + readonly audio: AudioController; + getTranslator(): Translator; + /** The covers, bounded and queued — this screen is its only user (see card-art.ts). */ + readonly art: CardArtCache; + /** The current game list, for the first paint (later ones arrive through setGames). */ + getGames(): readonly LibraryEntry[]; + /** A game was activated — app.ts opens its detail screen and remembers where it came from. */ + onOpenGame(id: string): void; + /** The "Add game" entry — controls.ts hands over to the Customize screen in add mode. */ + onAddGame(): void; + /** The screen closed itself (B / Close) — controls.ts restores the bar focus. */ + onClosed(): void; +} + +export interface LibraryScreen extends NavSurface { + /** + * A fresh visit: the first section, the first game, the top of the grid. `focusId` lands on one game + * instead — a game just added, which is the one thing the user is looking for the moment they arrive. + */ + open(options?: { readonly focusId?: string }): void; + /** Back from the detail screen (or from Add game): the screen returns exactly as it was left. */ + restore(): void; + /** `silent` is a hand-over to another surface, which sounds and re-focuses for itself. */ + close(silent?: boolean): void; + /** A new game list from main (a card went in or out) — the grid re-flows, the selection stays put. */ + setGames(games: readonly LibraryEntry[]): void; + /** The game AppState is busy with, so its dot pulses here as it does on the carousel. */ + setBusyGame(id: string | null): void; + /** A direction is HELD: artwork loading waits it out, exactly as it does in the carousel. */ + setFlipping(flipping: boolean): void; + /** The cover this screen already decoded, for the play button's morph (see carousel.primeArt). */ + artFor(id: string): string | null; +} + +const nodeKey = (id: string): string => `g:${id}`; + +export function createLibraryScreen(deps: LibraryScreenDeps): LibraryScreen { + const app = req('app'); + const appStyle = getComputedStyle(app); + const screen = req('library'); + const gridEl = req('library-grid'); + // The grid's focus body: ONE soft shape that travels from cover to cover, the strip's twin (see + // carousel.ts). Its canvas covers the PANE, not the scrolling content — see #library-jelly. + const jellyCanvas = reqCanvas('library-jelly'); + const scrollEl = req('library-scroll'); + const emptyEl = req('library-empty'); + const scroller = createScroller(scrollEl); + + const t = (): Translator => deps.getTranslator(); + + let open = false; + let filter: LibraryFilter = 'all'; + let games: readonly LibraryEntry[] = []; + let shown: readonly LibraryEntry[] = []; + let index = 0; + let cols = 1; + let busyId: string | null = null; + let flipping = false; + // A list arrived while the screen was away. The grid is NOT re-flowed then: it is still on screen, + // fading out under the detail screen, and cards moving during that fade is what read as a twitch. + // The next open/restore rebuilds it instead. + let stale = false; + /** + * A game the screen was opened ON that the list does not hold YET — a game added seconds ago, whose + * library push has not landed. Honoured by the next setGames and then forgotten; the same race the + * carousel's pendingFocusId exists for. + */ + let pendingFocusId: string | null = null; + // A held direction walks the column faster than the grid can be rebuilt, so the section the column + // moved onto is drawn ONCE, when the movement stops — the same debounce the Settings pane uses for the + // same reason. Short enough that a single press still reads as instant. + let previewTimer = 0; + let previewFilter: LibraryFilter | null = null; + /** Pending end of the arrival wave — the marks come off every POOLED node, see playEntrance. */ + let entranceTimer = 0; + // Every card node ever built, by game id — a POOL, not "what the grid holds right now". A section + // switch only takes nodes out of the grid: their covers are painted on them, and rebuilding a card on + // the way back to "All" would show its title again while the artwork was re-fetched (and, on a held + // direction, not re-fetched at all — loading is paused then). Entries go only when main drops the game. + const nodes = new Map<string, HTMLElement>(); + // The same nodes by ARTWORK key, so an eviction (which knows only the key) finds what to un-paint. + const painted = new Map<string, HTMLElement>(); + // The id the body currently wraps, so a repaint that did not move the selection (a dot, a busy game, + // a language change, a scroll) does not make it squeeze. null while it has nowhere to be. + let jellyId: string | null = null; + + const sidebar = createSidebar(req('library-nav'), { + audio: deps.audio, + onSection: (id, entered) => selectSection(id, entered), + onAction: (id) => runAction(id), + }); + + /** The glide pace: one morph per press, or exactly one repeat interval — linear — while held. */ + function pace(): { readonly durationMs: number; readonly linear: boolean } { + if (!flipping) return { durationMs: SINGLE_STEP_MS, linear: false }; + const raw = getComputedStyle(document.documentElement).getPropertyValue('--flip-step'); + const parsed = Number.parseFloat(raw); + return { + durationMs: Number.isFinite(parsed) && parsed > 0 ? parsed : FLIP_STEP_FALLBACK_MS, + linear: true, + }; + } + + function selectedGame(): LibraryEntry | undefined { + return shown[index]; + } + + /** + * Puts the focus on the game the screen was opened for, if the list holds it by now. Naming a game + * means the user is here to see it, so the focus leaves the sidebar and lands in the grid. + */ + function focusPending(): void { + const wanted = pendingFocusId; + if (wanted === null) return; + const at = shown.findIndex((game) => game.id === wanted); + if (at === -1) return; + pendingFocusId = null; + index = at; + sidebar.setFocused(false); + applyLayout(true); + const game = shown[at]; + const node = game === undefined ? undefined : nodeOf(game); + if (node !== undefined) scroller.reveal(node, true); + } + + function nodeOf(game: LibraryEntry): HTMLElement | undefined { + return nodes.get(nodeKey(game.id)); + } + + /** How many columns fit right now. Measured, not assumed: the width follows the screen's aspect ratio. */ + function measureColumns(): void { + const unit = pxUnit(); + const inner = unit > 0 ? gridEl.clientWidth / unit : 0; + const next = gridColumns(inner); + if (next === cols) return; + cols = next; + gridEl.style.setProperty('--cols', String(cols)); + } + + function buildCard(game: LibraryEntry): HTMLElement { + const card = document.createElement('div'); + card.className = 'card'; + const label = document.createElement('span'); + label.className = 'card-label'; + // Card data is untrusted (it comes from game.json) — textContent, never innerHTML. + label.textContent = game.title; + const dot = document.createElement('span'); + dot.className = 'card-dot'; + card.append(label, dot); + card.setAttribute('aria-label', game.title); + // Two-step, like the carousel's cards: a click on another card selects it, a click on the selected + // one opens it. The position is resolved at click time — the node outlives the list that made it. + card.addEventListener('click', () => { + const at = shown.findIndex((candidate) => candidate.id === game.id); + if (at === -1) return; + if (!sidebar.hasFocus() && at === index) { + activateSelected(); + return; + } + sidebar.setFocused(false); + index = at; + deps.audio.play('navigate'); + applyLayout(); + }); + return card; + } + + function paintArt(node: HTMLElement, url: string): void { + node.style.backgroundImage = `url("${url}")`; + node.classList.add('has-art'); + } + + function clearArt(node: HTMLElement): void { + node.style.removeProperty('background-image'); + node.classList.remove('has-art'); + } + + /** + * Loads the covers of the rows around the selection and drops the requests that left it. Both halves + * matter: main generates a cover synchronously on first sight, so a grid that asked for everything it + * ever scrolled past would stall the process that also answers every other call of the launcher. + */ + function loadWindowArt(): void { + if (!open || flipping) return; + const keep = new Set<string>(); + shown.forEach((game, at) => { + if (!isNearInGrid(at, index, cols)) return; + const key = artKey(game); + keep.add(key); + const node = nodeOf(game); + if (node === undefined) return; + painted.set(key, node); + const cached = deps.art.get(key); + if (cached !== undefined) { + if (cached !== null) paintArt(node, cached); + return; + } + void deps.art.load(key, game.id).then((url) => { + if (url === null) return; + const target = painted.get(key); + if (target !== undefined) paintArt(target, url); + }); + }); + deps.art.dropPending(keep); + } + + /** + * The box the focus body hugs, in the PANE's coordinates. + * + * Three systems meet here: the card's offset inside the grid, the grid's own offset inside the + * scroller (its padding), and how far that scroller has scrolled. The last one is why the canvas can + * stay viewport-sized instead of growing with the library — see #library-jelly in styles.css. + * + * Asked once per frame, so the body follows a grid that is still scrolling and a card that is still + * growing into its 1.06. + */ + function jellyTarget(): JellyBox | null { + const current = selectedGame(); + const node = current === undefined ? undefined : nodeOf(current); + if (node === undefined || !node.isConnected) return null; + const unit = pxUnit(); + const parsed = Number.parseFloat(getComputedStyle(node).borderTopLeftRadius); + const radius = Number.isFinite(parsed) ? parsed : 0; + // The card grows in place by --card-scale, which leaves offsetWidth alone — so the grown size has + // to be worked out rather than read, or the body would hug the card's resting box. + const grown = node.classList.contains('is-selected'); + const scale = grown ? LIB_CARD_SCALE : 1; + const w = node.offsetWidth * scale; + const h = node.offsetHeight * scale; + return jellyBoxOf( + gridEl.offsetLeft + node.offsetLeft - scrollEl.scrollLeft - (w - node.offsetWidth) / 2, + gridEl.offsetTop + node.offsetTop - scrollEl.scrollTop - (h - node.offsetHeight) / 2, + w, + h, + radius * scale, + unit, + ); + } + + const jelly = createFocusJelly(jellyCanvas, { + target: jellyTarget, + colour: () => { + const value = appStyle.getPropertyValue('--d2').trim(); + return value.length > 0 ? value : FALLBACK_COLOUR; + }, + unit: pxUnit, + }); + + /** Fits the canvas to the pane. The body never leaves it: the scroller keeps the selection in view. */ + function sizeJelly(): void { + jelly.resize(scrollEl.clientWidth, scrollEl.clientHeight); + } + + /** + * Points the body at the selected cover — or fades it out when there is nothing to wrap (the focus is + * in the column, the section is empty). `instant` is for the frames it has no business travelling + * through: a fresh open, a section switch, a restore, a resize. + */ + function placeJelly(instant: boolean): void { + const current = !sidebar.hasFocus() ? selectedGame() : undefined; + const id = current?.id ?? null; + jellyCanvas.classList.toggle('is-hidden', id === null); + if (id === null) { + jellyId = null; + return; + } + const moved = jellyId !== null && jellyId !== id; + const first = jellyId === null; + jellyId = id; + // Only a real move squeezes. applyLayout also runs for a dot, a busy game and every scroll frame, + // and a squeeze on those would have the body pulsing at nothing. + if (instant || first) jelly.bump(true); + else if (moved) jelly.bump(); + } + + /** The selection's ring, the dots, and the scroll that keeps the selected card in view. */ + function applyLayout(instant = false): void { + const active = !sidebar.hasFocus(); + const current = selectedGame(); + shown.forEach((game, at) => { + const node = nodeOf(game); + if (node === undefined) return; + node.classList.toggle('is-selected', active && at === index); + node.classList.toggle( + 'shows-dot', + (game.active && game.unconfigured !== true) || game.id === busyId, + ); + node.classList.toggle('is-busy', game.id === busyId); + }); + placeJelly(instant); + const selectedNode = active && current !== undefined ? nodeOf(current) : undefined; + // Only while the screen is actually up: scrolling a grid that is fading out under the screen above + // it moves cards nobody asked to move, right in the user's eye line. + if (open && selectedNode !== undefined) { + if (instant) scroller.reveal(selectedNode, true); + else scroller.revealGlide(selectedNode, pace()); + } + loadWindowArt(); + } + + function applyEmpty(): void { + const key: MessageKey = filter === 'all' ? 'library.empty' : 'library.emptyPlayable'; + emptyEl.textContent = t()(key); + emptyEl.setAttribute('aria-hidden', shown.length === 0 ? 'false' : 'true'); + } + + /** + * Re-flows the grid WITHOUT the cards jumping into place: FLIP, the carousel's trick in two dimensions. + * The inline transform composes translate WITH the scale, or the selected card would collapse to 1 for + * the length of the animation — the literal would replace `scale(var(--card-scale))` from the stylesheet. + */ + function reorderSmoothly(apply: () => void): void { + const before = new Map<string, { readonly left: number; readonly top: number }>(); + for (const [key, node] of nodes) + before.set(key, { left: node.offsetLeft, top: node.offsetTop }); + apply(); + const shifted: HTMLElement[] = []; + for (const [key, node] of nodes) { + const from = before.get(key); + if (from === undefined) continue; // new to the grid: it belongs where it is + const dx = from.left - node.offsetLeft; + const dy = from.top - node.offsetTop; + if (Math.abs(dx) < 1 && Math.abs(dy) < 1) continue; + node.style.transition = 'none'; + node.style.transform = `translate(${dx}px, ${dy}px) scale(var(--card-scale, 1))`; + shifted.push(node); + } + if (shifted.length === 0) return; + void gridEl.offsetWidth; // ONE reflow for the whole grid, so every card starts together + for (const node of shifted) { + node.style.transition = ''; + node.style.transform = ''; + } + } + + /** + * Takes cards OUT of the grid without letting them vanish: each is frozen at the place it currently + * occupies, out of the flow (so the ones behind close the gap straight away) and faded by the + * stylesheet. Switching sections otherwise looked like `display: none` — half the grid blinking out. + * + * Measured in FULL before anything is moved. Freezing one card re-flows the grid, so measuring and + * pinning them one at a time read every card's position after its predecessors had already left — + * which piled the whole section onto the first card's place and faded it out as one lump. + */ + function dismissAll(leaving: readonly HTMLElement[]): void { + const places = leaving.map((node) => ({ left: node.offsetLeft, top: node.offsetTop })); + leaving.forEach((node, at) => { + const place = places[at]; + if (place === undefined) return; + node.style.position = 'absolute'; + node.style.left = `${place.left}px`; + node.style.top = `${place.top}px`; + node.classList.remove('is-selected'); + node.classList.remove('is-entering'); // it is leaving; the arrival it was mid-way through is moot + node.classList.add('is-leaving'); + // Checked again on the way out: a fast switch back puts this very node in the grid again (it lives + // in the pool), and the timer must not then pull it out from under the section that took it. + window.setTimeout(() => { + if (node.classList.contains('is-leaving')) node.remove(); + }, LEAVE_MS); + }); + } + + /** Builds the nodes of the current section and puts them in order, reusing whatever is still there. */ + function syncNodes(animate: boolean): void { + shown = filterLibrary(games, filter); + const fresh: HTMLElement[] = []; + const wanted = shown.map((game, at) => { + const key = nodeKey(game.id); + const existing = nodes.get(key); + // The stagger is positional, so it is written on every pass — a card that moved forward in the + // list must arrive earlier than it did last time, not keep its old place in the wave. + const stagger = String(Math.min(at, ENTRANCE_STEPS)); + if (existing !== undefined) { + // It may be coming back from a section that dismissed it — undo the freeze before it is re-laid. + existing.classList.remove('is-leaving'); + existing.style.removeProperty('position'); + existing.style.removeProperty('left'); + existing.style.removeProperty('top'); + existing.style.setProperty('--card-index', stagger); + const label = existing.querySelector('.card-label'); + if (label !== null && label.textContent !== game.title) label.textContent = game.title; + existing.setAttribute('aria-label', game.title); + return existing; + } + const node = buildCard(game); + node.style.setProperty('--card-index', stagger); + nodes.set(key, node); + fresh.push(node); + return node; + }); + const leaving: HTMLElement[] = []; + for (const [key, node] of nodes) { + const game = games.find((candidate) => nodeKey(candidate.id) === key); + // Gone from main's list entirely: the node has nothing left to show, so it leaves the pool too. + if (game === undefined) nodes.delete(key); + if (shown.some((candidate) => nodeKey(candidate.id) === key)) continue; + if (!node.isConnected) continue; // already out of the grid — another section left it there + if (animate) leaving.push(node); + else node.remove(); + } + if (leaving.length > 0) dismissAll(leaving); + // In-order sync rather than replaceChildren: re-inserting a node the grid already holds would drop + // its transition state, which is exactly what the FLIP above is measuring. + wanted.forEach((node, at) => { + const current = gridEl.children[at]; + if (current !== node) gridEl.insertBefore(node, current ?? null); + }); + if (animate && fresh.length > 0) playEntrance(fresh); + applyEmpty(); + } + + /** + * Plays the arrival on `cards` and takes the marks off again when it is over. + * + * The clean-up walks the POOL, not the grid — and that is the whole point. entrance.ts clears by + * querying the container, which is right for a list whose rows only ever leave by being destroyed; + * here a card can step out of the grid and live on in the pool, and a mark left on it that way is + * permanent. It matters because the mark drives an ANIMATION: while it is there the animation owns + * `transform`, so the card stops growing on selection and starts snapping instead — some cards + * animating and some not, with no way to tell which from looking at them. + */ + function playEntrance(cards: readonly HTMLElement[]): void { + for (const node of cards) node.classList.remove('is-entering'); + void gridEl.offsetWidth; // re-adding a class the node already carries plays nothing at all + for (const node of cards) node.classList.add('is-entering'); + if (entranceTimer !== 0) window.clearTimeout(entranceTimer); + entranceTimer = window.setTimeout(() => { + entranceTimer = 0; + for (const node of nodes.values()) node.classList.remove('is-entering'); + }, ENTRANCE_MS); + } + + /** + * Puts a whole section on screen (a section switch, a fresh open, a list that arrived while away). + * + * Deliberately NOT the FLIP that a live re-flow uses. A section switch also sends the scroll back to + * the top, and a card sliding to its new place while the whole grid is scrolling under it moves twice + * at once — which is what made switching sections after scrolling look broken. Here the scroll snaps + * and the section ARRIVES instead, in the launcher's own staggered wave (see entrance.ts): one + * movement, and the same one the Settings pane plays when its section changes. + */ + function renderSection(animate: boolean): void { + syncNodes(animate); + stale = false; + index = clampIndex(index, 0, shown.length); + measureColumns(); + scroller.to(0, true); + applyLayout(true); + if (animate) playEntrance([...nodes.values()].filter((node) => node.isConnected)); + requestAnimationFrame(() => scroller.fades()); + } + + /** Draws whatever section the column last landed on, if the debounce has not done it yet. */ + function flushPreview(): void { + if (previewTimer !== 0) { + window.clearTimeout(previewTimer); + previewTimer = 0; + } + const next = previewFilter; + previewFilter = null; + if (next === null || next === filter) return; + filter = next; + index = 0; + renderSection(true); + } + + function selectSection(id: string, entered: boolean): void { + previewFilter = id === 'playable' ? 'playable' : 'all'; + if (entered) { + // Stepping INTO a section is a commitment — it must be on screen before the focus lands in it. + flushPreview(); + enterGrid(); + return; + } + if (previewTimer !== 0) window.clearTimeout(previewTimer); + previewTimer = window.setTimeout(() => { + previewTimer = 0; + flushPreview(); + }, PREVIEW_MS); + } + + /** Hands the focus from the column to the grid. An empty section has nothing to hand it to. */ + function enterGrid(): void { + if (shown.length === 0) { + deps.audio.playLimit(); + return; + } + sidebar.setFocused(false); + index = clampIndex(index, 0, shown.length); + applyLayout(); + } + + /** …and back. The column is the only place the screen can be left from, as on the Settings screen. */ + function leaveGrid(): void { + sidebar.setFocused(true); + applyLayout(); + } + + function runAction(id: string): void { + if (id === 'add') { + // Silent on purpose: the Customize screen sounds its OWN opening (openNew plays it at once, open(id) + // waits for the read to land, so an unreadable game never becomes a click). Sounding it here too is + // what made "Add game" click twice. + deps.onAddGame(); + return; + } + close(); + } + + function activateSelected(): void { + const game = selectedGame(); + if (game === undefined) return; + deps.audio.play('button'); + deps.onOpenGame(game.id); + } + + /** One press in the grid: the maths says where it lands, this says what it sounds like. */ + function step(dir: GridDir, repeat: boolean): void { + const move = gridStep(index, dir, shown.length, cols); + if (move.result === 'to-sidebar') { + // Leaving the grid takes a press of its OWN. A hold walks the row, and letting it carry on into the + // column meant a held left crossed a surface boundary the user was not aiming at — they were + // running to the first card, and the focus jumped out of the grid entirely. So the hold stops at + // the edge like any other wall, and the next deliberate press hands over. + if (repeat) return; // silent while held, exactly as `at-end` below treats the other three walls + deps.audio.play('navigate'); + leaveGrid(); + return; + } + if (move.result === 'at-end') { + if (!repeat) deps.audio.playLimit(); + return; + } + index = move.index; + deps.audio.play('navigate'); + applyLayout(); + } + + function sidebarEntries(): readonly SidebarEntry[] { + const translate = t(); + return [ + { id: 'all', label: translate('library.all'), kind: 'section' }, + { id: 'playable', label: translate('library.playable'), kind: 'section' }, + { id: 'add', label: translate('launcher.menu.addGame'), kind: 'action' }, + { id: 'close', label: translate('launcher.menu.close'), kind: 'action' }, + ]; + } + + /** + * Drops the screen's fade for one frame. A hand-over to the detail screen (and the way back) must be a + * CUT: through a 0.35s fade the carousel underneath is seen re-assembling itself — the strip fanning + * back in, the title swapping — and that reads as the launcher glitching, not as a screen changing. + */ + function withoutTransition(swap: () => void): void { + screen.classList.add('is-instant'); + swap(); + void screen.offsetWidth; // land the swapped state in this frame, before the class comes off + requestAnimationFrame(() => screen.classList.remove('is-instant')); + } + + function close(silent = false): void { + if (!open) return; + open = false; + if (previewTimer !== 0) { + window.clearTimeout(previewTimer); + previewTimer = 0; + } + previewFilter = null; + const hide = (): void => { + delete app.dataset['overlay']; + screen.setAttribute('aria-hidden', 'true'); + jelly.setActive(false); + }; + // A silent close is a hand-over to another surface (the detail screen, Add game): it sounds and + // re-focuses for itself, and announcing this one would fight it. + if (silent) { + withoutTransition(hide); + return; + } + hide(); + deps.audio.play('back'); + deps.onClosed(); + } + + function show(): void { + open = true; + app.dataset['overlay'] = 'library'; + screen.setAttribute('aria-hidden', 'false'); + sizeJelly(); + jelly.setActive(true); + } + + deps.art.onEvict((key) => { + const node = painted.get(key); + painted.delete(key); + if (node !== undefined) clearArt(node); + }); + + // The column count follows the pane's width, which follows the window — and the pane is the only thing + // that can tell us it changed (--px is tied to the height, so a resize moves both). + new ResizeObserver(() => { + if (!open) return; + measureColumns(); + sizeJelly(); + // Unconditionally, not only when the column count changed: --px is tied to the HEIGHT, so a resize + // that keeps the columns still moves every card in real px, and the body would otherwise stay + // wrapped around where the card used to be. + applyLayout(true); + }).observe(scrollEl); + + // The wheel drives the SELECTION, not the scrollbar. Left native, the grid would slide out from under + // a selection that stayed where it was — the one thing this layout must never do. + scrollEl.addEventListener( + 'wheel', + (event) => { + if (!open) return; + event.preventDefault(); + if (sidebar.hasFocus()) return; + step(event.deltaY > 0 ? 'down' : 'up', false); + }, + { passive: false }, + ); + + return { + isOpen: () => open, + open: (options) => { + if (open) return; + show(); + filter = 'all'; + previewFilter = null; + index = 0; + pendingFocusId = null; + games = deps.getGames(); + sidebar.render(sidebarEntries()); + sidebar.reset(); + sidebar.setFocused(true); + sidebar.animateIn(); + renderSection(false); + // A named game takes the focus off the sidebar and onto the grid: naming one means the user is + // here to see it, not to pick a section. An id the list does not hold leaves the screen as it is — + // "All" from the top, which is the honest answer when the game is not there. + const wanted = options?.focusId; + if (wanted === undefined) return; + pendingFocusId = wanted; + focusPending(); + }, + restore: () => { + if (open) return; + // With its own entrance, unlike the hand-over OUT of here (see withoutTransition): coming back is + // the screen arriving, and it should look like it. What made the fade unusable was the carousel + // rebuilding itself underneath — and that is hidden for as long as this screen is up (styles.css). + show(); + // The nodes and the scroll position survived the trip, so the screen comes back exactly as it was + // left — unless a list arrived while it was away, which is where that update finally lands. + if (stale) { + stale = false; + const previousId = selectedGame()?.id ?? null; + const previousIndex = index; + syncNodes(false); + const restored = + previousId === null ? -1 : shown.findIndex((game) => game.id === previousId); + index = restored === -1 ? clampIndex(previousIndex, 0, shown.length) : restored; + measureColumns(); + } + applyLayout(true); + }, + close, + setGames: (list) => { + games = list; + // While the screen is away the grid is left alone entirely — see `stale`. Re-flowing it there is + // both invisible work and, during the fade out to a detail screen, a visible twitch. + if (!open) { + stale = true; + return; + } + const previousId = selectedGame()?.id ?? null; + const previousIndex = index; + reorderSmoothly(() => syncNodes(true)); + const restored = previousId === null ? -1 : shown.findIndex((game) => game.id === previousId); + // Held BY IDENTITY: a card going in or out re-orders the whole list, and a positional cursor would + // silently land on a different game. When the game itself is gone, its old place is the nearest + // thing to where the user was looking. + index = restored === -1 ? clampIndex(previousIndex, 0, shown.length) : restored; + if (shown.length === 0) sidebar.setFocused(true); + measureColumns(); + applyLayout(); + // …unless this list is the one carrying the game the screen was opened for. + focusPending(); + }, + setBusyGame: (id) => { + if (id === busyId) return; + busyId = id; + if (open) applyLayout(); + }, + setFlipping: (next) => { + if (next === flipping) return; + flipping = next; + if (!flipping) loadWindowArt(); + }, + artFor: (id) => { + const game = games.find((candidate) => candidate.id === id); + if (game === undefined) return null; + return deps.art.get(artKey(game)) ?? null; + }, + // The column repeats on a hold, exactly as the Settings one does — it is a list like any other, and + // holding a direction on it is how you get to the actions at its foot without four presses. + navUp: (repeat = false) => { + if (sidebar.hasFocus()) { + sidebar.move(-1); + return; + } + step('up', repeat); + }, + navDown: (repeat = false) => { + if (sidebar.hasFocus()) { + sidebar.move(1); + return; + } + step('down', repeat); + }, + navLeft: (repeat = false) => { + // Left off the column is the edge of the screen, as it is on the Settings screen. + if (sidebar.hasFocus()) { + if (!repeat) deps.audio.playLimit(); + return; + } + step('left', repeat); + }, + navRight: (repeat = false) => { + if (sidebar.hasFocus()) { + if (sidebar.selected()?.kind === 'section') enterGrid(); + else deps.audio.playLimit(); // the actions at its foot lead nowhere sideways + return; + } + step('right', repeat); + }, + navActivate: () => { + if (sidebar.hasFocus()) { + sidebar.activate(); + return; + } + activateSelected(); + }, + navBack: () => { + // Out of the grid, back to the column; out of the column, off the screen — the Settings rule, and + // the reason Close sits in the column at all. + if (!sidebar.hasFocus()) { + deps.audio.play('back'); + leaveGrid(); + return; + } + close(); + }, + relocalize: () => { + sidebar.render(sidebarEntries()); + applyEmpty(); + for (const game of shown) { + const node = nodeOf(game); + if (node !== undefined) node.setAttribute('aria-label', game.title); + } + }, + }; +} diff --git a/src/renderer/mouse-sleep.ts b/src/renderer/mouse-sleep.ts new file mode 100644 index 00000000..9aa40d3f --- /dev/null +++ b/src/renderer/mouse-sleep.ts @@ -0,0 +1,51 @@ +// How far the mouse has to travel before the UI listens to it again. +// +// A launcher driven by a gamepad has a pointer sitting somewhere on the screen at all times, and that +// resting pointer is a second, uninvited input: it hovers whatever slides under it, its wheel flips the +// carousel, a bumped Deck trackpad moves it. The per-surface hover guards (hover-guard.ts) answer the +// narrow question "did the mouse move, or did the UI move under it?" — six pixels is enough for that. +// They cannot answer the wider one: "is the user ON the mouse right now?" A hand resting on a trackpad +// clears six pixels without meaning anything by it. +// +// So the mouse is ASLEEP by default and the whole UI ignores it (see controls.ts, where sleep swallows +// every pointer gesture and every key/pad step puts it back to sleep). Waking it takes a deliberate +// shove: this meter adds up the distance travelled and only reports a wake once the total crosses +// WAKE_TRAVEL_PX. Distance TRAVELLED, not distance from the start — shaking the mouse in place is as +// good a "hello" as dragging it across the screen, and both beat a drift nobody meant. + +/** Total travel, in CSS pixels, that wakes the mouse: about a sixth of a 1080p screen crossed in one go. + * Deliberately far. Nothing short of "I am reaching for the mouse now" should get through. */ +export const WAKE_TRAVEL_PX = 300; +/** A gap this long between moves starts the count over: two nudges a second apart are not one shove. */ +export const TRAVEL_RESET_MS = 250; + +export interface WakeMeter { + /** Feeds one real (non-synthetic) pointer position. True exactly once, on the move that wakes it. */ + moved(x: number, y: number, now: number): boolean; + /** Forgets the travel so far — the mouse went back to sleep, or has just woken. */ + reset(): void; +} + +export function createWakeMeter(): WakeMeter { + let lastX = 0; + let lastY = 0; + let lastAt = 0; + let travel = 0; + + return { + moved: (x, y, now) => { + const continues = lastAt !== 0 && now - lastAt <= TRAVEL_RESET_MS; + travel = continues ? travel + Math.hypot(x - lastX, y - lastY) : 0; + lastX = x; + lastY = y; + lastAt = now; + if (travel < WAKE_TRAVEL_PX) return false; + travel = 0; + return true; + }, + reset: () => { + lastAt = 0; + travel = 0; + }, + }; +} diff --git a/src/renderer/nav-surface.ts b/src/renderer/nav-surface.ts new file mode 100644 index 00000000..df9fdc6c --- /dev/null +++ b/src/renderer/nav-surface.ts @@ -0,0 +1,35 @@ +// The six navigation primitives, as a contract. Every surface of the launcher that can hold the focus +// implements it — the Settings screen, the Customize screen, and the surfaces that open ON TOP of +// Customize (the on-screen keyboard, the file picker). controls.ts routes into one of them; a screen with +// its own stack routes further into whichever of its surfaces is on top. +// +// It is a type, not a base class, precisely so a screen can satisfy it while owning its state however it +// likes: the point is that `left` means the same thing everywhere the user presses it. +export interface NavSurface { + isOpen(): boolean; + /** `repeat` marks a hold auto-repeat, exactly as it does for navLeft — surfaces that have no use for + * it simply take no parameter. */ + navUp(repeat?: boolean): void; + navDown(repeat?: boolean): void; + /** `repeat` marks a hold auto-repeat: a held direction must not walk out through several levels. */ + navLeft(repeat?: boolean): void; + navRight(repeat?: boolean): void; + navActivate(): void; + navBack(): void; + /** + * X, and Y. Optional because only the on-screen keyboard has a use for a second and third action + * (Backspace and Shift) — every other surface leaves the buttons alone, and controls.ts keeps its own + * meaning for Y (the strip ⇄ bar swap) whenever no surface claims one. + * + * `repeat` marks a press produced by holding X rather than a fresh one, exactly as it does for the + * directions: the keyboard keeps deleting through a hold, and skips the sound while it does. + */ + navSecondary?(repeat?: boolean): void; + navTertiary?(): void; + /** LB / RB (-1 / +1) — the keyboard's layout switch. Same rule: unclaimed means unchanged. */ + navShoulder?(direction: -1 | 1): void; + /** RT — "commit what I typed". Only the keyboard claims it; A on the Done key does the same thing. */ + navCommit?(): void; + /** Re-renders every label for the current translator, keeping the focus and the scroll position. */ + relocalize(): void; +} diff --git a/src/renderer/online-picker.ts b/src/renderer/online-picker.ts new file mode 100644 index 00000000..2a2f324e --- /dev/null +++ b/src/renderer/online-picker.ts @@ -0,0 +1,1309 @@ +// "Find online" — one full-screen surface for everything the external sources offer: which game this +// is, its cover, its backgrounds and its soundtrack. +// +// It replaces a stack. The flow used to be a query, then a menu of candidates, then a menu of +// categories, and only then a surface per category (the artwork gallery, and later the soundtrack one). +// Every one of those levels asked a question and then hid the answer behind itself, so choosing a cover +// and then a background meant climbing back out and in again. +// +// The two columns say it without nesting: the LEFT one holds the game, the section, the filters that +// section needs and the actions it can take, and the RIGHT one holds whatever that section is about — +// candidates, pictures, or tracks. Switching sections keeps the game; switching games keeps the screen. +// +// Two rules the sections share, because they are what a picker is for: +// +// • a tile or a row only ever TICKS. Committing is an action in the sidebar, so one press can never +// mean both "this one" and "and I am done"; +// • applying does not close the screen. A game usually wants a cover AND backgrounds AND music, and +// each section applies on its own (the screen behind this one holds the form the paths land in). +import { + QUALITY_LABEL, + QUALITY_ORDER, + sourceGroupsFor, + type ArtworkQuality, + type ArtworkSourceGroup, +} from '../shared/artwork-filter.js'; +import { + MAX_HERO_IMAGES, + type ArtworkFilter, + type ArtworkKind, + type ArtworkPage, + type ArtworkVariant, + type GameCandidate, + type MetadataResult, + type MusicAlbum, + type MusicTrack, +} from '../shared/types'; +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 { NavSurface } from './nav-surface.js'; + +/** Which question the right column is answering right now. */ +export type OnlineSection = 'candidates' | 'grid' | 'hero' | 'music'; + +/** How a set of backgrounds meets the ones the game already has. */ +export type HeroApplyMode = 'append' | 'replace'; + +/** What the surface asks main. A seam, so app.ts owns the window.api wiring (and a test can fake it). */ +export interface OnlinePickerApi { + searchGames(query: string): Promise<MetadataResult<readonly GameCandidate[]>>; + steamCandidate(appId: number): Promise<MetadataResult<GameCandidate>>; + artwork( + candidateKey: string, + kind: ArtworkKind, + page: number, + filter: ArtworkFilter, + ): Promise<MetadataResult<ArtworkPage>>; + albums(query: string): Promise<MetadataResult<readonly MusicAlbum[]>>; + tracks(albumKey: string): Promise<MetadataResult<readonly MusicTrack[]>>; + /** One track as an audio data: URL — a full download, which is why Listen shows a status line. */ + preview(trackKey: string): Promise<MetadataResult<string>>; + /** The user left — abort whatever is still downloading for this surface. */ + cancel(): void; +} + +/** What applying answered with: the screen owns the form and the files, so it reports back in words. */ +export interface ApplyOutcome { + readonly ok: boolean; + readonly message: string; +} + +export interface OnlinePickerDeps { + readonly audio: AudioController; + getTranslator(): Translator; + readonly api: OnlinePickerApi; + /** Opens the on-screen keyboard for a new query — the screen owns that surface. */ + editQuery(initial: string, onDone: (query: string) => void): void; + applyArtwork( + kind: ArtworkKind, + variantKeys: readonly string[], + mode: HeroApplyMode, + ): Promise<ApplyOutcome>; + applyTrack(trackKey: string): Promise<ApplyOutcome>; + /** Writes the candidate's name into the form's Title field. */ + applyTitle(title: string): void; + /** The user settled on a game — the screen fetches its description, genres and dates from here. */ + onCandidate(candidate: GameCandidate): void; + /** How many backgrounds the form already holds, which is what makes "add or replace" a question. */ + heroCount(): number; + /** + * A confirmation, through the launcher's own notification plate (top-right). Nothing to answer, so it + * takes itself away — the channel the rest of the app already speaks through. + */ + notify(text: string): void; + /** + * A FAILURE, through the launcher's error popup — the column on the right with a Close button. It + * waits for the user instead of racing them, which is the difference between "applied" and "the + * source refused": one is news, the other is something they have to decide what to do about. + */ + showError(text: string): void; + /** + * Work in progress, in that same column: a message and a Stop. Everything this launcher says lives + * there, so a download that is about to become a file says it in the same place as everything else. + */ + showBusy(text: string, onStop: () => void): void; + closeBusy(): void; + /** The one question this surface asks — replacing a title the user may have typed. */ + confirmTitle(title: string, onYes: () => void): void; +} + +export interface OnlinePickerSurface extends NavSurface { + /** Closes without applying anything — for the cascade when the whole screen goes. */ + close(): void; + open(request: { + /** The title to search for; empty opens the keyboard instead. */ + readonly query: string; + /** A Steam appid the manifest already names — the one thing a search exists to find. */ + readonly appId?: number; + }): void; +} + +/** Which column holds the focus. */ +type Column = 'side' | 'content'; + +/** One focusable row of the sidebar. Headings are drawn but never focused, so they are not here. */ +type SideAction = + | { readonly kind: 'game' } + | { readonly kind: 'search' } + | { readonly kind: 'title' } + | { readonly kind: 'section'; readonly section: OnlineSection } + | { readonly kind: 'source'; readonly group: ArtworkSourceGroup } + | { readonly kind: 'quality'; readonly quality: ArtworkQuality } + | { readonly kind: 'mode'; readonly mode: HeroApplyMode } + | { readonly kind: 'album'; readonly album: MusicAlbum } + | { readonly kind: 'listen' } + | { readonly kind: 'apply' } + | { readonly kind: 'clear' } + | { readonly kind: 'close' }; + +export function createOnlinePicker(deps: OnlinePickerDeps): OnlinePickerSurface { + const root = req('online-picker'); + const titleEl = req('online-picker-title'); + const sideEl = req('online-picker-side'); + const contentEl = req('online-picker-content'); + + const t = (): Translator => deps.getTranslator(); + const scroller = createScroller(contentEl); + const sideScroller = createScroller(sideEl); + const hover = createHoverGuard(); + + let open = false; + /** Bumped on every open/close, so a slow answer from a previous visit cannot paint over this one. */ + let visit = 0; + /** Bumped on every request for the right column, so an answer the user moved past is discarded. */ + let attempt = 0; + /** The same for auditions, kept apart: starting one must not cancel a list still arriving. */ + let listenAttempt = 0; + + let query = ''; + let candidates: readonly GameCandidate[] = []; + let candidate: GameCandidate | null = null; + let section: OnlineSection = 'candidates'; + + let variants: readonly ArtworkVariant[] = []; + let picked: string[] = []; + let page = 0; + let hasMore = false; + let sourceKey = 'all'; + let quality: ArtworkQuality = 'any'; + let heroMode: HeroApplyMode = 'append'; + + let albums: readonly MusicAlbum[] = []; + let albumKey: string | null = null; + let tracks: readonly MusicTrack[] = []; + let pickedTrack: string | null = null; + let listening = false; + + let cells: HTMLButtonElement[] = []; + let index = 0; + let column: Column = 'side'; + let sideIndex = 0; + let actions: SideAction[] = []; + let sideButtons: HTMLButtonElement[] = []; + let loading = false; + /** Whether a download-and-write is running: the column shows it, and Stop lands back here. */ + let applying = false; + /** + * Whether the user has moved the focus since the current load began. + * + * A load ordinarily hands the focus to what it brought — choosing a game lands on its backgrounds, + * because looking at them is why the game was chosen. But a load takes seconds, and in those seconds + * the user may well walk the sidebar to the next filter. Moving the focus out from under them at the + * moment the pictures arrive is how a press meant for "2K" lands on a wallpaper instead. + */ + let focusTouched = false; + + /** + * Stop, from the column's busy popup: whatever main is still fetching has nobody to arrive for, so the + * answers in flight are retired and the requests cancelled. The screen itself stays — the user stopped + * a download, not the search. + */ + function stopWork(): void { + applying = false; + attempt += 1; + listenAttempt += 1; + loading = false; + deps.api.cancel(); + paintContent(); + applyFocus(true); + } + + function maxPicks(): number { + if (section !== 'hero') return 1; + return heroModeAllowed() === 'append' + ? Math.max(0, MAX_HERO_IMAGES - deps.heroCount()) + : MAX_HERO_IMAGES; + } + + /** + * The apply mode the game's current backgrounds allow. A full set has nothing to add to, so the choice + * disappears and replacing is the only thing left to mean. + */ + function heroModeAllowed(): HeroApplyMode { + return deps.heroCount() >= MAX_HERO_IMAGES ? 'replace' : heroMode; + } + + function artworkKind(): ArtworkKind | null { + return section === 'grid' || section === 'hero' ? section : null; + } + + function groups(): readonly ArtworkSourceGroup[] { + return sourceGroupsFor(artworkKind() ?? 'hero'); + } + + function filter(): ArtworkFilter { + const group = groups().find((entry) => entry.key === sourceKey); + return { sources: group?.providers ?? [], quality }; + } + + // ── The sidebar ─────────────────────────────────────────────────────────── + + function paintSide(): void { + actions = []; + sideButtons = []; + const nodes: HTMLElement[] = []; + nodes.push(heading(t()('metadata.game'))); + nodes.push(sideButton({ kind: 'game' }, candidate?.title ?? t()('metadata.noCandidate'))); + nodes.push(sideButton({ kind: 'search' }, t()('metadata.searchAgain'))); + if (candidate !== null) { + nodes.push(sideButton({ kind: 'title' }, t()('metadata.applyTitle'))); + nodes.push(heading(t()('metadata.sections'))); + nodes.push(sideButton({ kind: 'section', section: 'grid' }, t()('metadata.cover'))); + nodes.push(sideButton({ kind: 'section', section: 'hero' }, t()('metadata.backgrounds'))); + nodes.push(sideButton({ kind: 'section', section: 'music' }, t()('metadata.music'))); + } + nodes.push(...sectionRows()); + const divider = document.createElement('div'); + divider.className = 'picker-divider'; + nodes.push(divider); + nodes.push(...actionRows()); + nodes.push(sideButton({ kind: 'close' }, t()('metadata.actionClose'))); + sideEl.replaceChildren(...nodes); + sideIndex = Math.min(sideIndex, Math.max(0, sideButtons.length - 1)); + paintSideState(); + } + + /** The rows that belong to the open section: the filters for pictures, the albums for music. */ + function sectionRows(): readonly HTMLElement[] { + const kind = artworkKind(); + if (kind !== null) { + const nodes: HTMLElement[] = [heading(t()('metadata.filterSource'))]; + for (const group of groups()) { + nodes.push(sideButton({ kind: 'source', group }, group.label ?? t()('metadata.filterAny'))); + } + // Backgrounds only: a cover is a portrait 600x900 whatever the source, so a floor named after a + // screen would empty that gallery rather than narrow it. + if (kind === 'hero') { + nodes.push(heading(t()('metadata.filterSize'))); + for (const named of QUALITY_ORDER) { + nodes.push( + sideButton( + { kind: 'quality', quality: named }, + QUALITY_LABEL[named] ?? t()('metadata.filterAny'), + ), + ); + } + // Only when the game HAS backgrounds: with none, "add" and "replace" mean the same thing, and a + // choice between two identical outcomes is a question nobody should be asked. + const held = deps.heroCount(); + if (held > 0) { + nodes.push(heading(t()('metadata.applyMode'))); + // A full set has nothing to add to — the row would name an outcome the game cannot have. + if (held < MAX_HERO_IMAGES) { + nodes.push( + sideButton( + { kind: 'mode', mode: 'append' }, + t()('metadata.heroAppend', { count: String(held) }), + ), + ); + } + nodes.push(sideButton({ kind: 'mode', mode: 'replace' }, t()('metadata.heroReplace'))); + } + } + return nodes; + } + if (section !== 'music') return []; + const nodes: HTMLElement[] = [heading(t()('metadata.albums'))]; + for (const album of albums) { + nodes.push(sideButton({ kind: 'album', album }, albumLabel(album))); + } + if (albums.length === 0 && !loading) nodes.push(hint(t()('metadata.noAlbums'))); + return nodes; + } + + /** What the open section can commit. */ + function actionRows(): readonly HTMLElement[] { + if (section === 'music') { + return [sideButton({ kind: 'listen' }, ''), sideButton({ kind: 'apply' }, '')]; + } + if (artworkKind() === null) return []; + return [sideButton({ kind: 'apply' }, ''), sideButton({ kind: 'clear' }, '')]; + } + + function albumLabel(album: MusicAlbum): string { + return album.trackCount === undefined ? album.title : `${album.title} (${album.trackCount})`; + } + + function heading(text: string): HTMLElement { + const node = document.createElement('div'); + node.className = 'metadata-side-heading'; + node.textContent = text; + return node; + } + + function hint(text: string): HTMLElement { + const node = document.createElement('div'); + node.className = 'picker-empty'; + node.textContent = text; + return node; + } + + function sideButton(action: SideAction, label: string): HTMLButtonElement { + const button = document.createElement('button'); + button.type = 'button'; + const plain = action.kind === 'source' || action.kind === 'quality' || action.kind === 'album'; + button.className = plain ? 'picker-item' : 'picker-item is-action'; + button.textContent = label; + const position = actions.length; + actions.push(action); + sideButtons.push(button); + button.addEventListener('click', () => { + hover.arm(); + column = 'side'; + sideIndex = position; + applyFocus(); + runAction(action); + }); + return button; + } + + /** Which rows are switched on, and which ones have nothing to act on yet. */ + function paintSideState(): void { + for (const [at, action] of actions.entries()) { + const button = sideButtons[at]; + if (button === undefined) continue; + const on = + (action.kind === 'section' && action.section === section) || + (action.kind === 'source' && action.group.key === sourceKey) || + (action.kind === 'quality' && action.quality === quality) || + (action.kind === 'mode' && action.mode === heroModeAllowed()) || + (action.kind === 'album' && action.album.key === albumKey) || + (action.kind === 'game' && section === 'candidates'); + button.classList.toggle('is-picked', on); + if (action.kind === 'apply') { + button.textContent = + section === 'music' + ? t()('metadata.useTrack') + : t()('metadata.applySelected', { count: String(picked.length) }); + button.classList.toggle('is-disabled', nothingPicked()); + } + if (action.kind === 'clear') { + button.textContent = t()('metadata.clearPicked', { count: String(picked.length) }); + button.classList.toggle('is-disabled', picked.length === 0); + } + if (action.kind === 'listen') { + button.textContent = t()(listening ? 'metadata.stopListen' : 'metadata.listen'); + button.classList.toggle('is-disabled', pickedTrack === null && !listening); + } + } + } + + function nothingPicked(): boolean { + return section === 'music' ? pickedTrack === null : picked.length === 0; + } + + // ── The right column ────────────────────────────────────────────────────── + + function paintContent(): void { + cells = []; + contentEl.classList.toggle('is-grid', artworkKind() !== null); + if (loading && section !== 'hero' && section !== 'grid') { + contentEl.replaceChildren(busyNote()); + return; + } + if (section === 'candidates') { + cells = candidates.map((entry, position) => candidateRow(entry, position)); + contentEl.replaceChildren( + ...(cells.length > 0 ? cells : [hint(t()('metadata.nothingFound'))]), + ); + applyFocus(true); + return; + } + if (section === 'music') { + cells = tracks.map((track, position) => trackRow(track, position)); + contentEl.replaceChildren( + ...(cells.length > 0 + ? cells + : [hint(t()(albumKey === null ? 'metadata.pickAlbum' : 'metadata.noTracks'))]), + ); + applyPicked(); + applyFocus(true); + return; + } + cells = variants.map((variant, position) => tile(variant, position)); + if (needsTail()) cells.push(tailTile()); + contentEl.replaceChildren(...(cells.length > 0 ? cells : [hint(t()('metadata.noArtwork'))])); + applyPicked(); + applyFocus(true); + } + + function candidateRow(entry: GameCandidate, position: number): HTMLButtonElement { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'picker-item music-row'; + const name = document.createElement('span'); + name.className = 'music-row-title'; + name.textContent = entry.title; + const source = document.createElement('span'); + source.className = 'music-row-size'; + source.textContent = PROVIDER_LABEL[entry.provider]; + button.append(name, source); + button.addEventListener('click', () => { + hover.arm(); + column = 'content'; + index = position; + applyFocus(); + chooseCandidate(entry); + }); + return button; + } + + function trackRow(track: MusicTrack, position: number): HTMLButtonElement { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'picker-item music-row'; + const name = document.createElement('span'); + name.className = 'music-row-title'; + name.textContent = track.title; + const size = document.createElement('span'); + size.className = 'music-row-size'; + size.textContent = track.sizeBytes === undefined ? '' : formatSize(track.sizeBytes); + button.append(name, size); + button.addEventListener('click', () => { + hover.arm(); + column = 'content'; + index = position; + applyFocus(); + toggleTrack(track); + }); + return button; + } + + function tile(variant: ArtworkVariant, position: number): HTMLButtonElement { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'metadata-tile'; + button.dataset['kind'] = variant.kind; + const image = document.createElement('img'); + image.className = 'metadata-tile-image'; + image.src = variant.thumbDataUrl; + image.alt = ''; + const caption = document.createElement('span'); + caption.className = 'metadata-tile-caption'; + caption.textContent = captionOf(variant); + button.append(image, caption); + button.addEventListener('click', () => { + hover.arm(); + column = 'content'; + index = position; + applyFocus(); + togglePick(variant); + }); + return button; + } + + /** + * The last tile of the grid, and the only one that is not a picture. Two states, one place: while a + * page is on its way it spins and says so, and once it lands it becomes "load more". + */ + function tailTile(): HTMLButtonElement { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'metadata-tile metadata-tile-more'; + button.dataset['kind'] = artworkKind() ?? 'hero'; + const box = document.createElement('span'); + box.className = 'metadata-tile-more-box'; + const caption = document.createElement('span'); + caption.className = 'metadata-tile-caption'; + button.append(box, caption); + button.addEventListener('click', () => { + hover.arm(); + column = 'content'; + index = cells.length - 1; + applyFocus(); + loadMore(); + }); + paintTail(button); + return button; + } + + function paintTail(button: HTMLButtonElement): void { + const box = button.querySelector('.metadata-tile-more-box'); + const caption = button.querySelector('.metadata-tile-caption'); + button.classList.toggle('is-busy', loading); + if (caption !== null) { + caption.textContent = t()(loading ? 'metadata.searching' : 'metadata.loadMore'); + } + if (box === null) return; + if (!loading) { + box.replaceChildren(); + box.textContent = '+'; + return; + } + const spin = document.createElement('span'); + spin.className = 'metadata-tile-spinner'; + box.replaceChildren(spin); + } + + function needsTail(): boolean { + return artworkKind() !== null && (hasMore || loading); + } + + function isOnTail(): boolean { + return needsTail() && index === variants.length; + } + + function busyNote(): HTMLElement { + const node = document.createElement('div'); + node.className = 'music-busy'; + const spin = document.createElement('span'); + spin.className = 'metadata-tile-spinner'; + const label = document.createElement('span'); + label.textContent = t()('metadata.searching'); + node.append(spin, label); + return node; + } + + /** `4.4 MB` — what the source claimed, so a long download is not a surprise. */ + function formatSize(bytes: number): string { + const mb = bytes / (1024 * 1024); + return mb >= 1 ? `${mb.toFixed(1)} MB` : `${Math.max(1, Math.round(bytes / 1024))} KB`; + } + + /** Proper names, so they are not translated — one per source, never "this or else Steam". */ + const PROVIDER_LABEL: Readonly<Record<ArtworkVariant['provider'], string>> = { + steam: 'Steam', + steamgriddb: 'SteamGridDB', + wallhaven: 'Wallhaven', + wallpapercave: 'Wallpaper Cave', + gog: 'GOG', + khinsider: 'Khinsider', + }; + + function captionOf(variant: ArtworkVariant): string { + const source = PROVIDER_LABEL[variant.provider]; + if (variant.width === undefined || variant.height === undefined) return source; + return `${source} · ${variant.width}x${variant.height}`; + } + + function applyPicked(): void { + cells.forEach((cell, position) => { + const key = section === 'music' ? tracks[position]?.key : variants[position]?.key; + const on = + section === 'music' ? key === pickedTrack : key !== undefined && picked.includes(key); + cell.classList.toggle('is-picked', on); + }); + paintSideState(); + } + + function applyFocus(instant = false): void { + cells.forEach((cell, position) => + cell.classList.toggle('is-focused', column === 'content' && position === index), + ); + sideButtons.forEach((button, position) => + button.classList.toggle('is-focused', column === 'side' && position === sideIndex), + ); + if (column === 'side') { + const row = sideButtons[sideIndex]; + if (row !== undefined) sideScroller.reveal(row, instant); + return; + } + const focused = cells[index]; + if (focused !== undefined) scroller.reveal(focused, instant); + } + + /** How many tiles fit on one row — read back from the layout, which has already answered it. */ + function columns(): number { + if (artworkKind() === null) return 1; + const first = cells[0]; + if (first === undefined) return 1; + const top = first.offsetTop; + return Math.max(1, cells.filter((cell) => cell.offsetTop === top).length); + } + + function move(delta: number): void { + hover.arm(); + focusTouched = true; + const length = column === 'side' ? sideButtons.length : cells.length; + const at = column === 'side' ? sideIndex : index; + if (length === 0) { + deps.audio.playLimit(); + return; + } + const next = clampIndex(at, delta, length); + if (next === at) { + deps.audio.playLimit(); + return; + } + if (column === 'side') sideIndex = next; + else index = next; + deps.audio.play('navigate'); + applyFocus(); + } + + // ── Choosing ────────────────────────────────────────────────────────────── + + function togglePick(variant: ArtworkVariant): void { + if (maxPicks() === 0) { + deps.audio.playLimit(); + return; + } + if (picked.includes(variant.key)) { + picked = picked.filter((key) => key !== variant.key); + deps.audio.play('navigate'); + applyPicked(); + return; + } + if (maxPicks() === 1) picked = [variant.key]; + else if (picked.length >= maxPicks()) { + deps.audio.playLimit(); + return; + } else picked.push(variant.key); + deps.audio.play('navigate'); + applyPicked(); + } + + function toggleTrack(track: MusicTrack): void { + // Whatever was playing belonged to the previous choice — a track auditioned under one name while + // another is ticked is the surest way to apply the wrong file. + if (listening) stopListening(); + pickedTrack = pickedTrack === track.key ? null : track.key; + deps.audio.play('navigate'); + applyPicked(); + } + + function chooseCandidate(entry: GameCandidate): void { + deps.audio.play('button'); + candidate = entry; + deps.onCandidate(entry); + picked = []; + pickedTrack = null; + albums = []; + albumKey = null; + tracks = []; + titleEl.textContent = entry.title; + void showSection('hero'); + } + + /** Moves to a section and loads whatever it needs, keeping everything the other sections hold. */ + async function showSection(next: OnlineSection): Promise<void> { + section = next; + index = 0; + if (next === 'candidates') { + paintSide(); + paintContent(); + return; + } + if (next === 'music') { + // The right column's "choose an album" only makes sense once there ARE albums on the left, so a + // section opened before they arrive shows the wait instead. + if (albums.length === 0) loading = true; + paintSide(); + paintContent(); + if (albums.length === 0) await loadAlbums(); + return; + } + picked = []; + page = 0; + hasMore = false; + variants = []; + // A game whose backgrounds are already full has nothing to add to, so the remembered mode is not a + // choice any more — the sidebar shows only "replace", and this keeps the state saying the same. + if (next === 'hero' && deps.heroCount() >= MAX_HERO_IMAGES) heroMode = 'replace'; + paintSide(); + await loadArtwork(0); + } + + function runAction(action: SideAction): void { + if (action.kind === 'game') { + deps.audio.play('button'); + void showSection('candidates'); + return; + } + if (action.kind === 'search') { + deps.audio.play('button'); + deps.editQuery(query, (value) => { + const term = value.trim(); + if (term === '') return; + query = term; + void search(term); + }); + return; + } + if (action.kind === 'title') { + const named = candidate; + if (named === null) { + deps.audio.playLimit(); + return; + } + deps.audio.play('button'); + // The one action here that REPLACES something rather than adding to it: the title may well have + // been typed by hand, and the store's spelling is not always the one the user wants ("Watch_Dogs™"). + deps.confirmTitle(named.title, () => { + deps.applyTitle(named.title); + deps.notify(t()('metadata.applied')); + }); + return; + } + if (action.kind === 'section') { + if (action.section === section) { + deps.audio.playLimit(); + return; + } + deps.audio.play('button'); + void showSection(action.section); + return; + } + if (action.kind === 'source' || action.kind === 'quality') { + const same = + action.kind === 'source' ? action.group.key === sourceKey : action.quality === quality; + if (same) { + deps.audio.playLimit(); + return; + } + if (action.kind === 'source') sourceKey = action.group.key; + else quality = action.quality; + deps.audio.play('button'); + deps.api.cancel(); + loading = false; + index = 0; + paintSideState(); + void loadArtwork(0, true); + return; + } + if (action.kind === 'mode') { + if (action.mode === heroMode) { + deps.audio.playLimit(); + return; + } + heroMode = action.mode; + deps.audio.play('navigate'); + // Switching to "add" narrows the room: whatever no longer fits is unticked here rather than + // dropped at apply time, and the plate says so — a tick that vanishes without a word reads as a bug. + const room = maxPicks(); + if (picked.length > room) { + picked = picked.slice(0, room); + deps.notify(t()('metadata.heroRoom', { count: String(room) })); + } + applyPicked(); + paintSideState(); + return; + } + if (action.kind === 'album') { + if (action.album.key === albumKey) { + deps.audio.playLimit(); + return; + } + deps.audio.play('button'); + albumKey = action.album.key; + paintSideState(); + void loadTracks(action.album.key); + return; + } + if (action.kind === 'listen') { + if (listening) { + deps.audio.play('button'); + stopListening(); + return; + } + const track = tracks.find((entry) => entry.key === pickedTrack); + if (track === undefined) { + deps.audio.playLimit(); + return; + } + deps.audio.play('button'); + void listen(track); + return; + } + if (action.kind === 'apply') { + void apply(); + return; + } + if (action.kind === 'clear') { + if (picked.length === 0) { + deps.audio.playLimit(); + return; + } + picked = []; + deps.audio.play('navigate'); + applyPicked(); + return; + } + deps.audio.play('popup-close'); + hide(); + } + + /** + * Applying leaves the screen open: a game usually wants a cover AND backgrounds AND a track, and + * closing after each one would make the second and third a fresh search every time. + */ + async function apply(): Promise<void> { + if (nothingPicked()) { + deps.audio.playLimit(); + return; + } + deps.audio.play('button'); + const token = ++attempt; + const visited = visit; + applying = true; + deps.showBusy(t()('metadata.applying'), () => stopWork()); + const kind = artworkKind(); + const outcome = + kind === null + ? await deps.applyTrack(pickedTrack ?? '') + : await deps.applyArtwork(kind, picked, kind === 'hero' ? heroModeAllowed() : 'replace'); + applying = false; + deps.closeBusy(); + if (visited !== visit || token !== attempt) return; + if (outcome.ok) deps.notify(outcome.message); + else deps.showError(outcome.message); + if (!outcome.ok) return; + // The picks are spent. Backgrounds that were APPENDED keep their meaning ("these are on the game + // now"), so the mode goes back to appending: a second set adds to the first rather than wiping it. + // Backgrounds that were APPENDED keep their meaning ("these are on the game now"), so the mode goes + // back to adding — unless the game is now full, where adding is no longer a thing that can happen. + if (kind === 'hero') heroMode = deps.heroCount() >= MAX_HERO_IMAGES ? 'replace' : 'append'; + picked = []; + pickedTrack = null; + applyPicked(); + paintSide(); + applyFocus(true); + } + + // ── Loading ─────────────────────────────────────────────────────────────── + + async function search(term: string): Promise<void> { + loading = true; + focusTouched = false; + const token = ++attempt; + const visited = visit; + section = 'candidates'; + titleEl.textContent = term; + paintSide(); + paintContent(); + const result = await deps.api.searchGames(term); + if (visited !== visit || token !== attempt) return; + loading = false; + if (!result.ok) { + candidates = []; + deps.showError(result.message); + paintContent(); + return; + } + candidates = result.value; + paintContent(); + if (candidates.length > 0 && !focusTouched) column = 'content'; + applyFocus(true); + } + + async function byAppId(appId: number): Promise<void> { + loading = true; + focusTouched = false; + const token = ++attempt; + const visited = visit; + paintContent(); + const result = await deps.api.steamCandidate(appId); + if (visited !== visit || token !== attempt) return; + loading = false; + if (!result.ok) { + deps.showError(result.message); + paintContent(); + return; + } + // Straight past the candidates: asking "which game is it?" about a game the user identified by + // appid would be a question with exactly one answer. + chooseCandidate(result.value); + } + + async function loadArtwork(nextPage: number, keepFocus = false): Promise<void> { + const named = candidate; + const kind = artworkKind(); + if (named === null || kind === null || loading) return; + loading = true; + focusTouched = false; + const token = ++attempt; + const visited = visit; + const shownBefore = variants.length; + if (nextPage === 0) paintContent(); + else syncTail(); + const result = await deps.api.artwork(named.key, kind, nextPage, filter()); + if (visited !== visit || token !== attempt) return; + loading = false; + if (!result.ok) { + deps.showError(result.message); + if (nextPage > 0) { + syncTail(); + return; + } + variants = []; + hasMore = false; + paintContent(); + return; + } + page = nextPage; + hasMore = result.value.hasMore; + variants = nextPage === 0 ? result.value.variants : [...variants, ...result.value.variants]; + paintContent(); + if (nextPage === 0) { + if (variants.length > 0 && !focusTouched && !keepFocus) column = 'content'; + applyFocus(true); + return; + } + if (column === 'content' && !focusTouched) { + index = Math.min(shownBefore, Math.max(0, cells.length - 1)); + } + applyFocus(); + } + + /** Brings the tail tile in step without rebuilding the grid — a repaint re-decodes every thumbnail. */ + function syncTail(): void { + const last = cells[cells.length - 1]; + const present = last !== undefined && last.classList.contains('metadata-tile-more'); + if (needsTail() && present && last !== undefined) { + paintTail(last); + return; + } + if (needsTail() && !present) { + const tile = tailTile(); + cells.push(tile); + contentEl.append(tile); + return; + } + if (!needsTail() && present && last !== undefined) { + cells.pop(); + last.remove(); + if (index >= cells.length) index = Math.max(0, cells.length - 1); + applyFocus(); + } + } + + function loadMore(): void { + if (loading) { + deps.audio.playLimit(); + return; + } + deps.audio.play('button'); + void loadArtwork(page + 1); + } + + async function loadAlbums(): Promise<void> { + const named = candidate; + if (named === null) return; + loading = true; + focusTouched = false; + const token = ++attempt; + const visited = visit; + paintContent(); + const result = await deps.api.albums(named.title); + if (visited !== visit || token !== attempt) return; + loading = false; + if (!result.ok) { + albums = []; + deps.showError(result.message); + paintSide(); + paintContent(); + return; + } + albums = result.value; + paintSide(); + // One album is no choice at all — opening it saves a press the user would always make. + const only = albums.length === 1 ? albums[0] : undefined; + if (only !== undefined) { + albumKey = only.key; + paintSideState(); + await loadTracks(only.key, true); + return; + } + paintContent(); + } + + /** + * `keepTouched` for the nested call: opening the only album is a continuation of the album search, not + * a fresh action, so a focus the user moved WHILE that search ran is still theirs. + */ + async function loadTracks(key: string, keepTouched = false): Promise<void> { + loading = true; + if (!keepTouched) focusTouched = false; + const token = ++attempt; + const visited = visit; + tracks = []; + pickedTrack = null; + index = 0; + stopListening(); + paintContent(); + const result = await deps.api.tracks(key); + if (visited !== visit || token !== attempt) return; + loading = false; + if (!result.ok) { + deps.showError(result.message); + paintContent(); + return; + } + tracks = result.value; + paintContent(); + if (tracks.length > 0 && !focusTouched) column = 'content'; + applyFocus(true); + } + + /** + * Listening downloads the whole track — tens of seconds on a Deck's Wi-Fi — hence the status line, + * and hence Back being able to abort it. + */ + async function listen(track: MusicTrack): Promise<void> { + const token = ++listenAttempt; + const visited = visit; + deps.showBusy(t()('metadata.downloading'), () => stopWork()); + const result = await deps.api.preview(track.key); + deps.closeBusy(); + if (visited !== visit || token !== listenAttempt) return; + if (!result.ok) { + deps.showError(result.message); + return; + } + listening = true; + deps.audio.setBrowseMusic(result.value, false); + paintSideState(); + } + + function stopListening(): void { + listenAttempt += 1; // a download still on its way is no longer wanted + if (!listening) return; + listening = false; + deps.audio.setBrowseMusic(null, false); + paintSideState(); + } + + function hide(): void { + if (!open) return; + if (applying) { + applying = false; + deps.closeBusy(); + } + open = false; + visit += 1; + stopListening(); + candidates = []; + candidate = null; + variants = []; + picked = []; + tracks = []; + albums = []; + albumKey = null; + pickedTrack = null; + cells = []; + actions = []; + sideButtons = []; + loading = false; + sideEl.replaceChildren(); + contentEl.replaceChildren(); + root.classList.remove('is-open'); + root.setAttribute('aria-hidden', 'true'); + deps.api.cancel(); + } + + // ── Mouse ───────────────────────────────────────────────────────────────── + + contentEl.addEventListener( + 'mousemove', + (event) => { + if (!open) return; + if (document.documentElement.classList.contains('mouse-asleep')) return; + if (!hover.awake(event.clientX, event.clientY)) return; + const target = event.target; + if (!(target instanceof Element)) return; + const cell = target.closest<HTMLButtonElement>('.metadata-tile, .picker-item'); + if (cell === null) return; + const position = cells.indexOf(cell); + if (position === -1 || (position === index && column === 'content')) return; + column = 'content'; + index = position; + focusTouched = true; + applyFocus(); + }, + { passive: true }, + ); + + sideEl.addEventListener( + 'mousemove', + (event) => { + if (!open) return; + if (document.documentElement.classList.contains('mouse-asleep')) return; + if (!hover.awake(event.clientX, event.clientY)) return; + const target = event.target; + if (!(target instanceof Element)) return; + const button = target.closest<HTMLButtonElement>('.picker-item'); + if (button === null) return; + const position = sideButtons.indexOf(button); + if (position === -1 || (position === sideIndex && column === 'side')) return; + column = 'side'; + sideIndex = position; + focusTouched = true; + applyFocus(); + }, + { passive: true }, + ); + + root.querySelector<HTMLElement>('.picker-veil')?.addEventListener('click', () => { + deps.audio.play('popup-close'); + hide(); + }); + + window.addEventListener('mousemove', (event) => hover.track(event.clientX, event.clientY), { + passive: true, + }); + + return { + isOpen: () => open, + close: () => hide(), + open: (request) => { + open = true; + visit += 1; + query = request.query; + candidates = []; + candidate = null; + section = 'candidates'; + variants = []; + picked = []; + tracks = []; + albums = []; + albumKey = null; + pickedTrack = null; + listening = false; + page = 0; + hasMore = false; + sourceKey = 'all'; + quality = 'any'; + heroMode = 'append'; + index = 0; + sideIndex = 0; + column = 'side'; + loading = false; + deps.audio.play('popup-open'); + titleEl.textContent = request.query; + paintSide(); + paintContent(); + root.classList.add('is-open'); + root.setAttribute('aria-hidden', 'false'); + scroller.to(0, true); + sideScroller.to(0, true); + hover.arm(); + if (request.appId !== undefined) { + void byAppId(request.appId); + return; + } + if (request.query.trim() === '') { + deps.editQuery('', (value) => { + const term = value.trim(); + if (term === '') return; + query = term; + void search(term); + }); + return; + } + void search(request.query); + }, + navUp: () => move(column === 'side' ? -1 : -columns()), + navDown: () => move(column === 'side' ? 1 : columns()), + /** Left walks the row and then steps into the sidebar — a HELD left stops at that wall. */ + navLeft: (repeat) => { + hover.arm(); + if (column === 'side') { + deps.audio.playLimit(); + return; + } + if (cells.length > 0 && index % Math.max(1, columns()) !== 0) { + move(-1); + return; + } + if (repeat === true) return; + column = 'side'; + focusTouched = true; + deps.audio.play('navigate'); + applyFocus(); + }, + navRight: () => { + hover.arm(); + if (column !== 'side') { + move(1); + return; + } + if (cells.length === 0) { + deps.audio.playLimit(); + return; + } + column = 'content'; + focusTouched = true; + deps.audio.play('navigate'); + applyFocus(); + }, + navActivate: () => { + hover.arm(); + if (column === 'side') { + const action = actions[sideIndex]; + if (action === undefined) { + deps.audio.playLimit(); + return; + } + runAction(action); + return; + } + if (isOnTail()) { + loadMore(); + return; + } + if (section === 'candidates') { + const entry = candidates[index]; + if (entry === undefined) { + deps.audio.playLimit(); + return; + } + chooseCandidate(entry); + return; + } + if (section === 'music') { + const track = tracks[index]; + if (track === undefined) { + deps.audio.playLimit(); + return; + } + toggleTrack(track); + return; + } + const variant = variants[index]; + if (variant === undefined) { + deps.audio.playLimit(); + return; + } + togglePick(variant); + }, + navBack: () => { + deps.audio.play('popup-close'); + hide(); + }, + /** X ticks a picture, or auditions a track — the shortcut each list had before. */ + navSecondary: () => { + if (column !== 'content') { + deps.audio.playLimit(); + return; + } + if (section === 'music') { + const track = tracks[index]; + if (track === undefined) { + deps.audio.playLimit(); + return; + } + if (listening) stopListening(); + pickedTrack = track.key; + applyPicked(); + void listen(track); + return; + } + const variant = variants[index]; + if (variant === undefined) { + deps.audio.playLimit(); + return; + } + togglePick(variant); + }, + relocalize: () => { + if (!open) return; + paintSide(); + paintContent(); + applyFocus(true); + }, + }; +} diff --git a/src/renderer/osk-text.ts b/src/renderer/osk-text.ts new file mode 100644 index 00000000..278fd383 --- /dev/null +++ b/src/renderer/osk-text.ts @@ -0,0 +1,110 @@ +// The on-screen keyboard's text state, as pure functions: a value and a caret inside it, plus the rule +// for what each field mode will accept at all. DOM-free and electron-free, so the editing itself is +// unit-testable while osk.ts keeps only the keys, the focus and the painting. +// +// The caret is counted in CODE POINTS, not in UTF-16 code units. A game's title is the one field here +// that routinely holds something outside the basic plane — an emoji in a name, a composed character — +// and a caret counted in code units eventually lands between the halves of a surrogate pair, where a +// backspace deletes half a character and leaves a replacement glyph behind. Every function below goes +// through `charsOf`, which is the only place that decides what "one character" means. + +/** A field mode, mirrored from osk.ts (kept here so this module imports nothing). */ +export type TextMode = 'text' | 'id' | 'number'; + +export interface TextState { + readonly value: string; + /** Where the next character goes, in code points: 0 is before the first, length is after the last. */ + readonly caret: number; +} + +/** The value split the way the caret sees it. */ +export function charsOf(value: string): readonly string[] { + return [...value]; +} + +/** Keeps a caret inside its value — used wherever a caret and a value could disagree. */ +export function clampCaret(value: string, caret: number): number { + return Math.min(Math.max(0, Math.trunc(caret)), charsOf(value).length); +} + +/** + * What a mode will accept, applied to everything that can put text into a field: a key press, the + * physical keyboard and a paste. + * + * `id` is the manifest's own key for the game on disk, so it is held to what the schema accepts and + * lower-cased (two ids differing only in case would be two games on this PC — a distinction nobody + * means to draw). Every mode drops control characters and folds a newline into a space: these fields + * are single-line, and a pasted line break would otherwise be stored verbatim in the manifest. + */ +export function sanitize(mode: TextMode, text: string): string { + const flat = text.replace(/[\t\n\r]+/g, ' ').replace(/[\p{Cc}\p{Cf}]/gu, ''); + if (mode === 'id') return flat.toLowerCase().replace(/[^a-z0-9._-]/g, ''); + if (mode === 'number') return flat.replace(/[^0-9-]/g, ''); + return flat; +} + +/** Inserts text AT the caret and leaves the caret after what was inserted. */ +export function insertAt(state: TextState, text: string): TextState { + if (text === '') return state; + const chars = charsOf(state.value); + const at = clampCaret(state.value, state.caret); + const added = charsOf(text); + return { + value: [...chars.slice(0, at), ...added, ...chars.slice(at)].join(''), + caret: at + added.length, + }; +} + +/** Deletes the character BEFORE the caret (Backspace). At the very start there is nothing to delete. */ +export function deleteBefore(state: TextState): TextState { + const chars = charsOf(state.value); + const at = clampCaret(state.value, state.caret); + if (at === 0) return state; + return { + value: [...chars.slice(0, at - 1), ...chars.slice(at)].join(''), + caret: at - 1, + }; +} + +/** Deletes the character AT the caret (Delete). At the very end there is nothing to delete. */ +export function deleteAfter(state: TextState): TextState { + const chars = charsOf(state.value); + const at = clampCaret(state.value, state.caret); + if (at >= chars.length) return state; + return { + value: [...chars.slice(0, at), ...chars.slice(at + 1)].join(''), + caret: at, + }; +} + +/** Steps the caret, stopping at either end rather than wrapping — text has ends, and they mean something. */ +export function moveCaret(state: TextState, delta: number): TextState { + const at = clampCaret(state.value, state.caret + delta); + return at === state.caret ? state : { value: state.value, caret: at }; +} + +/** The two halves the field draws: what is before the caret, and what is after it. */ +export function splitAtCaret(state: TextState): { + readonly before: string; + readonly after: string; +} { + const chars = charsOf(state.value); + const at = clampCaret(state.value, state.caret); + return { before: chars.slice(0, at).join(''), after: chars.slice(at).join('') }; +} + +/** + * Turns an offset the DOM reports into a caret. The DOM counts UTF-16 code units inside whichever half + * was clicked, and the halves are exactly what the field renders — so a click resolves against the text + * the user actually pointed at, and the conversion to code points happens once, here. + */ +export function caretFromOffset( + state: TextState, + half: 'before' | 'after', + offsetInHalf: number, +): number { + const { before, after } = splitAtCaret(state); + const at = Math.max(0, Math.trunc(offsetInHalf)); + if (half === 'before') return charsOf(before.slice(0, at)).length; + return charsOf(before).length + charsOf(after.slice(0, at)).length; +} diff --git a/src/renderer/osk.ts b/src/renderer/osk.ts new file mode 100644 index 00000000..fd8a7c57 --- /dev/null +++ b/src/renderer/osk.ts @@ -0,0 +1,701 @@ +// The on-screen keyboard — the only way to type anything in this launcher. +// +// It is not a convenience. The UI has no `<input>` anywhere and its CSS forbids a caret and a selection +// outright, and the Steam Deck's own keyboard is reachable only from a game Steam itself launched — so in +// Game Mode, without this, a text field is a field you can look at. Every character of a game's title, +// its id, its launch arguments and its watched process names comes through here. +// +// Three modes and three layouts, and the pairing matters: `id` is constrained to what the manifest schema +// accepts (`[A-Za-z0-9._-]`) and therefore never offers Cyrillic, while `title` — the game's own visible +// name — must, because a Russian game has a Russian name. `number` is digits and nothing else. +// +// Every control (Shift, Backspace, Space, the layout switch, Done, Cancel) is a KEY on the grid as well +// as a button shortcut, so the keyboard is complete with the d-pad and A alone; the shortcuts (X, Y, +// LB/RB) are the faster path for someone who knows them. +import type { Translator } from '../shared/i18n/index.js'; +import { type AudioController } from './audio.js'; +import { req } from './dom.js'; +import { createEntrance } from './entrance.js'; +import { createHoverGuard } from './hover-guard.js'; +import { clampIndex, wrapIndex } from './index-math.js'; +import { + caretFromOffset, + charsOf, + clampCaret, + deleteAfter, + deleteBefore, + insertAt, + moveCaret, + sanitize, + splitAtCaret, + type TextState, +} from './osk-text.js'; +import type { TextEntrySurface } from './game-settings-screen.js'; + +const PRESS_MS = 130; +/** The most a single paste may bring in. A manifest field is a title or a path — never a document. */ +const PASTE_MAX_CHARS = 512; + +export type OskMode = 'text' | 'id' | 'number'; +type Layout = 'en' | 'ru' | 'symbols'; + +type Key = + | { readonly kind: 'char'; readonly value: string } + | { readonly kind: 'shift' } + | { readonly kind: 'backspace' } + | { readonly kind: 'space' } + | { readonly kind: 'layout' } + | { readonly kind: 'caret-left' } + | { readonly kind: 'caret-right' } + | { readonly kind: 'paste' } + | { readonly kind: 'done' } + | { readonly kind: 'cancel' }; + +const char = (value: string): Key => ({ kind: 'char', value }); +const chars = (source: string): readonly Key[] => [...source].map(char); + +const EN_ROWS: readonly (readonly Key[])[] = [ + chars('1234567890'), + chars('qwertyuiop'), + chars('asdfghjkl'), + chars('zxcvbnm'), +]; + +const RU_ROWS: readonly (readonly Key[])[] = [ + chars('1234567890'), + chars('йцукенгшщзхъ'), + chars('фывапролджэ'), + chars('ячсмитьбюё'), +]; + +const SYMBOL_ROWS: readonly (readonly Key[])[] = [ + chars('1234567890'), + chars('-_.,:;/\\|'), + chars('!?@#$%^&*~'), + chars('()[]{}<>+='), +]; + +/** The `id` schema accepts exactly these punctuation marks, so those are the only ones offered. */ +const ID_SYMBOL_ROWS: readonly (readonly Key[])[] = [chars('1234567890'), chars('._-')]; + +const NUMBER_ROWS: readonly (readonly Key[])[] = [ + chars('123'), + chars('456'), + chars('789'), + chars('0'), +]; + +export interface OskDeps { + readonly audio: AudioController; + getTranslator(): Translator; + /** The system clipboard, read by main — the Paste key's only source (see clipboard:read). */ + readClipboard(): Promise<string>; +} + +export function createOsk(deps: OskDeps): TextEntrySurface { + const root = req('osk'); + const titleEl = req('osk-title'); + const fieldEl = req('osk-field'); + const valueEl = req('osk-value'); + const valueAfterEl = req('osk-value-after'); + const caretEl = req<HTMLElement>('osk-caret'); + const keysEl = req('osk-keys'); + const legendEl = req('osk-legend'); + + const t = (): Translator => deps.getTranslator(); + + let open = false; + let mode: OskMode = 'text'; + let layout: Layout = 'en'; + let shifted = false; + /** The value AND where in it the next character goes — every edit runs through osk-text.ts. */ + let text: TextState = { value: '', caret: 0 }; + let title = ''; + let onDone: (value: string) => void = () => undefined; + + let rows: readonly (readonly Key[])[] = []; + let buttons: HTMLButtonElement[][] = []; + let rowIndex = 0; + let colIndex = 0; + const hover = createHoverGuard(); + + /** The layouts this mode offers, in the order LB/RB and the layout key cycle through them. */ + function layoutsFor(current: OskMode): readonly Layout[] { + if (current === 'number') return ['symbols']; + if (current === 'id') return ['en', 'symbols']; + return ['en', 'ru', 'symbols']; + } + + /** The character rows of the current mode + layout, before the control row is appended. */ + function letterRows(): readonly (readonly Key[])[] { + if (mode === 'number') return NUMBER_ROWS; + if (mode === 'id' && layout === 'symbols') return ID_SYMBOL_ROWS; + if (layout === 'ru') return RU_ROWS; + if (layout === 'symbols') return SYMBOL_ROWS; + return EN_ROWS; + } + + /** + * Whether this mode + layout has a case to shift at all: the symbol rows and the digits have none, and + * an id is lower-case by rule (see `insert`), so offering the key there would be offering a key that + * lies. The legend asks the same question — one answer, two places that must agree. + */ + function hasShift(): boolean { + return mode !== 'number' && mode !== 'id' && layout !== 'symbols'; + } + + /** + * The two control rows. Two, not one: with the caret keys and Paste on it the single row grew wider + * than the panel, and a row that overflows is a key you cannot reach. The split is by SUBJECT — what + * you type with above, what you do with the text below — rather than by where the overflow happened. + */ + function controlRows(): readonly (readonly Key[])[] { + const typing: Key[] = []; + if (hasShift()) typing.push({ kind: 'shift' }); + if (layoutsFor(mode).length > 1) typing.push({ kind: 'layout' }); + if (mode !== 'number') typing.push({ kind: 'space' }); + typing.push({ kind: 'caret-left' }, { kind: 'caret-right' }, { kind: 'backspace' }); + return [typing, [{ kind: 'paste' }, { kind: 'cancel' }, { kind: 'done' }]]; + } + + function keyLabel(key: Key): string { + switch (key.kind) { + case 'char': + return shifted ? key.value.toUpperCase() : key.value; + case 'shift': + return t()('osk.shift'); + case 'backspace': + return t()('osk.backspace'); + case 'space': + return t()('osk.space'); + case 'layout': + return layoutLabel(nextLayout()); + // Glyphs, not words: an arrow needs no translation and fits a narrow key. + case 'caret-left': + return '◀'; + case 'caret-right': + return '▶'; + case 'paste': + return t()('osk.paste'); + case 'done': + return t()('osk.done'); + case 'cancel': + return t()('osk.cancel'); + } + } + + function layoutLabel(which: Layout): string { + if (which === 'en') return 'ABC'; + if (which === 'ru') return 'АБВ'; + return '#+='; + } + + function nextLayout(): Layout { + const list = layoutsFor(mode); + const at = list.indexOf(layout); + return list[wrapIndex(at === -1 ? 0 : at, 1, list.length)] ?? layout; + } + + /** Whether a key takes the wide form. The caret arrows are glyphs — they stay the size of a letter. */ + function isWideKey(key: Key): boolean { + return key.kind !== 'char' && key.kind !== 'caret-left' && key.kind !== 'caret-right'; + } + + function render(): void { + rows = [...letterRows(), ...controlRows()]; + buttons = rows.map((row, r) => { + const rowEl = document.createElement('div'); + rowEl.className = 'osk-row'; + rowEl.style.setProperty('--osk-row', String(r)); + const rowButtons = row.map((key, c) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'osk-key'; + if (isWideKey(key)) button.classList.add('is-wide'); + if (key.kind === 'shift' && shifted) button.classList.add('is-active'); + button.textContent = keyLabel(key); + button.addEventListener('click', () => { + rowIndex = r; + colIndex = c; + applyFocus(); + press(key, button); + }); + rowEl.append(button); + return button; + }); + keysEl.append(rowEl); + return rowButtons; + }); + applyFocus(); + } + + function rebuild(): void { + keysEl.replaceChildren(); + render(); + updateLegend(); // the layout may have changed, and with it whether Shift exists + } + + function applyFocus(): void { + rowIndex = clampIndex(rowIndex, 0, rows.length); + const row = buttons[rowIndex] ?? []; + colIndex = clampIndex(colIndex, 0, row.length); + buttons.forEach((rowButtons, r) => + rowButtons.forEach((button, c) => + button.classList.toggle('is-focused', r === rowIndex && c === colIndex), + ), + ); + } + + function paintValue(): void { + const { before, after } = splitAtCaret(text); + valueEl.textContent = before; + valueAfterEl.textContent = after; + // Restart the blink on every edit: the caret spends half of each second invisible, and landing in + // that half right after a click or a keypress reads as "nothing happened". + caretEl.style.setProperty('animation', 'none'); + void caretEl.offsetWidth; + caretEl.style.removeProperty('animation'); + } + + /** Applies a new text state and repaints. The one door every edit goes through. */ + function setText(next: TextState): void { + if (next === text) return; + text = next; + paintValue(); + } + + function pressFlash(el: HTMLElement): void { + el.classList.add('is-pressed'); + window.setTimeout(() => el.classList.remove('is-pressed'), PRESS_MS); + } + + /** Types text AT the caret. What each mode will accept lives in osk-text.ts, with its reasoning. */ + function insert(typed: string): void { + const filtered = sanitize(mode, typed); + if (filtered === '') return; + setText(insertAt(text, filtered)); + // Shift is a one-shot, the way a phone keyboard treats it — a name is "Hades", not "HADES". + if (shifted) { + shifted = false; + rebuild(); + } + } + + function backspace(): void { + setText(deleteBefore(text)); + } + + function moveCaretBy(delta: number): void { + const next = moveCaret(text, delta); + if (next === text) { + deps.audio.playLimit(); // the caret is already at that end + return; + } + // `button`, not `navigate`: these are the caret KEYS being pressed. `navigate` belongs to the + // highlight walking the grid — the caret moving in the text is what the key does, not the walk. + deps.audio.play('button'); + setText(next); + } + + /** + * Paste, the only edit whose text comes from outside the launcher. It is filtered exactly like typing: + * a clipboard holding a newline, a tab or a character the field's schema rejects must not be able to + * put into the manifest what the keys themselves cannot. + */ + async function paste(): Promise<void> { + const clipboard = await deps.readClipboard(); + if (!open) return; // the keyboard was closed while main was answering + // Capped, and capped by CHARACTER so the cut can't land inside one: nothing this keyboard edits is + // longer than a path, and a clipboard holding a whole file would otherwise be drawn into the field. + const filtered = charsOf(sanitize(mode, clipboard)).slice(0, PASTE_MAX_CHARS).join(''); + if (filtered === '') return; + setText(insertAt(text, filtered)); + } + + function press(key: Key, el?: HTMLElement): void { + if (el !== undefined) pressFlash(el); + switch (key.kind) { + case 'char': + // A character is a KEYSTROKE, not a move through the grid — the arrows already say `navigate`, + // and typing a name with that sound reads as walking the keyboard rather than writing. + deps.audio.play('typing'); + insert(shifted ? key.value.toUpperCase() : key.value); + return; + case 'shift': + deps.audio.play('button'); + shifted = !shifted; + rebuild(); + return; + case 'backspace': + deps.audio.play('typing'); // deleting is typing too — the field is being written either way + backspace(); + return; + case 'space': + deps.audio.play('typing'); // a space is a character like any other + insert(' '); + return; + case 'layout': + deps.audio.play('button'); + switchLayout(1); + return; + case 'caret-left': + moveCaretBy(-1); + return; + case 'caret-right': + moveCaretBy(1); + return; + case 'paste': + deps.audio.play('button'); + void paste(); + return; + case 'done': + deps.audio.play('button'); + confirm(); + return; + case 'cancel': + cancel(); + return; + } + } + + /** Cycles to the next layout of this mode. False when the mode has only one — nothing to switch to. */ + function switchLayout(direction: -1 | 1): boolean { + const list = layoutsFor(mode); + if (list.length < 2) return false; + const at = list.indexOf(layout); + layout = list[wrapIndex(at === -1 ? 0 : at, direction, list.length)] ?? layout; + shifted = false; + rebuild(); + focusKind('layout'); + return true; + } + + /** + * Puts the focus back on a CONTROL key by what it is, not by where it was. The control row is built + * per layout — the symbol layouts have no Shift — so the same index means a different key after a + * switch, and cycling en → ru → symbols would walk the focus off the layout key onto Space. + */ + function focusKind(kind: Key['kind']): void { + for (const [r, row] of rows.entries()) { + const c = row.findIndex((key) => key.kind === kind); + if (c === -1) continue; + rowIndex = r; + colIndex = c; + applyFocus(); + return; + } + } + + function confirm(): void { + const result = text.value; + hide(); + onDone(result); + } + + function cancel(): void { + hide(); + } + + function hide(): void { + if (!open) return; + deps.audio.play('popup-close'); + open = false; + entrance.cancel(); + root.classList.remove('is-open'); + root.setAttribute('aria-hidden', 'true'); + } + + /** + * The rows' staggered arrival. Armed by the keyboard rather than inherited from `.is-open`, because the + * rows are REBUILT far more often than the keyboard opens — every shift, and every shifted character + * types one and rebuilds them back — and an animation the elements simply inherit on creation would + * replay through all of that. entrance.ts is what keeps a rebuild DURING the arrival out of it too. + */ + const ENTRANCE_MS = 600; + const entrance = createEntrance(root, '.osk-row', ENTRANCE_MS); + + function move(rowDelta: number, colDelta: number): void { + hover.arm(); + if (rowDelta !== 0) { + const next = clampIndex(rowIndex, rowDelta, rows.length); + if (next === rowIndex) { + deps.audio.playLimit(); // the top / bottom row of the grid + return; + } + // The column is kept PROPORTIONALLY, not by index: the rows are of different lengths, and jumping + // from the middle of a ten-key row to the end of a four-key one reads as the focus teleporting. + const from = buttons[rowIndex]?.length ?? 1; + const to = buttons[next]?.length ?? 1; + const ratio = from <= 1 ? 0 : colIndex / (from - 1); + rowIndex = next; + colIndex = Math.round(ratio * Math.max(0, to - 1)); + } else { + const row = buttons[rowIndex] ?? []; + const next = wrapIndex(colIndex, colDelta, row.length); + if (next === colIndex) return; + colIndex = next; + } + deps.audio.play('navigate'); + applyFocus(); + } + + function focusedKey(): Key | undefined { + return rows[rowIndex]?.[colIndex]; + } + + /** + * The legend names the buttons this keyboard ACTUALLY has right now — it is built from the same two + * conditions the control row is (see controlRow), so it can never promise a key that is not there. A + * number pad has neither a case to shift nor a second layout to switch to, and listing both was telling + * the user to press buttons that do nothing. + */ + function updateLegend(): void { + const parts: string[] = [t()('osk.legendDelete')]; + if (hasShift()) parts.push(t()('osk.legendShift')); + if (layoutsFor(mode).length > 1) parts.push(t()('osk.legendLayout')); + parts.push(t()('osk.legendDone'), t()('osk.legendCancel')); + const text = parts.join(', '); + // Rewritten only when it changed: this runs on every rebuild, and a rebuild happens on every shift. + if (legendEl.textContent !== text) legendEl.textContent = text; + } + + /** What the DOM reports for a point: the node the caret would land in, and an offset inside it. */ + interface CaretHit { + readonly node: Node; + readonly offset: number; + } + + /** + * Where a click lands in the text. Both spellings of the same browser API are tried: the standard + * `caretPositionFromPoint` and the older `caretRangeFromPoint` Chromium has always had. Neither is in + * the DOM lib types we compile against, hence the narrow local shape rather than a cast to `any`. + */ + function caretHitAt(x: number, y: number): CaretHit | null { + const doc = document as unknown as { + caretPositionFromPoint?: ( + x: number, + y: number, + ) => { offsetNode: Node; offset: number } | null; + caretRangeFromPoint?: (x: number, y: number) => Range | null; + }; + const position = doc.caretPositionFromPoint?.(x, y) ?? null; + if (position !== null) return { node: position.offsetNode, offset: position.offset }; + const range = doc.caretRangeFromPoint?.(x, y) ?? null; + if (range !== null) return { node: range.startContainer, offset: range.startOffset }; + return null; + } + + /** + * Click anywhere in the value to put the caret there. This is the mouse's whole answer to "I want to + * fix the middle of this" — without it the only way back into typed text was to delete it. + */ + fieldEl.addEventListener('click', (event) => { + if (!open) return; + const hit = caretHitAt(event.clientX, event.clientY); + // A click on the padding around the text, or anywhere the DOM cannot resolve: treat it as "past the + // end", which is where a click into empty space means. + if (hit === null) { + setCaret(clampCaret(text.value, Infinity)); + return; + } + if (valueEl.contains(hit.node)) { + setCaret(caretFromOffset(text, 'before', hit.offset)); + return; + } + if (valueAfterEl.contains(hit.node)) { + setCaret(caretFromOffset(text, 'after', hit.offset)); + return; + } + setCaret(clampCaret(text.value, Infinity)); + }); + + /** Moves the caret without moving anything else — the mouse's own path into the text. */ + function setCaret(at: number): void { + const next = { value: text.value, caret: clampCaret(text.value, at) }; + if (next.caret === text.caret) return; + deps.audio.play('navigate'); + setText(next); + } + + root.querySelector<HTMLElement>('.osk-veil')?.addEventListener('click', () => { + cancel(); + }); + + window.addEventListener( + 'mousemove', + (event) => { + hover.track(event.clientX, event.clientY); + if (!open) return; + if (document.documentElement.classList.contains('mouse-asleep')) return; + if (!hover.awake(event.clientX, event.clientY)) return; + const target = event.target; + if (!(target instanceof Element)) return; + const button = target.closest<HTMLButtonElement>('.osk-key'); + if (button === null) return; + for (const [r, rowButtons] of buttons.entries()) { + const c = rowButtons.indexOf(button); + if (c === -1) continue; + if (r === rowIndex && c === colIndex) return; + rowIndex = r; + colIndex = c; + applyFocus(); + return; + } + }, + { passive: true }, + ); + + /** + * The physical keyboard writes straight through, which is the whole point of having one. It is a + * CAPTURE listener that stops the event dead: controls.ts also listens on the window and would read + * `a` as "move left" and Space as "activate", turning every typed letter into a navigation step. + */ + window.addEventListener( + 'keydown', + (event) => { + if (!open) return; + const key = event.key; + // Ctrl/Cmd+V is the only modified combination the keyboard claims — everything else with a modifier + // belongs to the OS (and a modified letter must not be typed as that letter). + if (event.ctrlKey || event.metaKey) { + if (key === 'v' || key === 'V' || key === 'м' || key === 'М') { + event.preventDefault(); + event.stopImmediatePropagation(); + void paste(); + } + return; + } + if (event.altKey) return; + if (key === 'Enter') { + event.preventDefault(); + event.stopImmediatePropagation(); + confirm(); + return; + } + if (key === 'Escape') { + event.preventDefault(); + event.stopImmediatePropagation(); + cancel(); + return; + } + if (key === 'Backspace') { + event.preventDefault(); + event.stopImmediatePropagation(); + if (!event.repeat) deps.audio.play('typing'); // silent while held, as the character keys are + backspace(); // auto-repeat included: a held Backspace should keep deleting, like anywhere else + return; + } + if (key === 'Delete') { + event.preventDefault(); + event.stopImmediatePropagation(); + setText(deleteAfter(text)); + return; + } + // The arrows move the CARET here, not the key highlight. The highlight is what a gamepad steers; + // someone on a physical keyboard is typing straight through and means the text. + if (key === 'ArrowLeft' || key === 'ArrowRight') { + event.preventDefault(); + event.stopImmediatePropagation(); + // Through the same primitive as the on-screen caret keys, so a physical arrow sounds like one + // and stops at the ends with the dead-end sound instead of silently doing nothing. + moveCaretBy(key === 'ArrowLeft' ? -1 : 1); + return; + } + if (key === 'Home' || key === 'End') { + event.preventDefault(); + event.stopImmediatePropagation(); + setText({ + value: text.value, + caret: key === 'Home' ? 0 : clampCaret(text.value, Infinity), + }); + return; + } + if ([...key].length === 1) { + event.preventDefault(); + event.stopImmediatePropagation(); + // The same keystroke sound the on-screen keys make — it is the same field being typed into. A + // HELD key is silent after the first: the OS repeats some 30 times a second, which is a rattle, + // not typing. + if (!event.repeat) deps.audio.play('typing'); + insert(key); + } + }, + { capture: true }, + ); + + return { + isOpen: () => open, + open: (request) => { + mode = request.mode; + // The caret opens at the END of what is already there: the commonest edit is "add to this", and + // anything else is one click or one arrow away. + text = { value: request.value, caret: clampCaret(request.value, request.value.length) }; + title = request.title; + onDone = request.onDone; + layout = layoutsFor(mode)[0] ?? 'en'; + shifted = false; + rowIndex = 0; + colIndex = 0; + open = true; + deps.audio.play('popup-open'); + titleEl.textContent = title; + paintValue(); + updateLegend(); + rebuild(); + hover.arm(); + root.classList.add('is-open'); + entrance.play(); + root.setAttribute('aria-hidden', 'false'); + }, + navUp: () => move(-1, 0), + navDown: () => move(1, 0), + navLeft: () => move(0, -1), + navRight: () => move(0, 1), + navActivate: () => { + const key = focusedKey(); + if (key === undefined) return; + press(key, buttons[rowIndex]?.[colIndex]); + }, + navBack: () => { + cancel(); + }, + close: () => { + hide(); + }, + // X is Backspace and Y is Shift — the two things a typist reaches for constantly, off the grid. + // A HELD X keeps deleting, one character at a time, the way a held Backspace does everywhere else. + navSecondary: (repeat = false) => { + if (text.caret === 0) { + if (!repeat) deps.audio.playLimit(); // nothing left to delete; a hold stays quiet + return; + } + if (!repeat) deps.audio.play('typing'); + backspace(); + }, + navTertiary: () => { + // Shift has no meaning on the digits or the symbol layout — neither has a second case. + if (mode === 'number' || layout === 'symbols') { + deps.audio.playLimit(); + return; + } + deps.audio.play('button'); + shifted = !shifted; + rebuild(); + }, + navShoulder: (direction) => { + // The number mode offers a single layout, so the shoulders have nothing to switch to there — and + // saying so is the point: they used to answer with `button`, sounding like an action that happened. + if (switchLayout(direction)) deps.audio.play('button'); + else deps.audio.playLimit(); + }, + navCommit: () => { + deps.audio.play('button'); + confirm(); + }, + relocalize: () => { + if (!open) return; + updateLegend(); + rebuild(); + }, + }; +} diff --git a/src/renderer/row-view-core.ts b/src/renderer/row-view-core.ts new file mode 100644 index 00000000..8a586563 --- /dev/null +++ b/src/renderer/row-view-core.ts @@ -0,0 +1,424 @@ +// The row vocabulary shared by the launcher's list screens (Settings and Customize): the label type, the +// dropdown option type, and the DOM builders/patchers for the row kinds both screens draw. Everything +// here is generic over the row `id`, so each screen keeps its own literal-union ids (and the exhaustive +// switches that come with them) while the DOM lives in one place. +// +// Two generalizations over the Settings-only original: +// • `id` is a type PARAMETER (defaulting to `string`), not a fixed union — a `CoreToggleRow<ToggleId>` +// is still assignable to the `CoreToggleRow` these functions take, because `id` is readonly; +// • a label is `{ key }` OR `{ text }`. Settings labels are all translation keys; Customize labels are +// in the main dynamic (a path, a value, an item number), which no MessageKey can express. +import type { MessageKey, Translator } from '../shared/i18n/index'; + +/** A row label: our own words (translated) or a value that is what it is (a path, a title, a number). */ +export type RowLabel = { readonly key: MessageKey } | { readonly text: string }; + +export function rowLabelText(label: RowLabel, t: Translator): string { + return 'key' in label ? t(label.key) : label.text; +} + +/** + * One dropdown option. Its label is either a translation key (`system`, `No ambience`) or a literal — + * sound sets and ambience tracks are proper names of bundled files and are never translated. + */ +export type CoreOption = + | { readonly value: string; readonly labelKey: MessageKey } + | { readonly value: string; readonly label: string }; + +/** The label of an option: a translation key for our own words, a literal for bundled proper names. */ +export function optionLabel(option: CoreOption, t: Translator): string { + return 'labelKey' in option ? t(option.labelKey) : option.label; +} + +/** + * Builds an option's label as a clipped, scrollable line: `button > .settings-option-clip > + * .settings-option-text`. A label wider than the column is NOT ellipsized — the bundled font renders the + * ellipsis as three vertically-centred dots, and a cut-off word is worse than a moving one anyway. The + * clip fades at both edges and the focused option's text slides to reveal its start (styles.css). + */ +export function optionLabelNode(text: string): HTMLElement { + const clip = document.createElement('span'); + clip.className = 'settings-option-clip'; + const inner = document.createElement('span'); + inner.className = 'settings-option-text'; + inner.textContent = text; + clip.append(inner); + return clip; +} + +/** + * The shape a row's thumbnails are drawn in. It is the ARTWORK's own shape, not a uniform tile: a hero + * background is 16:9 and the carousel card is a 600x900 portrait, and cropping one into the other's box + * is exactly the misreading a preview is there to prevent. + */ +export type PreviewAspect = 'wide' | 'portrait'; + +export function div(className: string, text?: string): HTMLElement { + const el = document.createElement('div'); + el.className = className; + if (text !== undefined) el.textContent = text; + return el; +} + +/** + * What every labelled row carries. `error` is the field's own validation problem, ALREADY localized (it + * comes from main's validator, which speaks the user's language): shown inside the row rather than in a + * list at the bottom, because a per-game form has thirty fields and "install.args: expected array" is + * useless when you cannot see which row it means. + */ +interface LabeledRow<Id extends string> { + readonly id: Id; + readonly label: RowLabel; + readonly hint?: RowLabel; + readonly error?: string; +} + +export interface CoreToggleRow<Id extends string = string> extends LabeledRow<Id> { + readonly kind: 'toggle'; + readonly value: boolean; + /** A toggle the current state forces (install.runAsAdmin under a `custom` installer) — shown, inert. */ + readonly disabled?: boolean; +} + +export interface CoreSelectRow<Id extends string = string> extends LabeledRow<Id> { + readonly kind: 'select'; + readonly value: string; + readonly options: readonly CoreOption[]; +} + +export interface CoreSliderRow<Id extends string = string> extends LabeledRow<Id> { + readonly kind: 'slider'; + /** 0..100, rounded — the display unit; the controller divides by 100 before it persists. */ + readonly percent: number; +} + +export interface CoreActionRow<Id extends string = string> { + readonly kind: 'action'; + readonly id: Id; + readonly label: RowLabel; + /** Marks a destructive action (Delete game) — styled apart from the neutral ones. */ + readonly danger?: boolean; + /** Shown but inert (Save while the validator is unhappy) — hiding it would hide WHY it cannot run. */ + readonly disabled?: boolean; +} + +/** A free-text field. The value is edited through the on-screen keyboard, never typed into the row. */ +export interface CoreTextRow<Id extends string = string> extends LabeledRow<Id> { + readonly kind: 'text'; + readonly value: string; + /** Shown greyed in place of an empty value ("not set", "auto"). */ + readonly placeholder?: RowLabel; +} + +/** A number field: ‹ value › steps it, A opens the keyboard in numeric mode. */ +export interface CoreNumberRow<Id extends string = string> extends LabeledRow<Id> { + readonly kind: 'number'; + /** Kept as TEXT, like the form model: '' means "omitted", which no number can express. */ + readonly value: string; + readonly placeholder?: RowLabel; + readonly step: number; + readonly min: number; + readonly max: number; +} + +/** A path field: the current value plus Browse / Clear, both reached from the row's own sub-actions. */ +export interface CorePathRow<Id extends string = string> extends LabeledRow<Id> { + readonly kind: 'path'; + readonly value: string; + readonly placeholder?: RowLabel; + /** Draw the value as a thumbnail as well (hero / grid artwork), in the artwork's own proportions. */ + readonly preview?: PreviewAspect; +} + +/** A list field (args, watchProcesses, winetricks, heroImage): opens its own editing surface. */ +export interface CoreListRow<Id extends string = string> extends LabeledRow<Id> { + readonly kind: 'list'; + readonly items: readonly string[]; + /** 0 = unlimited. heroImage caps at MAX_HERO_IMAGES. */ + readonly max: number; + /** Shown greyed for an empty list ("nothing yet"). */ + readonly placeholder?: RowLabel; + readonly preview?: PreviewAspect; +} + +/** A read-only line: schemaVersion, the game's source (card / This PC). Not focusable. */ +export interface CoreStaticRow<Id extends string = string> extends LabeledRow<Id> { + readonly kind: 'static'; + readonly value: RowLabel; +} + +/** A free-standing message inside the list (the `mixed` banner, the id-change warning, an error). */ +export interface CoreNoteRow<Id extends string = string> { + readonly kind: 'note'; + readonly id: Id; + readonly text: RowLabel; + readonly tone: 'info' | 'warning' | 'error'; +} + +export type CoreRow<Id extends string = string> = + | CoreToggleRow<Id> + | CoreSelectRow<Id> + | CoreSliderRow<Id> + | CoreActionRow<Id> + | CoreTextRow<Id> + | CoreNumberRow<Id> + | CorePathRow<Id> + | CoreListRow<Id> + | CoreStaticRow<Id> + | CoreNoteRow<Id>; + +/** The nodes a controller updates after a row has been built. */ +export interface CoreRendered { + readonly el: HTMLElement; + /** The value node whose content changes: the select's text, the slider's percent, a path. */ + readonly valueEl: HTMLElement | null; + /** The `.text-button` of an action row. */ + readonly buttonEl: HTMLButtonElement | null; + /** The slider's filled track. */ + readonly fillEl: HTMLElement | null; +} + +/** The inline check glyph of a toggle. Inline SVG — DOM, not a network resource, so the CSP is fine. */ +function checkIcon(): SVGSVGElement { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('class', 'setting-check'); + svg.setAttribute('aria-hidden', 'true'); + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', 'M4 12.5 L9.5 18 L20 6.5'); + svg.append(path); + return svg; +} + +/** A left/right chevron of a select / number row (clickable with the mouse). */ +export function chevron(direction: 'prev' | 'next'): HTMLElement { + const button = document.createElement('span'); + button.className = `setting-chevron is-${direction}`; + button.dataset['chevron'] = direction; + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('aria-hidden', 'true'); + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', direction === 'prev' ? 'M15 4 L7 12 L15 20' : 'M9 4 L17 12 L9 20'); + svg.append(path); + button.append(svg); + return button; +} + +function selectedLabel(row: CoreSelectRow, t: Translator): string { + const option = row.options.find((candidate) => candidate.value === row.value); + return option === undefined ? row.value : optionLabel(option, t); +} + +/** Positions a slider's fill + knob for a 0..100 percent. */ +export function applySliderPercent(fill: HTMLElement, knob: HTMLElement, percent: number): void { + fill.style.width = `${percent}%`; + knob.style.left = `${percent}%`; +} + +/** What a value cell shows: the value, or the placeholder when the value is empty. */ +function valueOrPlaceholder( + value: string, + placeholder: RowLabel | undefined, + t: Translator, +): { readonly text: string; readonly empty: boolean } { + if (value !== '') return { text: value, empty: false }; + return { text: placeholder === undefined ? '' : rowLabelText(placeholder, t), empty: true }; +} + +/** + * The text beside an ARTWORK value — which is nothing, once there is a picture to look at. The + * thumbnail already answers "what is set here", and a path repeated next to it only crowds the row; the + * path itself is one menu press away. An EMPTY artwork field still shows its placeholder, because then + * there is no picture and the row would otherwise be blank. + */ +function artworkText( + hasValue: boolean, + placeholder: RowLabel | undefined, + t: Translator, +): { readonly text: string; readonly empty: boolean } { + if (hasValue) return { text: '', empty: false }; + return { text: placeholder === undefined ? '' : rowLabelText(placeholder, t), empty: true }; +} + +/** The summary a list row shows in place of its items: "3 items" is useless, the items are not. */ +function listSummary(row: CoreListRow, t: Translator): string { + if (row.preview !== undefined) return artworkText(row.items.length > 0, row.placeholder, t).text; + if (row.items.length > 0) return row.items.join(', '); + return row.placeholder === undefined ? '' : rowLabelText(row.placeholder, t); +} + +/** The label side of a row (absent for the kinds that are nothing but their own control). */ +function appendLabelBox(el: HTMLElement, row: CoreRow, t: Translator): void { + if (row.kind === 'action' || row.kind === 'note') return; + const labelBox = div('setting-label-box'); + labelBox.append(div('setting-label', rowLabelText(row.label, t))); + if (row.hint !== undefined) labelBox.append(div('setting-hint', rowLabelText(row.hint, t))); + const error = div('setting-error', row.error ?? ''); + error.classList.toggle('is-hidden', row.error === undefined); + labelBox.append(error); + el.classList.toggle('has-error', row.error !== undefined); + el.append(labelBox); +} + +/** Re-applies a row's error line without rebuilding it (the validator answers on its own schedule). */ +function patchError(rendered: CoreRendered, error: string | undefined): void { + const el = rendered.el.querySelector<HTMLElement>('.setting-error'); + if (el !== null) { + el.textContent = error ?? ''; + el.classList.toggle('is-hidden', error === undefined); + } + rendered.el.classList.toggle('has-error', error !== undefined); +} + +/** Builds one row's element. The control is built per `kind`; the label side is shared. */ +export function buildCoreRow(row: CoreRow, t: Translator): CoreRendered { + const el = div('setting-row'); + el.dataset['kind'] = row.kind; + appendLabelBox(el, row, t); + + switch (row.kind) { + case 'toggle': { + const control = div('setting-toggle'); + control.append(checkIcon()); + control.classList.toggle('is-on', row.value); + el.classList.toggle('is-disabled', row.disabled === true); + el.append(control); + return { el, valueEl: control, buttonEl: null, fillEl: null }; + } + case 'select': { + const control = div('setting-select'); + const value = div('setting-value', selectedLabel(row, t)); + control.append(chevron('prev'), value, chevron('next')); + el.append(control); + return { el, valueEl: value, buttonEl: null, fillEl: null }; + } + case 'slider': { + const control = div('setting-slider'); + const track = div('setting-track'); + const fill = div('setting-fill'); + const knob = div('setting-knob'); + track.append(fill, knob); + const value = div('setting-value', `${row.percent}%`); + control.append(track, value); + applySliderPercent(fill, knob, row.percent); + el.append(control); + return { el, valueEl: value, buttonEl: null, fillEl: fill }; + } + case 'action': { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'text-button'; + button.textContent = rowLabelText(row.label, t); + el.classList.toggle('is-danger', row.danger === true); + el.classList.toggle('is-disabled', row.disabled === true); + el.append(button); + return { el, valueEl: null, buttonEl: button, fillEl: null }; + } + case 'text': + case 'path': { + const shown = + row.kind === 'path' && row.preview !== undefined + ? artworkText(row.value !== '', row.placeholder, t) + : valueOrPlaceholder(row.value, row.placeholder, t); + const value = div('setting-value setting-value-wide', shown.text); + value.classList.toggle('is-empty', shown.empty); + el.append(value); + return { el, valueEl: value, buttonEl: null, fillEl: null }; + } + case 'number': { + const control = div('setting-select'); + const shown = valueOrPlaceholder(row.value, row.placeholder, t); + const value = div('setting-value', shown.text); + value.classList.toggle('is-empty', shown.empty); + control.append(chevron('prev'), value, chevron('next')); + el.append(control); + return { el, valueEl: value, buttonEl: null, fillEl: null }; + } + case 'list': { + const value = div('setting-value setting-value-wide', listSummary(row, t)); + value.classList.toggle('is-empty', row.items.length === 0); + el.append(value); + return { el, valueEl: value, buttonEl: null, fillEl: null }; + } + case 'static': { + const value = div('setting-value setting-value-wide', rowLabelText(row.value, t)); + el.append(value); + return { el, valueEl: value, buttonEl: null, fillEl: null }; + } + case 'note': { + el.classList.add('setting-row-note', `is-${row.tone}`); + const text = div('setting-note-text', rowLabelText(row.text, t)); + el.append(text); + return { el, valueEl: text, buttonEl: null, fillEl: null }; + } + } +} + +/** + * Applies a new model row onto an already-rendered one, touching only what changed. Same `kind` only — + * a composition change goes through a full re-render instead. + */ +export function patchCoreRow(rendered: CoreRendered, row: CoreRow, t: Translator): void { + if (row.kind !== 'action' && row.kind !== 'note') patchError(rendered, row.error); + switch (row.kind) { + case 'toggle': + rendered.valueEl?.classList.toggle('is-on', row.value); + rendered.el.classList.toggle('is-disabled', row.disabled === true); + break; + case 'select': + if (rendered.valueEl !== null) rendered.valueEl.textContent = selectedLabel(row, t); + break; + case 'slider': { + if (rendered.valueEl !== null) rendered.valueEl.textContent = `${row.percent}%`; + const knob = rendered.el.querySelector<HTMLElement>('.setting-knob'); + if (rendered.fillEl !== null && knob !== null) { + applySliderPercent(rendered.fillEl, knob, row.percent); + } + break; + } + case 'action': + if (rendered.buttonEl !== null) rendered.buttonEl.textContent = rowLabelText(row.label, t); + rendered.el.classList.toggle('is-danger', row.danger === true); + rendered.el.classList.toggle('is-disabled', row.disabled === true); + break; + case 'text': + case 'path': + case 'number': { + const shown = + row.kind === 'path' && row.preview !== undefined + ? artworkText(row.value !== '', row.placeholder, t) + : valueOrPlaceholder(row.value, row.placeholder, t); + if (rendered.valueEl !== null) { + rendered.valueEl.textContent = shown.text; + rendered.valueEl.classList.toggle('is-empty', shown.empty); + } + break; + } + case 'list': + if (rendered.valueEl !== null) { + rendered.valueEl.textContent = listSummary(row, t); + rendered.valueEl.classList.toggle('is-empty', row.items.length === 0); + } + break; + case 'static': + if (rendered.valueEl !== null) rendered.valueEl.textContent = rowLabelText(row.value, t); + break; + case 'note': + if (rendered.valueEl !== null) rendered.valueEl.textContent = rowLabelText(row.text, t); + break; + } +} + +/** Re-applies a row's LABEL + hint for a new translator (values are patched by patchCoreRow). */ +export function relocalizeCoreRow(rendered: CoreRendered, row: CoreRow, t: Translator): void { + if (row.kind !== 'action' && row.kind !== 'note') { + const label = rendered.el.querySelector<HTMLElement>('.setting-label'); + if (label !== null) label.textContent = rowLabelText(row.label, t); + if (row.hint !== undefined) { + const hint = rendered.el.querySelector<HTMLElement>('.setting-hint'); + if (hint !== null) hint.textContent = rowLabelText(row.hint, t); + } + } + patchCoreRow(rendered, row, t); +} diff --git a/src/renderer/screen-scroller.ts b/src/renderer/screen-scroller.ts new file mode 100644 index 00000000..92d7b47d --- /dev/null +++ b/src/renderer/screen-scroller.ts @@ -0,0 +1,152 @@ +// The scrolling behaviour shared by every full-screen surface of the launcher (Settings, Customize, and +// the file picker): one fixed duration and easing, plus the edge fades that soften a row cut by the clip. +// Lifted verbatim out of settings-screen.ts when the second screen appeared — it was already closed over +// nothing but `box` and the design-pixel unit, so the move is a move, not a rewrite. + +/** + * How long the list takes to reach a new scroll target. The scroll is animated here rather than left to + * `scrollIntoView({behavior:'smooth'})`: the native one picks its own duration per distance, so a held + * direction produced a different (and visibly uneven) glide on every step. One fixed duration with one + * easing, re-aimed from wherever the current animation is, reads as a single continuous movement. + */ +const SCROLL_MS = 220; +/** How much of the list is kept visible past the focused row, so the next one is always already in view. */ +const SCROLL_MARGIN_PX = 90; +/** The mask's fade height at each edge (mirrors --fade-size in styles.css). */ +const EDGE_FADE_PX = 28; + +/** Standard ease-in-out — the same shape as the CSS transitions the focus highlight uses. */ +export function easeInOut(t: number): number { + return t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2; +} + +/** One design pixel in real px (--px is a vh unit, so it changes with the window). */ +export function pxUnit(): number { + const value = getComputedStyle(document.documentElement).getPropertyValue('--px'); + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? (parsed * window.innerHeight) / 100 : 1; +} + +/** + * A pace other than the default one. The Library grid needs it: while a direction is HELD its rows must + * scroll at exactly the repeat interval and in `linear`, so the steps glue into one continuous glide + * instead of easing in and out 143 ms at a time (the same trick the carousel's strip plays in CSS). + */ +export interface GlideOptions { + readonly durationMs: number; + readonly linear: boolean; +} + +export interface Scroller { + /** Animates (or jumps) to a scrollTop. */ + to(top: number, instant?: boolean): void; + /** Recomputes --fade-top / --fade-bottom for the current position. */ + fades(): void; + /** Brings `target` into view, keeping SCROLL_MARGIN_PX of context beyond it. */ + reveal(target: HTMLElement, instant?: boolean): void; + /** `to`, at a caller-chosen pace. */ + glide(top: number, options: GlideOptions): void; + /** `reveal`, at a caller-chosen pace. */ + revealGlide(target: HTMLElement, options: GlideOptions): void; +} + +export function createScroller(box: HTMLElement): Scroller { + let target = 0; + let from = 0; + let startedAt = 0; + let frame = 0; + // The pace of the animation currently running. Held in state rather than read from the constants, + // because a caller may ask for another one (see GlideOptions) — the defaults are what `to` passes. + let durationMs = SCROLL_MS; + let linear = false; + + const clamp = (top: number): number => + Math.min(Math.max(0, top), Math.max(0, box.scrollHeight - box.clientHeight)); + + /** + * Where the LAID-OUT content ends, in scroll coordinates — read from the last child's layout box and + * not from `scrollHeight`. + * + * The two differ while anything inside is animating: a transformed descendant counts towards the + * scrollable overflow, so an entrance that slides its rows in from 12px below makes a list that exactly + * fills its box report 12px of content past the bottom for as long as the animation runs. The fade below + * then switches on, dims the last row, and switches off again when the animation lands — a blink, on + * every open, on the one row the user is most likely to be looking at. `offsetTop`/`offsetHeight` ignore + * transforms, which is exactly the difference needed: the fade is about content the clip cuts, not about + * decoration passing over it. + */ + const contentBottom = (): number => { + const last = box.lastElementChild; + if (!(last instanceof HTMLElement)) return box.scrollHeight; + // A positioned box IS the offsetParent of its children, and then their offsetTop is already measured + // from it; an unpositioned one shares an offsetParent with them, and the difference is what counts. + const origin = last.offsetParent === box ? 0 : box.offsetTop; + return last.offsetTop - origin + last.offsetHeight; + }; + + const fades = (): void => { + // A fade only belongs where there IS content beyond the edge — at the very top and the very bottom + // the corresponding one is switched off, or the first and last rows read as dimmed for no reason. + const top = box.scrollTop > 1 ? EDGE_FADE_PX : 0; + const bottom = box.scrollTop < contentBottom() - box.clientHeight - 1 ? EDGE_FADE_PX : 0; + box.style.setProperty('--fade-top', `calc(${top} * var(--px))`); + box.style.setProperty('--fade-bottom', `calc(${bottom} * var(--px))`); + }; + + const step = (): void => { + const progress = Math.min(1, (performance.now() - startedAt) / durationMs); + box.scrollTop = from + (target - from) * (linear ? progress : easeInOut(progress)); + fades(); + if (progress >= 1) { + box.scrollTop = target; + frame = 0; + fades(); + return; + } + frame = requestAnimationFrame(step); + }; + + const move = (top: number, instant: boolean, ms: number, isLinear: boolean): void => { + const goal = clamp(top); + if (instant) { + if (frame !== 0) cancelAnimationFrame(frame); + frame = 0; + target = goal; + box.scrollTop = goal; + fades(); + return; + } + // Already heading there AT THE SAME PACE — a re-aim that only changes the pace still has to restart, + // or a held direction would keep gliding on the single-step easing it began with. + const sameGoal = frame !== 0 && Math.abs(goal - target) < 0.5; + if (sameGoal && ms === durationMs && isLinear === linear) return; + target = goal; + from = box.scrollTop; + startedAt = performance.now(); + durationMs = ms; + linear = isLinear; + if (frame === 0) frame = requestAnimationFrame(step); + }; + + const revealWith = (el: HTMLElement, instant: boolean, ms: number, isLinear: boolean): void => { + const margin = SCROLL_MARGIN_PX * pxUnit(); + const top = el.offsetTop - box.offsetTop; + const bottom = top + el.offsetHeight; + const viewTop = box.scrollTop; + const viewBottom = viewTop + box.clientHeight; + if (top - margin < viewTop) move(top - margin, instant, ms, isLinear); + else if (bottom + margin > viewBottom) { + move(bottom + margin - box.clientHeight, instant, ms, isLinear); + } else fades(); + }; + + const to = (top: number, instant = false): void => move(top, instant, SCROLL_MS, false); + const reveal = (el: HTMLElement, instant = false): void => revealWith(el, instant, SCROLL_MS, false); + const glide = (top: number, options: GlideOptions): void => + move(top, false, options.durationMs, options.linear); + const revealGlide = (el: HTMLElement, options: GlideOptions): void => + revealWith(el, false, options.durationMs, options.linear); + + box.addEventListener('scroll', () => fades(), { passive: true }); + return { to, fades, reveal, glide, revealGlide }; +} diff --git a/src/renderer/screen-sidebar.ts b/src/renderer/screen-sidebar.ts new file mode 100644 index 00000000..d1326efb --- /dev/null +++ b/src/renderer/screen-sidebar.ts @@ -0,0 +1,197 @@ +// The left-hand column both settings screens are built around: the sections of the screen, then the +// actions that end it (Save, Discard, Close…). +// +// It exists because a one-column form makes its own actions unreachable. Every screen here is +// bottom-anchored — Save and Close live at the END of the list — so committing anything meant running +// the whole form to the bottom first, every time, on a gamepad. With the sections split off into a +// column of their own, the actions are a fixed handful of steps away from wherever you are, and the pane +// beside them only ever holds the rows of one section. +// +// Movement here is CYCLIC, unlike the pane's: the column is short and closed, so wrapping from the last +// entry to the first is the shortest path to the actions rather than a surprise. The pane stays clamped +// — a long list that wraps loses your place. +import { type AudioController } from './audio.js'; +import { createEntrance } from './entrance.js'; +import { createScroller } from './screen-scroller.js'; +import { wrapIndex } from './index-math.js'; + +/** One entry of the column: a section of the screen, or an action that ends it. */ +export interface SidebarEntry { + readonly id: string; + readonly label: string; + /** A section opens the pane beside it; an action runs and is done. */ + readonly kind: 'section' | 'action'; + /** Destructive (Delete game) — styled apart, like the popup stacks' own danger items. */ + readonly danger?: boolean; + /** Shown but inert (Save with nothing to save): hiding it would hide the reason too. */ + readonly disabled?: boolean; +} + +export interface SidebarDeps { + readonly audio: AudioController; + /** A section was selected (moved onto, or activated) — the pane shows it. */ + onSection(id: string, entered: boolean): void; + /** An action entry was activated. */ + onAction(id: string): void; +} + +export interface Sidebar { + /** Rebuilds the column. Keeps the current selection when that entry still exists. */ + render(entries: readonly SidebarEntry[]): void; + /** Moves the selection, wrapping at both ends. */ + move(delta: number): void; + /** Activates the selected entry (A / click). */ + activate(): void; + /** The selected entry, or undefined for an empty column. */ + selected(): SidebarEntry | undefined; + /** Whether the COLUMN holds the focus (as opposed to the pane beside it). */ + hasFocus(): boolean; + setFocused(focused: boolean): void; + /** + * Selects an entry by id without announcing it — used to restore a selection after a rebuild, and to + * deep-link a screen straight to one section. Returns whether the id was there at all: silently doing + * nothing is how a deep link to a renamed section would go unnoticed. + */ + select(id: string): boolean; + /** + * Puts the selection back on the first entry. A re-opened screen must not resume where the last visit + * left the column while the pane falls back to section one — the two would then disagree about what is + * on screen. It does NOT empty the column: the entries survive, so the caller's own "has this changed?" + * guards stay honest and the screen re-opens with its buttons already there. + */ + reset(): void; + /** Replays the staggered entrance on the entries, as the popup stack does when it opens. */ + animateIn(): void; +} + +/** How long the staggered entrance runs before the class that drives it is dropped. */ +const ENTRANCE_MS = 700; + +export function createSidebar(box: HTMLElement, deps: SidebarDeps): Sidebar { + const scroller = createScroller(box); + let entries: readonly SidebarEntry[] = []; + let buttons: readonly HTMLButtonElement[] = []; + let index = 0; + let focused = true; + // The buttons, by entry id. The column is REBUILT on every render — Save's enabled state follows every + // keystroke — so making that rebuild replace the DOM was a button visibly blinking out and back in + // under the cursor. Reusing the node for an id that is still there turns the common rebuild into a + // handful of property writes, and the DOM is only touched when the SET of entries actually moved. + const nodes = new Map<string, HTMLButtonElement>(); + // Marks the entries themselves, so an entry ADDED by one of those frequent rebuilds does not arrive + // sliding while the rest of the column sits still — see entrance.ts. + const entrance = createEntrance(box, '.settings-nav-item', ENTRANCE_MS); + + function paintFocus(instant = false): void { + buttons.forEach((button, at) => { + button.classList.toggle('is-focused', focused && at === index); + // The section the pane is showing stays marked while the focus is in the pane — otherwise the + // column goes blank the moment you step into the form and nothing says where you are. + button.classList.toggle( + 'is-current', + !focused && at === index && entries[at]?.kind === 'section', + ); + }); + const target = buttons[index]; + if (target !== undefined) scroller.reveal(target, instant); + } + + function announce(entered: boolean): void { + const entry = entries[index]; + if (entry?.kind === 'section') deps.onSection(entry.id, entered); + } + + /** The button for one entry, created on first sight of its id and kept for as long as it is offered. */ + function nodeFor(entry: SidebarEntry): HTMLButtonElement { + const existing = nodes.get(entry.id); + if (existing !== undefined) return existing; + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'settings-nav-item'; + // The index is resolved at click time rather than captured: the node outlives the render that made it. + button.addEventListener('click', () => { + const at = entries.findIndex((candidate) => candidate.id === entry.id); + if (at === -1) return; + index = at; + focused = true; + paintFocus(); + runSelected(); + }); + nodes.set(entry.id, button); + return button; + } + + return { + render: (next) => { + const previousId = entries[index]?.id; + entries = next; + const restored = previousId === undefined ? -1 : next.findIndex((e) => e.id === previousId); + index = restored === -1 ? 0 : restored; + buttons = next.map((entry, at) => { + const button = nodeFor(entry); + button.dataset['kind'] = entry.kind; + button.classList.toggle('is-danger', entry.danger === true); + button.classList.toggle('is-disabled', entry.disabled === true); + button.style.setProperty('--nav-index', String(at)); + if (button.textContent !== entry.label) button.textContent = entry.label; + return button; + }); + for (const [id, node] of nodes) + if (!next.some((entry) => entry.id === id)) { + node.remove(); + nodes.delete(id); + } + // In-order sync rather than replaceChildren: re-inserting a node it already holds would restart + // that button's animation and drop its transition state for nothing. Only what actually moved moves. + buttons.forEach((button, at) => { + const current = box.children[at]; + if (current !== button) box.insertBefore(button, current ?? null); + }); + paintFocus(true); + }, + move: (delta) => { + if (entries.length === 0) return; + const at = wrapIndex(index, delta, entries.length); + if (at === index) return; + index = at; + deps.audio.play('navigate'); + paintFocus(); + // Moving through the column PREVIEWS the section beside it: seeing what you are about to open is + // the whole reason the column is there. + announce(false); + }, + activate: () => runSelected(), + selected: () => entries[index], + hasFocus: () => focused, + setFocused: (value) => { + focused = value; + paintFocus(); + }, + select: (id) => { + const at = entries.findIndex((entry) => entry.id === id); + if (at === -1) return false; + index = at; + // Instant, like reset(): a screen that OPENS on a section must already be there, not glide to it + // from the top while the user watches. + paintFocus(true); + return true; + }, + reset: () => { + index = 0; + paintFocus(true); + }, + animateIn: () => entrance.play(), + }; + + function runSelected(): void { + const entry = entries[index]; + if (entry === undefined) return; + if (entry.kind === 'section') { + deps.audio.play('button'); + announce(true); + return; + } + if (entry.disabled === true) return; + deps.onAction(entry.id); + } +} diff --git a/src/renderer/settings-form-model.ts b/src/renderer/settings-form-model.ts new file mode 100644 index 00000000..51000e3a --- /dev/null +++ b/src/renderer/settings-form-model.ts @@ -0,0 +1,293 @@ +// Pure (DOM-free, electron-free) declaration of the launcher's Settings screen: AppSettings + the +// environment (steam availability, bundled audio options, version, update status) in, a list of sections +// and rows out. The view renders this and the screen controller navigates it, so everything that decides +// WHAT is on the screen — order, visibility, value mapping — is testable in vitest (the view and the +// controller are DOM code, which the node-environment suite cannot reach). Mirrors the split that +// configure-form-model.ts established for the manifest form. +import type { AppSettings, AudioOptions, UpdateStatus } from '../shared/types'; +import type { MessageKey } from '../shared/i18n/index'; +import type { + CoreActionRow, + CoreOption, + CoreSelectRow, + CoreSliderRow, + CoreTextRow, + CoreToggleRow, +} from './row-view-core'; + +/** Every toggle row, keyed by the AppSettings field it writes. */ +export type ToggleId = + | 'prerelease' + | 'summonHotkey' + | 'preventScreensaver' + | 'keepOpenWithoutCard' + | 'disableSilentInstall' + | 'steamAutoLaunch' + | 'onlyGlobalAmbient'; + +/** Every dropdown row. */ +export type SelectId = 'autoUpdate' | 'language' | 'soundSet' | 'ambientTrack'; + +/** Every free-text row. The SteamGridDB key is the only one this screen has. */ +export type TextId = 'steamGridDbKey'; + +/** Every slider row (both are volumes, 0..100 %). */ +export type SliderId = 'sfxVolume' | 'musicVolume'; + +/** Every plain action row (a `.text-button` inside the row). */ +export type ActionId = 'reset' | 'close'; + +/** This screen's dropdown option — the shared one (see row-view-core), re-exported under its old name. */ +export type SettingsOption = CoreOption; + +/** + * A row of THIS screen. The four ordinary kinds are the shared ones (row-view-core) pinned to this + * screen's literal ids, so the controller's exhaustive switches keep working while the DOM stays generic; + * `update-status` is Settings' own — no other screen has a download bar in a row. + */ +export type SettingsRow = + | CoreToggleRow<ToggleId> + | CoreSelectRow<SelectId> + | CoreSliderRow<SliderId> + | CoreActionRow<ActionId> + | CoreTextRow<TextId> + | { readonly kind: 'update-status'; readonly status: UpdateStatus }; + +export interface SettingsSection { + /** Absent for the closing section: one titled "Other" over a pair of buttons says nothing. */ + readonly titleKey?: MessageKey; + readonly rows: readonly SettingsRow[]; +} + +export interface SettingsModel { + readonly sections: readonly SettingsSection[]; + /** Shown next to the screen title. Empty until `app:version` answers. */ + readonly appVersion: string; +} + +/** Everything the model needs beyond AppSettings itself. */ +export interface SettingsEnv { + /** Whether the Steam-shortcut feature exists here (linux + packaged AppImage) — hides its row if not. */ + readonly steamAvailable: boolean; + /** The bundled sound sets + ambience tracks, to populate the Audio dropdowns. */ + readonly audioOptions: AudioOptions; + readonly appVersion: string; + readonly updateStatus: UpdateStatus; +} + +/** The percent (0..100) a 0..1 volume is displayed and stepped in. */ +export function volumePercent(volume: number): number { + return Math.round(volume * 100); +} + +/** + * Cosmetic label for a raw set/track name: split on '-', capitalize each word, join with spaces + * (`steam-big-picture` → `Steam Big Picture`). These are proper names of bundled files — not translated. Mirrors the + * settings window's own prettifyName. + */ +export function prettifyName(raw: string): string { + return raw + .split('-') + .filter((word) => word.length > 0) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} + +/** + * Whether self-update can ever happen on this build — i.e. whether the auto-update MODE is a setting worth + * offering. False only for `unsupported` with reason `platform` (the unsigned macOS build): a dev run is + * `unsupported` too, but the mode it persists is what the installed build will use, so it keeps the rows. + */ +function updatesEverPossible(status: UpdateStatus): boolean { + return !(status.kind === 'unsupported' && status.reason === 'platform'); +} + +const AUTO_UPDATE_OPTIONS: readonly SettingsOption[] = [ + { value: 'download-install', labelKey: 'settings.autoDownloadInstall' }, + { value: 'download', labelKey: 'settings.autoDownloadManual' }, + { value: 'off', labelKey: 'settings.autoOff' }, +]; + +const LANGUAGE_OPTIONS: readonly SettingsOption[] = [ + { value: 'system', labelKey: 'settings.languageSystem' }, + { value: 'en', label: 'English' }, + { value: 'ru', label: 'Русский' }, +]; + +/** The ambience dropdown: "No ambience" (the empty value ⇄ `null` in AppSettings) plus one option per track. */ +function ambientOptions(tracks: readonly string[]): readonly SettingsOption[] { + return [ + { value: '', labelKey: 'settings.ambientNone' }, + ...tracks.map((track) => ({ + value: track, + label: prettifyName(track.replace(/\.[^.]+$/, '')), + })), + ]; +} + +function soundSetOptions(sets: readonly string[]): readonly SettingsOption[] { + return sets.map((name) => ({ value: name, label: prettifyName(name) })); +} + +/** + * How a stored API key is DISPLAYED: dots plus its last four characters. The key is a credential, and + * the screen is often on a TV or in a stream — but a fully hidden value gives the user no way to tell a + * key that is there from one that was pasted wrong, which the tail solves. + */ +export function maskApiKey(key: string): string { + if (key.length === 0) return ''; + const visible = key.length > 8 ? key.slice(-4) : ''; + return `••••••••${visible}`; +} + +/** + * The whole screen as data. The row order is the screen order; a row that does not apply here (the Steam + * Deck auto-launch outside a packaged AppImage) is absent rather than disabled — same rule the settings + * window followed. + */ +export function buildSettingsModel(settings: AppSettings, env: SettingsEnv): SettingsModel { + const general: readonly SettingsRow[] = [ + { + kind: 'toggle', + id: 'summonHotkey', + label: { key: 'settings.summonHotkey' }, + value: settings.summonHotkeyEnabled, + hint: { key: 'settings.summonHint' }, + }, + { + kind: 'toggle', + id: 'preventScreensaver', + label: { key: 'settings.preventScreensaver' }, + value: settings.preventScreensaver, + }, + { + kind: 'toggle', + id: 'keepOpenWithoutCard', + label: { key: 'settings.keepOpenWithoutCard' }, + value: settings.keepOpenWithoutCard, + }, + { + kind: 'toggle', + id: 'disableSilentInstall', + label: { key: 'settings.disableSilentInstall' }, + value: settings.disableSilentInstall, + }, + ...(env.steamAvailable + ? ([ + { + kind: 'toggle', + id: 'steamAutoLaunch', + label: { key: 'settings.steamAutoLaunch' }, + value: settings.steamAutoLaunch, + hint: { key: 'settings.steamAutoLaunchHint' }, + }, + ] as const) + : []), + ]; + + return { + appVersion: env.appVersion, + sections: [ + { + titleKey: 'settings.sectionUpdates', + rows: [ + { kind: 'update-status', status: env.updateStatus }, + // The mode selector and the pre-release toggle only exist to steer an updater that RUNS. On a + // build where self-update is impossible for good (macOS — unsigned bundle), they are controls + // that can never do anything, so the section is just the explanation of what to do instead. + // A dev run keeps them: it is temporary, and the mode it persists is honoured by the installed + // build — which is exactly why this checks the reason rather than the `unsupported` kind. + ...(updatesEverPossible(env.updateStatus) + ? ([ + { + kind: 'select', + id: 'autoUpdate', + label: { key: 'settings.sectionAutoUpdate' }, + value: settings.autoUpdate, + options: AUTO_UPDATE_OPTIONS, + }, + { + kind: 'toggle', + id: 'prerelease', + label: { key: 'settings.prerelease' }, + value: settings.allowPrerelease, + }, + ] as const) + : []), + ], + }, + { + titleKey: 'settings.sectionLanguage', + rows: [ + { + kind: 'select', + id: 'language', + label: { key: 'settings.language' }, + value: settings.language, + options: LANGUAGE_OPTIONS, + }, + ], + }, + { titleKey: 'settings.sectionGeneral', rows: general }, + { + titleKey: 'settings.sectionMetadata', + rows: [ + { + kind: 'text', + id: 'steamGridDbKey', + label: { key: 'settings.steamGridDbKey' }, + value: maskApiKey(settings.steamGridDbApiKey), + placeholder: { key: 'settings.steamGridDbKeyEmpty' }, + hint: { key: 'settings.steamGridDbKeyHint' }, + }, + ], + }, + { + titleKey: 'settings.sectionAudio', + rows: [ + { + kind: 'select', + id: 'soundSet', + label: { key: 'settings.soundSet' }, + value: settings.soundSet, + options: soundSetOptions(env.audioOptions.soundSets), + }, + { + kind: 'slider', + id: 'sfxVolume', + label: { key: 'settings.soundSetVolume' }, + percent: volumePercent(settings.sfxVolume), + }, + { + kind: 'select', + id: 'ambientTrack', + label: { key: 'settings.ambientTrack' }, + value: settings.ambientTrack ?? '', + options: ambientOptions(env.audioOptions.ambientTracks), + }, + { + kind: 'toggle', + id: 'onlyGlobalAmbient', + label: { key: 'settings.onlyGlobalAmbient' }, + value: settings.onlyGlobalAmbient, + hint: { key: 'settings.onlyGlobalAmbientHint' }, + }, + { + kind: 'slider', + id: 'musicVolume', + label: { key: 'settings.ambientVolume' }, + percent: volumePercent(settings.musicVolume), + }, + ], + }, + { + // No title: the last section is the screen's action stack — Reset over Close, bottom-aligned + // like every popup stack, where Close is the default way out for the mouse. + rows: [ + { kind: 'action', id: 'reset', label: { key: 'settings.reset' } }, + { kind: 'action', id: 'close', label: { key: 'launcher.menu.close' } }, + ], + }, + ], + }; +} diff --git a/src/renderer/settings-form-view.ts b/src/renderer/settings-form-view.ts new file mode 100644 index 00000000..ad532dbf --- /dev/null +++ b/src/renderer/settings-form-view.ts @@ -0,0 +1,212 @@ +// DOM rendering for the launcher's Settings screen: a SettingsModel in, a list of built rows out. The +// controller (settings-screen.ts) owns navigation and IPC and addresses rows BY INDEX, so this module +// hands back a flat array in screen order alongside the DOM it built. +// +// Two jobs, and the second is the load-bearing one: rows are also PATCHED in place (patchRow) when a new +// AppSettings snapshot arrives. Rebuilding the list on every settings:update would flash the screen and +// restart every transition mid-flight — see the plan's §3.6. A full rebuild is only for a change in the +// row COMPOSITION (steamAvailable arriving). +// +// Everything but the Updates row is drawn by row-view-core, which the Customize screen shares: this +// module is now the Settings-specific half (the status line, its progress bar and its primary button). +import type { SettingsModel, SettingsRow } from './settings-form-model'; +import type { Translator } from '../shared/i18n/index'; +import type { UpdateStatus } from '../shared/types'; +import { + buildCoreRow, + div, + patchCoreRow, + relocalizeCoreRow, + type CoreRendered, +} from './row-view-core'; + +export { optionLabel, optionLabelNode, applySliderPercent } from './row-view-core'; + +/** One rendered row: the model row it came from plus the nodes the controller updates. */ +export interface RenderedRow { + /** The row's model at render time — patchRow replaces this as values change. */ + row: SettingsRow; + /** The focusable row element (`.setting-row`); the controller toggles `is-focused` / `is-pressed`. */ + readonly el: HTMLElement; + /** The value node whose content changes: the select's text, the slider's percent, the status line. */ + readonly valueEl: HTMLElement | null; + /** The `.text-button` of an action row / the update-status row's primary button. */ + readonly buttonEl: HTMLButtonElement | null; + /** The slider's filled track, patched as the percent changes. */ + readonly fillEl: HTMLElement | null; + /** The download progress bar of the update-status row. */ + readonly progressEl: HTMLElement | null; +} + +export interface RenderedScreen { + /** Every focusable row, in screen order — the navigation model is this array's indices. */ + readonly rows: readonly RenderedRow[]; +} + +/** The status line + primary action of the Updates section, per the current UpdateStatus. */ +export function updateStatusText(status: UpdateStatus, t: Translator): string { + switch (status.kind) { + case 'idle': + return t('settings.status.idle'); + case 'not-available': + return t('settings.status.upToDate'); + case 'checking': + return t('settings.status.checking'); + case 'available': + return t('settings.status.available', { version: status.version }); + case 'downloading': + return t('settings.status.downloading', { percent: status.percent }); + case 'downloaded': + return t('settings.status.downloaded', { version: status.version }); + case 'error': + // Already localized in main (or a passthrough technical cause) — render as-is. + return status.message; + case 'unsupported': + // Two different situations wear the same status: a dev run (temporary, about the build) and the + // macOS build (permanent, and the user has something to DO about it — fetch the new dmg). + return t( + status.reason === 'platform' + ? 'settings.status.unsupportedPlatform' + : 'settings.status.unsupported', + ); + } +} + +/** The Updates row's primary button: its label, and whether it acts at all (null = disabled). */ +export interface UpdateAction { + readonly label: string; + readonly kind: 'check' | 'download' | 'install' | null; +} + +export function updateAction(status: UpdateStatus, t: Translator): UpdateAction | null { + switch (status.kind) { + case 'idle': + case 'not-available': + return { label: t('settings.action.check'), kind: 'check' }; + case 'checking': + return { label: t('settings.action.checking'), kind: null }; + case 'available': + return { + label: t('settings.action.updateTo', { version: status.version }), + kind: 'download', + }; + case 'downloading': + return { label: t('settings.action.downloading'), kind: null }; + case 'downloaded': + return { label: t('settings.action.restartInstall'), kind: 'install' }; + case 'error': + return { label: t('settings.action.retry'), kind: 'check' }; + case 'unsupported': + return null; + } +} + +/** Shows the download bar only while downloading, and sets its width to the percent. */ +function applyProgress(fill: HTMLElement, progress: HTMLElement, status: UpdateStatus): void { + const downloading = status.kind === 'downloading'; + progress.classList.toggle('is-visible', downloading); + if (downloading) fill.style.width = `${status.percent}%`; +} + +/** The Updates row — this screen's own kind, with a status line, a progress bar and a button. */ +function buildStatusRow(status: UpdateStatus, t: Translator): CoreRendered & { progressEl: HTMLElement } { + const el = div('setting-row'); + el.dataset['kind'] = 'update-status'; + el.classList.add('setting-row-status'); + const text = div('setting-status-text', updateStatusText(status, t)); + const progress = div('setting-progress'); + const bar = div('setting-progress-fill'); + progress.append(bar); + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'text-button'; + const action = updateAction(status, t); + button.textContent = action?.label ?? ''; + button.classList.toggle('is-hidden', action === null); + const body = div('setting-status-body'); + body.append(text, progress); + el.append(body, button); + applyProgress(bar, progress, status); + return { el, valueEl: text, buttonEl: button, fillEl: null, progressEl: progress }; +} + +/** Builds one row's element: the Updates row here, everything else in row-view-core. */ +function buildRow(row: SettingsRow, t: Translator): RenderedRow { + if (row.kind === 'update-status') { + return { row, ...buildStatusRow(row.status, t) }; + } + const core = buildCoreRow(row, t); + return { row, ...core, progressEl: null }; +} + +/** + * Renders the whole model into `container` (replacing its content) and returns the rows in screen order. + * Section titles are not focusable, so they are absent from the returned list by construction. + */ +export function renderSettings( + container: HTMLElement, + model: SettingsModel, + t: Translator, +): RenderedScreen { + const rows: RenderedRow[] = []; + const sections = model.sections.map((section) => { + const sectionEl = div('settings-section'); + if (section.titleKey !== undefined) { + sectionEl.append(div('settings-section-title', t(section.titleKey))); + } + for (const row of section.rows) { + const rendered = buildRow(row, t); + rows.push(rendered); + sectionEl.append(rendered.el); + } + return sectionEl; + }); + container.replaceChildren(...sections); + return { rows }; +} + +/** + * Applies a new model row onto an already-rendered one, touching only what changed. Same `kind` only — + * a composition change goes through renderSettings instead. + */ +export function patchRow(rendered: RenderedRow, row: SettingsRow, t: Translator): void { + rendered.row = row; + if (row.kind !== 'update-status') { + patchCoreRow(rendered, row, t); + return; + } + if (rendered.valueEl !== null) rendered.valueEl.textContent = updateStatusText(row.status, t); + const action = updateAction(row.status, t); + if (rendered.buttonEl !== null) { + rendered.buttonEl.textContent = action?.label ?? ''; + rendered.buttonEl.classList.toggle('is-hidden', action === null); + } + const fill = rendered.el.querySelector<HTMLElement>('.setting-progress-fill'); + if (fill !== null && rendered.progressEl !== null) { + applyProgress(fill, rendered.progressEl, row.status); + } +} + +/** Re-applies the SECTION titles for a new translator (the rows carry their own labels). */ +export function relocalizeSections( + container: HTMLElement, + model: SettingsModel, + t: Translator, +): void { + const sections = [...container.querySelectorAll<HTMLElement>('.settings-section')]; + model.sections.forEach((section, index) => { + const title = sections[index]?.querySelector<HTMLElement>('.settings-section-title'); + if (title === null || title === undefined || section.titleKey === undefined) return; + title.textContent = t(section.titleKey); + }); +} + +/** Re-applies the row's LABELS for a new translator (values are patched by patchRow). */ +export function relocalizeRow(rendered: RenderedRow, t: Translator): void { + const row = rendered.row; + if (row.kind === 'update-status') { + patchRow(rendered, row, t); + return; + } + relocalizeCoreRow(rendered, row, t); +} diff --git a/src/renderer/settings-screen.ts b/src/renderer/settings-screen.ts new file mode 100644 index 00000000..824b8b94 --- /dev/null +++ b/src/renderer/settings-screen.ts @@ -0,0 +1,1193 @@ +// The Settings screen's controller: the fourth surface of the launcher (see the plan §3). It owns its +// own state (the last AppSettings snapshot, the update status, the environment), the row focus, the +// expanded dropdown and the slider drag — and exposes the SAME six navigation primitives the rest of the +// UI uses, so controls.ts only has to route to it. Everything that decides WHAT is on screen lives in +// settings-form-model.ts (pure, unit-tested); the DOM building and patching in settings-form-view.ts. +// +// Two rules earn their own note, because both are easy to lose: +// • a settings:update arriving mid-drag must NOT move the knob under the cursor — the dragged field +// ignores incoming values until the pointer is released; +// • a new snapshot PATCHES the rendered rows; only a change in the row composition (steamAvailable +// arriving) rebuilds them, and the rebuild keeps the focused index. +import type { + AppSettings, + AudioOptions, + AutoUpdateMode, + LanguageMode, + UpdateStatus, +} from '../shared/types'; +import type { MessageKey, Translator } from '../shared/i18n/index.js'; +import { type AudioController } from './audio.js'; +import { 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 } from './screen-sidebar.js'; +import { + buildSettingsModel, + volumePercent, + type SelectId, + type SettingsModel, + type SettingsOption, + type SettingsRow, + type TextId, + type ToggleId, +} from './settings-form-model.js'; +import { rowLabelText } from './row-view-core.js'; +import type { TextEntrySurface } from './game-settings-screen.js'; +import { + optionLabel, + optionLabelNode, + patchRow, + relocalizeRow, + relocalizeSections, + renderSettings, + updateAction, + type RenderedRow, +} from './settings-form-view.js'; + +/** Gamepad A doesn't trigger :active — the same press flash the rest of the UI uses (controls.ts). */ +const PRESS_MS = 130; +/** One keyboard/gamepad step of a volume slider, in percent. */ +const VOLUME_STEP = 5; +/** While dragging, main is written at most this often; the release always writes the final value. */ +const DRAG_PERSIST_MS = 150; +/** The SFX preview plays at most this often while a volume is being dragged. */ +const PREVIEW_THROTTLE_MS = 220; +/** Marquee speed for a clipped option label, in DESIGN px per second (the 0.6 picker's own constant). */ +const MARQUEE_SPEED_PX_PER_S = 60; + +/** What the screen sends to main. A seam, so app.ts owns the window.api wiring (and tests can fake it). */ +export interface SettingsScreenApi { + setAutoUpdate(mode: AutoUpdateMode): void; + setPrerelease(on: boolean): void; + setSummonHotkey(on: boolean): void; + setPreventScreensaver(on: boolean): void; + setKeepOpenWithoutCard(on: boolean): void; + setDisableSilentInstall(on: boolean): void; + setSteamAutoLaunch(on: boolean): void; + setSoundSet(set: string): void; + setAmbientTrack(track: string | null): void; + setOnlyGlobalAmbient(on: boolean): void; + setMusicVolume(volume: number): void; + setSfxVolume(volume: number): void; + setLanguage(mode: LanguageMode): void; + /** Store the user's SteamGridDB key ('' clears it). */ + setSteamGridDbKey(key: string): void; + /** Fire-and-forget: the screen re-renders from the settings:update push, not from the invoke result. */ + resetSettings(): void; + checkForUpdates(): void; + downloadUpdate(): void; + installUpdate(): void; +} + +export interface SettingsScreenDeps { + readonly audio: AudioController; + getTranslator(): Translator; + readonly api: SettingsScreenApi; + /** + * The on-screen keyboard — this screen has one text field (the SteamGridDB key), and on a gamepad it + * is the only way to fill it. Shared with the Customize screen: at most one surface is open at a time. + */ + readonly keyboard: TextEntrySurface; + /** The screen closed itself (B / Esc / veil click) — controls.ts restores the bar focus. */ + onClosed(): void; + /** "Reset settings" was activated — controls.ts asks the shared confirm popup. */ + onResetRequested(): void; +} + +/** What controls.ts routes into. Mirrors the six primitives, plus open/close and the data pushes. */ +export interface SettingsScreen { + isOpen(): boolean; + /** + * `sectionKey` deep-links to one section (an "update ready" notification lands on Updates). + * + * `silent` is for an entrance that has ALREADY sounded: the carousel's Settings card plays `button` as + * it is activated, exactly like entering a game, and the screen's own opening sound would be a second + * copy of the same one. Reached any other way (that same notification) the screen still speaks for + * itself — the popup it came out of goes silently there. + */ + open(sectionKey?: MessageKey, options?: { readonly silent?: boolean }): void; + close(): void; + navUp(): void; + navDown(): void; + /** `repeat` marks a hold auto-repeat: a held left must not walk out of the expanded list and beyond. */ + navLeft(repeat?: boolean): void; + navRight(): void; + navActivate(): void; + navBack(): void; + /** X / Y / LB-RB / RT — claimed only while the keyboard is open on top of this screen. */ + navSecondary(repeat?: boolean): void; + navTertiary(): void; + navShoulder(direction: -1 | 1): void; + navCommit(): void; + /** A new AppSettings snapshot (the single source of truth for every value on screen). */ + applySettings(settings: AppSettings): void; + applyUpdateStatus(status: UpdateStatus): void; + /** The environment seeds that arrive once at startup (Steam availability, audio options, version). */ + applyEnv(env: { + readonly steamAvailable?: boolean; + readonly audioOptions?: AudioOptions; + readonly appVersion?: string; + }): void; + /** Re-renders every label for the current translator, keeping the focus and the scroll position. */ + relocalize(): void; + /** Runs the reset (the confirm popup said yes). */ + resetSettings(): void; +} + +/** The AppSettings field a toggle writes, and the api call that persists it. */ +type ToggleWriter = (api: SettingsScreenApi, value: boolean) => void; + +const TOGGLE_WRITERS: Readonly<Record<ToggleId, ToggleWriter>> = { + prerelease: (api, value) => api.setPrerelease(value), + summonHotkey: (api, value) => api.setSummonHotkey(value), + preventScreensaver: (api, value) => api.setPreventScreensaver(value), + keepOpenWithoutCard: (api, value) => api.setKeepOpenWithoutCard(value), + disableSilentInstall: (api, value) => api.setDisableSilentInstall(value), + steamAutoLaunch: (api, value) => api.setSteamAutoLaunch(value), + onlyGlobalAmbient: (api, value) => api.setOnlyGlobalAmbient(value), +}; + +/** Applies a toggle's new value to a settings snapshot, so the screen repaints without a round trip. */ +function withToggle(settings: AppSettings, id: ToggleId, value: boolean): AppSettings { + switch (id) { + case 'prerelease': + return { ...settings, allowPrerelease: value }; + case 'summonHotkey': + return { ...settings, summonHotkeyEnabled: value }; + case 'preventScreensaver': + return { ...settings, preventScreensaver: value }; + case 'keepOpenWithoutCard': + return { ...settings, keepOpenWithoutCard: value }; + case 'disableSilentInstall': + return { ...settings, disableSilentInstall: value }; + case 'steamAutoLaunch': + return { ...settings, steamAutoLaunch: value }; + case 'onlyGlobalAmbient': + return { ...settings, onlyGlobalAmbient: value }; + } +} + +function withSelect(settings: AppSettings, id: SelectId, value: string): AppSettings { + switch (id) { + case 'autoUpdate': + return { ...settings, autoUpdate: value as AutoUpdateMode }; + case 'language': + return { ...settings, language: value as LanguageMode }; + case 'soundSet': + return { ...settings, soundSet: value }; + case 'ambientTrack': + return { ...settings, ambientTrack: value === '' ? null : value }; + } +} + +/** 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 SettingsRow[]; +} + +function clampPercent(percent: number): number { + return Math.min(100, Math.max(0, Math.round(percent))); +} + +export function createSettingsScreen(deps: SettingsScreenDeps): SettingsScreen { + const app = req('app'); + const screen = req('settings'); + const veil = screen.querySelector<HTMLElement>('.settings-veil'); + const listEl = req('settings-list'); + const navEl = req('settings-nav'); + const versionEl = req('settings-version'); + const optionsEl = req('settings-options'); + const optionsListEl = req('settings-options-list'); + const optionsVeil = optionsEl.querySelector<HTMLElement>('.settings-options-veil'); + + const t = (): Translator => deps.getTranslator(); + + let open = false; + // null until the first settings:request answers — the screen shows the loading line meanwhile. + let settings: AppSettings | null = null; + let updateStatus: UpdateStatus = { kind: 'idle' }; + let steamAvailable = false; + let audioOptions: AudioOptions = { soundSets: [], ambientTracks: [] }; + let appVersion = ''; + + let model: SettingsModel | null = null; + /** The rows of the SELECTED section only — the pane shows one section at a time (screen-sidebar.ts). */ + let rendered: readonly RenderedRow[] = []; + let focusIndex = 0; + /** Which titled section the column has SELECTED, 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; + + // The expanded dropdown: which row it belongs to, its option buttons and the focused option. + let openSelect: { + readonly rowIndex: number; + readonly buttons: readonly HTMLButtonElement[]; + } | null = null; + let optionIndex = 0; + + // Slider drag: the field being dragged ignores incoming pushes until the pointer is released. + let dragging: { + readonly rowIndex: number; + readonly track: HTMLElement; + readonly pointerId: number; + } | null = null; + let lastPersistAt = 0; + let lastPreviewAt = 0; + + function focusedRow(): RenderedRow | undefined { + return rendered[focusIndex]; + } + + function pressFlash(el: HTMLElement): void { + el.classList.add('is-pressed'); + window.setTimeout(() => el.classList.remove('is-pressed'), PRESS_MS); + } + + // Both scrolling surfaces of this screen use the shared scroller (screen-scroller.ts) — the settings + // list and the expanded dropdown — so they behave identically, and so do the other screens. + const listScroller = createScroller(listEl); + + /** + * The section column. Selecting a section shows it in the pane; ACTIVATING one moves the focus there, + * which is the only way in — so B is always "back to the column", and the way out of the screen is + * from the column alone. + */ + const sidebar = createSidebar(navEl, { + audio: deps.audio, + onSection: (id, entered) => { + sectionKey = id as MessageKey; + if (entered) { + enterPane(); + return; + } + schedulePreview(); + }, + onAction: (id) => { + if (id === 'reset') { + deps.audio.play('button'); + deps.onResetRequested(); + return; + } + // Closing is a LEAVING gesture, and close() plays `back` for it — one gesture, one sound. A + // `button` here made the column's Close the only button in the app that sounded twice. + navBack(); + }, + }); + const optionsScroller = createScroller(optionsListEl); + + /** + * Paints the focus and keeps it on screen, with a margin: the list starts moving BEFORE the focused + * row reaches the edge, so there is always a row of context ahead of it and the movement is continuous + * rather than a jump per step at the boundary. + */ + 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 = focusedRow(); + if (target === undefined) return; + listScroller.reveal(target.el, instant); + } + + /** The loading line, shown until the first snapshot lands (the settings window did the same). */ + function renderLoading(): void { + listEl.replaceChildren(); + const loading = document.createElement('div'); + loading.className = 'settings-section-title'; + loading.textContent = t()('settings.loading'); + listEl.append(loading); + rendered = []; + } + + function currentModel(): SettingsModel | null { + if (settings === null) return null; + return buildSettingsModel(settings, { + steamAvailable, + audioOptions, + appVersion, + updateStatus, + }); + } + + /** Whether two models describe the same rows in the same order (a patch is enough when they do). */ + function sameComposition(a: SettingsModel, b: SettingsModel): boolean { + const ids = (m: SettingsModel): string => + m.sections + .flatMap((section) => + section.rows.map((row) => (row.kind === 'update-status' ? 'status' : row.id)), + ) + .join('|'); + return ids(a) === ids(b); + } + + /** How long the staggered row entrance runs — the marks come off once it is over. */ + const ENTRANCE_MS = 700; + /** The stagger stops counting here: past a handful of rows the wave is a wait, not a wave. */ + const ENTRANCE_STEPS = 8; + /** 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 at every step — which is what made the whole screen flicker under a hold. + * Short enough that a single press still reads as instant. + */ + 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(); + } + + /** The titled sections — the ones the column offers. The title-less one is the action stack. */ + function titledSections(from: SettingsModel): readonly TitledSection[] { + return from.sections.flatMap((section) => { + const key = section.titleKey; + return key === undefined ? [] : [{ titleKey: key, rows: section.rows }]; + }); + } + + /** The section the pane is showing, falling back to the first one. */ + function currentSection(from: SettingsModel): TitledSection | undefined { + const titled = titledSections(from); + return titled.find((section) => section.titleKey === sectionKey) ?? titled[0]; + } + + /** Rebuilds or patches the screen for the current state, keeping the focus index in range. */ + 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(); + const next = currentModel(); + versionEl.textContent = appVersion; + if (next === null) { + model = null; + renderLoading(); + return; + } + const previous = model; + model = next; + renderColumn(next); + if (previous !== null && sameComposition(previous, next) && rendered.length > 0) { + const rows = visibleRows(next); + rendered.forEach((row, index) => { + const nextRow = rows[index]; + // A field being dragged owns its value until the pointer is released — see the module note. + if (nextRow === undefined || (dragging !== null && dragging.rowIndex === index)) return; + patchRow(row, nextRow, t()); + }); + return; + } + renderPane(); + } + + /** + * The column: one entry per titled section, then the screen's actions. The actions come from the + * title-less section the model already ends with — the same one that used to sit at the bottom of the + * scroll, which is exactly what made them hard to reach. + */ + function renderColumn(from: SettingsModel): void { + sidebar.render([ + ...titledSections(from).map((section) => ({ + id: section.titleKey, + label: t()(section.titleKey), + kind: 'section' as const, + })), + ...from.sections + .filter((section) => section.titleKey === undefined) + .flatMap((section) => section.rows) + .flatMap((row) => + row.kind === 'action' + ? [{ id: row.id, label: rowLabelText(row.label, t()), kind: 'action' as const }] + : [], + ), + ]); + } + + /** The rows the pane currently shows — one section's worth. */ + function visibleRows(from: SettingsModel): readonly SettingsRow[] { + return currentSection(from)?.rows ?? []; + } + + /** Draws the selected section into the pane. The column is rebuilt separately (its entries change far + * less often than the values inside a section do). */ + function renderPane(): void { + const from = model; + if (from === null) return; + const section = currentSection(from); + if (section === undefined) return; + sectionKey = section.titleKey; + paneKey = 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 = renderSettings(listEl, { ...from, sections: [{ rows: section.rows }] }, t()).rows; + rendered.forEach((row, at) => + row.el.style.setProperty('--row-index', String(Math.min(at, ENTRANCE_STEPS))), + ); + entrance.play(); + focusIndex = Math.min(Math.max(focusIndex, 0), Math.max(0, rendered.length - 1)); + applyRowFocus(true); + listScroller.to(0, true); + // The rows were inserted THIS tick, so scrollHeight is still the pre-layout value — the fades would + // be computed against a list that "doesn't scroll yet". Re-run them once the layout has settled. + requestAnimationFrame(() => listScroller.fades()); + } + + /** + * Opens the screen ON a given section instead of the first one — the route an "update ready" + * notification takes to the Updates section. + * + * It runs AFTER the column has been built (render → renderColumn): `sidebar.select` on an empty column + * silently does nothing, and that failure would have been invisible for this very deep link, since + * Updates happens to be the first section anyway and the fallback lands on it by accident. + */ + function selectSection(key: MessageKey): void { + if (!sidebar.select(key)) { + console.warn(`[settings] no "${key}" section to open on — falling back to the first one`); + return; + } + sectionKey = key; + renderPane(); + } + + /** Hands the focus from the column to the pane, at its first 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 = 0; + armHover(); + applyRowFocus(); + } + + /** …and back. The column is the only place the screen can be left from. */ + function leavePane(): void { + closeOptions(); + sidebar.setFocused(true); + armHover(); + applyRowFocus(); + } + + // ── Value changes ────────────────────────────────────────────────────────── + + /** Applies a locally-known new settings state and repaints, ahead of main's echo. */ + function applyLocal(next: AppSettings): void { + settings = next; + render(); + } + + function toggleRow(index: number, row: Extract<SettingsRow, { kind: 'toggle' }>): void { + if (settings === null) return; + const value = !row.value; + TOGGLE_WRITERS[row.id](deps.api, value); + deps.audio.play('button'); + applyLocal(withToggle(settings, row.id, value)); + void index; + } + + /** + * Opens the keyboard on the REAL key rather than on the masked value the row shows — editing a field + * whose content is dots would mean retyping the whole key to change one character. + */ + function openKeyboardFor(row: Extract<SettingsRow, { kind: 'text' }>): void { + if (settings === null) return; + deps.keyboard.open({ + value: currentText(settings, row.id), + mode: 'text', + title: rowLabelText(row.label, t()), + onDone: (value) => persistText(row.id, value), + }); + } + + function currentText(snapshot: AppSettings, id: TextId): string { + switch (id) { + case 'steamGridDbKey': + return snapshot.steamGridDbApiKey; + } + } + + function persistText(id: TextId, value: string): void { + if (settings === null) return; + const trimmed = value.trim(); + switch (id) { + case 'steamGridDbKey': + deps.api.setSteamGridDbKey(trimmed); + applyLocal({ ...settings, steamGridDbApiKey: trimmed }); + break; + } + } + + function persistSelect(id: SelectId, value: string): void { + switch (id) { + case 'autoUpdate': + deps.api.setAutoUpdate(value as AutoUpdateMode); + break; + case 'language': + deps.api.setLanguage(value as LanguageMode); + break; + case 'soundSet': + deps.api.setSoundSet(value); + break; + case 'ambientTrack': + deps.api.setAmbientTrack(value === '' ? null : value); + break; + } + } + + /** Moves a dropdown to another value, animating the text in the direction of the press. */ + function setSelectValue( + rowIndex: number, + row: Extract<SettingsRow, { kind: 'select' }>, + value: string, + direction: 'prev' | 'next' | null, + ): void { + if (settings === null || value === row.value) return; + const valueEl = rendered[rowIndex]?.valueEl; + if (valueEl !== null && valueEl !== undefined && direction !== null) { + valueEl.classList.add(direction === 'prev' ? 'is-shift-prev' : 'is-shift-next'); + window.setTimeout(() => valueEl.classList.remove('is-shift-prev', 'is-shift-next'), 120); + } + persistSelect(row.id, value); + deps.audio.play('navigate'); + applyLocal(withSelect(settings, row.id, value)); + } + + /** Cycles a dropdown by one step, wrapping — the fast gamepad path that never expands the list. */ + function cycleSelect( + rowIndex: number, + row: Extract<SettingsRow, { kind: 'select' }>, + delta: number, + ): void { + if (row.options.length === 0) return; + const current = row.options.findIndex((option) => option.value === row.value); + const base = current === -1 ? 0 : current; + const next = (base + delta + row.options.length) % row.options.length; + const option = row.options[next]; + if (option === undefined) return; + setSelectValue(rowIndex, row, option.value, delta > 0 ? 'next' : 'prev'); + } + + /** Applies a volume LOCALLY first (the preview must sound at the new level), then persists it. */ + function applyVolume( + row: Extract<SettingsRow, { kind: 'slider' }>, + percent: number, + throttle: boolean, + ): void { + if (settings === null) return; + const clamped = clampPercent(percent); + const volume = clamped / 100; + if (row.id === 'sfxVolume') deps.audio.setSfxVolume(volume); + else deps.audio.setMusicVolume(volume); + const next: AppSettings = + row.id === 'sfxVolume' + ? { ...settings, sfxVolume: volume } + : { ...settings, musicVolume: volume }; + settings = next; + const rowsNext = currentModel(); + const rendered_ = rendered[indexOfRow(row.id)]; + if (rowsNext !== null && rendered_ !== undefined) { + const nextRow = visibleRows(rowsNext)[indexOfRow(row.id)]; + if (nextRow !== undefined) patchRow(rendered_, nextRow, t()); + model = rowsNext; + } + const now = performance.now(); + if (!throttle || now - lastPersistAt >= DRAG_PERSIST_MS) { + lastPersistAt = now; + if (row.id === 'sfxVolume') deps.api.setSfxVolume(volume); + else deps.api.setMusicVolume(volume); + } + // Only the SFX slider previews itself: the music volume is already audible on the running track. + if (row.id === 'sfxVolume' && now - lastPreviewAt >= PREVIEW_THROTTLE_MS) { + lastPreviewAt = now; + deps.audio.play('navigate'); + } + } + + /** The rendered index of a slider row (both ids are unique across the screen). */ + function indexOfRow(id: string): number { + return rendered.findIndex((row) => row.row.kind !== 'update-status' && row.row.id === id); + } + + /** Writes the final value of a drag / a key step, bypassing the throttle. */ + function persistVolume(row: Extract<SettingsRow, { kind: 'slider' }>): void { + if (settings === null) return; + const volume = row.id === 'sfxVolume' ? settings.sfxVolume : settings.musicVolume; + if (row.id === 'sfxVolume') deps.api.setSfxVolume(volume); + else deps.api.setMusicVolume(volume); + } + + function stepSlider(row: Extract<SettingsRow, { kind: 'slider' }>, delta: number): void { + const current = + settings === null + ? row.percent + : volumePercent(row.id === 'sfxVolume' ? settings.sfxVolume : settings.musicVolume); + const next = clampPercent(current + delta * VOLUME_STEP); + if (next === current) { + deps.audio.playLimit(); // already at 0 % / 100 % + return; + } + applyVolume(row, next, true); + } + + // ── Expanded dropdown ────────────────────────────────────────────────────── + + function closeOptions(options?: { readonly silent?: boolean }): void { + if (openSelect === null) return; + // `silent` for the cascade out of close(): the screen going away is one popup-close, not two (Р5). + if (options?.silent !== true) deps.audio.play('popup-close'); + openSelect = null; + screen.classList.remove('is-options-open'); + optionsEl.classList.remove('is-open'); + optionsEl.setAttribute('aria-hidden', 'true'); + optionsListEl.replaceChildren(); + } + + function applyOptionFocus(instant = false): void { + openSelect?.buttons.forEach((button, index) => + button.classList.toggle('is-focused', index === optionIndex), + ); + const focused = openSelect?.buttons[optionIndex]; + if (focused !== undefined) optionsScroller.reveal(focused, instant); + updateOptionMarquee(); // the marquee follows the focus — only the focused label moves + } + + function chooseOption(rowIndex: number, option: SettingsOption): void { + const row = rendered[rowIndex]?.row; + if (row === undefined || row.kind !== 'select') return; + closeOptions(); + setSelectValue(rowIndex, row, option.value, null); + } + + function openOptions(rowIndex: number, row: Extract<SettingsRow, { kind: 'select' }>): void { + const buttons = row.options.map((option) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'settings-option'; + button.append(optionLabelNode(optionLabel(option, t()))); + button.classList.toggle('is-current', option.value === row.value); + button.addEventListener('click', () => { + pressFlash(button); + chooseOption(rowIndex, option); + }); + return button; + }); + deps.audio.play('popup-open'); + optionsListEl.replaceChildren(...buttons); + screen.classList.add('is-options-open'); // switches the frost on (no fade — see styles.css) + optionsEl.classList.add('is-open'); + // Measured synchronously: reading clientWidth flushes the layout for the nodes just inserted, which + // a requestAnimationFrame callback would only get around to on the next frame — and never at all in + // a window that isn't painting. A label that doesn't fit gets the distance it must travel to show + // its start, and the marquee (CSS, focused option only) runs off that. + updateOptionMarquee(); + optionsEl.setAttribute('aria-hidden', 'false'); + const current = row.options.findIndex((option) => option.value === row.value); + optionIndex = current === -1 ? 0 : current; + openSelect = { rowIndex, buttons }; + applyOptionFocus(); + } + + /** + * Marks every option whose label doesn't fit as clipped (→ a soft fade at the cut) and starts the + * marquee on the FOCUSED one (→ both edges fade + it scrolls). Lifted from the 0.6 "Select game" + * picker's updateSelectGameMarquee: same measurement, same constant speed, so a long label reads at + * one pace whatever its length. An overflowing label is laid out from its start (flex alignment gives + * way to overflow), so it slides LEFT to reveal its end — hence the negative shift. + */ + function updateOptionMarquee(): void { + if (openSelect === null) return; + // A window that hasn't laid out yet (or isn't painting) reports zero widths — measuring against that + // would mark every label as fitting. Try again on the next frame instead of guessing. + const first = openSelect.buttons[0]?.querySelector<HTMLElement>('.settings-option-clip'); + if (first !== null && first !== undefined && first.clientWidth === 0) { + requestAnimationFrame(() => updateOptionMarquee()); + return; + } + for (const button of openSelect.buttons) { + const clip = button.querySelector<HTMLElement>('.settings-option-clip'); + const text = button.querySelector<HTMLElement>('.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'); + } + } + } + + // ── The six primitives ───────────────────────────────────────────────────── + + function moveRowFocus(delta: number): void { + if (rendered.length === 0) return; + const next = clampIndex(focusIndex, delta, rendered.length); + if (next === focusIndex) { + deps.audio.playLimit(); // the end of the list — a held direction still sounds only once + return; + } + focusIndex = next; + deps.audio.play('navigate'); + applyRowFocus(); + } + + function moveOptionFocus(delta: number): void { + if (openSelect === null || openSelect.buttons.length === 0) return; + const next = wrapIndex(optionIndex, delta, openSelect.buttons.length); + if (next === optionIndex) return; + optionIndex = next; + deps.audio.play('navigate'); + applyOptionFocus(); + } + + /** + * The keyboard opens ON TOP of this screen, so while it is up every primitive belongs to it — the same + * stack rule the Customize screen follows for its own sub-surfaces. + */ + function keyboardSurface(): TextEntrySurface | null { + return deps.keyboard.isOpen() ? deps.keyboard : null; + } + + function navUp(): void { + const keyboard = keyboardSurface(); + if (keyboard !== null) return keyboard.navUp(); + armHover(); // last input wins — see the mousemove handler + if (openSelect !== null) moveOptionFocus(-1); + else if (sidebar.hasFocus()) sidebar.move(-1); + else moveRowFocus(-1); + } + + function navDown(): void { + const keyboard = keyboardSurface(); + if (keyboard !== null) return keyboard.navDown(); + armHover(); + if (openSelect !== null) moveOptionFocus(1); + else if (sidebar.hasFocus()) sidebar.move(1); + else moveRowFocus(1); + } + + function navHorizontal(delta: number): void { + armHover(); + if (openSelect !== null) return; // handled by navLeft — the expanded list is otherwise vertical + // From the column, RIGHT steps into the pane — the direction the layout already suggests. Left is + // NOT its mirror inside the pane: there it belongs to the sliders and the dropdowns, so leaving is B. + if (sidebar.hasFocus()) { + // Left off the column, and right off anything that is not a section (the actions at its foot), + // lead nowhere — the column is the edge of the screen in both directions. + if (delta > 0 && sidebar.selected()?.kind === 'section') enterPane(); + else deps.audio.playLimit(); + return; + } + const target = focusedRow(); + 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 sliders, the dropdowns), 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(focusIndex, row, delta); + return; + } + if (row.kind === 'slider') { + stepSlider(row, delta); + return; + } + deps.audio.playLimit(); // a checkbox (and the static rows) has no range to step along — A flips it + } + + function navLeft(repeat = false): void { + const keyboard = keyboardSurface(); + if (keyboard !== null) return keyboard.navLeft(repeat); + armHover(); + // Left leaves the expanded list, the same way it leaves a popup (controls.ts): its column sits on the + // right edge, so moving left off it means "out". A HELD left is ignored, or the same press would + // close the list and then start cycling the row's value behind it. + if (openSelect !== null) { + if (!repeat) closeOptions(); + return; + } + navHorizontal(-1); + } + + function navRight(): void { + const keyboard = keyboardSurface(); + if (keyboard !== null) return keyboard.navRight(); + navHorizontal(1); + } + + function activateRow(target: RenderedRow, index: number): void { + const row = target.row; + switch (row.kind) { + case 'toggle': + pressFlash(target.el); + toggleRow(index, row); + break; + case 'select': + // Two sounds, deliberately: `button` is the row being pressed, `popup-open` (openOptions) is the + // list appearing — the same pair a launcher card plays when it opens its surface. + deps.audio.play('button'); + pressFlash(target.el); + openOptions(index, row); + break; + case 'slider': + deps.audio.playLimit(); // a slider is moved with left/right, and A has nothing to press on it + break; + case 'text': + deps.audio.play('button'); + pressFlash(target.el); + openKeyboardFor(row); + break; + case 'action': + if (row.id === 'close') { + navBack(); + break; + } + deps.audio.play('button'); + pressFlash(target.el); + deps.onResetRequested(); + break; + case 'update-status': { + const action = updateAction(row.status, t()); + if (action === null || action.kind === null) return; + deps.audio.play('button'); + pressFlash(target.el); + if (action.kind === 'check') deps.api.checkForUpdates(); + else if (action.kind === 'download') deps.api.downloadUpdate(); + else deps.api.installUpdate(); + break; + } + } + } + + function navActivate(): void { + const keyboard = keyboardSurface(); + if (keyboard !== null) return keyboard.navActivate(); + armHover(); + if (openSelect === null && sidebar.hasFocus()) { + sidebar.activate(); + return; + } + if (openSelect !== null) { + const row = rendered[openSelect.rowIndex]?.row; + if (row === undefined || row.kind !== 'select') return; + const option = row.options[optionIndex]; + if (option === undefined) return; + chooseOption(openSelect.rowIndex, option); + return; + } + const target = focusedRow(); + if (target === undefined) return; + activateRow(target, focusIndex); + } + + function close(): void { + if (!open) return; + open = false; + deps.audio.play('back'); + closeOptions({ silent: true }); // leaving the screen takes the dropdown with it — one sound, not two + deps.keyboard.close(); // …and the keyboard, which lives outside every screen (see #osk in index.html) + entrance.cancel(); + if (previewTimer !== 0) { + window.clearTimeout(previewTimer); + previewTimer = 0; + } + delete app.dataset['overlay']; + screen.setAttribute('aria-hidden', 'true'); + deps.onClosed(); + } + + function navBack(): void { + const keyboard = keyboardSurface(); + if (keyboard !== null) return keyboard.navBack(); + armHover(); + if (openSelect !== null) { + closeOptions(); + return; + } + // Out of the pane, back to the column; out of the column, off the screen. The screen can only be + // left from the column, which is also where Reset and Close live — so leaving is never a surprise. + // Only the step INSIDE the screen keeps `back`; leaving it is a popup closing, and close() says so. + if (!sidebar.hasFocus()) { + deps.audio.play('back'); + leavePane(); + return; + } + close(); + } + + // ── Mouse ────────────────────────────────────────────────────────────────── + + /** A click inside a row: the chevrons, the row's own button and the slider track act on their own. */ + listEl.addEventListener('click', (event) => { + const target = event.target; + if (!(target instanceof Element)) return; + const rowEl = target.closest<HTMLElement>('.setting-row'); + if (rowEl === null) return; + const index = rendered.findIndex((row) => row.el === rowEl); + if (index === -1) return; + const entry = rendered[index]; + if (entry === undefined) return; + sidebar.setFocused(false); + focusIndex = index; + applyRowFocus(); + const chevronEl = target.closest<HTMLElement>('.setting-chevron'); + if (chevronEl !== null && entry.row.kind === 'select') { + cycleSelect(index, entry.row, chevronEl.dataset['chevron'] === 'prev' ? -1 : 1); + return; + } + // The track handles its own pointer events (jump + drag) — don't double-act on the click. + if (target.closest('.setting-track') !== null) return; + activateRow(entry, index); + }); + + /** The percent a pointer at `clientX` picks on `track`. */ + function percentAt(track: HTMLElement, clientX: number): number { + const rect = track.getBoundingClientRect(); + if (rect.width === 0) return 0; + return clampPercent(((clientX - rect.left) / rect.width) * 100); + } + + listEl.addEventListener('pointerdown', (event) => { + const target = event.target; + if (!(target instanceof Element)) return; + const track = target.closest<HTMLElement>('.setting-track'); + if (track === null) return; + const rowEl = track.closest<HTMLElement>('.setting-row'); + if (rowEl === null) return; + const index = rendered.findIndex((row) => row.el === rowEl); + const entry = rendered[index]; + if (entry === undefined || entry.row.kind !== 'slider') return; + focusIndex = index; + applyRowFocus(); + // No transition while the knob follows the cursor — see the plan §3.6. + track.closest('.setting-slider')?.classList.add('is-dragging'); + dragging = { rowIndex: index, track, pointerId: event.pointerId }; + track.setPointerCapture(event.pointerId); + applyVolume(entry.row, percentAt(track, event.clientX), false); + }); + + listEl.addEventListener('pointermove', (event) => { + if (dragging === null || event.pointerId !== dragging.pointerId) return; + const entry = rendered[dragging.rowIndex]; + if (entry === undefined || entry.row.kind !== 'slider') return; + applyVolume(entry.row, percentAt(dragging.track, event.clientX), true); + }); + + function endDrag(): void { + if (dragging === null) return; + const entry = rendered[dragging.rowIndex]; + dragging.track.closest('.setting-slider')?.classList.remove('is-dragging'); + const held = dragging; + dragging = null; + if (held.track.hasPointerCapture(held.pointerId)) + held.track.releasePointerCapture(held.pointerId); + if (entry !== undefined && entry.row.kind === 'slider') persistVolume(entry.row); + render(); // any push held back during the drag lands now + } + + listEl.addEventListener('pointerup', endDrag); + listEl.addEventListener('pointercancel', endDrag); + + veil?.addEventListener('click', () => { + close(); + }); + + /** + * Hover, for both the row list and the expanded dropdown. WHEN it is allowed to move the focus is the + * shared hover guard's job (hover-guard.ts) — it keeps tracking the pointer while the screen is closed, + * so opening can arm it at wherever the cursor happens to rest. The gamepad's cursor-hide is a separate + * reason to ignore hover, and it is checked too: a hidden cursor must never fight the focus it is not + * driving. + */ + const hover = createHoverGuard(); + let pointerX = -1; + let pointerY = -1; + + /** Called whenever a surface opens: hover sleeps until the pointer leaves this spot. */ + function armHover(): void { + hover.arm(); + } + + window.addEventListener( + 'mousemove', + (event) => { + const moved = event.clientX !== pointerX || event.clientY !== pointerY; + pointerX = event.clientX; + pointerY = event.clientY; + hover.track(event.clientX, event.clientY); + if (!moved || !open) return; + if (document.documentElement.classList.contains('mouse-asleep')) return; + if (!hover.awake(event.clientX, event.clientY)) return; + const target = event.target; + if (!(target instanceof Element)) return; + if (openSelect !== null) { + const button = target.closest<HTMLButtonElement>('.settings-option'); + if (button === null) return; + const index = openSelect.buttons.indexOf(button); + if (index === -1 || index === optionIndex) return; + optionIndex = index; + applyOptionFocus(); + return; + } + const rowEl = target.closest<HTMLElement>('.setting-row'); + if (rowEl === null) return; + const index = rendered.findIndex((row) => row.el === rowEl); + if (index === -1 || (index === focusIndex && !sidebar.hasFocus())) return; + sidebar.setFocused(false); + focusIndex = index; + applyRowFocus(); + }, + { passive: true }, + ); + + optionsVeil?.addEventListener('click', () => { + closeOptions(); + }); + + return { + isOpen: () => open, + open: (section?: MessageKey, options?: { readonly silent?: boolean }) => { + if (open) return; + open = true; + if (options?.silent !== true) deps.audio.play('button'); + focusIndex = 0; + app.dataset['overlay'] = 'settings'; + screen.setAttribute('aria-hidden', 'false'); + sidebar.reset(); // a re-opened screen starts at the first section, column and pane together + sectionKey = null; + paneKey = null; + // …and the pane is REBUILT rather than patched: the rows still in it belong to whichever section + // the last visit ended on, and patching those with section one's values crosses the two. + rendered = []; + sidebar.setFocused(true); // the screen opens on its table of contents, not inside a section + sidebar.animateIn(); + armHover(); // same as the dropdown: the screen appears under wherever the mouse happens to rest + // Instant, not animated: a re-open must START at the top rather than glide there from wherever + // the previous visit left the list (which showed as a half-cropped first row). + listScroller.to(0, true); + render(); + if (section !== undefined) selectSection(section); + applyRowFocus(true); + }, + close, + navUp, + navDown, + navLeft, + navRight, + navActivate, + navBack, + applySettings: (next: AppSettings) => { + settings = next; + render(); + }, + applyUpdateStatus: (status: UpdateStatus) => { + updateStatus = status; + render(); + }, + applyEnv: (env) => { + if (env.steamAvailable !== undefined) steamAvailable = env.steamAvailable; + if (env.audioOptions !== undefined) audioOptions = env.audioOptions; + if (env.appVersion !== undefined) appVersion = env.appVersion; + render(); + }, + relocalize: () => { + versionEl.textContent = appVersion; + if (settings === null) { + renderLoading(); + return; + } + if (model !== null) { + const section = currentSection(model); + if (section !== undefined) + relocalizeSections(listEl, { ...model, sections: [section] }, t()); + // The column IS labels, so it is rebuilt rather than patched — it keeps its selection by id. + renderColumn(model); + } + for (const row of rendered) relocalizeRow(row, t()); + // The expanded list, if any, carries labels too. + if (openSelect !== null) { + const row = rendered[openSelect.rowIndex]?.row; + if (row !== undefined && row.kind === 'select') { + openSelect.buttons.forEach((button, index) => { + const option = row.options[index]; + const text = button.querySelector<HTMLElement>('.settings-option-text'); + if (option !== undefined && text !== null) text.textContent = optionLabel(option, t()); + }); + updateOptionMarquee(); + } + } + }, + // X / Y / the shoulders / RT belong to whatever surface is on top — here that is only ever the + // keyboard (Backspace, Shift, its layout switch and "commit"). With nothing above the form they mean + // nothing, and say so with the dead-end sound, exactly as the Customize screen does one level down. + navSecondary: (repeat = false) => { + const keyboard = keyboardSurface(); + if (keyboard?.navSecondary === undefined) { + if (!repeat) deps.audio.playLimit(); + return; + } + keyboard.navSecondary(repeat); + }, + navTertiary: () => { + const keyboard = keyboardSurface(); + if (keyboard?.navTertiary === undefined) { + deps.audio.playLimit(); + return; + } + keyboard.navTertiary(); + }, + navShoulder: (direction) => { + const keyboard = keyboardSurface(); + if (keyboard?.navShoulder === undefined) { + deps.audio.playLimit(); + return; + } + keyboard.navShoulder(direction); + }, + navCommit: () => { + const keyboard = keyboardSurface(); + if (keyboard?.navCommit === undefined) { + deps.audio.playLimit(); + return; + } + keyboard.navCommit(); + }, + resetSettings: () => deps.api.resetSettings(), + }; +} diff --git a/src/renderer/settings.css b/src/renderer/settings.css deleted file mode 100644 index fc3c2dc6..00000000 --- a/src/renderer/settings.css +++ /dev/null @@ -1,131 +0,0 @@ -/* Settings window layout. Component look (colors, typography, focus) comes from Fluent v3 + the theme - applied at runtime via setTheme() — which publishes the theme tokens as GLOBAL CSS custom properties. - So the page background/text below reference those tokens and follow the light/dark theme automatically - (the fallbacks apply only for the split second before setTheme runs). color-scheme is set from JS per - effective theme. Segoe UI is the system font Fluent uses; no external fonts (font-src 'self' holds). */ - -html, -body { - margin: 0; - height: 100%; -} - -body { - background: var(--colorNeutralBackground1, #1f1f1f); - color: var(--colorNeutralForeground1, #ffffff); - font-family: 'Segoe UI', system-ui, sans-serif; -} - -/* Custom title bar (native one is hidden). Sticky so it stays put while settings scroll. Draggable via - -webkit-app-region; the right padding keeps the title clear of the native min/max/close overlay. Its - background matches the page (and the overlay `color` set in settings-window.ts) so the strip is - seamless. */ -#titlebar { - position: sticky; - top: 0; - z-index: 10; - height: 48px; - display: flex; - align-items: center; - gap: 8px; - padding: 0 16px; - padding-right: 148px; - background: var(--colorNeutralBackground1, #1f1f1f); - -webkit-app-region: drag; - user-select: none; -} - -#titlebar-icon { - width: 18px; - height: 18px; - flex: none; -} - -#titlebar-title { - font-size: 12px; - font-weight: 600; - color: var(--colorNeutralForeground1, #ffffff); -} - -#titlebar-version { - font-size: 12px; - color: var(--colorNeutralForeground2, #adadad); -} - -#settings { - display: flex; - flex-direction: column; - gap: 28px; - padding: 24px 28px; -} - -.section { - display: flex; - flex-direction: column; - gap: 12px; -} - -fluent-dropdown { - align-self: flex-start; - min-inline-size: 260px; - /* Suppress the accent underline the control animates in on focus/open (.control::after) — visual noise. - The token only feeds that underline in single-select mode (its other use is the multiple-select - checkmark border, which these dropdowns never show), so this is a safe, surgical override. */ - --colorCompoundBrandStroke: transparent; -} - -.button-row { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -/* Empty-screen wallpaper preview: a small 16:9 thumbnail of the current background. */ -.wallpaper-preview { - display: block; - width: 220px; - max-width: 100%; - aspect-ratio: 16 / 9; - object-fit: cover; - border-radius: 6px; - border: 1px solid var(--colorNeutralStroke2, #3d3d3d); -} - -/* Labeled dropdown row (navigation sound set / background ambience): label stacked above its control. */ -.field-row { - display: flex; - flex-direction: column; - gap: 6px; -} - -/* Slider row: label + live value on the first line, the slider spanning full width below it. */ -.slider-row { - display: grid; - grid-template-columns: 1fr auto; - align-items: center; - column-gap: 12px; - row-gap: 6px; -} - -.slider-row > label { - grid-column: 1; -} - -.slider-value { - grid-column: 2; - justify-self: end; - color: var(--colorNeutralForeground2, #adadad); - font-variant-numeric: tabular-nums; -} - -.slider-row > fluent-slider { - grid-column: 1 / -1; -} - -#update-action { - align-self: flex-start; -} - -[hidden] { - display: none !important; -} diff --git a/src/renderer/settings.html b/src/renderer/settings.html deleted file mode 100644 index 996bf4dd..00000000 --- a/src/renderer/settings.html +++ /dev/null @@ -1,242 +0,0 @@ -<!doctype html> -<html lang="en"> - <head> - <meta charset="UTF-8" /> - <!-- CSP copied 1:1 from index.html (incl. media-src data:) so the settings renderer doesn't drift - from the game one. The Fluent bundle (settings.js) is same-origin (file://) → script-src 'self' - is enough; no eval/new Function/fetch in Fluent v3 or FAST 3 → no 'unsafe-eval' needed. Segoe UI - is a system font (no external font-src), and we use no icon fluent-* components (no icon assets). --> - <meta - http-equiv="Content-Security-Policy" - content="default-src 'none'; img-src data:; media-src data:; style-src 'self' 'unsafe-inline'; font-src 'self'; script-src 'self';" - /> - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - <title>Playhook — Settings - - - - -
- - Playhook - -
- -
- -
- Updates - Loading… - - -
- - -
- Automatic updates - - - Download and install automatically - Download automatically, install manually - Off (check manually) - - - - - - -
- - -
- Appearance - - - Match system - Light - Dark - - -
- - -
- Language - - - Match system - English - Русский - - -
- - -
- General - - - - - - - Hold Menu + View - on your gamepad at any time to bring the launcher to the front. - - - - - - - - - - - - - - - - - - - -
- Choose image… - Reset -
- -
- - -
- Audio - - -
- - - - -
-
- - - -
- - - -
- - - - No ambience - - -
- - - - - When on, only the global ambience plays — a game's own background music is ignored. -
- - - -
-
- - -
- Advanced -
- Open logs - Open games folder - Reset to defaults -
-
-
- - - diff --git a/src/renderer/settings.ts b/src/renderer/settings.ts deleted file mode 100644 index cbabbfa3..00000000 --- a/src/renderer/settings.ts +++ /dev/null @@ -1,531 +0,0 @@ -// Settings-window renderer (Fluent UI Web Components v3, dark theme). No gamepad / hero / audio — this -// is the plain "system settings" UI: app version + update management. -// -// Fluent import channel (fallback): web-components.min.js turned out to export -// NOTHING (a pure side-effect bundle), so we can't take setTheme from it. Instead we use the single -// `.`-index resolution graph — pointed `*/define.js` side-effect imports register just the elements we -// use, and setTheme comes from the same `@fluentui/web-components` index. One FAST copy, smaller bundle. -import '@fluentui/web-components/text/define.js'; -import '@fluentui/web-components/button/define.js'; -import '@fluentui/web-components/field/define.js'; -import '@fluentui/web-components/dropdown/define.js'; -import '@fluentui/web-components/listbox/define.js'; -import '@fluentui/web-components/option/define.js'; -import '@fluentui/web-components/switch/define.js'; -import '@fluentui/web-components/slider/define.js'; -import '@fluentui/web-components/progress-bar/define.js'; -import { setTheme } from '@fluentui/web-components'; -import { webDarkTheme, webLightTheme } from '@fluentui/tokens'; -import type { - AppSettings, - AutoUpdateMode, - LanguageMode, - ThemeMode, - UpdateStatus, -} from '../shared/types'; -import { createTranslator, type Locale, type Translator } from '../shared/i18n/index.js'; -import { localizeDocument } from './i18n-dom.js'; - -// Translator, refreshed on a language push. The HTML ships English fallback so there's no blank flash -// before the invoke-seed lands. -let translator: Translator = createTranslator('en'); - -// ── Theme ──────────────────────────────────────────────────────────────────── -// setTheme publishes the theme tokens as global CSS custom properties (see settings.css). `system` -// follows the OS preference via matchMedia and re-applies on OS changes; `light`/`dark` are fixed. -const darkQuery = window.matchMedia('(prefers-color-scheme: dark)'); -let systemListener: (() => void) | null = null; - -function isDark(mode: ThemeMode): boolean { - return mode === 'dark' || (mode === 'system' && darkQuery.matches); -} - -function paint(dark: boolean): void { - setTheme(dark ? webDarkTheme : webLightTheme); - document.documentElement.style.colorScheme = dark ? 'dark' : 'light'; - // Keep the native caption buttons (min/max/close) in sync with the effective theme. - window.settingsApi.setTitleBarDark(dark); -} - -function applyTheme(mode: ThemeMode): void { - paint(isDark(mode)); - // Only keep an OS-change subscription alive in `system` mode. - if (systemListener !== null) { - darkQuery.removeEventListener('change', systemListener); - systemListener = null; - } - if (mode === 'system') { - systemListener = () => paint(darkQuery.matches); - darkQuery.addEventListener('change', systemListener); - } -} - -// Apply a best-guess theme immediately (before settings load) to avoid a flash of unstyled tokens. -applyTheme('system'); - -function req(id: string): T { - const el = document.getElementById(id); - if (el === null) throw new Error(`#${id} not found`); - return el as T; -} - -const titlebarIcon = req('titlebar-icon'); -const titlebarVersion = req('titlebar-version'); -const statusEl = req('update-status'); -const progressEl = req('update-progress'); -const actionBtn = req('update-action'); -const autoUpdateGroup = req('auto-update'); -const themeGroup = req('theme'); -const languageGroup = req('language'); -const prereleaseSwitch = req('prerelease'); -const summonSwitch = req('summon-hotkey'); -const preventScreensaverSwitch = req('prevent-screensaver'); -const alwaysShowEmptySwitch = req('always-show-empty'); -const disableSilentInstallSwitch = req('disable-silent-install'); -const steamAutoLaunchSwitch = req('steam-auto-launch'); -const steamAutoLaunchField = req('steam-auto-launch-field'); -const steamAutoLaunchHint = req('steam-auto-launch-hint'); -const soundSetDropdown = req('sound-set'); -const ambientDropdown = req('ambient-track'); -const onlyGlobalAmbientSwitch = req('only-global-ambient'); -const musicSlider = req('music-volume'); -const musicValue = req('music-volume-value'); -const sfxSlider = req('sfx-volume'); -const sfxValue = req('sfx-volume-value'); -const openLogsBtn = req('open-logs'); -const openGamesBtn = req('open-games'); -const resetBtn = req('reset-defaults'); -const wallpaperPreview = req('wallpaper-preview'); -const wallpaperChooseBtn = req('wallpaper-choose'); -const wallpaperResetBtn = req('wallpaper-reset'); -const wallpaperError = req('wallpaper-error'); - -// Fluent custom elements reflect `disabled` / `value` as attributes/properties not present on the -// HTMLElement type; narrow casts (never `any`) keep this typed without pulling the element classes in. -function setDisabled(el: HTMLElement, disabled: boolean): void { - if (disabled) el.setAttribute('disabled', ''); - else el.removeAttribute('disabled'); -} - -function readAutoUpdateValue(el: HTMLElement): AutoUpdateMode | null { - const raw = (el as HTMLElement & { value?: unknown }).value; - return raw === 'download' || raw === 'download-install' || raw === 'off' ? raw : null; -} - -function readThemeValue(el: HTMLElement): ThemeMode | null { - const raw = (el as HTMLElement & { value?: unknown }).value; - return raw === 'system' || raw === 'light' || raw === 'dark' ? raw : null; -} - -function readLanguageValue(el: HTMLElement): LanguageMode | null { - const raw = (el as HTMLElement & { value?: unknown }).value; - return raw === 'system' || raw === 'en' || raw === 'ru' ? raw : null; -} - -// fluent-dropdown exposes a settable `value` (the selected option's value): setting it re-runs the -// component's selectOption, which also refreshes the collapsed control text. Kept as a narrow cast so we -// don't pull the element class in. -function setDropdownValue(el: HTMLElement, value: string): void { - (el as HTMLElement & { value?: string | null }).value = value; -} - -// Re-asserts the dropdown's current value so the collapsed control text re-renders from the (possibly -// just re-localized) selected option. selectOption reads option.text at selection time, so without this a -// language change would leave the old-language label showing in the closed control. -function refreshDropdownDisplay(el: HTMLElement): void { - const dd = el as HTMLElement & { value?: string | null }; - const current = dd.value; - if (typeof current === 'string') dd.value = current; -} - -// fluent-switch exposes a `checked` property; fluent-slider a numeric `valueAsNumber` / string `value`. -// Narrow casts (never `any`) keep these typed without importing the element classes. -function readChecked(el: HTMLElement): boolean { - return (el as HTMLElement & { checked?: boolean }).checked === true; -} - -function setChecked(el: HTMLElement, checked: boolean): void { - (el as HTMLElement & { checked?: boolean }).checked = checked; -} - -function readSliderPercent(el: HTMLElement): number { - const value = (el as HTMLElement & { valueAsNumber?: number }).valueAsNumber; - return typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : 0; -} - -function setSliderPercent(el: HTMLElement, percent: number): void { - (el as HTMLElement & { value?: string }).value = String(percent); -} - -// The selected set's "move" UI sound, loaded as a data URL (settings CSP allows media-src data:). Played -// as a volume preview when a slider is released. Null until it loads (or if it failed). Reloaded whenever -// the sound-set dropdown changes, so the preview reflects the CHOSEN set — the set is passed to main so a -// just-changed dropdown previews the new set without racing the on-disk settings write. -let moveSound: HTMLAudioElement | null = null; -function loadMoveSound(set: string): void { - void window.settingsApi.getMoveSound(set).then((url) => { - moveSound = url !== '' ? new Audio(url) : null; - }); -} - -// Cosmetic label for a raw set/track name: split on '-', capitalize each word, join with spaces -// (e.g. `dark-souls` → `Dark Souls`). These are proper names of bundled sets/tracks — not translated. -function prettifyName(raw: string): string { - return raw - .split('-') - .filter((word) => word.length > 0) - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); -} - -// Reads a fluent-dropdown's current value as a string (or null if unset). -function readDropdownRaw(el: HTMLElement): string | null { - const raw = (el as HTMLElement & { value?: unknown }).value; - return typeof raw === 'string' ? raw : null; -} - -// Fills the navigation-sound-set dropdown from the bundled set names (raw values, prettified labels). -function buildSoundSetOptions(sets: readonly string[]): void { - const listbox = soundSetDropdown.querySelector('fluent-listbox'); - if (listbox === null) return; - listbox.replaceChildren( - ...sets.map((name) => { - const option = document.createElement('fluent-option'); - option.setAttribute('value', name); - option.textContent = prettifyName(name); - return option; - }), - ); -} - -// Fills the ambience dropdown: a "No ambience" entry (value '' → null) plus a prettified option per track -// (value = the raw file name main reads back, label = the extension-stripped, prettified name). -function buildAmbientOptions(tracks: readonly string[]): void { - const listbox = ambientDropdown.querySelector('fluent-listbox'); - if (listbox === null) return; - const none = document.createElement('fluent-option'); - none.setAttribute('value', ''); - none.setAttribute('data-i18n', 'settings.ambientNone'); - none.textContent = translator('settings.ambientNone'); - const options = tracks.map((track) => { - const option = document.createElement('fluent-option'); - option.setAttribute('value', track); - option.textContent = prettifyName(track.replace(/\.[^.]+$/, '')); - return option; - }); - listbox.replaceChildren(none, ...options); -} - -// Plays the move sound at the slider's current level — the "how loud is this" preview. -function previewVolume(slider: HTMLElement): void { - if (moveSound === null) return; - // Clone so overlapping releases don't cut each other off (as the game renderer does for SFX). - const node = moveSound.cloneNode() as HTMLAudioElement; - node.volume = readSliderPercent(slider) / 100; - void node.play().catch(() => undefined); -} - -// Wires a volume slider: updates the live "N%" label on every input, persists the 0..1 volume on change -// (drag-commit), and plays a preview at the released level on pointer-up. The preview keys off pointerup -// (not change, which fires continuously during a drag) so it sounds once when the mouse is released; the -// one-shot window listener catches releases even when the pointer leaves the slider. -function wireVolumeSlider( - slider: HTMLElement, - valueEl: HTMLElement, - persist: (volume: number) => void, -): void { - const showValue = (): void => { - valueEl.textContent = `${readSliderPercent(slider)}%`; - }; - slider.addEventListener('input', showValue); - slider.addEventListener('change', () => { - showValue(); - persist(readSliderPercent(slider) / 100); - }); - slider.addEventListener('pointerdown', () => { - window.addEventListener('pointerup', () => previewVolume(slider), { once: true }); - }); -} - -// The context-dependent primary button action for the current status (null = the button is disabled -// or hidden). A single click listener dispatches to it, so render() only swaps label + handler. -let currentAction: (() => void) | null = null; - -function showAction(label: string, handler: (() => void) | null, disabled = false): void { - actionBtn.hidden = false; - actionBtn.textContent = label; - setDisabled(actionBtn, disabled || handler === null); - currentAction = handler; -} - -function hideAction(): void { - actionBtn.hidden = true; - currentAction = null; -} - -// Last rendered status, cached so a language push can re-render the Updates block in the new language -// (render is otherwise only called on a status change). null before the first snapshot. -let lastStatus: UpdateStatus | null = null; - -function render(status: UpdateStatus): void { - lastStatus = status; - const t = translator; - progressEl.hidden = true; - switch (status.kind) { - case 'idle': - statusEl.textContent = t('settings.status.idle'); - showAction(t('settings.action.check'), () => window.settingsApi.checkForUpdates()); - break; - case 'not-available': - statusEl.textContent = t('settings.status.upToDate'); - showAction(t('settings.action.check'), () => window.settingsApi.checkForUpdates()); - break; - case 'checking': - statusEl.textContent = t('settings.status.checking'); - showAction(t('settings.action.checking'), null, true); - break; - case 'available': - statusEl.textContent = t('settings.status.available', { version: status.version }); - showAction(t('settings.action.updateTo', { version: status.version }), () => - window.settingsApi.downloadUpdate(), - ); - break; - case 'downloading': - statusEl.textContent = t('settings.status.downloading', { percent: status.percent }); - progressEl.hidden = false; - progressEl.setAttribute('value', String(status.percent)); - showAction(t('settings.action.downloading'), null, true); - break; - case 'downloaded': - statusEl.textContent = t('settings.status.downloaded', { version: status.version }); - showAction(t('settings.action.restartInstall'), () => window.settingsApi.installUpdate()); - break; - case 'error': - // The message is already localized in main (or a passthrough technical cause) — render as-is. - statusEl.textContent = status.message; - showAction(t('settings.action.retry'), () => window.settingsApi.checkForUpdates()); - break; - case 'unsupported': - statusEl.textContent = t('settings.status.unsupported'); - hideAction(); - break; - } -} - -actionBtn.addEventListener('click', () => { - currentAction?.(); -}); - -function applyAutoUpdate(): void { - const value = readAutoUpdateValue(autoUpdateGroup); - if (value !== null) window.settingsApi.setAutoUpdate(value); -} -autoUpdateGroup.addEventListener('change', applyAutoUpdate); - -function applyThemeChoice(): void { - const value = readThemeValue(themeGroup); - if (value !== null) { - applyTheme(value); // apply live for instant feedback - window.settingsApi.setTheme(value); // and persist - } -} -themeGroup.addEventListener('change', applyThemeChoice); - -// Language is applied via a single push path: the change sends the mode, and the effective locale comes -// back through settingsLanguageUpdate (for `system` the renderer can't resolve it locally). No local -// application here — the push arrives within milliseconds. -function applyLanguageChoice(): void { - const value = readLanguageValue(languageGroup); - if (value !== null) window.settingsApi.setLanguage(value); -} -languageGroup.addEventListener('change', applyLanguageChoice); - -prereleaseSwitch.addEventListener('change', () => { - window.settingsApi.setPrerelease(readChecked(prereleaseSwitch)); -}); - -summonSwitch.addEventListener('change', () => { - window.settingsApi.setSummonHotkey(readChecked(summonSwitch)); -}); - -preventScreensaverSwitch.addEventListener('change', () => { - window.settingsApi.setPreventScreensaver(readChecked(preventScreensaverSwitch)); -}); - -steamAutoLaunchSwitch.addEventListener('change', () => { - window.settingsApi.setSteamAutoLaunch(readChecked(steamAutoLaunchSwitch)); -}); - -alwaysShowEmptySwitch.addEventListener('change', () => { - window.settingsApi.setAlwaysShowEmptyScreen(readChecked(alwaysShowEmptySwitch)); -}); -disableSilentInstallSwitch.addEventListener('change', () => { - window.settingsApi.setDisableSilentInstall(readChecked(disableSilentInstallSwitch)); -}); - -wireVolumeSlider(musicSlider, musicValue, (v) => window.settingsApi.setMusicVolume(v)); -wireVolumeSlider(sfxSlider, sfxValue, (v) => window.settingsApi.setSfxVolume(v)); - -soundSetDropdown.addEventListener('change', () => { - const value = readDropdownRaw(soundSetDropdown); - if (value === null) return; - window.settingsApi.setSoundSet(value); - loadMoveSound(value); // preview the newly-chosen set on the next slider release -}); -ambientDropdown.addEventListener('change', () => { - const value = readDropdownRaw(ambientDropdown); - if (value === null) return; - window.settingsApi.setAmbientTrack(value === '' ? null : value); -}); -onlyGlobalAmbientSwitch.addEventListener('change', () => { - window.settingsApi.setOnlyGlobalAmbient(readChecked(onlyGlobalAmbientSwitch)); -}); - -openLogsBtn.addEventListener('click', () => window.settingsApi.openLogs()); -openGamesBtn.addEventListener('click', () => window.settingsApi.openGamesFolder()); -resetBtn.addEventListener('click', () => { - void window.settingsApi.reset().then(applySettings); -}); - -// ── Empty-screen wallpaper ───────────────────────────────────────────────── -// Shows the preview thumbnail for the current data URL (empty string → hide the , no broken icon). -function showWallpaperPreview(dataUrl: string): void { - if (dataUrl !== '') { - wallpaperPreview.src = dataUrl; - wallpaperPreview.hidden = false; - } else { - wallpaperPreview.removeAttribute('src'); - wallpaperPreview.hidden = true; - } -} - -function showWallpaperError(message: string): void { - wallpaperError.textContent = message; - wallpaperError.hidden = false; -} - -function clearWallpaperError(): void { - wallpaperError.textContent = ''; - wallpaperError.hidden = true; -} - -// Refreshes the preview from main's current effective wallpaper (on open and after a general Reset). -async function refreshWallpaperPreview(): Promise { - clearWallpaperError(); - const { dataUrl } = await window.settingsApi.requestWallpaperPreview(); - showWallpaperPreview(dataUrl); -} - -wallpaperChooseBtn.addEventListener('click', () => { - void window.settingsApi.pickWallpaper().then((result) => { - if (result.ok) { - clearWallpaperError(); - showWallpaperPreview(result.dataUrl); - } else if (!('cancelled' in result)) { - showWallpaperError(result.message); // dismissed dialog → nothing; a real failure → message - } - }); -}); - -wallpaperResetBtn.addEventListener('click', () => { - void window.settingsApi.clearWallpaper().then(({ dataUrl }) => { - clearWallpaperError(); - showWallpaperPreview(dataUrl); - }); -}); - -// Reflects the full settings state onto every control (used on startup and after "Reset to defaults"). -function applySettings(settings: AppSettings): void { - setDropdownValue(autoUpdateGroup, settings.autoUpdate); - setDropdownValue(themeGroup, settings.theme); - setDropdownValue(languageGroup, settings.language); - setChecked(prereleaseSwitch, settings.allowPrerelease); - setChecked(summonSwitch, settings.summonHotkeyEnabled); - setChecked(preventScreensaverSwitch, settings.preventScreensaver); - setChecked(alwaysShowEmptySwitch, settings.alwaysShowEmptyScreen); - setChecked(disableSilentInstallSwitch, settings.disableSilentInstall); - setChecked(steamAutoLaunchSwitch, settings.steamAutoLaunch); - setDropdownValue(soundSetDropdown, settings.soundSet); - setDropdownValue(ambientDropdown, settings.ambientTrack ?? ''); - setChecked(onlyGlobalAmbientSwitch, settings.onlyGlobalAmbient); - loadMoveSound(settings.soundSet); // preview uses the current set (and after a Reset, the default) - const musicPercent = Math.round(settings.musicVolume * 100); - const sfxPercent = Math.round(settings.sfxVolume * 100); - setSliderPercent(musicSlider, musicPercent); - setSliderPercent(sfxSlider, sfxPercent); - musicValue.textContent = `${musicPercent}%`; - sfxValue.textContent = `${sfxPercent}%`; - applyTheme(settings.theme); - // The wallpaper preview isn't derivable from the scalar settings (it needs the image bytes) — pull the - // current effective wallpaper from main. Covers both startup and post-Reset (the file is gone by now). - void refreshWallpaperPreview(); -} - -// The app version, cached so a language change can re-render the "(version) — Settings" suffix. -let appVersion = ''; -function renderTitlebarVersion(): void { - titlebarVersion.textContent = translator('settings.titlebarVersion', { version: appVersion }); -} - -// A language push: rebuild the translator, re-localize the static DOM, re-title the window (so the HTML -// doesn't override the taskbar caption), and re-render the state-driven bits (Updates block from -// the cached status, the title-bar suffix). -function applyLocale(locale: Locale): void { - translator = createTranslator(locale); - document.documentElement.lang = locale; - // Match main's native window title so the HTML <title> doesn't override the taskbar caption. - // "Playhook" is the product name — not translated. - document.title = `Playhook — ${translator('window.settings')}`; - localizeDocument(translator); - // The dropdowns' collapsed control text is a snapshot of the selected option's text — re-assert each - // value so it re-renders with the freshly-localized labels (auto-update / theme / language "System"). - refreshDropdownDisplay(autoUpdateGroup); - refreshDropdownDisplay(themeGroup); - refreshDropdownDisplay(languageGroup); - // The ambience dropdown's "No ambience" option is localized; re-assert so the closed control re-renders - // in the new language (set/track names are proper nouns — unchanged, but re-asserting is harmless). - refreshDropdownDisplay(soundSetDropdown); - refreshDropdownDisplay(ambientDropdown); - renderTitlebarVersion(); - if (lastStatus !== null) render(lastStatus); -} - -async function init(): Promise<void> { - // Subscribe BEFORE requesting the initial snapshot, so a push arriving in between isn't lost. - window.settingsApi.onUpdateStatus(render); - window.settingsApi.onLanguageUpdate(applyLocale); - const [version, icon, settings, status, locale, steamAvailable, audioOptions] = await Promise.all([ - window.settingsApi.getAppVersion(), - window.settingsApi.getAppIcon(), - window.settingsApi.getSettings(), - window.settingsApi.requestUpdateStatus(), - window.settingsApi.getLanguage(), - window.settingsApi.isSteamAvailable(), - window.settingsApi.getAudioOptions(), - ]); - // Populate the Audio dropdowns from the bundle BEFORE applySettings sets their values (a value with no - // matching option wouldn't display). - buildSoundSetOptions(audioOptions.soundSets); - buildAmbientOptions(audioOptions.ambientTracks); - appVersion = version; - // Title bar: [icon] Playhook (version). Hide the <img> if the icon couldn't be read (empty string). - if (icon !== '') titlebarIcon.src = icon; - else titlebarIcon.hidden = true; - // The Steam Deck row exists only where the feature does — main decides (linux + packaged AppImage); - // the renderer cannot know the OS on its own. - steamAutoLaunchField.hidden = !steamAvailable; - steamAutoLaunchHint.hidden = !steamAvailable; - applySettings(settings); - render(status); - // Seed the locale last so it localizes the freshly-populated DOM and title-bar suffix in one pass. - applyLocale(locale); - // The Audio dropdowns' options were built THIS tick; fluent registers slotted <fluent-option>s on a - // microtask, so applySettings' synchronous value set above found no options yet and was dropped (both - // dropdowns showed blank). Re-assert on the next frame, once the options are registered. - requestAnimationFrame(() => { - setDropdownValue(soundSetDropdown, settings.soundSet); - setDropdownValue(ambientDropdown, settings.ambientTrack ?? ''); - }); -} - -void init(); diff --git a/src/renderer/sfx-limit.ts b/src/renderer/sfx-limit.ts new file mode 100644 index 00000000..4f15b283 --- /dev/null +++ b/src/renderer/sfx-limit.ts @@ -0,0 +1,26 @@ +// The rule behind the `limit` sound: it marks a dead end (a press that changed nothing), and a dead end +// held down is still ONE dead end. So the sound is latched — it fires once per series of blocked attempts, +// and the series ends when the user RELEASES the input, not when a timer expires. controls.ts re-arms the +// latch where it already detects the end of a hold (onDirectionsReleased for the pad, keyup for the +// keyboard); this module is the pure decision behind it, unit-tested without a DOM. + +/** + * Safety net for a release that never arrives — the window loses focus mid-hold and the keyup goes to + * whoever took it (the same hazard controls.ts covers with its flip watchdog). A gap this long between + * two attempts re-arms the latch on its own. + * + * It cannot be the main mechanism, and it has to sit above every repeat cadence in the app: the pad's + * HOLD_DELAY_MS is 350, and the keyboard's first repeat comes after an OS-configured 250-500 ms. A + * threshold below those would split one hold into two sounds 350 ms apart, which is worse than one; a + * deliberate re-tap is heard because of the release, not because of this number. + */ +export const LIMIT_IDLE_MS = 700; + +/** + * Whether this blocked attempt should sound. True when the latch is armed (the previous series ended + * with a release), or when enough idle time has passed that a release must have been missed. + * Pure — unit-tested. + */ +export function shouldPlayLimit(armed: boolean, lastAttemptAt: number, now: number): boolean { + return armed || now - lastAttemptAt >= LIMIT_IDLE_MS; +} diff --git a/src/renderer/state-view.ts b/src/renderer/state-view.ts index 11b7af92..b2bdb8c0 100644 --- a/src/renderer/state-view.ts +++ b/src/renderer/state-view.ts @@ -45,6 +45,13 @@ export function statusOf(state: AppState, t: Translator): string { case 'syncing-out': return t('launcher.state.syncingOut'); case 'ready': { + // A local (PC) draft with no launch method chosen yet: no status line — the absent Play button + // already says everything that needs saying, and "Launch is not set up" read as an error to fix + // right now rather than as the deliberate, in-progress state a draft actually is. + if (state.game.unconfigured === true) return ''; + // A local (PC) game whose files are gone: the card stays in the library, but there is nothing to + // launch, so say so instead of leaving an empty status under a dead Play button. + if (state.game.unavailable === true) return t('launcher.state.gameFilesMissing'); // Steam non-blocking install/uninstall indicators on the ready screen (the window stays usable). // No install percent: Steam exposes no reliable live progress in the files we read (see main). if (state.game.steamUninstalling === true) return t('launcher.state.uninstalling'); diff --git a/src/renderer/styles.css b/src/renderer/styles.css index aa752701..dc969ad1 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -28,31 +28,85 @@ src: url('./fonts/MPLUSRounded1c-ExtraBold.ttf') format('truetype'); } +/* M PLUS Rounded 1c renders '.' and '…' vertically centered — the CJK convention for punctuation, not the + Latin one — so a period, and worse, a run of them ("...", or a game title ending in one), sits noticeably + above the baseline and reads as a row of raised dots rather than an ellipsis. There is no glyph-level + fix from CSS alone, so these TWO code points are carved out of the bundled font family and left for the + next entry in the font stack (`'Segoe UI', system-ui, sans-serif` below) to draw instead: a `src` that + cannot resolve to anything makes THIS family claim no coverage for them, which is what makes a browser's + normal per-character fallback kick in — it is not specific to any one string, so it also covers text this + app does not author itself (a game's own title, if it happens to end in one). One override per weight the + four real faces above declare, so the substitution holds at every weight actually used in the UI. + Global rule for anything written here: don't rely on this being reliable everywhere it might render (a + downloaded update note, a future export) — prefer wording that has nothing trailing rather than typing a + literal '...' or '…'. */ +@font-face { + font-family: 'M PLUS Rounded 1c'; + font-style: normal; + font-weight: 300; + unicode-range: U+002E, U+2026; + src: local('.unavailable-glyph-source'); +} +@font-face { + font-family: 'M PLUS Rounded 1c'; + font-style: normal; + font-weight: 400; + unicode-range: U+002E, U+2026; + src: local('.unavailable-glyph-source'); +} +@font-face { + font-family: 'M PLUS Rounded 1c'; + font-style: normal; + font-weight: 500; + unicode-range: U+002E, U+2026; + src: local('.unavailable-glyph-source'); +} +@font-face { + font-family: 'M PLUS Rounded 1c'; + font-style: normal; + font-weight: 800; + unicode-range: U+002E, U+2026; + src: local('.unavailable-glyph-source'); +} + /* The two palette colors are declared as TYPED custom properties so they can be TRANSITIONED: a plain `--d1` is just a token and jumps instantly, which made the bar, the buttons and the popup snap to the new game's colors while the hero image was still cross-fading. With a `<color>` syntax the browser interpolates them, and every surface built on var(--d1)/var(--d2) follows the background smoothly. */ +/* The fallbacks are the EMPTY SCREEN's own palette — the dominant pair computed from the bundled + wallpaper (dominant-color.ts) — not an arbitrary pair. Anything else flashes its own colours for the + frames before the first hero is decoded, and a green flash over a violet wallpaper is exactly what the + crossfade cannot hide. Recompute these if assets/playhook-wallpaper.jpg is ever replaced. */ @property --d1 { syntax: '<color>'; inherits: true; - initial-value: #2a3340; + initial-value: #0d0a1b; } @property --d2 { syntax: '<color>'; inherits: true; - initial-value: #4caf50; + initial-value: #836e95; } :root { /* One design pixel (mockup is 1920x1080) expressed in vh, so the layout scales with the fullscreen height and keeps the mockup's proportions at 16:9. Write design px as calc(N*var(--px)). */ --px: 0.0925926vh; - --bg: #101014; + --bg: #0d0a1b; /* Palette fallbacks; #app gets --d1/--d2 from the hero background's two dominant colors. */ - --d1: #2a3340; - --d2: #4caf50; + --d1: #0d0a1b; + --d2: #836e95; /* Text colour OUTSIDE the app shell (there is none in practice — see the #app override below). */ --fg: #f0f0f0; + /* The card morph: how long the selected card grows/shrinks and how long the strip takes one step. + MIRRORED by MORPH_MS in carousel-geometry.ts (CSS cannot read it, JS cannot set it) — edit the two + together. Every carousel rule reads this, so the row's tempo is this one number. */ + --morph: 0.24s; + --morph-ease: cubic-bezier(0.4, 0, 0.2, 1); + /* One step of a HELD direction, written by controls.ts from NAV_REPEAT_MS. While a direction is down + the strip runs on this instead of --morph, and LINEARLY: a step that lasts exactly as long as the + gap to the next one makes the flip one continuous glide instead of nine eased hops a second. */ + --flip-step: 110ms; } * { @@ -63,11 +117,13 @@ cursor: default; } -/* Cursor hidden while the gamepad is in use, or after the idle timeout (class toggled from controls.ts). The - descendant selector's specificity beats the per-element cursor rules (buttons' pointer, the copyable - path's text); a real mouse move restores it. */ -html.cursor-hidden, -html.cursor-hidden * { +/* The mouse is asleep: the gamepad/keyboard is driving, or the idle timeout ran out (class toggled from + controls.ts). Asleep it is not merely invisible — controls.ts swallows every pointer gesture too, and + the :hover rules throughout this file are gated on :not(.mouse-asleep) so nothing stays lit under a + parked cursor. Only a deliberate shove wakes it (mouse-sleep.ts). The descendant selector's specificity + beats the per-element cursor rules (buttons' pointer, the copyable path's text). */ +html.mouse-asleep, +html.mouse-asleep * { cursor: none; } @@ -97,7 +153,7 @@ body { --fg: var(--d2); /* Palette crossfade, matched to the hero layers' own 1s opacity fade (see .hero-layer) so the colors and the image arrive together. hero.ts sets --d1/--d2 on this element. */ - transition: --d1 1s ease, --d2 1s ease; + transition: --d1 0.7s ease, --d2 0.7s ease; /* Establish a stacking context so the z-index:-1 hero ::before paints ABOVE #app's own background-color (otherwise the opaque bg covers it → black screen) but below the UI. */ isolation: isolate; @@ -112,9 +168,9 @@ body { inset: 0; z-index: -1; /* The screen-level zoom (see the data-screen rules below). Short on purpose: going back it has to be - DONE by the time the strip returns, which is 0.35s after the switch (see the reverse-morph delay). */ + DONE by the time the strip returns, which is --morph after the switch (see the reverse-morph delay). */ transform: scale(var(--hero-zoom, 1)); - transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + transition: transform var(--morph) var(--morph-ease); } /* Parallax: flipping through the carousel drags the background a little the same way (hero.ts sets @@ -141,7 +197,13 @@ body { background-size: cover; background-position: center; opacity: 0; - transition: opacity 1s ease; + /* Visibility rides along with the fade — flipped only when the cross-fade is over, so the outgoing + picture is never cut off mid-dissolve. What it buys is the idle layer: transparent but still + composited, it was a full-screen sheet of tiles the compositor kept rastering for nothing. */ + visibility: hidden; + transition: + opacity 0.7s ease, + visibility 0.7s ease; transform-origin: center; will-change: transform, opacity; animation: bg-pan 30s ease-in-out infinite alternate; @@ -149,6 +211,7 @@ body { .hero-layer.is-active { opacity: 1; + visibility: visible; } /* Ping-pong drift + a gentle zoom in/out, synced over the cycle (Ken Burns). The min scale (1.06) @@ -162,6 +225,47 @@ body { } } +/* The boot backdrop: the bundled wallpaper, held over the hero layers for the opening seconds (see + WALLPAPER_HOLD_MS in app.ts) and given a push of its own — a wider zoom and drift than the perpetual + bg-pan, so those seconds read as a title card rather than as a still image someone forgot to replace. + The push is a TRANSITION, not an animation, and the layer is separate from the hero on purpose. Both + follow from the same rule: nothing may travel BACKWARDS when boot ends. An animation dropped mid-flight + snaps to where its keyframes began; a shared layer would have to unwind its zoom with the hero riding + along — and a background reversing direction is the one jump the eye always catches. Here the backdrop + simply dissolves, over a hero that has been sitting settled underneath it all along. */ +#hero-boot { + position: absolute; + inset: 0; + background-size: cover; + background-position: center; + transform: scale(var(--boot-zoom, 1)) translateX(var(--boot-shift, 0%)); + transition: transform 7s cubic-bezier(0.3, 0, 0.6, 1); + will-change: transform, opacity; +} + +/* app.ts adds this a frame after load — the first frame has to paint the resting transform, or the + transition has nothing to start from. The drift direction (--boot-pan) is randomized there. The + easing matters as much as the numbers: a linear start moves at full speed from the very first frame, + which reads as a lurch on an image that has only just appeared. The soft ramp below spends the first + moments getting going, so the push is felt rather than seen. */ +#hero-boot.is-panning { + --boot-zoom: 1.22; + --boot-shift: var(--boot-pan, 4.5%); +} + +/* The handover. app.ts also writes an inline transform here — the CURRENT transform of the hero layer + below — so the backdrop converges on the image it is dissolving into instead of parting from it. That + matters most when the two are the SAME picture (no card: the empty screen is this very wallpaper), + where any offset between them would read as a double image sliding apart. + The two curves are deliberately opposites. Opacity drops fastest at the START (most of it is gone in + the first third), while the transform barely moves until the END — so the copy is at its most solid + while it is still, and does its travelling when there is almost nothing left to see. Same distance, + no visible motion. */ +#hero-boot.is-gone { + opacity: 0; + transition: opacity 0.9s cubic-bezier(0, 0, 0.3, 1), transform 1s cubic-bezier(0.7, 0, 1, 1); +} + /* ── Per-screen bottom-bar controls ──────────────────────────────────────── The bottom bar is the same on every screen. Game screens (ready / busy) show Play + title + More. The empty screen (idle, and the no-game error) shows no Play — just the title "Insert a game card" @@ -194,9 +298,11 @@ body { /* Straight off the mockup ("Home"): the cards' bottom edge sits 50 design px from the screen bottom — the same inset the bar content uses — so the selected 204-tall card's top lands at 254. The BOX reaches 30 px lower (20) so the active dot hanging under a card stays inside the clip with a little - slack; the strip is inset by the same 30 (see #carousel-strip). The top has slack too: the selected - card's pulsing ring blooms ~10 px past its edge (inset -7 + a 3 border, scaled 1.09 by the pulse), - and this box CLIPS — too tight a height chops the ring's top arc off. */ + slack; the strip is inset by the same 30 (see #carousel-strip). The top has slack too, and it is + load-bearing: this box CLIPS, and the focus body under the selected card reaches ~17 design px past + that card's edge at full breath (8 of stand-off plus the harmonics — see JELLY in focus-jelly.ts). + That leaves ~9 to spare: widen the body's reach and this height has to grow with it, or the clip + takes a flat bite out of the top of it. */ bottom: calc(20 * var(--px)); height: calc(260 * var(--px)); overflow: hidden; @@ -219,6 +325,29 @@ body { already handed its geometry over to #play-button — see the morph block below). */ #app[data-screen='carousel'] #carousel { opacity: 1; +} + + +/* The container stays transparent to the mouse even here, and the CARDS take the clicks instead. It is a + full-width band (see the box above) painted at z-index 1, so it lies right across the bottom bar — made + hit-testable it swallows every click meant for the bar's own buttons, More included. Nothing is bound to + the container itself: the card listeners live on the cards (carousel.ts) and the right-click-to-go-back + on the window. Gated on the screen because a card must NOT re-enable itself through the invisible strip + left behind on the detail screen — `pointer-events: none` on a parent is undone by an `auto` child. + + Gated on the OVERLAY for the same reason, one level up: a full-screen overlay hides its screen with + opacity plus `pointer-events: none` on the section, and an `auto` child punches straight through that + too. Without this, cards under an open Customize / Add-game screen still took clicks — the pointer + turned into a hand over a game nobody could see, and clicking it selected that game behind the + screen. The library's own cards are re-enabled below, for the overlay that does show them. */ +#app[data-screen='carousel']:not([data-overlay]) .card { + pointer-events: auto; +} + +/* The library IS an overlay, and its cards are what it exists to be clicked on. Named per overlay rather + than "any overlay" so a screen opened ON TOP of the library (Add game is reached from it) keeps the + grid underneath inert. */ +#app[data-overlay='library'] #library .library-grid .card { pointer-events: auto; } @@ -230,9 +359,11 @@ body { bottom: calc(30 * var(--px)); display: flex; align-items: flex-end; - gap: calc(16 * var(--px)); + /* MIRRORS GAP in carousel-geometry.ts — the offsets are computed there and cannot read this. The + SELECTED card widens its own two gaps with margins of its own (see below). */ + gap: calc(8 * var(--px)); transform: translateX(calc(var(--strip-offset, 0) * var(--px))); - transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1); + transition: transform var(--morph) var(--morph-ease), opacity 0.25s ease; } /* A card. The size transition MUST match the strip's curve and duration, or the card's growth and the @@ -247,8 +378,8 @@ body { background-size: cover; background-position: center; overflow: visible; - transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1), height 0.35s cubic-bezier(0.4, 0, 0.2, 1), - opacity 0.2s ease; + transition: width var(--morph) var(--morph-ease), height var(--morph) var(--morph-ease), + margin var(--morph) var(--morph-ease), opacity 0.2s ease; cursor: pointer; } @@ -257,38 +388,65 @@ body { height: calc(204 * var(--px)); } -/* The selected card wears the SAME pulsing ring as a focused Play button: on the carousel the card IS - the focus, so it must read as focused. Reuses the play-button's @keyframes and proportions (a 3px d2 - border 7px out, radius 17), and rides the size transition because it's anchored to the card's box. */ -.card.is-selected::after { - content: ''; +/* The selected card takes more room than the gap gives it: flex lays ONE gap between every pair, so the + difference is added here as margins. 8 + 16 = the 24 the row leaves around whatever is being looked at. + MIRRORS SEL_MARGIN in carousel-geometry.ts — stripOffset adds exactly this to put the card on the + anchor, so the two must move together. + Scoped to the strip: the Library's cards share the .card class, and a margin there would push its grid + tracks apart. */ +#carousel-strip .card.is-selected { + margin: 0 calc(16 * var(--px)); +} + +/* The focus indicator: a soft body lying UNDER the selected cover rather than a ring drawn around it. + A ring has to blink out on the card the selection leaves and in on the one it arrives at; one body per + surface travels between them, breathing where it stands. It is drawn on a canvas (focus-jelly.ts) — + the contour genuinely deforms, which no amount of border-radius can fake. + + The canvas rides INSIDE the strip, so the row's slide, its fades and its hiding all carry it for free, + exactly as the ring's element used to be carried. It is the strip's first child and `position: + absolute`, so it is no part of the flex row. */ +#carousel-jelly { position: absolute; - /* Half the Play button's ring (inset 7 / border 3 / radius 17): on a 204-tall card the full-size one - stands so far off the edge that its pulse reaches the active dot below. */ - inset: calc(-4 * var(--px)); - border: calc(2 * var(--px)) solid var(--d2); - border-radius: calc(14 * var(--px)); - /* Its own, gentler pulse for the same reason — pulse-ring's 1.09 scale is 9 px of travel on a card - this tall, which would swallow the dot's 16 px of clearance. */ - animation: card-ring-pulse 1.1s ease-in-out infinite; + left: calc(-26 * var(--px)); + /* Pinned to the row's TOP, not its floor: the strip's height follows the tallest card, so it shrinks + and grows all through a move — anchored at the bottom, the canvas would slide against the very + offsetTop values drawn onto it. The top edge does not move, and the canvas is tall enough for the + grown card either way (see stripCanvas). */ + top: calc(-26 * var(--px)); pointer-events: none; + /* Under the covers, over the wallpaper. The cards carry no z-index of their own, so a plain 0 here + puts the body behind every one of them. */ + z-index: 0; } -@keyframes card-ring-pulse { - 0%, - 100% { - transform: scale(1); - } - 50% { - transform: scale(1.035); - } -} -/* On the detail screen the strip is fading out — a pulsing ring underneath it would be noise. */ -#app[data-screen='detail'] .card.is-selected::after { - animation: none; +/* On the detail screen the strip is fading out — a breathing body underneath it would be noise. Scoped + to the STRIP's body: the grid's keeps breathing for the 0.35s the Library screen takes to fade, and a + snap there is the last thing seen of a card the user just pressed. */ +#app[data-screen='detail'] #carousel-jelly { opacity: 0; } +/* The launcher's own cards (Notifications / Settings / System): no artwork, a solid d2 plate with the + glyph punched out of it in d1 — the same inversion the focused rect-button uses. Selected, it grows to + 136x204 from the base rule like any other card; its name appears in the bar's title line, not on it. */ +.card.is-system { + display: flex; + align-items: center; + justify-content: center; + background-color: var(--d2); + background-image: none; + color: var(--d1); +} + +/* ~48 design px, the size the icons were exported at. currentColor, so the glyph follows the palette + through the plate above rather than carrying a colour of its own. */ +.card-icon { + width: calc(48 * var(--px)); + height: calc(48 * var(--px)); + fill: currentColor; +} + /* The title placeholder shown until the artwork arrives (a card may have none at all). */ .card-label { position: absolute; @@ -306,12 +464,10 @@ body { display: none; } -/* The 8x8 dot marking a game that is ON THE INSERTED CARD (launchable right now); history games have - none. It hangs below the card, clear of the artwork. */ -/* The dot marks "this game is on the inserted card". It is only shown when that MEANS something: when - the row also holds history entries (otherwise every card would carry one and it says nothing), or when - this game is busy — there the pulse is the only cue that an install/run is going on elsewhere in the - list. carousel.ts computes it and sets .shows-dot. */ +/* The 8x8 dot marking a game that is playable RIGHT NOW — on the inserted card or in the local library; + history games have none. It hangs below the card, clear of the artwork. A busy game wears it too, where + the pulse is the only cue that an install/run is going on elsewhere in the list. carousel.ts computes + it and sets .shows-dot. */ .card-dot { position: absolute; left: 50%; @@ -359,58 +515,133 @@ body { #app[data-screen='detail'][data-card-morph='off'] .card.is-selected { width: 0; height: 0; + /* Its margins go with it: a card shrinking to nothing must not leave a 16-wide hole in the row. */ + margin: 0; transform-origin: bottom left; - transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1), height 0.35s cubic-bezier(0.4, 0, 0.2, 1), - opacity 0.35s ease; + transition: width var(--morph) var(--morph-ease), height var(--morph) var(--morph-ease), + margin var(--morph) var(--morph-ease), opacity var(--morph) ease; } #app[data-screen='detail'][data-card-morph='off'] #carousel { /* The container has to outlast the shrink it is showing — the default 0.3s fade would take the card with it halfway through. It goes in one frame once the card has reached zero, which is the same moment the other branch hands over to the button. */ - transition: opacity 0s linear 0.35s; + transition: opacity 0s linear var(--morph); } /* Going BACK is the entry played backwards, not a cross-fade, and it runs in the same two beats whether - or not there is a Play button: FIRST the selected card comes back to full size on its own (0.35s), + or not there is a Play button: FIRST the selected card comes back to full size on its own (--morph), THEN the rest of the strip arrives. What differs is only WHO does the growing — the button when it exists (morph on), the card itself when it does not (morph off) — and, with it, when the strip's container may be shown: after the grow when the button is on top of it, before it otherwise (the card growing inside it IS the animation). */ #app[data-screen='carousel'][data-card-morph='on'] #carousel { - /* Switched on in ONE frame, not faded: the button disappears at exactly 0.35s, and a strip that is + /* Switched on in ONE frame, not faded: the button disappears at exactly the morph, and a strip that is still climbing out of opacity 0 at that instant leaves a hole where the artwork just was — read as a blink at the end of the morph. The strip's own cards decide how they come in. */ - transition: opacity 0s linear 0.35s; + transition: opacity 0s linear var(--morph); } #app[data-screen='carousel'][data-card-morph='off'] #carousel { /* No button to wait for — the container must be up from frame one or the card would grow unseen. */ transition: opacity 0s linear; } + +/* …and none of that applies when the detail screen was entered from the LIBRARY. The row's grace period + exists so the selected CARD can hand its geometry to #play-button — but a game opened from the Library + was never on the row (the strip sat hidden behind the screen), and it spends those 240ms re-aiming at + the game just picked. With the screen cut away rather than faded, that is 240ms of a carousel visibly + rebuilding itself, which is exactly what it was. + It has to sit HERE, after the morph rules, and carry two attributes: `[data-card-morph='off']` is the + one that bites — the flag is off at that instant, because `browse` still describes the launcher card + the Library was opened from, so the delay it pins would otherwise win on specificity and hold the row + on screen for its whole 240ms. app.ts sets the flag on the way in and clears it on the way back. */ +#app[data-detail-from='library'][data-screen='detail'] #carousel { + opacity: 0; + transition: opacity 0s linear; +} #app[data-screen='carousel'] .card { - /* ONLY the fade-in waits for the grow. Delaying width/height here too would also delay every - grow/shrink WHILE flipping — the strip would slide first and the cards resize after. + /* Flipping through the row: the only cards whose opacity moves are the ones crossing the window's edge + (.is-beyond below), and they must do it WITH the slide — no delay, or the row would arrive first and + the newcomer blink in afterwards. The staggered hand-back fade lives in the [data-returning] rule. */ + transition: width var(--morph) var(--morph-ease), height var(--morph) var(--morph-ease), + margin var(--morph) var(--morph-ease), opacity var(--morph) cubic-bezier(0.2, 0, 0.2, 1), + transform var(--morph) var(--morph-ease); +} +#app[data-screen='carousel'][data-returning] .card { + /* Coming back from the detail screen. ONLY the fade-in waits for the grow. Delaying width/height here + too would also delay every grow/shrink WHILE flipping — the strip would slide first and the cards + resize after. The fan: each card comes in one stagger step later than its neighbour closer to the selection, so the row opens outwards instead of appearing as one block. --fan is set per card by carousel.ts. The fade is much LONGER than the step on purpose — the cards overlap heavily, which reads as one soft wave travelling outwards rather than a row of separate blinks. - These three numbers (0.35s morph / 50ms step / 0.4s fade) are mirrored by RETURN_MS in - carousel-geometry.ts, which locks input out for exactly as long: edit them together. */ - transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1), height 0.35s cubic-bezier(0.4, 0, 0.2, 1), - opacity 0.4s cubic-bezier(0.2, 0, 0.2, 1) calc(0.35s + var(--fan, 0) * 50ms); + These three numbers (--morph / 50ms step / 0.4s fade) are mirrored by RETURN_LOCK_MS and + RETURN_FAN_MS in carousel-geometry.ts — the first locks input out for the morph, the second is how + long the renderer keeps `data-returning` on: edit them together. */ + transition: width var(--morph) var(--morph-ease), height var(--morph) var(--morph-ease), + margin var(--morph) var(--morph-ease), + opacity 0.4s cubic-bezier(0.2, 0, 0.2, 1) calc(var(--morph) + var(--fan, 0) * 50ms), + transform var(--morph) var(--morph-ease); +} +/* A HELD direction: the row GLIDES instead of hopping. Each repeat arrives every --flip-step (controls.ts + writes it from NAV_REPEAT_MS), and an eased 0.24s transition restarted three times faster than it can + finish is what made the flip read as bumps — the curve keeps re-accelerating from wherever it got to. + A LINEAR step of exactly one repeat's length hands the strip over to the next step at the same speed it + was travelling, so the whole hold is one even slide. The card grow/shrink follows the same clock, or + the size change would drift behind the row (the invariant the .card comment states). */ +#app[data-flipping] #carousel-strip { + transition: transform var(--flip-step) linear, opacity 0.25s ease; +} +#app[data-flipping][data-screen='carousel'] .card { + transition: width var(--flip-step) linear, height var(--flip-step) linear, + margin var(--flip-step) linear, opacity var(--morph) cubic-bezier(0.2, 0, 0.2, 1), + transform var(--flip-step) linear; +} + +/* Past the end of the shown window (VISIBLE_CARDS = 9 in carousel-geometry.ts): laid out as usual — the + strip's offset is positional, so removing the node would shift the whole row — but invisible and + unclickable until the selection walks far enough right for it to fade in. */ +#app[data-screen='carousel'] .card.is-beyond { + opacity: 0; + pointer-events: none; } + #app[data-screen='carousel'] .card.is-selected { /* The hand-back is a SWAP, same as the hand-over: this card is pixel-identical to the button it takes over from, so it must be fully opaque in the very frame the button goes — anything softer shows the background through it while the button is already gone. Its neighbours may fade in. */ transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1), height 0.35s cubic-bezier(0.4, 0, 0.2, 1), - opacity 0s linear 0.35s; + margin 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0s linear 0.35s; } #app[data-screen='carousel'][data-card-morph='off'] .card.is-selected { /* Here the card IS the button: it is opaque from frame one and grows out of the corner it shrank into, which is exactly what the button does on the other branch. */ transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1), height 0.35s cubic-bezier(0.4, 0, 0.2, 1), - opacity 0s linear; + margin 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0s linear; +} +/* The startup fan (carousel.playIntro): the same wave, except the selected card fades in WITH the row + instead of swapping in opaque. Both rules above exist to hand over from the play button — at startup + there is no button to hand over from, and a card snapping in mid-wave is what gives that away. */ +#app[data-screen='carousel'][data-returning='intro'] .card.is-selected { + transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1), height 0.35s cubic-bezier(0.4, 0, 0.2, 1), + margin 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.4s cubic-bezier(0.2, 0, 0.2, 1) 0.35s; +} +/* Coming back from a detail screen the body waits for the CARD, and the delay is 0.35s rather than the + --morph the container uses — the two are not the same moment, which is what made the hand-back blink. + The container switches on at 0.24s while the selected card stays transparent until 0.35s (it swaps in + opaque as the play button goes, see .card.is-selected below). Started at 0.24s the body spent those + 110ms alone on screen, burning up to 0.85 opacity under a button that had not finished growing — a + flash of colour where the cover was about to be. Now both arrive in the same frame. */ +#app[data-screen='carousel'] #carousel-jelly { + opacity: 1; + transition: opacity 0.2s ease 0.35s; } +/* The startup fan (carousel.playIntro) needs longer: there the selected card fades in WITH the row + rather than swapping in opaque, so it only starts appearing at 0.35s and is not done until 0.75s — + the rule above would leave the body fully drawn under a half-transparent card for a third of a second. */ +#app[data-returning='intro'] #carousel-jelly { + transition: opacity 0.2s ease calc(var(--morph) + 0.4s); +} + #app[data-screen='carousel'][data-card-morph='on'] .play-button { /* Stays visible (and keeps its cover) for exactly that grow, then is swapped out in one frame. */ transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1), height 0.35s cubic-bezier(0.4, 0, 0.2, 1), @@ -451,7 +682,8 @@ body { color-mix(in srgb, var(--d1) 75%, transparent) 42%, transparent 100% ); - transition: opacity 0.3s ease; + /* transform joins the fade so the bar leaves downwards when the Settings screen takes over. */ + transition: opacity 0.3s ease, transform 0.3s ease; } /* The blur, as its own layer so it can be masked to the pool's shape without touching the fill above. @@ -511,9 +743,16 @@ body { cursor: pointer; /* width/height are part of the carousel→detail morph and share the strip's curve; `opacity` is deliberately NOT transitioned — the carousel card and this button swap places instantaneously while - they are pixel-identical, and a fade there would show both at once. */ - transition: background 0.2s ease, color 0.2s ease, transform 0.12s ease, - width 0.35s cubic-bezier(0.4, 0, 0.2, 1), height 0.35s cubic-bezier(0.4, 0, 0.2, 1); + they are pixel-identical, and a fade there would show both at once. + The FOCUS FILL is not transitioned either, here or on any other focusable control in this file. It + used to fade over 0.2s, and on the Deck that fade ended in a dropped frame: a 30fps capture of the + Customize screen has the row the focus had just landed on measuring 131 … 7 … 134 — one frame at the + background's own brightness, no fill and no text, in the very frame the transition completed, while + every neighbouring row and the header stayed byte-identical. Chromium on Mesa losing the element as + its animated layer is retired. A highlight that arrives instantly cannot end, so it cannot blink — + and on a gamepad UI the 200ms lag was worth less than the twitch cost. */ + transition: transform 0.12s ease, width 0.35s cubic-bezier(0.4, 0, 0.2, 1), + height 0.35s cubic-bezier(0.4, 0, 0.2, 1); } /* The morph's cover layer: the selected card's artwork, painted behind the button's own content. It is @@ -541,26 +780,22 @@ body { opacity: 0; pointer-events: none; /* On this screen the button is not a button — it is the card's stand-in, so nothing that makes it read - as Play may survive the flip. Coming BACK it is focused (the d2 fill + the icon) and grows from 92 to - 136x204: without this reset a bright plate flashes over the artwork for the whole grow. The focus fill - outranks .play-button's own transparent background, hence the explicit override. */ + as Play may survive the flip. Coming BACK it is focused and grows from 92 to 136x204: without this + reset a bright plate flashes over the artwork for the whole grow. The focus fill outranks + .play-button's own transparent background, hence the explicit override. (The pulsing ring that used + to need silencing here is gone — the liquid body is the focus visual now.) */ background: transparent; } #app[data-screen='carousel'] .play-button > * { opacity: 0; } -#app[data-screen='carousel'] .play-button::after { - opacity: 0; - animation: none; - transition: none; -} #app[data-screen='carousel'] .play-button::before { opacity: 1; } /* :hover gated on a live cursor — see the note on .text-button:hover (avoids a stale hover highlight sticking under the pointer once the gamepad takes over). */ -html:not(.cursor-hidden) .play-button:hover, +html:not(.mouse-asleep) .play-button:hover, .play-button.is-focused { background: var(--d2); color: var(--d1); @@ -574,33 +809,6 @@ html:not(.cursor-hidden) .play-button:hover, transform: scale(0.9); } -/* Pulse ring — a rounded square echoing the button (kept on hover/focus per design). */ -.play-button::after { - content: ''; - position: absolute; - inset: calc(-7 * var(--px)); - border: calc(3 * var(--px)) solid var(--d2); - border-radius: calc(17 * var(--px)); - opacity: 0; - transition: opacity 0.2s ease; -} - -html:not(.cursor-hidden) .play-button:hover::after, -.play-button.is-focused::after { - opacity: 1; - animation: pulse-ring 1.1s ease-in-out infinite; -} - -@keyframes pulse-ring { - 0%, - 100% { - transform: scale(1); - } - 50% { - transform: scale(1.09); - } -} - .icon-play { width: calc(36 * var(--px)); height: calc(48 * var(--px)); @@ -629,11 +837,12 @@ html:not(.cursor-hidden) .play-button:hover::after, /* ── Rect (Info) button — normal: no background, content d2; fills with d2 on hover/focus ── */ -/* Carousel level: Play and More are hidden entirely (Р8) — the actions live one level deeper, on the - detail screen (A → detail → More → System/Close), so the carousel stays a pure browsing surface. */ +/* Carousel level: neither bar button is there. Play is hidden (it is the selected card's stand-in for the + morph — see below), and More goes with it: the menu it opens is the GAME's, and the launcher's own + actions are cards in the row. The button stays in the DOM — the bar's focus model is built on it — so + it is hidden here rather than removed. */ #app[data-screen='carousel'] #more-button { - opacity: 0; - pointer-events: none; + display: none; } .rect-button { @@ -651,11 +860,11 @@ html:not(.cursor-hidden) .play-button:hover::after, background: transparent; color: var(--d2); cursor: pointer; - transition: background 0.2s ease, color 0.2s ease, opacity 0.3s ease, transform 0.12s ease; + transition: opacity 0.3s ease, transform 0.12s ease; } /* :hover gated on a live cursor — see the note on .text-button:hover. */ -html:not(.cursor-hidden) .rect-button:hover, +html:not(.mouse-asleep) .rect-button:hover, .rect-button.is-focused { background: var(--d2); color: var(--d1); @@ -664,10 +873,18 @@ html:not(.cursor-hidden) .rect-button:hover, /* The launcher drives its own focus indicator via .is-focused (gamepad / arrow-key nav), so the native keyboard focus ring is redundant. It also lingered as a stray outline after closing a popup with Esc: the button kept DOM focus from the opening mouse click, and Esc flipped Chromium into keyboard - modality, painting the :focus-visible ring. Suppress it — .is-focused is the single focus visual. */ + modality, painting the :focus-visible ring. Suppress it — .is-focused is the single focus visual. + EVERY button the launcher draws belongs on this list: a click leaves DOM focus behind wherever it + lands, and the very next key press paints a ring on it that has nothing to do with where our own focus + now is. The settings screens' buttons were missing here, which is how the column kept a stray ring + after a click. */ .play-button, .rect-button, -.text-button { +.text-button, +.settings-nav-item, +.settings-option, +.osk-key, +.picker-item { outline: none; } @@ -772,15 +989,19 @@ html:not(.cursor-hidden) .rect-button:hover, transition: none; } -/* Carousel level: the text block moves NEXT TO the selected card, not under it — x=202 (the card's right - edge plus the 16 gap) with the title's centre 226 from the screen bottom, as in the mockup. +/* Carousel level: the text block moves NEXT TO the selected card, not under it — x=210 with the title's + centre 226 from the screen bottom, as in the mockup. + That 210 is not a free number: it is the anchor (50) plus the selected card's width (136) plus the + row's own GAP, so the text sits one gap off the cover exactly as the covers sit off each other. The + shift below is what is left after .bar-content's 50 and .title's 124, i.e. 210 - 174. Widening the gap + moves this with it — it went 16 -> 24, so the shift went 28 -> 36 (see GAP in carousel-geometry.ts). `--text-lift` is the single vertical knob (both lines share it) and `--title-x`/`--status-x` the horizontal one — never a second `left`, which would collide with the rules that already own it (base, idle/error, no-play). The busy shift stacks on top, reproducing the mockup's "Home - Installing". */ #app[data-screen='carousel'] { --text-lift: calc(130 * var(--px)); - --title-x: calc(28 * var(--px)); - --status-x: calc(28 * var(--px)); + --title-x: calc(36 * var(--px)); + --status-x: calc(36 * var(--px)); } #app[data-screen='carousel'] .title { font-size: calc(24 * var(--px)); @@ -842,21 +1063,61 @@ html:not(.cursor-hidden) .rect-button:hover, position: absolute; inset: 0; pointer-events: none; - /* Above the carousel (which lifts itself over the bar) so the veil covers the whole screen. */ - z-index: 2; + /* Above the carousel (which lifts itself over the bar) AND above the Settings screen (z-index 2), which + stays open under a confirm popup opened from it. */ + z-index: 3; } .popup-veil { position: absolute; + /* NOT narrowed like .popup-blur: that layer's mask reaches full opacity at screen 36%, a hair past + its own 35% box edge, so cutting the box there loses nothing visible. This one's mask does not + reach full opacity until 58%, and the background tint keeps climbing all the way to 100% — cutting + the box at 35% chopped off a third of that still-rising curve, which showed up as a hard seam right + through a focused row's highlight instead of the intended smooth fade. Full-width it stays. */ inset: 0; /* Horizontal gradient: transparent on the left (hero visible) → solid d1 on the right. */ background: linear-gradient(to right, transparent 0%, var(--d1) 100%); - backdrop-filter: blur(12px); - /* Mask so the frosted blur only appears on the right, fading in from the center. */ + /* Mask so the tint only builds up on the right, fading in from the center. */ -webkit-mask-image: linear-gradient(to right, transparent 0%, #000 58%); mask-image: linear-gradient(to right, transparent 0%, #000 58%); opacity: 0; transition: opacity 0.35s ease; + /* Keep this on its own composited layer: promoting it mid-animation is a frame the launcher can't + spare, and that frame is exactly when the focus tends to move (see .popup-blur). */ + will-change: opacity; +} + +/** + * The frosted layer, SEPARATE from the tint above and deliberately NOT animated. + * + * A backdrop-filter re-samples everything beneath it, and Chromium rasterizes that region afresh the + * first time a given popup shows it — one costly frame, once per popup (after that the raster is + * cached, which is why the stutter never repeated on the same popup). Two things keep that frame away + * from the moment the user is navigating: + * • the blur is switched on in ONE frame, never faded, so the cost lands on the press itself; + * • `will-change: transform` pins it to its own composited layer for good, so the layer is neither + * created nor thrown away as the animations ABOVE it (the tint, the column) start and finish — that + * hand-off was itself a repaint of the whole blurred region. + */ +.popup-blur { + position: absolute; + /* Only the RIGHT part of the screen, not inset:0. The mask hid the left half anyway, so a full-width + layer was sampling — and re-sampling — twice the pixels it ever showed. The mask keeps the soft + start, now measured against this narrower box (0.58 of the screen ≈ 0.36 of a box starting at 35%). */ + left: 35%; + right: 0; + top: 0; + bottom: 0; + backdrop-filter: blur(12px); + -webkit-mask-image: linear-gradient(to right, transparent 0%, #000 36%); + mask-image: linear-gradient(to right, transparent 0%, #000 36%); + opacity: 0; + will-change: transform; +} + +.popup.is-open .popup-blur { + opacity: 1; } /* Right column: 550px wide, inset 50px on right/top/bottom. Content sits at the top; the action stack @@ -873,6 +1134,9 @@ html:not(.cursor-hidden) .rect-button:hover, transform: translateX(calc(40 * var(--px))); opacity: 0; transition: transform 0.35s ease, opacity 0.35s ease; + /* Its own layer, permanently: promoting it when the slide starts and dropping it when the slide ends + repaints what is under it — including the blurred region (see .popup-blur). */ + will-change: transform, opacity; } .popup.is-open { @@ -887,9 +1151,13 @@ html:not(.cursor-hidden) .rect-button:hover, } /* Top content — inset 100 from the column top. Only the active view's block shows; each is a column - with a gap (info stats / confirm question+note / error title+detail). */ + with a gap (info stats / confirm question+note / error title+detail). + `flex: 0 0 auto` states what it already was and now has to defend: the stats are the one thing in this + column that must never be squeezed or scrolled away — the action stack below takes whatever is left + and scrolls inside itself instead (see .popup-actions). */ .popup-content { margin-top: calc(100 * var(--px)); + flex: 0 0 auto; } .popup-content > * { display: none; @@ -898,10 +1166,72 @@ html:not(.cursor-hidden) .rect-button:hover, } #popup[data-view='details'] .popup-info, #popup[data-view='confirm'] .popup-confirm, +#popup[data-view='busy'] .popup-busy, #popup[data-view='error'] .popup-error { display: flex; } +/* The stats and the action items come in one after another as the popup opens: the column slides in as a + whole (see .popup-column), and its contents arrive just behind it instead of being fully drawn the + moment it lands. The wave runs BOTTOM-UP — the item nearest the bottom of its block goes first — which + is both the direction the items rise from AND where the focus lands: the bottom item is the one the + user is already looking at, and having it arrive last made the whole menu feel slow to open. Counting + with nth-LAST-child is what keeps that true however many items the current menu happens to have. + Keyed on .is-open, so the animation restarts on every open. `both` holds the first frame during the + delay — without it each item would flash at full opacity before its turn. */ +@keyframes popup-item-in { + from { + opacity: 0; + transform: translateY(calc(12 * var(--px))); + } + to { + opacity: 1; + transform: none; + } +} + +.popup.is-open .info-item, +.popup.is-open .actions-group > .text-button, +.popup.is-open .notification-list > .text-button { + animation: popup-item-in 0.3s cubic-bezier(0.2, 0, 0.2, 1) both; + animation-delay: calc(0.12s + var(--pop, 0) * 45ms); +} + +.popup.is-open .info-item:nth-last-child(2), +.popup.is-open .actions-group > .text-button:nth-last-child(2), +.popup.is-open .notification-list > .text-button:nth-last-child(2) { + --pop: 1; +} +.popup.is-open .info-item:nth-last-child(3), +.popup.is-open .actions-group > .text-button:nth-last-child(3), +.popup.is-open .notification-list > .text-button:nth-last-child(3) { + --pop: 2; +} +.popup.is-open .actions-group > .text-button:nth-last-child(4), +.popup.is-open .notification-list > .text-button:nth-last-child(4) { + --pop: 3; +} +.popup.is-open .actions-group > .text-button:nth-last-child(5), +.popup.is-open .notification-list > .text-button:nth-last-child(5) { + --pop: 4; +} +.popup.is-open .actions-group > .text-button:nth-last-child(6), +.popup.is-open .notification-list > .text-button:nth-last-child(6) { + --pop: 5; +} +.popup.is-open .actions-group > .text-button:nth-last-child(n + 7), +.popup.is-open .notification-list > .text-button:nth-last-child(n + 7) { + --pop: 6; +} + +/* An item that collapses (or grows back) WHILE the popup is open must not have the entrance animation + sitting on its opacity — `both` would pin it at the animation's end value and the fade below would + never show. The entrance has long finished by then, so dropping it costs nothing. */ +.popup.is-open .actions-group > .text-button.is-hidden, +.popup.is-open .notification-list > .text-button.is-hidden { + animation: none; +} + .info-item { display: flex; flex-direction: column; @@ -969,9 +1299,25 @@ html:not(.cursor-hidden) .rect-button:hover, the copy one for data-install-via='copy'. Copying runs unattended and needs no path from the user, so the installer note would be actively misleading there. */ .note-copy, -.note-prefix { +.note-prefix, +.note-delete { + display: none; +} + +/* Deleting a game from its manifest: the detail carries the one thing the question does not — the game's + files (and, for a local game, its save backups) are not going anywhere. */ +#popup[data-view='confirm'][data-mode='delete-game'] .popup-confirm .popup-detail, +#popup[data-view='confirm'][data-mode='delete-game-history'] .popup-confirm .popup-detail { + display: block; +} +#popup[data-mode='delete-game'] .note-installer, +#popup[data-mode='delete-game-history'] .note-installer { display: none; } +#popup[data-mode='delete-game'] .note-delete, +#popup[data-mode='delete-game-history'] .note-delete { + display: inline; +} #popup[data-install-via='copy'] .note-installer { display: none; } @@ -1002,10 +1348,18 @@ html:not(.cursor-hidden) .rect-button:hover, /* A flex column (not a plain block): the active group must be a flex ITEM so a too-long one (the game list) can be constrained by the column and scroll internally. `min-height:0` lets this box shrink below its content height; without it — or as a block — the list overflows visibly past the column. */ +/* The lower half of the column: everything the top content leaves. It FILLS that space rather than + hugging its items (`flex: 1 1 auto`) and pushes them to the bottom from the inside — the anchoring + `margin-top: auto` used to do, now that there is space to be anchored within. The point of the change + is the overflow: with the box hugging its items, a stack too tall for the screen simply ran off it, + taking the stats with it (the column has no scroll of its own, so the focus ring's scrollIntoView + moved the whole APP instead). Now the group inside is what shrinks and scrolls. */ .popup-actions { margin-top: auto; display: flex; flex-direction: column; + justify-content: flex-end; + flex: 1 1 auto; min-height: 0; } @@ -1013,15 +1367,55 @@ html:not(.cursor-hidden) .rect-button:hover, display: none; flex-direction: column; align-items: flex-end; - gap: calc(8 * var(--px)); +} + +/* The gap is a MARGIN rather than `gap`, so an item that collapses can take its own spacing with it — + a flex gap survives a zero-height neighbour and would leave a step in the stack while it folds away. */ +.actions-group > .text-button + .text-button, +.notification-list > .text-button + .text-button { + margin-top: calc(8 * var(--px)); } #popup[data-view='details'] .actions-group[data-group='details'], +#popup[data-view='notifications'] .actions-group[data-group='notifications'], #popup[data-view='power'] .actions-group[data-group='power'], #popup[data-view='confirm'] .actions-group[data-group='confirm'], +#popup[data-view='busy'] .actions-group[data-group='busy'], #popup[data-view='error'] .actions-group[data-group='error'] { display: flex; } +/* The Details stack scrolls when it is too tall for what the stats leave — the same arrangement the + notification list uses, and for the same reason: `min-height: 0` so the flex item may shrink below its + content, `flex: 0 1 auto` (the default) so a short stack still sits at the bottom rather than stretching, + and the edge fades so an item cut by the clip is faded instead of sliced. The scrolling is driven by the + shared scroller (screen-scroller.ts), which also keeps these two variables current. */ +.actions-group[data-group='details'] { + min-height: 0; + overflow-y: auto; + /* Hidden like every other scrolling surface here — the focus ring IS the position indicator. */ + scrollbar-width: none; + --fade-top: 0px; + --fade-bottom: 0px; + -webkit-mask-image: linear-gradient( + to bottom, + transparent 0, + #000 var(--fade-top), + #000 calc(100% - var(--fade-bottom)), + transparent 100% + ); + mask-image: linear-gradient( + to bottom, + transparent 0, + #000 var(--fade-top), + #000 calc(100% - var(--fade-bottom)), + transparent 100% + ); +} + +.actions-group[data-group='details']::-webkit-scrollbar { + display: none; +} + /* ── TextButton — textual sibling of .rect-button ─────────────────────────── Transparent with d2 text; inverts to a d2 fill with d1 text on hover/focus (the design's "Active" variant — the mockup's filled bottom button is just the default focus, not a persistent state). */ @@ -1045,15 +1439,15 @@ html:not(.cursor-hidden) .rect-button:hover, font-size: calc(28 * var(--px)); white-space: nowrap; cursor: pointer; - transition: background 0.2s ease, color 0.2s ease, transform 0.12s ease; + transition: transform 0.12s ease; } /* Hover paints the same highlight as .is-focused, but ONLY while the cursor is live: once the gamepad - takes over (html.cursor-hidden), the pointer still physically sits over whatever it last hovered, so a + takes over (html.mouse-asleep), the pointer still physically sits over whatever it last hovered, so a bare :hover would keep that button highlighted alongside the gamepad's .is-focused one — two highlights, and A (which acts on .is-focused) would fire a different button than the hovered one looks. - Gating :hover on :not(.cursor-hidden) drops the stale hover the moment the gamepad is used. */ -html:not(.cursor-hidden) .text-button:hover, + Gating :hover on :not(.mouse-asleep) drops the stale hover the moment the gamepad is used. */ +html:not(.mouse-asleep) .text-button:hover, .text-button.is-focused { background: var(--d2); color: var(--d1); @@ -1069,3 +1463,2069 @@ html:not(.cursor-hidden) .text-button:hover, .text-button.is-hidden { display: none; } + +/* …except inside the popup's action stack, where an item can come and go while the menu is on screen + (a game starts running → Force close appears; it exits → it goes). There it FOLDS instead: height, + spacing and opacity animate to nothing and the items below slide up to meet it. Kept out of the base + rule on purpose — everywhere else `is-hidden` is decided before the element is ever seen, and a fold + would be an animation of nothing. */ +.actions-group > .text-button, +.notification-list > .text-button { + transition: transform 0.12s ease, opacity 0.2s ease, + height 0.24s cubic-bezier(0.4, 0, 0.2, 1), margin-top 0.24s cubic-bezier(0.4, 0, 0.2, 1); +} + +.actions-group > .text-button.is-hidden, +.notification-list > .text-button.is-hidden { + display: flex; + height: 0; + margin-top: 0; + opacity: 0; + overflow: hidden; + pointer-events: none; +} + +/* ── Notifications: the popup's list, the More item's unread dot, and the toast ── + The list is a stack of TextButtons one level deeper than the other groups' own buttons (it has a + scrolling box of its own around them), which is why every rule above written with the `>` combinator + names `.notification-list > .text-button` as well — otherwise the entries would fall out of the + stack's rhythm: no spacing, no staggered entrance, no fold. */ + +/* The scrolling half of the group. `min-height: 0` is load-bearing: .popup-actions has it, .actions-group + does not, and without it this flex column refuses to shrink below its content and the list overflows + the popup column instead of scrolling inside it. */ +/* The group that holds the scrolling list needs `min-height: 0` of its OWN. .popup-actions has it, + .actions-group does not — and a flex item's default `min-height: auto` means it refuses to shrink + below its content. Without this the group simply grew as tall as thirty notifications, the list inside + it never got a bounded height, `overflow-y` had nothing to overflow, and the whole stack ran off the + bottom of the screen taking the buttons with it. */ +.actions-group[data-group='notifications'] { + min-height: 0; +} + +/* `flex: 0 1 auto` (the default) on purpose, NOT `1 1 auto`: with a handful of notifications the list + stays its content's height and sits just above the buttons, keeping the stack bottom-anchored like + every other view; only when there are too many does it shrink and start scrolling. */ +.notification-list { + display: flex; + flex-direction: column; + align-items: flex-end; + overflow-y: auto; + /* Hidden, as on every other scrolling surface here (the focus ring is the position indicator). It also + kills a flash: the entries come in translated by the staggered entrance, so for those few frames the + content overflows and Chromium painted a native scrollbar over the plate before settling. */ + scrollbar-width: none; + min-height: 0; + margin-bottom: calc(8 * var(--px)); + /* Each edge fades rather than cuts, so an entry crossing the clip is half-transparent instead of + sliced — the same treatment the Settings list gets, driven by the same scroller (screen-scroller.ts). + An edge with nothing beyond it has NO fade, or the first and last entries would sit dimmed under a + gradient that hides nothing. */ + --fade-top: 0px; + --fade-bottom: 0px; + -webkit-mask-image: linear-gradient( + to bottom, + transparent 0, + #000 var(--fade-top), + #000 calc(100% - var(--fade-bottom)), + transparent 100% + ); + mask-image: linear-gradient( + to bottom, + transparent 0, + #000 var(--fade-top), + #000 calc(100% - var(--fade-bottom)), + transparent 100% + ); +} + +.notification-list::-webkit-scrollbar { + display: none; +} + +/* An entry is two lines (what happened, and when), so it cannot use the one-line TextButton geometry: + the height grows with the text, the lines stack right-aligned like everything else in this column, + and a long game title is clamped rather than allowed to run the popup off the screen. */ +.notification-item { + height: auto; + min-height: calc(92 * var(--px)); + flex-direction: column; + align-items: flex-end; + justify-content: center; + gap: calc(4 * var(--px)); + padding-top: calc(14 * var(--px)); + padding-bottom: calc(14 * var(--px)); + max-width: 100%; + white-space: normal; + text-align: right; +} + +/* NOT clamped: an entry is read once and acted on, so a notification that says something long (a move + that landed on an inactive card, or one that could not clean up after itself) has to say all of it — + two lines cut exactly the part that tells the user what to DO. The list scrolls, so height is free. */ +.notification-text { + line-height: calc(34 * var(--px)); +} + +.notification-time { + font-weight: 300; + font-size: calc(20 * var(--px)); + line-height: calc(28 * var(--px)); + opacity: 0.7; +} + +/* Unread marker — the same 8px dot the carousel puts under a card on the inserted card, for the same + reason: it is this UI's one word for "look here". It rides in the entry's own row, so a focused entry + (which inverts to a d2 fill) needs it painted in d1 or it would vanish into the fill. */ +.notification-dot { + flex: 0 0 auto; + width: calc(8 * var(--px)); + height: calc(8 * var(--px)); + border-radius: 50%; + background: var(--d2); + margin-left: calc(12 * var(--px)); + transition: opacity 0.2s ease; +} + +/* Read: the dot goes, and takes its space with it — a row of entries each carrying an invisible 20px of + right padding reads as a layout bug. The whole list is marked read in one go when the popup opens, so + this is one coordinated reflow during the entrance, not a jitter. */ +.notification-dot.is-hidden { + display: none; +} + +.notification-item.is-focused .notification-dot, +html:not(.mouse-asleep) .notification-item:hover .notification-dot { + background: var(--d1); +} + +/* The first line of an entry: the text, with the unread dot beside it. */ +.notification-line { + display: flex; + align-items: center; + justify-content: flex-end; + max-width: 100%; +} + +/* Empty state — a line, not a button: there is nothing to press, so it must not be focusable either. */ +.notification-empty { + padding: calc(24 * var(--px)) calc(32 * var(--px)); + font-weight: 300; + font-size: calc(24 * var(--px)); + line-height: calc(36 * var(--px)); + color: color-mix(in srgb, var(--fg) 75%, transparent); +} + +/* ── The toast ────────────────────────────────────────────────────────────── + Top-right, above every surface (the popup is 3, the screens 2), and NOT interactive: there is nothing + to press on it, which also keeps it clear of the hover guard in controls.ts. It shares its corner with + the popup column, so a toast that arrives while the popup is open waits in the queue instead (the list + inside the popup is updating live anyway). */ +.toast { + position: absolute; + top: calc(50 * var(--px)); + right: calc(50 * var(--px)); + max-width: calc(550 * var(--px)); + z-index: 4; + pointer-events: none; + display: flex; + flex-direction: column; + /* Each plate is as wide as its own text and hangs off the RIGHT edge — the same rule the popup's + action stack follows. Stretched (the flex default) they all took the width of the widest one, so + when that one left, the rest shrank and their left edges jumped inward. */ + align-items: flex-end; + gap: calc(12 * var(--px)); +} + +/* One message. The NEWEST is prepended, so the ones already up slide down by exactly their own height — + the stack moves rather than overlapping, which is what makes two messages in a row readable. The + entrance/exit lives here (translateX + opacity) and the pulse on the inner node (scale): one element + cannot animate two transforms independently. */ +.toast-plate { + opacity: 0; + transform: translateX(calc(40 * var(--px))); + transition: + opacity 0.3s ease, + transform 0.3s ease; +} + +.toast-plate.is-open { + opacity: 1; + transform: none; +} + +.toast-pulse { + background: var(--d1); + color: var(--d2); + border: 1px solid color-mix(in srgb, var(--d2) 30%, transparent); + border-radius: calc(10 * var(--px)); + padding: calc(20 * var(--px)) calc(32 * var(--px)); +} + +/* TWO beats over the plate's six seconds, and no more — a repeating pulse reads as an alarm and keeps + pulling the eye back to something already read. The first lands halfway through ("still here"), the + second just before the plate slides out, so it reads as hopping and then leaving of its own accord. + The duration MUST match SHOW_MS in toast.ts. Both return to `none` on purpose, and the last one by + 100% — the animation stops the moment `is-open` goes (the selector stops matching), and a keyframe + left mid-scale would snap back with a visible pop right as the exit begins. */ +.toast-plate.is-open .toast-pulse { + animation: toast-pulse 6s ease-in-out 1; +} + +@keyframes toast-pulse { + 0%, + 47% { + transform: none; + } + 50% { + transform: scale(1.03); + } + 53%, + 88% { + transform: none; + } + 94% { + transform: scale(1.03); + } + 100% { + transform: none; + } +} + +/* Unclamped for the same reason as .notification-text: the plate is on screen for six seconds and then + gone, so anything it cuts is simply never read. The plate's width is capped (.toast max-width), so a + long message grows downwards rather than across the screen. */ +.toast-text { + font-weight: 500; + font-size: calc(28 * var(--px)); + line-height: calc(38 * var(--px)); +} + +/* ── Full-screen overlays: Settings, and Customize ────────────────────────── + Not carousel screens: the strip keeps its layout underneath, so returning lands exactly where the user + left. Openness is #app[data-overlay=<name>]; the hidden state is opacity + pointer-events + visibility + + content-visibility, never display:none — that keeps everything animatable, keeps the bar's own mouse + handlers from firing through a hidden bar, and never collapses the carousel's measured layout. + + TWO kinds of rule live below, and mixing them up is how the second screen would have broken the first: + • what happens to the launcher UNDERNEATH an overlay (the hero fades, the bar leaves, the strip goes) + is the same for every overlay, so those rules key off the ATTRIBUTE — `#app[data-overlay]`; + • what makes ONE screen visible must name that screen, or opening Customize would fade Settings in on + top of it. `.settings-open` below is the shorthand for "the screen whose name is the current value", + written out per screen because CSS has no parent selector. */ + +.settings { + position: absolute; + inset: 0; + /* Below the popup (z-index 3): a confirm popup — the reset question — has to paint on top of this + screen, which stays open underneath it. */ + z-index: 2; + opacity: 0; + pointer-events: none; + /* A closed screen is not merely transparent, it is OUT of the frame: `opacity: 0` alone leaves every + layer inside it (veil, frosted dropdown, grid of covers) composited and rastered, and on a 2× display + that is where the tile budget goes — the compositor then runs out and drops tiles of what IS on + screen, which is how the power menu came back with half its items missing. Both properties are + transitioned so the fade OUT still plays: they flip back at the end of it, never at its start. */ + visibility: hidden; + content-visibility: hidden; + transition: + opacity 0.35s ease, + visibility 0.35s ease, + content-visibility 0.35s allow-discrete; +} + +#app[data-overlay='settings'] #settings, +#app[data-overlay='game-settings'] #game-settings, +#app[data-overlay='library'] #library { + opacity: 1; + pointer-events: auto; + visibility: visible; + content-visibility: visible; +} + +/* Veil in the popup's own language, but full-width: this screen is not a right-hand column. + NO backdrop-filter here, deliberately. A blur samples everything under it EVERY frame, and what is + under it is a full-screen hero running a perpetual pan animation — on a Deck that is enough to cost + frames, and dropped frames are what made the focus stutter as it moved. Instead the hero itself fades + out (see below) and the veil fills with flat --d1: one composited layer, no per-frame sampling. */ +.settings-veil { + position: absolute; + inset: 0; + background: var(--d1); + opacity: 0; + transition: opacity 0.35s ease; +} + +/* The hero goes with it — faded out, and its pan animation PAUSED so nothing keeps painting behind a + screen that no longer shows it. Both come back on the way out, in the same beat as everything else. + Any overlay, hence the bare attribute. */ +#app[data-overlay] #hero { + opacity: 0; + visibility: hidden; +} + +#app[data-overlay] .hero-layer { + animation-play-state: paused; +} + +#hero { + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.35s ease, visibility 0.35s ease; +} + +#app[data-overlay='settings'] #settings .settings-veil, +#app[data-overlay='game-settings'] #game-settings .settings-veil, +#app[data-overlay='library'] #library .settings-veil { + opacity: 1; +} + +/* The content column: full-height, inset like the popup column (50), a comfortable reading width. */ +.settings-column { + position: absolute; + left: calc(120 * var(--px)); + right: calc(120 * var(--px)); + top: calc(50 * var(--px)); + bottom: calc(50 * var(--px)); + display: flex; + flex-direction: column; + opacity: 0; + transform: translateY(calc(24 * var(--px))); + transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.35s cubic-bezier(0.4, 0, 0.2, 1); +} + +#app[data-overlay='settings'] #settings .settings-column, +#app[data-overlay='game-settings'] #game-settings .settings-column, +#app[data-overlay='library'] #library .settings-column { + opacity: 1; + transform: none; +} + +.settings-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: calc(16 * var(--px)); + padding: calc(30 * var(--px)) calc(32 * var(--px)) calc(20 * var(--px)); +} + +.settings-title { + font-weight: 800; + font-size: calc(48 * var(--px)); + line-height: calc(64 * var(--px)); + color: var(--d2); + transition: color 0.45s cubic-bezier(0.4, 0, 0.2, 1); +} + +.settings-version { + font-weight: 300; + font-size: calc(24 * var(--px)); + color: color-mix(in srgb, var(--fg) 55%, transparent); +} + +/* The trailing half of the header: the game's name and, after a single space, where its manifest lives. + A flex row with a gap rather than two header children — space-between would fling them apart. */ +.settings-heading { + display: flex; + align-items: baseline; + gap: calc(8 * var(--px)); + min-width: 0; +} + +/* Quieter than the name itself: it answers a question the user asks once ("whose file is this?"), not + one they keep asking. */ +.settings-source { + font-weight: 300; + font-size: calc(20 * var(--px)); + color: color-mix(in srgb, var(--fg) 40%, transparent); +} + +/* The two columns: the section list on the left, the selected section's rows on the right. */ +.settings-body { + flex: 1 1 auto; + min-height: 0; + display: flex; + gap: calc(40 * var(--px)); +} + +/* The section column. Narrow and fixed: it is a table of contents, not a second form. + The padding is what puts the BUTTONS' edge under the screen title rather than their text: the filled + entry is the shape the eye tracks down the screen, so it is the shape that has to line up with the + heading above it. */ +.settings-nav { + flex: 0 0 calc(380 * var(--px)); + padding-left: calc(32 * var(--px)); + display: flex; + flex-direction: column; + gap: calc(4 * var(--px)); + overflow-y: auto; + scrollbar-width: none; + --fade-top: 0px; + --fade-bottom: 0px; + -webkit-mask-image: linear-gradient( + to bottom, + transparent 0, + #000 var(--fade-top), + #000 calc(100% - var(--fade-bottom)), + transparent 100% + ); + mask-image: linear-gradient( + to bottom, + transparent 0, + #000 var(--fade-top), + #000 calc(100% - var(--fade-bottom)), + transparent 100% + ); +} + +.settings-nav::-webkit-scrollbar { + display: none; +} + +.settings-nav-item { + flex: 0 0 auto; + min-height: calc(72 * var(--px)); + display: flex; + align-items: center; + /* Inset from the button's own edge — that edge is what carries the alignment (see .settings-nav). */ + padding: 0 calc(22 * var(--px)); + border: none; + border-radius: calc(10 * var(--px)); + background: transparent; + color: var(--d2); + font-family: inherit; + font-weight: 500; + font-size: calc(28 * var(--px)); + text-align: left; + cursor: pointer; +} + +/* Instant, like every other focus fill — see the note on .play-button. This is the control the dropped + frame was actually caught on. */ +html:not(.mouse-asleep) .settings-nav-item:hover, +.settings-nav-item.is-focused { + background: var(--d2); + color: var(--d1); +} + +/* The section the pane is showing, while the focus is over in the pane: underlined rather than filled, + so "where I am" and "what has the focus" never claim the same highlight. */ +.settings-nav-item.is-current { + text-decoration: underline; + text-underline-offset: calc(8 * var(--px)); +} + +/* The actions that end the column, set apart from the sections above them. */ +.settings-nav-item[data-kind='action'] { + font-weight: 400; +} + +.settings-nav-item[data-kind='section'] + .settings-nav-item[data-kind='action'] { + margin-top: calc(20 * var(--px)); +} + +.settings-nav-item.is-disabled { + opacity: 0.4; + cursor: default; +} + +/* The column arrives the way the popup's items do — one after another, top-down, which is the order they + are read in. Driven by a class rather than by the overlay state, because the buttons are REUSED between + visits (screen-sidebar.ts): the animation has to be re-armed, not re-triggered by a state change. */ +.settings-nav-item.is-entering { + animation: popup-item-in 0.3s cubic-bezier(0.2, 0, 0.2, 1) both; + animation-delay: calc(var(--nav-index, 0) * 40ms); +} + +/* Save's own feedback, under both columns — see the note in index.html. */ +.settings-status { + flex: 0 0 auto; + display: flex; + flex-direction: column; + gap: calc(6 * var(--px)); + padding: calc(12 * var(--px)) calc(32 * var(--px)) 0; +} + +.settings-status:empty { + display: none; +} + +/* The scrolling list. The native scrollbar is hidden — the focus ring is the position indicator, exactly + as it is on every other surface here. Smooth scrolling is safe: vertical navigation is one step per + press (only left/right hold-to-repeat), so the steps can't pile up into a lagging scroll. */ +.settings-list { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + scrollbar-width: none; + /* The pane sits narrow while the COLUMN has the focus and opens out to the left when the focus steps + into it — its right edge never moves, so the rows stay aligned and only the entrance widens. That + sideways move is what says "you are in here now"; replaying the rows' own entrance said "here is a + different section", which is a different sentence and was read as one. */ + margin-left: calc(20 * var(--px)); + transition: margin-left 0.25s cubic-bezier(0.4, 0, 0.2, 1); + /* NOT scroll-behavior: smooth — the screen animates scrollTop itself (settings-screen.ts) with one + fixed duration and easing; leaving the native smoothing on would fight it frame for frame. */ + /* Each edge fades rather than cuts, so a row crossing the clip is half-transparent instead of sliced. + The sizes are driven from JS: an edge with nothing beyond it (the very top, the very bottom) has NO + fade, or the first and last rows would sit dimmed under a gradient that hides nothing. */ + --fade-top: 0px; + --fade-bottom: 0px; + -webkit-mask-image: linear-gradient( + to bottom, + transparent 0, + #000 var(--fade-top), + #000 calc(100% - var(--fade-bottom)), + transparent 100% + ); + mask-image: linear-gradient( + to bottom, + transparent 0, + #000 var(--fade-top), + #000 calc(100% - var(--fade-bottom)), + transparent 100% + ); +} + +.settings-list.is-active { + margin-left: 0; +} + +.settings-list::-webkit-scrollbar { + display: none; +} + +.settings-section { + display: flex; + flex-direction: column; + gap: calc(4 * var(--px)); + padding-bottom: calc(24 * var(--px)); +} + +/* The pane's rows arrive the same way the column's entries do — the two halves of the screen behave + alike. Armed by the screen (settings-screen.ts) when the pane changes SECTION, and only then: NOT on a + value patch, not on the rebuilds a held direction used to cause (those are debounced away before they + reach here), and not when the focus merely steps in — that one is the width change above, because + "you moved in here" and "this is another section" must not look like the same event. + The class sits on the ROW, not on the list around it — a class on the list is a window in which every + row built later starts the entrance over again. See entrance.ts. */ +.setting-row.is-entering { + animation: popup-item-in 0.28s cubic-bezier(0.2, 0, 0.2, 1) both; + animation-delay: calc(var(--row-index, 0) * 30ms); +} + +.settings-section-title { + font-weight: 500; + font-size: calc(26 * var(--px)); + line-height: calc(40 * var(--px)); + color: color-mix(in srgb, var(--fg) 55%, transparent); + padding: calc(12 * var(--px)) calc(32 * var(--px)) calc(4 * var(--px)); +} + +/* One row: label left, control right. Focus inverts the WHOLE row, so every control drawn in + currentColor inverts with it — no second set of rules per control. */ +.setting-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: calc(24 * var(--px)); + min-height: calc(78 * var(--px)); + padding: calc(8 * var(--px)) calc(32 * var(--px)); + border-radius: calc(10 * var(--px)); + color: var(--d2); + transition: transform 0.12s ease; +} + +/* :hover gated on a live cursor, like every other control here (see .text-button). */ +html:not(.mouse-asleep) .setting-row:hover, +.setting-row.is-focused { + background: var(--d2); + color: var(--d1); +} + +.setting-row.is-pressed { + transform: scale(0.98); +} + +/* Capped at 60% of the row, the same share .setting-value-wide takes on the other side: a hint is free to + run onto a second line, but never to push the value out of the column the rest of the rows keep it in. + The cap is what makes that a WRAP rather than a shove — without it the box is sized by its longest line + and the value gets whatever is left. */ +.setting-label-box { + display: flex; + flex-direction: column; + gap: calc(2 * var(--px)); + flex: 1 1 auto; + min-width: 0; + max-width: 60%; +} + +.setting-label { + font-weight: 500; + font-size: calc(28 * var(--px)); + line-height: calc(40 * var(--px)); +} + +.setting-hint { + font-weight: 300; + font-size: calc(20 * var(--px)); + line-height: calc(28 * var(--px)); + opacity: 0.7; +} + +/* Checkbox: an outlined square that fills with currentColor when on; the check is drawn in the row's + BACKGROUND colour, which is why it keeps reading right through the focus inversion. */ +.setting-toggle { + flex: 0 0 auto; + width: calc(34 * var(--px)); + height: calc(34 * var(--px)); + border: calc(2 * var(--px)) solid currentColor; + border-radius: calc(8 * var(--px)); + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s ease; +} + +.setting-toggle.is-on { + background: currentColor; +} + +.setting-check { + width: calc(22 * var(--px)); + height: calc(22 * var(--px)); + fill: none; + stroke: var(--d1); + stroke-width: 3; + stroke-linecap: round; + stroke-linejoin: round; + opacity: 0; + transform: scale(0.6); + transition: opacity 0.2s ease, transform 0.2s ease; +} + +.setting-row.is-focused .setting-check { + stroke: var(--d2); +} + +.setting-toggle.is-on .setting-check { + opacity: 1; + transform: scale(1); +} + +/* Dropdown, collapsed: ‹ value ›. The chevrons are clickable with the mouse; left/right cycle the value + from the gamepad without expanding at all. */ +.setting-select { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: calc(12 * var(--px)); +} + +.setting-chevron { + display: flex; + align-items: center; + justify-content: center; + width: calc(28 * var(--px)); + height: calc(28 * var(--px)); + cursor: pointer; +} + +.setting-chevron svg { + width: calc(18 * var(--px)); + height: calc(18 * var(--px)); + fill: none; + stroke: currentColor; + stroke-width: 2.5; + stroke-linecap: round; + stroke-linejoin: round; + pointer-events: none; +} + +.setting-value { + font-weight: 500; + font-size: calc(26 * var(--px)); + min-width: calc(220 * var(--px)); + text-align: center; + transition: opacity 0.12s ease, transform 0.12s ease; +} + +/* The slider's readout is not framed by chevrons — keep it flush right against the track. */ +.setting-slider .setting-value { + text-align: right; +} + +/* The direction of a value change is readable: the text slides the way the press went. */ +.setting-value.is-shift-prev { + opacity: 0; + transform: translateX(calc(10 * var(--px))); +} + +.setting-value.is-shift-next { + opacity: 0; + transform: translateX(calc(-10 * var(--px))); +} + +/* Slider: a track with a fill and a knob, plus the "N %" readout. */ +.setting-slider { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: calc(20 * var(--px)); +} + +.setting-track { + position: relative; + width: calc(320 * var(--px)); + height: calc(8 * var(--px)); + border-radius: calc(4 * var(--px)); + background: color-mix(in srgb, currentColor 30%, transparent); + cursor: pointer; +} + +.setting-fill { + position: absolute; + left: 0; + top: 0; + bottom: 0; + border-radius: calc(4 * var(--px)); + background: currentColor; + transition: width 0.12s ease; +} + +.setting-knob { + position: absolute; + top: 50%; + width: calc(24 * var(--px)); + height: calc(24 * var(--px)); + margin-left: calc(-12 * var(--px)); + border-radius: 50%; + background: currentColor; + transform: translateY(-50%); + transition: left 0.12s ease; + pointer-events: none; +} + +/* While dragging, the knob must sit under the cursor — a transition here reads as lag, not as polish. */ +.setting-slider.is-dragging .setting-fill, +.setting-slider.is-dragging .setting-knob { + transition: none; +} + +.setting-slider .setting-value { + min-width: calc(90 * var(--px)); +} + +/* Updates row: the status line (+ progress bar) on the left, the primary action on the right. */ +.setting-row-status { + align-items: center; +} + +.setting-status-body { + display: flex; + flex-direction: column; + justify-content: center; + gap: calc(10 * var(--px)); + min-width: 0; + flex: 1 1 auto; +} + +.setting-status-text { + font-weight: 300; + font-size: calc(26 * var(--px)); + line-height: calc(36 * var(--px)); + overflow-wrap: anywhere; +} + +.setting-progress { + /* Height, not display: the bar has to COLLAPSE when idle (otherwise the status text is pushed off the + row's centre line, away from its button) yet still grow in smoothly when a download starts. */ + height: 0; + width: calc(420 * var(--px)); + max-width: 100%; + border-radius: calc(4 * var(--px)); + background: color-mix(in srgb, currentColor 30%, transparent); + opacity: 0; + transition: opacity 0.2s ease, height 0.2s ease; +} + +.setting-progress.is-visible { + height: calc(8 * var(--px)); + opacity: 1; +} + +.setting-progress-fill { + height: 100%; + width: 0; + border-radius: calc(4 * var(--px)); + background: currentColor; + transition: width 0.3s ease; +} + +/* A button's own 32 of padding is pulled back out, so its LABEL lands on the same right edge as the + checkboxes and values above it — the button's BACKGROUND then reaches the list edge, exactly like the + popup's action stacks. Without this the buttons read as inset by a hair, which is all it takes. */ +.setting-row .text-button { + margin-right: calc(-32 * var(--px)); +} + +/* Rows whose ONLY control is a button (the reset action, the Updates row) put the focus on the BUTTON, + like every other button in this UI — filling the whole row and then re-inverting the button inside it + reads as two competing highlights. */ +.setting-row[data-kind='action'].is-focused, +.setting-row[data-kind='update-status'].is-focused, +html:not(.mouse-asleep) .setting-row[data-kind='action']:hover, +html:not(.mouse-asleep) .setting-row[data-kind='update-status']:hover { + background: transparent; + color: var(--d2); +} + +.setting-row[data-kind='action'].is-focused .text-button, +.setting-row[data-kind='update-status'].is-focused .text-button { + background: var(--d2); + color: var(--d1); +} + +/* The action row is nothing but its button, pushed to the right edge like the popup's own stacks. */ +.setting-row[data-kind='action'] { + justify-content: flex-end; +} + +/* Expanded dropdown — the launcher's popup, reused verbatim: the same frosted veil fading in from the + left and the same right-hand column sliding in. It sits INSIDE the settings screen (which is why it is + not #popup itself), but it must not look like a second, lesser kind of overlay. + The blur is affordable here, unlike the settings veil: the hero underneath is already faded out and its + pan animation paused, so this samples a static picture rather than an animating one every frame. */ +.settings-options { + position: absolute; + inset: 0; + opacity: 0; + pointer-events: none; + transition: opacity 0.35s ease; +} + +.settings-options.is-open { + opacity: 1; + pointer-events: auto; +} + +.settings-options-veil { + position: absolute; + /* Same as .popup-veil: NOT narrowed. See that rule for why cutting this box at 35% (rather than the + ~58% where its own gradient actually saturates) put a visible seam through a focused row. */ + inset: 0; + background: linear-gradient(to right, transparent 0%, var(--d1) 100%); + -webkit-mask-image: linear-gradient(to right, transparent 0%, #000 58%); + mask-image: linear-gradient(to right, transparent 0%, #000 58%); + will-change: opacity; +} + +/* Same split as the popup's: the blur is switched on in one frame, never faded. It sits beside the + dropdown rather than inside it, because that container fades as a whole. */ +.settings-options-blur { + position: absolute; + /* Same narrowing as .popup-blur — half the pixels to sample, identical result on screen. */ + left: 35%; + right: 0; + top: 0; + bottom: 0; + backdrop-filter: blur(12px); + -webkit-mask-image: linear-gradient(to right, transparent 0%, #000 36%); + mask-image: linear-gradient(to right, transparent 0%, #000 36%); + opacity: 0; + pointer-events: none; + will-change: transform; /* same layer pinning as .popup-blur */ +} + +.settings.is-options-open .settings-options-blur { + opacity: 1; +} + +/* The column mirrors .popup-column: 550 wide, inset 50, right-aligned, stack pinned to the bottom — + and it scrolls exactly like the settings list behind it, edge fades included (sizes from JS). */ +.settings-options-list { + position: absolute; + right: calc(50 * var(--px)); + top: calc(50 * var(--px)); + bottom: calc(50 * var(--px)); + width: calc(550 * var(--px)); + display: flex; + flex-direction: column; + align-items: flex-end; + gap: calc(8 * var(--px)); + overflow-y: auto; + scrollbar-width: none; + transform: translateX(calc(40 * var(--px))); + transition: transform 0.35s ease; + will-change: transform; + --fade-top: 0px; + --fade-bottom: 0px; + -webkit-mask-image: linear-gradient( + to bottom, + transparent 0, + #000 var(--fade-top), + #000 calc(100% - var(--fade-bottom)), + transparent 100% + ); + mask-image: linear-gradient( + to bottom, + transparent 0, + #000 var(--fade-top), + #000 calc(100% - var(--fade-bottom)), + transparent 100% + ); +} + +/* Bottom-pinned via an auto margin on the FIRST option, not `justify-content: flex-end`: that keeps a + list longer than the column scrollable to its start, where flex-end would push the overflow out of + reach above the scroll origin. With enough options the margin collapses to zero on its own. */ +.settings-options-list > .settings-option:first-child { + margin-top: auto; +} + +.settings-options-list::-webkit-scrollbar { + display: none; +} + +.settings-options.is-open .settings-options-list { + transform: none; +} + +/* …and its options arrive behind it exactly as the popup stack's items do: the column slides in as a + whole, the entries follow one after another, BOTTOM-UP. Same reasoning as there — this list is pinned + to the bottom and opens with the bottom entry focused, so the item the user is already looking at is + the one that arrives first. Both dropdowns (Settings and Customize) share these classes, so this is + one rule for both. The buttons are rebuilt on every level of a nested menu, which is what replays it + when you step into a submenu. */ +.settings-options.is-open .settings-option { + animation: popup-item-in 0.3s cubic-bezier(0.2, 0, 0.2, 1) both; + animation-delay: calc(0.12s + var(--pop, 0) * 45ms); +} +.settings-options.is-open .settings-option:nth-last-child(2) { + --pop: 1; +} +.settings-options.is-open .settings-option:nth-last-child(3) { + --pop: 2; +} +.settings-options.is-open .settings-option:nth-last-child(4) { + --pop: 3; +} +.settings-options.is-open .settings-option:nth-last-child(5) { + --pop: 4; +} +.settings-options.is-open .settings-option:nth-last-child(6) { + --pop: 5; +} +.settings-options.is-open .settings-option:nth-last-child(n + 7) { + --pop: 6; +} + +/* An option IS a .text-button, down to the focus inversion — only the alignment differs (a long label + grows left, like every popup stack item). */ +.settings-option { + height: calc(92 * var(--px)); + flex: 0 0 auto; + min-width: calc(184 * var(--px)); + max-width: 100%; + display: flex; + align-items: center; + justify-content: flex-end; + padding: 0 calc(32 * var(--px)); + border: none; + border-radius: calc(10 * var(--px)); + background: transparent; + color: var(--d2); + font-family: inherit; + font-weight: 500; + font-size: calc(28 * var(--px)); + cursor: pointer; + transition: transform 0.12s ease; +} + +/* The label's window + a moving inner span — the same mechanism the 0.6 "Select game" picker used for + long titles (.game-label / .game-label-inner), brought back here. A label too long for the column is + hard-clipped with a soft fade at the cut, and only the FOCUSED option scrolls, so the list doesn't + crawl. No ellipsis anywhere: the bundled font draws it as three mid-height dots. */ +.settings-option-clip { + /* `0 1 auto` + min-width:0 — sized by the text, but allowed to shrink once the button hits its cap; + that shrink is what produces the clip. */ + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + white-space: nowrap; + --fade: calc(8 * var(--px)); +} + +/* Clipped but not scrolling: an overflowing label is laid out from its start, so the cut is on the + RIGHT — that edge fades, exactly as the 0.6 picker did it. */ +.settings-option.is-clipped .settings-option-clip { + -webkit-mask-image: linear-gradient(to right, #000 calc(100% - var(--fade)), transparent 100%); + mask-image: linear-gradient(to right, #000 calc(100% - var(--fade)), transparent 100%); +} + +/* While it scrolls, both edges fade so the text enters and leaves softly on either side. */ +.settings-option.is-scrolling .settings-option-clip { + -webkit-mask-image: linear-gradient( + to right, + transparent 0, + #000 var(--fade), + #000 calc(100% - var(--fade)), + transparent 100% + ); + mask-image: linear-gradient( + to right, + transparent 0, + #000 var(--fade), + #000 calc(100% - var(--fade)), + transparent 100% + ); +} + +.settings-option-text { + display: inline-block; + white-space: nowrap; + will-change: transform; +} + +/* Ping-pong marquee: shift + per-label duration come from JS (measured overflow at a constant speed); + `alternate` runs to the end and back, `ease-in-out` dwells at each edge so both ends are readable. */ +.settings-option.is-scrolling .settings-option-text { + animation: option-marquee var(--marquee-duration, 6s) ease-in-out infinite alternate; +} + +@keyframes option-marquee { + from { + transform: translateX(0); + } + to { + transform: translateX(var(--marquee-shift, 0)); + } +} + +html:not(.mouse-asleep) .settings-option:hover, +.settings-option.is-focused { + background: var(--d2); + color: var(--d1); +} + +.settings-option.is-pressed { + transform: scale(0.9); +} + +/* The current value is marked even when the focus is elsewhere in the list. The rule sits on the TEXT, + not on the button: text-decoration does not propagate into an inline-block descendant, and the label + became one when it was wrapped for the marquee — which is how the underline went missing. */ +.settings-option.is-current .settings-option-text { + text-decoration: underline; + text-underline-offset: calc(8 * var(--px)); +} + +/* With ANY overlay open the bar leaves downwards (where it came from) and the strip fades out. The strip + is faded ONLY — it owns transform transitions of its own, and joining in risks desyncing its position. */ +#app[data-overlay] #bottom-bar { + opacity: 0; + pointer-events: none; + transform: translateY(calc(20 * var(--px))); + transition: opacity 0.3s ease, transform 0.3s ease; +} + +/* The STRIP is what fades, not its #carousel container: the container's opacity transition belongs to + the card morph, which pins it to `0s linear` at a higher specificity than anything written here (see + the [data-card-morph] rules above) — hiding it through that made the carousel snap back into place on + the way out, as if it had been display:none. The strip's own `opacity 0.25s` is untouched by the morph + and runs in BOTH directions, which is exactly what the return needs. */ +#app[data-overlay] #carousel-strip { + opacity: 0; +} + +/* …and for the LIBRARY it goes at once, with no fade of its own. That screen is the one you come back + INTO from a detail screen: it fades in over 0.35s, and the row spends those same milliseconds sliding + onto the game just left and re-fanning its cards — all of it in plain sight through a veil that is + still half transparent. Cut instead, the row re-arranges in private. Only the way IN is instant: on + the way out the transition is back, so closing the screen brings the row up as softly as ever. */ +#app[data-overlay='library'] #carousel-strip { + transition: none; +} + +#app[data-overlay] #carousel { + pointer-events: none; +} + +/* ── Customize screen: the row kinds Settings does not have ───────────────── + The skeleton (.settings-*, .setting-row and its focus inversion) is shared verbatim; only the controls + below are new. They all follow the same rule as the originals: everything is drawn in currentColor, so + the row's focus inversion carries them with it and no control needs a second set of rules. */ + +/* A value that is a PATH or a sentence, not a word: it takes the right half of the row and wraps rather + than being clipped — a path you cannot read is a path you cannot check. */ +.setting-value-wide { + flex: 0 1 auto; + min-width: 0; + max-width: 60%; + text-align: right; + overflow-wrap: anywhere; + font-weight: 400; + font-size: calc(24 * var(--px)); + line-height: calc(32 * var(--px)); +} + +/* An unset value is present but quiet — the row still reads as a row, and the placeholder says what + would happen if it stays empty ("cropped from the first background"). */ +.setting-value.is-empty { + opacity: 0.5; + font-style: italic; +} + +/* The field's own validation problem, under its label. It is a per-game form of thirty fields: a list of + messages at the bottom would name paths the user cannot point at. + + NO fixed red anywhere below. The palette here is computed from the game's own hero art (--d1/--d2), so + a hard-coded accent is a colour that will sooner or later land on a background it cannot be read + against — including a red one. Emphasis is carried by WEIGHT and by an inset bar in currentColor + instead, which is legible in every palette by construction. */ +.setting-error { + font-weight: 500; + font-size: calc(20 * var(--px)); + line-height: calc(28 * var(--px)); + color: inherit; +} + +/* A marker on the row itself, so a problem scrolled past is still findable — currentColor, so it + survives the focus inversion along with everything else in the row. */ +.setting-row.has-error { + box-shadow: inset calc(4 * var(--px)) 0 0 currentColor; +} + +/* A forced control (install.runAsAdmin under a custom installer) and an action that cannot run yet + (Save with nothing to save) are SHOWN, not hidden: hiding them hides the reason too. */ +.setting-row.is-disabled { + opacity: 0.4; +} + +.setting-row.is-disabled .text-button { + cursor: default; +} + +/* Artwork thumbnails, on the row itself: a hero list is unreadable as three file names. */ +.setting-thumbs { + display: flex; + align-items: center; + gap: calc(8 * var(--px)); + flex: 0 0 auto; +} + +/* 16:9 for a hero background, 2:3 for the carousel card (a 600x900 portrait) — the artwork's own shape, + so a cover squeezed into a landscape box can't read as "that's how it will look". */ +.setting-thumb { + height: calc(54 * var(--px)); + width: calc(96 * var(--px)); + object-fit: cover; + border-radius: calc(6 * var(--px)); + cursor: pointer; +} + +.setting-thumbs.is-portrait .setting-thumb { + height: calc(72 * var(--px)); + width: calc(48 * var(--px)); +} + +/* A note is text inside the list, not a control — no focus, no inversion, and a tone that says how much + it matters (the mixed-modes banner, the id-change warning, a neighbour's error). */ +/* A note is text inside the list, and its three tones are told apart WITHOUT a colour of their own: + info is a quiet tinted block, warning gains an outline, error inverts to the accent — the same + inversion a focused row uses, so it is the loudest thing on screen in any palette. */ +.setting-row-note { + display: block; + min-height: 0; + padding: calc(12 * var(--px)) calc(32 * var(--px)); + border: calc(2 * var(--px)) solid transparent; + border-radius: calc(10 * var(--px)); + background: color-mix(in srgb, var(--d2) 12%, transparent); +} + +.setting-row-note .setting-note-text { + font-weight: 400; + font-size: calc(22 * var(--px)); + line-height: calc(30 * var(--px)); + color: var(--d2); +} + +.setting-row-note.is-warning { + border-color: var(--d2); + background: transparent; +} + +.setting-row-note.is-warning .setting-note-text { + font-weight: 500; +} + +.setting-row-note.is-error { + background: var(--d2); +} + +.setting-row-note.is-error .setting-note-text { + color: var(--d1); + font-weight: 500; +} + +/* An inert row (a static line, a note) must never light up under the mouse — it cannot be activated. */ +.setting-row.is-inert { + pointer-events: none; +} + +html:not(.mouse-asleep) .setting-row.is-inert:hover { + background: transparent; + color: var(--d2); +} + +/* ── On-screen keyboard ───────────────────────────────────────────────────── + A surface INSIDE the Customize screen (see index.html), so it inherits the screen's own visibility and + only needs its own fade. It is the only way to type on a gamepad: the launcher has no <input> anywhere, + and the Deck's system keyboard is out of reach outside Steam-launched games. */ + +/* The keyboard is a surface of its own, not part of a screen: both Customize and Settings open it (see + the note on #osk in index.html). Hence z-index 2 — level with `.settings`, and above it by DOM order — + while the popup's 3 still paints over everything. */ +.osk { + position: absolute; + inset: 0; + opacity: 0; + pointer-events: none; + transition: opacity 0.25s ease; + z-index: 2; +} + +.osk.is-open { + opacity: 1; + pointer-events: auto; +} + +/* Fully opaque, not a tint: the form behind the keyboard is unreadable at 12% anyway, and showing it + through only competes with the keys for attention. */ +.osk-veil { + position: absolute; + inset: 0; + background: var(--d1); +} + +/* The panel comes UP from the edge it is anchored to and leaves the same way — the movement a keyboard + makes. A transition off the open state, not an animation: the panel itself is never rebuilt (only the + keys inside it are), so there is nothing here for a rebuild to replay. The X half of the transform is + the centring and must be repeated in both states, or the panel would slide in from the left as well. */ +.osk-panel { + position: absolute; + left: 50%; + bottom: calc(60 * var(--px)); + transform: translateX(-50%) translateY(calc(28 * var(--px))); + width: calc(1100 * var(--px)); + max-width: calc(100% - 80 * var(--px)); + display: flex; + flex-direction: column; + gap: calc(16 * var(--px)); + transition: transform 0.28s cubic-bezier(0.2, 0, 0.2, 1); +} + +.osk.is-open .osk-panel { + transform: translateX(-50%); +} + +/* …and the rows arrive behind it, in the order they are read. Armed by the keyboard (osk.ts) on open — + see the note there for why this cannot key off .is-open like the popup's items do, and entrance.ts for + why the class lands on the row rather than on the keyboard around it. */ +.osk-row.is-entering { + animation: popup-item-in 0.26s cubic-bezier(0.2, 0, 0.2, 1) both; + animation-delay: calc(0.08s + var(--osk-row, 0) * 35ms); +} + +.osk-title { + font-weight: 500; + font-size: calc(26 * var(--px)); + color: color-mix(in srgb, var(--d2) 70%, transparent); +} + +/* The value being edited. Not an <input>: the UI forbids a caret globally (and a real one would let the + OS focus wander), so the caret is drawn — one element, blinking, wherever the caret currently is. + Clickable: pressing anywhere in the text puts the caret there, which is why the pointer is a text one + here and nowhere else in this launcher. */ +.osk-field { + display: flex; + align-items: center; + min-height: calc(72 * var(--px)); + padding: calc(8 * var(--px)) calc(24 * var(--px)); + border-radius: calc(10 * var(--px)); + background: color-mix(in srgb, var(--d2) 14%, transparent); + color: var(--d2); + font-weight: 500; + font-size: calc(30 * var(--px)); + overflow-wrap: anywhere; + /* Spaces are rendered EXACTLY as they are stored. HTML normally collapses a run of them into one and + drops them at the end of a line entirely, so pressing space five times drew one space and stored + five — and the only way to notice was that it then took five presses of Backspace to clear. The + `break-spaces` flavour (rather than pre-wrap) also keeps trailing spaces from hanging past the edge, + which is what puts the caret in the right place after them. */ + white-space: break-spaces; + cursor: text; +} + +/* The two halves and the caret between them, as ONE inline box: a flex row would lay the halves out as + two independent items and break a long value across them at the caret rather than where it fits. */ +.osk-line { + cursor: text; +} + +.osk-caret { + display: inline-block; + width: calc(3 * var(--px)); + height: calc(34 * var(--px)); + /* Zero-width in the flow: the caret sits BETWEEN two characters, so it must not push the text it + divides apart — a margin here would make the value jump every time the caret moved through it. */ + margin: 0 calc(-1.5 * var(--px)); + vertical-align: text-bottom; + background: currentColor; + animation: osk-caret 1s steps(1) infinite; +} + +@keyframes osk-caret { + 0%, + 50% { + opacity: 1; + } + 50.01%, + 100% { + opacity: 0; + } +} + +.osk-keys { + display: flex; + flex-direction: column; + gap: calc(8 * var(--px)); +} + +.osk-row { + display: flex; + gap: calc(8 * var(--px)); + justify-content: center; +} + +.osk-key { + flex: 0 0 auto; + min-width: calc(76 * var(--px)); + height: calc(76 * var(--px)); + display: flex; + align-items: center; + justify-content: center; + padding: 0 calc(16 * var(--px)); + border: none; + border-radius: calc(10 * var(--px)); + background: color-mix(in srgb, var(--d2) 14%, transparent); + color: var(--d2); + font-family: inherit; + font-weight: 500; + font-size: calc(30 * var(--px)); + cursor: pointer; + transition: transform 0.12s ease; +} + +.osk-key.is-wide { + min-width: calc(168 * var(--px)); + font-size: calc(24 * var(--px)); +} + +html:not(.mouse-asleep) .osk-key:hover, +.osk-key.is-focused { + background: var(--d2); + color: var(--d1); +} + +.osk-key.is-pressed { + transform: scale(0.92); +} + +.osk-key.is-active { + outline: calc(2 * var(--px)) solid var(--d2); + outline-offset: calc(-2 * var(--px)); +} + +.osk-legend, +.picker-legend { + font-weight: 300; + font-size: calc(20 * var(--px)); + color: color-mix(in srgb, var(--d2) 55%, transparent); + text-align: center; +} + +/* ── File browser ─────────────────────────────────────────────────────────── + Read-only, and unrestricted on purpose: where to browse is the user's business (the commonest install + path there is, `…/steamapps/common`, is a system directory by any definition). What is guarded is what + main ACCEPTS back — see the plan, Р5.2. */ + +.picker { + position: absolute; + inset: 0; + opacity: 0; + pointer-events: none; + /* Same reason as `.settings`: a closed picker must cost no tiles. This one matters most — the online + picker holds a grid of downloaded artwork, and left composited it kept that grid rastered long after + the user walked away from it. */ + visibility: hidden; + content-visibility: hidden; + transition: + opacity 0.25s ease, + visibility 0.25s ease, + content-visibility 0.25s allow-discrete; + z-index: 1; +} + +.picker.is-open { + opacity: 1; + pointer-events: auto; + visibility: visible; + content-visibility: visible; +} + +.picker-veil { + position: absolute; + inset: 0; + background: var(--d1); +} + +.picker-panel { + position: absolute; + inset: calc(60 * var(--px)); + display: flex; + flex-direction: column; + gap: calc(12 * var(--px)); +} + +.picker-title { + font-weight: 800; + font-size: calc(36 * var(--px)); + color: var(--d2); +} + +.picker-path { + font-weight: 300; + font-size: calc(22 * var(--px)); + color: color-mix(in srgb, var(--d2) 65%, transparent); + overflow-wrap: anywhere; +} + +.picker-body { + flex: 1 1 auto; + min-height: 0; + display: flex; + gap: calc(24 * var(--px)); +} + +.picker-roots { + flex: 0 0 calc(360 * var(--px)); + overflow-y: auto; + scrollbar-width: none; + display: flex; + flex-direction: column; + gap: calc(4 * var(--px)); +} + +.picker-entries { + flex: 1 1 auto; + min-width: 0; + overflow-y: auto; + scrollbar-width: none; + display: flex; + flex-direction: column; + gap: calc(4 * var(--px)); + --fade-top: 0px; + --fade-bottom: 0px; + -webkit-mask-image: linear-gradient( + to bottom, + transparent 0, + #000 var(--fade-top), + #000 calc(100% - var(--fade-bottom)), + transparent 100% + ); + mask-image: linear-gradient( + to bottom, + transparent 0, + #000 var(--fade-top), + #000 calc(100% - var(--fade-bottom)), + transparent 100% + ); +} + +.picker-roots::-webkit-scrollbar, +.picker-entries::-webkit-scrollbar { + display: none; +} + +.picker-item { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: calc(12 * var(--px)); + min-height: calc(58 * var(--px)); + padding: 0 calc(20 * var(--px)); + border: none; + border-radius: calc(8 * var(--px)); + background: transparent; + color: var(--d2); + font-family: inherit; + font-weight: 400; + font-size: calc(26 * var(--px)); + text-align: left; + cursor: pointer; +} + +html:not(.mouse-asleep) .picker-item:hover, +.picker-item.is-focused { + background: var(--d2); + color: var(--d1); +} + +/* A folder reads differently from a file at a glance — the browser is used at arm's length on a Deck. + The ACTION rows (cancel, up, use this folder) deliberately carry no glyph: they are not part of the + tree, and a marker would put them in it. */ +.picker-item.is-dir::before { + content: '/'; + opacity: 0.6; +} + +.picker-item.is-action { + font-weight: 500; +} + +/* …and a rule of its own between them and the tree. A separate element, not a border on the last + button: a bottom border there squares one corner of a rounded, invertible button. */ +.picker-divider { + flex: 0 0 auto; + height: calc(2 * var(--px)); + margin: calc(8 * var(--px)) calc(20 * var(--px)); + background: color-mix(in srgb, var(--d2) 25%, transparent); +} + +.picker-item.is-picked { + text-decoration: underline; + text-underline-offset: calc(6 * var(--px)); +} + +.picker-empty { + padding: calc(20 * var(--px)); + font-weight: 300; + font-size: calc(24 * var(--px)); + color: color-mix(in srgb, var(--d2) 55%, transparent); +} + +/* ── Online artwork gallery ───────────────────────────────────────────────── + The "Find online" picker: the same panel as the file browser, with a grid of pictures where that one + has a list of names. `auto-fill` decides the column count, and the navigation reads it back off the + layout rather than computing a second answer (see metadata-picker.ts). */ + +/* The right column of "Find online". It holds a LIST (candidates, tracks) or a GRID (pictures), so it + is a column by default and becomes a grid when the open section is about pictures — one scroller and + one focus model either way, which is what lets the sections share a surface. */ +.online-content { + padding: calc(4 * var(--px)); +} + +.online-content.is-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(calc(200 * var(--px)), 1fr)); + gap: calc(24 * var(--px)); + align-content: start; +} + +.metadata-tile { + display: flex; + flex-direction: column; + gap: calc(8 * var(--px)); + padding: calc(8 * var(--px)); + border: none; + border-radius: calc(12 * var(--px)); + background: transparent; + color: var(--d2); + font-family: inherit; + cursor: pointer; +} + +html:not(.mouse-asleep) .metadata-tile:hover, +.metadata-tile.is-focused { + background: var(--d2); + color: var(--d1); +} + +/* A cover is a portrait 2:3, so its tile is one and CROPS to it: every source serves that exact shape. + A background is whatever the source had — a store backdrop, a 1920x1080 screenshot, or a 4:3 shot from + an old game — and the tile must not pretend otherwise, so the box keeps a steady 16:9 for the grid's + sake while the picture is FITTED inside it. Cropping there would hide exactly the edges the user is + choosing between. */ +.metadata-tile-image { + width: 100%; + aspect-ratio: 2 / 3; + object-fit: cover; + border-radius: calc(8 * var(--px)); + background: color-mix(in srgb, var(--d2) 12%, transparent); +} + +.metadata-tile[data-kind='hero'] .metadata-tile-image { + aspect-ratio: 16 / 9; + object-fit: contain; +} + +.metadata-tile-caption { + font-weight: 300; + font-size: calc(20 * var(--px)); + text-align: center; + overflow-wrap: anywhere; +} + +/* The gallery's sidebar — the file browser's roots column in another use: the two filters, then the + actions. Same width, so the two pickers line up when one opens over the other. */ +.metadata-side { + flex: 0 0 calc(360 * var(--px)); + min-height: 0; + overflow-y: auto; + scrollbar-width: none; + display: flex; + flex-direction: column; + gap: calc(4 * var(--px)); + /* Long enough to scroll — a game can have twenty soundtracks — so it carries the same edge fades the + file browser's columns do, driven by the scroller's --fade-top / --fade-bottom. */ + --fade-top: 0px; + --fade-bottom: 0px; + -webkit-mask-image: linear-gradient( + to bottom, + transparent 0, + #000 var(--fade-top), + #000 calc(100% - var(--fade-bottom)), + transparent 100% + ); + mask-image: linear-gradient( + to bottom, + transparent 0, + #000 var(--fade-top), + #000 calc(100% - var(--fade-bottom)), + transparent 100% + ); +} + +.metadata-side::-webkit-scrollbar { + display: none; +} + +.metadata-side-heading { + flex: 0 0 auto; + padding: calc(16 * var(--px)) calc(20 * var(--px)) calc(6 * var(--px)); + font-weight: 500; + font-size: calc(22 * var(--px)); + color: color-mix(in srgb, var(--d2) 55%, transparent); +} + +/* An action with nothing to act on — Apply before anything is ticked. Still focusable, so the user can + see it is there and hear why it refuses. */ +.metadata-side .picker-item.is-disabled { + opacity: 0.45; +} + +/* Ticked (multi-select backgrounds) — the same underline the file browser marks a ticked file with. */ +/* The trailing "load more" tile: the same box a thumbnail occupies, so the grid keeps its rhythm and the + tile is reached by the same press as the picture before it. A frame rather than a picture, because + there is nothing to show yet. */ +.metadata-tile-more-box { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + aspect-ratio: 2 / 3; + border: calc(2 * var(--px)) dashed color-mix(in srgb, var(--d2) 45%, transparent); + border-radius: calc(8 * var(--px)); + font-size: calc(48 * var(--px)); + font-weight: 300; + line-height: 1; +} + +.metadata-tile-more[data-kind='hero'] .metadata-tile-more-box { + aspect-ratio: 16 / 9; +} + +html:not(.mouse-asleep) .metadata-tile-more:hover .metadata-tile-more-box, +.metadata-tile-more.is-focused .metadata-tile-more-box { + border-color: var(--d1); +} + +/* Waiting for a page: the same tile, spinning where the pictures will appear. The border goes solid so + the frame reads as "busy" rather than "press me", and the arc reuses the play button's spin. */ +.metadata-tile-more.is-busy .metadata-tile-more-box { + border-style: solid; + border-color: color-mix(in srgb, var(--d2) 25%, transparent); +} + +.metadata-tile-spinner { + width: calc(44 * var(--px)); + height: calc(44 * var(--px)); + border: calc(4 * var(--px)) solid color-mix(in srgb, currentColor 30%, transparent); + border-top-color: currentColor; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.metadata-tile.is-picked .metadata-tile-caption { + text-decoration: underline; + text-underline-offset: calc(6 * var(--px)); + font-weight: 500; +} + +/* ── Soundtrack list ──────────────────────────────────────────────────────── + The gallery's right column, for tracks: names rather than pictures, so the file browser's rows are the + shape it takes. The claimed size sits at the row's end — a track is a download, and how long it will + take is the thing a name cannot say. */ + +.music-row { + justify-content: space-between; + gap: calc(20 * var(--px)); +} + +/* A track's name wraps rather than being cut: an ellipsis here would be the app's font drawing raised + dots (see CLAUDE.md), and a name cut short is exactly the part that tells two takes of one theme + apart. */ +.music-row-title { + overflow-wrap: anywhere; +} + +.music-row-size { + flex: 0 0 auto; + font-weight: 300; + opacity: 0.7; +} + +/* Waiting for albums or for an album's tracks — the gallery's spinning tile, laid out as a row. */ +.music-busy { + display: flex; + align-items: center; + gap: calc(16 * var(--px)); + padding: calc(20 * var(--px)); + font-weight: 300; + font-size: calc(24 * var(--px)); + color: color-mix(in srgb, var(--d2) 55%, transparent); +} + +/* ── Artwork lightbox ─────────────────────────────────────────────────────── + Inside the Customize screen, above the keyboard and the picker: the artwork at full size, which is the + only way to answer "is this the right picture?" from a 96px thumbnail. */ + +.lightbox { + position: absolute; + inset: 0; + z-index: 2; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: calc(16 * var(--px)); + opacity: 0; + pointer-events: none; + transition: opacity 0.25s ease; +} + +.lightbox.is-open { + opacity: 1; + pointer-events: auto; +} + +.lightbox-veil { + position: absolute; + inset: 0; + background: var(--d1); +} + +/* `contain`, never `cover`: the whole point is to see the picture as it IS, borders and all. */ +.lightbox-image { + position: relative; + max-width: calc(100% - 120 * var(--px)); + max-height: calc(100% - 200 * var(--px)); + object-fit: contain; + border-radius: calc(10 * var(--px)); +} + +.lightbox-caption { + position: relative; + max-width: calc(100% - 120 * var(--px)); + font-weight: 300; + font-size: calc(22 * var(--px)); + color: color-mix(in srgb, var(--d2) 65%, transparent); + text-align: center; + overflow-wrap: anywhere; +} + +/* ── Boot ────────────────────────────────────────────────────────────────── + The first second belongs to the background alone. Everything the launcher paints from data — the + bar (Play / title / status / More) and the carousel strip — arrives at once, after the state, the + hero and its palette have landed, so the UI is never seen assembling itself or changing colour under + the user's eyes. The attribute ships in index.html (not added from JS) so there is no frame of a + fully-drawn UI before the script runs; app.ts removes it. Its VALUE ('loading' then 'panning') only + drives the startup push on #hero-boot — everything hidden here keys off the attribute's presence. + Fading the STRIP rather than #carousel is the same rule the settings overlay follows — the container's + own transition belongs to the card morph. */ + +#app[data-boot] #bottom-bar { + opacity: 0; + transform: translateY(calc(20 * var(--px))); +} + +/* The CARDS wait, not the strip as a whole: they are built and laid out behind the boot screen, and + holding them at zero here is what leaves their staggered entrance (carousel.playIntro) still to play + when the wallpaper hands over. Fading the strip instead would show the row already assembled. + The focus body needs saying separately — it is not a .card but a canvas beside them, so the rule above + left it alone and it sat on the boot wallpaper with no cover to belong to. It comes in with the row: + the intro rule up by #carousel-jelly holds it back until the wave has passed. */ +#app[data-boot] .card, +#app[data-boot] #carousel-jelly { + opacity: 0; +} + +/* …and none of it is pressable while it is invisible: opacity 0 hides a button, it does not disarm it, + so a click landed on a menu nobody could see. The cards need a rule of their own — their + `pointer-events: auto` (see the carousel block) would otherwise undo the `none` inherited from #app. + The pad and the keyboard are fenced off in controls.ts (whileAwake), which this mirrors. */ +#app[data-boot], +#app[data-boot] .card { + pointer-events: none; +} + + +/* ── Library ──────────────────────────────────────────────────────────────── + The grid of covers. It reuses the #settings skeleton (veil, column, sidebar) and differs in two + things: the pane runs to the RIGHT EDGE of the screen, so the gap after the sidebar and the gap + before the edge come out equal (justify-content: center splits what is left over evenly), and the + pane holds .card nodes rather than rows. + + EVERY rule here is prefixed with `#app #library` — two ids against the one that any carousel rule + carries. The cards share the .card class with the strip, and specificity is what keeps the strip's + sizes, its detail-screen fade and its flip transition off them, deterministically rather than by + the order the rules happen to sit in the file. Not one line of the carousel's CSS is touched. */ + +#library .settings-column { + right: 0; +} + +/* Handing over to the detail screen and coming back is NOT a fade: for the 0.35s a fade takes, the + carousel underneath is visible through the veil rebuilding itself — the strip fanning back in, the + title swapping — which reads as the launcher glitching rather than as a screen changing. The class is + put on for exactly one frame around the swap (library-screen.ts). */ +#library.is-instant, +#library.is-instant .settings-veil, +#library.is-instant .settings-column { + transition: none; +} + +/* 0, not the 40 of #settings: the sidebar's own 380 already carries the gap, and the pane's padding + adds the rest — see the padding note below. */ +#library .settings-body { + gap: 0; +} + +.library-pane { + position: relative; + flex: 1 1 auto; + min-height: 0; + display: flex; +} + +/* The padding is not decoration: the selected card scales to 1.06 and the focus body stands 8 design px + off it and breathes a few more, which puts its corner ~22 design px outside the card's own box. The + scroller clips on both axes — but the body is drawn on a canvas OUTSIDE it (see #library-jelly), so + what this padding now protects is the card's own scale, and it keeps the body's ends off the pane's + edges. 44 leaves room for a decoration that reaches further, and costs nothing: even on a 16:10 screen + the column count is the same as it would be at 22. */ +.library-scroll { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: none; + /* Top is its own value, smaller than the other three sides: .settings-nav has NO top padding of its + own (its first item's centered content sits close to the very top of the column), so the full 44 + up here read as the grid starting visibly lower than the sidebar. ~20 is the least this can be + without the first row's selected card reaching the pane's edge — see the note below. */ + padding: calc(20 * var(--px)) calc(44 * var(--px)) calc(44 * var(--px)); + /* Same edge treatment as .settings-list: fades driven from JS, none where there is nothing beyond. */ + --fade-top: 0px; + --fade-bottom: 0px; + -webkit-mask-image: linear-gradient( + to bottom, + transparent 0, + #000 var(--fade-top), + #000 calc(100% - var(--fade-bottom)), + transparent 100% + ); + mask-image: linear-gradient( + to bottom, + transparent 0, + #000 var(--fade-top), + #000 calc(100% - var(--fade-bottom)), + transparent 100% + ); +} + +.library-scroll::-webkit-scrollbar { + display: none; +} + +/* --cols is measured in JS (library-screen.ts) rather than left to flex-wrap, because up/down are a + ±cols step and the maths has to agree with the layout. The fallback of 1 is deliberate: a fallback + of 6 would overflow the container on a 16:10 screen for the frame before the first measurement. */ +/* Positioned, because a card LEAVING the section is pulled out of the grid's flow and pinned where it + stood while it fades (see .is-leaving) — the cards behind it close the gap immediately instead of the + whole section blinking out. */ +.library-grid { + position: relative; + display: grid; + justify-content: center; + align-content: start; + /* MIRRORS LIB_GAP in library-grid.ts — the column count is worked out there from this same number. */ + gap: calc(24 * var(--px)); + grid-template-columns: repeat(var(--cols, 1), calc(200 * var(--px))); +} + +.library-empty { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 0 calc(60 * var(--px)); + text-align: center; + font-weight: 500; + font-size: calc(28 * var(--px)); + line-height: calc(38 * var(--px)); + color: color-mix(in srgb, var(--fg) 70%, transparent); + pointer-events: none; + opacity: 1; + transition: opacity 0.2s ease; +} + +.library-empty[aria-hidden='true'] { + opacity: 0; +} + +#app #library .library-grid .card { + width: calc(200 * var(--px)); + height: calc(300 * var(--px)); + /* Pinned against #app[data-screen='detail'] .card: opening a game switches the screen at once while + this overlay takes 0.35s to fade, so without it every cover would blink to zero on the first frame + of the way out. */ + opacity: 1; + /* The scale lives in a VARIABLE because the FLIP that reorders the grid writes an inline transform: + it composes translate() with scale(var(--card-scale)), and a literal here would be wiped by it, + collapsing the selected card for the length of the animation. */ + --card-scale: 1; + transform: scale(var(--card-scale)); + /* Pinned, and NOT decoration: the strip's shrink-into-the-corner rule + (#app[data-screen='detail'][data-card-morph='off'] .card.is-selected) sets `bottom left`, and it + reaches this card too — the id count keeps its width/height off, but transform-origin is not + declared here, so it would be inherited from there and the 1.06 scale would jump upwards the moment + a game without a Play button was opened. */ + transform-origin: center; + transition: transform var(--morph) var(--morph-ease), opacity var(--morph) ease; +} + +/* A card arriving with its section, and one leaving it: the same movement played each way, so a switch + reads as the grid re-forming rather than as covers blinking in and out. Animations, not transitions — + the arrival is staggered by the card's place in the list (--card-index, written by library-screen.ts), + which is the shape every other list in this launcher arrives in (see .setting-row.is-entering). */ +/* `backwards`, not `both`: the fill has to cover the card's WAIT for its turn in the wave, and nothing + after. A forwards fill keeps the animation owning `transform` once it is over, and for as long as the + mark is on the card that means selecting it snaps instead of growing — the transition below never gets + a say. */ +#app #library .library-grid .card.is-entering { + animation: library-card-in 0.26s cubic-bezier(0.2, 0, 0.2, 1) backwards; + animation-delay: calc(var(--card-index, 0) * 24ms); +} + +#app #library .library-grid .card.is-leaving { + pointer-events: none; + z-index: 0; + animation: library-card-out 0.22s cubic-bezier(0.4, 0, 1, 1) both; +} + +@keyframes library-card-in { + from { + opacity: 0; + transform: scale(0.9) translateY(calc(14 * var(--px))); + } + to { + opacity: 1; + transform: scale(var(--card-scale, 1)); + } +} + +@keyframes library-card-out { + from { + opacity: 1; + transform: scale(1); + } + to { + opacity: 0; + transform: scale(0.9); + } +} + +/* Grown in place: the grid's geometry does not depend on which card is selected, so the neighbours must + not shift. z-index lifts it over them, since it now overlaps. */ +#app #library .library-grid .card.is-selected { + --card-scale: 1.06; + z-index: 1; +} + +/* A held direction: the same trick the strip uses — a step exactly as long as the repeat interval, in + linear, which glues the steps into one continuous glide instead of 143 ms of ease each. */ +#app[data-flipping] #library .library-grid .card { + transition: transform var(--flip-step) linear; +} + +/* The grid's focus body. Unlike the strip's, its canvas is NOT inside the scrolling content: a library + of hundreds would make a canvas thousands of pixels tall, and its backing store is width x height x 4 + x dpr² bytes — tens of megabytes on a handheld. This one is the size of the pane, and library-screen.ts + folds the scroll offset into the coordinates it draws at. + Positioned over the pane but under the cards: .library-scroll takes a z-index of its own for this. */ +#library-jelly { + position: absolute; + left: 0; + top: 0; + pointer-events: none; + z-index: 0; + opacity: 1; + transition: opacity 0.2s ease; +} + +/* Nothing to wrap: the focus is in the column, or the section is empty. Faded rather than moved — the + body keeps its place, so the way back in is a fade rather than a jump from wherever it last stood. */ +#library-jelly.is-hidden { + opacity: 0; +} + +/* …and the instant frame kills even that: a hand-over to the detail screen and back is a CUT. In one + row with the rule at the head of this section. */ +#library.is-instant #library-jelly { + transition: none; +} + +/* The cards must sit ABOVE the body, and z-index only bites on a positioned element — the scroller had + none, so it gets one here. Harmless to the layout: it already clips on both axes. */ +#library .library-scroll { + position: relative; + z-index: 1; +} + +/* The playable dot moves INSIDE the card, to its top-right corner: the grid is dense and there is no + room under a card for the carousel's hanging dot. Only geometry and the ring are set here — opacity + and the busy pulse stay with .card.shows-dot / .card.is-busy.shows-dot. The ring is the background + colour: the dot now lies over arbitrary artwork and would vanish on a bright cover. */ +#app #library .library-grid .card-dot { + left: auto; + bottom: auto; + margin-left: 0; + top: calc(12 * var(--px)); + right: calc(12 * var(--px)); + width: calc(12 * var(--px)); + height: calc(12 * var(--px)); + box-shadow: 0 0 0 calc(2 * var(--px)) color-mix(in srgb, var(--d1) 85%, transparent); +} diff --git a/src/renderer/system-card-icons.ts b/src/renderer/system-card-icons.ts new file mode 100644 index 00000000..14a7dc38 --- /dev/null +++ b/src/renderer/system-card-icons.ts @@ -0,0 +1,64 @@ +// The glyphs of the launcher cards, exported from the mockup and inlined as DOM. Inline SVG built with +// createElementNS, never innerHTML or a file reference: the CSP forbids external resources, and building +// the nodes is the project's rule for markup that isn't in index.html (see checkIcon in row-view-core.ts). +// +// The exported `fill` is dropped deliberately — the card paints its icon with `currentColor`, so the +// glyph follows the live palette the way the rest of the UI does. +import type { SystemCardId } from './system-cards.js'; + +const SVG_NS = 'http://www.w3.org/2000/svg'; + +interface IconShape { + readonly viewBox: string; + readonly paths: readonly string[]; + readonly fillRule?: 'evenodd'; +} + +const GRID_PATHS = [ + 'M11.52 31.625C13.1106 31.625 14.4 32.9122 14.4 34.5V43.125C14.4 44.7128 13.1106 46 11.52 46H2.88C1.28942 46 0 44.7128 0 43.125V34.5C0 32.9122 1.28942 31.625 2.88 31.625H11.52Z', + 'M19.4297 32.3026C19.8694 32.0443 20.4134 32.0375 20.8594 32.2848L29.4994 37.0765C29.9565 37.33 30.24 37.8113 30.24 38.3333C30.24 38.8554 29.9565 39.3367 29.4994 39.5902L20.8594 44.3819C20.4134 44.6292 19.8694 44.6224 19.4297 44.3641C18.9899 44.1058 18.72 43.6343 18.72 43.125V33.5417C18.72 33.0323 18.9899 32.5609 19.4297 32.3026Z', + 'M11.52 15.8125C13.1106 15.8125 14.4 17.0997 14.4 18.6875V27.3125C14.4 28.9003 13.1106 30.1875 11.52 30.1875H2.88C1.28942 30.1875 0 28.9003 0 27.3125V18.6875C0 17.0997 1.28942 15.8125 2.88 15.8125H11.52Z', + 'M28.32 15.8125C29.9106 15.8125 31.2 17.0997 31.2 18.6875V27.3125C31.2 28.9003 29.9106 30.1875 28.32 30.1875H19.68C18.0894 30.1875 16.8 28.9003 16.8 27.3125V18.6875C16.8 17.0997 18.0894 15.8125 19.68 15.8125H28.32Z', + 'M45.12 15.8125C46.7106 15.8125 48 17.0997 48 18.6875V27.3125C48 28.9003 46.7106 30.1875 45.12 30.1875H36.48C34.8894 30.1875 33.6 28.9003 33.6 27.3125V18.6875C33.6 17.0997 34.8894 15.8125 36.48 15.8125H45.12Z', + 'M11.52 0C13.1106 0 14.4 1.28718 14.4 2.875V11.5C14.4 13.0878 13.1106 14.375 11.52 14.375H2.88C1.28942 14.375 0 13.0878 0 11.5V2.875C0 1.28718 1.28942 0 2.88 0H11.52Z', + 'M28.32 0C29.9106 0 31.2 1.28718 31.2 2.875V11.5C31.2 13.0878 29.9106 14.375 28.32 14.375H19.68C18.0894 14.375 16.8 13.0878 16.8 11.5V2.875C16.8 1.28718 18.0894 0 19.68 0H28.32Z', + 'M45.12 0C46.7106 0 48 1.28718 48 2.875V11.5C48 13.0878 46.7106 14.375 45.12 14.375H36.48C34.8894 14.375 33.6 13.0878 33.6 11.5V2.875C33.6 1.28718 34.8894 0 36.48 0H45.12Z', +]; + +const BELL = + 'M3.44085 38.8051H39.5591C41.7236 38.8051 43 37.69 43 36.0513C43 33.7753 40.6984 31.7269 38.7159 29.7013C37.2119 28.1309 36.8017 24.899 36.6423 22.2817C36.46 13.542 34.1584 7.51061 28.0742 5.32568C27.231 2.36691 24.8611 0 21.5114 0C18.1388 0 15.7917 2.36691 14.9257 5.32568C8.86434 7.51061 6.53994 13.542 6.38049 22.2817C6.19819 24.899 5.81074 28.1309 4.284 29.7013C2.3243 31.7269 0 33.7753 0 36.0513C0 37.69 1.29885 38.8051 3.44085 38.8051ZM14.5611 41.9005C14.8346 45.2234 17.6375 48 21.5114 48C25.3624 48 28.1653 45.2234 28.4615 41.9005H14.5611Z'; + +const GEAR = + 'M28.7031 6.19052C30.2696 6.66089 31.766 7.33913 33.1523 8.20713L35.5469 5.79971C36.1032 5.24603 36.8559 4.93522 37.6406 4.93522C38.4253 4.93522 39.1781 5.24603 39.7344 5.79971L42.1875 8.26575C42.7409 8.82232 43.0516 9.57546 43.0516 10.3605C43.0516 11.1456 42.7409 11.8987 42.1875 12.4553L39.6094 15.0347C40.3525 16.3812 40.9221 17.8165 41.3047 19.3063H45.0234C45.414 19.3053 45.8008 19.3813 46.1619 19.5301C46.523 19.679 46.8512 19.8976 47.1277 20.1735C47.4042 20.4494 47.6236 20.7772 47.7733 21.138C47.923 21.4989 48 21.8858 48 22.2765V25.7548C47.9979 26.5398 47.6853 27.2921 47.1305 27.8472C46.5756 28.4023 45.8237 28.7151 45.0391 28.7171H41.2852C40.8943 30.191 40.3208 31.6103 39.5781 32.9419L42.1875 35.5642C42.7409 36.1208 43.0516 36.8739 43.0516 37.659C43.0516 38.4441 42.7409 39.1972 42.1875 39.7538L39.7383 42.2081C39.1813 42.762 38.4279 43.0729 37.6426 43.0729C36.8573 43.0729 36.1038 42.762 35.5469 42.2081L33.0703 39.7264C31.7043 40.565 30.2349 41.2219 28.6992 41.6805V45.0376C28.6972 45.8227 28.3845 46.575 27.8297 47.1301C27.2749 47.6852 26.5229 47.9979 25.7383 48H22.2656C21.481 47.9979 20.729 47.6852 20.1742 47.1301C19.6194 46.575 19.3067 45.8227 19.3047 45.0376V42.0049C17.6192 41.6235 15.9953 41.0078 14.4805 40.1759L12.4492 42.2081C11.8919 42.7599 11.1395 43.0694 10.3555 43.0694C9.57141 43.0694 8.81901 42.7599 8.26172 42.2081L5.79688 39.7421C5.24441 39.185 4.93441 38.432 4.93441 37.6473C4.93441 36.8625 5.24441 36.1096 5.79688 35.5525L7.64062 33.7079C6.67489 32.1604 5.94303 30.4787 5.46875 28.7171H2.96094C2.17628 28.7151 1.42435 28.4023 0.869516 27.8472C0.314679 27.2921 0.00206218 26.5398 0 25.7548L0 22.2765C-2.72163e-06 21.4901 0.311703 20.7358 0.866763 20.179C1.42182 19.6222 2.17493 19.3084 2.96094 19.3063H5.44141C5.90239 17.5225 6.62789 15.8179 7.59375 14.2491L5.79688 12.4553C5.24536 11.8977 4.936 11.145 4.936 10.3605C4.936 9.57609 5.24536 8.82332 5.79688 8.26575L8.26172 5.79971C8.81802 5.24603 9.57079 4.93522 10.3555 4.93522C11.1402 4.93522 11.8929 5.24603 12.4492 5.79971L14.4023 7.75379C15.934 6.89009 17.5826 6.25296 19.2969 5.86224V2.96238C19.2989 2.17599 19.6126 1.42252 20.1692 0.867186C20.7257 0.311855 21.4796 -2.72296e-06 22.2656 0L25.7422 0C26.5268 0.00206319 27.2788 0.314833 27.8336 0.869941C28.3884 1.42505 28.7011 2.17735 28.7031 2.96238V6.19052ZM23.3828 14.3351C25.2835 14.3351 27.1416 14.8991 28.7219 15.9557C30.3022 17.0122 31.5339 18.514 32.2611 20.271C32.9883 22.0279 33.1784 23.9612 32.8073 25.8262C32.4362 27.6913 31.5206 29.4044 30.1763 30.7487C28.832 32.0931 27.1194 33.0085 25.2551 33.379C23.3908 33.7495 21.4586 33.5586 19.7028 32.8303C17.9469 32.102 16.4464 30.8692 15.391 29.2876C14.3356 27.7061 13.7727 25.8469 13.7734 23.9453C13.7734 22.6829 14.022 21.4329 14.505 20.2667C14.9879 19.1005 15.6958 18.0409 16.5882 17.1485C17.4806 16.2561 18.5399 15.5483 19.7058 15.0655C20.8716 14.5828 22.1211 14.3346 23.3828 14.3351Z'; + +const SHUTDOWN_ARC = + 'M33.532 6.31055C34.6685 4.99789 36.6647 4.84589 37.9907 5.97102C42.1552 9.50494 47 16.0457 47 25.0435C47 29.5607 45.5015 35.2247 41.8433 39.8271C38.093 44.5455 32.1773 48 23.8116 48C2.41454 48 -8.81138 22.5857 8.43995 6.09579C9.69628 4.89492 11.6981 4.92968 12.9111 6.17342C14.1241 7.41719 14.089 9.39898 12.8327 10.5999C-0.482499 23.3273 8.31794 41.7391 23.8116 41.7391C30.2021 41.7391 34.2996 39.1936 36.8734 35.9555C39.5393 32.6014 40.6759 28.3523 40.6759 25.0435C40.6759 18.3891 37.0885 13.4516 33.8749 10.7246C32.549 9.59948 32.3955 7.62321 33.532 6.31055Z'; + +const SHUTDOWN_BAR = + 'M23.2846 0C25.0309 0 26.4466 1.40154 26.4466 3.13043V21.3913C26.4466 23.1202 25.0309 24.5217 23.2846 24.5217C21.5382 24.5217 20.1225 23.1202 20.1225 21.3913V3.13043C20.1225 1.40155 21.5382 1.29092e-05 23.2846 0Z'; + +const SHAPES: Readonly<Record<SystemCardId, IconShape>> = { + library: { viewBox: '0 0 48 46', paths: GRID_PATHS }, + notifications: { viewBox: '0 0 43 48', paths: [BELL] }, + settings: { viewBox: '0 0 48 48', paths: [GEAR], fillRule: 'evenodd' }, + power: { viewBox: '0 0 47 48', paths: [SHUTDOWN_ARC, SHUTDOWN_BAR] }, +}; + +/** The glyph of one launcher card, as an inline SVG node painted with the current palette. */ +export function systemCardIcon(id: SystemCardId): SVGSVGElement { + const shape = SHAPES[id]; + const svg = document.createElementNS(SVG_NS, 'svg'); + svg.setAttribute('viewBox', shape.viewBox); + svg.setAttribute('class', 'card-icon'); + svg.setAttribute('aria-hidden', 'true'); + for (const d of shape.paths) { + const path = document.createElementNS(SVG_NS, 'path'); + path.setAttribute('d', d); + if (shape.fillRule !== undefined) { + path.setAttribute('fill-rule', shape.fillRule); + path.setAttribute('clip-rule', shape.fillRule); + } + svg.append(path); + } + return svg; +} diff --git a/src/renderer/system-cards.ts b/src/renderer/system-cards.ts new file mode 100644 index 00000000..2bc64658 --- /dev/null +++ b/src/renderer/system-cards.ts @@ -0,0 +1,34 @@ +// The launcher's own cards, sitting at the tail of the history carousel: Notifications, Settings and +// System. They belong to the RENDERER, not to main's library — main owns games (manifests, history, +// eviction, ordering) while these three are pure UI, and pushing them through LibraryEntry would make +// the library index know about buttons. carousel.ts splices them in after the games. +// +// No DOM here on purpose: the list is the contract with the mockup, and a test in a plain Node +// environment has to be able to read it. The icons live in system-card-icons.ts. +import type { MessageKey } from '../shared/i18n/index.js'; + +/** Which launcher card this is (also the value carousel.ts reports to app.ts on activation). */ +export type SystemCardId = 'library' | 'notifications' | 'settings' | 'power'; + +export interface SystemCard { + readonly id: SystemCardId; + /** + * The caption shown in #title while the card is selected — the same place a game's name goes (that is + * where the mockup puts it). Null for the power card: the mockup shows no caption for it at all. + */ + readonly titleKey: MessageKey | null; + /** The card node's aria-label — the only name the power card has, since it shows no caption. */ + readonly ariaKey: MessageKey; +} + +/** The three cards, in the mockup's order (they always sit after the games, never between them). */ +export const SYSTEM_CARDS: readonly SystemCard[] = [ + { id: 'library', titleKey: 'launcher.card.library', ariaKey: 'launcher.card.library' }, + { + id: 'notifications', + titleKey: 'launcher.card.notifications', + ariaKey: 'launcher.card.notifications', + }, + { id: 'settings', titleKey: 'launcher.card.settings', ariaKey: 'launcher.card.settings' }, + { id: 'power', titleKey: null, ariaKey: 'launcher.card.system' }, +] as const; diff --git a/src/renderer/toast.ts b/src/renderer/toast.ts new file mode 100644 index 00000000..5c741540 --- /dev/null +++ b/src/renderer/toast.ts @@ -0,0 +1,105 @@ +// The notification stack: plates in the top-right corner, six seconds each, newest on top. +// +// It used to be ONE plate with a queue behind it — a second message waited for the first to leave, which +// on a screen that reports several things in a row (a background applied, then a track, then a save) +// meant the later ones arrived long after the action that caused them. Now they stack: a new plate is +// prepended and the ones already up slide down by their own height, so two messages read as two messages +// rather than one overwriting the other. +// +// Plates are not interactive (pointer-events: none in styles.css): there is nothing to press on them, +// and notifications are only clickable inside the popup. That is also what keeps them out of the way of +// the hover guard in controls.ts. +import { type AudioController } from './audio.js'; +import { req } from './dom.js'; + +/** + * How long a plate stays up, and how long its exit transition runs (both must match .toast-plate in + * styles.css). SHOW_MS is also the length of the single pulse animation, which is what puts the beats in + * the middle of the plate's life rather than at some arbitrary point of a loop. + */ +const SHOW_MS = 6000; +const EXIT_MS = 300; +/** + * How many plates the corner may hold at once. Beyond this the oldest is retired early: a column of + * notifications taller than the screen is not more information, it is a wall, and the ones still worth + * reading are the recent ones (the popup keeps the full list either way). + */ +const MAX_PLATES = 3; + +export interface ToastDeps { + readonly audio: AudioController; + /** + * Whether the corner is currently taken — the notifications popup lives in exactly the same place. A + * blocked message is not dropped, it waits: the popup's own list is showing the very same notification + * live, so nothing is lost by holding the plate until the popup closes. + */ + isBlocked(): boolean; +} + +export interface Toast { + /** Queues a plate. Showing one is purely a display: read state is main's, and only the popup moves it. */ + show(text: string): void; + /** The corner is free again (the popup closed) — resume the queue. */ + resume(): void; +} + +export function createToast(deps: ToastDeps): Toast { + const stack = req('toast'); + const waiting: string[] = []; + /** The plates on screen, newest first — the same order they are laid out in. */ + const plates: HTMLElement[] = []; + + function pump(): void { + if (deps.isBlocked()) return; + while (waiting.length > 0) { + const text = waiting.shift(); + if (text === undefined) return; + add(text); + } + } + + function add(text: string): void { + const plate = document.createElement('div'); + plate.className = 'toast-plate'; + const pulse = document.createElement('div'); + pulse.className = 'toast-pulse'; + const line = document.createElement('span'); + line.className = 'toast-text'; + line.textContent = text; // never innerHTML: the text carries a game title off the card + pulse.append(line); + plate.append(pulse); + stack.prepend(plate); + plates.unshift(plate); + stack.setAttribute('aria-hidden', 'false'); + // The entrance has to be a CHANGE of class, not the class it was born with, or there is nothing for + // the transition to run from — hence the frame between appending and opening. + requestAnimationFrame(() => plate.classList.add('is-open')); + deps.audio.play('notify'); + window.setTimeout(() => retire(plate), SHOW_MS); + while (plates.length > MAX_PLATES) { + const oldest = plates[plates.length - 1]; + if (oldest === undefined) break; + retire(oldest); + } + } + + /** Slides one plate out and takes it off the stack once the transition has run. */ + function retire(plate: HTMLElement): void { + const at = plates.indexOf(plate); + if (at === -1) return; // already on its way out + plates.splice(at, 1); + plate.classList.remove('is-open'); + window.setTimeout(() => { + plate.remove(); + if (plates.length === 0) stack.setAttribute('aria-hidden', 'true'); + }, EXIT_MS); + } + + return { + show: (text) => { + waiting.push(text); + pump(); + }, + resume: () => pump(), + }; +} diff --git a/src/shared/artwork-filter.ts b/src/shared/artwork-filter.ts new file mode 100644 index 00000000..3a15be3d --- /dev/null +++ b/src/shared/artwork-filter.ts @@ -0,0 +1,91 @@ +// What the artwork gallery may show: which sources, and how large a picture has to be. +// +// Shared because both sides need the same answer. The renderer builds the sidebar out of these groups +// and thresholds; main asks only the chosen sources and drops what falls below the floor before a single +// thumbnail is downloaded. A filter is therefore a saving, not a hiding: a Wallpaper Cave tile IS its +// full-size file, so a picture that would be filtered out costs megabytes to show and then hide. +// +// The thresholds are the three names people actually use for a screen, not a computed scale: a wallpaper +// site states pixels, and "at least 4K" is a question with an exact answer. +import { type ArtworkKind, type MetadataProviderId } from './types'; + +/** The named size floors. `any` is the absence of a floor, not a small one. */ +export type ArtworkQuality = 'any' | 'fullhd' | 'qhd' | 'uhd'; + +export interface SizeFloor { + readonly width: number; + readonly height: number; +} + +export const QUALITY_FLOOR: Readonly<Record<ArtworkQuality, SizeFloor>> = { + any: { width: 0, height: 0 }, + fullhd: { width: 1920, height: 1080 }, + qhd: { width: 2560, height: 1440 }, + uhd: { width: 3840, height: 2160 }, +}; + +/** How a threshold is written on its button. Proper names of screen sizes, so they are not translated. */ +export const QUALITY_LABEL: Readonly<Record<ArtworkQuality, string | null>> = { + any: null, + fullhd: 'Full HD', + qhd: '2K', + uhd: '4K', +}; + +export const QUALITY_ORDER: readonly ArtworkQuality[] = ['any', 'fullhd', 'qhd', 'uhd']; + +/** + * One button of the source filter. A group can hold more than one source: the two stores answer with the + * same kind of picture (a frame out of the game), so telling them apart would be a distinction without a + * difference for someone choosing a background. + */ +export interface ArtworkSourceGroup { + readonly key: string; + /** A proper name, shown as it stands. Null means "every source", which the UI translates. */ + readonly label: string | null; + /** Empty for "every source" — main reads that as "ask them all". */ + readonly providers: readonly MetadataProviderId[]; +} + +/** + * The source buttons for a gallery. They differ by kind because the sources do: covers come from Steam + * and SteamGridDB, backgrounds from the two wallpaper sites and the two stores. + */ +export function sourceGroupsFor(kind: ArtworkKind): readonly ArtworkSourceGroup[] { + if (kind === 'grid') { + return [ + { key: 'all', label: null, providers: [] }, + { key: 'steam', label: 'Steam', providers: ['steam'] }, + { key: 'steamgriddb', label: 'SteamGridDB', providers: ['steamgriddb'] }, + ]; + } + return [ + { key: 'all', label: null, providers: [] }, + { key: 'wallhaven', label: 'Wallhaven', providers: ['wallhaven'] }, + { key: 'wallpapercave', label: 'Wallpaper Cave', providers: ['wallpapercave'] }, + { key: 'stores', label: 'Steam / GOG', providers: ['steam', 'gog'] }, + ]; +} + +/** + * Whether a picture clears the floor. A picture whose size nobody states is refused by every floor but + * `any`: the filter answers "at least this large", and "unknown" is not an answer to it. Steam's own + * backdrop is the honest example — measured 1438x810 for Half-Life 2, below Full HD despite the game. + */ +export function meetsQuality( + size: { readonly width?: number; readonly height?: number }, + quality: ArtworkQuality, +): boolean { + const floor = QUALITY_FLOOR[quality]; + if (floor.width === 0) return true; + if (size.width === undefined || size.height === undefined) return false; + return size.width >= floor.width && size.height >= floor.height; +} + +/** Whether a source takes part at all. An empty list is "every source", not "none". */ +export function includesSource( + sources: readonly MetadataProviderId[], + id: MetadataProviderId, +): boolean { + return sources.length === 0 || sources.includes(id); +} diff --git a/src/shared/asset-move-names.ts b/src/shared/asset-move-names.ts new file mode 100644 index 00000000..51c6fefe --- /dev/null +++ b/src/shared/asset-move-names.ts @@ -0,0 +1,34 @@ +// Deterministic, id-based names a locally moved game's assets get on the DESTINATION card (see the plan, +// Р2.4). The card's game.json is written verbatim (never re-serialized — see game-config.ts), so the +// target file names must be known BEFORE anything is written, not derived from what main happens to copy. +// +// Pure and shared between the renderer (which writes these paths into the target game.json text as part +// of building the move — see carryFormToCard in game-settings-model.ts) and main (which copies the actual +// asset files under these same names — see game-move.ts). Neither side re-derives the answer from the +// other, so the two cannot desync. +// +// A collision is possible only with a PREVIOUS copy of the SAME game (the id is already checked unique on +// the target card before anything is copied — see GameConfigService.moveToCard), so overwriting is safe. + +/** The file extension (with its leading dot), taken from the last path segment. '' when there is none. */ +function assetExtension(sourcePath: string): string { + const slash = Math.max(sourcePath.lastIndexOf('/'), sourcePath.lastIndexOf('\\')); + const base = slash === -1 ? sourcePath : sourcePath.slice(slash + 1); + const dot = base.lastIndexOf('.'); + return dot <= 0 ? '' : base.slice(dot); +} + +/** `assets/<id>-hero-<n>.<ext>` — n is 1-based, matching the manifest's hero rotation order. */ +export function movedHeroAssetPath(id: string, index: number, sourcePath: string): string { + return `assets/${id}-hero-${index + 1}${assetExtension(sourcePath)}`; +} + +/** `assets/<id>-grid.<ext>` — the carousel card image. */ +export function movedGridAssetPath(id: string, sourcePath: string): string { + return `assets/${id}-grid${assetExtension(sourcePath)}`; +} + +/** `assets/<id>-music.<ext>` — the background music track. */ +export function movedMusicAssetPath(id: string, sourcePath: string): string { + return `assets/${id}-music${assetExtension(sourcePath)}`; +} diff --git a/src/shared/i18n/en-plural.ts b/src/shared/i18n/en-plural.ts index d1127668..454e16a9 100644 --- a/src/shared/i18n/en-plural.ts +++ b/src/shared/i18n/en-plural.ts @@ -10,6 +10,9 @@ export const enPlural = { 'format.minutes': { one: '{n}m', other: '{n}m' }, // Drive-picker label for a multi-game card (the individual titles don't fit one line — show the count). 'drive.games': { one: '{n} game', other: '{n} games' }, + // The single summary plate shown instead of a queue of them: after a game exits, or when the user + // comes back to a launcher that has been collecting notifications while they were away. + 'notifications.unread': { one: '{n} unread notification', other: '{n} unread notifications' }, } as const satisfies Record<string, PluralForms>; /** Every plural key — the compile-time contract the Russian plural mirror indexes against. */ diff --git a/src/shared/i18n/en.ts b/src/shared/i18n/en.ts index 8b980852..53ffa2bd 100644 --- a/src/shared/i18n/en.ts +++ b/src/shared/i18n/en.ts @@ -1,6 +1,7 @@ // English dictionary — the SOURCE OF TRUTH for every user-facing string in the app (main + renderers). // Keys are flat with a dotted namespace per window/module: common.*, tray.*, menu.*, window.*, -// launcher.*, format.*, settings.*, configure.*, errors.*, drive.*, manifest.*. `ru.ts` mirrors these +// launcher.*, format.*, settings.*, gameConfig.*, gameSettings.*, errors.*, drive.*, manifest.*. +// `ru.ts` mirrors these // as a Partial (fill in gradually); the translator falls back to this file for any missing key. // // `{name}` tokens are interpolation placeholders filled at call time (see createTranslator). A literal @@ -8,16 +9,15 @@ // because those messages are translated WITHOUT params — see translateIssueMessage. export const en = { // ── Common (shared across windows) ────────────────────────────────────────── - // The two answers used by EVERY confirmation dialog (game launcher + configure window). Any confirm, + // The two answers used by EVERY confirmation dialog. Any confirm, // present or future, must ask a yes/no question and use these — never a context-specific verb like // "Discard"/"Replace", which is easy to confuse with the neighbouring "Cancel". 'common.yes': 'Yes', 'common.no': 'No', + 'common.stop': 'Stop', // ── Tray context menu (tray.ts) ───────────────────────────────────────────── 'tray.showLauncher': 'Show launcher', - 'tray.configureGame': 'Configure game', - 'tray.settings': 'Settings', 'tray.quit': 'Quit', // Steam Deck only: registering Playhook as a non-Steam game so it gets a Game Mode tile. The item is // hidden entirely on Windows and on any run that isn't a packaged AppImage. @@ -25,7 +25,7 @@ export const en = { 'tray.steamRemove': 'Remove from Steam', 'tray.steamBusy': 'Working…', - // ── Steam shortcut (steam-shortcut.ts, shown as message boxes) ────────────── + // ── Steam shortcut (steam-shortcut.ts, shown as message boxes) ───────────── 'steam.addedTitle': 'Added to Steam', 'steam.added': 'Playhook has been added to Steam. The tile appears the next time you enter Game Mode.', @@ -36,20 +36,16 @@ export const en = { 'steam.foreign': 'Steam already has a shortcut pointing at Playhook ({names}). Remove it in Steam first, then try again — it was added by hand, so Playhook will not delete it for you.', - // ── Native context menus (window.ts / configure-window.ts) ────────────────── + // ── Native context menus (window.ts) ─────────────────────────────────────── 'menu.cut': 'Cut', 'menu.copy': 'Copy', 'menu.paste': 'Paste', 'menu.selectAll': 'Select All', - 'menu.format': 'Format', - 'menu.reset': 'Reset', - // ── Window titles (settings-window.ts / configure-window.ts) ──────────────── + // ── Window titles ────────────────────────────────────────────────────────── 'window.settings': 'Settings', - 'window.configureGame': 'Configure game', // ── Game launcher renderer (index.html + app.ts/state-view.ts/controls.ts/hero.ts) ── - 'launcher.emptyTitle': 'Insert a game card', 'launcher.errorTitle': 'Something went wrong', 'launcher.info.lastPlayed': 'Last Played', 'launcher.info.playtime': 'Playtime', @@ -74,7 +70,26 @@ export const en = { 'launcher.menu.quit': 'Close Playhook', // Force-close the running game (Details menu item, visible only while a game is running). 'launcher.menu.forceClose': 'Force close', - 'launcher.menu.library': 'Library', + 'launcher.menu.goBack': 'Go back', + 'launcher.menu.forget': 'Remove from library', + 'launcher.menu.notifications': 'Notifications', + // Details menu entry that opens the Customize screen with no game behind it — the one way to CREATE a + // game from inside the launcher. + 'launcher.menu.addGame': 'Add game', + 'launcher.menu.settings': 'Settings', + // The launcher's own cards at the tail of the carousel (system-cards.ts). The first two name themselves + // in the bar's title line while they are selected, exactly as a game does; the third shows no caption at + // all in the mockup, so its key is only ever read as the card's aria-label. + 'launcher.card.library': 'Library', + 'launcher.card.notifications': 'Notifications', + 'launcher.card.settings': 'Settings', + 'launcher.card.system': 'System', + // The Library overlay: its two sidebar sections and the copy shown when a section has nothing in it. + 'library.all': 'All', + 'library.playable': 'Ready to play', + 'library.empty': 'No games here yet.', + 'library.emptyPlayable': + 'Nothing is ready to play - insert a card or add a game from your PC.', // Confirmation popup copy (controls.ts). The Yes/No buttons use the shared common.* keys. 'launcher.confirm.install': 'Do you want to install game?', 'launcher.confirm.uninstall': 'Do you want to uninstall game from your PC?', @@ -86,6 +101,8 @@ export const en = { // Force-close confirmation — warns that unsaved in-game progress may be lost (the game is killed, so it // may not get to write its save before syncing-out runs). 'launcher.confirm.kill': 'Force close the game? Unsaved progress may be lost.', + 'launcher.confirm.forget': + 'Remove "{title}" from the library? Its saves and playtime are kept — insert the card again and the game comes back.', // Power-action confirmations — single-question form, matching the installer confirm convention. 'launcher.confirm.shutdown': 'Shut down the PC?', 'launcher.confirm.reboot': 'Reboot the PC?', @@ -125,6 +142,9 @@ export const en = { 'launcher.installChatter8': "Almost done, pirate's honour...", 'launcher.installChatter9': 'Begging the progress bar to stop lying...', 'launcher.installChatter10': 'Warming up the SSD for the big moment...', + // A local (PC) game whose executable is no longer on disk — the card stays, only Play is disabled. + 'launcher.state.gameFilesMissing': 'Game files not found', + 'launcher.state.launchNotConfigured': 'Launch is not set up', 'launcher.state.running': 'Running...', 'launcher.state.killing': 'Force closing...', 'launcher.state.syncingOut': 'Saving progress...', @@ -138,37 +158,40 @@ export const en = { // ── Drive candidate labels (drive-watcher.ts) ─────────────────────────────── 'drive.blank': 'blank drive', + // The PC library's counterpart of "blank drive": there is no library file yet, only this machine. + 'drive.noGames': 'no games yet', 'drive.invalid': 'invalid game.json', // ── Settings window (settings.html + settings.ts) ─────────────────────────── 'settings.sectionUpdates': 'Updates', - 'settings.loading': 'Loading…', + 'settings.loading': 'Loading...', 'settings.sectionAutoUpdate': 'Automatic updates', 'settings.autoDownloadInstall': 'Download and install automatically', 'settings.autoDownloadManual': 'Download automatically, install manually', 'settings.autoOff': 'Off (check manually)', 'settings.prerelease': 'Receive pre-release (beta) updates', - 'settings.sectionAppearance': 'Appearance', - 'settings.themeSystem': 'Match system', - 'settings.themeLight': 'Light', - 'settings.themeDark': 'Dark', 'settings.sectionLanguage': 'Language', + // The row inside that section — named apart from the section title so the screen doesn't say + // "Language / Language" twice in a row. + 'settings.language': 'Interface language', // Same wording as the Appearance "Match system" option, for consistency across the two selectors. 'settings.languageSystem': 'Match system', 'settings.sectionGeneral': 'General', 'settings.summonHotkey': 'Show the launcher with a gamepad shortcut', - 'settings.summonHintPre': 'Hold', - 'settings.summonHintPost': 'on your gamepad at any time to bring the launcher to the front.', + // The launcher screen states the chord in one line (the settings window splits it around a <b>). + 'settings.summonHint': 'Hold Menu + View on your gamepad to bring the launcher to the front.', 'settings.preventScreensaver': 'Keep the screen awake while the launcher is open', - 'settings.alwaysShowEmpty': 'Always show the no-card screen', + 'settings.keepOpenWithoutCard': 'Keep the launcher open without a card', 'settings.disableSilentInstall': 'Disable silent installer mode (show the installer wizard)', // Steam Deck only — the row is hidden entirely elsewhere (see settings.ts / isSteamAvailable). 'settings.steamAutoLaunch': 'Open Playhook in Steam when a card is inserted (Game Mode only)', 'settings.steamAutoLaunchHint': 'Off frees about 120 MB of RAM: the background watcher stops running. The Steam tile stays — launch it from the library.', - 'settings.wallpaperLabel': 'Empty screen background', - 'settings.wallpaperChoose': 'Choose image…', - 'settings.wallpaperReset': 'Reset', + 'settings.sectionMetadata': 'Game metadata', + 'settings.steamGridDbKey': 'SteamGridDB API key', + 'settings.steamGridDbKeyEmpty': 'Not set', + 'settings.steamGridDbKeyHint': + 'Optional. With a key, "Find online" also offers covers and backgrounds from SteamGridDB. Get your own at steamgriddb.com, under Preferences → API.', 'settings.sectionAudio': 'Audio', 'settings.soundSet': 'Navigation sounds', 'settings.soundSetVolume': 'Navigation sounds volume', @@ -178,150 +201,269 @@ export const en = { 'settings.onlyGlobalAmbientHint': "When on, only the global ambience plays — a game's own background music is ignored.", 'settings.ambientVolume': 'Ambience volume', - 'settings.sectionAdvanced': 'Advanced', 'settings.openLogs': 'Open logs', 'settings.openGames': 'Open games folder', 'settings.reset': 'Reset to defaults', - 'settings.titlebarVersion': '({version}) — Settings', + 'settings.confirmReset': 'Reset all settings to defaults?', // Update-status line + primary button (settings.ts render()). 'settings.status.idle': 'Check for updates to see if a new version is available.', 'settings.status.upToDate': 'You’re up to date.', - 'settings.status.checking': 'Checking for updates…', + 'settings.status.checking': 'Checking for updates...', 'settings.status.available': 'Update available: {version}', - 'settings.status.downloading': 'Downloading… {percent}%', + 'settings.status.downloading': 'Downloading... {percent}%', 'settings.status.downloaded': 'Update {version} is ready to install.', 'settings.status.unsupported': 'Updates are available only in the installed build.', + // macOS: not a temporary state like the dev one above — the mac build cannot ever self-update + // (Squirrel.Mac requires a code-signed bundle), so this says what to do instead. + 'settings.status.unsupportedPlatform': + 'On macOS Playhook does not update itself — download the newer .dmg from the Releases page and replace the app. Your games, stats and saves are kept.', 'settings.action.check': 'Check for updates', - 'settings.action.checking': 'Checking…', + 'settings.action.checking': 'Checking...', 'settings.action.updateTo': 'Update to {version}', - 'settings.action.downloading': 'Downloading…', + 'settings.action.downloading': 'Downloading...', 'settings.action.restartInstall': 'Restart & install', 'settings.action.retry': 'Retry', - // ── Configure-game window (configure.html + configure.ts) ─────────────────── - 'configure.card': 'Card', - 'configure.insertCard': 'Insert an SD card or flash drive.', - // Multi-game picker (a card can carry several games). - 'configure.game': 'Game', - 'configure.addGame': 'Add game', - 'configure.removeGame': 'Remove current', - 'configure.confirmRemoveGame': 'Remove the current game from this card?', - // Dropdown option label: "1 / 3 · Hollow Knight". - 'configure.gameOption': '{index} / {count} · {title}', - // An issue on another game (not the one being edited) shown in the panel: "Game 3 (Celeste): …". - 'configure.otherGameIssue': 'Game {index} ({title}): {message}', - 'configure.untitledGame': 'untitled', - 'configure.save': 'Save & Apply', - 'configure.titlebarVersion': '({version}) — Configure game', - 'configure.configValid': 'Config is valid.', - 'configure.idChangedWarning': - 'Warning: id changed ({from} → {to}). Playtime stats are keyed by id and will reset for the new id.', - 'configure.cardGone': 'The selected card is no longer available. Your text is kept.', - 'configure.blankDrive': 'Blank drive — fill in the game and save.', - 'configure.couldNotRead': 'Could not read game.json: {message}', - 'configure.confirmSwitch': 'Discard unsaved changes and switch cards?', - 'configure.confirmReset': 'Discard unsaved changes and reset from the card?', - 'configure.fixSyntax': 'Fix the JSON syntax errors before formatting.', - 'configure.saving': 'Saving…', - 'configure.notSaved': 'Not saved: {message}', - 'configure.applied': 'Applied. The launcher was updated.', - 'configure.deferred': 'Saved. It will load shortly, or after the active card is removed.', - 'configure.savedRejected': 'Saved, but the manifest was rejected: {message}', - 'configure.unknownReason': 'unknown reason', - - // ── Configure-game window: interactive form (configure-form-view.ts) ───────── - // The JSON tab label (the section tabs reuse the section headings below); "JSON" is a filename/format. - 'configure.tabJson': 'JSON', - // A visible Reset button next to Save (re-reads game.json from the card, discarding edits). - 'configure.reset': 'Reset', - // Section headings. - 'configure.sectionBasics': 'Basics', - 'configure.sectionLaunch': 'Launch', - 'configure.sectionHero': 'Images', - 'configure.sectionSaves': 'Saves', - 'configure.sectionAudio': 'Audio', - 'configure.sectionAdvanced': 'Advanced', - // Field labels. - 'configure.fieldId': 'Game id', - 'configure.idHint': - 'Auto-filled from the name. Edit it to set your own; clear it to auto-fill again.', - 'configure.fieldTitle': 'Title', - 'configure.schemaVersion': 'Schema version: 1', - 'configure.launchType': 'Launch type', - 'configure.launchExecutable': 'Executable', - 'configure.launchInstaller': 'Installer', - 'configure.fieldExecutable': 'Executable path', - 'configure.executableNote': 'Relative to the card root.', - 'configure.fieldArgs': 'Arguments', - 'configure.fieldRunAsAdmin': 'Run as administrator', - 'configure.fieldCopyToPc': 'Move game to PC', - 'configure.copyExecutableNote': - 'Relative to the game directory below — the game is copied to the PC, and the executable is looked up inside the copy.', - 'configure.fieldCopySource': 'Game directory on the card', - 'configure.copySourceHint': - 'The root of the game’s own folder on the card. It is copied to the PC on “Install”; the copy on the card is kept.', - 'configure.copySourceOutside': - 'That file is outside the game directory ({source}) — pick one inside it, or fix the directory first.', - 'configure.fieldInstaller': 'Installer path', - 'configure.fieldInstallType': 'Installer type', - 'configure.fieldInstallArgs': 'Installer arguments', - 'configure.installArgsDirHint': - 'For a custom installer exactly one argument must contain the {dir} placeholder.', - 'configure.installerExperimental': - '⚠ Experimental: the Installer type may behave unpredictably (especially on Linux).', - 'configure.installerLinuxWarning': - 'Installers are unpredictable on Linux/Steam Deck: they come in many flavours, and under Proton some fail or hang. Prefer “Move game to PC”, or a plain Executable.', - 'configure.fieldWinetricks': 'Game winetricks (Linux)', - 'configure.winetricksHint': - 'Extra winetricks verbs/settings (e.g. d3dx9, or vd=1920x1080 for a virtual desktop) applied to the Wine prefix before the game launches, on top of the built-in set. Linux/Proton only; ignored on Windows.', - 'configure.fieldInstallWinetricks': 'Installer winetricks (Linux)', - 'configure.installWinetricksHint': - 'Extra winetricks verbs provisioned before the installer runs, on top of the built-in set. Linux/Proton only; ignored on Windows.', - 'configure.fieldUmuGameId': 'umu GAMEID (Linux)', - 'configure.umuGameIdHint': - 'A Steam appid or a custom UMU_ID — umu applies that game’s protonfix instead of the generic default. Leave empty for umu-default. Linux/Proton only.', - 'configure.fieldAppid': 'Steam appid', - 'configure.fieldWatchProcesses': 'Watched processes', - 'configure.watchProcessesHint': '1–16 process image names ending in .exe.', - 'configure.fieldHeroImages': 'Hero images', - 'configure.heroImagesHint': 'Up to 3 backgrounds; several cross-fade every minute.', - 'configure.fieldGridImage': 'Card image', - 'configure.gridImageHint': - 'The game’s card in the launcher’s history carousel. A 600x900 portrait cover is expected (the same format Steam uses). Optional — without it the card is cropped from the first hero image.', - // Helper link next to the card-image field (opens SteamGridDB in the default browser), mirroring the - // Steam appid link — a 600x900 cover is exactly what that site catalogues. - 'configure.gridImageHelp': 'Find a 600x900 cover on SteamGridDB', - 'configure.fieldSaveOnCard': 'Save folder on the card', - 'configure.fieldPcSavePath': 'PC save path', - 'configure.pcSavePathPlaceholder': '%APPDATA%/My Game', - 'configure.fieldBackgroundMusic': 'Background music', - 'configure.fieldLaunchTimeout': 'Launch timeout (seconds)', - 'configure.fieldKillTimeout': 'Force-close timeout (seconds)', - // Audio Default/Custom selector (Default → the field is omitted from game.json). - 'configure.musicNoneHint': 'No background music.', - // Steam appid helper link (opens SteamDB in the default browser). - 'configure.appidHelp': 'Find the appid on SteamDB', - // Dynamic-list + picker buttons. - 'configure.browse': 'Browse…', - 'configure.add': 'Add', - 'configure.addFile': 'Add…', - 'configure.replace': 'Replace…', - 'configure.remove': 'Remove', - 'configure.dragReorder': 'Drag to reorder', - // Banners / hints. - 'configure.corruptField': 'This field contains an invalid value; editing it replaces the value.', - 'configure.fixSyntaxSwitch': 'Fix the JSON syntax errors before switching to the form.', - 'configure.mixedLaunchModes': - 'This manifest defines more than one launch type. Only “{mode}” stays active; saving removes the other blocks.', - // Picker rejections (main → renderer). - 'configure.pickOutsideCard': 'The selected file is outside the card. Choose a file on the card.', - 'configure.pickChooseSubfolder': 'Choose a subfolder of the card, not the card root.', - 'configure.pickPcSaveOutside': + // ── Customize screen: the launcher's own per-game editor (gameConfig:* channels) ── + // The picker rejections main produces, in the launcher's namespace. They repeat the `configure.pick*` + // wording above on purpose: those belong to the window being dismantled and die with it, these belong + // to the screen replacing it. Everything the SCREEN itself says is below, in gameSettings.*. + 'gameConfig.thisPc': 'This PC', + 'gameConfig.homeFolder': 'Home folder', + 'gameConfig.pickOutsideCard': 'The selected file is outside the card. Choose a file on the card.', + 'gameConfig.pickChooseSubfolder': 'Choose a subfolder of the card, not the card root.', + 'gameConfig.pickPcSaveOutside': 'That folder is not under a known save location (%DOCUMENTS%, %APPDATA%, %LOCALAPPDATA%, %LOCALLOW% or %USERPROFILE%). Pick a folder inside one of those.', + 'gameConfig.pickImportFailed': 'Could not copy the selected file into the local library.', + 'gameConfig.pickMissing': 'That file is no longer there.', + 'gameConfig.pickSymlink': 'That is a shortcut to somewhere else — pick the file itself.', + 'gameConfig.pickNeedsFolder': 'Pick a folder for this field.', + 'gameConfig.pickNeedsFile': 'Pick a file for this field.', + 'gameConfig.pickWrongType': 'That file type does not fit this field.', + 'gameConfig.listFailed': 'This folder could not be opened.', + // Move to card (Р2.5) — GameConfigService.moveToCard. + 'gameConfig.moveGameBusy': 'Wait for the current install or launch to finish, then try again.', + 'gameConfig.moveIdTaken': 'This card already has a game with the same id.', + 'gameConfig.moveIdChanged': + 'The id cannot be changed while moving the game — move it first, then rename it on the card.', + 'gameConfig.moveLibraryInvalid': + 'Another game in the PC library has a problem that has to be fixed first: {reason}', + 'gameConfig.moveFilesNotOnCard': "Copy the game's own files onto the card first.", + 'gameConfig.moveAssetMissing': 'This file is gone from the PC and cannot be moved: {path}', + + // ── Customize screen: what the SCREEN itself says (game-settings-*.ts) ────── + 'launcher.menu.customize': 'Customize', + + // ── Notifications (the toast + the Notifications popup) ──────────────────── + // The TEXT of a notification is assembled here rather than stored with it: the UI language changes + // live, and a string written into notifications.json would be frozen at the language of the moment. + 'notifications.updateReady': 'Update {version} is ready — it will be installed on restart', + 'notifications.gameInstalled': '{title} is installed', + 'notifications.gameUninstalled': '{title} has been removed', + 'notifications.gameAddedDeferred': + '{title} was written to the card. It shows up once that card is the active one.', + 'notifications.gameMovedDeferred': + '{title} was moved to the card. It shows up once that card is the active one.', + 'notifications.gameMoveSaveSkipped': + '{title} was moved to the card, but its save folder there already had something in it — the PC saves were left uncopied.', + 'notifications.settingsWriteFailed': + 'Your settings could not be saved and will be back as they were on the next start. Playhook has no write access to its settings file.', + 'notifications.gameMoveDuplicate': + '{title} was written to the card, but could not be removed from the PC library — it now exists in both places. Remove the local copy through Customize.', + 'notifications.empty': 'No notifications', + 'notifications.clearAll': 'Clear all', + // Timestamp of a list entry: today shows the time alone, yesterday is named, older gets a date. + 'notifications.yesterday': 'yesterday, {time}', + + // ── On-screen keyboard + file browser (osk.ts / file-picker.ts) ──────────── + 'osk.shift': 'Shift', + 'osk.backspace': 'Delete', + 'osk.space': 'Space', + 'osk.done': 'Done', + 'osk.cancel': 'Cancel', + 'osk.paste': 'Paste', + 'osk.legendDelete': 'X - delete', + 'osk.legendShift': 'Y - shift', + 'osk.legendLayout': 'LB/RB - layout', + 'osk.legendDone': 'RT - done', + 'osk.legendCancel': 'B - cancel', + 'picker.title': 'Choose', + 'picker.cancel': 'Cancel', + 'picker.up': 'Up one level', + 'picker.useThisFolder': 'Use this folder', + 'picker.empty': 'Nothing here.', + 'picker.legend': 'A - open or choose, B - up one level, Y - actions, left/right - switch column', + 'picker.legendMulti': 'X - tick, A - choose, B - up one level, Y - actions, left/right - switch column', + + 'gameSettings.screenTitle': 'Customize', + // The same screen opened with no game behind it: it adds one instead of editing one. + 'gameSettings.addTitle': 'Add game', + 'gameSettings.loading': 'Reading the manifest...', + 'gameSettings.sectionBasics': 'Basics', + 'gameSettings.sectionLaunch': 'Launch', + 'gameSettings.sectionImages': 'Artwork', + 'gameSettings.sectionSaves': 'Saves', + 'gameSettings.sectionAudio': 'Audio', + 'gameSettings.sectionAdvanced': 'Advanced', + 'gameSettings.sectionLinux': 'Linux', + 'gameSettings.notSet': 'not set', + 'gameSettings.listEmpty': 'empty', + // Add-game only: WHERE the new game goes — a card, or this machine's own library. It is the first row + // of the form because everything below it is read against the answer. + 'gameSettings.source': 'Add to', + 'gameSettings.sourceHint': + 'A card carries the game with it; a game added to this PC stays on this machine.', + 'gameSettings.title': 'Title', + 'gameSettings.id': 'Id', + 'gameSettings.idChangedWarning': + 'Changing the id detaches this game from its playtime, its save backups and its library entry on this PC.', + 'gameSettings.launchMode': 'Launch type', + 'gameSettings.modeExecutable': 'Run from the card', + 'gameSettings.modeInstaller': 'Install from the card', + 'gameSettings.modeSteam': 'Steam', + 'gameSettings.modePc': 'Executable file', + 'gameSettings.modeNone': 'Not set up yet', + 'gameSettings.modeNoneHint': 'The game will not start until this is filled in', + 'gameSettings.mixedLaunchModes': + 'This manifest describes more than one launch type. Only the selected one is kept; saving removes the others.', + 'gameSettings.executable': 'Executable', + 'gameSettings.executableHint': 'Relative to the card root.', + 'gameSettings.executableCopyHint': + 'Relative to the game folder on the card that gets copied (the field below).', + 'gameSettings.executableInstallHint': 'Relative to the folder the installer installs into.', + 'gameSettings.pcExecutable': 'Executable', + 'gameSettings.args': 'Launch arguments', + 'gameSettings.runAsAdmin': 'Run as administrator', + 'gameSettings.copyToPc': 'Move game to PC', + 'gameSettings.copyToPcHint': 'The game is copied to this PC and runs from there; the card keeps its copy.', + 'gameSettings.copyDirectory': 'Game folder on the card', + 'gameSettings.copyDirectoryHint': 'The folder that is copied — the game’s own root, not the card root.', + 'gameSettings.installer': 'Installer', + 'gameSettings.installType': 'Installer type', + 'gameSettings.installNsis': 'NSIS', + 'gameSettings.installInno': 'Inno Setup', + 'gameSettings.installCustom': 'Custom (run by hand)', + 'gameSettings.installRunAsAdmin': 'Run the installer as administrator', + 'gameSettings.installCustomHint': 'A custom installer is run by you, so elevation is not ours to request.', + 'gameSettings.installArgs': 'Installer arguments', + 'gameSettings.installWinetricks': 'Winetricks for the installer', + 'gameSettings.steamAppid': 'Steam appid', + 'gameSettings.steamAppidHint': 'The number in the game’s Steam store URL.', + 'gameSettings.watchProcesses': 'Watched processes', + 'gameSettings.watchProcessesHint': + 'What shows the game is still running, when it starts through a launcher of its own.', + 'gameSettings.heroImage': 'Backgrounds', + 'gameSettings.gridImage': 'Card artwork', + 'gameSettings.gridImageAuto': 'cropped from the first background', + 'gameSettings.saveOnCard': 'Save folder on the card', + 'gameSettings.saveOnCardHint': 'Saves are copied here once you finish playing.', + 'gameSettings.pcSavePath': 'Save folder on the PC', + 'gameSettings.backgroundMusic': 'Background music', + 'gameSettings.musicNone': 'no music', + 'gameSettings.launchTimeout': 'Launch timeout', + 'gameSettings.killTimeout': 'Force-close timeout', + 'gameSettings.launchTimeoutHint': + 'How long to wait for the game to appear after it is started before giving up on it.', + 'gameSettings.killTimeoutHint': + 'How long a force-close waits for the game to actually die before reporting it did not.', + 'gameSettings.defaultSeconds30': '30 s (default)', + 'gameSettings.defaultSeconds60': '60 s (default)', + 'gameSettings.winetricks': 'Winetricks', + 'gameSettings.winetricksHint': 'Extra verbs provisioned into the prefix before the game runs (Linux).', + 'gameSettings.umuGameId': 'umu GAMEID', + 'gameSettings.umuGameIdAuto': 'automatic', + 'gameSettings.umuGameIdHint': 'Applies that game’s protonfix instead of the generic one (Linux).', + 'gameSettings.save': 'Save', + 'gameSettings.moveToCard': 'Move to card', + // The Save button in add mode: nothing is being saved back, a game is being created. + 'gameSettings.add': 'Add', + 'gameSettings.reset': 'Discard edits', + 'gameSettings.delete': 'Delete game', + 'gameSettings.viewImage': 'View', + 'gameSettings.cannotSave': + 'Fix the problems marked above to save. Every field with one is outlined on the left.', + 'gameSettings.browse': 'Browse...', + 'gameSettings.clear': 'Clear', + 'gameSettings.listAdd': 'Add...', + 'gameSettings.listReplace': 'Replace...', + 'gameSettings.listMoveUp': 'Move up', + 'gameSettings.listMoveDown': 'Move down', + 'gameSettings.listRemove': 'Remove', + 'gameSettings.saving': 'Saving...', + 'gameSettings.savedApplied': 'Saved and applied.', + 'gameSettings.savedDeferred': 'Saved. It applies when this card becomes the active one.', + 'gameSettings.savedNotApplied': 'Saved. It applies once you are done playing.', + 'gameSettings.slotUnreadable': 'This game cannot be shown as a form: {message}', + 'gameSettings.slotNotFound': 'The manifest no longer describes a game with the id "{id}".', + 'gameSettings.otherGameUnnamed': 'unnamed', + 'gameSettings.otherGameIssue': 'Problem in game {number} ({game}): {field} - {message}', + 'gameSettings.confirmReset': 'Discard the edits to this game and re-read the manifest?', + 'gameSettings.confirmDiscard': 'Leave without saving? The changes are lost.', + 'gameSettings.confirmSwitchSource': + 'Add the game somewhere else? The paths and the install settings are cleared — the name and the rest stay.', + 'gameSettings.confirmCancelMove': + 'Stop moving this game to a card? Nothing has been written yet — the game stays in the PC library.', + 'gameSettings.moveToCardTitle': 'Move to card', + 'gameSettings.moveNoCards': 'Insert a card to move this game onto it.', + 'gameSettings.confirmDelete': 'Delete "{title}" from the manifest?', + // The second half of the delete question. Its "No" is an ANSWER, not a way out — hence the last line: + // backing out of the question is what cancels the deletion. + 'gameSettings.confirmDeleteHistory': 'Remove "{title}" from the library as well?', + 'gameSettings.confirmDeleteHistoryNote': + 'Yes also drops its card from the carousel, with the artwork copied to this PC. No deletes the game and keeps the card. Closing this question cancels the deletion; the play time is kept either way.', + 'gameSettings.confirmDeleteNote': + 'The game files stay where they are. Unsaved changes on this screen are discarded.', + 'gameSettings.confirmDeleteSavesNote': + 'The game files stay where they are, and so do its save backups. Unsaved changes on this screen are discarded.', // ── User-facing errors from main (ipc.ts / game-config.ts / updater.ts) ───── // The wrapper is translated; the technical cause ({cause}) is inserted as-is (system messages, nested // exceptions and the like stay in their original form). + // ── Online metadata (main/metadata/*) ───────────────────────────────────── + 'metadata.findOnline': 'Find online', + 'metadata.searchTitle': 'Search for a game', + 'metadata.searching': 'Searching', + 'metadata.nothingFound': 'Nothing found. Try another title.', + 'metadata.game': 'Game', + 'metadata.sections': 'Sections', + 'metadata.noCandidate': 'No game chosen', + 'metadata.applyMode': 'Backgrounds already set', + 'metadata.searchAgain': 'Search again', + 'metadata.applyTitle': 'Update title', + 'metadata.cover': 'Cover', + 'metadata.backgrounds': 'Backgrounds', + 'metadata.music': 'Music', + 'metadata.noArtwork': 'No artwork found for this game.', + 'metadata.applySelected': 'Apply ({count})', + 'metadata.clearPicked': 'Clear ticked ({count})', + 'metadata.loadMore': 'Load more', + 'metadata.filterSource': 'Source', + 'metadata.filterSize': 'Size', + 'metadata.filterAny': 'Any', + 'metadata.actionClose': 'Close', + 'metadata.needsId': 'Give the game an id first — the downloaded files are named after it.', + 'metadata.applying': 'Downloading', + 'metadata.titleConfirm': 'Replace the game title with "{title}"?', + 'metadata.applied': 'Applied. Save the game to keep it.', + 'metadata.heroAppend': 'Add to them ({count})', + 'metadata.heroRoom': 'Room for {count} more', + 'metadata.heroReplace': 'Replace all of them', + 'metadata.appliedPartly': 'Applied. {count} did not fit and were skipped.', + 'metadata.albums': 'Albums', + 'metadata.noAlbums': 'No soundtrack found for this game.', + 'metadata.noTracks': 'This album has no tracks.', + 'metadata.listen': 'Listen', + 'metadata.stopListen': 'Stop', + 'metadata.pickAlbum': 'Choose an album on the left.', + 'metadata.useTrack': 'Apply', + 'metadata.downloading': 'Downloading the track', + 'metadata.noSources': 'No metadata source is available right now.', + 'metadata.staleSelection': 'That choice is no longer available. Search again.', + 'metadata.downloadFailed': 'Could not download the file.', + 'metadata.unsupportedFile': 'The downloaded file is not a supported image or audio file.', + 'metadata.writeFailed': 'Could not save the downloaded file.', + 'metadata.badRequest': 'Could not apply that choice.', + 'metadata.noDescriptions': 'No description is available for this game.', 'errors.finishBeforeApply': 'Finish what’s running before applying the config', 'errors.reloadInProgress': 'a reload is already in progress', 'errors.steamNotInstalled': 'Steam is not installed', @@ -342,14 +484,27 @@ export const en = { 'errors.killFailed': 'could not force-close the game (some processes are still running)', 'errors.finishBeforeInstall': 'Finish what’s running before installing the update.', 'errors.driveUnavailable': 'the selected drive is no longer available', + 'errors.gameNotFound': 'this game is not available right now', + 'errors.mediaChanged': + 'the card in this slot is not the one this game was read from — reopen the screen', 'errors.cannotReadManifest': 'cannot read {file}: {cause}', 'errors.cannotWriteManifest': 'failed to write {file}: {cause}', 'errors.configInvalid': 'the config is invalid', 'errors.powerUnsupported': 'power actions are only available on Windows', 'errors.powerFailed': 'power command failed: {cause}', - 'errors.wallpaperTooLarge': 'The image is too large (over 8 MB). Choose a smaller file.', - 'errors.wallpaperNotImage': 'That file is not a supported image (PNG, JPEG, WebP or GIF).', - 'errors.wallpaperFailed': 'Failed to set the background image.', + + // ── macOS refusals (platform/darwin) ──────────────────────────────────────── + // What the mac build cannot do, said in the words of the thing the user tried: a Windows executable, a + // card installer, an unreadable .app, a Gatekeeper-blocked binary, a denied Apple-Events prompt. + 'errors.macWindowsGame': + 'Windows games do not run on macOS — this game launches a *.exe. Native mac games and Steam mode work.', + 'errors.macInstallUnsupported': 'installing a game from a card is not supported on macOS', + 'errors.macAppBundleUnreadable': + 'cannot find the executable inside the app bundle: {path} (Contents/MacOS is missing or unreadable)', + 'errors.macGameBlocked': + 'macOS blocked the game (Gatekeeper): the file is quarantined or unsigned. Allow it in System Settings → Privacy & Security, or run: xattr -dr com.apple.quarantine "{path}"', + 'errors.macPowerNotPermitted': + 'macOS did not allow Playhook to control the system. Grant it in System Settings → Privacy & Security → Automation → Playhook → System Events.', // ── Manifest validation (manifest.ts) ─────────────────────────────────────── // Schema-level custom messages: stored in the schema AS THESE KEYS; translated at the issue-mapping @@ -357,7 +512,10 @@ export const en = { // message passes through). JSON field names inside the text stay as latin identifiers. 'manifest.idPattern': 'id must match [A-Za-z0-9._-]', 'manifest.idDots': 'id must not be . or ..', - 'manifest.watchProcessesName': 'watchProcesses entries must be a bare *.exe name', + 'manifest.watchProcessesName': + 'watchProcesses entries must be a bare file name (letters, digits, ". _ -", spaces) — no path separators', + 'manifest.watchProcessesBlank': 'watchProcesses entries must not be blank', + 'manifest.watchProcessesDots': 'watchProcesses entries must not be . or ..', 'manifest.winetricksName': 'winetricks entries must be verb names or key=value settings (letters, digits, _.=-)', 'manifest.umuGameIdName': 'umuGameId must be a Steam appid or a UMU_ID (letters, digits, _-)', @@ -372,9 +530,20 @@ export const en = { 'manifest.runAsAdminWithSteam': 'runAsAdmin is not allowed in steam mode', 'manifest.watchProcessesRequired': 'watchProcesses is required in steam mode', 'manifest.executableRequired': 'executable is required', + // PC mode (a game on this machine's own disk — see PcManifest). + 'manifest.pcWithSteam': 'pc is not allowed together with steam', + 'manifest.pcWithInstall': 'pc is not allowed together with install', + 'manifest.pcWithExecutable': 'executable is not allowed in pc mode (use pc.executable)', + 'manifest.pcWithSaveOnCard': 'saveOnCard is not allowed for a local game (Playhook keeps the backup)', + 'manifest.pcOnCard': 'the pc block is only allowed for local games, not on a card', + 'manifest.executableOnPcLibrary': 'executable is not allowed in the PC library (use pc.executable)', + 'manifest.installOnPcLibrary': 'install is not allowed in the PC library (use pc.executable)', + 'manifest.pcExecutableAbsolute': 'pc.executable must be an absolute path: {path}', // Pure-function messages (expandPcSavePath / resolveInstall / readManifest / validateManifestText): // the functions receive the translator and interpolate directly. 'manifest.pcSavePathPrefix': 'pcSavePath must start with {prefixes}', + 'manifest.pcSavePathPrefixOrAbsolute': + 'pcSavePath must be an absolute path or start with {prefixes}', 'manifest.pcSavePathNotAllowed': 'pcSavePath prefix %{prefix}% is not allowed (use {prefixes})', 'manifest.pcSavePathUnavailable': 'pcSavePath prefix %{prefix}% is not available on this system', 'manifest.pcSavePathNoTraversal': 'pcSavePath must not contain ".."', diff --git a/src/shared/i18n/ru-plural.ts b/src/shared/i18n/ru-plural.ts index eb34094b..1f717d9b 100644 --- a/src/shared/i18n/ru-plural.ts +++ b/src/shared/i18n/ru-plural.ts @@ -7,4 +7,10 @@ export const ruPlural: Partial<Record<PluralKey, PluralForms>> = { 'format.hours': { one: '{n} час', few: '{n} часа', many: '{n} часов', other: '{n} часа' }, 'format.minutes': { one: '{n} минута', few: '{n} минуты', many: '{n} минут', other: '{n} минуты' }, 'drive.games': { one: '{n} игра', few: '{n} игры', many: '{n} игр', other: '{n} игры' }, + 'notifications.unread': { + one: '{n} непрочитанное уведомление', + few: '{n} непрочитанных уведомления', + many: '{n} непрочитанных уведомлений', + other: '{n} непрочитанных уведомления', + }, }; diff --git a/src/shared/i18n/ru.ts b/src/shared/i18n/ru.ts index c5a00532..e720d34f 100644 --- a/src/shared/i18n/ru.ts +++ b/src/shared/i18n/ru.ts @@ -8,11 +8,10 @@ export const ru: Partial<Record<MessageKey, string>> = { // ── Common (shared across windows) ─────────────────────────────────────────── 'common.yes': 'Да', 'common.no': 'Нет', + 'common.stop': 'Прервать', // ── Tray context menu ──────────────────────────────────────────────────────── 'tray.showLauncher': 'Показать лаунчер', - 'tray.configureGame': 'Настроить игру', - 'tray.settings': 'Настройки', 'tray.quit': 'Выход', 'tray.steamAdd': 'Добавить в Steam', 'tray.steamRemove': 'Убрать из Steam', @@ -33,15 +32,11 @@ export const ru: Partial<Record<MessageKey, string>> = { 'menu.copy': 'Копировать', 'menu.paste': 'Вставить', 'menu.selectAll': 'Выделить всё', - 'menu.format': 'Форматировать', - 'menu.reset': 'Сбросить', // ── Window titles ──────────────────────────────────────────────────────────── 'window.settings': 'Настройки', - 'window.configureGame': 'Настройка игры', // ── Game launcher ──────────────────────────────────────────────────────────── - 'launcher.emptyTitle': 'Вставьте игровую карту', 'launcher.errorTitle': 'Что-то пошло не так', 'launcher.info.lastPlayed': 'Последний запуск', 'launcher.info.playtime': 'Время в игре', @@ -59,7 +54,20 @@ export const ru: Partial<Record<MessageKey, string>> = { 'launcher.menu.minimize': 'Свернуть Playhook', 'launcher.menu.quit': 'Закрыть Playhook', 'launcher.menu.forceClose': 'Закрыть принудительно', - 'launcher.menu.library': 'Библиотека', + 'launcher.menu.goBack': 'Вернуться назад', + 'launcher.menu.forget': 'Убрать из библиотеки', + 'launcher.menu.notifications': 'Уведомления', + 'launcher.menu.addGame': 'Добавить игру', + 'launcher.menu.settings': 'Настройки', + 'launcher.card.library': 'Библиотека', + 'launcher.card.notifications': 'Уведомления', + 'launcher.card.settings': 'Настройки', + 'launcher.card.system': 'Система', + 'library.all': 'Все', + 'library.playable': 'Готовые к запуску', + 'library.empty': 'Здесь пока нет игр.', + 'library.emptyPlayable': + 'Нет игр, готовых к запуску - вставьте карту или добавьте игру с ПК.', 'launcher.confirm.install': 'Установить игру?', 'launcher.confirm.uninstall': 'Удалить игру с компьютера?', 'launcher.confirm.uninstallPrefix': 'Очистить Proton-префикс?', @@ -70,6 +78,8 @@ export const ru: Partial<Record<MessageKey, string>> = { 'launcher.confirm.shutdown': 'Выключить компьютер?', 'launcher.confirm.reboot': 'Перезагрузить компьютер?', 'launcher.confirm.kill': 'Закрыть игру принудительно? Несохранённый прогресс может быть потерян.', + 'launcher.confirm.forget': + 'Убрать «{title}» из библиотеки? Сейвы и статистика останутся — вставьте карту, и игра вернётся.', 'launcher.confirm.sleep': 'Перевести компьютер в спящий режим?', 'launcher.installPathNote': 'Не все установщики поддерживают тихий режим, поэтому при установке нужно указать следующий путь:', @@ -103,6 +113,8 @@ export const ru: Partial<Record<MessageKey, string>> = { 'launcher.installChatter8': 'Ещё чуть-чуть, честное пиратское...', 'launcher.installChatter9': 'Уговариваем прогрессбар не врать...', 'launcher.installChatter10': 'Прогреваем SSD для важного дела...', + 'launcher.state.gameFilesMissing': 'Файлы игры не найдены', + 'launcher.state.launchNotConfigured': 'Запуск не настроен', 'launcher.state.running': 'Игра запущена...', 'launcher.state.killing': 'Принудительное закрытие...', 'launcher.state.syncingOut': 'Сохранение прогресса...', @@ -116,35 +128,34 @@ export const ru: Partial<Record<MessageKey, string>> = { // ── Drive candidate labels ─────────────────────────────────────────────────── 'drive.blank': 'пустой диск', + 'drive.noGames': 'игр пока нет', 'drive.invalid': 'некорректный game.json', // ── Settings window ────────────────────────────────────────────────────────── 'settings.sectionUpdates': 'Обновления', - 'settings.loading': 'Загрузка…', + 'settings.loading': 'Загрузка...', 'settings.sectionAutoUpdate': 'Автоматические обновления', 'settings.autoDownloadInstall': 'Скачивать и устанавливать автоматически', 'settings.autoDownloadManual': 'Скачивать автоматически, устанавливать вручную', 'settings.autoOff': 'Выключено (проверять вручную)', 'settings.prerelease': 'Получать предварительные (бета) обновления', - 'settings.sectionAppearance': 'Оформление', - 'settings.themeSystem': 'Как в системе', - 'settings.themeLight': 'Светлая', - 'settings.themeDark': 'Тёмная', 'settings.sectionLanguage': 'Язык', + 'settings.language': 'Язык интерфейса', 'settings.languageSystem': 'Как в системе', 'settings.sectionGeneral': 'Общие', 'settings.summonHotkey': 'Показывать лаунчер сочетанием на геймпаде', - 'settings.summonHintPre': 'Зажмите', - 'settings.summonHintPost': 'на геймпаде в любой момент, чтобы вывести лаунчер на передний план.', + 'settings.summonHint': 'Зажмите Menu + View на геймпаде, чтобы вывести лаунчер на передний план.', 'settings.preventScreensaver': 'Не гасить экран, пока открыт лаунчер', - 'settings.alwaysShowEmpty': 'Всегда показывать экран без карты', + 'settings.keepOpenWithoutCard': 'Держать лаунчер открытым без карты', 'settings.disableSilentInstall': 'Отключить тихую установку (показывать мастер установщика)', 'settings.steamAutoLaunch': 'Открывать Playhook в Steam при вставке карты (только Game Mode)', 'settings.steamAutoLaunchHint': 'Выключение освобождает около 120 МБ ОЗУ: фоновая служба перестаёт работать. Плитка в Steam останется — запускайте из библиотеки.', - 'settings.wallpaperLabel': 'Фон пустого экрана', - 'settings.wallpaperChoose': 'Выбрать изображение…', - 'settings.wallpaperReset': 'Сбросить', + 'settings.sectionMetadata': 'Метаданные игр', + 'settings.steamGridDbKey': 'API-ключ SteamGridDB', + 'settings.steamGridDbKeyEmpty': 'Не задан', + 'settings.steamGridDbKeyHint': + 'Необязательно. С ключом «Найти в интернете» дополнительно предлагает обложки и фоны из SteamGridDB. Свой ключ можно получить на steamgriddb.com, в разделе Preferences → API.', 'settings.sectionAudio': 'Звук', 'settings.soundSet': 'Звуки навигации', 'settings.soundSetVolume': 'Громкость звуков навигации', @@ -154,138 +165,261 @@ export const ru: Partial<Record<MessageKey, string>> = { 'settings.onlyGlobalAmbientHint': 'Если включено, играет только общий эмбиент — собственная фоновая музыка игры не воспроизводится.', 'settings.ambientVolume': 'Громкость эмбиента', - 'settings.sectionAdvanced': 'Дополнительно', 'settings.openLogs': 'Открыть логи', 'settings.openGames': 'Открыть папку игр', 'settings.reset': 'Сбросить настройки', - 'settings.titlebarVersion': '({version}) — Настройки', + 'settings.confirmReset': 'Сбросить все настройки к значениям по умолчанию?', 'settings.status.idle': 'Проверьте обновления, чтобы узнать о новой версии.', 'settings.status.upToDate': 'У вас последняя версия.', - 'settings.status.checking': 'Проверка обновлений…', + 'settings.status.checking': 'Проверка обновлений...', 'settings.status.available': 'Доступно обновление: {version}', - 'settings.status.downloading': 'Загрузка… {percent}%', + 'settings.status.downloading': 'Загрузка... {percent}%', 'settings.status.downloaded': 'Обновление {version} готово к установке.', 'settings.status.unsupported': 'Обновления доступны только в установленной сборке.', + 'settings.status.unsupportedPlatform': + 'На macOS Playhook не обновляется сам - скачайте новый .dmg со страницы Releases и замените приложение. Игры, статистика и сейвы сохранятся.', 'settings.action.check': 'Проверить обновления', - 'settings.action.checking': 'Проверка…', + 'settings.action.checking': 'Проверка...', 'settings.action.updateTo': 'Обновить до {version}', - 'settings.action.downloading': 'Загрузка…', + 'settings.action.downloading': 'Загрузка...', 'settings.action.restartInstall': 'Перезапустить и установить', 'settings.action.retry': 'Повторить', - // ── Configure-game window ──────────────────────────────────────────────────── - 'configure.card': 'Карта', - 'configure.insertCard': 'Вставьте SD-карту или флешку.', - 'configure.game': 'Игра', - 'configure.addGame': 'Добавить игру', - 'configure.removeGame': 'Удалить текущую', - 'configure.confirmRemoveGame': 'Удалить текущую игру с этой карты?', - 'configure.gameOption': '{index} / {count} · {title}', - 'configure.otherGameIssue': 'Игра {index} ({title}): {message}', - 'configure.untitledGame': 'без названия', - 'configure.save': 'Сохранить и применить', - 'configure.titlebarVersion': '({version}) — Настройка игры', - 'configure.configValid': 'Конфигурация корректна.', - 'configure.idChangedWarning': - 'Внимание: id изменён ({from} → {to}). Статистика времени в игре привязана к id и обнулится для нового id.', - 'configure.cardGone': 'Выбранная карта больше недоступна. Ваш текст сохранён.', - 'configure.blankDrive': 'Пустой диск — заполните игру и сохраните.', - 'configure.couldNotRead': 'Не удалось прочитать game.json: {message}', - 'configure.confirmSwitch': 'Отменить несохранённые изменения и переключить карту?', - 'configure.confirmReset': 'Отменить несохранённые изменения и перечитать с карты?', - 'configure.fixSyntax': 'Исправьте синтаксические ошибки JSON перед форматированием.', - 'configure.saving': 'Сохранение…', - 'configure.notSaved': 'Не сохранено: {message}', - 'configure.applied': 'Применено. Лаунчер обновлён.', - 'configure.deferred': - 'Сохранено. Загрузится в ближайшее время или после извлечения активной карты.', - 'configure.savedRejected': 'Сохранено, но манифест отклонён: {message}', - 'configure.unknownReason': 'неизвестная причина', - - // ── Configure-game window: интерактивная форма ─────────────────────────────── - 'configure.tabJson': 'JSON', - 'configure.reset': 'Сбросить', - 'configure.sectionBasics': 'Основное', - 'configure.sectionLaunch': 'Запуск', - 'configure.sectionHero': 'Изображения', - 'configure.sectionSaves': 'Сохранения', - 'configure.sectionAudio': 'Звук', - 'configure.sectionAdvanced': 'Дополнительно', - 'configure.fieldId': 'Идентификатор игры', - 'configure.idHint': - 'Заполняется из названия. Отредактируйте, чтобы задать свой; очистите - снова подставится.', - 'configure.fieldTitle': 'Название', - 'configure.schemaVersion': 'Версия схемы: 1', - 'configure.launchType': 'Тип запуска', - 'configure.launchExecutable': 'Исполняемый файл', - 'configure.launchInstaller': 'Установщик', - 'configure.fieldExecutable': 'Путь к исполняемому файлу', - 'configure.executableNote': 'Относительно корня карты.', - 'configure.fieldArgs': 'Аргументы', - 'configure.fieldRunAsAdmin': 'Запуск от имени администратора', - 'configure.fieldCopyToPc': 'Переместить игру на ПК', - 'configure.copyExecutableNote': - 'Относительно директории игры, указанной ниже: игра копируется на ПК, и исполняемый файл ищется внутри копии.', - 'configure.fieldCopySource': 'Директория игры на карте', - 'configure.copySourceHint': - 'Корень папки самой игры на карте. По нажатию «Install» она копируется на ПК; копия на карте остаётся.', - 'configure.copySourceOutside': - 'Этот файл вне директории игры ({source}) - выберите файл внутри неё или сначала поправьте директорию.', - 'configure.fieldInstaller': 'Путь к установщику', - 'configure.fieldInstallType': 'Тип установщика', - 'configure.fieldInstallArgs': 'Аргументы установщика', - 'configure.installArgsDirHint': - 'Для установщика custom ровно один аргумент должен содержать плейсхолдер {dir}.', - 'configure.installerExperimental': - '⚠ Экспериментально: тип «Установщик» может работать непредсказуемо (особенно на Linux).', - 'configure.installerLinuxWarning': - 'На Linux/Steam Deck установщики непредсказуемы: их много разных видов, и под Proton часть из них падает или зависает. Лучше использовать «Переместить игру на ПК» или обычный исполняемый файл.', - 'configure.fieldWinetricks': 'Winetricks игры (Linux)', - 'configure.winetricksHint': - 'Доп. winetricks-вербы/настройки (например d3dx9, или vd=1920x1080 для виртуального рабочего стола), применяемые к Wine-префиксу перед запуском игры, поверх встроенного набора. Только Linux/Proton; на Windows игнорируется.', - 'configure.fieldInstallWinetricks': 'Winetricks установщика (Linux)', - 'configure.installWinetricksHint': - 'Доп. winetricks-вербы, устанавливаемые перед запуском установщика, поверх встроенного набора. Только Linux/Proton; на Windows игнорируется.', - 'configure.fieldUmuGameId': 'umu GAMEID (Linux)', - 'configure.umuGameIdHint': - 'Steam appid или кастомный UMU_ID — umu применит protonfix этой игры вместо дефолтного. Пусто = umu-default. Только Linux/Proton.', - 'configure.fieldAppid': 'Steam appid', - 'configure.fieldWatchProcesses': 'Отслеживаемые процессы', - 'configure.watchProcessesHint': '1–16 имён процессов с расширением .exe.', - 'configure.fieldHeroImages': 'Фоны', - 'configure.heroImagesHint': 'До 3 фонов; если их несколько, они сменяются раз в минуту.', - 'configure.fieldGridImage': 'Карточка игры', - 'configure.gridImageHint': - 'Карточка игры в карусели истории лаунчера. Нужно вертикальное изображение 600x900 (тот же формат, что у Steam). Необязательно - без неё карточка обрезается из первого фона.', - 'configure.gridImageHelp': 'Найти обложку 600x900 на SteamGridDB', - 'configure.fieldSaveOnCard': 'Папка сохранений на карте', - 'configure.fieldPcSavePath': 'Путь сохранений на ПК', - 'configure.fieldBackgroundMusic': 'Фоновая музыка', - 'configure.fieldLaunchTimeout': 'Таймаут запуска (секунды)', - 'configure.fieldKillTimeout': 'Таймаут принудительного закрытия (секунды)', - 'configure.browse': 'Обзор…', - 'configure.add': 'Добавить', - 'configure.addFile': 'Добавить…', - 'configure.replace': 'Заменить…', - 'configure.remove': 'Удалить', - 'configure.dragReorder': 'Перетащите для изменения порядка', - 'configure.corruptField': - 'Поле содержит недопустимое значение; при редактировании оно будет заменено.', - 'configure.fixSyntaxSwitch': 'Исправьте синтаксические ошибки JSON перед переключением на форму.', - 'configure.mixedLaunchModes': - 'В манифесте указано несколько типов запуска. Активным останется только «{mode}»; при сохранении остальные блоки будут удалены.', - 'configure.musicNoneHint': 'Без фоновой музыки.', - 'configure.appidHelp': 'Найти appid на SteamDB', - 'configure.pickOutsideCard': 'Выбранный файл вне карты. Выберите файл на карте.', - 'configure.pickChooseSubfolder': 'Выберите подпапку карты, а не её корень.', - 'configure.pickPcSaveOutside': + // ── Customize screen: the launcher's own per-game editor (gameConfig:* channels) ── + 'gameConfig.thisPc': 'Этот ПК', + 'gameConfig.homeFolder': 'Домашняя папка', + 'gameConfig.pickOutsideCard': 'Выбранный файл вне карты. Выберите файл на карте.', + 'gameConfig.pickChooseSubfolder': 'Выберите подпапку карты, а не её корень.', + 'gameConfig.pickPcSaveOutside': 'Эта папка не находится в известном месте сохранений (%DOCUMENTS%, %APPDATA%, %LOCALAPPDATA%, %LOCALLOW% или %USERPROFILE%). Выберите папку внутри одного из них.', + 'gameConfig.pickImportFailed': 'Не удалось скопировать выбранный файл в локальную библиотеку.', + 'gameConfig.pickMissing': 'Этого файла больше нет.', + 'gameConfig.pickSymlink': 'Это ссылка на другое место - выберите сам файл.', + 'gameConfig.pickNeedsFolder': 'Для этого поля нужна папка.', + 'gameConfig.pickNeedsFile': 'Для этого поля нужен файл.', + 'gameConfig.pickWrongType': 'Такой тип файла не подходит для этого поля.', + 'gameConfig.listFailed': 'Не удалось открыть эту папку.', + 'gameConfig.moveGameBusy': 'Дождитесь окончания текущей установки или запуска и попробуйте снова.', + 'gameConfig.moveIdTaken': 'На этой карте уже есть игра с таким же id.', + 'gameConfig.moveIdChanged': + 'Во время переноса id менять нельзя - сначала перенесите игру, потом переименуйте её на карте.', + 'gameConfig.moveLibraryInvalid': + 'Сначала нужно починить проблему в другой игре PC-библиотеки: {reason}', + 'gameConfig.moveFilesNotOnCard': 'Сначала положите файлы самой игры на карту.', + 'gameConfig.moveAssetMissing': 'Этого файла больше нет на ПК, перенести его нельзя: {path}', + + // ── Customize screen: what the SCREEN itself says (game-settings-*.ts) ────── + 'launcher.menu.customize': 'Настройки игры', + + // ── Notifications (the toast + the Notifications popup) ──────────────────── + 'notifications.updateReady': 'Обновление {version} готово - установится при перезапуске', + 'notifications.gameInstalled': '{title} установлена', + 'notifications.gameUninstalled': '{title} удалена', + 'notifications.gameAddedDeferred': + '{title} записана на карту. Появится, когда эта карта станет активной.', + 'notifications.gameMovedDeferred': + '{title} перенесена на карту. Появится, когда эта карта станет активной.', + 'notifications.gameMoveSaveSkipped': + '{title} перенесена на карту, но в папке сейвов на карте уже что-то было - сейвы с ПК не скопированы.', + 'notifications.settingsWriteFailed': + 'Настройки не сохранились и после перезапуска вернутся как были: у Playhook нет доступа на запись к своему файлу настроек.', + 'notifications.gameMoveDuplicate': + '{title} записана на карту, но её не удалось убрать из PC-библиотеки - теперь она есть в обоих местах. Удалите локальную копию через настройки игры.', + 'notifications.empty': 'Уведомлений нет', + 'notifications.clearAll': 'Очистить всё', + 'notifications.yesterday': 'вчера, {time}', + + // ── On-screen keyboard + file browser (osk.ts / file-picker.ts) ──────────── + 'osk.shift': 'Shift', + 'osk.backspace': 'Стереть', + 'osk.space': 'Пробел', + 'osk.done': 'Готово', + 'osk.cancel': 'Отмена', + 'osk.paste': 'Вставить', + 'osk.legendDelete': 'X - стереть', + 'osk.legendShift': 'Y - регистр', + 'osk.legendLayout': 'LB/RB - раскладка', + 'osk.legendDone': 'RT - готово', + 'osk.legendCancel': 'B - отмена', + 'picker.title': 'Выбор', + 'picker.cancel': 'Отмена', + 'picker.up': 'На уровень выше', + 'picker.useThisFolder': 'Выбрать эту папку', + 'picker.empty': 'Здесь пусто.', + 'picker.legend': + 'A - открыть или выбрать, B - на уровень выше, Y - действия, влево/вправо - сменить колонку', + 'picker.legendMulti': + 'X - отметить, A - выбрать, B - на уровень выше, Y - действия, влево/вправо - сменить колонку', + + 'gameSettings.screenTitle': 'Настройки игры', + 'gameSettings.addTitle': 'Добавить игру', + 'gameSettings.loading': 'Читаем манифест...', + 'gameSettings.sectionBasics': 'Основное', + 'gameSettings.sectionLaunch': 'Запуск', + 'gameSettings.sectionImages': 'Оформление', + 'gameSettings.sectionSaves': 'Сохранения', + 'gameSettings.sectionAudio': 'Звук', + 'gameSettings.sectionAdvanced': 'Дополнительно', + 'gameSettings.sectionLinux': 'Linux', + 'gameSettings.notSet': 'не задано', + 'gameSettings.listEmpty': 'пусто', + 'gameSettings.source': 'Куда добавить', + 'gameSettings.sourceHint': + 'Карта носит игру с собой; игра, добавленная на этот ПК, остаётся на этой машине.', + 'gameSettings.title': 'Название', + 'gameSettings.id': 'Идентификатор', + 'gameSettings.idChangedWarning': + 'Смена идентификатора отвяжет игру от её наигранного времени, бэкапов сохранений и записи в библиотеке на этом ПК.', + 'gameSettings.launchMode': 'Тип запуска', + 'gameSettings.modeExecutable': 'Запуск с карты', + 'gameSettings.modeInstaller': 'Установка с карты', + 'gameSettings.modeSteam': 'Steam', + 'gameSettings.modePc': 'Исполняемый файл', + 'gameSettings.modeNone': 'Пока не настроен', + 'gameSettings.modeNoneHint': 'Игра не запустится, пока это не заполнено', + 'gameSettings.mixedLaunchModes': + 'В манифесте описано несколько типов запуска. Останется только выбранный, остальные при сохранении удалятся.', + 'gameSettings.executable': 'Исполняемый файл', + 'gameSettings.executableHint': 'Относительно корня карты.', + 'gameSettings.executableCopyHint': + 'Относительно указанной ниже папки игры на карте, которая копируется.', + 'gameSettings.executableInstallHint': 'Относительно папки, в которую установится игра.', + 'gameSettings.pcExecutable': 'Исполняемый файл', + 'gameSettings.args': 'Аргументы запуска', + 'gameSettings.runAsAdmin': 'Запускать от администратора', + 'gameSettings.copyToPc': 'Перенести игру на ПК', + 'gameSettings.copyToPcHint': + 'Игра копируется на этот ПК и запускается оттуда; на карте она остаётся.', + 'gameSettings.copyDirectory': 'Папка игры на карте', + 'gameSettings.copyDirectoryHint': 'Копируемая папка - корень самой игры, а не корень карты.', + 'gameSettings.installer': 'Установщик', + 'gameSettings.installType': 'Тип установщика', + 'gameSettings.installNsis': 'NSIS', + 'gameSettings.installInno': 'Inno Setup', + 'gameSettings.installCustom': 'Свой (запускается вручную)', + 'gameSettings.installRunAsAdmin': 'Запускать установщик от администратора', + 'gameSettings.installCustomHint': + 'Свой установщик запускаете вы, поэтому повышение прав запрашивать нам нечего.', + 'gameSettings.installArgs': 'Аргументы установщика', + 'gameSettings.installWinetricks': 'Winetricks для установщика', + 'gameSettings.steamAppid': 'Steam appid', + 'gameSettings.steamAppidHint': 'Число из адреса игры в магазине Steam.', + 'gameSettings.watchProcesses': 'Отслеживаемые процессы', + 'gameSettings.watchProcessesHint': + 'По ним видно, что игра ещё идёт, если она стартует через свой лаунчер.', + 'gameSettings.heroImage': 'Фоны', + 'gameSettings.gridImage': 'Обложка карточки', + 'gameSettings.gridImageAuto': 'обрезается из первого фона', + 'gameSettings.saveOnCard': 'Папка сохранений на карте', + 'gameSettings.saveOnCardHint': 'Сюда копируются сохранения после завершения игры.', + 'gameSettings.pcSavePath': 'Папка сохранений на ПК', + 'gameSettings.backgroundMusic': 'Фоновая музыка', + 'gameSettings.musicNone': 'без музыки', + 'gameSettings.launchTimeout': 'Таймаут запуска', + 'gameSettings.killTimeout': 'Таймаут принудительного закрытия', + 'gameSettings.launchTimeoutHint': + 'Сколько ждать появления игры после запуска, прежде чем считать, что она не стартовала.', + 'gameSettings.killTimeoutHint': + 'Сколько ждать, пока игра действительно завершится, прежде чем сообщить, что закрыть её не вышло.', + 'gameSettings.defaultSeconds30': '30 с (по умолчанию)', + 'gameSettings.defaultSeconds60': '60 с (по умолчанию)', + 'gameSettings.winetricks': 'Winetricks', + 'gameSettings.winetricksHint': + 'Дополнительные verbs, устанавливаемые в префикс перед запуском игры (Linux).', + 'gameSettings.umuGameId': 'umu GAMEID', + 'gameSettings.umuGameIdAuto': 'автоматически', + 'gameSettings.umuGameIdHint': 'Применяет protonfix конкретной игры вместо общего (Linux).', + 'gameSettings.save': 'Сохранить', + 'gameSettings.moveToCard': 'Перенести на карту', + 'gameSettings.add': 'Добавить', + 'gameSettings.reset': 'Отменить правки', + 'gameSettings.delete': 'Удалить игру', + 'gameSettings.viewImage': 'Посмотреть', + 'gameSettings.cannotSave': + 'Чтобы сохранить, исправьте отмеченные ошибки. Каждое такое поле выделено полосой слева.', + 'gameSettings.browse': 'Выбрать...', + 'gameSettings.clear': 'Очистить', + 'gameSettings.listAdd': 'Добавить...', + 'gameSettings.listReplace': 'Заменить...', + 'gameSettings.listMoveUp': 'Выше', + 'gameSettings.listMoveDown': 'Ниже', + 'gameSettings.listRemove': 'Удалить', + 'gameSettings.saving': 'Сохраняем...', + 'gameSettings.savedApplied': 'Сохранено и применено.', + 'gameSettings.savedDeferred': 'Сохранено. Применится, когда эта карта станет активной.', + 'gameSettings.savedNotApplied': 'Сохранено. Применится после выхода из игры.', + 'gameSettings.slotUnreadable': 'Эту игру нельзя показать формой: {message}', + 'gameSettings.slotNotFound': 'В манифесте больше нет игры с идентификатором "{id}".', + 'gameSettings.otherGameUnnamed': 'без названия', + 'gameSettings.otherGameIssue': 'Ошибка в игре {number} ({game}): {field} - {message}', + 'gameSettings.confirmReset': 'Отменить правки этой игры и перечитать манифест?', + 'gameSettings.confirmDiscard': 'Выйти без сохранения? Изменения будут потеряны.', + 'gameSettings.confirmSwitchSource': + 'Добавить игру в другое место? Пути и настройки установщика будут очищены - название и остальное останутся.', + 'gameSettings.confirmCancelMove': + 'Прервать перенос игры на карту? Пока ничего не записано - игра останется в PC-библиотеке.', + 'gameSettings.moveToCardTitle': 'Перенести на карту', + 'gameSettings.moveNoCards': 'Вставьте карту, чтобы перенести на неё игру.', + 'gameSettings.confirmDelete': 'Удалить "{title}" из манифеста?', + 'gameSettings.confirmDeleteHistory': 'Убрать "{title}" ещё и из библиотеки?', + 'gameSettings.confirmDeleteHistoryNote': + 'Да - карточка игры пропадёт из карусели вместе со скопированными на этот ПК картинками. Нет - игра удалится, карточка останется. Закрыть вопрос - отменить удаление; наигранное время сохранится в любом случае.', + 'gameSettings.confirmDeleteNote': + 'Файлы игры останутся на месте. Несохранённые изменения на этом экране будут отброшены.', + 'gameSettings.confirmDeleteSavesNote': + 'Файлы игры останутся на месте, бэкапы сохранений тоже. Несохранённые изменения на этом экране будут отброшены.', // ── User-facing errors from main ───────────────────────────────────────────── + // ── Online metadata (main/metadata/*) ───────────────────────────────────── + 'metadata.findOnline': 'Найти в интернете', + 'metadata.searchTitle': 'Поиск игры', + 'metadata.searching': 'Идёт поиск', + 'metadata.nothingFound': 'Ничего не найдено. Попробуйте другое название.', + 'metadata.game': 'Игра', + 'metadata.sections': 'Разделы', + 'metadata.noCandidate': 'Игра не выбрана', + 'metadata.applyMode': 'Уже есть фоны', + 'metadata.searchAgain': 'Искать заново', + 'metadata.applyTitle': 'Обновить название', + 'metadata.cover': 'Обложка', + 'metadata.backgrounds': 'Фоны', + 'metadata.music': 'Музыка', + 'metadata.noArtwork': 'Для этой игры не нашлось изображений.', + 'metadata.applySelected': 'Применить ({count})', + 'metadata.clearPicked': 'Снять отмеченное ({count})', + 'metadata.loadMore': 'Загрузить ещё', + 'metadata.filterSource': 'Источник', + 'metadata.filterSize': 'Размер', + 'metadata.filterAny': 'Любой', + 'metadata.actionClose': 'Закрыть', + 'metadata.needsId': 'Сначала задайте id игры - скачанные файлы называются по нему.', + 'metadata.applying': 'Идёт скачивание', + 'metadata.titleConfirm': 'Заменить название игры на «{title}»?', + 'metadata.applied': 'Применено. Сохраните игру, чтобы закрепить.', + 'metadata.heroAppend': 'Добавить к ним ({count})', + 'metadata.heroRoom': 'Свободных мест: {count}', + 'metadata.heroReplace': 'Заменить все', + 'metadata.appliedPartly': 'Применено. Не поместилось: {count}.', + 'metadata.albums': 'Альбомы', + 'metadata.noAlbums': 'Для этой игры не нашлось саундтрека.', + 'metadata.noTracks': 'В этом альбоме нет треков.', + 'metadata.listen': 'Прослушать', + 'metadata.stopListen': 'Остановить', + 'metadata.pickAlbum': 'Выберите альбом слева.', + 'metadata.useTrack': 'Применить', + 'metadata.downloading': 'Скачивание трека', + 'metadata.noSources': 'Сейчас нет доступных источников метаданных.', + 'metadata.staleSelection': 'Этот вариант больше недоступен. Выполните поиск заново.', + 'metadata.downloadFailed': 'Не удалось скачать файл.', + 'metadata.unsupportedFile': 'Скачанный файл не является поддерживаемым изображением или аудио.', + 'metadata.writeFailed': 'Не удалось сохранить скачанный файл.', + 'metadata.badRequest': 'Не удалось применить этот вариант.', + 'metadata.noDescriptions': 'Для этой игры нет описания.', 'errors.finishBeforeApply': 'Завершите текущие операции перед применением конфигурации', 'errors.reloadInProgress': 'перезагрузка уже выполняется', 'errors.steamNotInstalled': 'Steam не установлен', - 'errors.steamBusyOther': 'В Steam сейчас качается или удаляется другая игра. Дождитесь завершения.', + 'errors.steamBusyOther': + 'В Steam сейчас качается или удаляется другая игра. Дождитесь завершения.', 'errors.steamOpenInstall': 'не удалось открыть установку в Steam: {cause}', 'errors.steamOpenDownloads': 'не удалось открыть загрузки Steam: {cause}', 'errors.steamOpenUninstall': 'не удалось открыть удаление в Steam: {cause}', @@ -302,19 +436,33 @@ export const ru: Partial<Record<MessageKey, string>> = { 'errors.killFailed': 'не удалось принудительно закрыть игру (часть процессов ещё работает)', 'errors.finishBeforeInstall': 'Завершите текущие операции перед установкой обновления.', 'errors.driveUnavailable': 'выбранный диск больше недоступен', + 'errors.gameNotFound': 'эта игра сейчас недоступна', + 'errors.mediaChanged': + 'в этом слоте уже другая карта - не та, из которой была прочитана игра; откройте экран заново', 'errors.cannotReadManifest': 'не удалось прочитать {file}: {cause}', 'errors.cannotWriteManifest': 'не удалось записать {file}: {cause}', 'errors.configInvalid': 'конфигурация некорректна', 'errors.powerUnsupported': 'действия питания доступны только в Windows', 'errors.powerFailed': 'команда питания не выполнена: {cause}', - 'errors.wallpaperTooLarge': 'Изображение слишком большое (больше 8 МБ). Выберите файл поменьше.', - 'errors.wallpaperNotImage': 'Это не поддерживаемое изображение (PNG, JPEG, WebP или GIF).', - 'errors.wallpaperFailed': 'Не удалось установить фоновое изображение.', + + // ── Отказы macOS (platform/darwin) ────────────────────────────────────────── + 'errors.macWindowsGame': + 'Windows-игры не запускаются на macOS - эта игра стартует *.exe. Нативные mac-игры и режим Steam работают.', + 'errors.macInstallUnsupported': 'установка игры с карты не поддерживается на macOS', + 'errors.macAppBundleUnreadable': + 'не удалось найти исполняемый файл внутри app-бандла: {path} (нет или не читается Contents/MacOS)', + 'errors.macGameBlocked': + 'macOS заблокировала игру (Gatekeeper): файл на карантине или без подписи. Разрешите его в «Системных настройках» → «Конфиденциальность и безопасность» либо выполните: xattr -dr com.apple.quarantine "{path}"', + 'errors.macPowerNotPermitted': + 'macOS не разрешила Playhook управлять системой. Выдайте доступ в «Системных настройках» → «Конфиденциальность и безопасность» → «Автоматизация» → Playhook → System Events.', // ── Manifest validation (JSON field names stay latin identifiers) ──────────── 'manifest.idPattern': 'id должен соответствовать [A-Za-z0-9._-]', 'manifest.idDots': 'id не может быть . или ..', - 'manifest.watchProcessesName': 'элементы watchProcesses должны быть простым именем *.exe', + 'manifest.watchProcessesName': + 'элементы watchProcesses должны быть простым именем файла (буквы, цифры, «. _ -», пробелы) - без разделителей пути', + 'manifest.watchProcessesBlank': 'элементы watchProcesses не могут быть пустыми', + 'manifest.watchProcessesDots': 'элементы watchProcesses не могут быть . или ..', 'manifest.winetricksName': 'элементы winetricks должны быть именами вербов или настройками key=value (буквы, цифры, _.=-)', 'manifest.umuGameIdName': 'umuGameId должен быть Steam appid или UMU_ID (буквы, цифры, _-)', @@ -329,7 +477,18 @@ export const ru: Partial<Record<MessageKey, string>> = { 'manifest.runAsAdminWithSteam': 'runAsAdmin недопустим в режиме steam', 'manifest.watchProcessesRequired': 'watchProcesses обязателен в режиме steam', 'manifest.executableRequired': 'executable обязателен', + 'manifest.pcWithSteam': 'pc нельзя указывать вместе со steam', + 'manifest.pcWithInstall': 'pc нельзя указывать вместе с install', + 'manifest.pcWithExecutable': 'executable недопустим в режиме pc (используйте pc.executable)', + 'manifest.pcWithSaveOnCard': + 'saveOnCard недопустим для локальной игры (резервную копию хранит Playhook)', + 'manifest.pcOnCard': 'блок pc допустим только для локальных игр, но не на карте', + 'manifest.executableOnPcLibrary': 'executable недопустим в PC-библиотеке (используйте pc.executable)', + 'manifest.installOnPcLibrary': 'install недопустим в PC-библиотеке (используйте pc.executable)', + 'manifest.pcExecutableAbsolute': 'pc.executable должен быть абсолютным путём: {path}', 'manifest.pcSavePathPrefix': 'pcSavePath должен начинаться с {prefixes}', + 'manifest.pcSavePathPrefixOrAbsolute': + 'pcSavePath должен быть абсолютным путём или начинаться с {prefixes}', 'manifest.pcSavePathNotAllowed': 'префикс pcSavePath %{prefix}% недопустим (используйте {prefixes})', 'manifest.pcSavePathUnavailable': 'префикс pcSavePath %{prefix}% недоступен в этой системе', diff --git a/src/shared/types.ts b/src/shared/types.ts index 3d5671b7..580ae5d2 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2,6 +2,7 @@ // Types only — the file compiles to empty JS and creates no runtime dependencies, // so the renderer can import from here via `import type` without require. import type { Locale } from './i18n/index'; +import type { ArtworkQuality } from './artwork-filter'; /** Display name (window title / tray tooltip). The %APPDATA% data folder is derived separately by * Electron from package.json `name` (currently "playhook"). */ @@ -13,9 +14,26 @@ export const MANIFEST_FILENAME = 'game.json' as const; /** File name of the stats copy on the card (best-effort). */ export const CARD_STATS_FILENAME = 'stats.json' as const; +/** + * 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/<id>/` standing in for + * the card's save copy), so the whole manifest/asset/history pipeline reads it as an always-inserted + * card. See ManifestSource. + */ +export const PC_LIBRARY_DIRNAME = 'pc-games' as const; + +/** + * Where a manifest came from. `card` — an inserted removable card (UNTRUSTED: every path must stay + * inside its root). `pc` — the local library in `<userData>/pc-games`, whose games live wherever the + * user installed them, so `pc.executable` (and a `pcSavePath`) may be ABSOLUTE. The two modes are + * mutually exclusive per manifest: a card manifest carrying a `pc` block is rejected, and so is a PC + * manifest without one. + */ +export type ManifestSource = 'card' | 'pc'; + /** * How many hero backgrounds one game may carry — a CARD-FORMAT limit (not a library budget), so it lives - * in the shared contract: main enforces it (manifest.ts) and the Configure form caps its picker by it. + * in the shared contract: main enforces it (manifest.ts) and the Customize screen caps its list by it. * * Enforced with the same split as the "≥1 heroImage" policy: the EDITOR rejects a 4th image (it gates * Save), the runtime stays lenient — readManifests keeps the first three and logs a warn. A hard cap in @@ -127,6 +145,21 @@ export interface SteamManifest { 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 (`<userData>/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; @@ -147,6 +180,10 @@ export interface GameManifest { * 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[]; /** @@ -182,8 +219,26 @@ export interface GameManifest { * `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 (Р7b): 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. @@ -199,8 +254,21 @@ export interface GameManifest { } /** UI sound-effect slots. Each maps to a file in the bundled set chosen in Settings → Audio; a card - * cannot supply its own (the `sounds` block in an old game.json is ignored, not rejected). */ -export type SfxName = 'play' | 'navigate' | 'button' | 'back'; + * cannot supply its own (the `sounds` block in an old game.json is ignored, not rejected). + * `limit` is the dead end — a press that changed nothing (end of a list, a button with no meaning + * here); `popup-open`/`popup-close` mark a surface appearing over the screen and going away; `typing` + * is a character going into the on-screen keyboard, which is a keystroke rather than navigation. The + * kebab-case names are the file basenames, kept 1:1 so SFX_SLOT_FILE stays trivial. */ +export type SfxName = + | 'play' + | 'navigate' + | 'button' + | 'back' + | 'notify' + | 'limit' + | 'popup-open' + | 'popup-close' + | 'typing'; /** * Manifest with already-resolved and security-checked paths. @@ -210,6 +278,12 @@ export type SfxName = 'play' | 'navigate' | 'button' | 'back'; 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 `<installDir>/<executable>` (and `cwd` its * dirname) — it may NOT exist yet (that is exactly the "not installed" state). For a normal game it @@ -243,6 +317,8 @@ export interface ResolvedManifest { readonly steam?: { readonly appid: number; }; + /** PC library only: no launch method chosen yet — the game is visible but cannot be started. */ + readonly unconfigured?: true; } /** @@ -292,11 +368,18 @@ export interface LibraryEntry { readonly active: boolean; /** * Revision of this game's stored artwork — it changes whenever main re-copies the card's images. The - * renderer caches decoded covers by `id + artRev`, so editing `gridImage` in Configure and hitting - * Save & Apply shows the new cover immediately, instead of serving the cached one until a restart. + * renderer caches decoded covers by `id + artRev`, so editing `gridImage` on the Customize screen and + * hitting Save & Apply shows the new cover immediately, instead of serving the cached one until a + * restart. * Absent while the background copy hasn't produced a record yet. */ readonly artRev?: string; + /** + * PC library only: no launch method chosen yet. `active` stays true (the game IS the current local + * library — Customize must stay reachable), so consumers that gate on "ready to play" (the carousel + * dot, the "Ready to play" section, Play itself) must check this flag too, not `active` alone. + */ + readonly unconfigured?: true; } /** The carousel list, already in display order — the renderer never sorts it (see orderForCarousel). */ @@ -398,6 +481,20 @@ export interface GameInfo { * user cancelled Steam's dialog, by a timeout in the background poller (→ back to "Play"/"Uninstall"). */ readonly steamUninstalling?: boolean; + /** + * PC mode only: the game's executable is not on disk right now (deleted, or an external drive is + * unplugged). The card stays in the library with its art, stats and save backup — only Play is + * disabled and the status reads "Game files not found". Undefined for card games, whose executable is + * verified at read time (and whose absence drops them from the card instead). + */ + readonly unavailable?: boolean; + /** + * PC mode only: no launch method has been configured yet (a saved draft — see ResolvedManifest). The + * card stays in the library, fully editable, but Play is hidden and the status line is left EMPTY (the + * absent button already says it — see state-view.ts `statusOf`) — checked BEFORE `unavailable`, which + * does not apply (there is no executable to be missing). + */ + readonly unconfigured?: boolean; } /** The flow state machine (discriminated union). */ @@ -428,8 +525,9 @@ export type AppState = /** * Update state for the settings window (discriminated union). The UpdaterService owns the current * snapshot, returns it on request and pushes it on every change. Maps 1:1 onto electron-updater - * events (see updater.ts). `unsupported` is set immediately in dev / non-packaged builds, where - * self-update is a no-op — the settings window then just shows the version and an explanatory note. + * events (see updater.ts). `unsupported` is set immediately when this build cannot self-update at all — + * in dev / non-packaged, and on macOS (unsigned bundle, see UpdateUnsupportedReason) — and the settings + * screen then shows the version plus an explanation instead of the update controls. */ export type UpdateStatus = | { readonly kind: 'idle' } // not checked yet @@ -439,7 +537,18 @@ export type UpdateStatus = | { readonly kind: 'downloading'; readonly version: string; readonly percent: number } | { readonly kind: 'downloaded'; readonly version: string } // ready → "Restart & install" | { readonly kind: 'error'; readonly message: string } // → "Retry" - | { readonly kind: 'unsupported' }; // dev / not-packaged: update unavailable + | { readonly kind: 'unsupported'; readonly reason: UpdateUnsupportedReason }; + +/** + * WHY a build cannot self-update — the two cases need different words, and the settings screen shows + * different controls for them: + * - `not-packaged` — a dev run. Temporary and about the build, not the platform: the auto-update MODE is + * still worth showing and persisting there, because the installed build will honour it. + * - `platform` — macOS. Permanent for this distribution: Squirrel.Mac only updates a code-signed bundle + * and the mac build is unsigned (no Apple Developer ID), so updating means downloading the new dmg by + * hand. A mode selector would be a control that can never do anything, so the screen omits it. + */ +export type UpdateUnsupportedReason = 'not-packaged' | 'platform'; /** * Auto-update mode (persisted in settings.json). Maps onto electron-updater flags: @@ -480,18 +589,12 @@ export interface AppSettings { readonly musicVolume: number; /** Launcher UI sound-effects volume, 0..1. Default 1. */ readonly sfxVolume: number; - /** - * Custom Empty-screen wallpaper: the file name of the user's image copied into userData (e.g. - * `wallpaper-custom.png`), or `null` for the bundled default. We store only the file name — never the - * user's original path or the bytes (settings.json is rewritten whole on each patch). Default null. - */ - readonly customWallpaper: string | null; /** * Keep the launcher visible on the empty "no card" screen instead of hiding to the tray when no card * is present. Default false (the background-app behaviour: hidden until a card is detected). When true * the empty screen stays on card removal AND is shown at startup. */ - readonly alwaysShowEmptyScreen: boolean; + readonly keepOpenWithoutCard: boolean; /** * Disable trying silent mode for install-mode installers (Linux/Proton). Default false (installers run * unattended). When true, the installer shows its wizard so the user can click through steps a silent @@ -518,13 +621,14 @@ export interface AppSettings { * Name of the UI sound set used for navigation (the folder under `audio/ui/<set>/`) — the only source * of UI sounds there is. A plain string (not an enum): sets are enumerated dynamically from what ships * in the bundle, and a missing/incomplete folder falls back at read time (see AssetReader). - * Default 'winhanced'. `.default('winhanced')` migrates an older settings.json without the field. + * Default 'playhook-abyss'. `.default(…)` migrates an older settings.json without the field. */ readonly soundSet: string; /** * Default background ambience track (a file name under `audio/ambience/`, extension included), played * only when the current card has no music of its own — the game's music always wins. `null` = no - * ambience. Default null. `.default(null)` migrates an older settings.json without the field. + * ambience. Default 'playhook-abyss.mp3'; `.default(…)` migrates an older settings.json without the + * field. A name that is no longer bundled simply doesn't play (checked before reading — AssetReader). */ readonly ambientTrack: string | null; /** @@ -532,33 +636,101 @@ export interface AppSettings { * main, so the renderer just sees no game music). When false (default), a card's music wins. Default false. */ readonly onlyGlobalAmbient: boolean; + /** + * The user's own SteamGridDB API key, or `''` when they have not entered one. Empty by default and + * never shipped: an open-source build cannot carry a secret, so the alternative-artwork source is + * simply absent until the user pastes a key of their own (Settings → the SteamGridDB row). Stored in + * plain text alongside every other setting — the same trade every launcher with this feature makes. + */ + readonly steamGridDbApiKey: string; } /** The bundled UI sound sets + ambience tracks available to pick in the settings window. */ export interface AudioOptions { - /** Sound-set folder names under `audio/ui/` (e.g. `winhanced`, `ps5`); `winhanced` is always present. */ + /** Sound-set folder names under `audio/ui/` (e.g. `playhook-abyss`, `ps5`); the default is always present. */ readonly soundSets: readonly string[]; /** Ambience file names under `audio/ambience/`, extension included (e.g. `ps5.mp3`). */ readonly ambientTracks: readonly string[]; } -/** - * Result of picking a custom Empty-screen wallpaper (wallpaper:pick). A discriminated union so the - * settings renderer handles each outcome explicitly (untrusted external data → Result-union): the image - * data URL on success, a localized message on rejection (too large / not an image / copy failed), or a - * plain cancellation when the OS dialog was dismissed. - */ -export type WallpaperResult = - | { readonly ok: true; readonly dataUrl: string } - | { readonly ok: false; readonly message: string } - | { readonly ok: false; readonly cancelled: true }; - /** Launcher audio volumes (0..1), applied in the game renderer's AudioController. */ export interface AudioVolumes { readonly music: number; readonly sfx: number; } +// ── Notifications (main owns the inbox; the renderer only shows it) ───────────── + +/** What every notification carries, whatever it is about. */ +interface NotificationBase { + /** crypto.randomUUID(), assigned in main — the renderer addresses a notification by it. */ + readonly id: string; + /** epoch ms, the sort key (ascending: the newest sits at the END of a snapshot). */ + readonly at: number; + readonly read: boolean; +} + +/** + * One entry of the notification inbox, discriminated by `kind` so the renderer's text assembly is + * checked by the compiler. The TEXT is deliberately not stored: it is built in the renderer from the + * kind plus these fields, because the UI language changes live (app:language-update) and a stored + * string would freeze at the language of the moment it was written. + */ +export type AppNotification = + | (NotificationBase & { readonly kind: 'update-ready'; readonly version: string }) + | (NotificationBase & { + readonly kind: 'game-installed'; + readonly gameId: string; + readonly gameTitle: string; + }) + | (NotificationBase & { + readonly kind: 'game-uninstalled'; + readonly gameId: string; + readonly gameTitle: string; + }) + /** + * A game was added to a card that is NOT the active one, so it was written to disk and nothing else + * happened: the launcher's library cannot show it until that card becomes active. There is no `gameId` + * on purpose — the id names nothing the launcher can open, so pressing this entry only dismisses it. + */ + | (NotificationBase & { readonly kind: 'game-added-deferred'; readonly gameTitle: string }) + /** Same as `game-added-deferred`, but for a local game MOVED onto a card that is not active (Р2.5). */ + | (NotificationBase & { readonly kind: 'game-moved-deferred'; readonly gameTitle: string }) + /** + * A move to card succeeded, but its save folder already existed and was NOT empty on the card — the + * game's PC-side saves were left uncopied rather than overwriting someone else's progress there. + */ + | (NotificationBase & { readonly kind: 'game-move-save-skipped'; readonly gameTitle: string }) + /** + * The worst outcome a move can end in: the card was written, removing the game from the PC library + * failed, and undoing the card write failed too — so the game now exists in BOTH places. Defined + * behaviour rather than corruption (an inserted card shadows its local twin), but the user has to be + * told, because the screen reports the move as done and closes. + */ + | (NotificationBase & { readonly kind: 'game-move-duplicate'; readonly gameTitle: string }) + /** + * A settings change could not be written to disk, so it did not stick. Carries no detail: the cause is + * in the log, and the only thing the user can act on is that their setting did not save. + */ + | (NotificationBase & { readonly kind: 'settings-write-failed' }); + +// 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> = T extends unknown ? Omit<T, keyof NotificationBase> : never; + +/** What a source of events hands to NotificationsService.notify — the base fields are main's to fill. */ +export type NotificationInput = WithoutNotificationBase<AppNotification>; + +/** + * What the renderer must show OVER the UI (the toast plate, top right). Showing a plate does NOT make the + * notification read — that happens only when the popup is opened or an entry is pressed — so the dot + * beside the More item survives a toast the user may well have missed. + * `unread-summary` is the single plate shown after a game ends / after a long absence instead of a queue. + */ +export type NotificationToast = + | { readonly kind: 'item'; readonly item: AppNotification } + | { readonly kind: 'unread-summary'; readonly count: number }; + /** IPC channels (the preload typed bridge). */ export const IPC = { /** main → renderer: replica of the current AppState. */ @@ -604,8 +776,6 @@ export const IPC = { ambientUpdate: 'ambient:update', /** game-renderer → main (invoke): request the current ambience data URL (on window startup). */ ambientRequest: 'ambient:request', - /** main → game-renderer: play a one-shot UI sound (payload SfxName), e.g. "play" when an install ends. */ - sfxPlay: 'sfx:play', /** main → renderer: hero background images for the current game (or null when no card). */ heroUpdate: 'hero:update', /** renderer → main: request the current hero images (on window startup). */ @@ -621,6 +791,11 @@ export const IPC = { /** renderer → main: "the user is looking at this id" — main answers with browse:update + browse:hero * (+ browse:music). Does NOT change the selected game or the AppState. */ libraryBrowse: 'library:browse', + /** renderer → main: drop this game from the play history (its record + the copied artwork). Only ever + * accepted for a game that is NOT available right now — main re-checks that, the menu item is the + * renderer's half of the same rule. Saves and playtime survive: this forgets the catalogue entry, not + * the game. */ + libraryForget: 'library:forget', /** main → renderer: what is on screen (title/stats/active/GameInfo) — see BrowseInfo. */ browseUpdate: 'browse:update', /** renderer → main (invoke): the current BrowseInfo (seed on window startup, like state:request). */ @@ -640,8 +815,8 @@ export const IPC = { actionSelect: 'action:select', /** renderer → main: request the fallback wallpaper data URL (for the idle / empty screen). */ wallpaperRequest: 'wallpaper:request', - /** main → game-renderer: updated Empty-screen wallpaper data URL (pushed when changed in settings). */ - wallpaperUpdate: 'wallpaper:update', + /** renderer → main (invoke): the bundled startup jingle as a data URL (played once, on boot). */ + startupSoundRequest: 'audio:startup-request', /** game-renderer → main (invoke): request the current audio volumes (on window startup). */ volumeRequest: 'volume:request', /** main → game-renderer: updated audio volumes (pushed when changed in the settings window). */ @@ -651,137 +826,171 @@ export const IPC = { /** main → game-renderer: updated effective UI locale (pushed when the language changes). */ languageUpdate: 'app:language-update', - // ── Settings window: updates + app settings (separate namespace from the game channels) ── - /** main → settings-renderer: the current UpdateStatus snapshot (pushed on every change). */ + // ── Updates + app settings (the launcher's Settings screen; own namespace) ── + /** main → game-renderer: the current UpdateStatus snapshot (pushed on every change). */ updateStatusUpdate: 'update:status', - /** settings-renderer → main (invoke): request the current UpdateStatus. */ + /** game-renderer → main (invoke): request the current UpdateStatus. */ updateStatusRequest: 'update:request', - /** settings-renderer → main: run a manual update check. */ + /** game-renderer → main: run a manual update check. */ updateCheck: 'update:check', - /** settings-renderer → main: start downloading the available update (manual download). */ + /** game-renderer → main: start downloading the available update (manual download). */ updateDownload: 'update:download', - /** settings-renderer → main: install a downloaded update (quitAndInstall, guarded). */ + /** game-renderer → main: install a downloaded update (quitAndInstall, guarded). */ updateInstall: 'update:install', - /** settings-renderer → main (invoke): request the current AppSettings. */ + /** game-renderer → main (invoke): request the current AppSettings. */ settingsRequest: 'settings:request', - /** settings-renderer → main: change the auto-update mode (payload AutoUpdateMode). */ + /** main → game-renderer: the full AppSettings after ANY change (including a reset) — the Settings + * screen's single source of truth, pushed from AppSettingsStore's one write point. */ + settingsUpdate: 'settings:update', + /** game-renderer → main: change the auto-update mode (payload AutoUpdateMode). */ settingsSetAutoUpdate: 'settings:set-auto-update', - /** settings-renderer → main: toggle keeping the empty "no card" screen visible (payload boolean). */ - settingsSetAlwaysShowEmptyScreen: 'settings:set-always-show-empty-screen', - /** settings-renderer → main: toggle disabling silent installer mode (payload boolean). */ + /** game-renderer → main: toggle keeping the empty "no card" screen visible (payload boolean). */ + settingsSetKeepOpenWithoutCard: 'settings:set-keep-open-without-card', + /** game-renderer → main: toggle disabling silent installer mode (payload boolean). */ settingsSetDisableSilentInstall: 'settings:set-disable-silent-install', - /** settings-renderer → main: toggle Game Mode auto-launch on card insertion (payload boolean). */ + /** game-renderer → main: toggle Game Mode auto-launch on card insertion (payload boolean). */ settingsSetSteamAutoLaunch: 'settings:set-steam-auto-launch', - /** settings-renderer → main (invoke): whether the Steam-shortcut feature exists here (linux AppImage). */ + /** game-renderer → main (invoke): whether the Steam-shortcut feature exists here (linux AppImage). */ settingsSteamAvailable: 'settings:steam-available', - /** settings-renderer → main: change the UI theme (payload ThemeMode). */ - settingsSetTheme: 'settings:set-theme', - /** settings-renderer → main: toggle pre-release (beta) updates (payload boolean). */ + /** game-renderer → main: toggle pre-release (beta) updates (payload boolean). */ settingsSetPrerelease: 'settings:set-prerelease', - /** settings-renderer → main: toggle the Start+Back summon hotkey (payload boolean). */ + /** game-renderer → main: toggle the Start+Back summon hotkey (payload boolean). */ settingsSetSummonHotkey: 'settings:set-summon-hotkey', - /** settings-renderer → main: toggle keeping the display awake (no screensaver) (payload boolean). */ + /** game-renderer → main: toggle keeping the display awake (no screensaver) (payload boolean). */ settingsSetPreventScreensaver: 'settings:set-prevent-screensaver', - /** settings-renderer → main: set the background-music volume 0..1 (payload number). */ + /** game-renderer → main: set the background-music volume 0..1 (payload number). */ settingsSetMusicVolume: 'settings:set-music-volume', - /** settings-renderer → main: set the UI sound-effects volume 0..1 (payload number). */ + /** game-renderer → main: set the UI sound-effects volume 0..1 (payload number). */ settingsSetSfxVolume: 'settings:set-sfx-volume', - /** settings-renderer → main: change the UI language (payload LanguageMode). */ + /** game-renderer → main: change the UI language (payload LanguageMode). */ settingsSetLanguage: 'settings:set-language', - /** settings-renderer → main (invoke): request the current effective UI locale (on window startup). */ - settingsLanguageRequest: 'settings:language-request', - /** main → settings-renderer: updated effective UI locale (pushed when the language changes). */ - settingsLanguageUpdate: 'settings:language-update', - /** settings-renderer → main (invoke): reset all settings to defaults → returns the new AppSettings. */ + /** game-renderer → main (invoke): reset all settings to defaults → returns the new AppSettings. */ settingsReset: 'settings:reset', - /** settings-renderer → main (invoke): request the app version string. */ + /** game-renderer → main (invoke): request the app version string. */ appVersionRequest: 'app:version', - /** settings-renderer → main (invoke): request the app icon as a data URL (for the custom title bar). */ - appIconRequest: 'app:icon', - /** settings-renderer → main (invoke): the "move" UI sound of a GIVEN set as a data URL (volume preview). */ - moveSoundRequest: 'app:move-sound', - /** settings-renderer → main: change the navigation sound set (payload set name string). */ + /** game-renderer → main: change the navigation sound set (payload set name string). */ settingsSetSoundSet: 'settings:set-sound-set', - /** settings-renderer → main: change the default ambience track (payload file name string or null). */ + /** game-renderer → main: change the default ambience track (payload file name string or null). */ settingsSetAmbientTrack: 'settings:set-ambient-track', - /** settings-renderer → main: toggle using only the global ambience (payload boolean). */ + /** game-renderer → main: toggle using only the global ambience (payload boolean). */ settingsSetOnlyGlobalAmbient: 'settings:set-only-global-ambient', - /** settings-renderer → main (invoke): the bundled sound sets + ambience tracks to populate the dropdowns. */ + /** game-renderer → main (invoke): the bundled sound sets + ambience tracks to populate the dropdowns. */ audioOptionsRequest: 'app:audio-options', - /** settings-renderer → main: recolor the native title-bar overlay (caption buttons) for the theme. */ - titleBarOverlayUpdate: 'settings:titlebar-overlay', - /** settings-renderer → main: open the log folder in the OS file manager. */ - openLogs: 'app:open-logs', - /** settings-renderer → main: open the app-controlled games install folder in the OS file manager. */ - openGamesFolder: 'app:open-games-folder', - /** settings-renderer → main (invoke): pick a custom Empty-screen wallpaper via a file dialog → WallpaperResult. */ - wallpaperPick: 'wallpaper:pick', - /** settings-renderer → main (invoke): clear the custom Empty-screen wallpaper → the default data URL. */ - wallpaperClear: 'wallpaper:clear', - /** settings-renderer → main (invoke): current Empty-screen wallpaper data URL (for the settings preview). */ - wallpaperPreviewRequest: 'wallpaper:preview-request', - - // ── Configure-game window: edit/init a card's game.json (own namespace, own preload) ── - /** configure-renderer → main (invoke): snapshot of removable-drive candidates (incl. blank drives). */ - configDrivesRequest: 'config:drives-request', - /** main → configure-renderer: pushed drive-candidate list (only while the window is visible). */ - configDrivesUpdate: 'config:drives-update', - /** configure-renderer → main (invoke): read a card's game.json text (payload root). */ - configRead: 'config:read', - /** configure-renderer → main (invoke): static validation of manifest text (payload text). */ - configValidate: 'config:validate', - /** configure-renderer → main (invoke): write game.json + try to apply without a restart (payload {root,text}). */ - configSave: 'config:save', - /** configure-renderer → main (invoke): the manifest JSON Schema for the editor's completions/hover. */ - configSchemaRequest: 'config:schema-request', - /** configure-renderer → main (invoke): the current AppSettings (for the window theme). */ - configSettingsRequest: 'config:settings-request', - /** configure-renderer → main (invoke): the app icon as a data URL (for the custom title bar). */ - configIconRequest: 'config:icon', - /** configure-renderer → main (invoke): the app version string (for the custom title bar). */ - configVersionRequest: 'config:version', - /** main → configure-renderer: run an editor command from the native context menu (format). */ - configEditorCommand: 'config:editor-command', - /** configure-renderer → main: whether the JSON editor tab is active (gates the Format context-menu item). */ - configEditorActive: 'config:editor-active', - /** configure-renderer → main: recolor THIS window's native title-bar overlay for the theme. */ - configTitleBarOverlay: 'config:titlebar-overlay', - /** configure-renderer → main (invoke): request the current effective UI locale (on window startup). */ - configLanguageRequest: 'config:language-request', - /** main → configure-renderer: updated effective UI locale (pushed when the language changes). */ - configLanguageUpdate: 'config:language-update', - /** main → configure-renderer: updated UI theme, pushed live when the theme changes in settings so an - * open Configure window recolors without waiting for a hide/show. */ - configThemeUpdate: 'config:theme-update', - /** configure-renderer → main (invoke): pick file(s)/a folder from the card via a native dialog → - * ConfigPickResult (paths card-relative). Payload ConfigPickRequest. */ - configPickPath: 'config:pick-path', - /** configure-renderer → main (invoke): read a card-relative image into a data URL for the hero - * preview (or null when unreadable/outside root). Payload {root, path}. */ - configImagePreview: 'config:image-preview', - /** configure-renderer → main: open an external https URL in the default browser (e.g. the SteamDB - * appid lookup). Payload the URL string; main whitelists https. */ - configOpenExternal: 'config:open-external', -} as const; + /** game-renderer → main: store the user's SteamGridDB API key (payload string; '' clears it). */ + settingsSetSteamGridDbKey: 'settings:set-steamgriddb-key', -/** Editor commands dispatched from the Configure window's native right-click menu. Reset moved to a - * visible button, so `format` is the only remaining command. */ -export type ConfigEditorCommand = 'format'; + // ── Customize screen: per-game game.json editing INSIDE the launcher (own namespace) ── + // A namespace of its own rather than a move of `config:*`: the ipc-channels test requires a channel to + // belong to exactly one preload, so these were given names of their own rather than re-pointing the + // Configure window's `config:*` — which let that window keep working until its replacement was done. + /** game-renderer → main (invoke): the game.json TEXT of one game by id, plus which root/source it came + * from and the manifest's content signature (the swap guard for Save). Payload the game id. */ + gameConfigRead: 'gameConfig:read', + /** game-renderer → main (invoke): static validation of manifest text against a root's source. + * Payload {root, text}. */ + gameConfigValidate: 'gameConfig:validate', + /** game-renderer → main (invoke): write game.json + try to apply it without a restart. Payload + * {root, signature, text}; a signature mismatch means the media was swapped and the write is refused. */ + gameConfigSave: 'gameConfig:save', + /** game-renderer → main (invoke): read a root-relative image into a data URL for a row's thumbnail + * (null when unreadable / outside the root / not an image). Payload {root, path}. */ + gameConfigImagePreview: 'gameConfig:image-preview', + /** game-renderer → main (invoke): post-process path(s) the in-launcher file picker chose — the same + * card-relative / %PREFIX% / import-into-library conversions the native dialog used to feed. Payload + * GameConfigAcceptRequest; main re-checks the root, the file type and the size. */ + gameConfigAcceptPath: 'gameConfig:accept-path', + /** game-renderer → main (invoke): list one directory for the in-launcher file picker, plus the + * starting points offered beside it. Read-only. Payload GameConfigListDirRequest. */ + gameConfigListDir: 'gameConfig:list-dir', + /** game-renderer → main (invoke): every root a new game may be added to — the removable candidates + * plus the PC library. No payload; answers with DriveCandidate[]. */ + gameConfigSources: 'gameConfig:sources', + /** game-renderer → main (invoke): the game.json TEXT of one ROOT (not one game), for the Add-game + * screen — the chosen root may not carry a single game yet, which `hasManifest` states outright. + * Payload the root; answers with ConfigRootReadResult. */ + gameConfigReadRoot: 'gameConfig:read-root', + /** game-renderer → main (invoke): moves a local (PC-library) game onto a card in one transaction — the + * whole point being that the renderer cannot do "write the card, then write the library" as two + * gameConfig:save calls without a window where the game exists twice or nowhere (see the plan, Р2.5). + * Payload GameMoveRequest; answers with ConfigMoveResult. */ + gameConfigMoveToCard: 'gameConfig:move-to-card', + /** game-renderer → main (invoke): the system clipboard as text, for the on-screen keyboard's Paste. + * Reading it belongs to main like every other environment fact; the renderer is sandboxed and its own + * clipboard API would need a permission prompt that Game Mode has nowhere to show. No payload. */ + clipboardRead: 'clipboard:read', + + // ── Notifications (main owns the inbox; the renderer owns the two surfaces) ── + /** main → game-renderer: the whole inbox, oldest first. The unread COUNT is not sent — it is derived + * from the list, and a second source of truth would have to be kept in step in four places. */ + notificationsUpdate: 'notifications:update', + /** main → game-renderer: show a plate (one notification, or the "N unread" summary). */ + notificationsToast: 'notifications:toast', + /** game-renderer → main (invoke): the current inbox (seed on window startup / after a reload). */ + notificationsRequest: 'notifications:request', + // ── Online metadata ("Find online" on the Add/Customize screen; see main/metadata/) ── + // Every channel answers with a MetadataResult: a source being offline or rate-limiting is an ordinary + // outcome here, not an error the window should show as a crash. + /** game-renderer → main (invoke): search every source for a game by title. Payload the query string. */ + metadataSearch: 'metadata:search', + /** game-renderer → main (invoke): the candidate for a Steam appid the user has ALREADY named (the + * manifest's steam.appid) — the search exists to find that number, so knowing it skips the search. + * Payload the appid. */ + metadataSteamCandidate: 'metadata:steam-candidate', + /** game-renderer → main (invoke): the artwork gallery for one candidate — thumbnails already encoded + * as data: URLs (the renderer's CSP admits nothing else). Payload {candidateKey, kind}. */ + metadataArtwork: 'metadata:artwork', + /** game-renderer → main (invoke): soundtrack albums matching a title. Payload the query string. */ + metadataMusicAlbums: 'metadata:music-albums', + /** game-renderer → main (invoke): one album's tracks. Payload the album key. */ + metadataMusicTracks: 'metadata:music-tracks', + /** game-renderer → main (invoke): one track as an audio data: URL, to listen before applying it. + * Payload the track key. This is a full download — the renderer shows a status line for it. */ + metadataTrackPreview: 'metadata:track-preview', + /** game-renderer → main (invoke): everything known about the candidate that is not a picture — the + * en/ru descriptions, the genres, the release date, the platforms. Payload the candidate key. */ + metadataDescriptions: 'metadata:descriptions', + /** game-renderer → main (invoke): download the chosen variant into the game's root and answer with the + * manifest-relative path the form field takes. Payload MetadataApplyRequest. */ + metadataApply: 'metadata:apply', + /** game-renderer → main: the user left the surface — abort whatever is still being fetched. */ + metadataCancel: 'metadata:cancel', + + /** game-renderer → main: the user pressed a notification — drop it from the inbox. Payload id. */ + notificationsDismiss: 'notifications:dismiss', + /** game-renderer → main: "Clear all" in the notifications popup. */ + notificationsClear: 'notifications:clear', + /** game-renderer → main: the notifications popup was opened, so the whole inbox has been seen. The + * only other thing that clears an unread is pressing an entry, which removes it outright. */ + notificationsMarkRead: 'notifications:mark-read', +} as const; /** - * A removable-drive candidate for the Configure-game window (stage: init/edit game.json). Unlike - * DriveWatcher.scan (which only sees cards WITH a game.json), this lists ALL removable/non-system - * mountpoints so a BLANK drive can be initialized — `hasManifest` distinguishes them. + * A removable drive the editor may write to. Unlike DriveWatcher.scan (which only sees cards WITH a + * game.json), this lists ALL removable/non-system mountpoints, so a blank one is a candidate too — + * `hasManifest` distinguishes them. It is what isAllowedRoot is checked against in main, and it also + * crosses to the renderer over gameConfig:sources: the Add-game screen asks the user WHERE the new game + * goes, and every field of this shape answers part of that question (`root` is the value it stores, + * `label` what it shows, `isActive` which entry is preselected). */ export interface DriveCandidate { /** Mountpoint / card root, e.g. "E:\\". */ readonly root: string; - /** Display label: "E:\\ — Hollow Knight" | "E:\\ — 3 games" | "E:\\ — invalid game.json" | "E:\\ — blank drive". */ + /** + * What this candidate is: a removable card, or the machine's own PC library (`<userData>/pc-games`, + * offered as one more entry in the same picker). Drives the icon/labelling and, in main, which + * manifest source the editor validates and saves against. See ManifestSource. + */ + readonly kind: ManifestSource; + /** + * Display label: "E:\\ — Hollow Knight" | "E:\\ — 3 games" | "E:\\ — invalid game.json" | + * "E:\\ — blank drive". The PC library uses the same shape with its own name in front: + * "This PC — Hades" | "This PC — 3 games" | "This PC — no games yet". + */ readonly label: string; /** * Content signature of this card's game.json — the sorted game ids (`''` blank, `'invalid'` unreadable). - * Identifies the MEDIA, not the slot: the Configure window compares it to detect a card swapped into the - * same mountpoint (the drive letter never changes) and to ignore cosmetic edits. Never displayed — + * Identifies the MEDIA, not the slot: a save compares it to detect a card swapped into the same + * mountpoint (the drive letter never changes) and to ignore cosmetic edits. Never displayed — * `label` may be a bare count ("3 games") that two different cards would share. */ readonly signature: string; @@ -822,21 +1031,27 @@ export type ConfigSaveResult = | { readonly saved: false; readonly message: string }; /** - * What the Configure form's Browse button is picking — drives the dialog's filters and mode: - * file pickers for exe/installer/image/audio (image is multi-select), a folder picker for `directory` - * (card-relative), and `pc-save` (a PC folder OUTSIDE the card, converted to a %PREFIX%\… save path). + * What a Browse is picking — it decides what the picker filters to, and what main will accept back: + * files for exe/installer/image/audio (image is multi-select), a folder for `directory` + * (card-relative), `pc-save` (a PC folder OUTSIDE the card, converted to a %PREFIX%\… save path) and + * `pc-executable` (PC library only: any executable anywhere on this machine, kept ABSOLUTE). + * `pc-save-local` is `pc-save` WITHOUT that conversion — the picked folder is kept absolute. It is for a + * local game that runs from this machine's disk (its saves are an ordinary host folder); a local STEAM + * game keeps `pc-save`, because its saves live inside Steam's Proton prefix, which only the %PREFIX% + * form can name. */ export type ConfigPickKind = - 'executable' | 'installer' | 'image' | 'audio' | 'directory' | 'pc-save'; - -/** Request payload for config:pick-path: the card root (re-checked in main) and the pick kind. */ -export interface ConfigPickRequest { - readonly root: string; - readonly kind: ConfigPickKind; -} + | 'executable' + | 'installer' + | 'image' + | 'audio' + | 'directory' + | 'pc-save' + | 'pc-save-local' + | 'pc-executable'; /** - * Result of picking path(s) from the card via the native dialog. On success `paths` are card-RELATIVE + * Result of picking path(s) for a manifest field. On success `paths` are card-RELATIVE * with forward slashes (ready to drop into game.json). A discriminated union (untrusted external action → * Result-union): success (one or more relative paths), a plain cancellation, or a rejection carrying a * localized message (a file outside the card root, the card root itself for a folder pick, …). @@ -846,6 +1061,327 @@ export type ConfigPickResult = | { readonly ok: false; readonly cancelled: true } | { readonly ok: false; readonly message: string }; +// ── Customize screen (per-game editing in the launcher) ───────────────────────── + +/** + * One game's manifest, addressed BY ID. `root`/`source` say which file it lives in and which dialect it + * speaks; `signature` identifies the MEDIA (the same sorted-ids signature DriveCandidate carries), so a + * card swapped into the same mountpoint while the screen is open cannot receive the edit. + * + * `text` is the WHOLE file, not the one game: a card may carry several, and the screen edits its slot in + * place so the neighbours (including any that failed to resolve) survive the round trip verbatim. + */ +/** + * Which OS the LAUNCHER itself is running on, as the renderer is allowed to know it. The renderer has no + * business asking the OS directly, and one screen genuinely needs the answer: a game installed on THIS PC + * under Windows or macOS will never be run through Proton, so its Linux/Proton section is not merely empty + * there but meaningless. A CARD keeps that section on every OS — the card is the portable half, and its + * manifest is read on the Deck too. + */ +export type HostPlatform = 'windows' | 'linux' | 'macos'; + +export type GameConfigReadResult = + | { + readonly ok: true; + readonly root: string; + readonly source: ManifestSource; + readonly signature: string; + readonly text: string; + /** The OS the launcher runs on — see HostPlatform. */ + readonly platform: HostPlatform; + } + | { readonly ok: false; readonly message: string }; + +/** + * One ROOT's manifest, addressed by the root itself — what the Add-game screen reads once the user has + * picked where the game goes. It is deliberately not gameConfig:read with a different payload: that + * channel's question is "which file does game X live in", and here there may be no game X yet. + * + * `hasManifest` is the field ConfigReadResult cannot express: a root with no game.json is the normal + * case here (a blank card, a first local game), and it must not be confused with a file that exists but + * cannot be read. When it is false, `text` is `''` and the screen starts from an empty slot list — + * sending `'[]'` instead would trip textToGames, which rejects an empty games array. + */ +export type ConfigRootReadResult = + | { + readonly ok: true; + readonly root: string; + readonly source: ManifestSource; + readonly signature: string; + readonly hasManifest: boolean; + readonly text: string; + /** The OS the launcher runs on — see HostPlatform. */ + readonly platform: HostPlatform; + } + | { readonly ok: false; readonly message: string }; + +/** Payload for gameConfig:save — the manifest text plus the media signature read alongside it. */ +export interface GameConfigSaveRequest { + readonly root: string; + /** The signature from gameConfig:read; a mismatch means a different card is in the slot now. */ + readonly signature: string; + readonly text: string; +} + +/** + * Payload for gameConfig:move-to-card — moving a local (PC-library) game onto a card (see the plan Р2.5). + * `fromText` is deliberately NOT part of this payload: main derives the PC library's post-move text + * itself, from a fresh read, by removing the game being moved (see game-move.ts) — the same + * never-trust-the-renderer's-derived-text stance the rest of this file takes for the writable side of a + * save (GameConfigService.save re-validates instead of trusting the renderer's verdict). + */ +export interface GameMoveRequest { + /** The id the moved game carries in `toText` — i.e. what it will be called ON THE CARD. */ + readonly id: string; + /** + * The id the game was READ with, and the only thing the PC-library side of the move is addressed by + * (which slot to remove, whose manifest to resolve, whose sync-state to drop). + * + * Separate from `id` on purpose: `id` comes from an editable form field, so the two can disagree, and + * addressing the library by the EDITED value would remove — and copy the assets and saves of — whichever + * other local game happens to answer to it. main additionally refuses a move where they differ at all: + * a rename would orphan everything keyed by the old id (stats, history, pending-flush) — see the plan's + * assumption 4 — so the rename belongs in a separate Save, before or after the move. + */ + readonly fromId: string; + readonly fromRoot: string; + /** The signature gameConfig:read gave for the PC library — a mismatch means it changed underneath us. */ + readonly fromSignature: string; + readonly toRoot: string; + /** The signature the target card was read against (gameConfig:sources / gameConfig:read-root). */ + readonly toSignature: string; + /** The target card's WHOLE game.json text, with the moved game's slot already inserted. */ + readonly toText: string; +} + +/** + * Result of a PC → card move. `moved` false → nothing changed on EITHER side (message tells why). + * `applied` mirrors ConfigSaveResult's meaning for the TARGET card. There is no `warning` counterpart: + * a move that succeeded but skipped something non-fatal (an existing non-empty save folder on the card, + * a library write that could not be undone) closes the screen, so the only place left to say it is a + * notification — which is where main sends it. + */ +export type ConfigMoveResult = + | { + readonly moved: true; + readonly applied: 'applied' | 'deferred'; + } + | { readonly moved: false; readonly message: string }; + +/** + * Payload for gameConfig:accept-path: absolute path(s) the in-launcher picker chose, and what field they + * are for. Unlike the native dialog this comes FROM the renderer, so main re-checks everything the dialog + * used to guarantee — see GameConfigService.acceptPickedPaths. + */ +export interface GameConfigAcceptRequest { + readonly root: string; + readonly kind: ConfigPickKind; + readonly paths: readonly string[]; + /** + * A root-RELATIVE sub-directory the resulting manifest path is measured from, when the field is not + * measured from the root itself. Only "move game to PC" uses one: there the manifest resolves + * `executable` under the install directory, which receives the CONTENTS of the named game folder, so + * a card-relative path would be one level too deep. Re-checked against the root in main. + */ + readonly base?: string; +} + +/** + * Payload for gameConfig:list-dir. With `path` it lists that directory; without one main picks the + * STARTING point for the field (`kind`) — the directory of `current` when it is already filled, else the + * card root / the home folder / %APPDATA% (see the plan, Р5.2). + */ +export interface GameConfigListDirRequest { + readonly path?: string; + readonly root?: string; + readonly kind?: ConfigPickKind; + /** The field's current value, so a filled field reopens where it points. */ + readonly current?: string; + /** The sub-directory this field is measured from — see GameConfigAcceptRequest.base. */ + readonly base?: string; +} + +/** One entry of a listed directory. Symlinks are reported as what they point AT, or skipped when broken. */ +export interface DirEntry { + readonly name: string; + readonly kind: 'dir' | 'file'; +} + +/** A starting point offered in the picker's left column. Not a restriction — see the plan, Р5.2. */ +export interface DirRoot { + readonly path: string; + readonly label: string; + readonly kind: 'card' | 'pc' | 'home' | 'drive'; +} + +/** + * Result of one directory listing. The roots travel with every answer (including a failure) so the picker + * can always offer a way out of a directory it could not read. + */ +export type ListDirResult = + | { + readonly ok: true; + readonly path: string; + /** null at a filesystem root — there is nowhere further up. */ + readonly parent: string | null; + readonly entries: readonly DirEntry[]; + readonly roots: readonly DirRoot[]; + } + | { readonly ok: false; readonly message: string; readonly roots: readonly DirRoot[] }; + +// ── Online metadata (Steam / SteamGridDB / Khinsider; see the metadata:* channels) ────────────── + +/** + * Which external source an answer came from. The renderer only ever shows it as a label beside a + * candidate; every request is addressed by an opaque `key` instead, so a provider can change how it + * identifies a game without the renderer knowing. + */ +export type MetadataProviderId = + | 'steam' + | 'steamgriddb' + | 'wallhaven' + | 'wallpapercave' + | 'gog' + | 'khinsider'; + +/** Which artwork slot a variant is offered for: the portrait cover, or a hero background. */ +export type ArtworkKind = 'grid' | 'hero'; + +/** + * The Result-union every metadata call answers with — the same never-throw-across-IPC stance the + * manifest reader takes for untrusted disk data (see CLAUDE.md). A source being offline, rate-limiting + * or answering with something the schema rejects is a NORMAL outcome here, not an exception. + */ +export type MetadataResult<T> = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly message: string }; + +/** + * A game as the sources know it. `key` is opaque to the renderer and round-trips back in every request. + * + * One candidate can carry SEVERAL references at once: the sources are searched in parallel and their + * answers are merged by title, so a game that both Steam and GOG know appears once and either source can + * be asked about it. `provider` names the source the key belongs to, which is also the one whose + * spelling of the title is shown. + */ +export interface GameCandidate { + readonly key: string; + readonly title: string; + readonly provider: MetadataProviderId; + /** Set when the candidate is a Steam app — what the CDN art and the descriptions are addressed by. */ + readonly steamAppId?: number; + /** Set when GOG sells this game. A STRING: GOG's product ids are not numbers. */ + readonly gogId?: string; +} + +/** + * One offered picture. `thumbDataUrl` is a data: URL because the renderer's CSP allows no other image + * source (`img-src data:`) — main downloads the bytes and encodes them, exactly as it does for the hero + * and the carousel art. + */ +export interface ArtworkVariant { + readonly key: string; + readonly kind: ArtworkKind; + readonly provider: MetadataProviderId; + readonly width?: number; + readonly height?: number; + readonly thumbDataUrl: string; +} + +/** + * What a gallery was asked to show: the sources the user left switched on (empty means all of them) and + * the size floor from the sidebar. It travels with every page request, and changing it starts the + * gallery over — main asks fewer sources and drops what is too small BEFORE downloading a thumbnail, + * which for a source whose tile is its full-size file is the difference between megabytes and none. + */ +export interface ArtworkFilter { + readonly sources: readonly MetadataProviderId[]; + readonly quality: ArtworkQuality; +} + +/** + * One page of a gallery. The sources hold far more than a screen's worth — a busy game has hundreds of + * wallpapers, most of them not what this user wants — so the gallery shows a page at a time and says + * whether there is another behind it. `hasMore` false is what removes the "load more" tile: an offer to + * fetch nothing is worse than no offer at all. + */ +export interface ArtworkPage { + readonly variants: readonly ArtworkVariant[]; + readonly hasMore: boolean; +} + +/** One soundtrack album as the music provider knows it. */ +export interface MusicAlbum { + readonly key: string; + readonly title: string; + readonly trackCount?: number; +} + +/** One track inside an album. `sizeBytes` is what the source claims, shown before a long download. */ +export interface MusicTrack { + readonly key: string; + readonly title: string; + readonly sizeBytes?: number; +} + +/** + * Text a source carries per language. Both fields are optional: Steam answers in whatever languages the + * publisher supplied, and a missing translation is normal. Consumers fall back `[locale] ?? en`. + */ +export interface LocalizedText { + readonly en?: string; + readonly ru?: string; +} + +/** The platforms a store states a game runs on. Kept as the store's own three, lower-cased. */ +export type GamePlatform = 'windows' | 'mac' | 'linux'; + +/** + * The facts about a game that are worth keeping but have no screen of their own yet: the description, + * and the three fields a future library view would sort and filter by. + * + * Stored now, shown later — deliberately. They arrive inside answers this feature already fetches (the + * Steam store page, the GOG catalogue entry), so keeping them costs nothing extra at the time the user + * picks a game, whereas going back for them afterwards would mean asking the same endpoints again for a + * game the user has moved on from. + */ +export interface GameDetails { + /** Short description per language (see LocalizedText). */ + readonly description?: LocalizedText; + /** Genres as the ENGLISH store names them — a filter has to compare them, so they must not shift. */ + readonly genres?: readonly string[]; + /** Release date as `YYYY-MM-DD`, or `YYYY` when the store states no more than a year. */ + readonly releaseDate?: string; + /** Which platforms the store says it runs on natively. */ + readonly platforms?: readonly GamePlatform[]; +} + +/** Which manifest field an applied download lands in. `hero` carries the 0-based rotation index. */ +export type MetadataApplySlot = 'grid' | 'music' | { readonly hero: number }; + +/** + * Payload for metadata:apply — "download this variant and put it into that game's root". Like + * GameConfigAcceptRequest this comes FROM the renderer, so main re-checks every part of it (the root is + * a live candidate, the id matches the manifest id syntax, the hero index is in range) BEFORE any + * network or disk work happens. + */ +export interface MetadataApplyRequest { + readonly root: string; + /** The game id the target file is named after (see shared/asset-move-names.ts). */ + readonly gameId: string; + readonly variantKey: string; + readonly slot: MetadataApplySlot; +} + +/** + * Result of metadata:apply. On success `path` is the MANIFEST-relative path the renderer writes into the + * form field — the same shape gameConfig:accept-path answers with, so both pickers feed the form + * identically. + */ +export type MetadataApplyResult = + | { readonly ok: true; readonly path: string } + | { readonly ok: false; readonly message: string }; + /** API that preload exposes on `window.api`. */ export interface RendererApi { onStateUpdate(callback: (state: AppState) => void): void; @@ -877,8 +1413,6 @@ export interface RendererApi { onAmbientUpdate(callback: (url: string | null) => void): void; /** The current default-ambience data URL (on window startup); null when no ambience is set. */ requestAmbient(): Promise<string | null>; - /** Play a one-shot UI sound pushed from main (e.g. the "play" sound when an install completes). */ - onSfxPlay(callback: (name: SfxName) => void): void; onHeroUpdate(callback: (assets: HeroAssets | null) => void): void; requestHero(): Promise<HeroAssets | null>; /** Live carousel-list updates (card games + history, in display order; null when there is nothing). */ @@ -887,8 +1421,18 @@ export interface RendererApi { requestLibrary(): Promise<GameLibrary | null>; /** The carousel card artwork of one game as a data URL (null when it has none). Cached per id. */ requestGrid(id: string): Promise<string | null>; - /** Tell main which game the carousel is on — it answers with browse:update/hero/music. */ - browseGame(id: string): void; + /** + * Tell main which game the carousel is on — it answers with browse:update/hero/music. `immediate` says + * the user COMMITTED to this game (opened its screen) rather than flipped onto it, so the heavy half + * (hero images, music) is read at once instead of waiting out main's debounce. + * + * `null` is the carousel standing on one of the launcher's own cards: nothing is on screen, and main + * answers with an empty browse (and pins the cursor there — see the browse section in ipc.ts). + */ + browseGame(id: string | null, immediate?: boolean): void; + /** Drop a game from the play history. Refused by main for a game that is available right now (on the + * card or in the PC library) — that one is not history, it is a game you can play. */ + forgetGame(id: string): void; /** Live updates of what is on screen (title/stats/active/GameInfo). */ onBrowseUpdate(callback: (browse: BrowseInfo | null) => void): void; /** What is on screen right now (on window startup). */ @@ -904,122 +1448,126 @@ export interface RendererApi { /** Pick a game by id (entering its detail screen) — switches to it on the ready screen. */ selectGame(id: string): void; requestWallpaper(): Promise<string | null>; - /** Live Empty-screen wallpaper updates, pushed when the custom wallpaper changes in the settings window. */ - onWallpaperUpdate(callback: (url: string) => void): void; + /** The bundled startup jingle as a data URL, or null when it can't be read. Played once, on boot. */ + requestStartupSound(): Promise<string | null>; /** Current launcher audio volumes (on window startup). */ requestVolumes(): Promise<AudioVolumes>; - /** Live audio-volume updates, pushed when changed in the settings window. */ + /** Live audio-volume updates, pushed when a volume changes (the Settings screen or a reset). */ onVolumesUpdate(callback: (volumes: AudioVolumes) => void): void; /** Current effective UI locale (on window startup). */ getLanguage(): Promise<Locale>; /** Live UI-locale updates, pushed when the language changes. */ onLanguageUpdate(callback: (locale: Locale) => void): void; -} -/** API that the settings preload exposes on `window.settingsApi` (separate from the game `api`). */ -export interface SettingsApi { - getAppVersion(): Promise<string>; - getAppIcon(): Promise<string>; - /** - * The "move" UI sound of a GIVEN set as a data URL, played as a volume preview on slider release. The - * set is passed explicitly (not read from settings in main) so a just-changed dropdown previews the new - * set without racing the on-disk settings write. - */ - getMoveSound(set: string): Promise<string>; + // ── Settings screen (moved here with the window it used to live in) ── + /** The current AppSettings (seed for the Settings screen). */ + getSettings(): Promise<AppSettings>; + /** Live AppSettings pushes — the screen's single source of truth, including after a reset. */ + onSettingsUpdate(callback: (settings: AppSettings) => void): void; + /** Whether the Steam-related row exists at all — false on Windows and on a non-AppImage run. */ + isSteamAvailable(): Promise<boolean>; /** The bundled sound sets + ambience tracks, to populate the Audio dropdowns. */ getAudioOptions(): Promise<AudioOptions>; - /** Change the navigation sound set (applied live to the game window by main). */ - setSoundSet(set: string): void; - /** Change the default ambience track (null = no ambience; applied live to the game window by main). */ - setAmbientTrack(track: string | null): void; - /** Toggle using only the global ambience (a card's own music ignored when on). */ - setOnlyGlobalAmbient(on: boolean): void; - getSettings(): Promise<AppSettings>; + /** The app version string, shown beside the screen title. */ + getAppVersion(): Promise<string>; setAutoUpdate(mode: AutoUpdateMode): void; + setPrerelease(on: boolean): void; + setSummonHotkey(on: boolean): void; + /** Toggle keeping the display awake (no screensaver / display-sleep) while the launcher owns the session. */ + setPreventScreensaver(on: boolean): void; /** Toggle keeping the empty "no card" screen visible instead of hiding to the tray. */ - setAlwaysShowEmptyScreen(on: boolean): void; + setKeepOpenWithoutCard(on: boolean): void; /** Toggle disabling silent installer mode (installers show their wizard when on). */ setDisableSilentInstall(on: boolean): void; /** Toggle the Game Mode card-insert auto-launch (Steam Deck only; see AppSettings.steamAutoLaunch). */ setSteamAutoLaunch(on: boolean): void; - /** Whether to show the Steam-related settings at all — false on Windows and on a non-AppImage run. */ - isSteamAvailable(): Promise<boolean>; - setTheme(mode: ThemeMode): void; - setPrerelease(on: boolean): void; - setSummonHotkey(on: boolean): void; - /** Toggle keeping the display awake (no screensaver / display-sleep) while the launcher owns the session. */ - setPreventScreensaver(on: boolean): void; + /** Change the navigation sound set (applied live by main). */ + setSoundSet(set: string): void; + /** Change the default ambience track (null = no ambience; applied live by main). */ + setAmbientTrack(track: string | null): void; + /** Toggle using only the global ambience (a card's own music ignored when on). */ + setOnlyGlobalAmbient(on: boolean): void; + /** Store the user's SteamGridDB API key ('' clears it and turns that source off). */ + setSteamGridDbKey(key: string): void; setMusicVolume(volume: number): void; setSfxVolume(volume: number): void; /** Change the UI language (the effective locale comes back via onLanguageUpdate). */ setLanguage(mode: LanguageMode): void; - /** Current effective UI locale (on window startup). */ - getLanguage(): Promise<Locale>; - /** Live UI-locale updates, pushed when the language changes. */ - onLanguageUpdate(callback: (locale: Locale) => void): void; - /** Resets all settings to defaults; resolves with the new AppSettings so the UI can re-render. */ - reset(): Promise<AppSettings>; - /** Tell main to recolor the native caption buttons to match the effective (dark/light) theme. */ - setTitleBarDark(dark: boolean): void; - openLogs(): void; - openGamesFolder(): void; - /** Pick a custom Empty-screen wallpaper via a file dialog; resolves with the outcome (image / error / cancel). */ - pickWallpaper(): Promise<WallpaperResult>; - /** Clear the custom Empty-screen wallpaper; resolves with the default wallpaper data URL for the preview. */ - clearWallpaper(): Promise<{ dataUrl: string }>; - /** Current Empty-screen wallpaper data URL, for the settings preview (on open and after a general Reset). */ - requestWallpaperPreview(): Promise<{ dataUrl: string }>; - onUpdateStatus(cb: (status: UpdateStatus) => void): void; + /** Resets all settings to defaults. The screen re-renders from the settings:update push instead. */ + resetSettings(): Promise<AppSettings>; + onUpdateStatus(callback: (status: UpdateStatus) => void): void; requestUpdateStatus(): Promise<UpdateStatus>; checkForUpdates(): void; downloadUpdate(): void; installUpdate(): void; -} -/** API that the configure preload exposes on `window.configureApi` (separate from game/settings). */ -export interface ConfigureApi { - /** Snapshot of removable-drive candidates (incl. blank drives) for the picker. */ - getDrives(): Promise<readonly DriveCandidate[]>; - /** Live candidate-list updates, pushed while the window is visible. */ - onDrivesUpdate(callback: (drives: readonly DriveCandidate[]) => void): void; - /** Read a card's game.json text into the editor. */ - readConfig(root: string): Promise<ConfigReadResult>; - /** Static (fs-free) validation of the current editor text — the Save verdict. */ - validateConfig(text: string): Promise<ConfigValidationResult>; - /** Write game.json to the card and try to apply it without a restart. */ - saveConfig(root: string, text: string): Promise<ConfigSaveResult>; - /** Pick file(s)/a folder from the card via a native dialog; resolves with card-relative paths. */ - pickPath(root: string, kind: ConfigPickKind): Promise<ConfigPickResult>; - /** Read a card-relative image into a data URL for the hero preview (null when unreadable). */ - getImagePreview(root: string, path: string): Promise<string | null>; - /** Open an external https URL in the default browser (e.g. the SteamDB appid lookup). */ - openExternal(url: string): void; - /** The manifest JSON Schema, fed to the editor for completions/hover. */ - getSchema(): Promise<unknown>; - /** The current AppSettings (for the window theme). */ - getSettings(): Promise<AppSettings>; - /** The app icon as a data URL, shown in the custom title bar (matches the settings window). */ - getAppIcon(): Promise<string>; - /** The app version string, shown in the custom title bar (matches the settings window). */ - getAppVersion(): Promise<string>; - /** Editor commands (format) triggered from the native right-click menu. */ - onEditorCommand(callback: (command: ConfigEditorCommand) => void): void; - /** Tell main whether the JSON editor tab is active (gates the Format context-menu item). */ - setJsonEditorActive(active: boolean): void; - /** Tell main to recolor THIS window's native caption buttons to match the effective theme. */ - setTitleBarDark(dark: boolean): void; - /** Current effective UI locale (on window startup). */ - getLanguage(): Promise<Locale>; - /** Live UI-locale updates, pushed when the language changes. */ - onLanguageUpdate(callback: (locale: Locale) => void): void; - /** Live UI-theme updates, pushed when the theme changes in the settings window. */ - onThemeUpdate(callback: (mode: ThemeMode) => void): void; + // ── Customize screen (per-game game.json editing; see the gameConfig:* channels) ── + /** The manifest text of one game by id, with the root/source/signature it was read against. */ + readGameConfig(id: string): Promise<GameConfigReadResult>; + /** Static (fs-free) validation of the edited text — the Save verdict, debounced by the screen. */ + validateGameConfig(root: string, text: string): Promise<ConfigValidationResult>; + /** Write game.json and try to apply it without a restart; refused when the media signature moved on. */ + saveGameConfig(request: GameConfigSaveRequest): Promise<ConfigSaveResult>; + /** A root-relative image as a data URL for a row's thumbnail (null when unreadable). */ + getGameConfigImage(root: string, path: string): Promise<string | null>; + /** Post-process path(s) the in-launcher picker chose into what the manifest field stores. */ + acceptGameConfigPaths(request: GameConfigAcceptRequest): Promise<ConfigPickResult>; + /** List one directory for the in-launcher file picker (read-only). */ + listGameConfigDir(request: GameConfigListDirRequest): Promise<ListDirResult>; + /** Every root a new game may be added to — the cards plus the PC library (the Add-game screen). */ + listGameConfigSources(): Promise<readonly DriveCandidate[]>; + /** The manifest text of one ROOT, for adding a game to it (the root may carry no game yet). */ + readGameConfigRoot(root: string): Promise<ConfigRootReadResult>; + /** Moves a local (PC-library) game onto a card in one transaction (see GameMoveRequest). */ + moveGameConfigToCard(request: GameMoveRequest): Promise<ConfigMoveResult>; + /** The clipboard as text, for the on-screen keyboard's Paste key. Empty when there is nothing to paste. */ + readClipboard(): Promise<string>; + + // ── Online metadata ("Find online"; see the metadata:* channels) ── + /** Search every source for a game by title. */ + searchMetadata(query: string): Promise<MetadataResult<readonly GameCandidate[]>>; + /** The candidate behind a Steam appid the manifest already names — no search needed. */ + requestMetadataSteamCandidate(appId: number): Promise<MetadataResult<GameCandidate>>; + /** + * One page of the artwork gallery for a candidate — thumbnails arrive as data: URLs. Page 0 starts the + * gallery over; every later page continues where the previous one stopped. + */ + requestMetadataArtwork( + candidateKey: string, + kind: ArtworkKind, + page: number, + filter: ArtworkFilter, + ): Promise<MetadataResult<ArtworkPage>>; + /** Soundtrack albums matching a title. */ + searchMetadataMusic(query: string): Promise<MetadataResult<readonly MusicAlbum[]>>; + /** One album's tracks. */ + requestMetadataTracks(albumKey: string): Promise<MetadataResult<readonly MusicTrack[]>>; + /** One track as an audio data: URL (a full download — show a status line while it runs). */ + requestMetadataTrackPreview(trackKey: string): Promise<MetadataResult<string>>; + /** The candidate's descriptions, genres, release date and platforms — see GameDetails. */ + requestMetadataDescriptions(candidateKey: string): Promise<MetadataResult<GameDetails>>; + /** Download the chosen variant into the game's root; answers with the manifest-relative path. */ + applyMetadata(request: MetadataApplyRequest): Promise<MetadataApplyResult>; + /** Abort whatever is still being fetched (the user left the surface). */ + cancelMetadata(): void; + + // ── Notifications (the toast + the "Notifications" popup; see the notifications:* channels) ── + /** Live inbox pushes — the popup list is drawn from the latest snapshot, never from local edits. */ + onNotifications(callback: (items: readonly AppNotification[]) => void): void; + /** Show a plate over the UI (one notification, or the "N unread" summary). */ + onNotificationToast(callback: (toast: NotificationToast) => void): void; + /** The current inbox (seed on window startup). */ + requestNotifications(): Promise<readonly AppNotification[]>; + /** The user pressed a notification — it leaves the inbox (pressing one is what removes it). */ + dismissNotification(id: string): void; + /** "Clear all" — empties the inbox. */ + clearNotifications(): void; + /** The popup was opened — everything in the inbox counts as seen. */ + markNotificationsRead(): void; } declare global { interface Window { readonly api: RendererApi; - readonly settingsApi: SettingsApi; - readonly configureApi: ConfigureApi; } } diff --git a/test/app-bundle-darwin.test.ts b/test/app-bundle-darwin.test.ts new file mode 100644 index 00000000..6abb04d5 --- /dev/null +++ b/test/app-bundle-darwin.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { + bundleExecutablePath, + infoPlistPath, + isAppBundlePath, + parseCFBundleExecutable, +} from '../src/main/platform/app-bundle.darwin'; + +const XML_PLIST = `<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>CFBundleName</key> + <string>Valheim</string> + <key>CFBundleExecutable</key> + <string>valheim.x86_64</string> + <key>CFBundleIdentifier</key> + <string>com.irongate.valheim</string> +</dict> +</plist> +`; + +describe('darwin app bundle — path helpers', () => { + it('recognizes a .app bundle path, case-insensitively and with a trailing slash', () => { + expect(isAppBundlePath('/Applications/Valheim.app')).toBe(true); + expect(isAppBundlePath('/Applications/Valheim.APP/')).toBe(true); + expect(isAppBundlePath('/Applications/valheim')).toBe(false); + expect(isAppBundlePath('/Games/valheim.exe')).toBe(false); + }); + + it('builds the Info.plist and Contents/MacOS paths with posix separators', () => { + expect(infoPlistPath('/Applications/Valheim.app')).toBe( + '/Applications/Valheim.app/Contents/Info.plist', + ); + expect(bundleExecutablePath('/Applications/Valheim.app', 'valheim.x86_64')).toBe( + '/Applications/Valheim.app/Contents/MacOS/valheim.x86_64', + ); + }); +}); + +describe('darwin app bundle — Info.plist parsing', () => { + it('reads CFBundleExecutable out of an XML plist', () => { + expect(parseCFBundleExecutable(XML_PLIST)).toBe('valheim.x86_64'); + }); + + it('returns null when the key is absent', () => { + expect(parseCFBundleExecutable('<plist><dict><key>CFBundleName</key><string>X</string></dict></plist>')).toBeNull(); + }); + + it('returns null for an empty or whitespace-only value', () => { + expect( + parseCFBundleExecutable('<key>CFBundleExecutable</key>\n<string> </string>'), + ).toBeNull(); + }); + + it('decodes XML entities in the executable name', () => { + expect( + parseCFBundleExecutable('<key>CFBundleExecutable</key><string>Rock & Roll</string>'), + ).toBe('Rock & Roll'); + }); +}); diff --git a/test/app-settings.test.ts b/test/app-settings.test.ts index 5d7c2b96..cfe4e2c2 100644 --- a/test/app-settings.test.ts +++ b/test/app-settings.test.ts @@ -2,11 +2,14 @@ // read-modify-writes (a slider burst can't lose updates), flush() drains in-flight writes (awaited before // an update install), patch() propagates its result/rejection to the caller, the write is atomic, and a // partial file missing a defaulted field still validates instead of resetting everything to defaults. +// Plus the two rules the Settings-screen move added: `theme` is normalized to 'system' on read, and every +// write notifies the onChange listener (that push is what the screen renders from). import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { AppSettingsStore, DEFAULT_SETTINGS } from '../src/main/app-settings'; +import type { AppSettings } from '../src/shared/types'; let baseDir: string; @@ -37,19 +40,19 @@ describe('AppSettingsStore — write queue', () => { it('patch() resolves with the merged settings (result propagation)', async () => { const store = new AppSettingsStore(baseDir); - const next = await store.patch({ theme: 'dark' }); - expect(next.theme).toBe('dark'); + const next = await store.patch({ musicVolume: 0.75 }); + expect(next.musicVolume).toBe(0.75); expect(next.schemaVersion).toBe(1); }); it('flush() resolves only after in-flight writes have settled', async () => { const store = new AppSettingsStore(baseDir); // Fire-and-forget (no await), as the settings handlers do; flush must drain them. - void store.patch({ theme: 'dark' }); + void store.patch({ musicVolume: 0.3 }); void store.patch({ language: 'ru' }); await store.flush(); const settings = await store.read(); - expect(settings.theme).toBe('dark'); + expect(settings.musicVolume).toBe(0.3); expect(settings.language).toBe('ru'); }); @@ -62,8 +65,8 @@ describe('AppSettingsStore — write queue', () => { describe('AppSettingsStore — atomic write + schema tolerance', () => { it('write persists a valid, re-readable JSON file and leaves no temp behind', async () => { const store = new AppSettingsStore(baseDir); - await store.write({ ...DEFAULT_SETTINGS, theme: 'light' }); - expect((await store.read()).theme).toBe('light'); + await store.write({ ...DEFAULT_SETTINGS, musicVolume: 0.42 }); + expect((await store.read()).musicVolume).toBe(0.42); // The temp file used by the atomic rename must not linger. const entries = await fs.readdir(baseDir); expect(entries.some((e) => e.endsWith('.tmp'))).toBe(false); @@ -77,7 +80,73 @@ describe('AppSettingsStore — atomic write + schema tolerance', () => { const store = new AppSettingsStore(baseDir); const settings = await store.read(); expect(settings.autoUpdate).toBe(DEFAULT_SETTINGS.autoUpdate); // filled from the schema default - expect(settings.theme).toBe('dark'); // NOT reset — the whole parse no longer fails - expect(settings.musicVolume).toBe(0.25); + expect(settings.musicVolume).toBe(0.25); // NOT reset — the whole parse no longer fails + }); + + it('drops an unknown key from an older file instead of failing the parse', async () => { + // `customWallpaper` shipped in 0.7 and was removed with the Settings-screen move; a settings.json + // still carrying it must read fine (the schema is a plain z.object — unknown keys are stripped). + const legacy = { ...DEFAULT_SETTINGS, customWallpaper: 'wallpaper-custom.png', musicVolume: 0.15 }; + await fs.writeFile(path.join(baseDir, 'settings.json'), JSON.stringify(legacy), 'utf8'); + const settings = await new AppSettingsStore(baseDir).read(); + expect(settings.musicVolume).toBe(0.15); + expect('customWallpaper' in settings).toBe(false); + }); + + // `alwaysShowEmptyScreen` became `keepOpenWithoutCard` when the empty screen went away. Nothing else + // catches this: the schema's `.default(false)` swallows the missing key without a word, so a file + // written by an older build would silently switch the toggle back off for everyone who turned it on. + it('carries an older alwaysShowEmptyScreen over to keepOpenWithoutCard', async () => { + const legacy: Record<string, unknown> = { ...DEFAULT_SETTINGS, alwaysShowEmptyScreen: true }; + delete legacy['keepOpenWithoutCard']; + await fs.writeFile(path.join(baseDir, 'settings.json'), JSON.stringify(legacy), 'utf8'); + const settings = await new AppSettingsStore(baseDir).read(); + expect(settings.keepOpenWithoutCard).toBe(true); + expect('alwaysShowEmptyScreen' in settings).toBe(false); + }); + + it('lets the new key win when a file somehow carries both', async () => { + const both: Record<string, unknown> = { + ...DEFAULT_SETTINGS, + keepOpenWithoutCard: false, + alwaysShowEmptyScreen: true, + }; + await fs.writeFile(path.join(baseDir, 'settings.json'), JSON.stringify(both), 'utf8'); + expect((await new AppSettingsStore(baseDir).read()).keepOpenWithoutCard).toBe(false); + }); +}); + +describe('AppSettingsStore — theme normalization', () => { + it('reads back `system` even when the file says `dark` (the selector is gone)', async () => { + const stored = { ...DEFAULT_SETTINGS, theme: 'dark' as const }; + await fs.writeFile(path.join(baseDir, 'settings.json'), JSON.stringify(stored), 'utf8'); + expect((await new AppSettingsStore(baseDir).read()).theme).toBe('system'); + }); + + it('normalizes it for patch() results too, so no caller can resurrect the old value', async () => { + const stored = { ...DEFAULT_SETTINGS, theme: 'light' as const }; + await fs.writeFile(path.join(baseDir, 'settings.json'), JSON.stringify(stored), 'utf8'); + const store = new AppSettingsStore(baseDir); + expect((await store.patch({ musicVolume: 0.6 })).theme).toBe('system'); + }); +}); + +describe('AppSettingsStore — onChange', () => { + it('fires on write, patch and reset — every path through persist()', async () => { + const seen: AppSettings[] = []; + const store = new AppSettingsStore(baseDir, (next) => seen.push(next)); + await store.write({ ...DEFAULT_SETTINGS, musicVolume: 0.2 }); + await store.patch({ sfxVolume: 0.4 }); + await store.reset(); + expect(seen.map((s) => [s.musicVolume, s.sfxVolume])).toEqual([ + [0.2, DEFAULT_SETTINGS.sfxVolume], + [0.2, 0.4], + [DEFAULT_SETTINGS.musicVolume, DEFAULT_SETTINGS.sfxVolume], + ]); + }); + + it('is optional — a store built without it (the daemon) writes fine', async () => { + const store = new AppSettingsStore(baseDir); + await expect(store.patch({ sfxVolume: 0.5 })).resolves.toMatchObject({ sfxVolume: 0.5 }); }); }); diff --git a/test/artwork-filter.test.ts b/test/artwork-filter.test.ts new file mode 100644 index 00000000..cb4747c2 --- /dev/null +++ b/test/artwork-filter.test.ts @@ -0,0 +1,94 @@ +// The gallery's two filters: which sources take part, and how large a picture has to be. +import { describe, expect, it } from 'vitest'; +import { + QUALITY_FLOOR, + includesSource, + meetsQuality, + sourceGroupsFor, +} from '../src/shared/artwork-filter'; +import { sameFilter, toFilter } from '../src/main/metadata/service'; +import { searchUrl } from '../src/main/metadata/wallhaven'; + +describe('source groups', () => { + it('offers the two stores as one choice for backgrounds — their pictures are the same kind', () => { + const stores = sourceGroupsFor('hero').find((group) => group.key === 'stores'); + expect(stores?.providers).toEqual(['steam', 'gog']); + }); + + it('offers the cover sources for covers, and no wallpaper site among them', () => { + expect(sourceGroupsFor('grid').map((group) => group.key)).toEqual([ + 'all', + 'steam', + 'steamgriddb', + ]); + }); + + it('starts each list with "every source", which names no provider at all', () => { + for (const kind of ['hero', 'grid'] as const) { + expect(sourceGroupsFor(kind)[0]).toMatchObject({ key: 'all', label: null, providers: [] }); + } + }); + + it('reads an empty list as every source rather than none', () => { + expect(includesSource([], 'wallhaven')).toBe(true); + expect(includesSource(['wallhaven'], 'wallhaven')).toBe(true); + expect(includesSource(['wallhaven'], 'steam')).toBe(false); + }); +}); + +describe('size floor', () => { + it('lets everything through when no floor is set', () => { + expect(meetsQuality({}, 'any')).toBe(true); + expect(meetsQuality({ width: 640, height: 480 }, 'any')).toBe(true); + }); + + it('measures against the named screen sizes', () => { + expect(meetsQuality({ width: 1920, height: 1080 }, 'fullhd')).toBe(true); + expect(meetsQuality({ width: 1600, height: 900 }, 'fullhd')).toBe(false); + expect(meetsQuality({ width: 2560, height: 1440 }, 'qhd')).toBe(true); + expect(meetsQuality({ width: 1920, height: 1080 }, 'qhd')).toBe(false); + expect(meetsQuality({ width: 3840, height: 2160 }, 'uhd')).toBe(true); + }); + + // Steam states no size for a screenshot, and its own backdrop measured 1438x810 for Half-Life 2 — so + // "unknown" cannot be treated as "large enough" without lying to a filter that asks for at least 4K. + it('refuses a picture whose size nobody states, once a floor is set', () => { + expect(meetsQuality({}, 'fullhd')).toBe(false); + expect(meetsQuality({ width: 1920 }, 'fullhd')).toBe(false); + }); +}); + +describe('a filter as it arrives over IPC', () => { + it('reads anything unrecognized as no filter at all', () => { + expect(toFilter(undefined)).toEqual({ sources: [], quality: 'any' }); + }); + + it('keeps what the sidebar sent', () => { + expect(toFilter({ sources: ['wallhaven'], quality: 'uhd' })).toEqual({ + sources: ['wallhaven'], + quality: 'uhd', + }); + }); + + it('tells two galleries apart, so changing a filter starts one over', () => { + const base = { sources: ['wallhaven'] as const, quality: 'any' } as const; + expect(sameFilter(base, { sources: ['wallhaven'], quality: 'any' })).toBe(true); + expect(sameFilter(base, { sources: ['wallhaven'], quality: 'qhd' })).toBe(false); + expect(sameFilter(base, { sources: ['wallpapercave'], quality: 'any' })).toBe(false); + expect(sameFilter(base, { sources: [], quality: 'any' })).toBe(false); + }); +}); + +describe('the floor reaches the source that can search by it', () => { + it('asks wallhaven for the size the sidebar set', () => { + const url = new URL(searchUrl('Hades', 0, QUALITY_FLOOR.uhd)); + expect(url.searchParams.get('atleast')).toBe('3840x2160'); + }); + + // Its own floor is 1080p and stays there: below it the endpoint would start offering wallpapers too + // small for the screen this launcher paints them on. + it('never lowers its own floor', () => { + const url = new URL(searchUrl('Hades', 0, QUALITY_FLOOR.any)); + expect(url.searchParams.get('atleast')).toBe('1920x1080'); + }); +}); diff --git a/test/asset-reader-audio.test.ts b/test/asset-reader-audio.test.ts index 79d6eed2..bdcf2f3d 100644 --- a/test/asset-reader-audio.test.ts +++ b/test/asset-reader-audio.test.ts @@ -1,7 +1,12 @@ // Pure audio helpers in asset-reader.ts: the UI-slot→file mapping and the ambience anti-traversal guard. // The rest of AssetReader touches the filesystem / electron and isn't unit-tested here. import { describe, expect, it } from 'vitest'; -import { isValidAmbientTrack, sfxFileName } from '../src/main/asset-reader'; +import { + DEFAULT_SOUND_SET, + isValidAmbientTrack, + sfxFileName, + sfxSetsForSlot, +} from '../src/main/asset-reader'; import type { SfxName } from '../src/shared/types'; describe('sfxFileName — UI slot → set file basename', () => { @@ -11,6 +16,11 @@ describe('sfxFileName — UI slot → set file basename', () => { navigate: 'move', button: 'button', back: 'back', + notify: 'notify', + limit: 'limit', + 'popup-open': 'popup-open', + 'popup-close': 'popup-close', + typing: 'typing', }; for (const [slot, file] of Object.entries(expected) as [SfxName, string][]) { expect(sfxFileName(slot)).toBe(file); @@ -18,6 +28,26 @@ describe('sfxFileName — UI slot → set file basename', () => { }); }); +describe('sfxSetsForSlot — the borrowing slots fall back to the default set', () => { + it('falls back to the default set for the slots the older sets do not carry yet', () => { + for (const slot of ['notify', 'limit', 'popup-open', 'popup-close', 'typing'] as const) { + expect(sfxSetsForSlot(slot, 'ps2')).toEqual(['ps2', DEFAULT_SOUND_SET]); + } + }); + + it('does not duplicate the default set when it is the chosen one', () => { + for (const slot of ['notify', 'limit', 'popup-open', 'popup-close', 'typing'] as const) { + expect(sfxSetsForSlot(slot, DEFAULT_SOUND_SET)).toEqual([DEFAULT_SOUND_SET]); + } + }); + + it('leaves every other slot on "missing file ⇒ silence" — no borrowing across sets', () => { + for (const slot of ['play', 'navigate', 'button', 'back'] as const) { + expect(sfxSetsForSlot(slot, 'ps2')).toEqual(['ps2']); + } + }); +}); + describe('isValidAmbientTrack — bundled-folder anti-traversal', () => { it('accepts a bare file name with a supported audio extension', () => { expect(isValidAmbientTrack('ps5.mp3')).toBe(true); diff --git a/test/card-art.test.ts b/test/card-art.test.ts new file mode 100644 index 00000000..8908fedb --- /dev/null +++ b/test/card-art.test.ts @@ -0,0 +1,129 @@ +// The library's artwork cache is the only thing standing between a grid of hundreds and main, which +// generates every cover synchronously on first request. Its two bounds — the LRU and the request queue — +// are pure logic, and neither would ever fail loudly: a broken queue just makes a Deck stutter. +import { describe, expect, it, vi } from 'vitest'; +import { artKey, createCardArtCache } from '../src/renderer/card-art'; + +/** A requestGrid whose promises are resolved by hand, so the queue can be inspected mid-flight. */ +function deferredGrid(): { + requestGrid: (id: string) => Promise<string | null>; + calls: string[]; + settle: (id: string, url: string | null) => Promise<void>; +} { + const calls: string[] = []; + const pending = new Map<string, (url: string | null) => void>(); + return { + calls, + requestGrid: (id) => { + calls.push(id); + return new Promise<string | null>((resolve) => pending.set(id, resolve)); + }, + settle: async (id, url) => { + pending.get(id)?.(url); + pending.delete(id); + await Promise.resolve(); + await Promise.resolve(); + }, + }; +} + +describe('artKey', () => { + it('keys by id AND artwork revision, so a re-copied cover misses the cache', () => { + expect(artKey({ id: 'g', title: 'g', active: true, artRev: '7' })).toBe('g@7'); + expect(artKey({ id: 'g', title: 'g', active: true })).not.toBe('g@7'); + }); +}); + +describe('createCardArtCache', () => { + it('reports an unknown key as undefined and a game with no cover as null', async () => { + const cache = createCardArtCache({ requestGrid: () => Promise.resolve(null) }); + expect(cache.get('a@')).toBeUndefined(); + await cache.load('a@', 'a'); + expect(cache.get('a@')).toBeNull(); + }); + + it('asks for a cover once, however often the slot is loaded', async () => { + const grid = deferredGrid(); + const cache = createCardArtCache(grid); + const first = cache.load('a@', 'a'); + const second = cache.load('a@', 'a'); + await grid.settle('a', 'data:a'); + expect(await first).toBe('data:a'); + expect(await second).toBe('data:a'); + await cache.load('a@', 'a'); + expect(grid.calls).toEqual(['a']); + }); + + it('evicts the oldest entry and tells the host about it', async () => { + const evicted: string[] = []; + const cache = createCardArtCache({ requestGrid: (id) => Promise.resolve(`data:${id}`) }, 2); + cache.onEvict((key) => evicted.push(key)); + await cache.load('a@', 'a'); + await cache.load('b@', 'b'); + await cache.load('c@', 'c'); + expect(evicted).toEqual(['a@']); + expect(cache.get('a@')).toBeUndefined(); + expect(cache.get('c@')).toBe('data:c'); + }); + + it('counts a read as freshness — the entry just looked at is not the one thrown out', async () => { + const evicted: string[] = []; + const cache = createCardArtCache({ requestGrid: (id) => Promise.resolve(`data:${id}`) }, 2); + cache.onEvict((key) => evicted.push(key)); + await cache.load('a@', 'a'); + await cache.load('b@', 'b'); + cache.get('a@'); + await cache.load('c@', 'c'); + expect(evicted).toEqual(['b@']); + expect(cache.get('a@')).toBe('data:a'); + }); + + it('keeps at most `concurrency` requests in flight and serves the backlog newest-first', async () => { + const grid = deferredGrid(); + const cache = createCardArtCache(grid, 100, 2); + for (const id of ['a', 'b', 'c', 'd']) void cache.load(`${id}@`, id); + expect(grid.calls).toEqual(['a', 'b']); + // 'c' and 'd' waited: the freed slot goes to the one asked for LAST, which is the card the selection + // has just reached rather than the one it left behind. + await grid.settle('a', 'data:a'); + expect(grid.calls).toEqual(['a', 'b', 'd']); + }); + + it('frees the slot when a request rejects, instead of jamming the queue', async () => { + const failing = vi + .fn<(id: string) => Promise<string | null>>() + .mockRejectedValueOnce(new Error('ipc is gone')) + .mockResolvedValue('data:b'); + const cache = createCardArtCache({ requestGrid: failing }, 100, 1); + expect(await cache.load('a@', 'a')).toBeNull(); + expect(await cache.load('b@', 'b')).toBe('data:b'); + expect(failing).toHaveBeenCalledTimes(2); + }); + + it('drops the queued requests that left the window, and keeps the started ones', async () => { + const grid = deferredGrid(); + const cache = createCardArtCache(grid, 100, 1); + const started = cache.load('a@', 'a'); + const kept = cache.load('b@', 'b'); + const dropped = cache.load('c@', 'c'); + cache.dropPending(new Set(['b@'])); + expect(await dropped).toBeNull(); + await grid.settle('a', 'data:a'); + expect(await started).toBe('data:a'); + expect(grid.calls).toEqual(['a', 'b']); + await grid.settle('b', 'data:b'); + expect(await kept).toBe('data:b'); + expect(grid.calls).not.toContain('c'); + }); + + it('lets a dropped key be asked for again when it comes back into the window', async () => { + const grid = deferredGrid(); + const cache = createCardArtCache(grid, 100, 1); + void cache.load('a@', 'a'); + void cache.load('b@', 'b'); + cache.dropPending(new Set()); + void cache.load('b@', 'b'); + await grid.settle('a', 'data:a'); + expect(grid.calls).toEqual(['a', 'b']); + }); +}); diff --git a/test/carousel-geometry.test.ts b/test/carousel-geometry.test.ts index 7b44e824..c74c2feb 100644 --- a/test/carousel-geometry.test.ts +++ b/test/carousel-geometry.test.ts @@ -5,25 +5,36 @@ import { CARD_W, FAN_MAX, GAP, + MAX_STRIP_GAMES, + SEL_GAP, + SEL_H, + SEL_MARGIN, + SEL_W, STEP, + stripCanvas, cardLeft, clampIndex, fanIndex, isNearViewport, + isWithinWindow, stripOffset, + VISIBLE_CARDS, } from '../src/renderer/carousel-geometry'; +import { SYSTEM_CARDS } from '../src/renderer/system-cards'; describe('stripOffset', () => { - it('does not shift the strip for the first card', () => { - expect(stripOffset(0)).toBe(0); + it('shifts the FIRST card by the selected margin — it is off the origin like any other', () => { + // Not zero any more: the selected card carries its own margins wherever it stands, so even card 0 + // starts one margin in and the strip has to pull back by exactly that to land it on the anchor. + expect(stripOffset(0)).toBe(-SEL_MARGIN); }); it('advances by one card + gap per index', () => { expect(STEP).toBe(CARD_W + GAP); - // 90 (card) + 16 (gap) — the step measured off the mockup, where consecutive unselected cards sit - // at x=202/308/414. - expect(stripOffset(1)).toBe(-106); - expect(stripOffset(3)).toBe(-318); + // Against STEP rather than a literal: the gap is a design decision that has moved twice already, + // and a hard-coded step turns that into a failing test rather than a re-spaced row. + expect(stripOffset(1)).toBe(-(STEP + SEL_MARGIN)); + expect(stripOffset(3)).toBe(-(3 * STEP + SEL_MARGIN)); }); it('is linear — the step between neighbours never depends on where you are', () => { @@ -40,13 +51,56 @@ describe('cardLeft (the anchor invariant)', () => { } }); - it('places neighbours symmetrically around the anchor', () => { - expect(cardLeft(4, 5)).toBe(-STEP); - expect(cardLeft(6, 5)).toBe(STEP); + it('places the neighbours ASYMMETRICALLY — the selected card is wider than the rest', () => { + // The one to the left ends a normal step plus the selected card's own margin away; the one to the + // right starts past the WIDE card and its full gap. Symmetry here would mean the row overlapped. + expect(cardLeft(4, 5)).toBe(-STEP - SEL_MARGIN); + expect(cardLeft(6, 5)).toBe(SEL_W + SEL_GAP); }); it('sends the cards left of the selection off to negative x (they leave the screen edge)', () => { - expect(cardLeft(0, 10)).toBe(-10 * STEP); + expect(cardLeft(0, 10)).toBe(-10 * STEP - SEL_MARGIN); + }); + + it('never lets two cards overlap, whichever one is selected', () => { + for (const selected of [0, 1, 5, 12]) { + for (let i = 0; i < 12; i += 1) { + const width = i === selected ? SEL_W : CARD_W; + expect(cardLeft(i + 1, selected)).toBeGreaterThanOrEqual(cardLeft(i, selected) + width); + } + } + }); + + it('leaves the selected card the wider gap and everyone else the narrow one', () => { + const selected = 5; + expect(cardLeft(selected, selected) - (cardLeft(selected - 1, selected) + CARD_W)).toBe(SEL_GAP); + expect(cardLeft(selected + 1, selected) - SEL_W).toBe(SEL_GAP); + expect(cardLeft(selected - 1, selected) - (cardLeft(selected - 2, selected) + CARD_W)).toBe(GAP); + }); +}); + +describe('stripCanvas (the focus body\'s canvas)', () => { + it('spans the whole row plus slack on both sides', () => { + const margin = 26; + const room = SEL_W + 2 * SEL_MARGIN; // the selected card takes its margins with it + expect(stripCanvas(1, margin).width).toBe(room + 2 * margin); + expect(stripCanvas(4, margin).width).toBe(3 * STEP + room + 2 * margin); + expect(stripCanvas(4, margin).height).toBe(SEL_H + 2 * margin); + }); + + it('covers the row at its widest — the selected card standing at the last step', () => { + // Whatever card is selected, the row's right edge is at most this: everything before it at the + // normal width (the layout invariant above), and the selected one grown. + const count = 13; + expect(stripCanvas(count, 0).width).toBe((count - 1) * STEP + SEL_W + 2 * SEL_MARGIN); + // …and it really is the row's own extent, measured the other way: cardLeft counts from the SELECTED + // card's left edge, so the row is that card's left margin plus everything out to the last card's + // right edge. One margin, not two — the right-hand one is already inside cardLeft's SEL_GAP. + expect(stripCanvas(count, 0).width).toBe(SEL_MARGIN + cardLeft(count - 1, 0) + CARD_W); + }); + + it('never collapses on an empty row', () => { + expect(stripCanvas(0, 26).width).toBe(SEL_W + 2 * SEL_MARGIN + 52); }); }); @@ -86,3 +140,42 @@ describe('isNearViewport', () => { expect(isNearViewport(25, 12)).toBe(false); }); }); + +describe('isWithinWindow (how many cards the row shows)', () => { + it('shows the selected card and the eight after it', () => { + expect(VISIBLE_CARDS).toBe(9); + expect(isWithinWindow(0, 0)).toBe(true); + expect(isWithinWindow(8, 0)).toBe(true); + expect(isWithinWindow(9, 0)).toBe(false); + expect(isWithinWindow(39, 0)).toBe(false); + }); + + it('moves with the selection — one flip right brings exactly one card in', () => { + expect(isWithinWindow(9, 0)).toBe(false); + expect(isWithinWindow(9, 1)).toBe(true); + expect(isWithinWindow(10, 1)).toBe(false); + }); + + it('leaves everything BEHIND the selection alone (the strip slides those off screen itself)', () => { + expect(isWithinWindow(0, 20)).toBe(true); + expect(isWithinWindow(19, 20)).toBe(true); + }); + + it('never hides anything in a list that fits the window', () => { + for (let selected = 0; selected < VISIBLE_CARDS; selected += 1) { + for (let index = 0; index < VISIBLE_CARDS; index += 1) { + expect(isWithinWindow(index, selected)).toBe(true); + } + } + }); +}); + +describe('MAX_STRIP_GAMES', () => { + it('keeps Home a shortlist: 9 games plus the launcher cards is 13 cards', () => { + expect(MAX_STRIP_GAMES + SYSTEM_CARDS.length).toBe(13); + }); + + it('is not wider than the shown window — a capped row is never partly out of view', () => { + expect(MAX_STRIP_GAMES).toBeLessThanOrEqual(VISIBLE_CARDS); + }); +}); diff --git a/test/config-paths.test.ts b/test/config-paths.test.ts new file mode 100644 index 00000000..f2dbc2fa --- /dev/null +++ b/test/config-paths.test.ts @@ -0,0 +1,215 @@ +// The path rules the in-launcher file picker leans on. They used to be the native dialog's job (its +// filters decided what could be picked at all), so they were never expressible as a test; now that a +// renderer names the path, they are the gate — see the plan, Р5.1/Р5.2. +// +// The paths here are HOST paths (a card root is `E:\` on Windows and `/run/media/deck/…` on the Deck), so +// expectations are built with `path.join` from the platform root rather than written as posix literals — +// the same shape test/pc-library.test.ts already uses for the library's own absolute paths. The one thing +// asserted as a literal is what lands in the MANIFEST, which is forward-slashed on both. +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + acceptsExtensions, + checkPickedType, + hostPlatform, + listsAsFile, + picksDirectory, + startDirFor, + toCardRelative, + type PickedStat, +} from '../src/main/config-paths'; + +const file: PickedStat = { isSymbolicLink: false, isDirectory: false, isFile: true }; +const dir: PickedStat = { isSymbolicLink: false, isDirectory: true, isFile: false }; +const link: PickedStat = { isSymbolicLink: true, isDirectory: false, isFile: false }; + +const root = path.join(path.resolve(path.sep), 'card'); +const home = path.join(path.resolve(path.sep), 'home', 'deck'); +const appData = path.join(path.resolve(path.sep), 'home', 'deck', 'AppData', 'Roaming'); + +describe('acceptsExtensions', () => { + it('holds a CARD field to .exe on both platforms (a card is a Windows dictionary)', () => { + expect(acceptsExtensions('executable', 'win32')).toEqual(['exe']); + expect(acceptsExtensions('executable', 'linux')).toEqual(['exe']); + expect(acceptsExtensions('installer', 'linux')).toEqual(['exe']); + }); + + it('only nudges for a LOCAL executable on Windows, and allows anything on Linux', () => { + expect(acceptsExtensions('pc-executable', 'win32')).toEqual(['exe', 'bat', 'cmd', 'lnk']); + expect(acceptsExtensions('pc-executable', 'linux')).toBeNull(); + }); + + it('leaves the asset kinds to the caller (the AssetReader owns those lists)', () => { + expect(acceptsExtensions('image', 'linux')).toBeNull(); + expect(acceptsExtensions('audio', 'linux')).toBeNull(); + }); +}); + +describe('picksDirectory', () => { + it('is true exactly for the folder-shaped fields', () => { + expect(picksDirectory('directory')).toBe(true); + expect(picksDirectory('pc-save')).toBe(true); + expect(picksDirectory('pc-save-local')).toBe(true); + expect(picksDirectory('image')).toBe(false); + expect(picksDirectory('executable')).toBe(false); + }); +}); + +describe('checkPickedType', () => { + const images = ['jpg', 'png']; + + it('accepts a file whose extension matches the kind', () => { + expect(checkPickedType('/x/hero.png', 'image', file, images)).toBeNull(); + }); + + it('refuses a private key offered as a hero image (the attack Р5.1 names)', () => { + expect(checkPickedType('/home/deck/.ssh/id_rsa', 'image', file, images)).toBe('wrong-type'); + }); + + it('refuses a symlink rather than following it', () => { + expect(checkPickedType('/x/hero.png', 'image', link, images)).toBe('symlink'); + }); + + it('refuses a path that is not there', () => { + expect(checkPickedType('/x/hero.png', 'image', null, images)).toBe('missing'); + }); + + it('requires a folder for a folder field, and a file for a file field', () => { + expect(checkPickedType('/x/saves', 'directory', dir, null)).toBeNull(); + expect(checkPickedType('/x/saves', 'directory', file, null)).toBe('needs-folder'); + expect(checkPickedType('/x/game.exe', 'executable', dir, ['exe'])).toBe('needs-file'); + }); + + it('compares the extension case-insensitively', () => { + expect(checkPickedType('/x/GAME.EXE', 'executable', file, ['exe'])).toBeNull(); + }); + + it('accepts any extension when the kind has no list', () => { + expect(checkPickedType('/x/hades', 'pc-executable', file, null)).toBeNull(); + }); + + // A macOS `.app` is a DIRECTORY that means one executable — accepted for a local game there, and only + // there: a card stays a Windows dictionary, and on Windows/Linux a `.app` folder is just a folder. + it('accepts a .app bundle as a local executable on macOS', () => { + expect(checkPickedType('/Applications/Hades.app', 'pc-executable', dir, null, 'darwin')).toBeNull(); + expect(checkPickedType('/Applications/Hades.APP', 'pc-executable', dir, null, 'darwin')).toBeNull(); + }); + + it('does not accept a .app bundle on Windows or Linux, nor for a card field', () => { + expect(checkPickedType('/Applications/Hades.app', 'pc-executable', dir, null, 'win32')).toBe( + 'needs-file', + ); + expect(checkPickedType('/Applications/Hades.app', 'pc-executable', dir, null, 'linux')).toBe( + 'needs-file', + ); + expect(checkPickedType('/Applications/Hades.app', 'executable', dir, ['exe'], 'darwin')).toBe( + 'needs-file', + ); + }); + + it('still refuses an ordinary folder as a local executable on macOS', () => { + expect(checkPickedType('/Applications/Hades', 'pc-executable', dir, null, 'darwin')).toBe( + 'needs-file', + ); + }); +}); + +describe('listsAsFile (the picker shows a .app bundle as one entry)', () => { + it('collapses a .app directory to a file while browsing for a local executable on macOS', () => { + expect(listsAsFile('/Applications/Hades.app', true, 'pc-executable', 'darwin')).toBe(true); + }); + + it('leaves it a directory for any other field, OS or entry type', () => { + expect(listsAsFile('/Applications/Hades.app', true, 'image', 'darwin')).toBe(false); + expect(listsAsFile('/Applications/Hades.app', true, undefined, 'darwin')).toBe(false); + expect(listsAsFile('/Applications/Hades.app', true, 'pc-executable', 'win32')).toBe(false); + expect(listsAsFile('/Applications/Hades.app', false, 'pc-executable', 'darwin')).toBe(false); + expect(listsAsFile('/Applications/Hades', true, 'pc-executable', 'darwin')).toBe(false); + }); +}); + +describe('hostPlatform (what the renderer is told about the running OS)', () => { + it('names the three ports and folds everything else into linux', () => { + expect(hostPlatform('win32')).toBe('windows'); + expect(hostPlatform('darwin')).toBe('macos'); + expect(hostPlatform('linux')).toBe('linux'); + expect(hostPlatform('freebsd')).toBe('linux'); + }); +}); + +describe('toCardRelative', () => { + it('makes a card-relative, forward-slashed manifest path', () => { + expect(toCardRelative(root, path.join(root, 'Hades', 'Hades.exe'))).toBe('Hades/Hades.exe'); + }); + + it('refuses a path outside the card instead of emitting a `..` escape', () => { + expect( + toCardRelative(root, path.join(path.resolve(path.sep), 'elsewhere', 'x.exe')), + ).toBeNull(); + }); + + it('refuses the card root itself (an empty relative the schema would reject)', () => { + expect(toCardRelative(root, root)).toBeNull(); + }); +}); + +describe('startDirFor', () => { + const downloads = path.join(home, 'Downloads'); + const env = { + homeDir: home, + appDataDir: appData, + downloadsDir: downloads, + rootIsCard: true, + }; + + it('reopens where a filled card-relative value points', () => { + expect(startDirFor({ root, kind: 'executable', current: 'Hades/Hades.exe' }, env)).toBe( + path.join(root, 'Hades'), + ); + }); + + it('reopens where a filled ABSOLUTE value points', () => { + const exe = path.join(home, 'Games', 'Hades', 'Hades.exe'); + expect(startDirFor({ kind: 'pc-executable', current: exe }, env)).toBe(path.dirname(exe)); + }); + + it('ignores a %PREFIX% value — it names no host directory', () => { + expect(startDirFor({ kind: 'pc-save', current: '%APPDATA%/Hades' }, env)).toBe(appData); + }); + + it('starts a card field at the card root', () => { + expect(startDirFor({ root, kind: 'image' }, env)).toBe(root); + }); + + // Artwork and music for a LOCAL game were downloaded a minute ago far more often than they were + // authored in place, and the library root itself holds only what we already copied into it. + it('starts a local library artwork field in Downloads, not at the library root', () => { + expect(startDirFor({ root, kind: 'image' }, { ...env, rootIsCard: false })).toBe(downloads); + expect(startDirFor({ root, kind: 'audio' }, { ...env, rootIsCard: false })).toBe(downloads); + }); + + // The game writes its saves on the PC whatever it was launched from, so a CARD game's save path has no + // business opening on the card. + it('starts a save path at %APPDATA% even for a card game', () => { + expect(startDirFor({ root, kind: 'pc-save' }, env)).toBe(appData); + }); + + // "Move game to PC": the manifest resolves `executable` under the install directory, which receives + // the CONTENTS of the named game folder — so both the browsing and the measuring happen from there. + it('browses a sub-directory field from that sub-directory, not from the root', () => { + const gameDir = path.join(root, 'Games', 'Hades'); + expect(startDirFor({ root, kind: 'executable', baseDir: gameDir }, env)).toBe(gameDir); + }); + + it('resolves a filled value against the sub-directory when there is one', () => { + const gameDir = path.join(root, 'Games', 'Hades'); + expect( + startDirFor({ root, kind: 'executable', baseDir: gameDir, current: 'bin/hades.exe' }, env), + ).toBe(path.join(gameDir, 'bin')); + }); + + it('starts a local executable at the home folder and a save path at %APPDATA%', () => { + expect(startDirFor({ root, kind: 'pc-executable' }, env)).toBe(home); + expect(startDirFor({ root, kind: 'pc-save-local' }, env)).toBe(appData); + }); +}); diff --git a/test/configure-form-model.test.ts b/test/configure-form-model.test.ts index af72d748..daaa0eb5 100644 --- a/test/configure-form-model.test.ts +++ b/test/configure-form-model.test.ts @@ -1,10 +1,14 @@ +import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { + emptyFormModel, formModelToText, slugifyId, textToFormModel, textToGames, gamesToText, + isRawSlot, + slotsWithNewGame, launchModeOf, KNOWN_MANIFEST_KEYS, type GameFormState, @@ -365,6 +369,202 @@ describe('launch mode', () => { }); }); +describe('pc mode (a local game on this PC)', () => { + // Native, absolute path: the PC library never travels, and CI runs this suite on Windows too. + const exe = path.join(path.resolve(path.sep), 'Games', 'Hades', 'Hades.exe'); + const pcText = JSON.stringify({ + schemaVersion: 1, + id: 'hades', + title: 'Hades', + pc: { executable: exe }, + heroImage: 'assets/hero.jpg', + watchProcesses: ['Hades.exe'], + }); + + it('parses into pc mode without spilling the block into `rest`', () => { + const result = parseOk(pcText); + expect(launchModeOf(result.model)).toBe('pc'); + expect(result.model.pc.executable).toBe(exe); + expect(result.rest).toEqual({}); + expect(result.corrupt).toEqual({}); + }); + + it('round-trips into a manifest the PC-source validator accepts', () => { + const text = serialize(pcText); + expect(validateManifestText(text, t, 'pc').ok).toBe(true); + expect(serialize(text)).toBe(text); // idempotent + }); + + it('does not emit the card-relative executable or an install block', () => { + const base = parseOk(pcText); + const model: ManifestFormModel = { + ...base.model, + executable: 'ghost.exe', + copyToPc: true, + }; + const parsed = JSON.parse(formModelToText(model, {}, {})) as Record<string, unknown>; + expect(parsed).toHaveProperty('pc'); + expect(parsed).not.toHaveProperty('executable'); + expect(parsed).not.toHaveProperty('install'); + }); + + it('keeps args/runAsAdmin/watchProcesses, which a local game may use like any other', () => { + const model: ManifestFormModel = { + ...parseOk(pcText).model, + args: ['-windowed'], + runAsAdmin: true, + }; + const parsed = JSON.parse(formModelToText(model, {}, {})) as Record<string, unknown>; + expect(parsed['args']).toEqual(['-windowed']); + expect(parsed['runAsAdmin']).toBe(true); + expect(parsed['watchProcesses']).toEqual(['Hades.exe']); + }); + + it('switching a parsed pc game to another mode drops the block (modes are exclusive)', () => { + const model: ManifestFormModel = { + ...parseOk(pcText).model, + launchMode: 'executable', + executable: 'g/g.exe', + }; + const parsed = JSON.parse(formModelToText(model, {}, {})) as Record<string, unknown>; + expect(parsed).not.toHaveProperty('pc'); + expect(parsed['executable']).toBe('g/g.exe'); + }); + + it('emptyFormModel("pc") starts a blank library in pc mode', () => { + expect(launchModeOf(emptyFormModel('pc'))).toBe('pc'); + expect(launchModeOf(emptyFormModel())).toBe('executable'); + }); + + it('serializes an EMPTY game list as [] (the PC library was emptied)', () => { + expect(gamesToText([])).toBe('[]\n'); + }); +}); + +describe('none mode (PC-library draft — no launch method chosen yet, Р1)', () => { + it('emits no launch block at all, but keeps args/runAsAdmin/winetricks/umuGameId', () => { + const model: ManifestFormModel = { + ...emptyFormModel('none'), + id: 'hades', + title: 'Hades', + heroImage: ['assets/hero.jpg'], + args: ['-windowed'], + runAsAdmin: true, + winetricks: ['dotnet48'], + umuGameId: 'umu-hades', + }; + const parsed = JSON.parse(formModelToText(model, {}, {})) as Record<string, unknown>; + expect(parsed).not.toHaveProperty('pc'); + expect(parsed).not.toHaveProperty('steam'); + expect(parsed).not.toHaveProperty('install'); + expect(parsed).not.toHaveProperty('executable'); + expect(parsed['args']).toEqual(['-windowed']); + expect(parsed['runAsAdmin']).toBe(true); + expect(parsed['winetricks']).toEqual(['dotnet48']); + expect(parsed['umuGameId']).toBe('umu-hades'); + }); + + it('serializes into a manifest the PC-library validator accepts as a draft', () => { + const model: ManifestFormModel = { + ...emptyFormModel('none'), + id: 'hades', + title: 'Hades', + heroImage: ['assets/hero.jpg'], + }; + expect(validateManifestText(formModelToText(model, {}, {}), t, 'pc').ok).toBe(true); + }); + + it('round-trips args/runAsAdmin/winetricks/umuGameId — nothing vanishes silently on Save', () => { + const model: ManifestFormModel = { + ...emptyFormModel('none'), + id: 'hades', + title: 'Hades', + heroImage: ['assets/hero.jpg'], + args: ['-windowed'], + runAsAdmin: true, + winetricks: ['dotnet48'], + umuGameId: 'umu-hades', + }; + const reparsed = parseOk(formModelToText(model, {}, {})); + // textToFormModel has no `source` — it defaults to 'executable' here; the screen corrects that via + // draftModeFor once it knows the manifest is from the PC library (see game-settings-model.test.ts). + expect(reparsed.model.args).toEqual(['-windowed']); + expect(reparsed.model.runAsAdmin).toBe(true); + expect(reparsed.model.winetricks).toEqual(['dotnet48']); + expect(reparsed.model.umuGameId).toBe('umu-hades'); + }); + + it('unknown/corrupt keys still survive the round-trip in none mode', () => { + const model: ManifestFormModel = { ...emptyFormModel('none'), id: 'hades', title: 'Hades' }; + const rest = { customLauncherHint: 'wine' }; + const corrupt = { launchTimeoutSec: 'soon' }; + const parsed = JSON.parse(formModelToText(model, rest, corrupt)) as Record<string, unknown>; + expect(parsed['customLauncherHint']).toBe('wine'); + expect(parsed['launchTimeoutSec']).toBe('soon'); + }); + + it('switching pc -> none -> pc does not lose the typed pc.executable path', () => { + const exe = path.join(path.resolve(path.sep), 'Games', 'Hades', 'Hades.exe'); + const withPath: ManifestFormModel = { + ...emptyFormModel('pc'), + pc: { executable: exe, rest: {} }, + }; + const draft: ManifestFormModel = { ...withPath, launchMode: 'none' }; + const backToPc: ManifestFormModel = { ...draft, launchMode: 'pc' }; + expect(backToPc.pc.executable).toBe(exe); + }); + + it('emptyFormModel("none") is a valid, blank model', () => { + expect(launchModeOf(emptyFormModel('none'))).toBe('none'); + }); +}); + +describe('steam mode in the PC library (a Steam game installed on this PC)', () => { + const steamText = JSON.stringify({ + schemaVersion: 1, + id: 'hades', + title: 'Hades', + steam: { appid: 1145360 }, + watchProcesses: ['Hades.exe'], + heroImage: 'assets/hero.jpg', + pcSavePath: '%APPDATA%/Hades', + }); + + it('round-trips into a manifest the PC-source validator accepts', () => { + const result = parseOk(steamText); + expect(launchModeOf(result.model)).toBe('steam'); + const text = serialize(steamText); + expect(validateManifestText(text, t, 'pc').ok).toBe(true); + expect(serialize(text)).toBe(text); // idempotent + }); + + it('keeps pcSavePath — the save backup a local Steam game gets from the library', () => { + const parsed = JSON.parse(serialize(steamText)) as Record<string, unknown>; + expect(parsed['pcSavePath']).toBe('%APPDATA%/Hades'); + expect(parsed).not.toHaveProperty('saveOnCard'); + }); + + // The counterpart of the rule above, and the reason `saveOnCard` may NOT be gated by launch mode: on a + // CARD a Steam game's save sync is exactly `saveOnCard` + `pcSavePath`. Suppressing it for steam mode + // would silently break every existing Steam card. The PC library is kept clean by the form instead (it + // hides the field and clears the slot when the edited root is the library — see FormView.setSource). + it('still emits saveOnCard for a CARD steam game', () => { + const cardSteam = JSON.stringify({ + schemaVersion: 1, + id: 'hades', + title: 'Hades', + steam: { appid: 1145360 }, + watchProcesses: ['Hades.exe'], + heroImage: 'assets/hero.jpg', + saveOnCard: 'saves/hades', + pcSavePath: '%APPDATA%/Hades', + }); + const parsed = JSON.parse(serialize(cardSteam)) as Record<string, unknown>; + expect(parsed['saveOnCard']).toBe('saves/hades'); + expect(validateManifestText(serialize(cardSteam), t, 'card').ok).toBe(true); + }); +}); + describe('multi-game wrapper (textToGames / gamesToText)', () => { const gameText = (id: string): string => `{"schemaVersion":1,"id":"${id}","title":"${id}","executable":"g.exe","heroImage":"h.jpg"}`; @@ -432,6 +632,135 @@ describe('multi-game wrapper (textToGames / gamesToText)', () => { expect(parsed.games[1]?.ok).toBe(false); } }); + + // The per-game editor makes this reachable: readManifests SKIPS a game that does not resolve, the rest + // of the card stays playable, and the user edits one of them. Saving must not take the broken neighbour + // with it — hence the raw slot (see the plan, Р2). + it('writes an unrepresentable neighbour back VERBATIM instead of dropping it', () => { + const source = `[${gameText('a')}, ["not", "a game"]]`; + const parsed = textToGames(source); + expect(parsed.ok).toBe(true); + if (!parsed.ok) throw new Error('unreachable'); + + const rebuilt: GameFormState[] = parsed.games.map((game, index) => { + if (!game.ok) return { raw: parsed.values[index] }; + return { model: game.model, rest: game.rest, corrupt: game.corrupt }; + }); + // The editable slot even changes, exactly as a real edit would. + const first = rebuilt[0]; + if (first === undefined || !('model' in first)) throw new Error('unreachable'); + rebuilt[0] = { ...first, model: { ...first.model, title: 'Renamed' } }; + + const out = JSON.parse(gamesToText(rebuilt)) as unknown; + expect(Array.isArray(out)).toBe(true); + const games = out as readonly Record<string, unknown>[]; + expect(games).toHaveLength(2); + expect(games[0]?.title).toBe('Renamed'); + expect(games[1]).toEqual(['not', 'a game']); + }); +}); + +// Adding a game to a root the launcher has never written to is the case the multi-game wrapper alone +// cannot express: `textToGames` rejects an empty list, so a blank card would look like a broken file. +describe('slotsWithNewGame (the Add-game screen\'s starting point)', () => { + const gameText = (id: string): string => + `{"schemaVersion":1,"id":"${id}","title":"${id}","executable":"g.exe","heroImage":"h.jpg"}`; + + it('starts a root with NO game.json on a single blank slot', () => { + const result = slotsWithNewGame(null, 'pc'); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error('unreachable'); + expect(result.slots).toHaveLength(1); + expect(result.index).toBe(0); + const slot = result.slots[0]; + if (slot === undefined || isRawSlot(slot)) throw new Error('unreachable'); + expect(slot.model.launchMode).toBe('pc'); + expect(slot.model.id).toBe(''); + }); + + it('treats an empty file the same way (nothing to carry over)', () => { + const result = slotsWithNewGame(' ', 'executable'); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error('unreachable'); + expect(result.slots).toHaveLength(1); + }); + + it('appends the new game AFTER a single-object manifest', () => { + const result = slotsWithNewGame(gameText('hades'), 'executable'); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error('unreachable'); + expect(result.slots).toHaveLength(2); + expect(result.index).toBe(1); + const first = result.slots[0]; + if (first === undefined || isRawSlot(first)) throw new Error('unreachable'); + expect(first.model.id).toBe('hades'); + }); + + it('appends the new game AFTER an array manifest', () => { + const result = slotsWithNewGame(`[${gameText('a')},${gameText('b')}]`, 'executable'); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error('unreachable'); + expect(result.slots).toHaveLength(3); + expect(result.index).toBe(2); + }); + + // The neighbour a naive rewrite destroys: an element the form cannot represent is carried verbatim. + it('keeps a slot the form cannot represent, verbatim', () => { + const result = slotsWithNewGame(`[${gameText('a')},42]`, 'executable'); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error('unreachable'); + const raw = result.slots[1]; + if (raw === undefined || !isRawSlot(raw)) throw new Error('unreachable'); + expect(raw.raw).toBe(42); + expect(JSON.parse(gamesToText(result.slots.slice(0, 2)))).toEqual([ + expect.objectContaining({ id: 'a' }), + 42, + ]); + }); + + it('reports an unreadable manifest rather than starting from a blank one', () => { + expect(slotsWithNewGame('{ not json', 'executable').ok).toBe(false); + }); +}); + +describe('description (written by "Find online", carried by `rest`)', () => { + it('survives a round trip through the form untouched', () => { + const description = { en: 'A rogue-like.', ru: 'Рогалик.' }; + const text = JSON.stringify({ + schemaVersion: 1, + id: 'hades', + title: 'Hades', + executable: 'g/g.exe', + heroImage: 'a/hero.jpg', + description, + }); + const parsed = textToFormModel(text); + expect(parsed.ok).toBe(true); + if (!parsed.ok) throw new Error('unreachable'); + // It lands in `rest` rather than in a field: no row edits it, and the form must not drop it. + expect(parsed.rest['description']).toEqual(description); + const written = JSON.parse(formModelToText(parsed.model, parsed.rest, parsed.corrupt)) as { + description?: unknown; + }; + expect(written.description).toEqual(description); + }); + + it('carries a description the flow has just written into the saved text', () => { + const text = JSON.stringify({ + schemaVersion: 1, + id: 'hades', + title: 'Hades', + executable: 'g/g.exe', + heroImage: 'a/hero.jpg', + }); + const parsed = textToFormModel(text); + if (!parsed.ok) throw new Error('unreachable'); + const withDescription = { ...parsed.rest, description: { en: 'Fetched online.' } }; + const written = JSON.parse(formModelToText(parsed.model, withDescription, parsed.corrupt)) as { + description?: unknown; + }; + expect(written.description).toEqual({ en: 'Fetched online.' }); + }); }); describe('drift guard: form keys vs the zod schema', () => { @@ -443,6 +772,11 @@ describe('drift guard: form keys vs the zod schema', () => { }; const objectSchema = schema.oneOf?.[0]; const schemaKeys = new Set(Object.keys(objectSchema?.properties ?? {})); + // These four are the schema keys the form deliberately does NOT own: they are written by the "Find + // online" flow straight into `rest` (which round-trips them verbatim) and no row edits them, so + // giving them form fields would mean controls for values the UI has nothing to say about yet. Every + // OTHER schema key must still be listed — that is what this guard exists for. + for (const key of ['description', 'genres', 'releaseDate', 'platforms']) schemaKeys.delete(key); expect(schemaKeys).toEqual(new Set(KNOWN_MANIFEST_KEYS)); }); }); diff --git a/test/focus-jelly.test.ts b/test/focus-jelly.test.ts new file mode 100644 index 00000000..3a44719f --- /dev/null +++ b/test/focus-jelly.test.ts @@ -0,0 +1,141 @@ +// The focus body's geometry: the box it wraps, the contour it walks and the squeeze it makes on the way +// to the next cover. The renderer only feeds these numbers to a canvas, so a contour that clipped the +// cover's corners — or a squeeze that never came back to 1 — would only ever show up on a Deck. +import { describe, expect, it } from 'vitest'; +import { JELLY, jellyBoxOf, outlinePoint, pinchScale, type JellyBox } from '../src/renderer/focus-jelly'; + +const box = (over: Partial<JellyBox> = {}): JellyBox => ({ x: 0, y: 0, w: 200, h: 300, r: 20, ...over }); + +describe('jellyBoxOf', () => { + it('pushes the cover out by the stand-off on every side', () => { + const b = jellyBoxOf(100, 50, 200, 300, 12, 1); + expect(b.x).toBe(100 - JELLY.inset); + expect(b.y).toBe(50 - JELLY.inset); + expect(b.w).toBe(200 + 2 * JELLY.inset); + expect(b.h).toBe(300 + 2 * JELLY.inset); + }); + + it('grows the radius by the same distance — the body echoes the cover, it does not just allude to it', () => { + expect(jellyBoxOf(0, 0, 200, 300, 12, 1).r).toBe(12 + JELLY.inset); + }); + + it('stays concentric with the cover it wraps', () => { + const b = jellyBoxOf(340, 120, 200, 300, 12, 1); + expect(b.x + b.w / 2).toBeCloseTo(340 + 100); + expect(b.y + b.h / 2).toBeCloseTo(120 + 150); + }); + + it('scales the stand-off with --px, but never the measured offset', () => { + const b = jellyBoxOf(500, 300, 200, 300, 12, 0.7); + expect(b.x).toBeCloseTo(500 - JELLY.inset * 0.7); + expect(b.w).toBeCloseTo(200 + 2 * JELLY.inset * 0.7); + }); +}); + +describe('outlinePoint', () => { + it('stays on the box — never outside it, never short of it', () => { + const b = box(); + for (let i = 0; i < 200; i += 1) { + const [x, y] = outlinePoint(b, i / 200); + expect(x).toBeGreaterThanOrEqual(b.x - 1e-9); + expect(x).toBeLessThanOrEqual(b.x + b.w + 1e-9); + expect(y).toBeGreaterThanOrEqual(b.y - 1e-9); + expect(y).toBeLessThanOrEqual(b.y + b.h + 1e-9); + } + }); + + it('touches all four edges — a contour that missed one would not be the cover\'s shape', () => { + const b = box(); + let top = false; + let right = false; + let bottom = false; + let left = false; + for (let i = 0; i < 400; i += 1) { + const [x, y] = outlinePoint(b, i / 400); + if (Math.abs(y - b.y) < 1e-6) top = true; + if (Math.abs(x - (b.x + b.w)) < 1e-6) right = true; + if (Math.abs(y - (b.y + b.h)) < 1e-6) bottom = true; + if (Math.abs(x - b.x) < 1e-6) left = true; + } + expect([top, right, bottom, left]).toEqual([true, true, true, true]); + }); + + it('rounds the corners by exactly the radius', () => { + const b = box({ r: 20 }); + // The far corner of the box is outside the body by r*(1 - 1/√2) on each axis; the nearest contour + // point to it must sit on the corner's arc, i.e. exactly r away from that arc's centre. + const centre = { x: b.x + b.w - b.r, y: b.y + b.r }; + let nearest = Infinity; + for (let i = 0; i < 400; i += 1) { + const [x, y] = outlinePoint(b, i / 400); + if (x <= centre.x || y >= centre.y) continue; // only the quarter beyond the corner's centre + nearest = Math.min(nearest, Math.abs(Math.hypot(x - centre.x, y - centre.y) - b.r)); + } + expect(nearest).toBeLessThan(1e-6); + }); + + it('gives every corner points of its own — the reason it walks by arc length', () => { + const b = box({ w: 200, h: 300, r: 28 }); + // A point is IN a corner when both of its coordinates are past the straight part of their edge. + const corners = [0, 0, 0, 0]; + for (let i = 0; i < JELLY.points; i += 1) { + const [x, y] = outlinePoint(b, i / JELLY.points); + const inCornerX = x < b.x + b.r || x > b.x + b.w - b.r; + const inCornerY = y < b.y + b.r || y > b.y + b.h - b.r; + if (!inCornerX || !inCornerY) continue; + const right = x > b.x + b.w / 2 ? 1 : 0; + const low = y > b.y + b.h / 2 ? 2 : 0; + const at = right + low; + corners[at] = (corners[at] ?? 0) + 1; + } + expect(corners.filter((count) => count > 0)).toHaveLength(4); + }); + + it('wraps: t and t+1 are the same place, and t=0 is the top edge', () => { + const b = box(); + expect(outlinePoint(b, 1.25)).toEqual(outlinePoint(b, 0.25)); + expect(outlinePoint(b, -0.25)).toEqual(outlinePoint(b, 0.75)); + expect(outlinePoint(b, 0)[1]).toBe(b.y); + }); + + it('survives a radius larger than the box — it clamps instead of turning inside out', () => { + const b = box({ w: 100, h: 100, r: 900 }); + for (let i = 0; i < 50; i += 1) { + const [x, y] = outlinePoint(b, i / 50); + expect(Number.isFinite(x)).toBe(true); + expect(Number.isFinite(y)).toBe(true); + } + }); + + it('handles a square corner (radius 0) without dividing by it', () => { + const b = box({ r: 0 }); + const [x, y] = outlinePoint(b, 0.25); + expect(Number.isFinite(x)).toBe(true); + expect(Number.isFinite(y)).toBe(true); + }); +}); + +describe('pinchScale', () => { + it('is a bell: full size at both ends, tightest half way', () => { + expect(pinchScale(0)).toBeCloseTo(1); + expect(pinchScale(1)).toBeCloseTo(1); + expect(pinchScale(0.5)).toBeCloseTo(JELLY.pinch); + }); + + it('never overshoots past the floor or past full size', () => { + for (let i = 0; i <= 100; i += 1) { + const k = pinchScale(i / 100); + expect(k).toBeGreaterThanOrEqual(JELLY.pinch - 1e-9); + expect(k).toBeLessThanOrEqual(1 + 1e-9); + } + }); + + it('clamps a progress that ran past the move — a finished trip is not a second squeeze', () => { + expect(pinchScale(1.7)).toBeCloseTo(1); + expect(pinchScale(-3)).toBeCloseTo(1); + }); + + it('a floor of 1 means no squeeze at all', () => { + expect(pinchScale(0.5, 1)).toBeCloseTo(1); + }); +}); diff --git a/test/game-config-add.test.ts b/test/game-config-add.test.ts new file mode 100644 index 00000000..f906e7b4 --- /dev/null +++ b/test/game-config-add.test.ts @@ -0,0 +1,62 @@ +// The electron-free half of adding a game: what a root read reports, and which games a write added. The +// second one decides whether the user is TOLD about a game written to a card the launcher cannot show — +// getting it wrong means either silence or a notification about a game nobody added. +import { describe, expect, it } from 'vitest'; +import { addedGamesOf, rootReadResult } from '../src/main/game-config-add'; + +const base = { root: 'E:\\', source: 'card', signature: 'a|b', platform: 'windows' } as const; + +const game = (id: string, title?: string): string => + JSON.stringify({ schemaVersion: 1, id, title: title ?? id, executable: 'g.exe' }); + +describe('rootReadResult', () => { + it('reports a root with no game.json as one, rather than as a read failure', () => { + const result = rootReadResult(base, null); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error('unreachable'); + expect(result.hasManifest).toBe(false); + expect(result.text).toBe(''); + expect(result.root).toBe('E:\\'); + expect(result.platform).toBe('windows'); + }); + + it('hands the manifest text over as it was read', () => { + const result = rootReadResult(base, game('hades')); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error('unreachable'); + expect(result.hasManifest).toBe(true); + expect(result.text).toBe(game('hades')); + }); +}); + +describe('addedGamesOf', () => { + it('names the game a write added to a file that already had one', () => { + expect(addedGamesOf('hades', `[${game('hades')},${game('bastion', 'Bastion')}]`)).toEqual([ + { id: 'bastion', title: 'Bastion' }, + ]); + }); + + it('names the first game of a root that had none', () => { + expect(addedGamesOf('', game('hades', 'Hades'))).toEqual([{ id: 'hades', title: 'Hades' }]); + }); + + it('says nothing when a write only renamed what was already there', () => { + expect(addedGamesOf('hades', game('hades', 'Hades II'))).toEqual([]); + }); + + it('falls back to the id when the game carries no title', () => { + expect(addedGamesOf('', JSON.stringify({ id: 'hades' }))).toEqual([ + { id: 'hades', title: 'hades' }, + ]); + }); + + // The previous file could not be read, so its ids are unknown — calling every game in the new one + // "added" would announce games the user never touched. + it('stays silent when the file before the write was unreadable', () => { + expect(addedGamesOf('invalid', `[${game('a')},${game('b')}]`)).toEqual([]); + }); + + it('stays silent on text it cannot parse', () => { + expect(addedGamesOf('', '{ not json')).toEqual([]); + }); +}); diff --git a/test/game-move-transaction.test.ts b/test/game-move-transaction.test.ts new file mode 100644 index 00000000..a41144f3 --- /dev/null +++ b/test/game-move-transaction.test.ts @@ -0,0 +1,433 @@ +// GameConfigService.moveToCard end to end: the transaction itself, not the pure helpers game-move.test.ts +// covers. Everything risky about a move lives in the ORDER of its steps and in its two levels of rollback +// — a copy that lands on the card, a card manifest that is written and then has to be taken back — and +// none of that is reachable except by driving the whole sequence, so that is what happens here. +// +// Two modules are mocked, both for reasons of environment rather than of design: +// • drive-watcher's `listDriveCandidates` enumerates the machine's REAL removable drives, which no test +// can have; the temp dir standing in for a card is declared to be one. +// • json-store's `writeFileAtomicEnsuringDir` is the only way to make a write fail ON PURPOSE and on +// every platform. Making the file read-only would not do it: the atomic write's own fallback exists +// precisely to survive that (see json-store.ts replaceInPlace), and on Windows it does. +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import fse from 'fs-extra'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + type DriveCandidate, + type GameMoveRequest, + type NotificationInput, + type ResolvedManifest, +} from '../src/shared/types'; +import { createTranslator } from '../src/shared/i18n/index'; + +const hooks = vi.hoisted(() => ({ + failWrite: (_filePath: string): boolean => false, + cards: [] as DriveCandidate[], +})); + +vi.mock('../src/main/json-store', async (importOriginal) => { + const actual = await importOriginal<typeof import('../src/main/json-store')>(); + return { + ...actual, + writeFileAtomicEnsuringDir: async (filePath: string, data: string | Buffer): Promise<void> => { + if (hooks.failWrite(filePath)) throw new Error('write refused by the test'); + await actual.writeFileAtomicEnsuringDir(filePath, data); + }, + }; +}); + +vi.mock('../src/main/drive-watcher', async (importOriginal) => { + const actual = await importOriginal<typeof import('../src/main/drive-watcher')>(); + return { + ...actual, + listDriveCandidates: (): Promise<readonly DriveCandidate[]> => Promise.resolve(hooks.cards), + }; +}); + +const { GameConfigService } = await import('../src/main/game-config'); +const { PcLibraryStore } = await import('../src/main/pc-library'); +const { describeManifestContent } = await import('../src/main/drive-watcher'); +const { readManifests } = await import('../src/main/manifest'); + +const t = createTranslator('en'); + +interface Harness { + readonly service: InstanceType<typeof GameConfigService>; + readonly pcRoot: string; + readonly cardRoot: string; + readonly liveSaves: string; + readonly notifications: NotificationInput[]; + readonly removedSyncStates: string[]; + readonly request: GameMoveRequest; +} + +let dir: string; +let harness: Harness; + +/** The two-game PC library the move starts from: `hades` (art, music, saves — the one that moves) and + * `keeper` (which must survive untouched, and keeps the post-move library from being an empty list). */ +async function seedPcLibrary(root: string, liveSaves: string): Promise<void> { + await fse.ensureDir(path.join(root, 'assets')); + await fs.writeFile(path.join(root, 'assets', 'hades-bg.png'), 'hero-bytes'); + await fs.writeFile(path.join(root, 'assets', 'hades-tile.png'), 'grid-bytes'); + await fs.writeFile(path.join(root, 'assets', 'hades-theme.mp3'), 'music-bytes'); + await fs.writeFile(path.join(root, 'assets', 'keeper-bg.png'), 'keeper-hero'); + await fse.ensureDir(path.join(root, 'games')); + await fs.writeFile(path.join(root, 'games', 'Hades.exe'), 'exe'); + await fs.writeFile(path.join(root, 'games', 'Keeper.exe'), 'exe'); + await fse.ensureDir(liveSaves); + await fs.writeFile(path.join(liveSaves, 'slot1.sav'), 'live-save'); + await fs.writeFile( + path.join(root, 'game.json'), + `${JSON.stringify( + [ + { + schemaVersion: 1, + id: 'hades', + title: 'Hades', + pc: { executable: path.join(root, 'games', 'Hades.exe') }, + heroImage: 'assets/hades-bg.png', + gridImage: 'assets/hades-tile.png', + backgroundMusic: 'assets/hades-theme.mp3', + pcSavePath: liveSaves, + }, + { + schemaVersion: 1, + id: 'keeper', + title: 'Keeper', + pc: { executable: path.join(root, 'games', 'Keeper.exe') }, + heroImage: 'assets/keeper-bg.png', + }, + ], + null, + 2, + )}\n`, + ); +} + +/** The card as it stands BEFORE the move: one unrelated game, whose slot every assertion below expects to + * come through the transaction byte for byte. */ +async function seedCard(root: string): Promise<void> { + await fse.ensureDir(path.join(root, 'other')); + await fs.writeFile(path.join(root, 'other', 'Other.exe'), 'exe'); + await fse.ensureDir(path.join(root, 'hades')); + await fs.writeFile(path.join(root, 'hades', 'Hades.exe'), 'exe'); + await fs.writeFile(path.join(root, 'game.json'), `${JSON.stringify(cardGameOther(), null, 2)}\n`); +} + +function cardGameOther(): Record<string, unknown> { + return { + schemaVersion: 1, + id: 'other', + title: 'Other', + executable: path.posix.join('other', 'Other.exe'), + heroImage: 'assets/other-hero.png', + }; +} + +/** The moved game's slot as the RENDERER builds it (carryFormToCard): card-relative paths under the + * deterministic names from asset-move-names.ts. */ +function cardGameHades(overrides: Record<string, unknown> = {}): Record<string, unknown> { + return { + schemaVersion: 1, + id: 'hades', + title: 'Hades', + executable: path.posix.join('hades', 'Hades.exe'), + heroImage: 'assets/hades-hero-1.png', + gridImage: 'assets/hades-grid.png', + backgroundMusic: 'assets/hades-music.mp3', + saveOnCard: 'saves/hades', + pcSavePath: '%APPDATA%/Hades', + ...overrides, + }; +} + +async function signatureOf(root: string): Promise<string> { + const file = path.join(root, 'game.json'); + const { signature } = await describeManifestContent(file, await fse.pathExists(file), t, ''); + return signature; +} + +async function resolvedPcManifest(pcRoot: string, id: string): Promise<ResolvedManifest> { + const read = await readManifests( + pcRoot, + { documents: path.join(dir, 'documents'), t }, + () => null, + { + source: 'pc', + }, + ); + if (!read.ok) throw new Error(`the seeded PC library did not resolve: ${read.message}`); + const found = read.manifests.find((manifest) => manifest.raw.id === id); + if (found === undefined) throw new Error(`the seeded PC library has no game "${id}"`); + return found; +} + +async function buildHarness(toText: string): Promise<Harness> { + const pcLibrary = new PcLibraryStore({ baseDir: dir }); + const pcRoot = pcLibrary.root; + const cardRoot = path.join(dir, 'card'); + const liveSaves = path.join(dir, 'live-saves'); + await fse.ensureDir(cardRoot); + await seedPcLibrary(pcRoot, liveSaves); + await seedCard(cardRoot); + + hooks.cards = [ + { + root: cardRoot, + kind: 'card', + label: 'card', + signature: '', + hasManifest: true, + isActive: false, + }, + ]; + + const notifications: NotificationInput[] = []; + const removedSyncStates: string[] = []; + const manifest = await resolvedPcManifest(pcRoot, 'hades'); + + const service = new GameConfigService({ + getActiveRoot: () => null, + reloadManifest: () => Promise.resolve({ ok: true as const }), + pcLibrary, + reloadPcLibrary: () => Promise.resolve({ ok: true as const }), + getTranslator: () => t, + toManifestPcSavePath: () => null, + findGameSource: () => ({ root: pcRoot, source: 'pc' as const }), + notify: (input) => notifications.push(input), + resolveManifest: (id) => (id === 'hades' ? manifest : null), + isBusy: () => false, + pcStore: { + removeSyncState: (id: string) => { + removedSyncStates.push(id); + return Promise.resolve(); + }, + }, + savePathResolver: { + resolvePcSavePath: () => Promise.resolve({ path: liveSaves, containerExists: true }), + }, + }); + + return { + service, + pcRoot, + cardRoot, + liveSaves, + notifications, + removedSyncStates, + request: { + id: 'hades', + fromId: 'hades', + fromRoot: pcRoot, + fromSignature: await signatureOf(pcRoot), + toRoot: cardRoot, + toSignature: await signatureOf(cardRoot), + toText, + }, + }; +} + +function toTextWith(hades: Record<string, unknown> = cardGameHades()): string { + return `${JSON.stringify([cardGameOther(), hades], null, 2)}\n`; +} + +async function libraryIds(pcRoot: string): Promise<readonly string[]> { + const parsed: unknown = await fse.readJson(path.join(pcRoot, 'game.json')); + const games: readonly unknown[] = Array.isArray(parsed) ? parsed : [parsed]; + return games.map((game) => String((game as { readonly id?: unknown }).id)); +} + +beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'playhook-move-')); + hooks.failWrite = () => false; + harness = await buildHarness(toTextWith()); +}); + +afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }); +}); + +describe('moveToCard — the happy path', () => { + it('copies the art and music under the names the renderer already wrote into the target text', async () => { + const result = await harness.service.moveToCard(harness.request); + expect(result).toEqual({ moved: true, applied: 'deferred' }); + expect( + await fs.readFile(path.join(harness.cardRoot, 'assets', 'hades-hero-1.png'), 'utf8'), + ).toBe('hero-bytes'); + expect(await fs.readFile(path.join(harness.cardRoot, 'assets', 'hades-grid.png'), 'utf8')).toBe( + 'grid-bytes', + ); + expect( + await fs.readFile(path.join(harness.cardRoot, 'assets', 'hades-music.mp3'), 'utf8'), + ).toBe('music-bytes'); + }); + + it('copies the saves from the LIVE location, not the library backup', async () => { + // The backup carries different bytes on purpose: whichever one lands on the card is identifiable. + await fse.ensureDir(path.join(harness.pcRoot, 'saves', 'hades')); + await fs.writeFile(path.join(harness.pcRoot, 'saves', 'hades', 'slot1.sav'), 'stale-backup'); + await harness.service.moveToCard(harness.request); + expect( + await fs.readFile(path.join(harness.cardRoot, 'saves', 'hades', 'slot1.sav'), 'utf8'), + ).toBe('live-save'); + }); + + it('writes the target text verbatim and removes the game from the library, keeping its neighbour', async () => { + await harness.service.moveToCard(harness.request); + expect(await fs.readFile(path.join(harness.cardRoot, 'game.json'), 'utf8')).toBe( + harness.request.toText, + ); + expect(await libraryIds(harness.pcRoot)).toEqual(['keeper']); + }); + + it('drops the now-meaningless sync baseline', async () => { + await harness.service.moveToCard(harness.request); + expect(harness.removedSyncStates).toEqual(['hades']); + }); + + it('notifies that the move will apply when the card becomes active', async () => { + await harness.service.moveToCard(harness.request); + expect(harness.notifications).toEqual([{ kind: 'game-moved-deferred', gameTitle: 'Hades' }]); + }); +}); + +describe('moveToCard — refusals that write nothing', () => { + it('refuses a move that would also rename the game', async () => { + const result = await harness.service.moveToCard({ ...harness.request, id: 'hades-2' }); + expect(result).toEqual({ moved: false, message: t('gameConfig.moveIdChanged') }); + expect(await libraryIds(harness.pcRoot)).toEqual(['hades', 'keeper']); + expect(await fse.pathExists(path.join(harness.cardRoot, 'assets'))).toBe(false); + }); + + it('refuses when the game is no longer in the library at all', async () => { + const result = await harness.service.moveToCard({ + ...harness.request, + id: 'ghost', + fromId: 'ghost', + }); + expect(result.moved).toBe(false); + expect(await libraryIds(harness.pcRoot)).toEqual(['hades', 'keeper']); + }); + + it("refuses when the game's own files are not on the card yet", async () => { + await fse.remove(path.join(harness.cardRoot, 'hades')); + const result = await harness.service.moveToCard(harness.request); + expect(result).toEqual({ moved: false, message: t('gameConfig.moveFilesNotOnCard') }); + expect(await libraryIds(harness.pcRoot)).toEqual(['hades', 'keeper']); + }); + + // Installer mode is reachable because the form stays open after the target is chosen and offers every + // mode a card allows. There `executable` names a path INSIDE the installed game, so checking it against + // the card would reject the move forever — the installer is what has to be there. + it('checks the INSTALLER on the card in install mode, not the executable', async () => { + await fs.writeFile(path.join(harness.cardRoot, 'setup.exe'), 'installer'); + const installMode = cardGameHades({ + executable: 'Hades.exe', + install: { installer: 'setup.exe', type: 'nsis' }, + }); + harness = await buildHarness(toTextWith(installMode)); + await fs.writeFile(path.join(harness.cardRoot, 'setup.exe'), 'installer'); + const result = await harness.service.moveToCard(harness.request); + expect(result).toEqual({ moved: true, applied: 'deferred' }); + }); + + it('names the missing file when an asset is gone from the PC', async () => { + await fse.remove(path.join(harness.pcRoot, 'assets', 'hades-tile.png')); + const result = await harness.service.moveToCard(harness.request); + expect(result.moved).toBe(false); + expect(result.moved === false ? result.message : '').toContain('hades-tile.png'); + // The hero copied before the grid failed must be gone again. + expect(await fse.pathExists(path.join(harness.cardRoot, 'assets', 'hades-hero-1.png'))).toBe( + false, + ); + expect(await libraryIds(harness.pcRoot)).toEqual(['hades', 'keeper']); + }); +}); + +describe('moveToCard — rollback', () => { + it('undoes every copy when the card manifest cannot be written', async () => { + hooks.failWrite = (filePath) => filePath.startsWith(harness.cardRoot); + const result = await harness.service.moveToCard(harness.request); + expect(result.moved).toBe(false); + expect(await fse.pathExists(path.join(harness.cardRoot, 'assets', 'hades-hero-1.png'))).toBe( + false, + ); + expect(await fse.pathExists(path.join(harness.cardRoot, 'saves', 'hades'))).toBe(false); + expect(await fs.readFile(path.join(harness.cardRoot, 'game.json'), 'utf8')).toBe( + `${JSON.stringify(cardGameOther(), null, 2)}\n`, + ); + expect(await libraryIds(harness.pcRoot)).toEqual(['hades', 'keeper']); + }); + + it('puts the card back and undoes the copies when the library write fails', async () => { + hooks.failWrite = (filePath) => filePath.startsWith(harness.pcRoot); + const result = await harness.service.moveToCard(harness.request); + expect(result.moved).toBe(false); + expect(await fs.readFile(path.join(harness.cardRoot, 'game.json'), 'utf8')).toBe( + `${JSON.stringify(cardGameOther(), null, 2)}\n`, + ); + expect(await fse.pathExists(path.join(harness.cardRoot, 'assets', 'hades-hero-1.png'))).toBe( + false, + ); + expect(await libraryIds(harness.pcRoot)).toEqual(['hades', 'keeper']); + expect(harness.removedSyncStates).toEqual([]); + }); + + // The worst branch there is: the card is written, the library write fails, and putting the card back + // fails too. The game then exists in BOTH places — a defined outcome — which is only true if the card's + // copy is whole. Undoing the copies here would leave the card's game.json naming art and saves that are + // not there, and report success while doing it. + it('keeps the card copy intact when the library write AND the card rollback both fail', async () => { + let cardWrites = 0; + hooks.failWrite = (filePath) => { + if (filePath.startsWith(harness.pcRoot)) return true; + if (!filePath.startsWith(harness.cardRoot)) return false; + cardWrites += 1; + return cardWrites > 1; + }; + const result = await harness.service.moveToCard(harness.request); + + expect(result).toEqual({ moved: true, applied: 'deferred' }); + expect(await fs.readFile(path.join(harness.cardRoot, 'game.json'), 'utf8')).toBe( + harness.request.toText, + ); + expect( + await fs.readFile(path.join(harness.cardRoot, 'assets', 'hades-hero-1.png'), 'utf8'), + ).toBe('hero-bytes'); + expect(await fs.readFile(path.join(harness.cardRoot, 'assets', 'hades-grid.png'), 'utf8')).toBe( + 'grid-bytes', + ); + expect( + await fs.readFile(path.join(harness.cardRoot, 'assets', 'hades-music.mp3'), 'utf8'), + ).toBe('music-bytes'); + expect( + await fs.readFile(path.join(harness.cardRoot, 'saves', 'hades', 'slot1.sav'), 'utf8'), + ).toBe('live-save'); + // Still in the library too — that IS the duplicate — so its baseline must survive with it. + expect(await libraryIds(harness.pcRoot)).toEqual(['hades', 'keeper']); + expect(harness.removedSyncStates).toEqual([]); + expect(harness.notifications).toContainEqual({ + kind: 'game-move-duplicate', + gameTitle: 'Hades', + }); + }); +}); + +describe('moveToCard — saves already on the card', () => { + it('leaves a non-empty save folder alone and says so', async () => { + await fse.ensureDir(path.join(harness.cardRoot, 'saves', 'hades')); + await fs.writeFile(path.join(harness.cardRoot, 'saves', 'hades', 'slot1.sav'), 'card-save'); + const result = await harness.service.moveToCard(harness.request); + expect(result).toEqual({ moved: true, applied: 'deferred' }); + expect( + await fs.readFile(path.join(harness.cardRoot, 'saves', 'hades', 'slot1.sav'), 'utf8'), + ).toBe('card-save'); + expect(harness.notifications).toContainEqual({ + kind: 'game-move-save-skipped', + gameTitle: 'Hades', + }); + }); +}); diff --git a/test/game-move.test.ts b/test/game-move.test.ts new file mode 100644 index 00000000..23e4c577 --- /dev/null +++ b/test/game-move.test.ts @@ -0,0 +1,164 @@ +// The electron-free half of "Move to card…": deterministic asset names, the fromText/toTextBeforeMove +// removal logic both rollback paths lean on, the id-collision check, and which card-relative path must +// already exist before a move commits. +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + countGamesWithId, + expectedGameFilePath, + findGameInText, + planAssetCopies, + removeGameFromManifestText, + type AssetCopyPlan, +} from '../src/main/game-move'; +import { + movedGridAssetPath, + movedHeroAssetPath, + movedMusicAssetPath, +} from '../src/shared/asset-move-names'; + +const game = (id: string, extra: Record<string, unknown> = {}): Record<string, unknown> => ({ + schemaVersion: 1, + id, + title: id, + ...extra, +}); + +describe('asset-move-names — deterministic destination names', () => { + it('names hero images by 1-based index and keeps the source extension', () => { + expect(movedHeroAssetPath('hades', 0, 'assets/h1.jpg')).toBe('assets/hades-hero-1.jpg'); + expect(movedHeroAssetPath('hades', 1, 'assets/h2.PNG')).toBe('assets/hades-hero-2.PNG'); + }); + + it('names the grid and music assets', () => { + expect(movedGridAssetPath('hades', 'assets/grid.jpg')).toBe('assets/hades-grid.jpg'); + expect(movedMusicAssetPath('hades', 'assets/theme.ogg')).toBe('assets/hades-music.ogg'); + }); + + it('ignores the source directory — only the extension survives, even from outside assets/', () => { + expect(movedHeroAssetPath('hades', 0, 'C:\\Users\\me\\Pictures\\cover.webp')).toBe( + 'assets/hades-hero-1.webp', + ); + }); + + it('produces no extension when the source has none', () => { + expect(movedGridAssetPath('hades', 'assets/grid')).toBe('assets/hades-grid'); + }); +}); + +describe('removeGameFromManifestText', () => { + it('drops the last game to an empty-array marker (deletes the library file / a blank pre-move card)', () => { + expect(removeGameFromManifestText('hades', JSON.stringify(game('hades')))).toBe('[]\n'); + }); + + it('collapses two-to-one back into a single object (legacy shape)', () => { + const text = JSON.stringify([game('hades'), game('bastion')]); + const result = removeGameFromManifestText('hades', text); + expect(result).not.toBeNull(); + expect(JSON.parse(result ?? '')).toEqual(game('bastion')); + expect(Array.isArray(JSON.parse(result ?? ''))).toBe(false); + }); + + it('keeps an array when more than one game is left', () => { + const text = JSON.stringify([game('hades'), game('bastion'), game('pyre')]); + const result = removeGameFromManifestText('hades', text); + expect(JSON.parse(result ?? '')).toEqual([game('bastion'), game('pyre')]); + }); + + // A silent no-op, NOT an error — which is exactly why every caller must assert the game is there first + // (GameConfigService.moveToCard does it with countGamesWithId): without that check a move addressed by a + // wrong id would write the library back unchanged and still report success. + it('leaves the text untouched (games unaffected) when the id is not present', () => { + const text = JSON.stringify(game('bastion')); + expect(JSON.parse(removeGameFromManifestText('hades', text) ?? '')).toEqual(game('bastion')); + }); + + it('removes only the named game when another one carries a similar id', () => { + const text = JSON.stringify([game('hades'), game('hades-2'), game('hades_ii')]); + expect(JSON.parse(removeGameFromManifestText('hades', text) ?? '')).toEqual([ + game('hades-2'), + game('hades_ii'), + ]); + }); + + it('returns null on unparsable text', () => { + expect(removeGameFromManifestText('hades', '{ not json')).toBeNull(); + }); +}); + +describe('findGameInText', () => { + it('finds a game inside a single-object manifest', () => { + expect(findGameInText('hades', JSON.stringify(game('hades', { executable: 'g.exe' })))).toEqual( + game('hades', { executable: 'g.exe' }), + ); + }); + + it('finds a game inside a multi-game array', () => { + const text = JSON.stringify([game('hades'), game('bastion', { executable: 'b.exe' })]); + expect(findGameInText('bastion', text)).toEqual(game('bastion', { executable: 'b.exe' })); + }); + + it('returns null when the id is absent or the text is unparsable', () => { + expect(findGameInText('missing', JSON.stringify(game('hades')))).toBeNull(); + expect(findGameInText('hades', '{ not json')).toBeNull(); + }); +}); + +describe('expectedGameFilePath', () => { + it('names the executable for an executable-mode game', () => { + expect(expectedGameFilePath(game('hades', { executable: 'Hades/Hades.exe' }))).toBe( + 'Hades/Hades.exe', + ); + }); + + it('requires nothing for a steam-mode game', () => { + expect(expectedGameFilePath(game('hades', { steam: { appid: 1145360 } }))).toBeNull(); + }); + + it('returns null when the shape is unrecognized (defensive)', () => { + expect(expectedGameFilePath(game('hades'))).toBeNull(); + }); +}); + +describe('countGamesWithId', () => { + it('counts our own inserted slot as one, not zero', () => { + expect(countGamesWithId('hades', JSON.stringify(game('hades')))).toBe(1); + }); + + it('counts two when the target card already carried a game with the same id', () => { + const text = JSON.stringify([game('hades'), game('hades')]); + expect(countGamesWithId('hades', text)).toBe(2); + }); + + it('counts zero when the id names nothing', () => { + expect(countGamesWithId('missing', JSON.stringify(game('hades')))).toBe(0); + }); +}); + +describe('planAssetCopies', () => { + const targetRoot = path.join(path.resolve(path.sep), 'Cards', 'E'); + + it('plans a copy per hero image, in order, plus grid and music', () => { + const manifest = { + heroImagePaths: [ + path.join(path.resolve(path.sep), 'pc-games', 'assets', 'h1.jpg'), + path.join(path.resolve(path.sep), 'pc-games', 'assets', 'h2.jpg'), + ], + gridImagePath: path.join(path.resolve(path.sep), 'pc-games', 'assets', 'grid.png'), + backgroundMusicPath: path.join(path.resolve(path.sep), 'pc-games', 'assets', 'theme.ogg'), + }; + const plans = planAssetCopies(manifest, 'hades', targetRoot); + const byTo = (plan: AssetCopyPlan): string => plan.to; + expect(plans.map(byTo)).toEqual([ + path.join(targetRoot, 'assets', 'hades-hero-1.jpg'), + path.join(targetRoot, 'assets', 'hades-hero-2.jpg'), + path.join(targetRoot, 'assets', 'hades-grid.png'), + path.join(targetRoot, 'assets', 'hades-music.ogg'), + ]); + expect(plans[0]?.from).toBe(manifest.heroImagePaths[0]); + }); + + it('plans nothing for a game with no art or music', () => { + expect(planAssetCopies({}, 'hades', targetRoot)).toEqual([]); + }); +}); diff --git a/test/game-settings-model.test.ts b/test/game-settings-model.test.ts new file mode 100644 index 00000000..4401ac62 --- /dev/null +++ b/test/game-settings-model.test.ts @@ -0,0 +1,641 @@ +// What the Customize screen SHOWS, decided in one pure function. The visibility rules are the whole +// point of testing it: a field that is hidden when it should not be is a field the user cannot edit at +// all now that the JSON tab is gone, and a field shown for the wrong launch mode produces a manifest the +// validator rejects. +import { describe, expect, it } from 'vitest'; +import { + emptyFormModel, + formModelToText, + type ManifestFormModel, +} from '../src/renderer/configure-form-model'; +import { validateManifestText } from '../src/main/manifest'; +import { createTranslator } from '../src/shared/i18n/index'; +import { + buildGameSettingsModel, + carryFormAcrossSources, + carryFormToCard, + defaultLaunchMode, + draftModeFor, + hasSourceBoundValues, + launchModesFor, + pickKindFor, + withInstallType, + type GameRowId, + type GameSettingsEnv, + type GameSettingsModel, +} from '../src/renderer/game-settings-model'; + +const baseEnv: GameSettingsEnv = { + mode: 'edit', + move: false, + sources: [], + sourceLabel: null, + source: 'card', + platform: 'linux', + root: 'E:\\', + loadedId: 'hades', + mixed: false, + issues: new Map(), + otherIssues: [], + status: null, + canSave: true, + dirty: false, + canDelete: true, + canMove: false, +}; + +function model( + form: Partial<ManifestFormModel>, + env: Partial<GameSettingsEnv> = {}, +): GameSettingsModel { + return buildGameSettingsModel( + { ...emptyFormModel(), id: 'hades', ...form }, + { ...baseEnv, ...env }, + ); +} + +function ids(built: GameSettingsModel): readonly GameRowId[] { + return built.sections.flatMap((section) => section.rows.map((row) => row.id)); +} + +function row(built: GameSettingsModel, id: GameRowId) { + return built.sections.flatMap((section) => section.rows).find((candidate) => candidate.id === id); +} + +describe('launchModesFor / defaultLaunchMode', () => { + it('offers a card the three card modes and a local library its three (incl. the draft)', () => { + expect(launchModesFor('card')).toEqual(['executable', 'installer', 'steam']); + expect(launchModesFor('pc')).toEqual(['pc', 'steam', 'none']); + }); + + it('starts a blank form in the only mode its source would validate — never the draft', () => { + expect(defaultLaunchMode('card')).toBe('executable'); + expect(defaultLaunchMode('pc')).toBe('pc'); + }); +}); + +describe('draftModeFor', () => { + it('reinterprets a launch-block-less PC-library form as the draft mode', () => { + expect(draftModeFor(emptyFormModel(), 'pc')).toBe('none'); + }); + + it('leaves a launch-block-less CARD form alone (a genuinely blank card form)', () => { + expect(draftModeFor(emptyFormModel(), 'card')).toBe('executable'); + }); + + it('leaves the mode alone once any of the four launch blocks is filled in (pc)', () => { + expect( + draftModeFor({ ...emptyFormModel(), pc: { executable: 'C:\\g.exe', rest: {} } }, 'pc'), + ).toBe('executable'); + }); + + it('leaves the mode alone once any of the four launch blocks is filled in (steam)', () => { + expect(draftModeFor({ ...emptyFormModel(), steam: { appid: '480', rest: {} } }, 'pc')).toBe( + 'executable', + ); + }); + + it('leaves the mode alone once any of the four launch blocks is filled in (executable)', () => { + expect(draftModeFor({ ...emptyFormModel(), executable: 'g/g.exe' }, 'pc')).toBe('executable'); + }); + + it('leaves the mode alone once any of the four launch blocks is filled in (install / copyToPc)', () => { + expect( + draftModeFor({ ...emptyFormModel(), copyToPc: true }, 'pc'), + ).toBe('executable'); + }); +}); + +describe('field visibility per launch mode', () => { + it('executable mode: the card executable, no installer block', () => { + const built = ids(model({ launchMode: 'executable' })); + expect(built).toContain('executable'); + expect(built).toContain('copyToPc'); + expect(built).not.toContain('install.installer'); + expect(built).not.toContain('steam.appid'); + expect(built).not.toContain('pc.executable'); + }); + + it('installer mode: the installer block, and no "move to PC" checkbox', () => { + const built = ids(model({ launchMode: 'installer' })); + expect(built).toContain('install.installer'); + expect(built).toContain('install.type'); + expect(built).toContain('install.args'); + expect(built).toContain('install.winetricks'); + expect(built).not.toContain('copyToPc'); + }); + + it('steam mode drops everything that describes a local launch', () => { + const built = ids(model({ launchMode: 'steam' })); + expect(built).toContain('steam.appid'); + expect(built).not.toContain('args'); + expect(built).not.toContain('runAsAdmin'); + expect(built).not.toContain('executable'); + }); + + it('pc mode names an absolute executable and keeps the launch options', () => { + const built = ids(model({ launchMode: 'pc' }, { source: 'pc' })); + expect(built).toContain('pc.executable'); + expect(built).toContain('args'); + expect(built).not.toContain('executable'); + }); + + it('shows the copy directory only while "move game to PC" is on', () => { + expect(ids(model({ launchMode: 'executable', copyToPc: false }))).not.toContain( + 'copyInstall.installer', + ); + expect(ids(model({ launchMode: 'executable', copyToPc: true }))).toContain( + 'copyInstall.installer', + ); + }); + + it('offers the card save folder only for a card (a local game has no card to copy to)', () => { + expect(ids(model({}))).toContain('saveOnCard'); + expect(ids(model({ launchMode: 'pc' }, { source: 'pc' }))).not.toContain('saveOnCard'); + }); + + // The id addresses BOTH halves of a move (which slot leaves the PC library, which stats/saves follow the + // game), so it must not be editable while one is pending — main refuses a move that renames. + it('hides the id row while a move is pending, and shows it otherwise', () => { + expect(ids(model({}, { source: 'pc', move: false }))).toContain('id'); + expect(ids(model({}, { source: 'pc', move: true }))).not.toContain('id'); + }); + + it('drops the id-changed warning along with the row during a move', () => { + const built = ids( + model({ id: 'renamed' }, { source: 'pc', move: true, loadedId: 'hades' }), + ); + expect(built).not.toContain('note.idChanged'); + }); + + it('offers "Find online" in the action column, directly above Save', () => { + const actions = ids(model({})).filter( + (id) => id === 'find-online' || id === 'save' || id === 'close', + ); + // In the trailing column with the screen's other actions — the flow fills fields across three + // sections, so it belongs to the game rather than to Basics, where it first lived. + expect(actions.slice(0, 2)).toEqual(['find-online', 'save']); + }); + + it('keeps "Find online" out of Basics, where the title field is', () => { + const basics = model({}).sections[0]; + expect(basics?.rows.map((row) => row.id)).not.toContain('find-online'); + }); + + it('offers it while adding a game too — a new game is exactly what has nothing filled in yet', () => { + expect(ids(model({}, { mode: 'add' }))).toContain('find-online'); + }); + + it('labels Save as the move action and drops Discard while a move is pending', () => { + const built = model({}, { source: 'pc', move: true, dirty: true }); + expect(ids(built)).not.toContain('reset'); + const save = row(built, 'save'); + expect(save !== undefined && 'label' in save && 'key' in save.label ? save.label.key : '').toBe( + 'gameSettings.moveToCard', + ); + }); + + it('none mode (the PC-library draft): hides every launch-method field, keeps args and the rest', () => { + const built = ids(model({ launchMode: 'none' }, { source: 'pc' })); + expect(built).not.toContain('executable'); + expect(built).not.toContain('pc.executable'); + expect(built).not.toContain('install.installer'); + expect(built).not.toContain('steam.appid'); + expect(built).not.toContain('runAsAdmin'); + expect(built).not.toContain('copyToPc'); + expect(built).toContain('args'); + expect(built).toContain('watchProcesses'); + expect(built).toContain('heroImage'); + expect(built).toContain('pcSavePath'); + expect(built).toContain('winetricks'); + expect(built).toContain('umuGameId'); + }); +}); + +describe('the rules that are not visibility', () => { + it('forces install.runAsAdmin off and inert for a custom installer', () => { + const custom = withInstallType( + { + ...emptyFormModel('installer'), + install: { ...emptyFormModel().install, runAsAdmin: true }, + }, + 'custom', + ); + const built = model({ ...custom, launchMode: 'installer' }); + const toggle = row(built, 'install.runAsAdmin'); + expect(toggle?.kind).toBe('toggle'); + if (toggle?.kind !== 'toggle') throw new Error('unreachable'); + expect(toggle.value).toBe(false); + expect(toggle.disabled).toBe(true); + }); + + it('caps the hero list at the card format limit', () => { + const built = model({ heroImage: ['a.jpg'] }); + const hero = row(built, 'heroImage'); + if (hero?.kind !== 'list') throw new Error('unreachable'); + expect(hero.max).toBe(3); + }); + + it('warns only once the id actually differs from the one it was read with', () => { + expect(ids(model({ id: 'hades' }))).not.toContain('note.idChanged'); + expect(ids(model({ id: 'hades-2' }))).toContain('note.idChanged'); + }); + + it('shows the mixed-modes banner from the environment, not from the form', () => { + expect(ids(model({}, { mixed: true }))).toContain('note.mixed'); + expect(ids(model({}, { mixed: false }))).not.toContain('note.mixed'); + }); + + it('maps a validator issue onto the row that owns the path', () => { + const built = model({}, { issues: new Map([['title', 'is required']]) }); + const title = row(built, 'title'); + if (title?.kind !== 'text') throw new Error('unreachable'); + expect(title.error).toBe('is required'); + }); + + it('maps an issue INSIDE a value onto the row that owns it', () => { + // The validator names the offending element ("dd" in the watched-process list), not the field — + // and the field is the only thing the user can be sent to. + const built = model( + { watchProcesses: ['dd'] }, + { issues: new Map([['watchProcesses.0', 'must be a .exe name']]) }, + ); + const list = row(built, 'watchProcesses'); + if (list?.kind !== 'list') throw new Error('unreachable'); + expect(list.error).toBe('must be a .exe name'); + }); + + it('prefers the row that owns a path exactly over one that merely contains it', () => { + const built = model( + { launchMode: 'installer', install: { ...emptyFormModel().install, args: ['a'] } }, + { + issues: new Map([ + ['install.args', 'expected array'], + ['install.args.0', 'must be a string'], + ]), + }, + ); + const args = row(built, 'install.args'); + if (args?.kind !== 'list') throw new Error('unreachable'); + expect(args.error).toBe('expected array'); + }); + + it('hides Delete when the environment says it cannot run, and shows it otherwise', () => { + expect(ids(model({}, { canDelete: false }))).not.toContain('delete'); + expect(ids(model({}, { canDelete: true }))).toContain('delete'); + }); + + it('shows "Move to card…" only when the environment says it may run, placed above Delete', () => { + expect(ids(model({}, { canMove: false, canDelete: true }))).not.toContain('move-to-card'); + const built = ids(model({}, { canMove: true, canDelete: true })); + expect(built).toContain('move-to-card'); + expect(built.indexOf('move-to-card')).toBeLessThan(built.indexOf('delete')); + }); + + it('disables Save with nothing to save, and while the validator is unhappy', () => { + const clean = row(model({}, { dirty: false, canSave: true }), 'save'); + if (clean?.kind !== 'action') throw new Error('unreachable'); + expect(clean.disabled).toBe(true); + + const invalid = row(model({}, { dirty: true, canSave: false }), 'save'); + if (invalid?.kind !== 'action') throw new Error('unreachable'); + expect(invalid.disabled).toBe(true); + + const ready = row(model({}, { dirty: true, canSave: true }), 'save'); + if (ready?.kind !== 'action') throw new Error('unreachable'); + expect(ready.disabled).toBe(false); + }); + + // The source is a header line, not a row: it is read-only, and among thirty editable rows a fact you + // cannot change reads as a control that refuses to work. + it('names the source in the header rather than as a row', () => { + expect(model({}).source).toEqual({ text: 'E:\\' }); + expect(model({ launchMode: 'pc' }, { source: 'pc' }).source).toEqual({ + key: 'gameConfig.thisPc', + }); + expect(ids(model({}))).not.toContain('source'); + }); + + // schemaVersion is always 1 and cannot be anything else — a row for it is a row that does nothing. + it('does not show the manifest version at all', () => { + expect(ids(model({}))).not.toContain('schemaVersion'); + }); +}); + +// The SAME builder, asked to describe a game that does not exist yet. What separates the two modes is +// small and easy to get wrong in one direction only: a source row leaking into Customize would put an +// editable "move this game elsewhere" control on a screen that cannot honour it. +describe('add mode', () => { + const addEnv: Partial<GameSettingsEnv> = { + mode: 'add', + sources: [ + { value: 'E:\\', label: 'E:\\ — 3 games' }, + { value: '/pc', label: 'This PC — no games yet' }, + ], + sourceLabel: 'E:\\ — 3 games', + }; + + it('asks where the game goes FIRST, before anything measured against that answer', () => { + const first = ids(model({}, addEnv))[0]; + expect(first).toBe('source'); + }); + + it('offers the roots it was given, and marks the current one', () => { + const built = row(model({}, addEnv), 'source'); + if (built?.kind !== 'select') throw new Error('unreachable'); + expect(built.value).toBe('E:\\'); + expect(built.options).toHaveLength(2); + }); + + it('names the button Add, and drops the actions a non-existent game has no use for', () => { + const built = model({}, { ...addEnv, dirty: true, canSave: true, canDelete: false }); + const save = row(built, 'save'); + if (save?.kind !== 'action') throw new Error('unreachable'); + expect(save.label).toEqual({ key: 'gameSettings.add' }); + expect(ids(built)).not.toContain('reset'); + expect(ids(built)).not.toContain('delete'); + // Close stays: without it the screen could not be left with a mouse. + expect(ids(built)).toContain('close'); + }); + + // What the SCREEN is called ("Add game" vs "Customize") is not the model's to say — it follows `mode`, + // which the controller already has, and it lives in its own element (see .settings-title in index.html). + // The model only names the GAME, which has no name yet while one is being added. + it('names the game, which is nothing yet, and labels the source as the picker does', () => { + const built = model({}, addEnv); + expect(built.title).toBe(''); + expect(built.source).toEqual({ text: 'E:\\ — 3 games' }); + }); + + it('leaves Customize exactly as it was', () => { + const built = model({}); + expect(ids(built)).not.toContain('source'); + expect(ids(built)).toContain('reset'); + }); +}); + +describe('the Linux section', () => { + // The Proton fields describe how a game is run under Wine. A card is read on the Deck too, whatever + // machine it is being edited on, so it keeps them everywhere; a game installed on a Windows PC is only + // ever launched natively, and there they describe nothing. + it('is kept for a card on every platform', () => { + expect(ids(model({}, { source: 'card', platform: 'windows' }))).toContain('umuGameId'); + expect(ids(model({}, { source: 'card', platform: 'linux' }))).toContain('umuGameId'); + expect(ids(model({}, { source: 'card', platform: 'macos' }))).toContain('umuGameId'); + }); + + it('is kept for a local game on Linux', () => { + const built = model({ launchMode: 'pc' }, { source: 'pc', platform: 'linux' }); + expect(ids(built)).toContain('umuGameId'); + expect(ids(built)).toContain('winetricks'); + }); + + it('is dropped for a local game on Windows', () => { + const built = model({ launchMode: 'pc' }, { source: 'pc', platform: 'windows' }); + expect(ids(built)).not.toContain('umuGameId'); + expect(ids(built)).not.toContain('winetricks'); + }); + + it('is dropped for a local game on macOS (no Proton there either)', () => { + const built = model({ launchMode: 'pc' }, { source: 'pc', platform: 'macos' }); + expect(ids(built)).not.toContain('umuGameId'); + expect(ids(built)).not.toContain('winetricks'); + }); +}); + +describe('pickKindFor', () => { + it('sends each path field to the browse it needs', () => { + expect(pickKindFor('executable', 'executable', 'card')).toBe('executable'); + expect(pickKindFor('install.installer', 'installer', 'card')).toBe('installer'); + expect(pickKindFor('copyInstall.installer', 'executable', 'card')).toBe('directory'); + expect(pickKindFor('heroImage', 'executable', 'card')).toBe('image'); + expect(pickKindFor('backgroundMusic', 'executable', 'card')).toBe('audio'); + expect(pickKindFor('saveOnCard', 'executable', 'card')).toBe('directory'); + expect(pickKindFor('pc.executable', 'pc', 'pc')).toBe('pc-executable'); + }); + + // The one that depends on the mode: a LOCAL game's saves are an ordinary host folder, but a local + // STEAM game's live inside Steam's Proton prefix, which only the %PREFIX% form can name. + it('picks the save-path flavour from the launch mode, not from the source alone', () => { + expect(pickKindFor('pcSavePath', 'pc', 'pc')).toBe('pc-save-local'); + expect(pickKindFor('pcSavePath', 'steam', 'pc')).toBe('pc-save'); + expect(pickKindFor('pcSavePath', 'executable', 'card')).toBe('pc-save'); + }); + + it('has nothing to browse for a field that is not a path', () => { + expect(pickKindFor('title', 'executable', 'card')).toBeNull(); + expect(pickKindFor('args', 'executable', 'card')).toBeNull(); + }); +}); + +// What a half-filled Add form loses when the user changes their mind about WHERE the game goes. The rule +// is easy to let drift away from the validator's: a field kept across the move points at a root that no +// longer holds it, and the failure shows up as a validation error the user cannot explain. +describe('carryFormAcrossSources / hasSourceBoundValues', () => { + const filled = (over: Partial<ManifestFormModel> = {}): ManifestFormModel => ({ + ...emptyFormModel('executable'), + id: 'hades', + title: 'Hades', + executable: 'game/hades.exe', + args: ['-windowed'], + runAsAdmin: true, + watchProcesses: ['hades.exe'], + heroImage: ['art/hero.jpg'], + gridImage: 'art/grid.jpg', + backgroundMusic: 'music/theme.mp3', + saveOnCard: 'saves', + pcSavePath: '%APPDATA%/Hades', + launchTimeoutSec: '45', + killTimeoutSec: '90', + winetricks: ['corefonts'], + umuGameId: '1145360', + ...over, + }); + + it('keeps the name and everything the root has no say over', () => { + const moved = carryFormAcrossSources(filled(), 'pc'); + expect(moved.title).toBe('Hades'); + expect(moved.id).toBe('hades'); + expect(moved.args).toEqual(['-windowed']); + expect(moved.runAsAdmin).toBe(true); + expect(moved.watchProcesses).toEqual(['hades.exe']); + expect(moved.launchTimeoutSec).toBe('45'); + expect(moved.killTimeoutSec).toBe('90'); + expect(moved.winetricks).toEqual(['corefonts']); + expect(moved.umuGameId).toBe('1145360'); + }); + + it('drops every path and the whole install block — they were measured against the old root', () => { + const moved = carryFormAcrossSources( + filled({ + launchMode: 'installer', + install: { + installer: 'setup.exe', + type: 'inno', + runAsAdmin: true, + args: ['/S'], + winetricks: [], + rest: {}, + }, + }), + 'pc', + ); + expect(moved.executable).toBe(''); + expect(moved.pc.executable).toBe(''); + expect(moved.install.installer).toBe(''); + expect(moved.install.args).toEqual([]); + expect(moved.copyToPc).toBe(false); + expect(moved.copyInstall.installer).toBe(''); + expect(moved.heroImage).toEqual([]); + expect(moved.gridImage).toBe(''); + expect(moved.backgroundMusic).toBe(''); + expect(moved.saveOnCard).toBe(''); + expect(moved.pcSavePath).toBe(''); + }); + + it('moves the launch mode only when the new source will not have it', () => { + expect(carryFormAcrossSources(filled(), 'pc').launchMode).toBe('pc'); + expect(carryFormAcrossSources(filled({ launchMode: 'pc' }), 'card').launchMode).toBe( + 'executable', + ); + }); + + it('drops the draft mode when moving a PC-library draft to a card (a card cannot be one)', () => { + expect(carryFormAcrossSources(filled({ launchMode: 'none' }), 'card').launchMode).toBe( + 'executable', + ); + }); + + // Steam is the one mode both sources accept — and its appid names a game, not a place on a disk. + it('lets a Steam game travel in either direction, appid included', () => { + const steam = filled({ launchMode: 'steam', steam: { appid: '1145360', rest: {} } }); + const toPc = carryFormAcrossSources(steam, 'pc'); + expect(toPc.launchMode).toBe('steam'); + expect(toPc.steam.appid).toBe('1145360'); + const backToCard = carryFormAcrossSources(toPc, 'card'); + expect(backToCard.launchMode).toBe('steam'); + expect(backToCard.steam.appid).toBe('1145360'); + }); + + it('asks before the move only when the move would cost something', () => { + expect(hasSourceBoundValues(emptyFormModel('executable'))).toBe(false); + expect( + hasSourceBoundValues({ ...emptyFormModel('executable'), title: 'Hades', id: 'hades' }), + ).toBe(false); + expect(hasSourceBoundValues(filled())).toBe(true); + expect( + hasSourceBoundValues({ ...emptyFormModel('executable'), heroImage: ['art/hero.jpg'] }), + ).toBe(true); + }); +}); + +// Moving a REAL local game onto a card (Р2.2) — unlike carryFormAcrossSources (a half-filled ADD form +// with nothing of the old root's to keep), this carries actual game data across, including art/music, +// whose paths become the deterministic destination names (see asset-move-names.ts). +describe('carryFormToCard', () => { + const pcGame = (over: Partial<ManifestFormModel> = {}): ManifestFormModel => ({ + ...emptyFormModel('pc'), + id: 'hades', + title: 'Hades', + pc: { executable: 'C:\\Games\\Hades\\Hades.exe', rest: {} }, + args: ['-windowed'], + runAsAdmin: true, + watchProcesses: ['hades.exe'], + heroImage: ['assets/hero-1.jpg', 'assets/hero-2.png'], + gridImage: 'assets/grid.jpg', + backgroundMusic: 'assets/theme.ogg', + saveOnCard: '', // forbidden in the pc dialect — never set to begin with + pcSavePath: '%APPDATA%/Hades', + launchTimeoutSec: '45', + killTimeoutSec: '90', + winetricks: ['corefonts'], + umuGameId: '1145360', + ...over, + }); + + it('keeps the name, the launch-adjacent fields and the timings', () => { + const moved = carryFormToCard(pcGame()); + expect(moved.id).toBe('hades'); + expect(moved.title).toBe('Hades'); + expect(moved.args).toEqual(['-windowed']); + expect(moved.runAsAdmin).toBe(true); + expect(moved.watchProcesses).toEqual(['hades.exe']); + expect(moved.launchTimeoutSec).toBe('45'); + expect(moved.killTimeoutSec).toBe('90'); + expect(moved.winetricks).toEqual(['corefonts']); + expect(moved.umuGameId).toBe('1145360'); + }); + + it('rewrites art/music to the deterministic destination names, in order', () => { + const moved = carryFormToCard(pcGame()); + expect(moved.heroImage).toEqual(['assets/hades-hero-1.jpg', 'assets/hades-hero-2.png']); + expect(moved.gridImage).toBe('assets/hades-grid.jpg'); + expect(moved.backgroundMusic).toBe('assets/hades-music.ogg'); + }); + + it('drops pc.executable, saveOnCard and any install/copyToPc', () => { + const moved = carryFormToCard( + pcGame({ copyToPc: true, copyInstall: { installer: 'x', type: 'copy', runAsAdmin: false, args: [], winetricks: [], rest: {} } }), + ); + expect(moved.pc.executable).toBe(''); + expect(moved.saveOnCard).toBe(''); + expect(moved.copyToPc).toBe(false); + expect(moved.copyInstall.installer).toBe(''); + expect(moved.install.installer).toBe(''); + }); + + it('pcSavePath: a %PREFIX% string survives (already card-shaped), an absolute one does not', () => { + expect(carryFormToCard(pcGame({ pcSavePath: '%APPDATA%/Hades' })).pcSavePath).toBe( + '%APPDATA%/Hades', + ); + expect( + carryFormToCard(pcGame({ pcSavePath: 'C:\\Games\\Hades\\Saves' })).pcSavePath, + ).toBe(''); + }); + + it('launchMode: steam survives, pc/none become executable', () => { + expect(carryFormToCard(pcGame({ launchMode: 'pc' })).launchMode).toBe('executable'); + expect(carryFormToCard(pcGame({ launchMode: 'none' })).launchMode).toBe('executable'); + const steam = pcGame({ launchMode: 'steam', steam: { appid: '1145360', rest: {} } }); + const moved = carryFormToCard(steam); + expect(moved.launchMode).toBe('steam'); + expect(moved.steam.appid).toBe('1145360'); + }); + + it('leaves art/music empty when the source game has none', () => { + const moved = carryFormToCard( + pcGame({ heroImage: [], gridImage: '', backgroundMusic: '' }), + ); + expect(moved.heroImage).toEqual([]); + expect(moved.gridImage).toBe(''); + expect(moved.backgroundMusic).toBe(''); + }); + + it('produces a manifest the CARD validator accepts once executable + saveOnCard/pcSavePath are filled in', () => { + const t = createTranslator('en'); + const moved: ManifestFormModel = { + ...carryFormToCard(pcGame()), + executable: 'Hades/Hades.exe', + saveOnCard: 'saves', + pcSavePath: '%APPDATA%/Hades', + }; + const text = formModelToText(moved, {}, {}); + expect(validateManifestText(text, t, 'card').ok).toBe(true); + }); + + it('rejects an absolute pcSavePath carried straight over (the card allowlist still rules there)', () => { + const t = createTranslator('en'); + const moved: ManifestFormModel = { + ...carryFormToCard(pcGame()), + executable: 'Hades/Hades.exe', + saveOnCard: 'saves', + // pcGame()'s pcSavePath was carried over via `{...carryFormToCard(...)}`? No — carryFormToCard + // resets it to '', so simulate the mistake of typing the absolute PC-side value back in by hand. + pcSavePath: 'C:\\Users\\me\\AppData\\Roaming\\Hades', + }; + const text = formModelToText(moved, {}, {}); + const result = validateManifestText(text, t, 'card'); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.issues.some((issue) => issue.path === 'pcSavePath')).toBe(true); + }); +}); diff --git a/test/gamepad-repeat.test.ts b/test/gamepad-repeat.test.ts index 26234d65..589b6da5 100644 --- a/test/gamepad-repeat.test.ts +++ b/test/gamepad-repeat.test.ts @@ -2,14 +2,27 @@ // the carousel after a delay. The timing lives in a closure driven by requestAnimationFrame, so the test // fakes the pad, the clock and the frame loop and steps them by hand. import { afterEach, describe, expect, it, vi } from 'vitest'; -import { NAV_REPEAT_MS, createGamepadController } from '../src/renderer/gamepad'; +import { + AUTO_CHAIN_MS, + HOLD_DELAY_MS, + NAV_REPEAT_MS, + createAutoRepeatChain, +} from '../src/renderer/auto-repeat'; +import { createGamepadController } from '../src/renderer/gamepad'; const DPAD_LEFT = 14; +const DPAD_RIGHT = 15; interface Harness { readonly press: (index: number, down: boolean) => void; + /** Sets the left stick's Y axis (+down / -up, standard mapping). */ + readonly stickY: (value: number) => void; readonly tick: (ms: number) => void; readonly moves: () => number; + /** Moves the OTHER way — for the runs that swing from one direction into its opposite. */ + readonly rightMoves: () => number; + readonly verticalMoves: () => { up: number; down: number }; + readonly releases: () => number; readonly setPaused: (paused: boolean) => void; } @@ -19,6 +32,10 @@ function harness(): Harness { let frame: (() => void) | null = null; let now = 0; let left = 0; + let right = 0; + let up = 0; + let down = 0; + let releases = 0; vi.stubGlobal('navigator', { getGamepads: () => [pad] }); vi.stubGlobal('requestAnimationFrame', (cb: () => void) => { @@ -29,28 +46,51 @@ function harness(): Harness { vi.stubGlobal('performance', { now: () => now }); const noop = (): void => undefined; - const controller = createGamepadController({ - onLeft: () => { - left += 1; + const controller = createGamepadController( + { + onLeft: () => { + left += 1; + }, + onRight: () => { + right += 1; + }, + onUp: () => { + up += 1; + }, + onDown: () => { + down += 1; + }, + onA: noop, + onB: noop, + onY: noop, + onX: noop, + onShoulderLeft: noop, + onShoulderRight: noop, + onTriggerRight: noop, + onDirectionsReleased: () => { + releases += 1; + }, }, - onRight: noop, - onUp: noop, - onDown: noop, - onA: noop, - onB: noop, - }); + createAutoRepeatChain(), + ); controller.start(); return { - press: (index, down) => { + press: (index, isDown) => { const button = buttons[index]; - if (button !== undefined) button.pressed = down; + if (button !== undefined) button.pressed = isDown; + }, + stickY: (value) => { + pad.axes[1] = value; }, tick: (ms) => { now += ms; frame?.(); }, moves: () => left, + rightMoves: () => right, + verticalMoves: () => ({ up, down }), + releases: () => releases, setPaused: (paused) => controller.setPaused(paused), }; } @@ -65,7 +105,7 @@ describe('gamepad hold-to-repeat', () => { pad.press(DPAD_LEFT, true); pad.tick(16); expect(pad.moves()).toBe(1); - for (let elapsed = 0; elapsed < 300; elapsed += 16) pad.tick(16); + for (let elapsed = 0; elapsed < HOLD_DELAY_MS - 32; elapsed += 16) pad.tick(16); expect(pad.moves()).toBe(1); }); @@ -73,7 +113,7 @@ describe('gamepad hold-to-repeat', () => { const pad = harness(); pad.press(DPAD_LEFT, true); pad.tick(16); - pad.tick(400); // past the hold delay + pad.tick(HOLD_DELAY_MS); // past the hold delay expect(pad.moves()).toBe(2); pad.tick(NAV_REPEAT_MS); expect(pad.moves()).toBe(3); @@ -81,21 +121,51 @@ describe('gamepad hold-to-repeat', () => { expect(pad.moves()).toBe(3); }); - it('treats a release as a reset — the next press is a single move again', () => { + it('treats a full stop as a reset — the next press waits out the delay again', () => { const pad = harness(); pad.press(DPAD_LEFT, true); pad.tick(16); - pad.tick(400); + pad.tick(HOLD_DELAY_MS); expect(pad.moves()).toBe(2); pad.press(DPAD_LEFT, false); - pad.tick(16); + pad.tick(AUTO_CHAIN_MS); // stopped long enough for the run to go cold pad.press(DPAD_LEFT, true); pad.tick(16); expect(pad.moves()).toBe(3); - pad.tick(100); // still inside the fresh delay + pad.tick(HOLD_DELAY_MS - 32); // still inside the fresh delay expect(pad.moves()).toBe(3); }); + it('carries a warm run into the opposite direction with no delay of its own', () => { + const pad = harness(); + pad.press(DPAD_LEFT, true); + pad.tick(16); + pad.tick(HOLD_DELAY_MS); // left is now auto-moving + expect(pad.moves()).toBe(2); + // The swing: left goes, right comes, with the gap a thumb (or a stick through its centre) leaves. + pad.press(DPAD_LEFT, false); + pad.tick(16); + pad.press(DPAD_RIGHT, true); + pad.tick(16); + expect(pad.rightMoves()).toBe(1); // the press itself + pad.tick(NAV_REPEAT_MS); + expect(pad.rightMoves()).toBe(2); // …and the run picks up at the cadence, not after the delay + }); + + it('goes cold when the hands stop between the two directions', () => { + const pad = harness(); + pad.press(DPAD_LEFT, true); + pad.tick(16); + pad.tick(HOLD_DELAY_MS); + pad.press(DPAD_LEFT, false); + pad.tick(AUTO_CHAIN_MS); // a real stop, not a swing + pad.press(DPAD_RIGHT, true); + pad.tick(16); + expect(pad.rightMoves()).toBe(1); + pad.tick(NAV_REPEAT_MS); + expect(pad.rightMoves()).toBe(1); // the delay is back + }); + it('does not burst when resuming onto a direction that was already held', () => { const pad = harness(); pad.setPaused(true); @@ -106,7 +176,94 @@ describe('gamepad hold-to-repeat', () => { pad.setPaused(false); pad.tick(16); expect(pad.moves()).toBe(0); // no phantom edge… - pad.tick(400); + pad.tick(HOLD_DELAY_MS); expect(pad.moves()).toBe(1); // …and the delay is counted from the resume }); }); + +// Holding a direction is a STATE for some consumers, not a stream of presses: the hero background stops +// swapping for as long as the strip is flipping (see hero.setFlipping). That needs a release edge. +describe('gamepad direction release', () => { + it('reports the release once, not on every idle frame', () => { + const pad = harness(); + pad.press(DPAD_LEFT, true); + pad.tick(16); + expect(pad.releases()).toBe(0); + pad.press(DPAD_LEFT, false); + pad.tick(16); + expect(pad.releases()).toBe(1); + pad.tick(16); + pad.tick(16); + expect(pad.releases()).toBe(1); + }); + + it('waits for the LAST direction before calling it a release', () => { + const pad = harness(); + pad.press(DPAD_LEFT, true); + pad.stickY(1); // and down at the same time + pad.tick(16); + pad.press(DPAD_LEFT, false); + pad.tick(16); + expect(pad.releases()).toBe(0); // down is still held + pad.stickY(0); + pad.tick(16); + expect(pad.releases()).toBe(1); + pad.press(DPAD_RIGHT, true); // a fresh hold reports its own release later + pad.tick(16); + pad.press(DPAD_RIGHT, false); + pad.tick(16); + expect(pad.releases()).toBe(2); + }); + + it('reports the release even while paused — a backgrounded launcher holds nothing', () => { + const pad = harness(); + pad.press(DPAD_LEFT, true); + pad.tick(16); + pad.setPaused(true); + pad.press(DPAD_LEFT, false); + pad.tick(16); + expect(pad.releases()).toBe(1); + }); +}); + +// A thumbstick springs back through centre and overshoots past the deadzone on the far side. Read +// literally that is a press the other way — one step down, then an instant step back up, which is +// exactly the "it jumped and came back" stutter. The guard is time-based and stick-only. +describe('gamepad stick spring-back', () => { + it('ignores the overshoot that follows releasing the stick', () => { + const pad = harness(); + pad.stickY(1); // pushed down + pad.tick(16); + expect(pad.verticalMoves()).toEqual({ up: 0, down: 1 }); + pad.stickY(0); // released + pad.tick(16); + pad.stickY(-0.7); // the spring overshoots past the deadzone the other way + pad.tick(16); + expect(pad.verticalMoves()).toEqual({ up: 0, down: 1 }); + }); + + it('still accepts a deliberate reversal once the stick has settled', () => { + const pad = harness(); + pad.stickY(1); + pad.tick(16); + pad.stickY(0); + pad.tick(16); // the frame that SEES the release and starts the settle clock + pad.tick(200); // …which then runs out + pad.stickY(-1); + pad.tick(16); + expect(pad.verticalMoves()).toEqual({ up: 1, down: 1 }); + }); + + it('never gates the d-pad, which has no spring to bounce back', () => { + const pad = harness(); + const DPAD_UP = 12; + const DPAD_DOWN = 13; + pad.press(DPAD_DOWN, true); + pad.tick(16); + pad.press(DPAD_DOWN, false); + pad.tick(16); + pad.press(DPAD_UP, true); // an immediate reversal on the d-pad is honest input + pad.tick(16); + expect(pad.verticalMoves()).toEqual({ up: 1, down: 1 }); + }); +}); diff --git a/test/i18n.test.ts b/test/i18n.test.ts index 451555ea..380f3cd5 100644 --- a/test/i18n.test.ts +++ b/test/i18n.test.ts @@ -38,6 +38,16 @@ describe('en dictionary integrity', () => { expect(value.length, `en[${key}] must be non-empty`).toBeGreaterThan(0); } }); + + // The launcher cards' captions are written from JS (no data-i18n to scan), and `ru` is a Partial — a + // forgotten translation compiles and merely falls back to English on screen. These three are the only + // guard against a Russian launcher naming its own cards in English. + it('names the launcher cards in BOTH locales', () => { + for (const key of ['launcher.card.notifications', 'launcher.card.settings', 'launcher.card.system'] as const) { + expect(en[key]?.length, `en[${key}] must be filled`).toBeGreaterThan(0); + expect(ru[key]?.length, `ru[${key}] must be filled`).toBeGreaterThan(0); + } + }); }); describe('plural (tp) via Intl.PluralRules', () => { @@ -122,9 +132,7 @@ describe('translateIssueMessage', () => { // fallback text equal to that key's value — otherwise the two silently diverge. [data-i18n-aria-label] // keys are only checked for existence (the fallback lives in the aria-label attribute). describe('HTML data-i18n ↔ en dictionary', () => { - const HTML_FILES = ['index.html', 'settings.html', 'configure.html'].map((f) => - path.resolve(__dirname, '../src/renderer', f), - ); + const HTML_FILES = ['index.html'].map((f) => path.resolve(__dirname, '../src/renderer', f)); /** Collapse whitespace runs and decode the entities we use, so indentation/wrapping don't cause false * mismatches. */ diff --git a/test/ipc-channels.test.ts b/test/ipc-channels.test.ts index a85254a9..5ce575b9 100644 --- a/test/ipc-channels.test.ts +++ b/test/ipc-channels.test.ts @@ -8,11 +8,10 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { IPC } from '../src/shared/types'; -const PRELOAD_FILES = [ - path.resolve(__dirname, '../src/preload/preload.ts'), - path.resolve(__dirname, '../src/preload/settings-preload.ts'), - path.resolve(__dirname, '../src/preload/configure-preload.ts'), -]; +// One preload again, now that the Configure window's is gone — but the test stays written for a LIST. +// The invariant it guards ("every channel is exposed by exactly one preload") is what a second window +// would put at risk, and the pairwise check below costs nothing while there is only one. +const PRELOAD_FILES = [path.resolve(__dirname, '../src/preload/preload.ts')]; /** Extracts the string values of the `const CHANNELS = { … }` object literal from a preload source. */ function readChannelValues(file: string): string[] { diff --git a/test/json-store.test.ts b/test/json-store.test.ts new file mode 100644 index 00000000..2a309c87 --- /dev/null +++ b/test/json-store.test.ts @@ -0,0 +1,106 @@ +// The atomic write and its fallback. The fallback is the interesting half: a per-user install taking +// over a %APPDATA% file an all-users one left behind cannot RENAME over it (that needs delete rights, +// which are separate from write rights), and every settings change then failed silently. +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { writeFileAtomic } from '../src/main/json-store'; + +describe('writeFileAtomic', () => { + let dir: string; + + beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'playhook-json-store-')); + }); + + afterEach(async () => { + // chmod back first: a read-only file inside the dir would otherwise block the cleanup on some hosts. + await fs + .chmod(path.join(dir, 'settings.json'), 0o666) + .catch(() => undefined); + await fs.rm(dir, { recursive: true, force: true }); + }); + + it('creates a file that is not there yet', async () => { + const target = path.join(dir, 'settings.json'); + await writeFileAtomic(target, '{"a":1}'); + expect(await fs.readFile(target, 'utf8')).toBe('{"a":1}'); + }); + + it('replaces an existing file', async () => { + const target = path.join(dir, 'settings.json'); + await fs.writeFile(target, 'old'); + await writeFileAtomic(target, 'new'); + expect(await fs.readFile(target, 'utf8')).toBe('new'); + }); + + it('leaves no .tmp behind on a normal write', async () => { + const target = path.join(dir, 'settings.json'); + await writeFileAtomic(target, 'x'); + expect(await fs.readdir(dir)).toEqual(['settings.json']); + }); + + // The regression this was written for: a READ-ONLY target. On Windows that alone makes the atomic + // replace fail outright (EPERM), which is what left settings silently unsaved — so it is the WINDOWS + // CI job that exercises the fallback here. On posix `rename` only needs write on the DIRECTORY, so the + // same case goes through the normal atomic path; the assertion holds either way, which is the point. + // The explicit timeout is for the Windows job specifically: there this case really does fail the + // replace and ride out REPLACE_ATTEMPTS of backoff (~1.4s) before the fallback lands, which is close + // enough to vitest's 5s default to go flaky on a loaded runner. On posix it finishes in milliseconds. + it( + 'still writes when the target is read-only', + async () => { + const target = path.join(dir, 'settings.json'); + await fs.writeFile(target, 'old'); + await fs.chmod(target, 0o444); + await writeFileAtomic(target, 'new'); + expect(await fs.readFile(target, 'utf8')).toBe('new'); + expect(await fs.readdir(dir)).toEqual(['settings.json']); + }, + 20000, + ); + + it('propagates a failure that no fallback can rescue (unwritable directory)', async () => { + const target = path.join(dir, 'missing-dir', 'settings.json'); + await expect(writeFileAtomic(target, 'x')).rejects.toThrow(); + }); + + // The fallback, forced on EVERY platform: a read-only DIRECTORY refuses the rename (the replace needs + // to unlink the old name, which is a directory permission) while the existing file itself stays + // writable — which is precisely the shape the fallback is for. Without it this write is simply lost. + // Skipped as root, who bypasses the permission check and would make the assertion meaningless. + const asRoot = typeof process.getuid === 'function' && process.getuid() === 0; + it.skipIf(asRoot || process.platform === 'win32')( + 'falls back to an in-place write when the replace is refused', + async () => { + const target = path.join(dir, 'settings.json'); + await fs.writeFile(target, 'old'); + await fs.chmod(dir, 0o555); + try { + await writeFileAtomic(target, 'new'); + expect(await fs.readFile(target, 'utf8')).toBe('new'); + } finally { + await fs.chmod(dir, 0o755); + } + }, + 20000, + ); + + it('leaves no .tmp behind when the write fails', async () => { + await expect(writeFileAtomic(path.join(dir, 'missing-dir', 'x.json'), 'x')).rejects.toThrow(); + expect(await fs.readdir(dir)).toEqual([]); + }); + + // Concurrent writers of the SAME file used to share one `<file>.tmp`: the first rename consumed it and + // the rest failed with ENOENT (the history index lost five writes in a row this way while a card's + // games were copied in). Each write now gets a temp of its own, so they all land. + it('survives concurrent writes to the same file', async () => { + const target = path.join(dir, 'index.json'); + await Promise.all( + Array.from({ length: 8 }, (_, i) => writeFileAtomic(target, `write-${i}`)), + ); + expect(await fs.readFile(target, 'utf8')).toMatch(/^write-\d$/); + expect(await fs.readdir(dir)).toEqual(['index.json']); + }); +}); diff --git a/test/library-grid.test.ts b/test/library-grid.test.ts new file mode 100644 index 00000000..6243375b --- /dev/null +++ b/test/library-grid.test.ts @@ -0,0 +1,115 @@ +// The Library grid's stepping rules: where a press lands, which edges are walls and which one hands the +// focus to the sidebar. Nothing else in the renderer states that — library-screen.ts only paints what +// these functions decide — so a wrapped row or a swallowed dead end would only ever be caught on a Deck. +import { describe, expect, it } from 'vitest'; +import { + filterLibrary, + gridColumns, + gridStep, + isNearInGrid, + LIB_CARD_W, + LIB_GAP, + rowOf, +} from '../src/renderer/library-grid'; +import type { LibraryEntry } from '../src/shared/types'; + +const game = (id: string, active: boolean): LibraryEntry => ({ id, title: id, active }); + +describe('gridColumns', () => { + it('fits 6 columns into a 16:9 screen and 5 into a Steam Deck one', () => { + // 1920 - 500 (sidebar edge) - 88 (the scroller's padding, both sides) = 1332; the Deck is 1728 + // design px wide, so 1140. + expect(gridColumns(1332)).toBe(6); + expect(gridColumns(1140)).toBe(5); + }); + + it('counts the trailing card with no gap after it', () => { + expect(gridColumns(LIB_CARD_W)).toBe(1); + expect(gridColumns(LIB_CARD_W * 2 + LIB_GAP)).toBe(2); + expect(gridColumns(LIB_CARD_W * 2 + LIB_GAP - 1)).toBe(1); + }); + + it('never drops below one column, however narrow the area is', () => { + expect(gridColumns(0)).toBe(1); + expect(gridColumns(-100)).toBe(1); + }); +}); + +describe('rowOf', () => { + it('groups the indices by the column count', () => { + expect(rowOf(0, 6)).toBe(0); + expect(rowOf(5, 6)).toBe(0); + expect(rowOf(6, 6)).toBe(1); + expect(rowOf(13, 6)).toBe(2); + }); +}); + +describe('gridStep', () => { + it('walks within a row and stops at its right edge', () => { + expect(gridStep(0, 'right', 20, 6)).toEqual({ index: 1, result: 'moved' }); + expect(gridStep(5, 'right', 20, 6)).toEqual({ index: 5, result: 'at-end' }); + }); + + it('stops at the last card even mid-row', () => { + expect(gridStep(15, 'right', 16, 6)).toEqual({ index: 15, result: 'at-end' }); + }); + + it('hands the focus to the sidebar off the first column, and steps otherwise', () => { + expect(gridStep(6, 'left', 20, 6)).toEqual({ index: 6, result: 'to-sidebar' }); + expect(gridStep(7, 'left', 20, 6)).toEqual({ index: 6, result: 'moved' }); + }); + + it('never wraps a row into its neighbour', () => { + expect(gridStep(5, 'right', 20, 6).index).toBe(5); + expect(gridStep(6, 'left', 20, 6).index).toBe(6); + }); + + it('walks the rows and stops at the first and the last', () => { + expect(gridStep(2, 'up', 20, 6)).toEqual({ index: 2, result: 'at-end' }); + expect(gridStep(8, 'up', 20, 6)).toEqual({ index: 2, result: 'moved' }); + expect(gridStep(8, 'down', 20, 6)).toEqual({ index: 14, result: 'moved' }); + expect(gridStep(19, 'down', 20, 6)).toEqual({ index: 19, result: 'at-end' }); + }); + + it('lands on the last card when the final row is ragged', () => { + // 16 games, 6 columns: the last row holds 12..15, so down from 11 catches its end, not a hole. + expect(gridStep(11, 'down', 16, 6)).toEqual({ index: 15, result: 'moved' }); + expect(gridStep(14, 'down', 16, 6)).toEqual({ index: 14, result: 'at-end' }); + }); + + it('treats an empty grid as a dead end in every direction', () => { + for (const dir of ['left', 'right', 'up', 'down'] as const) { + expect(gridStep(0, dir, 0, 6)).toEqual({ index: 0, result: 'at-end' }); + } + }); +}); + +describe('isNearInGrid', () => { + it('keeps a window of rows around the selection', () => { + expect(isNearInGrid(0, 0, 6)).toBe(true); + expect(isNearInGrid(5, 0, 6)).toBe(true); + expect(isNearInGrid(24, 0, 6, 4)).toBe(true); + expect(isNearInGrid(30, 0, 6, 4)).toBe(false); + }); + + it('is symmetric — the window reaches up as far as it reaches down', () => { + expect(isNearInGrid(0, 24, 6, 4)).toBe(true); + expect(isNearInGrid(0, 30, 6, 4)).toBe(false); + }); +}); + +describe('filterLibrary', () => { + const games = [game('a', true), game('b', false), game('c', true)]; + + it('shows everything, history included, under "All"', () => { + expect(filterLibrary(games, 'all')).toEqual(games); + }); + + it('keeps only the games on the inserted card under "Ready to play"', () => { + expect(filterLibrary(games, 'playable').map((entry) => entry.id)).toEqual(['a', 'c']); + }); + + it("preserves main's order — the renderer never sorts", () => { + expect(filterLibrary(games, 'all').map((entry) => entry.id)).toEqual(['a', 'b', 'c']); + }); +}); diff --git a/test/library-store.test.ts b/test/library-store.test.ts index 531e058d..99b5e524 100644 --- a/test/library-store.test.ts +++ b/test/library-store.test.ts @@ -48,6 +48,7 @@ function manifest(id: string, overrides: Partial<ResolvedManifest> = {}): Resolv winetricks: [], }, root: cardRoot, + source: 'card', executablePath: path.join(cardRoot, 'g.exe'), cwd: cardRoot, ...overrides, @@ -389,3 +390,46 @@ describe('entriesForCarousel', () => { ]); }); }); + +describe('forget (the user removing a game from the history)', () => { + it('drops the record and the copied artwork, leaving the other games alone', async () => { + const library = store(); + await library.init(); + await library.saveFromCard([ + manifest('a', { gridImagePath: await card('a.jpg') }), + manifest('b', { gridImagePath: await card('b.jpg') }), + ]); + + expect(await library.forget('a')).toBe(true); + expect(library.entry('a')).toBeNull(); + expect((await readIndex()).entries.map((e) => e.id)).toEqual(['b']); + await expect(fs.stat(path.join(baseDir, 'library', 'a'))).rejects.toThrow(); + // The neighbour keeps both its record and its files — this removes one game, not the catalogue. + expect(library.entry('b')).not.toBeNull(); + expect(await fs.readdir(path.join(baseDir, 'library', 'b'))).toEqual(['grid.jpg']); + }); + + it('reports an unknown id instead of rewriting the index for nothing', async () => { + const library = store(); + await library.init(); + await library.saveFromCard([manifest('a', { gridImagePath: await card('a.jpg') })]); + expect(await library.forget('nope')).toBe(false); + expect((await readIndex()).entries.map((e) => e.id)).toEqual(['a']); + }); + + it('brings the game back with its old playtime when the card returns', async () => { + const library = store(); + await library.init(); + statsById.set('a', { + schemaVersion: 1, + totalPlaySeconds: 3600, + lastPlayedAt: '2026-01-01T00:00:00.000Z', + launchCount: 7, + }); + await library.saveFromCard([manifest('a', { gridImagePath: await card('a.jpg') })]); + await library.forget('a'); + // forget() never touches stats/<id>.json — the store reads the same authority again on re-insert. + await library.saveFromCard([manifest('a', { gridImagePath: await card('a.jpg') })]); + expect(library.entry('a')?.launchCount).toBe(7); + }); +}); diff --git a/test/manifest.test.ts b/test/manifest.test.ts index 7161ba9e..114d52ce 100644 --- a/test/manifest.test.ts +++ b/test/manifest.test.ts @@ -70,7 +70,9 @@ describe('stripCopySourcePrefix (copy mode: executable relative to the copied di it('normalizes Windows backslashes on both sides (Р12)', () => { expect(stripCopySourcePrefix('game\\game.exe', 'game')).toBe('game.exe'); - expect(stripCopySourcePrefix('Games\\MyGame\\bin\\game.exe', 'Games\\MyGame')).toBe('bin/game.exe'); + expect(stripCopySourcePrefix('Games\\MyGame\\bin\\game.exe', 'Games\\MyGame')).toBe( + 'bin/game.exe', + ); }); it('tolerates a trailing slash on the source', () => { @@ -133,7 +135,6 @@ describe('expandPcSavePath', () => { }); describe('validateManifestText', () => { - it('rejects JSONC (README-style // comments) as a syntax error', () => { const jsonc = '{\n "schemaVersion": 1, // a comment\n "id": "x"\n}'; const result = validateManifestText(jsonc, t); @@ -146,9 +147,81 @@ describe('validateManifestText', () => { expect(result.ok).toBe(false); }); - it('rejects a non-steam manifest with no executable (schema)', () => { - const result = validateManifestText(JSON.stringify({ schemaVersion: 1, id: 'x', title: 'X' }), t); + it('rejects a non-steam CARD manifest with no executable (semantic — the schema no longer requires it, to allow the PC-library draft state)', () => { + const result = validateManifestText( + JSON.stringify({ schemaVersion: 1, id: 'x', title: 'X' }), + t, + ); expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.issues.some((i) => i.path === 'executable')).toBe(true); + // heroRequired fires in the same pass now that the schema itself no longer short-circuits. + expect(result.issues.some((i) => i.path === 'heroImage')).toBe(true); + } + }); + + // The description is fetched online and written by the form; nothing reads it back yet. What matters + // now is that a bad one can never cost the user a playable game — see the `.catch(undefined)` in the + // schema, and the same tolerance the manifest already shows towards unknown keys. + it('accepts a manifest carrying a localized description', () => { + const text = JSON.stringify({ + schemaVersion: 1, + id: 'x', + title: 'X', + executable: 'g/g.exe', + heroImage: 'a/hero.jpg', + description: { en: 'A game.', ru: 'Игра.' }, + }); + expect(validateManifestText(text, t).ok).toBe(true); + }); + + it('does not reject a manifest whose description is malformed — it is dropped instead', () => { + for (const description of ['just a string', 42, { en: 'a'.repeat(5000) }, { en: 7 }]) { + const text = JSON.stringify({ + schemaVersion: 1, + id: 'x', + title: 'X', + executable: 'g/g.exe', + heroImage: 'a/hero.jpg', + description, + }); + expect(validateManifestText(text, t).ok, JSON.stringify(description)).toBe(true); + } + }); + + it('accepts the facts stored for a future library view', () => { + const text = JSON.stringify({ + schemaVersion: 1, + id: 'x', + title: 'X', + executable: 'g/g.exe', + heroImage: 'a/hero.jpg', + genres: ['Action', 'Roguelike'], + releaseDate: '2020-09-17', + platforms: ['windows', 'linux'], + }); + expect(validateManifestText(text, t).ok).toBe(true); + }); + + it('drops a malformed genre list / date / platform rather than rejecting the game', () => { + const cases: readonly Record<string, unknown>[] = [ + { genres: 'Action' }, + { genres: [1, 2] }, + { releaseDate: 'Coming soon' }, + { platforms: ['amiga'] }, + { platforms: 'windows' }, + ]; + for (const extra of cases) { + const text = JSON.stringify({ + schemaVersion: 1, + id: 'x', + title: 'X', + executable: 'g/g.exe', + heroImage: 'a/hero.jpg', + ...extra, + }); + expect(validateManifestText(text, t).ok, JSON.stringify(extra)).toBe(true); + } }); it('rejects steam mode without watchProcesses (schema)', () => { @@ -156,6 +229,48 @@ describe('validateManifestText', () => { expect(validateManifestText(text, t).ok).toBe(false); }); + // The `.exe` suffix is optional (Д5): a native macOS process has no such name, and steam mode requires + // watchProcesses — so demanding it would make steam mode impossible on macOS. Everything that made the + // old pattern safe (no separators, no quotes, no traversal) still holds. + it('accepts a watchProcesses name WITHOUT the .exe suffix (a native mac binary)', () => { + const text = JSON.stringify({ + schemaVersion: 1, + id: 'x', + title: 'X', + steam: { appid: 480 }, + heroImage: 'a/hero.jpg', + watchProcesses: ['valheim'], + }); + expect(validateManifestText(text, t).ok).toBe(true); + }); + + it('still accepts the *.exe spelling a cross-platform card carries', () => { + const text = JSON.stringify({ + schemaVersion: 1, + id: 'x', + title: 'X', + steam: { appid: 480 }, + heroImage: 'a/hero.jpg', + watchProcesses: ['valheim.exe'], + }); + expect(validateManifestText(text, t).ok).toBe(true); + }); + + it('rejects a watchProcesses name that is a path, a traversal or blank', () => { + const bad = ['games/valheim', 'games\\valheim', '..', '.', ' ', '"valheim.exe"', '']; + for (const name of bad) { + const text = JSON.stringify({ + schemaVersion: 1, + id: 'x', + title: 'X', + steam: { appid: 480 }, + heroImage: 'a/hero.jpg', + watchProcesses: [name], + }); + expect(validateManifestText(text, t).ok, name).toBe(false); + } + }); + it('rejects a custom installer that is elevated (schema refine)', () => { const text = JSON.stringify({ schemaVersion: 1, @@ -303,7 +418,7 @@ describe('validateManifestText — multi-game array', () => { if (!result.ok) expect(result.issues.some((i) => i.path === 'games.1.id')).toBe(true); }); - it('prefixes each element\'s issue path with games.<i>.', () => { + it("prefixes each element's issue path with games.<i>.", () => { // Second game is missing its hero → the issue is attributed to games.1.heroImage. const text = JSON.stringify([game('a'), game('b', { heroImage: undefined })]); const result = validateManifestText(text, t); @@ -341,7 +456,9 @@ describe('absoluteToPcSavePath (reverse of expandPcSavePath, for the folder pick }); it('maps a folder under %DOCUMENTS%', () => { - expect(absoluteToPcSavePath(path.join(docs, 'MyGame', 'Saves'), env)).toBe('%DOCUMENTS%/MyGame/Saves'); + expect(absoluteToPcSavePath(path.join(docs, 'MyGame', 'Saves'), env)).toBe( + '%DOCUMENTS%/MyGame/Saves', + ); }); it('prefers the most specific base (%APPDATA% over %USERPROFILE%)', () => { @@ -375,7 +492,9 @@ describe('manifestJsonSchema', () => { expect(Object.keys(objectSchema?.properties ?? {})).toEqual( expect.arrayContaining(['schemaVersion', 'id', 'title']), ); - expect(objectSchema?.required).toEqual(expect.arrayContaining(['schemaVersion', 'id', 'title'])); + expect(objectSchema?.required).toEqual( + expect.arrayContaining(['schemaVersion', 'id', 'title']), + ); // Second branch: an array of the same object schema. expect(arraySchema?.type).toBe('array'); expect(arraySchema?.items?.type).toBe('object'); @@ -423,7 +542,10 @@ describe('validateManifestText — gridImage', () => { }); it('rejects a 4th heroImage (editor-only cap)', () => { - const result = validateManifestText(game({ heroImage: ['a.jpg', 'b.jpg', 'c.jpg', 'd.jpg'] }), t); + const result = validateManifestText( + game({ heroImage: ['a.jpg', 'b.jpg', 'c.jpg', 'd.jpg'] }), + t, + ); expect(result.ok).toBe(false); if (!result.ok) expect(result.issues.some((i) => i.path === 'heroImage')).toBe(true); }); @@ -472,7 +594,13 @@ describe('readManifests — gridImage + hero truncation (runtime is lenient)', ( }); it('resolves gridImage inside the card root', async () => { - await write({ schemaVersion: 1, id: 'x', title: 'X', executable: 'g/g.exe', gridImage: 'art/grid.jpg' }); + await write({ + schemaVersion: 1, + id: 'x', + title: 'X', + executable: 'g/g.exe', + gridImage: 'art/grid.jpg', + }); const result = await readManifests(cardRoot, env, resolveInstallDir); expect(result.ok).toBe(true); if (!result.ok) return; @@ -519,3 +647,331 @@ describe('readManifests — gridImage + hero truncation (runtime is lenient)', ( expect(result.ok).toBe(false); }); }); + +// ── PC mode (local games) ──────────────────────────────────────────────────── +// The whole feature rests on one asymmetry: a `pc` block (and an absolute path) is legal ONLY when the +// manifest was read from the PC library. These tests pin both directions of that, plus the "a missing +// game keeps its card" rule that lets a deleted game stay in the library. +// Every path here is built with path.join/os.tmpdir: pc paths are NATIVE (the library never travels) and +// CI runs the suite on Windows too, where a `/games/x.exe` literal is not absolute. + +describe('validateManifestText — pc mode', () => { + const exe = path.join(path.resolve(path.sep), 'Games', 'Hades', 'Hades.exe'); + const pcGame = (extra: Record<string, unknown> = {}): string => + JSON.stringify({ + schemaVersion: 1, + id: 'hades', + title: 'Hades', + pc: { executable: exe }, + heroImage: 'assets/hero.jpg', + ...extra, + }); + + it('accepts a pc-mode game for source "pc"', () => { + expect(validateManifestText(pcGame(), t, 'pc').ok).toBe(true); + }); + + it('rejects a pc block on a card', () => { + const result = validateManifestText(pcGame(), t, 'card'); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.issues.some((i) => i.path === 'pc')).toBe(true); + }); + + it('rejects a card-dialect executable in the PC library (B1 — no fifth launch mode)', () => { + const text = JSON.stringify({ + schemaVersion: 1, + id: 'x', + title: 'X', + executable: 'g/g.exe', + heroImage: 'assets/hero.jpg', + }); + const result = validateManifestText(text, t, 'pc'); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.issues.some((i) => i.path === 'executable')).toBe(true); + }); + + it('accepts a draft with no launch method configured yet', () => { + const text = JSON.stringify({ + schemaVersion: 1, + id: 'x', + title: 'X', + heroImage: 'assets/hero.jpg', + }); + expect(validateManifestText(text, t, 'pc').ok).toBe(true); + }); + + it('rejects a relative pc.executable', () => { + const result = validateManifestText(pcGame({ pc: { executable: 'games/hades.exe' } }), t, 'pc'); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.issues.some((i) => i.path === 'pc.executable')).toBe(true); + }); + + it('rejects pc together with steam / install / executable / saveOnCard (schema)', () => { + expect(validateManifestText(pcGame({ steam: { appid: 480 } }), t, 'pc').ok).toBe(false); + expect( + validateManifestText(pcGame({ install: { installer: 's.exe', type: 'nsis' } }), t, 'pc').ok, + ).toBe(false); + expect(validateManifestText(pcGame({ executable: 'g/g.exe' }), t, 'pc').ok).toBe(false); + expect(validateManifestText(pcGame({ saveOnCard: 'saves' }), t, 'pc').ok).toBe(false); + }); + + it('accepts a lone pcSavePath (the backup side is supplied by the app)', () => { + const abs = path.join(path.resolve(path.sep), 'Games', 'Hades', 'Saves'); + expect(validateManifestText(pcGame({ pcSavePath: abs }), t, 'pc').ok).toBe(true); + expect(validateManifestText(pcGame({ pcSavePath: '%DOCUMENTS%/Hades' }), t, 'pc').ok).toBe( + true, + ); + }); + + it('rejects an absolute pcSavePath on a CARD (the allowlist still rules there)', () => { + const abs = path.join(path.resolve(path.sep), 'Games', 'Hades', 'Saves'); + const text = JSON.stringify({ + schemaVersion: 1, + id: 'x', + title: 'X', + executable: 'g/g.exe', + heroImage: 'hero.jpg', + saveOnCard: 'saves', + pcSavePath: abs, + }); + const result = validateManifestText(text, t, 'card'); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.issues.some((i) => i.path === 'pcSavePath')).toBe(true); + }); + + it('accepts an empty array for source "pc" (the library has no games left)', () => { + expect(validateManifestText('[]', t, 'pc').ok).toBe(true); + expect(validateManifestText('[]', t, 'card').ok).toBe(false); + }); +}); + +describe('validateManifestText — a STEAM game in the PC library', () => { + const steamGame = (extra: Record<string, unknown> = {}): string => + JSON.stringify({ + schemaVersion: 1, + id: 'hades', + title: 'Hades', + steam: { appid: 1145360 }, + watchProcesses: ['Hades.exe'], + heroImage: 'assets/hero.jpg', + ...extra, + }); + + it('accepts a steam game as the second mode the library allows', () => { + expect(validateManifestText(steamGame(), t, 'pc').ok).toBe(true); + }); + + it('still rejects a card-dialect executable in the PC library, even with steam-shaped fields absent', () => { + const text = JSON.stringify({ + schemaVersion: 1, + id: 'x', + title: 'X', + executable: 'g/g.exe', + heroImage: 'assets/hero.jpg', + }); + const result = validateManifestText(text, t, 'pc'); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.issues.some((i) => i.path === 'executable')).toBe(true); + }); + + it('rejects saveOnCard for a LOCAL steam game (the library keeps the backup itself)', () => { + const result = validateManifestText(steamGame({ saveOnCard: 'saves' }), t, 'pc'); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.issues.some((i) => i.path === 'saveOnCard')).toBe(true); + // …while the very same manifest is the normal spelling on a card. + expect( + validateManifestText( + steamGame({ saveOnCard: 'saves', pcSavePath: '%APPDATA%/Hades' }), + t, + 'card', + ).ok, + ).toBe(true); + }); + + it('accepts a %PREFIX% pcSavePath (a Proton game keeps its saves inside the prefix)', () => { + expect(validateManifestText(steamGame({ pcSavePath: '%APPDATA%/Hades' }), t, 'pc').ok).toBe( + true, + ); + }); +}); + +describe('readManifests — pc source', () => { + const env = { documents: path.resolve('documents'), t }; + const resolveInstallDir = (): null => null; + let pcRoot: string; + const exe = path.join(path.resolve(path.sep), 'Games', 'Hades', 'Hades.exe'); + + beforeEach(async () => { + pcRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'playhook-pc-')); + }); + + afterEach(async () => { + await fs.rm(pcRoot, { recursive: true, force: true }); + }); + + const write = async (value: unknown): Promise<void> => { + await fs.writeFile(path.join(pcRoot, 'game.json'), JSON.stringify(value)); + }; + + const pcGame = (extra: Record<string, unknown> = {}): Record<string, unknown> => ({ + schemaVersion: 1, + id: 'hades', + title: 'Hades', + pc: { executable: exe }, + ...extra, + }); + + it('resolves a pc game whose executable does NOT exist (it stays in the library)', async () => { + await write(pcGame()); + const result = await readManifests(pcRoot, env, resolveInstallDir, { source: 'pc' }); + expect(result.ok).toBe(true); + if (!result.ok) return; + const manifest = result.manifests[0]; + expect(manifest?.source).toBe('pc'); + expect(manifest?.executablePath).toBe(exe); + expect(manifest?.cwd).toBe(path.dirname(exe)); + }); + + it('marks a card manifest with source "card"', async () => { + const cardRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'playhook-card-')); + await fs.mkdir(path.join(cardRoot, 'g'), { recursive: true }); + await fs.writeFile(path.join(cardRoot, 'g', 'g.exe'), ''); + await fs.writeFile( + path.join(cardRoot, 'game.json'), + JSON.stringify({ schemaVersion: 1, id: 'x', title: 'X', executable: 'g/g.exe' }), + ); + const result = await readManifests(cardRoot, env, resolveInstallDir); + expect(result.ok).toBe(true); + if (result.ok) expect(result.manifests[0]?.source).toBe('card'); + await fs.rm(cardRoot, { recursive: true, force: true }); + }); + + it('substitutes saveOnCardPath under the library root when pcSavePath is set', async () => { + const saves = path.join(path.resolve(path.sep), 'Games', 'Hades', 'Saves'); + await write(pcGame({ pcSavePath: saves })); + const result = await readManifests(pcRoot, env, resolveInstallDir, { source: 'pc' }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.manifests[0]?.saveOnCardPath).toBe(path.join(pcRoot, 'saves', 'hades')); + expect(result.manifests[0]?.pcSavePath).toBe(saves); + }); + + it('leaves saveOnCardPath undefined when the game declares no pcSavePath', async () => { + await write(pcGame()); + const result = await readManifests(pcRoot, env, resolveInstallDir, { source: 'pc' }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.manifests[0]?.saveOnCardPath).toBeUndefined(); + }); + + it('rejects a relative pc.executable', async () => { + await write(pcGame({ pc: { executable: 'games/hades.exe' } })); + const result = await readManifests(pcRoot, env, resolveInstallDir, { source: 'pc' }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.manifests).toEqual([]); + }); + + it('rejects a pc block read from a card root', async () => { + await write(pcGame()); + const result = await readManifests(pcRoot, env, resolveInstallDir); + expect(result.ok).toBe(false); + }); + + it('rejects a card-shaped manifest read from the PC library', async () => { + await write({ schemaVersion: 1, id: 'x', title: 'X', executable: 'g/g.exe' }); + const result = await readManifests(pcRoot, env, resolveInstallDir, { source: 'pc' }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.manifests).toEqual([]); + }); + + it('rejects an install block in the PC library, even without executable (B1)', async () => { + await write({ + schemaVersion: 1, + id: 'x', + title: 'X', + install: { installer: 's.exe', type: 'nsis' }, + }); + const result = await readManifests(pcRoot, env, resolveInstallDir, { source: 'pc' }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.manifests).toEqual([]); + }); + + it('resolves a draft PC game with no launch method and marks it unconfigured', async () => { + await write({ schemaVersion: 1, id: 'x', title: 'X', heroImage: 'assets/hero.jpg' }); + const result = await readManifests(pcRoot, env, resolveInstallDir, { source: 'pc' }); + expect(result.ok).toBe(true); + if (!result.ok) return; + const manifest = result.manifests[0]; + expect(manifest?.unconfigured).toBe(true); + expect(manifest?.executablePath).toBe(''); + expect(manifest?.cwd).toBe(''); + }); + + it('treats a missing game.json as an empty library', async () => { + const result = await readManifests(pcRoot, env, resolveInstallDir, { source: 'pc' }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.manifests).toEqual([]); + }); + + it('treats an empty array as an empty library (fatal on a card)', async () => { + await write([]); + const pcResult = await readManifests(pcRoot, env, resolveInstallDir, { source: 'pc' }); + expect(pcResult.ok).toBe(true); + if (pcResult.ok) expect(pcResult.manifests).toEqual([]); + expect((await readManifests(pcRoot, env, resolveInstallDir)).ok).toBe(false); + }); + + it('resolves a local STEAM game (no executable of its own — steam:// does the launching)', async () => { + await write({ + schemaVersion: 1, + id: 'hades', + title: 'Hades', + steam: { appid: 1145360 }, + watchProcesses: ['Hades.exe'], + }); + const result = await readManifests(pcRoot, env, resolveInstallDir, { source: 'pc' }); + expect(result.ok).toBe(true); + if (!result.ok) return; + const manifest = result.manifests[0]; + expect(manifest?.source).toBe('pc'); + expect(manifest?.steam).toEqual({ appid: 1145360 }); + expect(manifest?.executablePath).toBe(''); + }); + + it('gives a local steam game the same library-side save backup as a pc game', async () => { + await write({ + schemaVersion: 1, + id: 'hades', + title: 'Hades', + steam: { appid: 1145360 }, + watchProcesses: ['Hades.exe'], + pcSavePath: '%APPDATA%/Hades', + }); + const result = await readManifests(pcRoot, env, resolveInstallDir, { source: 'pc' }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.manifests[0]?.saveOnCardPath).toBe(path.join(pcRoot, 'saves', 'hades')); + } + }); + + it('drops a local steam game that names a saveOnCard', async () => { + await write({ + schemaVersion: 1, + id: 'hades', + title: 'Hades', + steam: { appid: 1145360 }, + watchProcesses: ['Hades.exe'], + saveOnCard: 'saves', + pcSavePath: '%APPDATA%/Hades', + }); + const result = await readManifests(pcRoot, env, resolveInstallDir, { source: 'pc' }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.manifests).toEqual([]); + }); + + it('reads several local games from an array', async () => { + await write([pcGame(), pcGame({ id: 'celeste', title: 'Celeste' })]); + const result = await readManifests(pcRoot, env, resolveInstallDir, { source: 'pc' }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.manifests.map((m) => m.raw.id)).toEqual(['hades', 'celeste']); + }); +}); diff --git a/test/metadata-apply.test.ts b/test/metadata-apply.test.ts new file mode 100644 index 00000000..86d04051 --- /dev/null +++ b/test/metadata-apply.test.ts @@ -0,0 +1,403 @@ +// The checks that stand between a downloaded file and the user's card: request validation, the target +// path it lands on, the stale files it supersedes, and what the bytes are allowed to claim to be. +import { describe, expect, it } from 'vitest'; +import { + applyRelativePath, + stalePathsFor, + validateApply, + type ApplyTarget, +} from '../src/main/metadata/apply-target'; +import { sniffMedia } from '../src/main/metadata/media-type'; +import { + capArtworkPerProvider, + mergeCandidates, + mergeDetails, + normalizeTitle, + dedupe, + orderByProvider, + withoutSpareStore, + withMergedRefs, +} from '../src/main/metadata/service'; +import type { ArtworkOffer } from '../src/main/metadata/provider'; +import type { GameCandidate } from '../src/shared/types'; + +const target = (slot: ApplyTarget['slot'], gameId = 'hades'): ApplyTarget => ({ + gameId, + slot, + expectedKind: slot === 'music' ? 'audio' : 'image', +}); + +describe('metadata apply — request validation', () => { + it('accepts the three slots the manifest has fields for', () => { + expect(validateApply('hades', 'grid').ok).toBe(true); + expect(validateApply('hades', 'music').ok).toBe(true); + expect(validateApply('hades', { hero: 0 }).ok).toBe(true); + }); + + it('refuses an id that could escape the root', () => { + for (const id of ['../evil', 'a/b', '..', '.', '', 'has space']) { + expect(validateApply(id, 'grid'), id).toEqual({ ok: false, reason: 'bad-id' }); + } + }); + + it('refuses a hero index outside the manifest cap', () => { + expect(validateApply('hades', { hero: 3 })).toEqual({ ok: false, reason: 'bad-slot' }); + expect(validateApply('hades', { hero: -1 })).toEqual({ ok: false, reason: 'bad-slot' }); + expect(validateApply('hades', { hero: 1.5 })).toEqual({ ok: false, reason: 'bad-slot' }); + }); + + it('refuses a slot that is not one of the known shapes', () => { + expect(validateApply('hades', 'executable')).toEqual({ ok: false, reason: 'bad-slot' }); + expect(validateApply('hades', null)).toEqual({ ok: false, reason: 'bad-slot' }); + expect(validateApply('hades', { hero: '0' })).toEqual({ ok: false, reason: 'bad-slot' }); + }); + + it('expects audio for the music slot and images for the rest', () => { + const music = validateApply('hades', 'music'); + const hero = validateApply('hades', { hero: 2 }); + expect(music.ok === true && music.target.expectedKind).toBe('audio'); + expect(hero.ok === true && hero.target.expectedKind).toBe('image'); + }); +}); + +describe('metadata apply — target paths', () => { + it('reuses the move-to-card asset names, so both routes produce the same file names', () => { + expect(applyRelativePath(target('grid'), 'jpg')).toBe('assets/hades-grid.jpg'); + expect(applyRelativePath(target('music'), 'mp3')).toBe('assets/hades-music.mp3'); + expect(applyRelativePath(target({ hero: 0 }), 'png')).toBe('assets/hades-hero-1.png'); + expect(applyRelativePath(target({ hero: 2 }), 'png')).toBe('assets/hades-hero-3.png'); + }); + + it('lists the same slot under every other extension as superseded', () => { + const stale = stalePathsFor(target('grid'), 'png', ['jpg', 'png', 'webp']); + expect(stale).toEqual(['assets/hades-grid.jpg', 'assets/hades-grid.webp']); + }); + + it('never lists the file it is about to write', () => { + const stale = stalePathsFor(target({ hero: 1 }), 'jpg', ['jpg', 'png']); + expect(stale).not.toContain('assets/hades-hero-2.jpg'); + }); +}); + +describe('metadata apply — sniffing the downloaded bytes', () => { + const bytesOf = (...values: number[]): Uint8Array => new Uint8Array(values); + const ascii = (text: string, pad = 0): Uint8Array => + new Uint8Array([...new Array<number>(pad).fill(0), ...[...text].map((c) => c.charCodeAt(0))]); + + it('recognizes the image formats the reader can decode', () => { + expect(sniffMedia(bytesOf(0xff, 0xd8, 0xff, 0xe0))).toEqual({ + kind: 'image', + extension: 'jpg', + }); + expect(sniffMedia(bytesOf(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a))).toEqual({ + kind: 'image', + extension: 'png', + }); + expect(sniffMedia(ascii('GIF89a'))).toEqual({ kind: 'image', extension: 'gif' }); + expect(sniffMedia(ascii('RIFF____WEBPVP8 '))).toEqual({ kind: 'image', extension: 'webp' }); + }); + + it('tells a WAV from a WebP, which share the RIFF container', () => { + expect(sniffMedia(ascii('RIFF____WAVEfmt '))).toEqual({ kind: 'audio', extension: 'wav' }); + }); + + it('recognizes both a tagged and an untagged mp3', () => { + expect(sniffMedia(ascii('ID3'))).toEqual({ kind: 'audio', extension: 'mp3' }); + expect(sniffMedia(bytesOf(0xff, 0xfb, 0x90, 0x00))).toEqual({ + kind: 'audio', + extension: 'mp3', + }); + }); + + it('recognizes ogg, flac and m4a', () => { + expect(sniffMedia(ascii('OggS'))).toEqual({ kind: 'audio', extension: 'ogg' }); + expect(sniffMedia(ascii('fLaC'))).toEqual({ kind: 'audio', extension: 'flac' }); + expect(sniffMedia(ascii('ftyp', 4))).toEqual({ kind: 'audio', extension: 'm4a' }); + }); + + it('refuses anything it does not recognize — an HTML error page above all', () => { + expect(sniffMedia(ascii('<!DOCTYPE html>'))).toBeNull(); + expect(sniffMedia(bytesOf(0x00, 0x01, 0x02, 0x03))).toBeNull(); + expect(sniffMedia(new Uint8Array())).toBeNull(); + }); + + it('does not mistake a reserved MPEG header for an mp3', () => { + expect(sniffMedia(bytesOf(0xff, 0xe9, 0x00, 0x00))).toBeNull(); + }); +}); + +describe('metadata artwork — how much one gallery may hold', () => { + const offer = (provider: ArtworkOffer['provider'], index: number): ArtworkOffer => ({ + key: `${provider}:${index}`, + kind: 'grid', + provider, + thumbUrl: `https://cdn.test/${provider}-${index}-thumb.jpg`, + fullUrl: `https://cdn.test/${provider}-${index}.jpg`, + }); + + it('keeps every offer when the sources are modest', () => { + const offers = [offer('steam', 1), offer('steamgriddb', 1), offer('steamgriddb', 2)]; + expect(capArtworkPerProvider(offers, 24)).toHaveLength(3); + }); + + it('caps a talkative source at the limit', () => { + const offers = Array.from({ length: 60 }, (_, index) => offer('steamgriddb', index)); + expect(capArtworkPerProvider(offers, 24)).toHaveLength(24); + }); + + it('counts per source, so a long list cannot crowd out the other source', () => { + const offers = [ + ...Array.from({ length: 60 }, (_, index) => offer('steamgriddb', index)), + offer('steam', 1), + ]; + const capped = capArtworkPerProvider(offers, 24); + expect(capped.filter((o) => o.provider === 'steam')).toHaveLength(1); + expect(capped).toHaveLength(25); + }); + + it('keeps the order the sources answered in', () => { + const offers = [offer('steamgriddb', 1), offer('steamgriddb', 2), offer('steamgriddb', 3)]; + expect(capArtworkPerProvider(offers, 2).map((o) => o.key)).toEqual([ + 'steamgriddb:1', + 'steamgriddb:2', + ]); + }); +}); + +describe('metadata search — merging the sources', () => { + const steam = (id: number, title: string): GameCandidate => ({ + key: `steam:${id}`, + title, + provider: 'steam', + steamAppId: id, + }); + const sgdb = (id: number, title: string): GameCandidate => ({ + key: `sgdb:${id}`, + title, + provider: 'steamgriddb', + }); + const gog = (id: string, title: string): GameCandidate => ({ + key: `gog:${id}`, + title, + provider: 'gog', + gogId: id, + }); + + it('keeps one entry per Steam appid', () => { + const merged = mergeCandidates([steam(220, 'Half-Life 2'), steam(220, 'Half-Life 2 (dup)')]); + expect(merged).toEqual([steam(220, 'Half-Life 2')]); + }); + + it('leads with the source that can also reach the descriptions and the CDN cover', () => { + const merged = mergeCandidates([gog('1207', 'Hollow Knight'), steam(367520, 'Hollow Knight')]); + expect(merged.map((c) => c.key)).toEqual(['steam:367520']); + }); + + it('collapses the same game from two sources into one candidate carrying both references', () => { + const merged = mergeCandidates([ + gog('1207658691', 'The Witcher 3: Wild Hunt'), + steam(292030, 'The Witcher 3: Wild Hunt'), + ]); + expect(merged).toHaveLength(1); + expect(merged[0]).toMatchObject({ + provider: 'steam', + steamAppId: 292030, + gogId: '1207658691', + }); + }); + + it('merges across the punctuation and trademark marks publishers spell differently', () => { + const merged = mergeCandidates([ + steam(292030, 'The Witcher® 3: Wild Hunt'), + gog('1207658691', 'The Witcher 3 - Wild Hunt'), + ]); + expect(merged).toHaveLength(1); + expect(merged[0]?.gogId).toBe('1207658691'); + }); + + it('keeps two different games apart rather than guessing', () => { + const merged = mergeCandidates([ + steam(220, 'Half-Life 2'), + gog('x', 'Half-Life 2: Episode One'), + ]); + expect(merged).toHaveLength(2); + }); + + it('keeps distinct games from the same source', () => { + const merged = mergeCandidates([steam(220, 'HL2'), steam(380, 'HL2: Episode One')]); + expect(merged).toHaveLength(2); + }); + + it('does not fold two entries of ONE source into each other, however alike their titles', () => { + const merged = mergeCandidates([sgdb(7, 'Hollow Knight'), sgdb(8, 'Hollow Knight™')]); + expect(merged).toHaveLength(2); + }); + + it('drops a repeated key even without an appid', () => { + expect(mergeCandidates([sgdb(7, 'HK'), sgdb(7, 'HK')])).toHaveLength(1); + }); + + it('normalizes a title only as far as two sources can be expected to agree', () => { + expect(normalizeTitle('The Witcher® 3: Wild Hunt')).toBe('the witcher 3 wild hunt'); + expect(normalizeTitle(' S.T.A.L.K.E.R. ')).toBe('s t a l k e r'); + expect(normalizeTitle('Мор')).toBe('мор'); + }); +}); + +describe('metadata search — the appid shortcut still reaches the other sources', () => { + // Naming a game by its Steam appid skips the search, and the search is where sources are merged — so + // the shortcut has to collect the other references itself, or such a game would be offered Steam's + // backgrounds and nothing else however many GOG and RAWG hold. + const steamCandidate: GameCandidate = { + key: 'steam:1145360', + title: 'Hades', + provider: 'steam', + steamAppId: 1145360, + }; + + it("gains the other sources' references while keeping its own key", () => { + const enriched = withMergedRefs(steamCandidate, [ + { key: 'gog:1', title: 'Hades', provider: 'gog', gogId: '1' }, + ]); + expect(enriched).toEqual({ ...steamCandidate, gogId: '1' }); + }); + + it("ignores the other sources' near misses", () => { + const enriched = withMergedRefs(steamCandidate, [ + { key: 'gog:2', title: 'Hades II', provider: 'gog', gogId: '2' }, + ]); + expect(enriched).toEqual(steamCandidate); + }); + + it('is unchanged when no other source answered at all', () => { + expect(withMergedRefs(steamCandidate, [])).toBe(steamCandidate); + }); +}); + +describe('metadata details — merging what the sources know', () => { + it('takes the first answer that states a field, per FIELD', () => { + const merged = mergeDetails([ + { description: { en: 'From Steam.' }, genres: ['Action'] }, + { genres: ['Adventure'], releaseDate: '2017-02-24', platforms: ['windows', 'linux'] }, + ]); + expect(merged).toEqual({ + description: { en: 'From Steam.' }, + genres: ['Action'], + releaseDate: '2017-02-24', + platforms: ['windows', 'linux'], + }); + }); + + it('lets a later source fill what the first knew nothing about', () => { + const merged = mergeDetails([{}, { genres: ['Metroidvania'], releaseDate: '2017' }]); + expect(merged).toEqual({ genres: ['Metroidvania'], releaseDate: '2017' }); + }); + + it('never stores an empty list as an answer', () => { + const merged = mergeDetails([{ genres: [], platforms: [] }, { genres: ['RPG'] }]); + expect(merged).toEqual({ genres: ['RPG'] }); + }); + + it('answers with nothing when no source knew anything', () => { + expect(mergeDetails([{}, {}])).toEqual({}); + }); +}); + +describe('metadata candidates — GOG only fills the gaps Steam leaves', () => { + const candidate = (provider: 'steam' | 'gog' | 'steamgriddb', title: string): GameCandidate => ({ + key: `${provider}:${title}`, + title, + provider, + }); + + it('drops GOG lines once Steam has recognized the game', () => { + const merged = withoutSpareStore([ + candidate('steam', 'Watch Dogs'), + candidate('gog', "Din's Curse"), + candidate('gog', 'The Iron Oath'), + ]); + expect(merged.map((entry) => entry.title)).toEqual(['Watch Dogs']); + }); + + it('keeps them when Steam answered with nothing — a GOG-only game is still a game', () => { + const only = [candidate('gog', 'Beneath a Steel Sky')]; + expect(withoutSpareStore(only)).toEqual(only); + }); + + it('leaves the other sources alone', () => { + const merged = withoutSpareStore([ + candidate('steam', 'Hollow Knight'), + candidate('steamgriddb', 'Hollow Knight'), + ]); + expect(merged).toHaveLength(2); + }); + + // The reference survives inside the Steam entry, which is what the gallery reads — dropping a LINE + // from the menu must never cost a SOURCE in the gallery. + it('keeps the gog reference a merge folded into the Steam candidate', () => { + const withRef: GameCandidate = { + key: 'steam:292030', + title: 'The Witcher 3', + provider: 'steam', + steamAppId: 292030, + gogId: '1207658691', + }; + expect(withoutSpareStore([withRef, candidate('gog', 'The Witcher Adventure Game')])).toEqual([ + withRef, + ]); + }); +}); + +describe('metadata artwork — what a later page may repeat', () => { + const offer = (key: string): ArtworkOffer => ({ + key, + kind: 'hero', + provider: 'wallpapercave', + thumbUrl: `https://cdn.test/${key}.jpg`, + fullUrl: `https://cdn.test/${key}.jpg`, + }); + + it('drops what the gallery has already shown — two albums really do share a picture', () => { + const shown = new Set(['a']); + expect(dedupe([offer('a'), offer('b')], shown).map((o) => o.key)).toEqual(['b']); + }); + + it('drops a repeat inside one batch as well', () => { + expect(dedupe([offer('a'), offer('a')], new Set()).map((o) => o.key)).toEqual(['a']); + }); + + it('keeps the order it was given', () => { + expect(dedupe([offer('b'), offer('a')], new Set()).map((o) => o.key)).toEqual(['b', 'a']); + }); +}); + +describe('metadata artwork — the order sources appear in', () => { + const offerOf = (provider: ArtworkOffer['provider']): ArtworkOffer => ({ + key: `${provider}:1`, + kind: 'hero', + provider, + thumbUrl: 'https://cdn.test/t.jpg', + fullUrl: 'https://cdn.test/f.jpg', + }); + + it('lists both wallpaper sources first, then Steam, then GOG — a stable order between visits', () => { + const ordered = orderByProvider([ + offerOf('gog'), + offerOf('steam'), + offerOf('wallpapercave'), + offerOf('wallhaven'), + ]); + expect(ordered.map((offer) => offer.provider)).toEqual([ + 'wallhaven', + 'wallpapercave', + 'steam', + 'gog', + ]); + }); + + it('keeps the relative order inside one source', () => { + const first = { ...offerOf('gog'), key: 'gog:1' }; + const second = { ...offerOf('gog'), key: 'gog:2' }; + expect(orderByProvider([first, second]).map((o) => o.key)).toEqual(['gog:1', 'gog:2']); + }); +}); diff --git a/test/metadata-gog.test.ts b/test/metadata-gog.test.ts new file mode 100644 index 00000000..43ef77db --- /dev/null +++ b/test/metadata-gog.test.ts @@ -0,0 +1,303 @@ +// GOG provider: the catalogue search, the screenshot formatters, and the fact that backgrounds cost no +// second request. Fixtures only — no test reaches gog.com. +import { describe, expect, it, vi } from 'vitest'; +import { HttpClient, type FetchResponse } from '../src/main/metadata/http'; +import { + GogProvider, + gogCandidateKey, + gogIdFromKey, + searchUrl, + titleMatches, + toArtworkOffers, + toDetails, + toIsoDate, + toPlatforms, + withFormatter, +} from '../src/main/metadata/gog'; + +/** What a page request looks like now: the page, plus the size floor the sidebar's filter sets. */ +function pageRequest( + page = 0, + minSize = { width: 0, height: 0 }, +): { + readonly page: number; + readonly minSize: { readonly width: number; readonly height: number }; +} { + return { page, minSize }; +} + +const CATALOG_FIXTURE = JSON.stringify({ + products: [ + { + id: '1207658691', + slug: 'the_witcher_3_wild_hunt', + title: 'The Witcher 3: Wild Hunt', + screenshots: [ + 'https://images.gog-statics.com/aaa_{formatter}.jpg', + 'https://images.gog-statics.com/bbb_{formatter}.jpg', + ], + }, + { id: '1207666393', slug: 'the_witcher', title: 'The Witcher', screenshots: [] }, + ], +}); + +function textResponse(text: string, status = 200): FetchResponse { + const chunks = [new TextEncoder().encode(text)]; + let index = 0; + return { + ok: status >= 200 && status < 300, + status, + headers: { get: () => null }, + body: { + getReader: () => ({ + read: async () => { + if (index >= chunks.length) return { done: true }; + const value = chunks[index]!; + index += 1; + return { done: false, value }; + }, + cancel: async () => undefined, + }), + }, + }; +} + +function providerOf(routes: (url: string) => FetchResponse = () => textResponse(CATALOG_FIXTURE)): { + provider: GogProvider; + fetch: ReturnType<typeof vi.fn>; +} { + const fetch = vi.fn(async (url: string) => routes(url)); + const http = new HttpClient({ fetch, userAgent: 'Playhook/test' }); + return { provider: new GogProvider({ http }), fetch }; +} + +describe('gog facts kept for a future library view', () => { + it('normalizes the dotted catalogue date to ISO', () => { + expect(toIsoDate('2017.02.24')).toBe('2017-02-24'); + expect(toIsoDate('2017')).toBe('2017'); + expect(toIsoDate('soon')).toBeUndefined(); + expect(toIsoDate(undefined)).toBeUndefined(); + }); + + it("maps GOG's osx onto the same platform Steam calls mac", () => { + expect(toPlatforms(['windows', 'linux', 'osx'])).toEqual(['windows', 'linux', 'mac']); + }); + + it('drops an operating system it does not know', () => { + expect(toPlatforms(['windows', 'amiga'])).toEqual(['windows']); + expect(toPlatforms([])).toBeUndefined(); + expect(toPlatforms(undefined)).toBeUndefined(); + }); + + it('states no description — the catalogue answer carries none', () => { + const details = toDetails({ + id: '1', + title: 'Hollow Knight', + screenshots: [], + genres: [{ name: 'Metroidvania' }], + releaseDate: '2017.02.24', + operatingSystems: ['windows', 'linux'], + }); + expect(details).toEqual({ + genres: ['Metroidvania'], + releaseDate: '2017-02-24', + platforms: ['windows', 'linux'], + }); + }); +}); + +describe('gog provider', () => { + describe('urls and keys', () => { + it('searches the catalogue with the like: prefix the endpoint expects', () => { + expect(searchUrl('Witcher 3')).toBe( + 'https://catalog.gog.com/v1/catalog?query=like%3AWitcher%203&limit=10', + ); + }); + + it('round-trips a product id through its key, keeping it a string', () => { + expect(gogIdFromKey(gogCandidateKey('1207658691'))).toBe('1207658691'); + expect(gogIdFromKey('steam:220')).toBeUndefined(); + }); + + it('fills the formatter into a screenshot template', () => { + expect(withFormatter('https://images.gog-statics.com/x_{formatter}.jpg', 'ggvgm')).toBe( + 'https://images.gog-statics.com/x_ggvgm.jpg', + ); + }); + + it('leaves a URL with no placeholder as it is rather than dropping it', () => { + const plain = 'https://images.gog-statics.com/x.jpg'; + expect(withFormatter(plain, 'ggvgm')).toBe(plain); + }); + }); + + describe('search', () => { + it('parses the catalogue answer into candidates carrying the product id', async () => { + const { provider } = providerOf(); + const result = await provider.search('witcher'); + expect(result.ok === true && result.value[0]).toEqual({ + key: 'gog:1207658691', + title: 'The Witcher 3: Wild Hunt', + provider: 'gog', + gogId: '1207658691', + }); + }); + + it('reports a moved-on answer shape as a failure, not as an empty catalogue', async () => { + const { provider } = providerOf(() => textResponse('<html>maintenance</html>')); + expect((await provider.search('witcher')).ok).toBe(false); + }); + }); + + // The catalogue's `like:` matches DESCRIPTIONS AND TAGS, not titles: measured against the live API, + // `like:Watch Dogs` (a game GOG does not sell) answered with "The Signal From Tölva", "Din's Curse" + // and five more, and `like:cyberpunk` answered with RoboCop and Mirror's Edge beside Cyberpunk 2077. + describe('only the products whose NAME answers the query become candidates', () => { + it('keeps the game and its editions', () => { + expect(titleMatches('Cyberpunk 2077', 'Cyberpunk 2077')).toBe(true); + expect(titleMatches('Cyberpunk 2077: Phantom Liberty', 'Cyberpunk 2077')).toBe(true); + expect(titleMatches('The Witcher 3: Wild Hunt - Complete Edition', 'The Witcher 3')).toBe( + true, + ); + }); + + it('drops what merely shares a tag or a word of the description', () => { + expect(titleMatches('The Signal From Tölva', 'Watch Dogs')).toBe(false); + expect(titleMatches("Din's Curse", 'Watch Dogs')).toBe(false); + expect(titleMatches('RoboCop: Rogue City', 'Cyberpunk 2077')).toBe(false); + expect(titleMatches('The Pedestrian Soundtrack', 'Hades')).toBe(false); + }); + + it('drops another game of the same series — a missing word is a different game', () => { + expect(titleMatches('Sniper Elite V2 Remastered', 'Sniper Elite 5')).toBe(false); + }); + + it('ignores the articles and the marks stores sprinkle differently', () => { + expect(titleMatches('Witcher 3: Wild Hunt', 'The Witcher 3')).toBe(true); + expect(titleMatches('Watch Dogs', 'Watch_Dogs™')).toBe(true); + }); + + it('filters the candidates a search answers with', async () => { + const { provider } = providerOf(() => + textResponse( + JSON.stringify({ + products: [ + { id: '1', title: 'The Witcher 3: Wild Hunt', screenshots: [] }, + { id: '2', title: "Din's Curse", screenshots: [] }, + ], + }), + ), + ); + const result = await provider.search('The Witcher 3'); + expect(result.ok === true && result.value.map((c) => c.title)).toEqual([ + 'The Witcher 3: Wild Hunt', + ]); + }); + + // The filter is about the MENU. A candidate that came from another source keeps its GOG pictures: + // they are reached by product id, and the id was cached while the answer was still whole. + it('still offers the pictures of a product the filter kept out of the menu', async () => { + const { provider } = providerOf(() => + textResponse( + JSON.stringify({ + products: [ + { + id: '9', + title: 'Some Other Spelling', + screenshots: ['https://images.gog-statics.com/x_{formatter}.jpg'], + }, + ], + }), + ), + ); + await provider.search('The Witcher 3'); + const art = await provider.artwork( + { key: 'steam:1', title: 'The Witcher 3', gogId: '9' }, + 'hero', + pageRequest(), + ); + expect(art.ok === true && art.value.offers).toHaveLength(1); + }); + }); + + describe('backgrounds', () => { + it('uses the small formatter for the grid and the true 1920x1080 one for the download', () => { + const offers = toArtworkOffers({ + id: '1207658691', + title: 'The Witcher 3: Wild Hunt', + screenshots: ['https://images.gog-statics.com/aaa_{formatter}.jpg'], + }); + expect(offers[0]).toEqual({ + key: 'gog:1207658691:shot-0', + kind: 'hero', + provider: 'gog', + width: 1920, + height: 1080, + thumbUrl: 'https://images.gog-statics.com/aaa_ggvgm.jpg', + fullUrl: 'https://images.gog-statics.com/aaa_ggvgl_2x.jpg', + }); + }); + + it('answers from what the search already returned, with no second request', async () => { + const { provider, fetch } = providerOf(); + await provider.search('witcher'); + const calls = fetch.mock.calls.length; + const result = await provider.artwork( + { key: 'gog:1207658691', title: 'The Witcher 3', gogId: '1207658691' }, + 'hero', + pageRequest(), + ); + expect(result.ok === true && result.value.offers).toHaveLength(2); + expect(fetch.mock.calls.length).toBe(calls); + }); + + it('fetches by title for a candidate merged in from another source', async () => { + const { provider, fetch } = providerOf(); + const result = await provider.artwork( + { + key: 'steam:292030', + title: 'The Witcher 3: Wild Hunt', + steamAppId: 292030, + gogId: '1207658691', + }, + 'hero', + pageRequest(), + ); + expect(result.ok === true && result.value.offers).toHaveLength(2); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('has nothing to offer for a game GOG does not sell', async () => { + const { provider, fetch } = providerOf(); + const result = await provider.artwork( + { key: 'steam:220', title: 'HL2', steamAppId: 220 }, + 'hero', + pageRequest(), + ); + expect(result).toEqual({ ok: true, value: { offers: [], hasMore: false } }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('offers no covers — GOG covers are the wrong proportions for the launcher card', async () => { + const { provider, fetch } = providerOf(); + const result = await provider.artwork( + { key: 'gog:1207658691', title: 'x', gogId: '1207658691' }, + 'grid', + pageRequest(), + ); + expect(result).toEqual({ ok: true, value: { offers: [], hasMore: false } }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('yields nothing for a product the catalogue lists with no screenshots', async () => { + const { provider } = providerOf(); + await provider.search('witcher'); + const result = await provider.artwork( + { key: 'gog:1207666393', title: 'The Witcher', gogId: '1207666393' }, + 'hero', + pageRequest(), + ); + expect(result).toEqual({ ok: true, value: { offers: [], hasMore: false } }); + }); + }); +}); diff --git a/test/metadata-http.test.ts b/test/metadata-http.test.ts new file mode 100644 index 00000000..d0cdf6e6 --- /dev/null +++ b/test/metadata-http.test.ts @@ -0,0 +1,139 @@ +// The network client's rules, exercised with a fake fetch (no test touches the network — the whole +// reason HttpClient takes `fetch` through deps). +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; +import { HttpClient, type FetchInit, type FetchResponse } from '../src/main/metadata/http'; + +function bodyOf(chunks: readonly Uint8Array[]): FetchResponse['body'] { + let index = 0; + return { + getReader: () => ({ + read: async () => { + if (index >= chunks.length) return { done: true }; + const value = chunks[index]!; + index += 1; + return { done: false, value }; + }, + cancel: async () => undefined, + }), + }; +} + +function respond(text: string, init?: { status?: number; contentType?: string }): FetchResponse { + const status = init?.status ?? 200; + return { + ok: status >= 200 && status < 300, + status, + headers: { + get: (name) => (name.toLowerCase() === 'content-type' ? (init?.contentType ?? null) : null), + }, + body: bodyOf([new TextEncoder().encode(text)]), + }; +} + +function clientOf(fetch: (url: string, init?: FetchInit) => Promise<FetchResponse>): HttpClient { + return new HttpClient({ fetch, userAgent: 'Playhook/test' }); +} + +describe('metadata http client', () => { + it('validates a JSON answer against the schema', async () => { + const client = clientOf(async () => + respond('{"total":1,"items":[{"id":220,"name":"Half-Life 2"}]}'), + ); + const schema = z.object({ items: z.array(z.object({ id: z.number(), name: z.string() })) }); + const result = await client.json('https://example.test/search', schema); + expect(result).toEqual({ ok: true, value: { items: [{ id: 220, name: 'Half-Life 2' }] } }); + }); + + it('fails a JSON answer whose shape the schema rejects, rather than casting it', async () => { + const client = clientOf(async () => respond('{"items":"nope"}')); + const result = await client.json( + 'https://example.test/search', + z.object({ items: z.array(z.string()) }), + ); + expect(result.ok).toBe(false); + }); + + it('fails on malformed JSON', async () => { + const client = clientOf(async () => respond('{ not json')); + const result = await client.json('https://example.test/search', z.object({})); + expect(result.ok).toBe(false); + }); + + it('reports a non-2xx status as a failure carrying the code', async () => { + const client = clientOf(async () => respond('', { status: 404 })); + const result = await client.text('https://example.test/missing'); + expect(result).toEqual({ ok: false, message: 'https://example.test/missing: HTTP 404' }); + }); + + it('sends the User-Agent and merges per-call headers', async () => { + const fetch = vi.fn(async (_url: string, _init?: FetchInit) => respond('{}')); + await clientOf(fetch).json('https://example.test/x', z.object({}), { + headers: { Authorization: 'Bearer k' }, + }); + expect(fetch.mock.calls[0]?.[1]?.headers).toEqual({ + 'User-Agent': 'Playhook/test', + Authorization: 'Bearer k', + }); + }); + + it('normalizes the content type, dropping its parameters', async () => { + const client = clientOf(async () => + respond('x', { contentType: 'IMAGE/JPEG; charset=binary' }), + ); + const result = await client.bytes('https://example.test/a.jpg', 1024); + expect(result.ok === true && result.value.contentType).toBe('image/jpeg'); + }); + + it('refuses a body that grows past the cap, mid-stream', async () => { + const chunk = new Uint8Array(64); + const client = clientOf(async () => ({ + ok: true, + status: 200, + headers: { get: () => null }, + body: bodyOf([chunk, chunk, chunk]), + })); + const result = await client.bytes('https://example.test/big.bin', 100); + expect(result).toEqual({ + ok: false, + message: 'https://example.test/big.bin: larger than 100 bytes', + }); + }); + + it('joins the streamed chunks in order', async () => { + const client = clientOf(async () => ({ + ok: true, + status: 200, + headers: { get: () => null }, + body: bodyOf([new Uint8Array([1, 2]), new Uint8Array([3]), new Uint8Array([4, 5])]), + })); + const result = await client.bytes('https://example.test/a.bin', 1024); + expect(result.ok === true && [...result.value.bytes]).toEqual([1, 2, 3, 4, 5]); + }); + + it('gives up immediately when the caller has already aborted', async () => { + const controller = new AbortController(); + controller.abort(); + const client = clientOf(async (_url, init) => { + if (init?.signal?.aborted === true) throw new Error('aborted'); + return respond('{}'); + }); + const result = await client.text('https://example.test/x', { signal: controller.signal }); + expect(result.ok).toBe(false); + }); + + it('answers exists() from the status of a HEAD request', async () => { + const fetch = vi.fn(async (_url: string, init?: FetchInit) => + init?.method === 'HEAD' ? respond('', { status: 200 }) : respond('', { status: 500 }), + ); + await expect(clientOf(fetch).exists('https://example.test/art.jpg')).resolves.toBe(true); + expect(fetch.mock.calls[0]?.[1]?.method).toBe('HEAD'); + }); + + it('treats a throwing HEAD as "not there" rather than an error', async () => { + const client = clientOf(async () => { + throw new Error('offline'); + }); + await expect(client.exists('https://example.test/art.jpg')).resolves.toBe(false); + }); +}); diff --git a/test/metadata-khinsider.test.ts b/test/metadata-khinsider.test.ts new file mode 100644 index 00000000..b8e132f9 --- /dev/null +++ b/test/metadata-khinsider.test.ts @@ -0,0 +1,233 @@ +// Khinsider is scraped, not queried — so its parsers are the part that will break first, and the part +// that is tested hardest. Fixtures only: no test reaches the site. +import { describe, expect, it, vi } from 'vitest'; +import { HttpClient, type FetchResponse } from '../src/main/metadata/http'; +import { + KhinsiderProvider, + albumUrl, + parseAlbums, + parseAudioUrl, + parseSize, + parseTrackKey, + parseTracks, + searchUrl, + trackKey, +} from '../src/main/metadata/khinsider'; + +const SEARCH_PAGE = ` +<table class="albumList"> + <tr> + <td><a href="/game-soundtracks/album/hades-original-soundtrack"><img src="x.jpg"></a></td> + <td><a href="/game-soundtracks/album/hades-original-soundtrack">Hades & Friends OST</a></td> + </tr> + <tr> + <td><a href="/game-soundtracks/album/hades-ii">Hades II</a></td> + </tr> +</table> +`; + +const ALBUM_PAGE = ` +<table id="songlist"> + <tr> + <td class="playlistDownloadSong"><a href="/game-soundtracks/album/hades-original-soundtrack/01%20-%20Good%20Riddance.mp3">Good Riddance</a></td> + <td align="right">3:12</td> + <td align="right">4.44 MB</td> + </tr> + <tr> + <td class="playlistDownloadSong"><a href="/game-soundtracks/album/hades-original-soundtrack/02%20-%20No%20Escape.mp3"></a></td> + <td align="right">2:05</td> + <td align="right">763 KB</td> + </tr> +</table> +`; + +const TRACK_PAGE = ` +<p><a style="color: #ef9f00;" href="https://vgmsite.com/soundtracks/hades/01%20-%20Good%20Riddance.mp3">Click here to download</a></p> +<audio id="audio" src="https://vgmsite.com/soundtracks/hades/01%20-%20Good%20Riddance.mp3"></audio> +`; + +function textResponse(text: string, status = 200): FetchResponse { + const chunks = [new TextEncoder().encode(text)]; + let index = 0; + return { + ok: status >= 200 && status < 300, + status, + headers: { get: () => null }, + body: { + getReader: () => ({ + read: async () => { + if (index >= chunks.length) return { done: true }; + const value = chunks[index]!; + index += 1; + return { done: false, value }; + }, + cancel: async () => undefined, + }), + }, + }; +} + +function providerOf(routes: (url: string) => FetchResponse): KhinsiderProvider { + const fetch = vi.fn(async (url: string) => routes(url)); + return new KhinsiderProvider({ http: new HttpClient({ fetch, userAgent: 'Playhook/test' }) }); +} + +describe('khinsider parsing', () => { + describe('albums', () => { + it('lists each album once, decoding its title', () => { + expect(parseAlbums(SEARCH_PAGE)).toEqual([ + { key: 'hades-original-soundtrack', title: 'Hades & Friends OST' }, + { key: 'hades-ii', title: 'Hades II' }, + ]); + }); + + it('skips a link whose text is an image rather than a title', () => { + const albums = parseAlbums('<a href="/game-soundtracks/album/x"><img src="y.jpg"></a>'); + expect(albums).toEqual([]); + }); + + it('answers with nothing at all for a page it does not recognize', () => { + expect(parseAlbums('<html><body>Nothing here</body></html>')).toEqual([]); + }); + }); + + describe('tracks', () => { + const tracks = parseTracks(ALBUM_PAGE, 'hades-original-soundtrack'); + + it('reads the track name and builds its page url', () => { + expect(tracks[0]).toEqual({ + key: trackKey('hades-original-soundtrack', '01%20-%20Good%20Riddance.mp3'), + title: 'Good Riddance', + sizeBytes: 4655677, + pageUrl: + 'https://downloads.khinsider.com/game-soundtracks/album/hades-original-soundtrack/01%20-%20Good%20Riddance.mp3', + }); + }); + + it('falls back to the file name when the link carries no text', () => { + expect(tracks[1]?.title).toBe('02 - No Escape.mp3'); + }); + + it('reads the size stated on the row', () => { + expect(tracks[1]?.sizeBytes).toBe(781312); + }); + + it('ignores links belonging to another album', () => { + const mixed = `${ALBUM_PAGE}<a href="/game-soundtracks/album/other-album/01.mp3">Other</a>`; + expect(parseTracks(mixed, 'hades-original-soundtrack')).toHaveLength(2); + }); + + it('yields nothing for a page whose markup moved on', () => { + expect(parseTracks('<div>Album unavailable</div>', 'hades-original-soundtrack')).toEqual([]); + }); + + // A row states the same file FOUR times — name, length, mp3 size, flac size — and every cell is a + // link to it. Reading "a link, then some text, then the end of the row" walked over the first three + // closing tags and produced a title with the sizes glued onto it (measured on TUNIC's gamerip). + it('takes the name from its own cell, not from the whole row', () => { + const row = `<tr> + <td class="clickable-row"><a href="/game-soundtracks/album/tunic/001.mp3">Waterfall</a></td> + <td class="clickable-row" align="right"><a href="/game-soundtracks/album/tunic/001.mp3" style="font-weight:normal;">2:20</a></td> + <td class="clickable-row" align="right"><a href="/game-soundtracks/album/tunic/001.mp3" style="font-weight:normal;">4.05 MB</a></td> + <td class="clickable-row" align="right"><a href="/game-soundtracks/album/tunic/001.mp3" style="font-weight:normal;">7.58 MB</a></td> + </tr>`; + const parsed = parseTracks(row, 'tunic'); + expect(parsed).toHaveLength(1); + expect(parsed[0]?.title).toBe('Waterfall'); + expect(parsed[0]?.sizeBytes).toBe(Math.round(4.05 * 1024 * 1024)); + }); + + // A gamerip lists everything the game ships with (TUNIC: 4244 rows), and every row becomes a button. + it('stops at a few hundred tracks rather than building a list nobody can scroll', () => { + const rows = Array.from( + { length: 400 }, + (_, index) => + `<tr><td><a href="/game-soundtracks/album/big/${index}.mp3">Track ${index}</a></td></tr>`, + ).join(''); + expect(parseTracks(rows, 'big')).toHaveLength(300); + }); + }); + + describe('sizes', () => { + it('reads KB, MB and GB', () => { + expect(parseSize('4.44 MB')).toBe(4655677); + expect(parseSize('763 KB')).toBe(781312); + expect(parseSize('1.5 GB')).toBe(1610612736); + }); + + it('answers undefined when there is no figure to read', () => { + expect(parseSize('3:12')).toBeUndefined(); + expect(parseSize('')).toBeUndefined(); + }); + }); + + describe('the audio url', () => { + it('takes the direct link off a track page', () => { + expect(parseAudioUrl(TRACK_PAGE)).toBe( + 'https://vgmsite.com/soundtracks/hades/01%20-%20Good%20Riddance.mp3', + ); + }); + + it('finds nothing on a page that carries no audio file', () => { + expect(parseAudioUrl('<p>Track not found</p>')).toBeUndefined(); + expect(parseAudioUrl('<a href="https://example.test/page.html">x</a>')).toBeUndefined(); + }); + }); + + describe('track keys', () => { + it('round-trips an album and file through a key', () => { + expect(parseTrackKey(trackKey('album', 'file.mp3'))).toEqual({ + album: 'album', + file: 'file.mp3', + }); + }); + + it("does not claim another provider's key", () => { + expect(parseTrackKey('sgdb:art:81')).toBeUndefined(); + }); + }); + + describe('urls', () => { + it('escapes the search term and names the album path', () => { + expect(searchUrl('Hades II')).toBe( + 'https://downloads.khinsider.com/search?search=Hades%20II', + ); + expect(albumUrl('hades-ii')).toBe( + 'https://downloads.khinsider.com/game-soundtracks/album/hades-ii', + ); + }); + }); +}); + +describe('khinsider provider', () => { + it('searches, lists and resolves in the three hops the site forces', async () => { + const provider = providerOf((url) => { + if (url.includes('/search')) return textResponse(SEARCH_PAGE); + if (url.endsWith('.mp3')) return textResponse(TRACK_PAGE); + return textResponse(ALBUM_PAGE); + }); + const albums = await provider.musicSearch('hades'); + expect(albums.ok === true && albums.value[0]?.key).toBe('hades-original-soundtrack'); + const tracks = await provider.musicTracks('hades-original-soundtrack'); + expect(tracks.ok === true && tracks.value).toHaveLength(2); + const first = tracks.ok === true ? tracks.value[0] : undefined; + const audio = first === undefined ? null : await provider.musicTrackUrl(first); + expect(audio?.ok === true && audio.value).toContain('vgmsite.com'); + }); + + it('reports a track page with no audio link as a failure, not as silence', async () => { + const provider = providerOf(() => textResponse('<p>gone</p>')); + const result = await provider.musicTrackUrl({ + key: trackKey('a', 'b.mp3'), + title: 'b', + pageUrl: 'https://downloads.khinsider.com/game-soundtracks/album/a/b.mp3', + }); + expect(result.ok).toBe(false); + }); + + it('passes an HTTP failure through instead of pretending the album is empty', async () => { + const provider = providerOf(() => textResponse('', 503)); + const result = await provider.musicTracks('hades-ii'); + expect(result.ok).toBe(false); + }); +}); diff --git a/test/metadata-search-title.test.ts b/test/metadata-search-title.test.ts new file mode 100644 index 00000000..ec689684 --- /dev/null +++ b/test/metadata-search-title.test.ts @@ -0,0 +1,43 @@ +// The title cleanup the word-matching sources need. Measured against live answers for Watch_Dogs™: +// with the marks in place Wallhaven and Wallpaper Cave found nothing and Khinsider found other games. +import { describe, expect, it } from 'vitest'; +import { searchableTitle } from '../src/main/metadata/search-title'; +import { searchTerms } from '../src/main/metadata/wallhaven'; +import { searchUrl as khinsiderSearchUrl } from '../src/main/metadata/khinsider'; + +describe('searchable title', () => { + it('drops the trademark marks publishers put in a name', () => { + expect(searchableTitle('Watch_Dogs™')).toBe('Watch Dogs'); + expect(searchableTitle('Watch_Dogs® 2')).toBe('Watch Dogs 2'); + expect(searchableTitle('PAYDAY 2©')).toBe('PAYDAY 2'); + }); + + it('reads an underscore as the space it stands for', () => { + expect(searchableTitle('Watch_Dogs')).toBe('Watch Dogs'); + }); + + it('leaves a title that needs nothing exactly as it was', () => { + expect(searchableTitle('The Witcher 3: Wild Hunt')).toBe('The Witcher 3: Wild Hunt'); + expect(searchableTitle('Ведьмак 3')).toBe('Ведьмак 3'); + }); + + it('collapses what the removals leave behind', () => { + expect(searchableTitle('Hades ™ ')).toBe('Hades'); + expect(searchableTitle(' ')).toBe(''); + }); +}); + +describe('the sources that match on words use it', () => { + it('wallhaven searches for the cleaned title, and trims editions from that', () => { + expect(searchTerms('Watch_Dogs™')).toEqual(['Watch Dogs']); + expect(searchTerms('Watch_Dogs® 2 - Gold Edition')).toEqual([ + 'Watch Dogs 2 - Gold Edition', + 'Watch Dogs 2', + 'Watch Dogs 2 Gold Edition', + ]); + }); + + it('khinsider searches for the cleaned title', () => { + expect(khinsiderSearchUrl('Watch_Dogs™')).toContain('search=Watch%20Dogs'); + }); +}); diff --git a/test/metadata-steam.test.ts b/test/metadata-steam.test.ts new file mode 100644 index 00000000..84d3c3d0 --- /dev/null +++ b/test/metadata-steam.test.ts @@ -0,0 +1,418 @@ +// Steam provider: URL building, answer parsing and description sanitizing. Fixtures only — the HTTP +// client is faked, so the unofficial endpoints are never actually called from a test. +import { describe, expect, it, vi } from 'vitest'; +import { HttpClient, type FetchInit, type FetchResponse } from '../src/main/metadata/http'; +import { + SteamProvider, + appDetailsUrl, + libraryGridUrl, + sanitizeDescription, + toAppArt, + toDetails, + toIsoDate, + toPlatforms, + steamAppIdFromKey, + steamCandidateKey, + storeSearchUrl, + toCandidates, +} from '../src/main/metadata/steam'; + +/** What a page request looks like now: the page, plus the size floor the sidebar's filter sets. */ +function pageRequest( + page = 0, + minSize = { width: 0, height: 0 }, +): { + readonly page: number; + readonly minSize: { readonly width: number; readonly height: number }; +} { + return { page, minSize }; +} + +const SEARCH_FIXTURE = JSON.stringify({ + total: 3, + items: [ + { type: 'app', name: 'Half-Life 2', id: 220 }, + { type: 'app', name: 'Half-Life 2: Episode One', id: 380 }, + { type: 'bundle', name: 'Half-Life Collection', id: 999 }, + ], +}); + +const ART_DETAILS = JSON.stringify({ + '220': { + success: true, + data: { + name: 'Half-Life 2', + background_raw: 'https://cdn.test/220/page-bg.jpg', + screenshots: [ + { + id: 1, + path_thumbnail: 'https://cdn.test/220/shot1-thumb.jpg', + path_full: 'https://cdn.test/220/shot1.1920x1080.jpg', + }, + { + id: 2, + path_thumbnail: 'https://cdn.test/220/shot2-thumb.jpg', + path_full: 'https://cdn.test/220/shot2.1920x1080.jpg', + }, + ], + }, + }, +}); + +const DETAILS_EN = JSON.stringify({ + '220': { + success: true, + data: { name: 'Half-Life 2', short_description: '<strong>1998.</strong> A war.' }, + }, +}); +const DETAILS_RU = JSON.stringify({ + '220': { success: true, data: { name: 'Half-Life 2', short_description: 'Война.' } }, +}); + +function textResponse(text: string, status = 200): FetchResponse { + const chunks = [new TextEncoder().encode(text)]; + let index = 0; + return { + ok: status >= 200 && status < 300, + status, + headers: { get: () => null }, + body: { + getReader: () => ({ + read: async () => { + if (index >= chunks.length) return { done: true }; + const value = chunks[index]!; + index += 1; + return { done: false, value }; + }, + cancel: async () => undefined, + }), + }, + }; +} + +function providerOf( + routes: (url: string, init?: FetchInit) => FetchResponse, + locale: 'en' | 'ru' = 'en', +): SteamProvider { + const http = new HttpClient({ + fetch: async (url, init) => routes(url, init), + userAgent: 'Playhook/test', + }); + return new SteamProvider({ http, locale: () => locale }); +} + +describe('steam metadata provider', () => { + describe('urls', () => { + it('searches with the English store parameters by default', () => { + expect(storeSearchUrl('Half-Life 2', 'en')).toBe( + 'https://store.steampowered.com/api/storesearch/?term=Half-Life%202&l=english&cc=US', + ); + }); + + it('searches in Russian for a Russian UI, so Russian titles are findable', () => { + expect(storeSearchUrl('Мор', 'ru')).toBe( + 'https://store.steampowered.com/api/storesearch/?term=%D0%9C%D0%BE%D1%80&l=russian&cc=US', + ); + }); + + // `cc` decides what the store admits EXISTS, not what language it speaks: from a region where a game + // is not sold, the search omits it and appdetails answers success:false with no artwork. Following + // the UI language there cost the user every game their own region does not carry. + it('always asks from one store region, whatever the interface language', () => { + for (const locale of ['en', 'ru'] as const) { + expect(storeSearchUrl('Atomfall', locale)).toContain('cc=US'); + expect(appDetailsUrl(801800, locale)).toContain('cc=US'); + } + }); + + it('builds the appdetails url per language', () => { + expect(appDetailsUrl(220, 'ru')).toBe( + 'https://store.steampowered.com/api/appdetails?appids=220&l=russian&cc=US', + ); + }); + + it('builds the CDN cover urls from the appid', () => { + expect(libraryGridUrl(220)).toBe( + 'https://cdn.cloudflare.steamstatic.com/steam/apps/220/library_600x900.jpg', + ); + expect(libraryGridUrl(220, true)).toBe( + 'https://cdn.cloudflare.steamstatic.com/steam/apps/220/library_600x900_2x.jpg', + ); + }); + }); + + describe('candidate keys', () => { + it('round-trips an appid through its key', () => { + expect(steamAppIdFromKey(steamCandidateKey(220))).toBe(220); + }); + + it("does not claim another provider's key", () => { + expect(steamAppIdFromKey('sgdb:1234')).toBeUndefined(); + expect(steamAppIdFromKey('steam:not-a-number')).toBeUndefined(); + }); + }); + + describe('search', () => { + it('keeps apps and drops non-app store entries', () => { + const candidates = toCandidates([ + { id: 220, name: 'Half-Life 2', type: 'app' }, + { id: 999, name: 'Bundle', type: 'bundle' }, + ]); + expect(candidates).toEqual([ + { key: 'steam:220', title: 'Half-Life 2', provider: 'steam', steamAppId: 220 }, + ]); + }); + + it('parses a real-shaped storesearch answer', async () => { + const provider = providerOf(() => textResponse(SEARCH_FIXTURE)); + const result = await provider.search('half-life'); + expect(result.ok === true && result.value.map((c) => c.steamAppId)).toEqual([220, 380]); + }); + + it('reports a broken answer as a failure instead of throwing', async () => { + const provider = providerOf(() => textResponse('<html>maintenance</html>')); + const result = await provider.search('half-life'); + expect(result.ok).toBe(false); + }); + }); + + describe('artwork', () => { + it('offers only the variants whose full-size file exists', async () => { + const provider = providerOf((url, init) => { + if (init?.method === 'HEAD') { + return textResponse('', url.includes('_2x') ? 404 : 200); + } + return textResponse('{}'); + }); + const result = await provider.artwork( + { key: 'steam:220', title: 'Half-Life 2', steamAppId: 220 }, + 'grid', + pageRequest(), + ); + expect(result.ok === true && result.value.offers.map((v) => v.key)).toEqual([ + 'steam:220:grid', + ]); + }); + + it('offers the store backdrop first, then the screenshots in the order Steam lists them', async () => { + const provider = providerOf(() => textResponse(ART_DETAILS)); + const result = await provider.artwork( + { key: 'steam:220', title: 'HL2', steamAppId: 220 }, + 'hero', + pageRequest(), + ); + expect(result.ok === true && result.value.offers.map((v) => v.key)).toEqual([ + 'steam:220:backdrop', + 'steam:220:shot-1', + 'steam:220:shot-2', + ]); + }); + + it("takes a screenshot's thumbnail for the grid and its full size for the download", async () => { + const provider = providerOf(() => textResponse(ART_DETAILS)); + const result = await provider.artwork( + { key: 'steam:220', title: 'HL2', steamAppId: 220 }, + 'hero', + pageRequest(), + ); + expect(result.ok === true && result.value.offers[1]).toMatchObject({ + thumbUrl: 'https://cdn.test/220/shot1-thumb.jpg', + fullUrl: 'https://cdn.test/220/shot1.1920x1080.jpg', + }); + }); + + it('states no dimensions for a screenshot — the path says nothing about the real size', async () => { + const provider = providerOf(() => textResponse(ART_DETAILS)); + const result = await provider.artwork( + { key: 'steam:220', title: 'HL2', steamAppId: 220 }, + 'hero', + pageRequest(), + ); + const shot = result.ok === true ? result.value.offers[1] : undefined; + expect(shot).not.toHaveProperty('width'); + expect(shot).not.toHaveProperty('height'); + }); + + it('never checks a background for existence — the URL came from the answer itself', async () => { + const fetch = vi.fn(async (_url: string, init?: FetchInit) => { + expect(init?.method).not.toBe('HEAD'); + return textResponse(ART_DETAILS); + }); + const http = new HttpClient({ fetch, userAgent: 'Playhook/test' }); + const provider = new SteamProvider({ http, locale: () => 'en' }); + await provider.artwork( + { key: 'steam:220', title: 'HL2', steamAppId: 220 }, + 'hero', + pageRequest(), + ); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('has no backgrounds for a delisted app, whose appdetails answers success:false', async () => { + const provider = providerOf(() => textResponse('{"220":{"success":false}}')); + const result = await provider.artwork( + { key: 'steam:220', title: 'HL2', steamAppId: 220 }, + 'hero', + pageRequest(), + ); + expect(result).toEqual({ ok: true, value: { offers: [], hasMore: false } }); + }); + + it('asks appdetails once per app, however often the gallery is opened', async () => { + let detailCalls = 0; + const provider = providerOf(() => { + detailCalls += 1; + return textResponse(ART_DETAILS); + }); + const ref = { key: 'steam:220', title: 'HL2', steamAppId: 220 }; + await provider.artwork(ref, 'hero', pageRequest()); + await provider.artwork(ref, 'hero', pageRequest()); + expect(detailCalls).toBe(1); + }); + + it('has nothing to offer for a candidate that is not a Steam app', async () => { + const provider = providerOf(() => textResponse('', 200)); + const result = await provider.artwork( + { key: 'sgdb:7', title: 'Some game' }, + 'grid', + pageRequest(), + ); + expect(result).toEqual({ ok: true, value: { offers: [], hasMore: false } }); + }); + }); + + describe('the facts kept for a future library view', () => { + // The store writes the month first with `cc=US`, which is what this app asks with — and day-first + // elsewhere. Both are read: a date that does not parse is dropped SILENTLY, so getting the order + // wrong loses the field without a single error to notice. + it('normalizes the English store date to ISO, whichever order it comes in', () => { + expect(toIsoDate('Sep 17, 2020')).toBe('2020-09-17'); + expect(toIsoDate('Nov 16, 2004')).toBe('2004-11-16'); + expect(toIsoDate('17 Sep, 2020')).toBe('2020-09-17'); + expect(toIsoDate('1 Jan, 1998')).toBe('1998-01-01'); + expect(toIsoDate('Sep 2020')).toBe('2020-09'); + expect(toIsoDate('2020')).toBe('2020'); + }); + + it('states no date rather than a guessed one', () => { + expect(toIsoDate('Coming soon')).toBeUndefined(); + expect(toIsoDate('Q4 2026')).toBeUndefined(); + expect(toIsoDate(undefined)).toBeUndefined(); + }); + + it('lists only the platforms the store flags', () => { + expect(toPlatforms({ windows: true, mac: false, linux: true })).toEqual(['windows', 'linux']); + expect(toPlatforms({ windows: false })).toBeUndefined(); + expect(toPlatforms(undefined)).toBeUndefined(); + }); + + it('carries genres, date and platforms out of one answer', () => { + const details = toDetails( + { en: 'A game.' }, + { + genres: [{ description: 'Action' }, { description: 'Roguelike' }], + release_date: { date: 'Sep 17, 2020' }, + platforms: { windows: true, mac: true, linux: false }, + }, + ); + expect(details).toEqual({ + description: { en: 'A game.' }, + genres: ['Action', 'Roguelike'], + releaseDate: '2020-09-17', + platforms: ['windows', 'mac'], + }); + }); + + it('leaves out what the store did not state, rather than storing empties', () => { + expect(toDetails({}, undefined)).toEqual({}); + expect(toDetails({}, { genres: [] })).toEqual({}); + }); + }); + + describe('reading the art fields of an appdetails answer', () => { + const answer = JSON.parse(ART_DETAILS) as Parameters<typeof toAppArt>[0]; + + it('takes the backdrop and every screenshot that has a full size', () => { + const art = toAppArt(answer, 220); + expect(art.backdrop).toBe('https://cdn.test/220/page-bg.jpg'); + expect(art.screenshots.map((shot) => shot.id)).toEqual([1, 2]); + }); + + it('falls back to the full size when a screenshot states no thumbnail', () => { + const noThumb = { + '220': { success: true, data: { screenshots: [{ id: 5, path_full: 'f.jpg' }] } }, + }; + expect(toAppArt(noThumb, 220).screenshots[0]).toEqual({ + id: 5, + thumb: 'f.jpg', + full: 'f.jpg', + }); + }); + + it('drops a screenshot with no full size — that is the picture apply would download', () => { + const noFull = { + '220': { success: true, data: { screenshots: [{ id: 5, path_thumbnail: 't.jpg' }] } }, + }; + expect(toAppArt(noFull, 220).screenshots).toEqual([]); + }); + + it('reads nothing at all out of an unsuccessful answer', () => { + expect(toAppArt({ '220': { success: false } }, 220)).toEqual({ screenshots: [] }); + }); + }); + + describe('descriptions', () => { + it('strips store markup, decodes entities and collapses whitespace', () => { + expect(sanitizeDescription('<p>Hello <br> <b>world</b> & friends</p>')).toBe( + 'Hello world & friends', + ); + }); + + it('cuts an overlong description on a word boundary', () => { + const long = `${'word '.repeat(600)}tail`; + const cut = sanitizeDescription(long); + expect(cut.length).toBeLessThanOrEqual(2000); + expect(cut.endsWith('word')).toBe(true); + }); + + it('fetches both languages and returns them side by side', async () => { + const provider = providerOf((url) => + textResponse(url.includes('russian') ? DETAILS_RU : DETAILS_EN), + ); + const result = await provider.details({ + key: 'steam:220', + title: 'HL2', + steamAppId: 220, + }); + expect(result.ok === true && result.value.description).toEqual({ + en: '1998. A war.', + ru: 'Война.', + }); + }); + + it('keeps the language that answered when the other one fails', async () => { + const provider = providerOf((url) => + url.includes('russian') ? textResponse('', 500) : textResponse(DETAILS_EN), + ); + const result = await provider.details({ + key: 'steam:220', + title: 'HL2', + steamAppId: 220, + }); + expect(result.ok === true && result.value.description).toEqual({ en: '1998. A war.' }); + }); + + it('omits a language Steam has no text for', async () => { + const empty = JSON.stringify({ '220': { success: false } }); + const provider = providerOf((url) => + textResponse(url.includes('russian') ? empty : DETAILS_EN), + ); + const result = await provider.details({ + key: 'steam:220', + title: 'HL2', + steamAppId: 220, + }); + expect(result.ok === true && result.value.description).toEqual({ en: '1998. A war.' }); + }); + }); +}); diff --git a/test/metadata-steamgriddb.test.ts b/test/metadata-steamgriddb.test.ts new file mode 100644 index 00000000..e26c8e2c --- /dev/null +++ b/test/metadata-steamgriddb.test.ts @@ -0,0 +1,201 @@ +// SteamGridDB provider: url building, answer parsing, and the "no key → no source" rule. +import { describe, expect, it, vi, type Mock } from 'vitest'; +import { HttpClient, type FetchInit, type FetchResponse } from '../src/main/metadata/http'; +import { + SteamGridDbProvider, + autocompleteUrl, + coversUrl, + sgdbCandidateKey, + sgdbGameIdFromKey, + toArtworkOffers, +} from '../src/main/metadata/steamgriddb'; + +/** What a page request looks like now: the page, plus the size floor the sidebar's filter sets. */ +function pageRequest( + page = 0, + minSize = { width: 0, height: 0 }, +): { + readonly page: number; + readonly minSize: { readonly width: number; readonly height: number }; +} { + return { page, minSize }; +} + +const SEARCH_FIXTURE = JSON.stringify({ + success: true, + data: [ + { id: 5250, name: 'Hollow Knight' }, + { id: 5251, name: 'Hollow Knight: Silksong' }, + ], +}); + +const GRIDS_FIXTURE = JSON.stringify({ + success: true, + data: [ + { + id: 81, + url: 'https://cdn.test/grid-81.png', + thumb: 'https://cdn.test/t81.jpg', + width: 600, + height: 900, + }, + { id: 82, url: 'https://cdn.test/grid-82.png', thumb: 'https://cdn.test/t82.jpg' }, + ], +}); + +function textResponse(text: string, status = 200): FetchResponse { + const chunks = [new TextEncoder().encode(text)]; + let index = 0; + return { + ok: status >= 200 && status < 300, + status, + headers: { get: () => null }, + body: { + getReader: () => ({ + read: async () => { + if (index >= chunks.length) return { done: true }; + const value = chunks[index]!; + index += 1; + return { done: false, value }; + }, + cancel: async () => undefined, + }), + }, + }; +} + +function providerOf( + key: string, + routes: (url: string, init?: FetchInit) => FetchResponse = () => textResponse('{}'), +): { + provider: SteamGridDbProvider; + fetch: Mock<(url: string, init?: FetchInit) => Promise<FetchResponse>>; +} { + const fetch = vi.fn(async (url: string, init?: FetchInit) => routes(url, init)); + const http = new HttpClient({ fetch, userAgent: 'Playhook/test' }); + return { provider: new SteamGridDbProvider({ http, apiKey: () => key }), fetch }; +} + +describe('steamgriddb metadata provider', () => { + describe('urls', () => { + it('escapes the search term', () => { + expect(autocompleteUrl('Hollow Knight')).toBe( + 'https://www.steamgriddb.com/api/v2/search/autocomplete/Hollow%20Knight', + ); + }); + + it("asks for covers in the launcher's own geometry", () => { + expect(coversUrl({ kind: 'game', id: 5250 })).toBe( + 'https://www.steamgriddb.com/api/v2/grids/game/5250?dimensions=600x900', + ); + }); + + it('addresses a Steam candidate by its appid, with no extra lookup', () => { + expect(coversUrl({ kind: 'steam', id: 220 })).toBe( + 'https://www.steamgriddb.com/api/v2/grids/steam/220?dimensions=600x900', + ); + }); + + it('round-trips a game id through its key', () => { + expect(sgdbGameIdFromKey(sgdbCandidateKey(5250))).toBe(5250); + expect(sgdbGameIdFromKey('steam:220')).toBeUndefined(); + }); + }); + + describe('without a key', () => { + it('reports itself unavailable', () => { + expect(providerOf(' ').provider.available()).toBe(false); + expect(providerOf('abc').provider.available()).toBe(true); + }); + + it('answers an empty search without making a request at all', async () => { + const { provider, fetch } = providerOf(''); + await expect(provider.search('anything')).resolves.toEqual({ ok: true, value: [] }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('offers no artwork and makes no request', async () => { + const { provider, fetch } = providerOf(''); + await expect( + provider.artwork({ key: 'sgdb:1', title: 'x' }, 'grid', pageRequest()), + ).resolves.toEqual({ + ok: true, + value: { offers: [], hasMore: false }, + }); + expect(fetch).not.toHaveBeenCalled(); + }); + }); + + describe('with a key', () => { + it("authorizes with the user's key", async () => { + const { provider, fetch } = providerOf('secret', () => textResponse(SEARCH_FIXTURE)); + await provider.search('hollow'); + expect(fetch.mock.calls[0]?.[1]?.headers).toMatchObject({ Authorization: 'Bearer secret' }); + }); + + it('parses the autocomplete answer into candidates', async () => { + const { provider } = providerOf('secret', () => textResponse(SEARCH_FIXTURE)); + const result = await provider.search('hollow'); + expect(result.ok === true && result.value[0]).toEqual({ + key: 'sgdb:5250', + title: 'Hollow Knight', + provider: 'steamgriddb', + }); + }); + + it('turns art rows into offers, keeping the dimensions the source states', () => { + const offers = toArtworkOffers([ + { + id: 81, + url: 'https://cdn.test/a.png', + thumb: 'https://cdn.test/t.jpg', + width: 600, + height: 900, + }, + { id: 82, url: 'https://cdn.test/b.png', thumb: 'https://cdn.test/u.jpg' }, + ]); + expect(offers[0]).toEqual({ + key: 'sgdb:art:81', + kind: 'grid', + provider: 'steamgriddb', + width: 600, + height: 900, + thumbUrl: 'https://cdn.test/t.jpg', + fullUrl: 'https://cdn.test/a.png', + }); + expect(offers[1]).not.toHaveProperty('width'); + }); + + it('offers no backgrounds at all — its heroes are banners, not full-screen art', async () => { + const { provider, fetch } = providerOf('secret', () => textResponse(GRIDS_FIXTURE)); + const result = await provider.artwork( + { key: 'steam:220', title: 'HL2', steamAppId: 220 }, + 'hero', + pageRequest(), + ); + expect(result).toEqual({ ok: true, value: { offers: [], hasMore: false } }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('fetches art for a Steam candidate through the steam endpoint', async () => { + const { provider, fetch } = providerOf('secret', () => textResponse(GRIDS_FIXTURE)); + const result = await provider.artwork( + { key: 'steam:220', title: 'HL2', steamAppId: 220 }, + 'grid', + pageRequest(), + ); + expect(fetch.mock.calls[0]?.[0]).toContain('/grids/steam/220'); + expect(result.ok === true && result.value.offers).toHaveLength(2); + }); + + it('reports a rejected key as a failure rather than as an empty gallery', async () => { + const { provider } = providerOf('bad', () => textResponse('{"success":false}', 401)); + const result = await provider.artwork( + { key: 'sgdb:5250', title: 'HK' }, + 'grid', + pageRequest(), + ); + expect(result.ok).toBe(false); + }); + }); +}); diff --git a/test/metadata-wallhaven.test.ts b/test/metadata-wallhaven.test.ts new file mode 100644 index 00000000..5d79f860 --- /dev/null +++ b/test/metadata-wallhaven.test.ts @@ -0,0 +1,343 @@ +// Wallhaven provider: the search parameters, the edition-tail cascade that keeps an AND-search from +// coming back empty, and the file-size filter. Fixtures only — no test reaches wallhaven.cc. +import { describe, expect, it, vi } from 'vitest'; +import { HttpClient, type FetchResponse } from '../src/main/metadata/http'; +import { + WallhavenProvider, + hasMorePages, + isLatinTitle, + searchTerms, + searchUrl, + toArtworkOffers, + withoutEditionTail, +} from '../src/main/metadata/wallhaven'; + +/** What a page request looks like now: the page, plus the size floor the sidebar's filter sets. */ +function pageRequest( + page = 0, + minSize = { width: 0, height: 0 }, +): { + readonly page: number; + readonly minSize: { readonly width: number; readonly height: number }; +} { + return { page, minSize }; +} + +const RESULTS = JSON.stringify({ + data: [ + { + id: 'abc123', + path: 'https://w.wallhaven.cc/full/ab/wallhaven-abc123.jpg', + file_size: 2_400_000, + dimension_x: 3840, + dimension_y: 2160, + thumbs: { small: 'https://th.wallhaven.cc/small/ab/abc123.jpg' }, + }, + { + id: 'def456', + path: 'https://w.wallhaven.cc/full/de/wallhaven-def456.png', + file_size: 40_000_000, + dimension_x: 3840, + dimension_y: 2160, + thumbs: { small: 'https://th.wallhaven.cc/small/de/def456.jpg' }, + }, + ], +}); + +const EMPTY = JSON.stringify({ data: [] }); + +function textResponse(text: string, status = 200): FetchResponse { + const chunks = [new TextEncoder().encode(text)]; + let index = 0; + return { + ok: status >= 200 && status < 300, + status, + headers: { get: () => null }, + body: { + getReader: () => ({ + read: async () => { + if (index >= chunks.length) return { done: true }; + const value = chunks[index]!; + index += 1; + return { done: false, value }; + }, + cancel: async () => undefined, + }), + }, + }; +} + +/** `english: null` stands for "no English name is known" — undefined would take the default. */ +function providerOf( + routes: (url: string) => FetchResponse, + english: string | null = 'Hades', +): { provider: WallhavenProvider; fetch: ReturnType<typeof vi.fn> } { + const fetch = vi.fn(async (url: string) => routes(url)); + const http = new HttpClient({ fetch, userAgent: 'Playhook/test' }); + return { + provider: new WallhavenProvider({ http, englishTitle: () => english ?? undefined }), + fetch, + }; +} + +/** The `q` a request was made with, so a test can assert the cascade rather than a whole URL. */ +function queriesOf(fetch: ReturnType<typeof vi.fn>): string[] { + return fetch.mock.calls.map((call) => { + const url = new URL(String(call[0])); + return url.searchParams.get('q') ?? ''; + }); +} + +describe('wallhaven search parameters', () => { + const params = new URL(searchUrl('Hades')).searchParams; + + it('asks for SFW general and anime wallpapers, with people switched off', () => { + expect(params.get('categories')).toBe('110'); + expect(params.get('purity')).toBe('100'); + }); + + it('asks for landscape wallpapers of at least 1080p', () => { + expect(params.get('atleast')).toBe('1920x1080'); + }); + + // The endpoint matches listed ratios EXACTLY: '16x9,16x10' dropped a 4096x2286 wallpaper for being + // 1.79 instead of 1.78, which cost most of the choice for anything but the most photographed games. + it('asks for landscape as a shape, not as a list of exact ratios', () => { + expect(params.get('ratios')).toBe('landscape'); + }); + + it('sorts by relevance, so the gallery keeps its order within a session', () => { + expect(params.get('sorting')).toBe('relevance'); + }); + + it('escapes the query', () => { + expect(new URL(searchUrl('The Witcher 3: Wild Hunt')).searchParams.get('q')).toBe( + 'The Witcher 3: Wild Hunt', + ); + }); +}); + +describe('wallhaven paging', () => { + it('counts pages from one, where the endpoint does', () => { + expect(new URL(searchUrl('Hades')).searchParams.get('page')).toBe('1'); + expect(new URL(searchUrl('Hades', 2)).searchParams.get('page')).toBe('3'); + }); + + it('offers another page only when the answer says one exists', () => { + expect(hasMorePages({ current_page: 1, last_page: 7 })).toBe(true); + expect(hasMorePages({ current_page: 7, last_page: 7 })).toBe(false); + expect(hasMorePages(undefined)).toBe(false); + }); +}); + +describe('wallhaven edition tails', () => { + it('cuts the edition markers Steam titles carry', () => { + expect(withoutEditionTail('The Witcher 3: Wild Hunt - Complete Edition')).toBe( + 'The Witcher 3: Wild Hunt', + ); + expect(withoutEditionTail('Disco Elysium - The Final Cut')).toBe('Disco Elysium'); + expect(withoutEditionTail('Dark Souls Remastered')).toBe('Dark Souls'); + expect(withoutEditionTail('Skyrim Game of the Year Edition')).toBe('Skyrim'); + }); + + it('keeps a subtitle that is part of the name', () => { + expect(withoutEditionTail('The Witcher 3: Wild Hunt')).toBe('The Witcher 3: Wild Hunt'); + expect(withoutEditionTail('Hades')).toBe('Hades'); + }); + + it('never cuts a title down to nothing', () => { + expect(withoutEditionTail('Remastered')).toBe('Remastered'); + expect(withoutEditionTail('Final Fantasy')).toBe('Final Fantasy'); + }); + + it('offers the full title first and the trimmed one as a fallback', () => { + expect(searchTerms('Disco Elysium - The Final Cut')).toEqual([ + 'Disco Elysium - The Final Cut', + 'Disco Elysium', + 'Disco Elysium The Final Cut', + ]); + }); + + it('keeps the part before a subtitle, then the words alone, as the last resorts', () => { + expect(searchTerms('The Witcher 3: Wild Hunt')).toEqual([ + 'The Witcher 3: Wild Hunt', + 'The Witcher 3', + 'The Witcher 3 Wild Hunt', + ]); + }); + + // Punctuation is not noise to these sites (F.E.A.R. finds 12 wallpapers, "F E A R" none), so the + // stripped form is tried LAST — where the alternative is nothing at all, as with the apostrophe that + // sends Wallpaper Cave into a redirect loop. + it('offers the words alone once everything with punctuation has been tried', () => { + expect(searchTerms('F.E.A.R.')).toEqual(['F.E.A.R.', 'F E A R']); + }); + + // "Assassin's" belongs to the name, "Tom Clancy's" does not — and nothing here has to tell them apart, + // because the cascade tries the full title first and only walks on when it finds nothing. + it("drops the publisher's possessive, after the full title has had its turn", () => { + expect(searchTerms("Tom Clancy's Splinter Cell Chaos Theory")).toEqual([ + "Tom Clancy's Splinter Cell Chaos Theory", + 'Splinter Cell Chaos Theory', + 'Tom Clancys Splinter Cell Chaos Theory', + ]); + expect(searchTerms("Assassin's Creed Odyssey")).toEqual([ + "Assassin's Creed Odyssey", + 'Creed Odyssey', + 'Assassins Creed Odyssey', + ]); + }); + + it('offers a single term when there is nothing to trim', () => { + expect(searchTerms('Hades')).toEqual(['Hades']); + }); + + it('has nothing to search for an empty title', () => { + expect(searchTerms(' ')).toEqual([]); + }); +}); + +describe('wallhaven titles it can search at all', () => { + it('accepts Latin titles', () => { + expect(isLatinTitle('The Witcher 3')).toBe(true); + expect(isLatinTitle('Ōkami HD')).toBe(true); + }); + + it('rejects the scripts whose words its tags do not carry', () => { + expect(isLatinTitle('Ведьмак 3')).toBe(false); + expect(isLatinTitle('原神')).toBe(false); + }); +}); + +describe('wallhaven offers', () => { + it('takes the ready-made thumbnail and the full-size path, with the stated dimensions', () => { + const offers = toArtworkOffers([ + { + id: 'abc123', + path: 'https://w.wallhaven.cc/full/ab/wallhaven-abc123.jpg', + file_size: 2_400_000, + dimension_x: 3840, + dimension_y: 2160, + thumbs: { small: 'https://th.wallhaven.cc/small/ab/abc123.jpg' }, + }, + ]); + expect(offers[0]).toEqual({ + key: 'wallhaven:abc123', + kind: 'hero', + provider: 'wallhaven', + width: 3840, + height: 2160, + thumbUrl: 'https://th.wallhaven.cc/small/ab/abc123.jpg', + fullUrl: 'https://w.wallhaven.cc/full/ab/wallhaven-abc123.jpg', + }); + }); + + it('drops a wallpaper too heavy to apply, rather than offering a tile that would fail', () => { + const offers = toArtworkOffers( + (JSON.parse(RESULTS) as { readonly data: Parameters<typeof toArtworkOffers>[0] }).data, + ); + expect(offers.map((offer) => offer.key)).toEqual(['wallhaven:abc123']); + }); +}); + +describe('wallhaven provider', () => { + it('searches by the English name, not by the localized candidate title', async () => { + const { provider, fetch } = providerOf(() => textResponse(RESULTS), 'Hades'); + await provider.artwork( + { key: 'steam:1145360', title: 'Аид', steamAppId: 1145360 }, + 'hero', + pageRequest(), + ); + expect(queriesOf(fetch)).toEqual(['Hades']); + }); + + it('falls back to the candidate title when it is already Latin', async () => { + const { provider, fetch } = providerOf(() => textResponse(RESULTS), null); + await provider.artwork( + { key: 'gog:1', title: 'Hollow Knight', gogId: '1' }, + 'hero', + pageRequest(), + ); + expect(queriesOf(fetch)).toEqual(['Hollow Knight']); + }); + + it('does not search at all for a title it can only spell in another script', async () => { + const { provider, fetch } = providerOf(() => textResponse(RESULTS), null); + const result = await provider.artwork( + { key: 'steam:1', title: 'Ведьмак 3' }, + 'hero', + pageRequest(), + ); + expect(result).toEqual({ ok: true, value: { offers: [], hasMore: false } }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('retries without the edition tail when the full title finds nothing', async () => { + const { provider, fetch } = providerOf( + (url) => textResponse(url.includes('Complete') ? EMPTY : RESULTS), + 'The Witcher 3: Wild Hunt - Complete Edition', + ); + const result = await provider.artwork( + { key: 'steam:292030', title: 'x' }, + 'hero', + pageRequest(), + ); + expect(queriesOf(fetch)).toEqual([ + 'The Witcher 3: Wild Hunt - Complete Edition', + 'The Witcher 3: Wild Hunt', + ]); + expect(result.ok === true && result.value.offers).toHaveLength(1); + }); + + it('stops at the first term that finds something', async () => { + const { provider, fetch } = providerOf( + () => textResponse(RESULTS), + 'Disco Elysium - The Final Cut', + ); + await provider.artwork({ key: 'steam:632470', title: 'x' }, 'hero', pageRequest()); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('reports nothing found as an empty gallery, not as an error', async () => { + const { provider } = providerOf(() => textResponse(EMPTY), 'Some Niche Indie'); + expect(await provider.artwork({ key: 'steam:1', title: 'x' }, 'hero', pageRequest())).toEqual({ + ok: true, + value: { offers: [], hasMore: false }, + }); + }); + + it('reports a failing endpoint as a failure', async () => { + const { provider } = providerOf(() => textResponse('', 429), 'Hades'); + expect((await provider.artwork({ key: 'steam:1', title: 'x' }, 'hero', pageRequest())).ok).toBe( + false, + ); + }); + + it('pages through the term that answered, not through the cascade again', async () => { + const { provider, fetch } = providerOf( + (url) => textResponse(url.includes('Complete') ? EMPTY : RESULTS), + 'The Witcher 3: Wild Hunt - Complete Edition', + ); + const ref = { key: 'steam:292030', title: 'x' }; + await provider.artwork(ref, 'hero', pageRequest()); + fetch.mockClear(); + await provider.artwork(ref, 'hero', pageRequest(1)); + expect(queriesOf(fetch)).toEqual(['The Witcher 3: Wild Hunt']); + expect(new URL(String(fetch.mock.calls[0]?.[0])).searchParams.get('page')).toBe('2'); + }); + + it('reports a later page as the last one when the answer states no more', async () => { + const { provider } = providerOf(() => textResponse(RESULTS), 'Hades'); + const result = await provider.artwork({ key: 'steam:1', title: 'x' }, 'hero', pageRequest()); + expect(result.ok === true && result.value.hasMore).toBe(false); + }); + + it('offers no covers — it is a wallpaper source', async () => { + const { provider, fetch } = providerOf(() => textResponse(RESULTS), 'Hades'); + expect(await provider.artwork({ key: 'steam:1', title: 'x' }, 'grid', pageRequest())).toEqual({ + ok: true, + value: { offers: [], hasMore: false }, + }); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/test/metadata-wallpapercave.test.ts b/test/metadata-wallpapercave.test.ts new file mode 100644 index 00000000..d049bbaf --- /dev/null +++ b/test/metadata-wallpapercave.test.ts @@ -0,0 +1,387 @@ +// Wallpaper Cave provider: the two page shapes it must accept (a list of albums, and the album page a +// strong match redirects to), album selection, and the per-file filters read out of the markup. +// Fixtures only — no test reaches wallpapercave.com. +import { describe, expect, it, vi } from 'vitest'; +import { HttpClient, type FetchResponse } from '../src/main/metadata/http'; +import { + WallpaperCaveProvider, + absoluteUrl, + isMobileAlbum, + parseAlbums, + parseWallpapers, + rankAlbums, + searchUrl, + toArtworkOffers, + type CaveWallpaper, +} from '../src/main/metadata/wallpapercave'; + +/** What a page request looks like now: the page, plus the size floor the sidebar's filter sets. */ +function pageRequest( + page = 0, + minSize = { width: 0, height: 0 }, +): { + readonly page: number; + readonly minSize: { readonly width: number; readonly height: number }; +} { + return { page, minSize }; +} + +/** The search page as it comes back for an ambiguous query: album cards, each one anchor with a title. */ +const SEARCH_PAGE = ` +<div class="albumthumb"> + <a href="/atomfall-wallpapers" title="27 wallpapers in Atomfall" title="27 wallpapers in Atomfall"> + <img class="albumthumbimg" src="/uwp/wp1.jpg"> + </a> +</div> +<div class="albumthumb"> + <a href="/atomfall-phone-wallpapers" title="18 wallpapers in Atomfall Phone"></a> +</div> +<div class="albumthumb"> + <a href="/atomfall-fan-art-wallpapers" title="9 wallpapers in Atomfall Fan Art"></a> +</div> +<div class="albumthumb"> + <a href="/cellphone-atomfall-wallpapers" title="12 wallpapers in Cellphone Atomfall"></a> +</div> +<a href="/">Home</a> +`; + +/** An album page: every wallpaper at once, each stating its own size. */ +const ALBUM_PAGE = ` +<img class="wimg" src="/wp/wp111.webp" width="1920" height="1080" loading="lazy"> +<img class="wimg" src="/wp/wp222.jpg" width="2560" height="1440" loading="lazy"> +<img class="wimg" src="/wp/wp333.png" width="3840" height="2160" loading="lazy"> +<img class="wimg" src="/wp/wp444.jpg" width="1242" height="2688" loading="lazy"> +<img class="sidebar" src="/wp/wp555.jpg" width="1920" height="1080"> +`; + +const SECOND_ALBUM_PAGE = ` +<img class="wimg" src="/wp/wp111.webp" width="1920" height="1080"> +<img class="wimg" src="/wp/iee9mCb.jpg" width="1600" height="900"> +`; + +/** Four albums, so a page of three leaves one behind — what "load more" is there to reach. */ +const SEARCH_PAGE_MANY = ` +<a href="/atomfall-wallpapers" title="27 wallpapers in Atomfall"></a> +<a href="/atomfall-2-wallpapers" title="20 wallpapers in Atomfall 2"></a> +<a href="/atomfall-3-wallpapers" title="15 wallpapers in Atomfall 3"></a> +<a href="/atomfall-4-wallpapers" title="10 wallpapers in Atomfall 4"></a> +`; + +function textResponse(text: string, status = 200): FetchResponse { + const chunks = [new TextEncoder().encode(text)]; + let index = 0; + return { + ok: status >= 200 && status < 300, + status, + headers: { get: () => null }, + body: { + getReader: () => ({ + read: async () => { + if (index >= chunks.length) return { done: true }; + const value = chunks[index]!; + index += 1; + return { done: false, value }; + }, + cancel: async () => undefined, + }), + }, + }; +} + +/** `english: null` stands for "no English name is known" — undefined would take the default. */ +function providerOf( + routes: (url: string) => FetchResponse, + english: string | null = 'Atomfall', +): { provider: WallpaperCaveProvider; fetch: ReturnType<typeof vi.fn> } { + const fetch = vi.fn(async (url: string) => routes(url)); + const http = new HttpClient({ fetch, userAgent: 'Playhook/test' }); + return { + provider: new WallpaperCaveProvider({ http, englishTitle: () => english ?? undefined }), + fetch, + }; +} + +function urlsOf(fetch: ReturnType<typeof vi.fn>): string[] { + return fetch.mock.calls.map((call) => String(call[0])); +} + +function paper(file: string, width?: number, height?: number): CaveWallpaper { + return { + url: `https://wallpapercave.com/wp/${file}`, + file, + ...(width === undefined ? {} : { width }), + ...(height === undefined ? {} : { height }), + }; +} + +describe('wallpapercave search page', () => { + it('reads every album link, with the count the title states', () => { + const albums = parseAlbums(SEARCH_PAGE); + expect(albums).toContainEqual({ path: '/atomfall-wallpapers', title: 'Atomfall', count: 27 }); + expect(albums).toHaveLength(4); + }); + + it('offers an album once even though its anchor carries the title attribute twice', () => { + expect(parseAlbums(SEARCH_PAGE).filter((a) => a.path === '/atomfall-wallpapers')).toHaveLength( + 1, + ); + }); + + it('takes no album from an album page — that is what tells the two shapes apart', () => { + expect(parseAlbums(ALBUM_PAGE)).toEqual([]); + }); +}); + +describe('wallpapercave album choice', () => { + it('drops phone albums, including the ones no word match would catch', () => { + expect(isMobileAlbum('/atomfall-phone-wallpapers')).toBe(true); + expect(isMobileAlbum('/cellphone-atomfall-wallpapers')).toBe(true); + expect(isMobileAlbum('/android-atomfall-wallpapers')).toBe(true); + expect(isMobileAlbum('/atomfall-4k-phone-wallpapers')).toBe(true); + expect(isMobileAlbum('/atomfall-wallpapers')).toBe(false); + }); + + it('puts the album whose name matches the query first, then the fuller ones', () => { + const ranked = rankAlbums(parseAlbums(SEARCH_PAGE), 'Atomfall'); + expect(ranked.map((album) => album.path)).toEqual([ + '/atomfall-wallpapers', + '/atomfall-fan-art-wallpapers', + ]); + }); + + it('ranks by how much an album holds when the names match equally well', () => { + const albums = [ + { path: '/a-wallpapers', title: 'Something Else', count: 4 }, + { path: '/b-wallpapers', title: 'Another Thing', count: 40 }, + ]; + expect(rankAlbums(albums, 'Atomfall').map((album) => album.path)).toEqual([ + '/b-wallpapers', + '/a-wallpapers', + ]); + }); +}); + +describe('wallpapercave album page', () => { + it('reads the pictures with their stated sizes and ignores the rest of the markup', () => { + const wallpapers = parseWallpapers(ALBUM_PAGE); + expect(wallpapers.map((paper) => paper.file)).toEqual([ + 'wp111.webp', + 'wp222.jpg', + 'wp333.png', + 'wp444.jpg', + ]); + expect(wallpapers[0]).toEqual({ + url: 'https://wallpapercave.com/wp/wp111.webp', + file: 'wp111.webp', + width: 1920, + height: 1080, + }); + }); + + it('makes every form of src absolute', () => { + expect(absoluteUrl('/wp/wp1.jpg')).toBe('https://wallpapercave.com/wp/wp1.jpg'); + expect(absoluteUrl('//w.test/wp1.jpg')).toBe('https://w.test/wp1.jpg'); + expect(absoluteUrl('https://w.test/wp1.jpg')).toBe('https://w.test/wp1.jpg'); + }); +}); + +describe('wallpapercave offers', () => { + it('serves the same file as the tile and as the full size', () => { + const offers = toArtworkOffers([paper('wp111.webp', 1920, 1080)]); + expect(offers[0]).toEqual({ + key: 'wallpapercave:wp111.webp', + kind: 'hero', + provider: 'wallpapercave', + width: 1920, + height: 1080, + thumbUrl: 'https://wallpapercave.com/wp/wp111.webp', + fullUrl: 'https://wallpapercave.com/wp/wp111.webp', + }); + }); + + it('drops portrait pictures — a phone shot behind a 16:10 screen is a ribbon', () => { + const offers = toArtworkOffers([paper('a.jpg', 1242, 2688), paper('b.jpg', 1920, 1080)]); + expect(offers.map((offer) => offer.key)).toEqual(['wallpapercave:b.jpg']); + }); + + it('offers a picture once when two albums both carry it', () => { + const offers = toArtworkOffers([ + paper('wp111.webp', 1920, 1080), + paper('wp111.webp', 1920, 1080), + ]); + expect(offers).toHaveLength(1); + }); + + it('takes the sizes the Deck can use before the 4K ones', () => { + const offers = toArtworkOffers([ + paper('big.png', 3840, 2160), + paper('hd.jpg', 1920, 1080), + paper('qhd.jpg', 2560, 1440), + ]); + expect(offers.map((offer) => offer.key)).toEqual([ + 'wallpapercave:qhd.jpg', + 'wallpapercave:hd.jpg', + 'wallpapercave:big.png', + ]); + }); + + // The gallery's page size lives in the service now (MAX_ARTWORK_PER_PROVIDER); what does not fit on a + // page is kept for the next one, so a source that trimmed its own answer would hide pictures for good. + it('offers everything it parsed, leaving the page size to the service', () => { + const many = Array.from({ length: 30 }, (_, index) => paper(`wp${index}.jpg`, 1920, 1080)); + expect(toArtworkOffers(many)).toHaveLength(30); + }); +}); + +describe('wallpapercave provider', () => { + it('searches by the English name and opens the album the search listed', async () => { + const { provider, fetch } = providerOf((url) => + textResponse(url.includes('/search') ? SEARCH_PAGE : ALBUM_PAGE), + ); + const result = await provider.artwork( + { key: 'steam:1', title: 'Атомфолл' }, + 'hero', + pageRequest(), + ); + expect(urlsOf(fetch)[0]).toBe(searchUrl('Atomfall')); + expect(urlsOf(fetch)[1]).toBe('https://wallpapercave.com/atomfall-wallpapers'); + expect(result.ok === true && result.value.offers.map((offer) => offer.key)).toEqual([ + 'wallpapercave:wp222.jpg', + 'wallpapercave:wp111.webp', + 'wallpapercave:wp333.png', + ]); + }); + + // A strong match answers 302 straight to the album; the client follows it silently, so the page that + // comes back for a search URL is the album's. Getting this wrong would blind the best-covered games. + it('takes the album page the search redirected to, without asking for anything else', async () => { + const { provider, fetch } = providerOf(() => textResponse(ALBUM_PAGE)); + const result = await provider.artwork( + { key: 'steam:1', title: 'Atomfall' }, + 'hero', + pageRequest(), + ); + expect(fetch).toHaveBeenCalledTimes(1); + expect(result.ok === true && result.value.offers).toHaveLength(3); + }); + + it('collects from several albums and offers a shared picture once', async () => { + const pages: Readonly<Record<string, string>> = { + '/atomfall-wallpapers': SECOND_ALBUM_PAGE, + '/atomfall-fan-art-wallpapers': SECOND_ALBUM_PAGE, + }; + const { provider } = providerOf((url) => { + if (url.includes('/search')) return textResponse(SEARCH_PAGE); + const path = new URL(url).pathname; + return textResponse(pages[path] ?? ''); + }); + const result = await provider.artwork( + { key: 'steam:1', title: 'Atomfall' }, + 'hero', + pageRequest(), + ); + expect(result.ok === true && result.value.offers.map((offer) => offer.key)).toEqual([ + 'wallpapercave:wp111.webp', + 'wallpapercave:iee9mCb.jpg', + ]); + }); + + it('opens the next albums on a later page, and says when none are left', async () => { + const opened: string[] = []; + const { provider } = providerOf((url) => { + if (url.includes('/search')) return textResponse(SEARCH_PAGE_MANY); + opened.push(new URL(url).pathname); + return textResponse(SECOND_ALBUM_PAGE); + }); + const ref = { key: 'steam:1', title: 'Atomfall' }; + const first = await provider.artwork(ref, 'hero', pageRequest()); + expect(opened).toHaveLength(3); + expect(first.ok === true && first.value.hasMore).toBe(true); + opened.length = 0; + const second = await provider.artwork(ref, 'hero', pageRequest(1)); + expect(opened).toEqual(['/atomfall-4-wallpapers']); + expect(second.ok === true && second.value.hasMore).toBe(false); + }); + + it('searches once for a gallery, however many pages it is paged through', async () => { + const { provider, fetch } = providerOf((url) => + textResponse(url.includes('/search') ? SEARCH_PAGE_MANY : SECOND_ALBUM_PAGE), + ); + const ref = { key: 'steam:1', title: 'Atomfall' }; + await provider.artwork(ref, 'hero', pageRequest()); + fetch.mockClear(); + await provider.artwork(ref, 'hero', pageRequest(1)); + expect(urlsOf(fetch).filter((url) => url.includes('/search'))).toEqual([]); + }); + + // The redirect case has no list to page through: everything the album holds arrived with page 0, and + // the service hands out what did not fit on screen. + it('has no later page after a search that landed on the album itself', async () => { + const { provider } = providerOf(() => textResponse(ALBUM_PAGE)); + const ref = { key: 'steam:1', title: 'Atomfall' }; + const first = await provider.artwork(ref, 'hero', pageRequest()); + expect(first.ok === true && first.value.hasMore).toBe(false); + expect(await provider.artwork(ref, 'hero', pageRequest(1))).toEqual({ + ok: true, + value: { offers: [], hasMore: false }, + }); + }); + + it('falls back to the candidate title when it is already Latin', async () => { + const { provider, fetch } = providerOf(() => textResponse(ALBUM_PAGE), null); + await provider.artwork({ key: 'gog:1', title: 'Hollow Knight' }, 'hero', pageRequest()); + expect(urlsOf(fetch)[0]).toBe(searchUrl('Hollow Knight')); + }); + + it('does not search at all for a title it can only spell in another script', async () => { + const { provider, fetch } = providerOf(() => textResponse(ALBUM_PAGE), null); + expect( + await provider.artwork({ key: 'steam:1', title: 'Ведьмак 3' }, 'hero', pageRequest()), + ).toEqual({ + ok: true, + value: { offers: [], hasMore: false }, + }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('retries without the edition tail when the full title finds nothing', async () => { + const { provider, fetch } = providerOf( + (url) => textResponse(url.includes('Complete') ? '' : ALBUM_PAGE), + 'The Witcher 3: Wild Hunt - Complete Edition', + ); + const result = await provider.artwork( + { key: 'steam:292030', title: 'x' }, + 'hero', + pageRequest(), + ); + expect(urlsOf(fetch)).toEqual([ + searchUrl('The Witcher 3: Wild Hunt - Complete Edition'), + searchUrl('The Witcher 3: Wild Hunt'), + ]); + expect(result.ok === true && result.value.offers).toHaveLength(3); + }); + + it('reports nothing found as an empty gallery, not as an error', async () => { + const { provider } = providerOf(() => textResponse('<html></html>'), 'Some Niche Indie'); + expect(await provider.artwork({ key: 'steam:1', title: 'x' }, 'hero', pageRequest())).toEqual({ + ok: true, + value: { offers: [], hasMore: false }, + }); + }); + + it('reports a failing site as a failure', async () => { + const { provider } = providerOf(() => textResponse('', 503)); + expect((await provider.artwork({ key: 'steam:1', title: 'x' }, 'hero', pageRequest())).ok).toBe( + false, + ); + }); + + it('offers no covers — it is a wallpaper source', async () => { + const { provider, fetch } = providerOf(() => textResponse(ALBUM_PAGE)); + expect(await provider.artwork({ key: 'steam:1', title: 'x' }, 'grid', pageRequest())).toEqual({ + ok: true, + value: { offers: [], hasMore: false }, + }); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/test/mouse-sleep.test.ts b/test/mouse-sleep.test.ts new file mode 100644 index 00000000..bfb1a8f9 --- /dev/null +++ b/test/mouse-sleep.test.ts @@ -0,0 +1,97 @@ +// The shove that wakes the mouse: total travel, not distance from the start, and only while the moves +// keep coming. The meter is pure — the test feeds it coordinates and a clock of its own. Distances are +// expressed as fractions of WAKE_TRAVEL_PX so that retuning the threshold cannot quietly make a case +// meaningless (a fixed 150px "drift" stops proving anything the day the threshold moves past it). +import { describe, expect, it } from 'vitest'; +import { TRAVEL_RESET_MS, WAKE_TRAVEL_PX, createWakeMeter } from '../src/renderer/mouse-sleep'; + +const STEP_PX = 10; +/** Moves that add up to `fraction` of the wake threshold. */ +const stepsFor = (fraction: number): number => Math.ceil((WAKE_TRAVEL_PX * fraction) / STEP_PX); + +interface Run { + /** Index of the move that woke the meter, or -1 if it stayed asleep. */ + readonly wokeAt: number; + /** The clock after the run, so a follow-up run can continue from it. */ + readonly endedAt: number; +} + +/** Feeds a straight run of `steps` moves `px` apart, `gap` ms between them. */ +function run( + meter: ReturnType<typeof createWakeMeter>, + steps: number, + px: number, + gap: number, + startAt = 1_000, +): Run { + let now = startAt; + let x = 0; + for (let i = 0; i < steps; i += 1) { + x += px; + now += gap; + if (meter.moved(x, 0, now)) return { wokeAt: i, endedAt: now }; + } + return { wokeAt: -1, endedAt: now }; +} + +describe('createWakeMeter', () => { + it('stays asleep through a drift that never adds up', () => { + expect(run(createWakeMeter(), stepsFor(0.5), 1, 10).wokeAt).toBe(-1); + }); + + it('wakes once the travel crosses the threshold', () => { + const woke = run(createWakeMeter(), stepsFor(1.2), STEP_PX, 10).wokeAt; + expect(woke).toBeGreaterThanOrEqual(0); + expect((woke + 1) * STEP_PX).toBeGreaterThanOrEqual(WAKE_TRAVEL_PX); + }); + + it('counts the FIRST move as travel-free: it only establishes where the pointer is', () => { + expect(createWakeMeter().moved(10_000, 10_000, 1_000)).toBe(false); + }); + + it('counts distance travelled, so shaking in place wakes it as well as a straight run', () => { + const meter = createWakeMeter(); + const swing = 30; + let now = 1_000; + let woke = false; + for (let i = 0; i < stepsFor(1.5) && !woke; i += 1) { + now += 10; + woke = meter.moved(i % 2 === 0 ? 0 : swing, 0, now); + } + expect(woke).toBe(true); + }); + + it('starts the count over after a pause, so two separate nudges are not one shove', () => { + const meter = createWakeMeter(); + // Two runs of 60% each: carried over they would wake it, separated by a pause they must not. + const first = run(meter, stepsFor(0.6), STEP_PX, 10); + expect(first.wokeAt).toBe(-1); + expect(run(meter, stepsFor(0.6), STEP_PX, 10, first.endedAt + TRAVEL_RESET_MS + 1).wokeAt).toBe( + -1, + ); + }); + + it('treats a gap of exactly TRAVEL_RESET_MS as continuous, one millisecond more as a pause', () => { + expect( + run(createWakeMeter(), stepsFor(1.2), STEP_PX, TRAVEL_RESET_MS).wokeAt, + ).toBeGreaterThanOrEqual(0); + expect(run(createWakeMeter(), stepsFor(1.2), STEP_PX, TRAVEL_RESET_MS + 1).wokeAt).toBe(-1); + }); + + it('reports the wake exactly once, then counts again from zero', () => { + const meter = createWakeMeter(); + const woke = run(meter, stepsFor(1.2), STEP_PX, 10); + expect(woke.wokeAt).toBeGreaterThanOrEqual(0); + // The run continues uninterrupted, but the meter was zeroed by the wake it just reported. + expect(meter.moved((woke.wokeAt + 2) * STEP_PX, 0, woke.endedAt + 10)).toBe(false); + }); + + it('forgets the travel so far on reset (a pad step landed mid-shove)', () => { + const meter = createWakeMeter(); + // Same two runs as the pause case, back to back with no pause: only the reset can stop this one. + const first = run(meter, stepsFor(0.6), STEP_PX, 10); + expect(first.wokeAt).toBe(-1); + meter.reset(); + expect(run(meter, stepsFor(0.6), STEP_PX, 10, first.endedAt).wokeAt).toBe(-1); + }); +}); diff --git a/test/notification-time.test.ts b/test/notification-time.test.ts new file mode 100644 index 00000000..db6bc3a4 --- /dev/null +++ b/test/notification-time.test.ts @@ -0,0 +1,70 @@ +// The notification list's own text: what a notification says (assembled from the kind, never stored) +// and when it arrived (today = the time alone, the day before = "yesterday", older = a date). +import { describe, expect, it } from 'vitest'; +import { createTranslator } from '../src/shared/i18n/index'; +import { formatNotification, formatNotificationTime } from '../src/renderer/format'; +import type { AppNotification } from '../src/shared/types'; + +const en = createTranslator('en'); +const ru = createTranslator('ru'); + +/** Local time, so the "same calendar day" rule is exercised in the zone the launcher actually runs in. */ +function at(year: number, month: number, day: number, hour: number, minute: number): number { + return new Date(year, month - 1, day, hour, minute).getTime(); +} + +describe('formatNotification — the text is built, not stored', () => { + it('names the game for an install and an uninstall', () => { + const installed: AppNotification = { + id: 'a', + at: 0, + read: false, + kind: 'game-installed', + gameId: 'hades', + gameTitle: 'Hades', + }; + expect(formatNotification(installed, en)).toContain('Hades'); + expect(formatNotification({ ...installed, kind: 'game-uninstalled' }, en)).toContain('Hades'); + // The two must not read the same — "installed" and "removed" is the whole information. + expect(formatNotification(installed, en)).not.toBe( + formatNotification({ ...installed, kind: 'game-uninstalled' }, en), + ); + }); + + it('carries the version of a ready update', () => { + const update: AppNotification = { id: 'b', at: 0, read: false, kind: 'update-ready', version: '0.9.1' }; + expect(formatNotification(update, en)).toContain('0.9.1'); + }); + + it('follows the current language', () => { + const update: AppNotification = { id: 'b', at: 0, read: false, kind: 'update-ready', version: '0.9.1' }; + expect(formatNotification(update, ru)).not.toBe(formatNotification(update, en)); + }); +}); + +describe('formatNotificationTime', () => { + const now = at(2026, 8, 16, 9, 5); + + it('shows the time alone for the same calendar day', () => { + expect(formatNotificationTime(at(2026, 8, 16, 14, 32), now, en, 'en')).toBe('14:32'); + }); + + it('counts calendar days, not 24-hour windows: last night is still yesterday', () => { + // 23:50 the previous evening is barely 9 hours ago, yet it belongs to yesterday. + expect(formatNotificationTime(at(2026, 8, 15, 23, 50), now, en, 'en')).toBe('yesterday, 23:50'); + }); + + it('names yesterday in the current language', () => { + expect(formatNotificationTime(at(2026, 8, 15, 23, 50), now, ru, 'ru')).toContain('вчера'); + }); + + it('falls back to a plain date for anything older', () => { + const older = formatNotificationTime(at(2026, 8, 10, 14, 32), now, en, 'en'); + expect(older).not.toContain('yesterday'); + expect(older).toContain('2026'); + }); + + it('treats a moment earlier the same morning as today, not as a future date', () => { + expect(formatNotificationTime(at(2026, 8, 16, 0, 1), now, en, 'en')).toBe('00:01'); + }); +}); diff --git a/test/notifications-model.test.ts b/test/notifications-model.test.ts new file mode 100644 index 00000000..dc34e5eb --- /dev/null +++ b/test/notifications-model.test.ts @@ -0,0 +1,104 @@ +// The notification inbox's pure rules: ordering + eviction, dismissal, the two shapes of markRead, the +// unread count, and the delivery truth table (which is what decides whether the launcher makes a sound). +import { describe, expect, it } from 'vitest'; +import { + MAX_NOTIFICATIONS, + addNotification, + deliveryFor, + dismissNotification, + markRead, + unreadCount, + type PresenceInput, +} from '../src/main/notifications-model'; +import type { AppNotification } from '../src/shared/types'; + +function installed(id: string, at: number, read = false): AppNotification { + return { id, at, read, kind: 'game-installed', gameId: `game-${id}`, gameTitle: `Game ${id}` }; +} + +describe('addNotification — append + eviction', () => { + it('appends to the END (newest last — the order the popup lists them in)', () => { + const items = addNotification(addNotification([], installed('a', 1)), installed('b', 2)); + expect(items.map((n) => n.id)).toEqual(['a', 'b']); + }); + + it('does not mutate the list it was given', () => { + const before: readonly AppNotification[] = [installed('a', 1)]; + addNotification(before, installed('b', 2)); + expect(before).toHaveLength(1); + }); + + it('drops the OLDEST once the cap is exceeded', () => { + let items: readonly AppNotification[] = []; + for (let i = 0; i < MAX_NOTIFICATIONS + 3; i += 1) items = addNotification(items, installed(`n${i}`, i)); + expect(items).toHaveLength(MAX_NOTIFICATIONS); + expect(items[0]?.id).toBe('n3'); + expect(items[items.length - 1]?.id).toBe(`n${MAX_NOTIFICATIONS + 2}`); + }); +}); + +describe('dismissNotification', () => { + it('removes exactly the pressed one', () => { + const items = [installed('a', 1), installed('b', 2), installed('c', 3)]; + expect(dismissNotification(items, 'b').map((n) => n.id)).toEqual(['a', 'c']); + }); + + it('leaves the list alone for an id it does not know', () => { + const items = [installed('a', 1)]; + expect(dismissNotification(items, 'zzz').map((n) => n.id)).toEqual(['a']); + }); +}); + +describe('markRead', () => { + it('marks the whole inbox — what opening the popup means', () => { + const items = [installed('a', 1), installed('b', 2)]; + expect(markRead(items).every((n) => n.read)).toBe(true); + }); + + it('keeps an already-read entry as the SAME object (so a caller can skip a pointless write)', () => { + const read = installed('a', 1, true); + const items = [read, installed('b', 2)]; + const next = markRead(items); + expect(next[0]).toBe(read); + expect(next[1]).not.toBe(items[1]); + }); + + it('does not mutate the list it was given', () => { + const items = [installed('a', 1)]; + markRead(items); + expect(items[0]?.read).toBe(false); + }); +}); + +describe('unreadCount', () => { + it('counts only the unread ones', () => { + expect(unreadCount([installed('a', 1), installed('b', 2, true), installed('c', 3)])).toBe(2); + }); + + it('is 0 for an empty inbox', () => { + expect(unreadCount([])).toBe(0); + }); +}); + +describe('deliveryFor — when the launcher may make noise', () => { + const present: PresenceInput = { + windowVisible: true, + windowFocused: true, + gameRunning: false, + }; + + it('is live whenever the launcher is in front — no idle timer in the way', () => { + expect(deliveryFor(present)).toBe('live'); + }); + + it('is muted while a game runs — whatever the window says', () => { + expect(deliveryFor({ ...present, gameRunning: true })).toBe('muted'); + expect(deliveryFor({ ...present, gameRunning: true, windowVisible: false })).toBe('muted'); + expect(deliveryFor({ ...present, gameRunning: true, windowFocused: false })).toBe('muted'); + }); + + it('is deferred when the window is hidden or behind something — a plate nobody would see', () => { + expect(deliveryFor({ ...present, windowVisible: false })).toBe('deferred'); + expect(deliveryFor({ ...present, windowFocused: false })).toBe('deferred'); + }); +}); diff --git a/test/notifications-store.test.ts b/test/notifications-store.test.ts new file mode 100644 index 00000000..c34c2b30 --- /dev/null +++ b/test/notifications-store.test.ts @@ -0,0 +1,112 @@ +// NotificationsStore invariants: schema defaults (an older/partial file migrates instead of resetting), +// a corrupted file falls back to an empty inbox with a warn breadcrumb, the write queue serializes +// concurrent read-modify-writes (two installs finishing at once must not clobber each other), and the +// update-dedup marker survives a round trip. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { log } from '../src/main/logger'; +import { addNotification } from '../src/main/notifications-model'; +import { EMPTY_NOTIFICATIONS, NotificationsStore } from '../src/main/notifications-store'; +import type { AppNotification } from '../src/shared/types'; + +let baseDir: string; + +function installed(id: string, at: number): AppNotification { + return { id, at, read: false, kind: 'game-installed', gameId: `g-${id}`, gameTitle: `Game ${id}` }; +} + +beforeEach(async () => { + baseDir = await fs.mkdtemp(path.join(os.tmpdir(), 'playhook-notifications-')); +}); + +afterEach(async () => { + await fs.rm(baseDir, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +describe('NotificationsStore — schema tolerance', () => { + it('reads an empty inbox when the file does not exist yet (first run, silent)', async () => { + const warn = vi.spyOn(log, 'warn').mockImplementation(() => undefined); + expect(await new NotificationsStore(baseDir).read()).toEqual(EMPTY_NOTIFICATIONS); + expect(warn).not.toHaveBeenCalled(); + }); + + it('fills the defaulted fields of a file written before they existed', async () => { + await fs.writeFile( + path.join(baseDir, 'notifications.json'), + JSON.stringify({ schemaVersion: 1 }), + 'utf8', + ); + const file = await new NotificationsStore(baseDir).read(); + expect(file.items).toEqual([]); + expect(file.lastNotifiedUpdateVersion).toBeNull(); + }); + + it('falls back to an empty inbox AND warns when the file is corrupted', async () => { + const warn = vi.spyOn(log, 'warn').mockImplementation(() => undefined); + await fs.writeFile(path.join(baseDir, 'notifications.json'), '{ this is not json', 'utf8'); + expect(await new NotificationsStore(baseDir).read()).toEqual(EMPTY_NOTIFICATIONS); + expect(warn).toHaveBeenCalled(); + }); + + it('rejects a file whose entries are not valid notifications rather than serving junk to the UI', async () => { + vi.spyOn(log, 'warn').mockImplementation(() => undefined); + await fs.writeFile( + path.join(baseDir, 'notifications.json'), + JSON.stringify({ schemaVersion: 1, items: [{ kind: 'nonsense' }] }), + 'utf8', + ); + expect((await new NotificationsStore(baseDir).read()).items).toEqual([]); + }); +}); + +describe('NotificationsStore — write queue', () => { + it('serializes concurrent updates so neither notification is lost', async () => { + const store = new NotificationsStore(baseDir); + await Promise.all([ + store.update((current) => ({ ...current, items: addNotification(current.items, installed('a', 1)) })), + store.update((current) => ({ ...current, items: addNotification(current.items, installed('b', 2)) })), + ]); + expect((await store.read()).items.map((n) => n.id)).toEqual(['a', 'b']); + }); + + it('update() resolves with what was actually written', async () => { + const store = new NotificationsStore(baseDir); + const next = await store.update((current) => ({ ...current, lastNotifiedUpdateVersion: '0.9.0' })); + expect(next.lastNotifiedUpdateVersion).toBe('0.9.0'); + }); + + it('flush() drains fire-and-forget writes, and resolves at once on an idle store', async () => { + const store = new NotificationsStore(baseDir); + void store.update((current) => ({ ...current, items: addNotification(current.items, installed('a', 1)) })); + void store.update((current) => ({ ...current, lastNotifiedUpdateVersion: '1.0.0' })); + await store.flush(); + const file = await store.read(); + expect(file.items).toHaveLength(1); + expect(file.lastNotifiedUpdateVersion).toBe('1.0.0'); + await expect(store.flush()).resolves.toBeUndefined(); + }); + + it('writes atomically and leaves no temp file behind', async () => { + const store = new NotificationsStore(baseDir); + await store.update((current) => ({ ...current, items: addNotification(current.items, installed('a', 1)) })); + const entries = await fs.readdir(baseDir); + expect(entries.some((e) => e.endsWith('.tmp'))).toBe(false); + }); +}); + +describe('NotificationsStore — round trip', () => { + it('keeps the dedup marker and the inbox across a re-read', async () => { + const store = new NotificationsStore(baseDir); + await store.update((current) => ({ + ...current, + items: addNotification(current.items, installed('a', 42)), + lastNotifiedUpdateVersion: '0.8.1', + })); + const file = await new NotificationsStore(baseDir).read(); + expect(file.lastNotifiedUpdateVersion).toBe('0.8.1'); + expect(file.items[0]).toMatchObject({ id: 'a', at: 42, kind: 'game-installed', read: false }); + }); +}); diff --git a/test/osk-text.test.ts b/test/osk-text.test.ts new file mode 100644 index 00000000..2534d51b --- /dev/null +++ b/test/osk-text.test.ts @@ -0,0 +1,125 @@ +// The on-screen keyboard's editing rules. They are worth testing on their own because the keyboard is +// the ONLY way to type in this launcher: an off-by-one here is a character the user cannot enter or +// cannot remove, with no <input> anywhere to fall back on. +import { describe, expect, it } from 'vitest'; +import { + caretFromOffset, + clampCaret, + deleteAfter, + deleteBefore, + insertAt, + moveCaret, + sanitize, + splitAtCaret, +} from '../src/renderer/osk-text'; + +describe('insertAt', () => { + it('writes at the caret rather than at the end', () => { + expect(insertAt({ value: 'Hads', caret: 1 }, 'e')).toEqual({ value: 'Heads', caret: 2 }); + }); + + it('leaves the caret after what was inserted, whatever its length', () => { + expect(insertAt({ value: 'ab', caret: 1 }, 'XYZ')).toEqual({ value: 'aXYZb', caret: 4 }); + }); + + it('appends when the caret is at the end, which is the plain typing case', () => { + expect(insertAt({ value: 'Hade', caret: 4 }, 's')).toEqual({ value: 'Hades', caret: 5 }); + }); + + it('does nothing with nothing to insert', () => { + const state = { value: 'Hades', caret: 2 }; + expect(insertAt(state, '')).toBe(state); + }); +}); + +describe('deleteBefore / deleteAfter', () => { + it('backspace takes the character before the caret', () => { + expect(deleteBefore({ value: 'Heads', caret: 2 })).toEqual({ value: 'Hads', caret: 1 }); + }); + + it('backspace at the very start is a no-op, not an underflow', () => { + const state = { value: 'Hades', caret: 0 }; + expect(deleteBefore(state)).toBe(state); + }); + + it('delete takes the character at the caret and leaves the caret alone', () => { + expect(deleteAfter({ value: 'Heads', caret: 1 })).toEqual({ value: 'Hads', caret: 1 }); + }); + + it('delete at the very end is a no-op', () => { + const state = { value: 'Hades', caret: 5 }; + expect(deleteAfter(state)).toBe(state); + }); +}); + +// A caret counted in UTF-16 code units eventually splits a surrogate pair, and a backspace then deletes +// half a character — the value keeps a lone surrogate and renders as a replacement glyph. Game titles +// are exactly where this shows up. +describe('characters outside the basic plane', () => { + it('treats an astral character as one character everywhere', () => { + const state = { value: 'a🎮b', caret: 2 }; + expect(splitAtCaret(state)).toEqual({ before: 'a🎮', after: 'b' }); + expect(deleteBefore(state)).toEqual({ value: 'ab', caret: 1 }); + expect(moveCaret({ value: 'a🎮b', caret: 1 }, 1)).toEqual({ value: 'a🎮b', caret: 2 }); + }); + + it('inserts a whole astral character, not a half of one', () => { + expect(insertAt({ value: 'ab', caret: 1 }, '🎮')).toEqual({ value: 'a🎮b', caret: 2 }); + }); +}); + +describe('moveCaret / clampCaret', () => { + it('stops at both ends instead of wrapping', () => { + expect(moveCaret({ value: 'ab', caret: 0 }, -1)).toEqual({ value: 'ab', caret: 0 }); + expect(moveCaret({ value: 'ab', caret: 2 }, 1)).toEqual({ value: 'ab', caret: 2 }); + }); + + it('returns the same state when it did not move (nothing to repaint)', () => { + const state = { value: 'ab', caret: 0 }; + expect(moveCaret(state, -1)).toBe(state); + }); + + it('clamps a caret that no longer fits its value', () => { + expect(clampCaret('ab', 9)).toBe(2); + expect(clampCaret('ab', -3)).toBe(0); + }); +}); + +describe('sanitize', () => { + it('holds an id to the schema, in lower case', () => { + expect(sanitize('id', 'Hades II')).toBe('hadesii'); + expect(sanitize('id', 'my_game-2.0')).toBe('my_game-2.0'); + }); + + it('keeps a number field to digits', () => { + expect(sanitize('number', '30 сек')).toBe('30'); + }); + + it('folds a pasted line break into a space instead of storing it', () => { + expect(sanitize('text', 'Hades\nII')).toBe('Hades II'); + expect(sanitize('text', 'a\r\nb')).toBe('a b'); + }); + + it('drops control characters a paste may carry', () => { + expect(sanitize('text', 'Ha\u0000des\u200B')).toBe('Hades'); + }); + + it('leaves an ordinary title alone, spaces and all', () => { + expect(sanitize('text', 'Sid Meier’s Civilization VI')).toBe('Sid Meier’s Civilization VI'); + }); +}); + +describe('caretFromOffset', () => { + const state = { value: 'a🎮bc', caret: 2 }; // before = "a🎮", after = "bc" + + it('maps an offset inside the left half onto a code-point caret', () => { + expect(caretFromOffset(state, 'before', 0)).toBe(0); + expect(caretFromOffset(state, 'before', 1)).toBe(1); + expect(caretFromOffset(state, 'before', 3)).toBe(2); // past the surrogate pair + }); + + it('maps an offset inside the right half past everything on the left', () => { + expect(caretFromOffset(state, 'after', 0)).toBe(2); + expect(caretFromOffset(state, 'after', 2)).toBe(4); + }); +}); diff --git a/test/overlay-hit-testing.test.ts b/test/overlay-hit-testing.test.ts new file mode 100644 index 00000000..2589620f --- /dev/null +++ b/test/overlay-hit-testing.test.ts @@ -0,0 +1,46 @@ +// Guard for the one CSS rule a screen can be clicked THROUGH. +// +// A full-screen overlay hides the screen under it with opacity plus `pointer-events: none` on the +// section — but that is undone by any child that sets `auto`, and the cards do exactly that so the +// carousel strip (a full-width band across the bottom bar) can stay transparent to the mouse while its +// cards are not. Ungated, that `auto` reached cards under an open Customize / Add-game screen: the +// pointer turned into a hand over a game nobody could see, and clicking selected it behind the screen. +// +// Read from source, the way the IPC contract is: the rule cannot be exercised here (the suite runs in +// plain Node with no layout engine), and it is one deletion away from coming back. +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const css = fs.readFileSync(path.resolve(__dirname, '../src/renderer/styles.css'), 'utf8'); + +/** Every selector that turns hit-testing back ON for a card, with the comments above it stripped off. */ +function cardSelectorsEnablingPointerEvents(): readonly string[] { + const withoutComments = css.replaceAll(/\/\*[\s\S]*?\*\//g, ''); + return [...withoutComments.matchAll(/([^{}]*\.card[^{}]*)\{([^}]*)\}/g)] + .filter(([, , body]) => /pointer-events:\s*auto/.test(body ?? '')) + .map(([, selector]) => (selector ?? '').replace(/\s+/g, ' ').trim()); +} + +describe('cards cannot be clicked through an overlay', () => { + const selectors = cardSelectorsEnablingPointerEvents(); + + it('has rules that make cards hit-testable at all', () => { + expect(selectors.length).toBeGreaterThan(0); + }); + + it('gates every one of them on an overlay state', () => { + for (const selector of selectors) { + const gated = selector.includes(':not([data-overlay])') || selector.includes('[data-overlay='); + expect(gated, `ungated card rule: ${selector}`).toBe(true); + } + }); + + it('keeps the carousel strip clickable only with no overlay open', () => { + expect(selectors).toContain("#app[data-screen='carousel']:not([data-overlay]) .card"); + }); + + it("re-enables the library's own cards for the overlay that shows them", () => { + expect(selectors).toContain("#app[data-overlay='library'] #library .library-grid .card"); + }); +}); diff --git a/test/pc-library.test.ts b/test/pc-library.test.ts new file mode 100644 index 00000000..da755ad7 --- /dev/null +++ b/test/pc-library.test.ts @@ -0,0 +1,173 @@ +// The PC library's data-touching half: how an absent/empty/broken game.json is graded, asset import +// (sanitizing + de-duplication) and the orphan sweep that must never touch save backups. +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { PcLibraryStore } from '../src/main/pc-library'; +import { createTranslator } from '../src/shared/i18n/index'; + +const env = { documents: path.resolve('documents'), t: createTranslator('en') }; +const resolveInstallDir = (): null => null; + +let baseDir: string; +let library: PcLibraryStore; + +// pc paths are NATIVE (the library never travels between machines) and CI runs on Windows too, so an +// absolute path is built from the platform root rather than written as a `/games/...` literal. +const exe = path.join(path.resolve(path.sep), 'Games', 'Hades', 'Hades.exe'); + +beforeEach(async () => { + baseDir = await fs.mkdtemp(path.join(os.tmpdir(), 'playhook-userdata-')); + library = new PcLibraryStore({ baseDir }); + await library.init(); +}); + +afterEach(async () => { + await fs.rm(baseDir, { recursive: true, force: true }); +}); + +async function writeManifest(value: unknown): Promise<void> { + await fs.writeFile(path.join(library.root, 'game.json'), JSON.stringify(value)); +} + +const pcGame = (extra: Record<string, unknown> = {}): Record<string, unknown> => ({ + schemaVersion: 1, + id: 'hades', + title: 'Hades', + pc: { executable: exe }, + ...extra, +}); + +describe('PcLibraryStore.init', () => { + it('creates the library skeleton but no game.json (an absent file means "no games yet")', async () => { + expect(await fs.stat(path.join(library.root, 'assets')).then(() => true)).toBe(true); + expect(await library.hasManifest()).toBe(false); + }); +}); + +describe('PcLibraryStore.read', () => { + it('reports an empty, intact library when there is no game.json', async () => { + expect(await library.read(env, resolveInstallDir)).toEqual({ manifests: [], intact: true }); + }); + + it('reports an empty, intact library for an empty array', async () => { + await writeManifest([]); + expect(await library.read(env, resolveInstallDir)).toEqual({ manifests: [], intact: true }); + }); + + it('reads local games', async () => { + await writeManifest([pcGame(), pcGame({ id: 'celeste', title: 'Celeste' })]); + const read = await library.read(env, resolveInstallDir); + expect(read.intact).toBe(true); + expect(read.manifests.map((m) => m.raw.id)).toEqual(['hades', 'celeste']); + expect(read.manifests[0]?.source).toBe('pc'); + }); + + it('flags a BROKEN game.json as not intact instead of throwing', async () => { + await fs.writeFile(path.join(library.root, 'game.json'), '{ not json'); + const read = await library.read(env, resolveInstallDir); + expect(read).toEqual({ manifests: [], intact: false }); + }); +}); + +// The extension list is the caller's (game-config passes the AssetReader's), so the tests pass the same +// shape rather than importing it — this module stays electron-free and so does its suite. +const IMAGE_EXT = ['jpg', 'jpeg', 'png', 'webp', 'gif']; +const importImage = (absolute: string): Promise<string> => + library.importAsset(absolute, 'image', IMAGE_EXT); + +describe('PcLibraryStore.importAsset', () => { + let source: string; + + beforeEach(async () => { + source = path.join(baseDir, 'hero image.jpg'); + await fs.writeFile(source, 'IMG'); + }); + + it('copies the file into assets/ and returns a root-relative path with forward slashes', async () => { + const relative = await importImage(source); + expect(relative).toBe('assets/hero-image.jpg'); + expect(await fs.readFile(path.join(library.root, 'assets', 'hero-image.jpg'), 'utf8')).toBe('IMG'); + }); + + it('de-duplicates a colliding name instead of overwriting the first game\'s artwork', async () => { + const other = path.join(baseDir, 'other', 'hero image.jpg'); + await fs.mkdir(path.dirname(other), { recursive: true }); + await fs.writeFile(other, 'OTHER'); + + expect(await importImage(source)).toBe('assets/hero-image.jpg'); + expect(await importImage(other)).toBe('assets/hero-image-2.jpg'); + expect(await fs.readFile(path.join(library.root, 'assets', 'hero-image.jpg'), 'utf8')).toBe('IMG'); + expect(await fs.readFile(path.join(library.root, 'assets', 'hero-image-2.jpg'), 'utf8')).toBe('OTHER'); + }); + + it('sanitizes a name that would escape or hide (traversal, leading dots)', async () => { + const nasty = path.join(baseDir, '..hidden .jpg'); + await fs.writeFile(nasty, 'IMG'); + const relative = await importImage(nasty); + expect(relative.startsWith('assets/')).toBe(true); + expect(relative).not.toContain('..'); + }); + + // The three refusals that replace the native dialog's filters, now that the in-launcher picker names + // the path from the renderer (see the plan, Р5.1). + it('refuses a file whose extension does not match the kind', async () => { + const key = path.join(baseDir, 'id_rsa'); + await fs.writeFile(key, 'PRIVATE KEY'); + await expect(importImage(key)).rejects.toThrow(/not a image extension/); + expect(await fs.readdir(path.join(library.root, 'assets'))).toEqual([]); + }); + + it('refuses a symlink instead of copying whatever it points at', async () => { + const link = path.join(baseDir, 'link.jpg'); + await fs.symlink(source, link); + await expect(importImage(link)).rejects.toThrow(/symbolic link/); + expect(await fs.readdir(path.join(library.root, 'assets'))).toEqual([]); + }); + + it('refuses a file past the size cap', async () => { + const huge = path.join(baseDir, 'huge.png'); + await fs.writeFile(huge, Buffer.alloc(1024)); + await expect(library.importAsset(huge, 'image', IMAGE_EXT)).resolves.toBe('assets/huge.png'); + const bigger = path.join(baseDir, 'bigger.png'); + await fs.writeFile(bigger, Buffer.alloc(33 * 1024 * 1024)); + await expect(importImage(bigger)).rejects.toThrow(/larger than/); + }); +}); + +describe('PcLibraryStore.gcOrphans', () => { + it('removes unreferenced assets and keeps the referenced ones', async () => { + const kept = await importImage(await file('keep.jpg')); + await importImage(await file('drop.jpg')); + + await library.gcOrphans([kept]); + + expect(await fs.readdir(path.join(library.root, 'assets'))).toEqual(['keep.jpg']); + }); + + it('never touches the save backups', async () => { + const saves = library.savesDir('hades'); + await fs.mkdir(saves, { recursive: true }); + await fs.writeFile(path.join(saves, 'slot1.sav'), 'PROGRESS'); + + await library.gcOrphans([]); + + expect(await fs.readFile(path.join(saves, 'slot1.sav'), 'utf8')).toBe('PROGRESS'); + }); + + async function file(name: string): Promise<string> { + const full = path.join(baseDir, name); + await fs.writeFile(full, 'IMG'); + return full; + } +}); + +describe('PcLibraryStore.removeManifest', () => { + it('drops game.json — how "the last local game was deleted" is spelled', async () => { + await writeManifest([pcGame()]); + expect(await library.hasManifest()).toBe(true); + await library.removeManifest(); + expect(await library.hasManifest()).toBe(false); + }); +}); diff --git a/test/pc-store.test.ts b/test/pc-store.test.ts new file mode 100644 index 00000000..2d409f39 --- /dev/null +++ b/test/pc-store.test.ts @@ -0,0 +1,80 @@ +// PcStore's sync-state slots: a game can be synced against a card AND against the local backup Playhook +// keeps for it, and each pairing needs its own baseline — sharing one is what would turn every second +// sync into a false conflict resolved by last-write-wins. +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { PcStore, acceptsPendingFlush } from '../src/main/pc-store'; +import type { SyncState } from '../src/main/save-sync'; + +let baseDir: string; +let store: PcStore; + +const state = (file: string, mtime: number): SyncState => ({ + card: { [file]: mtime }, + pc: { [file]: mtime }, + syncedAt: mtime, +}); + +beforeEach(async () => { + baseDir = await fs.mkdtemp(path.join(os.tmpdir(), 'playhook-store-')); + store = new PcStore(baseDir); + await store.init(); +}); + +afterEach(async () => { + await fs.rm(baseDir, { recursive: true, force: true }); +}); + +describe('PcStore sync-state slots', () => { + it('defaults to the card slot (existing callers are unchanged)', async () => { + await store.writeSyncState('hades', state('slot1.sav', 111)); + expect(await store.readSyncState('hades')).toEqual(state('slot1.sav', 111)); + expect(await store.readSyncState('hades', 'card')).toEqual(state('slot1.sav', 111)); + }); + + it('keeps the two slots independent — neither write clobbers the other', async () => { + await store.writeSyncState('hades', state('card.sav', 111), 'card'); + await store.writeSyncState('hades', state('local.sav', 222), 'pc'); + + expect(await store.readSyncState('hades', 'card')).toEqual(state('card.sav', 111)); + expect(await store.readSyncState('hades', 'pc')).toEqual(state('local.sav', 222)); + }); + + it('does not confuse an id containing a dot with the other slot', async () => { + // `id` allows dots, so a `<id>.pc.json` suffix would make the card game `hades.pc` and the PC slot of + // `hades` the same file. The slots live in separate directories for exactly this reason. + await store.writeSyncState('hades', state('local.sav', 1), 'pc'); + await store.writeSyncState('hades.pc', state('card.sav', 2), 'card'); + + expect(await store.readSyncState('hades', 'pc')).toEqual(state('local.sav', 1)); + expect(await store.readSyncState('hades.pc', 'card')).toEqual(state('card.sav', 2)); + }); + + it('reports a missing baseline as null, per slot', async () => { + await store.writeSyncState('hades', state('card.sav', 111), 'card'); + expect(await store.readSyncState('hades', 'pc')).toBeNull(); + expect(await store.readSyncState('unknown', 'card')).toBeNull(); + }); + + it('acceptsPendingFlush refuses a LOCAL game, even though it has a save-backup path', () => { + // The regression this guards: a pending snapshot is progress promised to a CARD. A local game also + // has a `saveOnCardPath` (its backup in the PC library), so a source-blind check would pour the + // snapshot in there and clear the queue — the card would never receive it. + const backup = path.join(baseDir, 'pc-games', 'saves', 'hades'); + expect(acceptsPendingFlush({ source: 'pc', saveOnCardPath: backup })).toBe(false); + expect(acceptsPendingFlush({ source: 'card', saveOnCardPath: backup })).toBe(true); + expect(acceptsPendingFlush({ source: 'card' })).toBe(false); + }); + + it('hasCardSyncState tells whether a card carrying this game was ever synced here', async () => { + // This is the gate for queueing a local game's progress for a card (see queueLocalProgressForCard): + // without it, every local session would leave a third copy of the saves behind forever. + expect(await store.hasCardSyncState('hades')).toBe(false); + await store.writeSyncState('hades', state('local.sav', 1), 'pc'); + expect(await store.hasCardSyncState('hades')).toBe(false); + await store.writeSyncState('hades', state('card.sav', 2), 'card'); + expect(await store.hasCardSyncState('hades')).toBe(true); + }); +}); diff --git a/test/process-monitor-darwin.test.ts b/test/process-monitor-darwin.test.ts new file mode 100644 index 00000000..2e3600fa --- /dev/null +++ b/test/process-monitor-darwin.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest'; +import { + descendantPids, + normalizeImageName, + parsePsCommand, + parsePsParents, + snapshotFromEntries, +} from '../src/main/platform/process-monitor.darwin'; + +describe('darwin ProcessMonitor — ps output parsing', () => { + it('reads pid and the full command path from `ps -axwwo pid=,comm=`', () => { + const stdout = [ + ' 1 /sbin/launchd', + ' 742 /Applications/Steam.app/Contents/MacOS/steam_osx', + '', + ].join('\n'); + expect(parsePsCommand(stdout)).toEqual([ + { pid: 1, imageName: 'launchd' }, + { pid: 742, imageName: 'steam_osx' }, + ]); + }); + + it('keeps spaces in a bundle path (only the pid field is split off)', () => { + const stdout = ' 1234 /Applications/My Game.app/Contents/MacOS/My Game'; + expect(parsePsCommand(stdout)).toEqual([{ pid: 1234, imageName: 'My Game' }]); + }); + + it('returns nothing for empty output', () => { + expect(parsePsCommand('')).toEqual([]); + }); + + it('skips lines that carry no pid', () => { + expect(parsePsCommand('ps: illegal option\n 5 /bin/zsh')).toEqual([ + { pid: 5, imageName: 'zsh' }, + ]); + }); + + it('records a pid with no command as present but nameless', () => { + expect(parsePsCommand(' 99 ')).toEqual([{ pid: 99, imageName: null }]); + }); + + it('reads pid/ppid pairs and ignores anything else', () => { + const stdout = [' 1 0', ' 742 1', 'garbage', ' 900 742'].join('\n'); + expect(parsePsParents(stdout)).toEqual([ + { pid: 1, ppid: 0 }, + { pid: 742, ppid: 1 }, + { pid: 900, ppid: 742 }, + ]); + }); +}); + +describe('darwin ProcessMonitor — image-name matching', () => { + it('normalizes to a lower-cased basename without the .exe suffix', () => { + expect(normalizeImageName('Valheim.exe')).toBe('valheim'); + expect(normalizeImageName('valheim')).toBe('valheim'); + expect(normalizeImageName('/Applications/Valheim.app/Contents/MacOS/Valheim')).toBe('valheim'); + expect(normalizeImageName('C:\\Games\\Valheim\\valheim.exe')).toBe('valheim'); + }); + + it('matches a watched name with OR without .exe against the running mac binary', () => { + const snapshot = snapshotFromEntries([{ pid: 10, imageName: 'valheim' }]); + expect(snapshot.hasImageName('valheim.exe')).toBe(true); + expect(snapshot.hasImageName('valheim')).toBe(true); + expect(snapshot.hasImageName('VALHEIM.EXE')).toBe(true); + }); + + it('does not match a different game (exact basename, not substring)', () => { + const snapshot = snapshotFromEntries([{ pid: 10, imageName: 'valheim_server' }]); + expect(snapshot.hasImageName('valheim.exe')).toBe(false); + }); + + it('tracks pids and ignores nameless entries for name matching', () => { + const snapshot = snapshotFromEntries([ + { pid: 10, imageName: null }, + { pid: 11, imageName: 'hades' }, + ]); + expect(snapshot.hasPid(10)).toBe(true); + expect(snapshot.hasPid(12)).toBe(false); + expect(snapshot.hasImageName('hades')).toBe(true); + }); +}); + +describe('darwin ProcessMonitor — process tree', () => { + const links = [ + { pid: 1, ppid: 0 }, + { pid: 100, ppid: 1 }, + { pid: 200, ppid: 100 }, + { pid: 201, ppid: 100 }, + { pid: 300, ppid: 200 }, + { pid: 400, ppid: 1 }, + ]; + + it('collects the root plus every descendant', () => { + expect([...descendantPids(100, links)].sort((a, b) => a - b)).toEqual([100, 200, 201, 300]); + }); + + it('returns just the pid when it has no children', () => { + expect(descendantPids(400, links)).toEqual([400]); + }); + + it('returns the pid itself when it is not in the table at all', () => { + expect(descendantPids(999, links)).toEqual([999]); + }); + + it('terminates on a cyclic parent chain', () => { + const cyclic = [ + { pid: 10, ppid: 11 }, + { pid: 11, ppid: 10 }, + ]; + expect([...descendantPids(10, cyclic)].sort((a, b) => a - b)).toEqual([10, 11]); + }); +}); diff --git a/test/renderer/file-picker.test.ts b/test/renderer/file-picker.test.ts new file mode 100644 index 00000000..18e2f3b0 --- /dev/null +++ b/test/renderer/file-picker.test.ts @@ -0,0 +1,351 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createFilePicker } from '../../src/renderer/file-picker'; +import { req } from '../../src/renderer/dom'; +import { createTranslator } from '../../src/shared/i18n/index'; +import type { FilePickerSurface } from '../../src/renderer/game-settings-screen'; +import type { ConfigPickKind, ConfigPickResult } from '../../src/shared/types'; +import { hoverOver, loadFixture } from './helpers/fixture'; +import { + fakeAudio, + fakeFilePickerApi, + type FakeAudio, + type FakeFilePickerApi, +} from './helpers/fakes'; +import { flushAsync } from './helpers/async'; +import { installRafHarness } from './helpers/raf'; + +const TREE = { + '/card': [ + { name: 'games', kind: 'dir' as const }, + { name: 'game.json', kind: 'file' as const }, + ], + '/card/games': [ + { name: 'hades', kind: 'dir' as const }, + { name: 'run.exe', kind: 'file' as const }, + { name: 'cover.png', kind: 'file' as const }, + ], + '/card/games/hades': [{ name: 'hades.exe', kind: 'file' as const }], +}; + +let picker: FilePickerSurface; +let audio: FakeAudio; +let api: FakeFilePickerApi; +let results: ConfigPickResult[]; + +const rows = (): readonly string[] => + [...req('picker-entries').querySelectorAll('.picker-item')].map((item) => item.textContent ?? ''); + +const focusedRow = (): string | null => + req('picker-entries').querySelector('.picker-item.is-focused')?.textContent ?? null; + +const focusedRoot = (): string | null => + req('picker-roots').querySelector('.picker-item.is-focused')?.textContent ?? null; + +const focusIndex = (): number => + [...req('picker-entries').querySelectorAll('.picker-item')].findIndex((item) => + item.classList.contains('is-focused'), + ); + +async function open( + request: { kind?: ConfigPickKind; multi?: boolean; current?: string } = {}, +): Promise<void> { + picker.open({ + root: '/card', + kind: request.kind ?? 'executable', + current: request.current ?? '', + multi: request.multi ?? false, + onDone: (result) => { + results.push(result); + }, + }); + await flushAsync(); +} + +/** Steps down until the named row holds the focus — rows differ per kind, so no index is hard-coded. */ +function focusRow(label: string): void { + const target = rows().indexOf(label); + if (target === -1) throw new Error(`no row labelled ${label}`); + for (let step = 0; step < rows().length; step += 1) { + const at = focusIndex(); + if (at === target) return; + if (at < target) picker.navDown(); + else picker.navUp(); + } + throw new Error(`could not reach row ${label}`); +} + +beforeEach(() => { + loadFixture(); + installRafHarness(); + audio = fakeAudio(); + api = fakeFilePickerApi(TREE); + results = []; + picker = createFilePicker({ audio, getTranslator: () => createTranslator('en'), api }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('file picker opening', () => { + it('shows the requested directory once main answers', async () => { + picker.open({ + root: '/card', + kind: 'executable', + current: '', + multi: false, + onDone: () => undefined, + }); + + expect(rows()).toEqual([]); + + await flushAsync(); + + expect(req('file-picker').classList.contains('is-open')).toBe(true); + expect(req('picker-title').textContent).toBe('Choose'); + expect(req('picker-path').textContent).toBe('/card'); + expect(rows()).toEqual(['Cancel', 'games', 'game.json']); + }); + + it('lands the focus on the first real entry, past the action rows', async () => { + await open(); + + expect(focusedRow()).toBe('games'); + }); + + it('offers "Use this folder" for a directory field only', async () => { + await open({ kind: 'directory' }); + + expect(rows()).toContain('Use this folder'); + }); + + it('draws the roots column main travelled with the listing', async () => { + await open(); + + expect( + [...req('picker-roots').querySelectorAll('.picker-item')].map((item) => item.textContent), + ).toEqual(['Card']); + }); + + it('shows the failure message in place of a listing it could not read', async () => { + api = fakeFilePickerApi(TREE, '/nowhere'); + picker = createFilePicker({ audio, getTranslator: () => createTranslator('en'), api }); + + await open(); + + expect(req('picker-path').textContent).toBe('no such directory: /nowhere'); + expect(rows()).toEqual([]); + }); +}); + +describe('file picker navigation', () => { + it('moves the focus class down the column and stops at the end', async () => { + await open(); + + picker.navDown(); + expect(focusedRow()).toBe('game.json'); + + picker.navDown(); + expect(focusedRow()).toBe('game.json'); + expect(audio.limits()).toBe(1); + }); + + it('switches columns left and right', async () => { + await open(); + + picker.navLeft(); + expect(focusedRoot()).toBe('Card'); + expect(focusedRow()).toBe(null); + + picker.navRight(); + expect(focusedRoot()).toBe(null); + expect(focusedRow()).toBe('games'); + }); + + it('jumps between the tree and the action rows with Y', async () => { + await open(); + + picker.navTertiary?.(); + expect(focusedRow()).toBe('Cancel'); + + picker.navTertiary?.(); + expect(focusedRow()).toBe('games'); + }); +}); + +describe('file picker walking the tree', () => { + it('redraws the listing and the path on entering a directory', async () => { + await open(); + + picker.navActivate(); + await flushAsync(); + + expect(req('picker-path').textContent).toBe('/card/games'); + expect(rows()).toEqual(['Cancel', 'Up one level', 'hades', 'run.exe', 'cover.png']); + expect(api.listed).toEqual(['/card', '/card/games']); + }); + + it('goes back up a level and puts the focus on the folder it came out of', async () => { + await open(); + picker.navActivate(); + await flushAsync(); + + picker.navBack(); + await flushAsync(); + + expect(req('picker-path').textContent).toBe('/card'); + expect(focusedRow()).toBe('games'); + }); + + it('answers the dead-end sound at the top of the filesystem', async () => { + await open(); + + picker.navBack(); + await flushAsync(); + + expect(audio.limits()).toBe(1); + expect(api.listed).toEqual(['/card']); + }); +}); + +describe('file picker choosing', () => { + it('hands the accepted path back and closes', async () => { + await open(); + picker.navActivate(); + await flushAsync(); + focusRow('run.exe'); + + picker.navActivate(); + await flushAsync(); + + expect(api.accepted).toEqual([['/card/games/run.exe']]); + expect(results).toEqual([{ ok: true, paths: ['/card/games/run.exe'] }]); + expect(picker.isOpen()).toBe(false); + expect(req('file-picker').classList.contains('is-open')).toBe(false); + }); + + it('stays open and shows why when main refuses the path', async () => { + await open(); + api.acceptWith = () => ({ ok: false, message: 'Outside the card' }); + picker.navActivate(); + await flushAsync(); + focusRow('run.exe'); + + picker.navActivate(); + await flushAsync(); + + expect(req('picker-path').textContent).toBe('Outside the card'); + expect(results).toEqual([]); + expect(picker.isOpen()).toBe(true); + expect(audio.limits()).toBe(1); + }); + + it('refuses a file for a folder field', async () => { + await open({ kind: 'directory' }); + focusRow('game.json'); + + picker.navActivate(); + await flushAsync(); + + expect(api.accepted).toEqual([]); + expect(audio.limits()).toBe(1); + }); + + it('picks the directory it is standing in', async () => { + await open({ kind: 'directory' }); + focusRow('Use this folder'); + + picker.navActivate(); + await flushAsync(); + + expect(api.accepted).toEqual([['/card']]); + }); + + it('reports a cancellation from the Cancel row', async () => { + await open(); + focusRow('Cancel'); + + picker.navActivate(); + await flushAsync(); + + expect(results).toEqual([{ ok: false, cancelled: true }]); + expect(picker.isOpen()).toBe(false); + }); +}); + +describe('file picker mouse', () => { + it('takes the focus on hover once the mouse is awake', async () => { + await open(); + const target = [...req('picker-entries').querySelectorAll<HTMLElement>('.picker-item')][2]; + + if (target === undefined) throw new Error('the picker drew no entries'); + hoverOver(target); + + expect(focusedRow()).toBe('game.json'); + }); + + it('ignores hover while the mouse is still asleep', async () => { + await open(); + const target = [...req('picker-entries').querySelectorAll<HTMLElement>('.picker-item')][2]; + + target?.dispatchEvent( + new MouseEvent('mousemove', { bubbles: true, clientX: 400, clientY: 300 }), + ); + + expect(focusedRow()).toBe('games'); + }); + + it('cancels on a click into the veil', async () => { + await open(); + + req('file-picker').querySelector<HTMLElement>('.picker-veil')?.click(); + await flushAsync(); + + expect(results).toEqual([{ ok: false, cancelled: true }]); + expect(picker.isOpen()).toBe(false); + }); +}); + +describe('file picker multi-select', () => { + it('ticks a file with X and marks it in the listing', async () => { + await open({ kind: 'image', multi: true }); + picker.navActivate(); + await flushAsync(); + focusRow('cover.png'); + + picker.navSecondary?.(); + + expect(req('picker-entries').querySelectorAll('.is-picked')).toHaveLength(1); + expect(rows().filter((row) => row === 'cover.png')).toHaveLength(1); + expect(api.accepted).toEqual([]); + }); + + it('finishes with every ticked file plus the one activated', async () => { + await open({ kind: 'image', multi: true }); + picker.navActivate(); + await flushAsync(); + focusRow('cover.png'); + picker.navSecondary?.(); + focusRow('run.exe'); + + picker.navActivate(); + await flushAsync(); + + expect(api.accepted).toEqual([['/card/games/cover.png', '/card/games/run.exe']]); + }); + + it('names the multi-select legend while it is open', async () => { + await open({ kind: 'image', multi: true }); + + expect(req('picker-legend').textContent).toContain('X - tick'); + }); + + it('refuses to tick anything in a single-select picker', async () => { + await open(); + + picker.navSecondary?.(); + + expect(req('picker-entries').querySelectorAll('.is-picked')).toHaveLength(0); + expect(audio.limits()).toBe(1); + }); +}); diff --git a/test/renderer/game-settings-screen.test.ts b/test/renderer/game-settings-screen.test.ts new file mode 100644 index 00000000..e7163ef9 --- /dev/null +++ b/test/renderer/game-settings-screen.test.ts @@ -0,0 +1,509 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + createGameSettingsScreen, + type GameSettingsConfirm, + type GameSettingsScreen, + type GameSettingsScreenApi, +} from '../../src/renderer/game-settings-screen'; +import { req } from '../../src/renderer/dom'; +import { createTranslator } from '../../src/shared/i18n/index'; +import { loadFixture } from './helpers/fixture'; +import { + fakeAudio, + fakeGameSettingsApi, + fakeKeyboard, + fakeOnlinePicker, + fakePicker, + type FakeAudio, + type FakeKeyboard, + type FakePicker, +} from './helpers/fakes'; +import { flushAsync } from './helpers/async'; +import { installRafHarness, type RafHarness } from './helpers/raf'; + +const HADES = { schemaVersion: 1, id: 'hades', title: 'Hades', executable: 'Hades.exe' }; +const BASTION = { schemaVersion: 1, id: 'bastion', title: 'Bastion', executable: 'Bastion.exe' }; + +/** The manifest exactly as the form serializes it back, so an untouched screen is not dirty. */ +const manifest = (games: readonly unknown[]): string => + `${JSON.stringify(games.length === 1 ? games[0] : games, null, 2)}\n`; + +const READ_OK = { + ok: true, + root: 'E:\\', + source: 'card', + signature: 'a|b', + text: manifest([HADES, BASTION]), + platform: 'windows', +} as const; + +const ROOT_OK = { + ok: true, + root: 'E:\\', + source: 'card', + signature: 'a|b', + hasManifest: false, + text: '', + platform: 'windows', +} as const; + +const CARD = { + root: 'E:\\', + label: 'Card', + kind: 'card', + signature: 'a|b', + hasManifest: false, + isActive: true, +} as const; + +/** A menu paints, then re-measures its marquee on the next frame — two is what a full open takes. */ +const OPEN_FRAMES = 2; + +let screen: GameSettingsScreen; +/** Every instance this test made, so `afterEach` closes what exists rather than a leftover from before. */ +const live: GameSettingsScreen[] = []; +let audio: FakeAudio; +let keyboard: FakeKeyboard; +let picker: FakePicker; +let api: GameSettingsScreenApi; +let raf: RafHarness; +let confirms: { readonly kind: GameSettingsConfirm; readonly title?: string }[]; +let errors: string[]; +let notes: string[]; +let closed: number; + +const sections = (): readonly string[] => + [...req('game-settings-nav').children].map((entry) => entry.textContent ?? ''); + +const focusedSection = (): string | null => + req('game-settings-nav').querySelector('.is-focused')?.textContent ?? null; + +const rows = (): readonly HTMLElement[] => [ + ...req('game-settings-list').querySelectorAll<HTMLElement>('.setting-row'), +]; + +const rowLabels = (): readonly string[] => + rows().map((row) => row.querySelector('.setting-label')?.textContent ?? ''); + +const focusedRowLabel = (): string | null => + req('game-settings-list').querySelector('.setting-row.is-focused .setting-label')?.textContent ?? + null; + +const rowOf = (label: string): HTMLElement => { + const row = rows().find((entry) => entry.querySelector('.setting-label')?.textContent === label); + if (row === undefined) throw new Error(`no row labelled ${label}`); + return row; +}; + +const valueOf = (label: string): string => + rowOf(label).querySelector('.setting-value')?.textContent ?? ''; + +const menuEntries = (): readonly string[] => + [...req('game-settings-options-list').querySelectorAll('.settings-option')].map( + (entry) => entry.textContent ?? '', + ); + +const status = (): string => req('game-settings-status').textContent ?? ''; + +function createScreen(overrides: Partial<GameSettingsScreenApi> = {}): void { + api = fakeGameSettingsApi({ read: vi.fn(() => Promise.resolve(READ_OK)), ...overrides }); + const instance = createGameSettingsScreen({ + audio, + getTranslator: () => createTranslator('en'), + api, + keyboard, + picker, + onlinePicker: fakeOnlinePicker(), + onClosed: () => { + closed += 1; + }, + onConfirmRequested: (kind, options) => { + confirms.push({ kind, ...(options?.title !== undefined ? { title: options.title } : {}) }); + }, + isBusy: () => false, + onAdded: () => undefined, + notify: (text) => { + notes.push(text); + }, + showError: (text) => { + errors.push(text); + }, + }); + live.push(instance); + screen = instance; +} + +async function open(overrides: Partial<GameSettingsScreenApi> = {}): Promise<void> { + createScreen(overrides); + screen.open('hades'); + await flushAsync(); +} + +/** Moves the column onto a section and steps into its pane. */ +function enterSection(title: string): void { + for (let step = 0; step < sections().length; step += 1) { + if (focusedSection() === title) break; + screen.navDown(); + } + if (focusedSection() !== title) throw new Error(`no section named ${title}`); + screen.navActivate(); +} + +function focusColumn(title: string): void { + if (focusedRowLabel() !== null) screen.navBack(); + for (let step = 0; step < sections().length; step += 1) { + if (focusedSection() === title) return; + screen.navDown(); + } + throw new Error(`no column entry named ${title}`); +} + +function focusRow(label: string): void { + for (let step = 0; step < rows().length; step += 1) { + if (focusedRowLabel() === label) return; + screen.navDown(); + } + throw new Error(`could not reach row ${label}`); +} + +function focusMenuEntry(label: string): void { + for (let step = 0; step < menuEntries().length; step += 1) { + const focused = req('game-settings-options-list').querySelector('.settings-option.is-focused'); + if (focused?.textContent === label) return; + screen.navDown(); + } + throw new Error(`could not reach menu entry ${label}`); +} + +beforeEach(() => { + loadFixture(); + raf = installRafHarness(); + audio = fakeAudio(); + keyboard = fakeKeyboard(); + picker = fakePicker(); + confirms = []; + errors = []; + notes = []; + closed = 0; +}); + +afterEach(() => { + for (const instance of live) instance.close(); + live.length = 0; + vi.unstubAllGlobals(); +}); + +describe('customize screen opening', () => { + it('shows the loading line until the manifest read lands', () => { + createScreen(); + + screen.open('hades'); + + expect(req('app').dataset['overlay']).toBe('game-settings'); + expect(req('game-settings-list').textContent).toBe('Reading the manifest...'); + expect(rows()).toHaveLength(0); + }); + + it('draws the column and the first section once the manifest arrives', async () => { + await open(); + + expect(req('game-settings-title').textContent).toBe('Customize'); + expect(req('game-settings-heading').textContent).toBe('Hades'); + expect(sections()).toContain('Basics'); + expect(rowLabels()).toContain('Title'); + expect(valueOf('Title')).toBe('Hades'); + }); + + it('reports a manifest it could not read instead of an empty form', async () => { + await open({ + read: vi.fn(() => Promise.resolve({ ok: false, message: 'Card removed' } as const)), + }); + + expect(req('game-settings-list').textContent).toContain('Card removed'); + expect(rows()).toHaveLength(0); + }); + + it('opens an empty form in add mode without reading a game', async () => { + createScreen({ + sources: vi.fn(() => Promise.resolve([CARD])), + readRoot: vi.fn(() => Promise.resolve(ROOT_OK)), + }); + + screen.openNew(); + await flushAsync(); + + expect(req('game-settings-title').textContent).toBe('Add game'); + expect(api.read).not.toHaveBeenCalled(); + expect(valueOf('Title')).toBe('not set'); + expect(rowOf('Title').querySelector('.setting-value')?.classList.contains('is-empty')).toBe( + true, + ); + }); +}); + +describe('customize screen navigation', () => { + it('replaces the pane with the section the column steps into', async () => { + await open(); + + enterSection('Artwork'); + + expect(rowLabels()).toEqual(['Backgrounds', 'Card artwork']); + expect(req('game-settings-list').classList.contains('is-active')).toBe(true); + }); + + it('moves the row focus in step with the DOM', async () => { + await open(); + enterSection('Basics'); + + screen.navDown(); + + expect(focusedRowLabel()).toBe('Id'); + expect(req('game-settings-list').querySelectorAll('.setting-row.is-focused')).toHaveLength(1); + }); + + it('hands the focus back to the column on back', async () => { + await open(); + enterSection('Basics'); + + screen.navBack(); + + expect(focusedRowLabel()).toBe(null); + expect(focusedSection()).toBe('Basics'); + }); +}); + +describe('customize screen text fields', () => { + it('opens the keyboard on the field value and writes back what it commits', async () => { + await open(); + enterSection('Basics'); + focusRow('Title'); + + screen.navActivate(); + + expect(keyboard.last().value).toBe('Hades'); + expect(keyboard.last().title).toBe('Title'); + expect(keyboard.last().mode).toBe('text'); + + keyboard.commit('Hades II'); + await flushAsync(); + + expect(valueOf('Title')).toBe('Hades II'); + expect(screen.isDirty()).toBe(true); + }); + + it('asks for the id in the mode the manifest schema accepts', async () => { + await open(); + enterSection('Basics'); + focusRow('Id'); + + screen.navActivate(); + + expect(keyboard.last().mode).toBe('id'); + }); + + it('leaves the form untouched when the keyboard is cancelled', async () => { + await open(); + enterSection('Basics'); + focusRow('Title'); + screen.navActivate(); + + keyboard.cancel(); + await flushAsync(); + + expect(valueOf('Title')).toBe('Hades'); + expect(screen.isDirty()).toBe(false); + }); +}); + +describe('customize screen file picking', () => { + it('browses from a path row and writes the picked path into it', async () => { + await open(); + enterSection('Launch'); + focusRow('Executable'); + + screen.navActivate(); + raf.flush(OPEN_FRAMES); + expect(menuEntries()).toContain('Browse...'); + + focusMenuEntry('Browse...'); + screen.navActivate(); + await flushAsync(); + + expect(picker.last().kind).toBe('executable'); + expect(picker.last().multi).toBe(false); + + picker.done({ ok: true, paths: ['bin/Hades.exe'] }); + await flushAsync(); + + expect(valueOf('Executable')).toBe('bin/Hades.exe'); + expect(req('game-settings-options').classList.contains('is-open')).toBe(false); + }); + + it('shows the reason main refused a path and keeps the form as it was', async () => { + await open(); + enterSection('Launch'); + focusRow('Executable'); + screen.navActivate(); + raf.flush(OPEN_FRAMES); + focusMenuEntry('Browse...'); + screen.navActivate(); + await flushAsync(); + + picker.done({ ok: false, message: 'Outside the card' }); + await flushAsync(); + + expect(errors).toEqual(['Outside the card']); + expect(valueOf('Executable')).toBe('Hades.exe'); + }); + + it('changes nothing when the browse is cancelled', async () => { + await open(); + enterSection('Launch'); + focusRow('Executable'); + screen.navActivate(); + raf.flush(OPEN_FRAMES); + focusMenuEntry('Browse...'); + screen.navActivate(); + await flushAsync(); + + picker.done({ ok: false, cancelled: true }); + await flushAsync(); + + expect(errors).toEqual([]); + expect(screen.isDirty()).toBe(false); + }); +}); + +describe('customize screen saving', () => { + it('says what is in flight and reports the result through the plate', async () => { + await open(); + enterSection('Basics'); + focusRow('Title'); + screen.navActivate(); + keyboard.commit('Hades II'); + await flushAsync(); + + focusColumn('Save'); + screen.navActivate(); + + expect(status()).toContain('Saving...'); + + await flushAsync(); + + expect(api.save).toHaveBeenCalled(); + expect(notes).toEqual(['Saved and applied.']); + expect(status()).toBe(''); + expect(screen.isDirty()).toBe(false); + }); + + it('holds a failed save in the error popup and clears the status line', async () => { + await open({ + save: vi.fn(() => Promise.resolve({ saved: false, message: 'Card is read-only' } as const)), + }); + enterSection('Basics'); + focusRow('Title'); + screen.navActivate(); + keyboard.commit('Hades II'); + await flushAsync(); + + focusColumn('Save'); + screen.navActivate(); + await flushAsync(); + + expect(errors).toEqual(['Card is read-only']); + expect(status()).toBe(''); + expect(screen.isDirty()).toBe(true); + }); +}); + +describe('customize screen confirmations', () => { + it('asks before deleting and writes the manifest without the game once it is answered', async () => { + await open(); + focusColumn('Delete game'); + + screen.navActivate(); + + expect(confirms).toEqual([{ kind: 'delete' }]); + expect(api.save).not.toHaveBeenCalled(); + + screen.confirmAccepted('delete'); + await flushAsync(); + + expect(api.save).toHaveBeenCalledWith( + expect.objectContaining({ root: 'E:\\', signature: 'a|b', text: manifest([BASTION]) }), + ); + expect(screen.isOpen()).toBe(false); + expect(closed).toBe(1); + }); + + it('drops the history record only when that is what was answered', async () => { + await open(); + focusColumn('Delete game'); + screen.navActivate(); + + screen.confirmAccepted('delete-history'); + await flushAsync(); + + expect(api.forgetHistory).toHaveBeenCalledWith('hades'); + }); + + it('asks before leaving with unsaved edits and closes once that is answered', async () => { + await open(); + enterSection('Basics'); + focusRow('Title'); + screen.navActivate(); + keyboard.commit('Hades II'); + await flushAsync(); + screen.navBack(); + + screen.navBack(); + + expect(confirms).toEqual([{ kind: 'discard' }]); + expect(screen.isOpen()).toBe(true); + + screen.confirmAccepted('discard'); + + expect(screen.isOpen()).toBe(false); + expect(closed).toBe(1); + }); + + it('puts the form back as it was read when a reset is answered', async () => { + await open(); + enterSection('Basics'); + focusRow('Title'); + screen.navActivate(); + keyboard.commit('Hades II'); + await flushAsync(); + + screen.confirmAccepted('reset'); + await flushAsync(); + + expect(valueOf('Title')).toBe('Hades'); + expect(screen.isDirty()).toBe(false); + }); +}); + +describe('customize screen closing', () => { + it('leaves without asking when nothing was edited', async () => { + await open(); + + screen.navBack(); + + expect(confirms).toEqual([]); + expect(screen.isOpen()).toBe(false); + expect(req('app').dataset['overlay']).toBeUndefined(); + }); + + it('takes its open menu and the keyboard with it', async () => { + await open(); + enterSection('Launch'); + focusRow('Executable'); + screen.navActivate(); + raf.flush(OPEN_FRAMES); + + screen.close(); + + expect(req('game-settings-options').classList.contains('is-open')).toBe(false); + expect(keyboard.isOpen()).toBe(false); + }); +}); diff --git a/test/renderer/helpers/async.ts b/test/renderer/helpers/async.ts new file mode 100644 index 00000000..87b37c3f --- /dev/null +++ b/test/renderer/helpers/async.ts @@ -0,0 +1,4 @@ +/** Lets pending microtask chains (`await api.listDir(…)`, `await load(id)`) settle before asserting. */ +export async function flushAsync(turns = 5): Promise<void> { + for (let turn = 0; turn < turns; turn += 1) await Promise.resolve(); +} diff --git a/test/renderer/helpers/fakes.ts b/test/renderer/helpers/fakes.ts new file mode 100644 index 00000000..6b7780ba --- /dev/null +++ b/test/renderer/helpers/fakes.ts @@ -0,0 +1,282 @@ +import { vi } from 'vitest'; +import type { AudioController } from '../../../src/renderer/audio'; +import type { + FilePickerSurface, + GameSettingsScreenApi, + TextEntrySurface, +} from '../../../src/renderer/game-settings-screen'; +import type { FilePickerApi } from '../../../src/renderer/file-picker'; +import type { OnlinePickerSurface } from '../../../src/renderer/online-picker'; +import type { SettingsScreenApi } from '../../../src/renderer/settings-screen'; +import type { + ConfigPickKind, + ConfigPickResult, + ConfigValidationResult, + ListDirResult, + SfxName, +} from '../../../src/shared/types'; + +export interface FakeAudio extends AudioController { + /** Every `play(name)` in order — the launcher's navigation feedback is part of the behaviour. */ + readonly played: readonly SfxName[]; + readonly limits: () => number; + readonly reset: () => void; +} + +export function fakeAudio(): FakeAudio { + const played: SfxName[] = []; + let limits = 0; + const noop = (): void => undefined; + return { + played, + limits: () => limits, + reset: () => { + played.length = 0; + limits = 0; + }, + setCardMusic: noop, + setBrowseMusic: noop, + setAmbient: noop, + setSounds: noop, + play: (name) => { + played.push(name); + }, + playLimit: () => { + limits += 1; + }, + rearmLimit: noop, + playStartup: () => Promise.resolve(), + setMusicPlaying: noop, + setMusicVolume: noop, + setSfxVolume: noop, + }; +} + +interface KeyboardRequest { + readonly value: string; + readonly mode: 'text' | 'id' | 'number'; + readonly title: string; + readonly onDone: (value: string) => void; +} + +export interface FakeKeyboard extends TextEntrySurface { + readonly requests: readonly KeyboardRequest[]; + readonly last: () => KeyboardRequest; + /** Answers the pending request, as the real keyboard's Done key does. */ + readonly commit: (value: string) => void; + /** Drops the pending request without answering, as B does. */ + readonly cancel: () => void; +} + +export function fakeKeyboard(): FakeKeyboard { + const requests: KeyboardRequest[] = []; + let pending: KeyboardRequest | null = null; + const noop = (): void => undefined; + return { + requests, + last: () => { + const request = requests.at(-1); + if (request === undefined) throw new Error('keyboard was never opened'); + return request; + }, + commit: (value) => { + const request = pending; + if (request === null) throw new Error('keyboard is not open'); + pending = null; + request.onDone(value); + }, + cancel: () => { + pending = null; + }, + isOpen: () => pending !== null, + open: (request) => { + requests.push(request); + pending = request; + }, + close: () => { + pending = null; + }, + navUp: noop, + navDown: noop, + navLeft: noop, + navRight: noop, + navActivate: noop, + navBack: noop, + relocalize: noop, + }; +} + +interface PickerRequest { + readonly root: string; + readonly kind: ConfigPickKind; + readonly current: string; + readonly multi: boolean; + readonly base?: string; + readonly onDone: (result: ConfigPickResult) => void; +} + +export interface FakePicker extends FilePickerSurface { + readonly requests: readonly PickerRequest[]; + readonly last: () => PickerRequest; + readonly done: (result: ConfigPickResult) => void; +} + +export function fakePicker(): FakePicker { + const requests: PickerRequest[] = []; + let pending: PickerRequest | null = null; + const noop = (): void => undefined; + return { + requests, + last: () => { + const request = requests.at(-1); + if (request === undefined) throw new Error('picker was never opened'); + return request; + }, + done: (result) => { + const request = pending; + if (request === null) throw new Error('picker is not open'); + pending = null; + request.onDone(result); + }, + isOpen: () => pending !== null, + open: (request) => { + requests.push(request); + pending = request; + }, + navUp: noop, + navDown: noop, + navLeft: noop, + navRight: noop, + navActivate: noop, + navBack: noop, + relocalize: noop, + }; +} + +interface OnlineRequest { + readonly query: string; + readonly appId?: number; +} + +export interface FakeOnlinePicker extends OnlinePickerSurface { + readonly requests: readonly OnlineRequest[]; +} + +export function fakeOnlinePicker(): FakeOnlinePicker { + const requests: OnlineRequest[] = []; + let open = false; + const noop = (): void => undefined; + return { + requests, + isOpen: () => open, + open: (request) => { + requests.push(request); + open = true; + }, + close: () => { + open = false; + }, + navUp: noop, + navDown: noop, + navLeft: noop, + navRight: noop, + navActivate: noop, + navBack: noop, + relocalize: noop, + }; +} + +export function fakeSettingsApi(): SettingsScreenApi { + return { + setAutoUpdate: vi.fn(), + setPrerelease: vi.fn(), + setSummonHotkey: vi.fn(), + setPreventScreensaver: vi.fn(), + setKeepOpenWithoutCard: vi.fn(), + setDisableSilentInstall: vi.fn(), + setSteamAutoLaunch: vi.fn(), + setSoundSet: vi.fn(), + setAmbientTrack: vi.fn(), + setOnlyGlobalAmbient: vi.fn(), + setMusicVolume: vi.fn(), + setSfxVolume: vi.fn(), + setLanguage: vi.fn(), + setSteamGridDbKey: vi.fn(), + resetSettings: vi.fn(), + checkForUpdates: vi.fn(), + downloadUpdate: vi.fn(), + installUpdate: vi.fn(), + }; +} + +/** A directory tree keyed by absolute path, as the picker's `listDir` sees it. */ +export interface FakeTree { + readonly [path: string]: readonly { readonly name: string; readonly kind: 'dir' | 'file' }[]; +} + +export interface FakeFilePickerApi extends FilePickerApi { + readonly listed: readonly string[]; + readonly accepted: readonly (readonly string[])[]; + /** What `acceptPaths` answers with; the default turns the picked paths into relative ones. */ + acceptWith: (paths: readonly string[]) => ConfigPickResult; +} + +const PICKER_ROOTS = [{ path: '/card', label: 'Card', kind: 'card' as const }]; + +export function fakeFilePickerApi(tree: FakeTree, start = '/card'): FakeFilePickerApi { + const listed: string[] = []; + const accepted: (readonly string[])[] = []; + const parentOf = (path: string): string | null => { + const at = path.lastIndexOf('/'); + if (at <= 0) return null; + return path.slice(0, at); + }; + const api: FakeFilePickerApi = { + listed, + accepted, + acceptWith: (paths) => ({ ok: true, paths }), + listDir: (request) => { + const path = request.path ?? start; + listed.push(path); + const entries = tree[path]; + const result: ListDirResult = + entries === undefined + ? { ok: false, message: `no such directory: ${path}`, roots: PICKER_ROOTS } + : { ok: true, path, parent: parentOf(path), entries, roots: PICKER_ROOTS }; + return Promise.resolve(result); + }, + acceptPaths: (request) => { + accepted.push(request.paths); + return Promise.resolve(api.acceptWith(request.paths)); + }, + }; + return api; +} + +const VALID: ConfigValidationResult = { ok: true }; + +export function fakeGameSettingsApi( + overrides: Partial<GameSettingsScreenApi> = {}, +): GameSettingsScreenApi { + return { + read: vi.fn(() => Promise.resolve({ ok: false, message: 'not stubbed' } as const)), + validate: vi.fn(() => Promise.resolve(VALID)), + save: vi.fn(() => Promise.resolve({ saved: true, applied: 'applied' } as const)), + imagePreview: vi.fn(() => Promise.resolve(null)), + sources: vi.fn(() => Promise.resolve([])), + readRoot: vi.fn(() => Promise.resolve({ ok: false, message: 'not stubbed' } as const)), + forgetHistory: vi.fn(), + moveToCard: vi.fn(() => Promise.resolve({ moved: false, message: 'not stubbed' } as const)), + acceptPath: vi.fn(() => Promise.resolve({ ok: false, cancelled: true } as const)), + searchMetadata: vi.fn(() => Promise.resolve({ ok: true, value: [] } as const)), + requestSteamCandidate: vi.fn(() => + Promise.resolve({ ok: false, message: 'not stubbed' } as const), + ), + metadataDescriptions: vi.fn(() => + Promise.resolve({ ok: false, message: 'not stubbed' } as const), + ), + applyMetadata: vi.fn(() => Promise.resolve({ ok: false, message: 'not stubbed' } as const)), + cancelMetadata: vi.fn(), + ...overrides, + }; +} diff --git a/test/renderer/helpers/fixture.ts b/test/renderer/helpers/fixture.ts new file mode 100644 index 00000000..14c9e31f --- /dev/null +++ b/test/renderer/helpers/fixture.ts @@ -0,0 +1,45 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const INDEX_HTML = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../../src/renderer/index.html', +); + +const BODY = /<body>([\s\S]*)<\/body>/; +const MODULE_SCRIPT = /<script type="module"[\s\S]*?<\/script>/g; + +function readBody(): string { + const html = fs.readFileSync(INDEX_HTML, 'utf8'); + const body = BODY.exec(html); + if (body === null) throw new Error('index.html has no <body>'); + return (body[1] ?? '').replace(MODULE_SCRIPT, ''); +} + +const bodyHtml = readBody(); + +/** + * Installs the real renderer markup so `req()` finds every id the controllers ask for. `mouse-asleep` + * lives on `<html>` in index.html — outside the body — and is the state every mousemove branch reads, + * so it is restored here too. Call it per test: the controllers never remove their listeners, so a + * fixture shared across tests collects one live instance per test on the same nodes. + */ +export function loadFixture(): void { + document.body.innerHTML = bodyHtml; + document.documentElement.className = 'mouse-asleep'; +} + +/** Drops `mouse-asleep`, as controls.ts does on the first real move — every hover branch is behind it. */ +export function wakeMouse(): void { + document.documentElement.classList.remove('mouse-asleep'); +} + +/** + * A mouse move onto `target`. The hover guard ignores a move that lands within 6px of where it was armed + * (the UI arriving under a still cursor), so the default coordinates sit well clear of that. + */ +export function hoverOver(target: Element, x = 400, y = 300): void { + wakeMouse(); + target.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: x, clientY: y })); +} diff --git a/test/renderer/helpers/raf.ts b/test/renderer/helpers/raf.ts new file mode 100644 index 00000000..f089cbb4 --- /dev/null +++ b/test/renderer/helpers/raf.ts @@ -0,0 +1,63 @@ +import { vi } from 'vitest'; + +export interface RafHarness { + /** + * Runs exactly `frames` batches of the callbacks queued so far. Never drains the queue: the marquees + * reschedule themselves while element widths are zero, which they always are without layout. + */ + readonly flush: (frames?: number) => void; + /** Advances the faked clock the scroller and the marquees read through `performance.now()`. */ + readonly advance: (ms: number) => void; + readonly now: () => number; +} + +/** + * `performance` with only `now` replaced. A plain `{ now }` would drop mark/measure/timeOrigin, and + * copying them off the real object breaks them (their `this` must be the genuine Performance), so the + * rest is forwarded to it — a future `performance.mark` behaves instead of throwing out of the harness. + */ +function fakeClock(now: () => number): Performance { + return new Proxy(performance, { + get: (target, property, receiver) => { + if (property === 'now') return now; + const value: unknown = Reflect.get(target, property, receiver); + if (typeof value !== 'function') return value; + return (value as (this: Performance, ...args: readonly unknown[]) => unknown).bind(target); + }, + }); +} + +/** Deterministic rAF + performance clock, removed by `vi.unstubAllGlobals()` in `afterEach`. */ +export function installRafHarness(): RafHarness { + let queue = new Map<number, FrameRequestCallback>(); + let nextHandle = 1; + let now = 0; + + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + const handle = nextHandle; + nextHandle += 1; + queue.set(handle, callback); + return handle; + }); + vi.stubGlobal('cancelAnimationFrame', (handle: number) => { + queue.delete(handle); + }); + vi.stubGlobal( + 'performance', + fakeClock(() => now), + ); + + return { + flush: (frames = 1) => { + for (let frame = 0; frame < frames; frame += 1) { + const batch = queue; + queue = new Map(); + for (const callback of batch.values()) callback(now); + } + }, + advance: (ms) => { + now += ms; + }, + now: () => now, + }; +} diff --git a/test/renderer/osk.test.ts b/test/renderer/osk.test.ts new file mode 100644 index 00000000..5358df92 --- /dev/null +++ b/test/renderer/osk.test.ts @@ -0,0 +1,356 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { createOsk } from '../../src/renderer/osk'; +import { req } from '../../src/renderer/dom'; +import { createTranslator } from '../../src/shared/i18n/index'; +import type { TextEntrySurface } from '../../src/renderer/game-settings-screen'; +import { loadFixture } from './helpers/fixture'; +import { fakeAudio, type FakeAudio } from './helpers/fakes'; +import { flushAsync } from './helpers/async'; + +let osk: TextEntrySurface; +let audio: FakeAudio; +let clipboard: string; +let committed: string[]; + +const keys = (): readonly HTMLButtonElement[] => [ + ...req('osk-keys').querySelectorAll<HTMLButtonElement>('.osk-key'), +]; + +const rowLabels = (): readonly (readonly string[])[] => + [...req('osk-keys').querySelectorAll<HTMLElement>('.osk-row')].map((row) => + [...row.querySelectorAll('.osk-key')].map((key) => key.textContent ?? ''), + ); + +const focusedKey = (): string | null => + req('osk-keys').querySelector('.osk-key.is-focused')?.textContent ?? null; + +const field = (): { readonly before: string; readonly after: string } => ({ + before: req('osk-value').textContent ?? '', + after: req('osk-value-after').textContent ?? '', +}); + +/** Walks the grid to a key by its label — the gamepad path, without hard-coding row/column numbers. */ +function focusKey(label: string): void { + const target = rowLabels().findIndex((entries) => entries.includes(label)); + if (target === -1) throw new Error(`no key labelled ${label}`); + for (let step = 0; step < rowLabels().length; step += 1) { + const at = focusedRow(); + if (at === target) break; + if (at < target) osk.navDown(); + else osk.navUp(); + } + const width = rowLabels()[target]?.length ?? 0; + for (let step = 0; step <= width; step += 1) { + if (focusedKey() === label) return; + osk.navRight(); + } + throw new Error(`could not reach key ${label} in row ${String(target)}`); +} + +function focusedRow(): number { + return [...req('osk-keys').querySelectorAll<HTMLElement>('.osk-row')].findIndex( + (row) => row.querySelector('.osk-key.is-focused') !== null, + ); +} + +function type(text: string): void { + for (const character of text) { + focusKey(character); + osk.navActivate(); + } +} + +function open( + request: { value?: string; mode?: 'text' | 'id' | 'number'; title?: string } = {}, +): void { + osk.open({ + value: request.value ?? '', + mode: request.mode ?? 'text', + title: request.title ?? 'Title', + onDone: (value) => { + committed.push(value); + }, + }); +} + +beforeAll(() => { + loadFixture(); + audio = fakeAudio(); + osk = createOsk({ + audio, + getTranslator: () => createTranslator('en'), + readClipboard: () => Promise.resolve(clipboard), + }); +}); + +afterEach(() => { + osk.close(); +}); + +beforeEach(() => { + audio.reset(); + clipboard = ''; + committed = []; +}); + +describe('osk opening', () => { + it('shows the title, the value and the caret at its end', () => { + open({ value: 'Hades', title: 'Game title' }); + + expect(req('osk').classList.contains('is-open')).toBe(true); + expect(req('osk').getAttribute('aria-hidden')).toBe('false'); + expect(req('osk-title').textContent).toBe('Game title'); + expect(field()).toEqual({ before: 'Hades', after: '' }); + }); + + it('starts on the first key of the first row', () => { + open(); + + expect(focusedKey()).toBe('1'); + }); + + it('names only the buttons the current mode actually has', () => { + open({ mode: 'number' }); + + expect(req('osk-legend').textContent).toBe('X - delete, RT - done, B - cancel'); + }); +}); + +describe('osk layouts per mode', () => { + it('offers letters, a shift and three layouts in text mode', () => { + open({ mode: 'text' }); + + const labels = rowLabels().flat(); + expect(labels).toContain('q'); + expect(labels).toContain('Shift'); + expect(labels).toContain('АБВ'); + }); + + it('drops shift and cyrillic in id mode', () => { + open({ mode: 'id' }); + + const labels = rowLabels().flat(); + expect(labels).toContain('q'); + expect(labels).not.toContain('Shift'); + expect(labels).toContain('#+='); + }); + + it('offers digits alone in number mode', () => { + open({ mode: 'number' }); + + const labels = rowLabels().flat(); + expect(labels).not.toContain('q'); + expect(labels).not.toContain('Space'); + expect(labels.filter((label) => /^[0-9]$/.test(label))).toHaveLength(10); + }); + + it('switches the layout on a shoulder press and lands the focus back on the layout key', () => { + open({ mode: 'text' }); + + osk.navShoulder?.(1); + + expect(rowLabels().flat()).toContain('й'); + expect(focusedKey()).toBe('#+='); + }); + + it('answers a shoulder press with the dead-end sound when there is nothing to switch to', () => { + open({ mode: 'number' }); + + osk.navShoulder?.(1); + + expect(audio.limits()).toBe(1); + }); +}); + +describe('osk navigation', () => { + it('moves the focus class with the grid', () => { + open(); + + osk.navRight(); + expect(focusedKey()).toBe('2'); + + osk.navDown(); + expect(focusedRow()).toBe(1); + expect(keys().filter((key) => key.classList.contains('is-focused'))).toHaveLength(1); + }); + + it('wraps within a row and stops at the top of the grid', () => { + open(); + + osk.navLeft(); + expect(focusedKey()).toBe('0'); + + osk.navUp(); + expect(focusedRow()).toBe(0); + expect(audio.limits()).toBe(1); + }); +}); + +describe('osk typing', () => { + it('writes the activated key into the field', () => { + open(); + + type('cat'); + + expect(field()).toEqual({ before: 'cat', after: '' }); + expect(audio.played.filter((name) => name === 'typing')).toHaveLength(3); + }); + + it('applies shift to the next character only', () => { + open(); + + osk.navTertiary?.(); + type('H'); + type('i'); + + expect(field().before).toBe('Hi'); + }); + + it('lower-cases what an id field is given', () => { + open({ mode: 'id' }); + + type('a1'); + + expect(field().before).toBe('a1'); + expect(rowLabels().flat()).not.toContain('Shift'); + }); + + it('deletes backwards through the secondary button and stops at the start', () => { + open({ value: 'ab' }); + + osk.navSecondary?.(); + expect(field()).toEqual({ before: 'a', after: '' }); + + osk.navSecondary?.(); + osk.navSecondary?.(); + + expect(field()).toEqual({ before: '', after: '' }); + expect(audio.limits()).toBe(1); + }); + + it('splits the value around the caret and inserts there', () => { + open({ value: 'ac' }); + + focusKey('◀'); + osk.navActivate(); + expect(field()).toEqual({ before: 'a', after: 'c' }); + + type('b'); + + expect(field()).toEqual({ before: 'ab', after: 'c' }); + }); +}); + +describe('osk clipboard', () => { + it('inserts the sanitized clipboard once main answers', async () => { + clipboard = 'Ha\ndes'; + open(); + + focusKey('Paste'); + osk.navActivate(); + expect(field().before).toBe(''); + + await flushAsync(); + + expect(field().before).toBe('Ha des'); + }); + + it('filters a paste through the mode of the field', async () => { + clipboard = 'Hades 2'; + open({ mode: 'id' }); + + focusKey('Paste'); + osk.navActivate(); + await flushAsync(); + + expect(field().before).toBe('hades2'); + }); +}); + +describe('osk committing', () => { + it('hands the typed value back and closes', () => { + open(); + + type('ok'); + osk.navCommit?.(); + + expect(committed).toEqual(['ok']); + expect(osk.isOpen()).toBe(false); + expect(req('osk').classList.contains('is-open')).toBe(false); + expect(req('osk').getAttribute('aria-hidden')).toBe('true'); + }); + + it('commits from the Done key too', () => { + open({ value: 'x' }); + + focusKey('Done'); + osk.navActivate(); + + expect(committed).toEqual(['x']); + }); + + it('answers nothing when it is cancelled', () => { + open({ value: 'x' }); + + osk.navBack(); + + expect(committed).toEqual([]); + expect(osk.isOpen()).toBe(false); + }); + + it('answers nothing when the screen under it closes the keyboard', () => { + open({ value: 'x' }); + + osk.close(); + + expect(committed).toEqual([]); + expect(osk.isOpen()).toBe(false); + }); +}); + +describe('osk physical keyboard', () => { + const press = (init: KeyboardEventInit): void => { + window.dispatchEvent( + new KeyboardEvent('keydown', { ...init, bubbles: true, cancelable: true }), + ); + }; + + it('types a character straight through and swallows the event', () => { + open(); + + const event = new KeyboardEvent('keydown', { key: 'z', bubbles: true, cancelable: true }); + window.dispatchEvent(event); + + expect(field().before).toBe('z'); + expect(event.defaultPrevented).toBe(true); + }); + + it('commits on Enter and cancels on Escape', () => { + open({ value: 'a' }); + press({ key: 'Enter' }); + expect(committed).toEqual(['a']); + + open({ value: 'b' }); + press({ key: 'Escape' }); + expect(committed).toEqual(['a']); + }); + + it('moves the caret with the arrows instead of the key highlight', () => { + open({ value: 'ab' }); + const before = focusedKey(); + + press({ key: 'ArrowLeft' }); + + expect(field()).toEqual({ before: 'a', after: 'b' }); + expect(focusedKey()).toBe(before); + }); + + it('ignores a keystroke once the keyboard is closed', () => { + open({ value: 'a' }); + osk.close(); + + press({ key: 'z' }); + + expect(field().before).toBe('a'); + }); +}); diff --git a/test/renderer/screen-sidebar.test.ts b/test/renderer/screen-sidebar.test.ts new file mode 100644 index 00000000..eaf710c5 --- /dev/null +++ b/test/renderer/screen-sidebar.test.ts @@ -0,0 +1,227 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createSidebar, type Sidebar, type SidebarEntry } from '../../src/renderer/screen-sidebar'; +import { req } from '../../src/renderer/dom'; +import { loadFixture } from './helpers/fixture'; +import { fakeAudio, type FakeAudio } from './helpers/fakes'; +import { installRafHarness } from './helpers/raf'; + +const ENTRIES: readonly SidebarEntry[] = [ + { id: 'general', label: 'General', kind: 'section' }, + { id: 'audio', label: 'Audio', kind: 'section' }, + { id: 'save', label: 'Save', kind: 'action' }, + { id: 'delete', label: 'Delete', kind: 'action', danger: true }, +]; + +interface Harness { + readonly sidebar: Sidebar; + readonly box: HTMLElement; + readonly audio: FakeAudio; + readonly sections: readonly { readonly id: string; readonly entered: boolean }[]; + readonly actions: readonly string[]; +} + +function harness(): Harness { + const audio = fakeAudio(); + const sections: { readonly id: string; readonly entered: boolean }[] = []; + const actions: string[] = []; + const box = req('settings-nav'); + const sidebar = createSidebar(box, { + audio, + onSection: (id, entered) => { + sections.push({ id, entered }); + }, + onAction: (id) => { + actions.push(id); + }, + }); + return { sidebar, box, audio, sections, actions }; +} + +const labels = (box: HTMLElement): readonly string[] => + [...box.children].map((node) => node.textContent ?? ''); + +const focused = (box: HTMLElement): string | null => + box.querySelector('.is-focused')?.textContent ?? null; + +beforeEach(() => { + loadFixture(); + installRafHarness(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('sidebar rendering', () => { + it('draws every entry as a button carrying its kind and danger flag', () => { + const { sidebar, box } = harness(); + sidebar.render(ENTRIES); + + expect(labels(box)).toEqual(['General', 'Audio', 'Save', 'Delete']); + const buttons = [...box.querySelectorAll('button')]; + expect(buttons.map((button) => button.dataset['kind'])).toEqual([ + 'section', + 'section', + 'action', + 'action', + ]); + expect(buttons[3]?.classList.contains('is-danger')).toBe(true); + expect(focused(box)).toBe('General'); + }); + + it('keeps the selection on the same entry across a rebuild that reorders nothing', () => { + const { sidebar, box } = harness(); + sidebar.render(ENTRIES); + sidebar.move(1); + + sidebar.render([...ENTRIES, { id: 'close', label: 'Close', kind: 'action' }]); + + expect(sidebar.selected()?.id).toBe('audio'); + expect(focused(box)).toBe('Audio'); + expect(labels(box)).toEqual(['General', 'Audio', 'Save', 'Delete', 'Close']); + }); + + it('removes the node of an entry that is gone', () => { + const { sidebar, box } = harness(); + sidebar.render(ENTRIES); + + sidebar.render(ENTRIES.filter((entry) => entry.id !== 'delete')); + + expect(labels(box)).toEqual(['General', 'Audio', 'Save']); + }); + + it('marks a disabled entry and refuses to run it', () => { + const { sidebar, box, actions } = harness(); + sidebar.render([ + ...ENTRIES.slice(0, 2), + { id: 'save', label: 'Save', kind: 'action', disabled: true }, + ]); + sidebar.move(2); + + sidebar.activate(); + + expect(box.querySelectorAll('.is-disabled')).toHaveLength(1); + expect(actions).toEqual([]); + }); +}); + +describe('sidebar movement', () => { + it('moves the focus class with the selection and previews the section it lands on', () => { + const { sidebar, box, sections, audio } = harness(); + sidebar.render(ENTRIES); + + sidebar.move(1); + + expect(sidebar.selected()?.id).toBe('audio'); + expect(focused(box)).toBe('Audio'); + expect(sections).toEqual([{ id: 'audio', entered: false }]); + expect(audio.played).toEqual(['navigate']); + }); + + it('wraps from the last entry to the first', () => { + const { sidebar, box } = harness(); + sidebar.render(ENTRIES); + + sidebar.move(-1); + + expect(sidebar.selected()?.id).toBe('delete'); + expect(focused(box)).toBe('Delete'); + }); + + it('announces nothing when the selection lands on an action', () => { + const { sidebar, sections } = harness(); + sidebar.render(ENTRIES); + + sidebar.move(2); + + expect(sections).toEqual([]); + }); +}); + +describe('sidebar activation', () => { + it('enters the selected section', () => { + const { sidebar, sections, audio } = harness(); + sidebar.render(ENTRIES); + + sidebar.activate(); + + expect(sections).toEqual([{ id: 'general', entered: true }]); + expect(audio.played).toEqual(['button']); + }); + + it('runs the selected action', () => { + const { sidebar, actions } = harness(); + sidebar.render(ENTRIES); + sidebar.move(2); + + sidebar.activate(); + + expect(actions).toEqual(['save']); + }); + + it('runs the action a click lands on and takes the focus with it', () => { + const { sidebar, box, actions } = harness(); + sidebar.render(ENTRIES); + + box.querySelectorAll('button')[2]?.click(); + + expect(actions).toEqual(['save']); + expect(focused(box)).toBe('Save'); + }); +}); + +describe('sidebar focus handover', () => { + it('marks the shown section as current once the pane takes the focus', () => { + const { sidebar, box } = harness(); + sidebar.render(ENTRIES); + sidebar.move(1); + + sidebar.setFocused(false); + + expect(sidebar.hasFocus()).toBe(false); + expect(focused(box)).toBe(null); + expect(box.querySelector('.is-current')?.textContent).toBe('Audio'); + }); + + it('leaves no current mark when the pane is entered from an action', () => { + const { sidebar, box } = harness(); + sidebar.render(ENTRIES); + sidebar.move(2); + + sidebar.setFocused(false); + + expect(box.querySelector('.is-current')).toBe(null); + }); +}); + +describe('sidebar selection by id', () => { + it('selects a known id silently', () => { + const { sidebar, box, sections, audio } = harness(); + sidebar.render(ENTRIES); + + expect(sidebar.select('save')).toBe(true); + expect(focused(box)).toBe('Save'); + expect(sections).toEqual([]); + expect(audio.played).toEqual([]); + }); + + it('reports an unknown id instead of silently doing nothing', () => { + const { sidebar, box } = harness(); + sidebar.render(ENTRIES); + + expect(sidebar.select('nowhere')).toBe(false); + expect(focused(box)).toBe('General'); + }); + + it('puts the selection back on the first entry without emptying the column', () => { + const { sidebar, box } = harness(); + sidebar.render(ENTRIES); + sidebar.move(2); + + sidebar.reset(); + + expect(sidebar.selected()?.id).toBe('general'); + expect(focused(box)).toBe('General'); + expect(labels(box)).toEqual(['General', 'Audio', 'Save', 'Delete']); + }); +}); diff --git a/test/renderer/settings-screen.test.ts b/test/renderer/settings-screen.test.ts new file mode 100644 index 00000000..e7af5865 --- /dev/null +++ b/test/renderer/settings-screen.test.ts @@ -0,0 +1,441 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createSettingsScreen, type SettingsScreen } from '../../src/renderer/settings-screen'; +import { req } from '../../src/renderer/dom'; +import { createTranslator } from '../../src/shared/i18n/index'; +import { DEFAULT_SETTINGS } from '../../src/main/app-settings'; +import type { AppSettings } from '../../src/shared/types'; +import type { SettingsScreenApi } from '../../src/renderer/settings-screen'; +import { hoverOver, loadFixture } from './helpers/fixture'; +import { + fakeAudio, + fakeKeyboard, + fakeSettingsApi, + type FakeAudio, + type FakeKeyboard, +} from './helpers/fakes'; +import { installRafHarness, type RafHarness } from './helpers/raf'; + +/** The dropdown paints, then re-measures its marquee on the next frame — two is what a full open takes. */ +const OPEN_FRAMES = 2; + +const AUDIO_OPTIONS = { + soundSets: ['playhook-abyss', 'ps5'], + ambientTracks: ['deep-space.mp3', 'playhook-abyss.mp3'], +}; + +let screen: SettingsScreen; +let audio: FakeAudio; +let keyboard: FakeKeyboard; +let api: SettingsScreenApi; +let raf: RafHarness; +let closed: number; + +const settings = (overrides: Partial<AppSettings> = {}): AppSettings => ({ + ...DEFAULT_SETTINGS, + ...overrides, +}); + +const sections = (): readonly string[] => + [...req('settings-nav').children].map((entry) => entry.textContent ?? ''); + +const focusedSection = (): string | null => + req('settings-nav').querySelector('.is-focused')?.textContent ?? null; + +const rows = (): readonly HTMLElement[] => [ + ...req('settings-list').querySelectorAll<HTMLElement>('.setting-row'), +]; + +const rowLabels = (): readonly string[] => + rows().map((row) => row.querySelector('.setting-label')?.textContent ?? ''); + +const focusedRowLabel = (): string | null => + req('settings-list').querySelector('.setting-row.is-focused .setting-label')?.textContent ?? null; + +const rowOf = (label: string): HTMLElement => { + const row = rows().find((entry) => entry.querySelector('.setting-label')?.textContent === label); + if (row === undefined) throw new Error(`no row labelled ${label}`); + return row; +}; + +const valueOf = (label: string): string => + rowOf(label).querySelector('.setting-value')?.textContent ?? ''; + +const isOn = (label: string): boolean => + rowOf(label).querySelector('.setting-toggle')?.classList.contains('is-on') ?? false; + +const options = (): readonly string[] => + [...req('settings-options-list').querySelectorAll('.settings-option')].map( + (option) => option.textContent ?? '', + ); + +const focusedOption = (): string | null => + req('settings-options-list').querySelector('.settings-option.is-focused')?.textContent ?? null; + +/** Opens the screen the way app.ts does: open(), then the pushed snapshot and environment. */ +function openWith(overrides: Partial<AppSettings> = {}): void { + screen.open(); + screen.applyEnv({ steamAvailable: true, audioOptions: AUDIO_OPTIONS, appVersion: '0.8.0' }); + screen.applySettings(settings(overrides)); +} + +/** Moves the column onto a section and steps into its pane. */ +function enterSection(title: string): void { + for (let step = 0; step < sections().length; step += 1) { + if (focusedSection() === title) break; + screen.navDown(); + } + if (focusedSection() !== title) throw new Error(`no section named ${title}`); + screen.navActivate(); +} + +function focusRow(label: string): void { + for (let step = 0; step < rows().length; step += 1) { + if (focusedRowLabel() === label) return; + screen.navDown(); + } + throw new Error(`could not reach row ${label}`); +} + +beforeEach(() => { + loadFixture(); + raf = installRafHarness(); + audio = fakeAudio(); + keyboard = fakeKeyboard(); + api = fakeSettingsApi(); + closed = 0; + screen = createSettingsScreen({ + audio, + getTranslator: () => createTranslator('en'), + api, + keyboard, + onClosed: () => { + closed += 1; + }, + onResetRequested: () => undefined, + }); +}); + +afterEach(() => { + screen.close(); + vi.unstubAllGlobals(); +}); + +describe('settings opening', () => { + it('waits for the first snapshot before drawing anything but the loading line', () => { + screen.open(); + + expect(req('app').dataset['overlay']).toBe('settings'); + expect(req('settings').getAttribute('aria-hidden')).toBe('false'); + expect(req('settings-list').textContent).toBe('Loading...'); + }); + + it('draws the column and the first section once the snapshot lands', () => { + openWith(); + + expect(sections()).toEqual([ + 'Updates', + 'Language', + 'General', + 'Game metadata', + 'Audio', + 'Reset to defaults', + 'Close', + ]); + expect(focusedSection()).toBe('Updates'); + expect(rowLabels()).toContain('Automatic updates'); + }); + + it('keeps the focus on the column, not in the pane', () => { + openWith(); + + expect(focusedRowLabel()).toBe(null); + expect(req('settings-list').classList.contains('is-active')).toBe(false); + }); + + it('shows the version the environment pushed', () => { + openWith(); + + expect(req('settings-version').textContent).toBe('0.8.0'); + }); +}); + +describe('settings section navigation', () => { + it('replaces the pane with the section the column steps into', () => { + openWith(); + + enterSection('Audio'); + + expect(rowLabels()).toEqual([ + 'Navigation sounds', + 'Navigation sounds volume', + 'Background ambience', + 'Only global ambience', + 'Ambience volume', + ]); + expect(focusedRowLabel()).toBe('Navigation sounds'); + expect(req('settings-list').classList.contains('is-active')).toBe(true); + }); + + it('moves the row focus with the DOM in step and stops at the last row', () => { + openWith(); + enterSection('Audio'); + + screen.navDown(); + expect(focusedRowLabel()).toBe('Navigation sounds volume'); + + for (let step = 0; step < 5; step += 1) screen.navDown(); + + expect(focusedRowLabel()).toBe('Ambience volume'); + expect(audio.limits()).toBeGreaterThan(0); + }); + + it('hands the focus back to the column on back, keeping the pane drawn', () => { + openWith(); + enterSection('Audio'); + + screen.navBack(); + + expect(focusedRowLabel()).toBe(null); + expect(focusedSection()).toBe('Audio'); + expect(rowLabels()).toContain('Navigation sounds'); + }); +}); + +describe('settings toggles', () => { + it('flips the checkbox, persists it and repaints the row', () => { + openWith({ onlyGlobalAmbient: false }); + enterSection('Audio'); + focusRow('Only global ambience'); + + screen.navActivate(); + + expect(api.setOnlyGlobalAmbient).toHaveBeenCalledWith(true); + expect(isOn('Only global ambience')).toBe(true); + }); + + it('refuses to step a checkbox sideways', () => { + openWith({ onlyGlobalAmbient: false }); + enterSection('Audio'); + focusRow('Only global ambience'); + + screen.navRight(); + + expect(api.setOnlyGlobalAmbient).not.toHaveBeenCalled(); + expect(isOn('Only global ambience')).toBe(false); + }); +}); + +describe('settings dropdowns', () => { + it('cycles a value sideways without expanding the list', () => { + openWith({ soundSet: 'playhook-abyss' }); + enterSection('Audio'); + focusRow('Navigation sounds'); + + screen.navRight(); + + expect(api.setSoundSet).toHaveBeenCalledWith('ps5'); + expect(valueOf('Navigation sounds')).toBe('Ps5'); + expect(req('settings-options').classList.contains('is-open')).toBe(false); + }); + + it('expands the list focused on the current value', () => { + openWith({ soundSet: 'ps5' }); + enterSection('Audio'); + focusRow('Navigation sounds'); + + screen.navActivate(); + raf.flush(OPEN_FRAMES); + + expect(req('settings-options').classList.contains('is-open')).toBe(true); + expect(options()).toEqual(['Playhook Abyss', 'Ps5']); + expect(focusedOption()).toBe('Ps5'); + }); + + it('picks the option the focus is on and closes the list', () => { + openWith({ soundSet: 'ps5' }); + enterSection('Audio'); + focusRow('Navigation sounds'); + screen.navActivate(); + raf.flush(OPEN_FRAMES); + + screen.navUp(); + screen.navActivate(); + raf.flush(OPEN_FRAMES); + + expect(api.setSoundSet).toHaveBeenCalledWith('playhook-abyss'); + expect(valueOf('Navigation sounds')).toBe('Playhook Abyss'); + expect(req('settings-options').classList.contains('is-open')).toBe(false); + }); + + it('leaves the list without changing anything on back', () => { + openWith({ soundSet: 'ps5' }); + enterSection('Audio'); + focusRow('Navigation sounds'); + screen.navActivate(); + raf.flush(OPEN_FRAMES); + + screen.navBack(); + raf.flush(OPEN_FRAMES); + + expect(req('settings-options').classList.contains('is-open')).toBe(false); + expect(api.setSoundSet).not.toHaveBeenCalled(); + expect(focusedRowLabel()).toBe('Navigation sounds'); + }); +}); + +describe('settings sliders', () => { + it('steps the value with left and right and persists each step', () => { + openWith({ sfxVolume: 0.5 }); + enterSection('Audio'); + focusRow('Navigation sounds volume'); + raf.advance(200); + + screen.navRight(); + + expect(valueOf('Navigation sounds volume')).toBe('55%'); + expect(api.setSfxVolume).toHaveBeenCalledWith(0.55); + + raf.advance(200); + screen.navLeft(); + + expect(valueOf('Navigation sounds volume')).toBe('50%'); + }); + + it('stops at the ends with the dead-end sound', () => { + openWith({ sfxVolume: 1 }); + enterSection('Audio'); + focusRow('Navigation sounds volume'); + + screen.navRight(); + + expect(valueOf('Navigation sounds volume')).toBe('100%'); + expect(audio.limits()).toBe(1); + }); + + it('has nothing for A to press', () => { + openWith({ sfxVolume: 0.5 }); + enterSection('Audio'); + focusRow('Navigation sounds volume'); + + screen.navActivate(); + + expect(audio.limits()).toBe(1); + expect(valueOf('Navigation sounds volume')).toBe('50%'); + }); +}); + +describe('settings text field', () => { + it('opens the keyboard on the real key and writes back what it commits', () => { + openWith({ steamGridDbApiKey: 'abcdef1234567890' }); + enterSection('Game metadata'); + focusRow('SteamGridDB API key'); + + screen.navActivate(); + + expect(keyboard.last().value).toBe('abcdef1234567890'); + + keyboard.commit(' fresh-key '); + + expect(api.setSteamGridDbKey).toHaveBeenCalledWith('fresh-key'); + expect(valueOf('SteamGridDB API key')).toBe('••••••••-key'); + }); + + it('routes navigation into the keyboard while it is up', () => { + openWith(); + enterSection('Game metadata'); + focusRow('SteamGridDB API key'); + screen.navActivate(); + const before = focusedRowLabel(); + + screen.navDown(); + + expect(focusedRowLabel()).toBe(before); + }); +}); + +describe('settings mouse', () => { + it('takes the row focus on hover once the mouse is awake', () => { + openWith(); + enterSection('Audio'); + const target = rowOf('Background ambience'); + + hoverOver(target); + + expect(focusedRowLabel()).toBe('Background ambience'); + }); + + it('ignores hover while the mouse is still asleep', () => { + openWith(); + enterSection('Audio'); + const target = rowOf('Background ambience'); + + target.dispatchEvent( + new MouseEvent('mousemove', { bubbles: true, clientX: 400, clientY: 300 }), + ); + + expect(focusedRowLabel()).toBe('Navigation sounds'); + }); + + it('moves the option focus on hover inside the expanded list', () => { + openWith({ soundSet: 'playhook-abyss' }); + enterSection('Audio'); + focusRow('Navigation sounds'); + screen.navActivate(); + raf.flush(OPEN_FRAMES); + const option = [...req('settings-options-list').querySelectorAll('.settings-option')][1]; + + if (option === undefined) throw new Error('the dropdown drew no options'); + hoverOver(option); + + expect(focusedOption()).toBe('Ps5'); + }); + + it('closes the expanded list on a click into its veil', () => { + openWith(); + enterSection('Audio'); + focusRow('Navigation sounds'); + screen.navActivate(); + raf.flush(OPEN_FRAMES); + + req('settings-options').querySelector<HTMLElement>('.settings-options-veil')?.click(); + + expect(req('settings-options').classList.contains('is-open')).toBe(false); + }); +}); + +describe('settings closing', () => { + it('leaves the screen from the column and reports it', () => { + openWith(); + + screen.navBack(); + + expect(screen.isOpen()).toBe(false); + expect(closed).toBe(1); + expect(req('app').dataset['overlay']).toBeUndefined(); + expect(req('settings').getAttribute('aria-hidden')).toBe('true'); + }); + + it('takes the expanded dropdown and the keyboard with it', () => { + openWith(); + enterSection('Audio'); + focusRow('Navigation sounds'); + screen.navActivate(); + raf.flush(OPEN_FRAMES); + + screen.close(); + + expect(req('settings-options').classList.contains('is-open')).toBe(false); + expect(keyboard.isOpen()).toBe(false); + }); + + it('re-opens on the first section rather than where the last visit ended', () => { + openWith(); + enterSection('Audio'); + screen.close(); + + openWith(); + + expect(focusedSection()).toBe('Updates'); + expect(rowLabels()).toContain('Automatic updates'); + }); +}); diff --git a/test/save-path-darwin.test.ts b/test/save-path-darwin.test.ts new file mode 100644 index 00000000..ae9a0e7f --- /dev/null +++ b/test/save-path-darwin.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import { + applicationSupportDir, + darwinSaveBase, + darwinToManifestPcSavePath, + resolveDarwinPcSavePath, +} from '../src/main/platform/save-path.darwin'; + +const bases = { home: '/Users/deck', documents: '/Users/deck/Documents' } as const; + +describe('darwin SavePathResolver — forward mapping', () => { + it('sends every AppData-family prefix to ~/Library/Application Support', () => { + expect(darwinSaveBase(bases, 'APPDATA')).toBe('/Users/deck/Library/Application Support'); + expect(darwinSaveBase(bases, 'LOCALAPPDATA')).toBe('/Users/deck/Library/Application Support'); + expect(darwinSaveBase(bases, 'LOCALLOW')).toBe('/Users/deck/Library/Application Support'); + }); + + it('sends %USERPROFILE% to the home dir and %DOCUMENTS% to Documents', () => { + expect(darwinSaveBase(bases, 'USERPROFILE')).toBe('/Users/deck'); + expect(darwinSaveBase(bases, 'DOCUMENTS')).toBe('/Users/deck/Documents'); + }); + + it('accepts a lower-cased token and refuses an unknown one', () => { + expect(darwinSaveBase(bases, 'appdata')).toBe(applicationSupportDir(bases.home)); + expect(darwinSaveBase(bases, 'WINDIR')).toBeNull(); + }); + + it('appends the tail, accepting both separators a Windows manifest may use', () => { + expect(resolveDarwinPcSavePath(bases, '%APPDATA%\\IronGate\\Valheim')).toBe( + '/Users/deck/Library/Application Support/IronGate/Valheim', + ); + expect(resolveDarwinPcSavePath(bases, '%LOCALLOW%/IronGate/Valheim')).toBe( + '/Users/deck/Library/Application Support/IronGate/Valheim', + ); + }); + + it('resolves a bare prefix to the base itself', () => { + expect(resolveDarwinPcSavePath(bases, '%USERPROFILE%')).toBe('/Users/deck'); + }); + + it('refuses a traversal in the tail and a value with no prefix at all', () => { + expect(resolveDarwinPcSavePath(bases, '%APPDATA%/../../etc')).toBeNull(); + expect(resolveDarwinPcSavePath(bases, '/Users/deck/Games')).toBeNull(); + }); +}); + +describe('darwin SavePathResolver — reverse mapping', () => { + it('expresses a folder under Application Support with the canonical %APPDATA%', () => { + expect( + darwinToManifestPcSavePath(bases, '/Users/deck/Library/Application Support/IronGate/Valheim'), + ).toBe('%APPDATA%/IronGate/Valheim'); + }); + + it('expresses a Documents folder with %DOCUMENTS%', () => { + expect(darwinToManifestPcSavePath(bases, '/Users/deck/Documents/My Games/Hades')).toBe( + '%DOCUMENTS%/My Games/Hades', + ); + }); + + it('falls back to %USERPROFILE% only when no longer base matches', () => { + expect(darwinToManifestPcSavePath(bases, '/Users/deck/Games/Hades')).toBe( + '%USERPROFILE%/Games/Hades', + ); + }); + + it('returns the bare token for the base itself', () => { + expect(darwinToManifestPcSavePath(bases, '/Users/deck')).toBe('%USERPROFILE%'); + expect(darwinToManifestPcSavePath(bases, '/Users/deck/Library/Application Support')).toBe( + '%APPDATA%', + ); + }); + + it('refuses a folder outside the home dir', () => { + expect(darwinToManifestPcSavePath(bases, '/Volumes/CARD/saves')).toBeNull(); + }); + + it('does not mistake a same-prefixed sibling directory for the base', () => { + expect(darwinToManifestPcSavePath(bases, '/Users/deck2/Games')).toBeNull(); + }); + + it('round-trips %APPDATA% but deliberately does NOT restore %LOCALLOW% (see Д3)', () => { + const absolute = resolveDarwinPcSavePath(bases, '%LOCALLOW%/IronGate/Valheim'); + expect(absolute).not.toBeNull(); + expect(darwinToManifestPcSavePath(bases, absolute ?? '')).toBe('%APPDATA%/IronGate/Valheim'); + }); +}); diff --git a/test/save-path-linux.test.ts b/test/save-path-linux.test.ts index 85feafbd..99689818 100644 --- a/test/save-path-linux.test.ts +++ b/test/save-path-linux.test.ts @@ -1,8 +1,13 @@ -import { describe, expect, it } from 'vitest'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { + createLinuxSavePathResolver, resolveInsideWinePrefix, winePrefixToManifestPcSavePath, } from '../src/main/platform/save-path.linux'; +import type { GameManifest, ResolvedManifest } from '../src/shared/types'; const PFX = '/home/deck/.config/playhook/prefixes/mygame'; const HOME = `${PFX}/drive_c/users/steamuser`; @@ -100,3 +105,78 @@ describe('winePrefixToManifestPcSavePath — Configure Browse reverse mapping ( } }); }); + +// The two ways a LOCAL game (source: 'pc') can name its saves, and why the resolver must keep them apart: +// an ordinary local game browses to a host folder and gets an absolute path, a local STEAM game's saves +// live inside Steam's compatdata prefix and can only be named with a %PREFIX% token. +describe('createLinuxSavePathResolver — a local game vs a local Steam game', () => { + const appid = 1145360; + let base: string; + let steamPath: string; + let compat: string; + + const deps = (): Parameters<typeof createLinuxSavePathResolver>[0] => ({ + userData: path.join(base, 'userData'), + steamLocator: { + locateSteam: async (): Promise<string | null> => steamPath, + }, + }); + + const raw: GameManifest = { + schemaVersion: 1, + id: 'hades', + title: 'Hades', + args: [], + runAsAdmin: false, + launchTimeoutSec: 60, + killTimeoutSec: 10, + winetricks: [], + }; + const manifest = (over: Partial<ResolvedManifest>): ResolvedManifest => ({ + raw, + root: path.join(base, 'pc-games'), + source: 'pc', + executablePath: '', + cwd: '', + ...over, + }); + + beforeEach(async () => { + base = await fs.mkdtemp(path.join(os.tmpdir(), 'playhook-savepath-')); + steamPath = path.join(base, 'Steam'); + compat = path.join(steamPath, 'steamapps', 'compatdata', String(appid), 'pfx'); + await fs.mkdir(compat, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(base, { recursive: true, force: true }); + }); + + it('resolves a local STEAM game into compatdata — not the host fs, not our own prefix', async () => { + const resolver = createLinuxSavePathResolver(deps()); + const location = await resolver.resolvePcSavePath( + manifest({ steam: { appid } }), + '%APPDATA%\\Hades\\Saves', + ); + expect(location).not.toBeNull(); + // Asserted as "under compatdata + the Windows-profile tail" rather than one literal, because the + // compatdata root is a real temp dir whose separators follow the OS the suite runs on (CI runs it on + // Windows too) — the tail below drive_c is the part this mapping owns. + expect(location?.path.startsWith(compat)).toBe(true); + expect(location?.path.endsWith('drive_c/users/steamuser/AppData/Roaming/Hades/Saves')).toBe(true); + expect(location?.containerExists).toBe(true); + }); + + it('keeps an ordinary local game`s absolute path verbatim (the host fs IS its container)', async () => { + const resolver = createLinuxSavePathResolver(deps()); + const location = await resolver.resolvePcSavePath(manifest({}), '/home/deck/Games/Hades/Saves'); + expect(location).toEqual({ path: '/home/deck/Games/Hades/Saves', containerExists: true }); + }); + + it('is a no-op while Steam has made no compatdata for the game yet', async () => { + await fs.rm(path.join(steamPath, 'steamapps'), { recursive: true, force: true }); + const resolver = createLinuxSavePathResolver(deps()); + const location = await resolver.resolvePcSavePath(manifest({ steam: { appid } }), '%APPDATA%\\Hades'); + expect(location).toBeNull(); + }); +}); diff --git a/test/settings-form-model.test.ts b/test/settings-form-model.test.ts new file mode 100644 index 00000000..9cd767b4 --- /dev/null +++ b/test/settings-form-model.test.ts @@ -0,0 +1,203 @@ +// buildSettingsModel — the Settings screen's composition. The view and the screen controller are DOM +// code (vitest runs in plain node, no jsdom), so everything that decides WHAT is on the screen lives +// here and is covered here: section/row order, the Steam row's conditional presence, and the value +// mapping from AppSettings (volumes as percent, a null ambience as the "no ambience" option). +import { describe, it, expect } from 'vitest'; +import { + buildSettingsModel, + maskApiKey, + prettifyName, + volumePercent, + type SettingsEnv, + type SettingsRow, +} from '../src/renderer/settings-form-model'; +import { DEFAULT_SETTINGS } from '../src/main/app-settings'; +import type { AppSettings } from '../src/shared/types'; + +const env = (overrides: Partial<SettingsEnv> = {}): SettingsEnv => ({ + steamAvailable: false, + audioOptions: { soundSets: ['winhanced', 'ps5'], ambientTracks: ['deep-space.mp3'] }, + appVersion: '0.8.0', + updateStatus: { kind: 'idle' }, + ...overrides, +}); + +const settings = (overrides: Partial<AppSettings> = {}): AppSettings => ({ + ...DEFAULT_SETTINGS, + ...overrides, +}); + +const rowIds = (rows: readonly SettingsRow[]): readonly string[] => + rows.map((row) => (row.kind === 'update-status' ? 'update-status' : row.id)); + +describe('the SteamGridDB key row', () => { + it('shows nothing at all for an empty key, so the placeholder speaks instead', () => { + expect(maskApiKey('')).toBe(''); + }); + + it('masks a stored key but keeps its last four characters recognizable', () => { + expect(maskApiKey('abcdef1234567890')).toBe('••••••••7890'); + }); + + it('hides a short key completely — four of eight characters would be half the secret', () => { + expect(maskApiKey('abc123')).toBe('••••••••'); + }); + + it('carries the masked value onto the screen, never the key itself', () => { + const model = buildSettingsModel(settings({ steamGridDbApiKey: 'abcdef1234567890' }), env()); + const row = model.sections + .flatMap((section) => section.rows) + .find((candidate) => candidate.kind === 'text'); + expect(row?.kind === 'text' && row.value).toBe('••••••••7890'); + }); +}); + +describe('buildSettingsModel — composition', () => { + it('lays the sections out in screen order', () => { + const model = buildSettingsModel(settings(), env()); + expect(model.sections.map((section) => section.titleKey)).toEqual([ + 'settings.sectionUpdates', + 'settings.sectionLanguage', + 'settings.sectionGeneral', + 'settings.sectionMetadata', + 'settings.sectionAudio', + // The action stack closing the screen carries no title — see buildSettingsModel. + undefined, + ]); + }); + + it('opens Updates with the status row, then the mode and the beta toggle', () => { + const model = buildSettingsModel(settings(), env()); + expect(rowIds(model.sections[0]?.rows ?? [])).toEqual([ + 'update-status', + 'autoUpdate', + 'prerelease', + ]); + }); + + // macOS: self-update can never work there (unsigned bundle), so the mode selector and the beta toggle + // would be controls that do nothing — the section is left as the explanation alone. + it('drops the mode and beta rows when the platform can never self-update', () => { + const model = buildSettingsModel( + settings(), + env({ updateStatus: { kind: 'unsupported', reason: 'platform' } }), + ); + expect(rowIds(model.sections[0]?.rows ?? [])).toEqual(['update-status']); + }); + + // A dev run is `unsupported` too, but temporarily and for a different reason: the mode it persists is + // what the INSTALLED build will honour, so those rows must stay. + it('keeps the mode and beta rows in a dev build', () => { + const model = buildSettingsModel( + settings(), + env({ updateStatus: { kind: 'unsupported', reason: 'not-packaged' } }), + ); + expect(rowIds(model.sections[0]?.rows ?? [])).toEqual([ + 'update-status', + 'autoUpdate', + 'prerelease', + ]); + }); + + it('carries the current update status into the status row', () => { + const model = buildSettingsModel( + settings(), + env({ updateStatus: { kind: 'downloading', version: '0.9.0', percent: 42 } }), + ); + const row = model.sections[0]?.rows[0]; + expect(row?.kind).toBe('update-status'); + if (row?.kind === 'update-status') + expect(row.status).toEqual({ kind: 'downloading', version: '0.9.0', percent: 42 }); + }); + + it('omits the Steam auto-launch row where the feature does not exist', () => { + const model = buildSettingsModel(settings(), env({ steamAvailable: false })); + const general = model.sections.find( + (section) => section.titleKey === 'settings.sectionGeneral', + ); + expect(rowIds(general?.rows ?? [])).not.toContain('steamAutoLaunch'); + }); + + it('includes it (last in General) where it does', () => { + const model = buildSettingsModel(settings(), env({ steamAvailable: true })); + const general = model.sections.find( + (section) => section.titleKey === 'settings.sectionGeneral', + ); + expect(rowIds(general?.rows ?? [])).toEqual([ + 'summonHotkey', + 'preventScreensaver', + 'keepOpenWithoutCard', + 'disableSilentInstall', + 'steamAutoLaunch', + ]); + }); + + it('closes with the untitled action stack: reset over close', () => { + const model = buildSettingsModel(settings(), env()); + const last = model.sections[model.sections.length - 1]; + expect(last?.titleKey).toBeUndefined(); + expect(rowIds(last?.rows ?? [])).toEqual(['reset', 'close']); + }); + + it('carries the app version through', () => { + expect(buildSettingsModel(settings(), env({ appVersion: '1.2.3' })).appVersion).toBe('1.2.3'); + }); +}); + +describe('buildSettingsModel — value mapping', () => { + it('shows volumes as whole percents', () => { + const model = buildSettingsModel(settings({ sfxVolume: 0.35, musicVolume: 1 }), env()); + const audio = model.sections.find((section) => section.titleKey === 'settings.sectionAudio'); + const sfx = audio?.rows.find((row) => row.kind === 'slider' && row.id === 'sfxVolume'); + const music = audio?.rows.find((row) => row.kind === 'slider' && row.id === 'musicVolume'); + expect(sfx?.kind === 'slider' ? sfx.percent : null).toBe(35); + expect(music?.kind === 'slider' ? music.percent : null).toBe(100); + }); + + it('maps a null ambience onto the "no ambience" option', () => { + const model = buildSettingsModel(settings({ ambientTrack: null }), env()); + const audio = model.sections.find((section) => section.titleKey === 'settings.sectionAudio'); + const ambient = audio?.rows.find((row) => row.kind === 'select' && row.id === 'ambientTrack'); + expect(ambient?.kind === 'select' ? ambient.value : null).toBe(''); + expect(ambient?.kind === 'select' ? ambient.options[0] : null).toEqual({ + value: '', + labelKey: 'settings.ambientNone', + }); + }); + + it('offers every bundled sound set, prettified', () => { + const model = buildSettingsModel(settings(), env()); + const audio = model.sections.find((section) => section.titleKey === 'settings.sectionAudio'); + const soundSet = audio?.rows.find((row) => row.kind === 'select' && row.id === 'soundSet'); + expect(soundSet?.kind === 'select' ? soundSet.options : null).toEqual([ + { value: 'winhanced', label: 'Winhanced' }, + { value: 'ps5', label: 'Ps5' }, + ]); + }); + + it('reflects the toggles from AppSettings', () => { + const model = buildSettingsModel( + settings({ allowPrerelease: true, summonHotkeyEnabled: false }), + env(), + ); + const prerelease = model.sections[0]?.rows.find((row) => row.kind === 'toggle'); + expect(prerelease?.kind === 'toggle' ? prerelease.value : null).toBe(true); + const general = model.sections.find( + (section) => section.titleKey === 'settings.sectionGeneral', + ); + const summon = general?.rows.find((row) => row.kind === 'toggle' && row.id === 'summonHotkey'); + expect(summon?.kind === 'toggle' ? summon.value : null).toBe(false); + }); +}); + +describe('helpers', () => { + it('rounds volumes to whole percents', () => { + expect(volumePercent(0.505)).toBe(51); + expect(volumePercent(0)).toBe(0); + }); + + it('prettifies a dashed file name into words', () => { + expect(prettifyName('steam-big-picture')).toBe('Steam Big Picture'); + expect(prettifyName('ps5')).toBe('Ps5'); + }); +}); diff --git a/test/settings-update-status.test.ts b/test/settings-update-status.test.ts new file mode 100644 index 00000000..d35f3c4a --- /dev/null +++ b/test/settings-update-status.test.ts @@ -0,0 +1,40 @@ +// The Updates section's wording and primary action, per UpdateStatus. settings-form-view.ts is DOM code, +// but these two functions are pure (a status in, text/an action out) and importable in plain node — which +// is what makes the one case with no visible button, and the one that differs only by `reason`, testable +// at all. +import { describe, expect, it } from 'vitest'; +import { updateAction, updateStatusText } from '../src/renderer/settings-form-view'; +import { createTranslator } from '../src/shared/i18n/index'; + +const t = createTranslator('en'); + +describe('updateStatusText — the two "unsupported" situations', () => { + it('tells a macOS user what to do instead of updating in place', () => { + const text = updateStatusText({ kind: 'unsupported', reason: 'platform' }, t); + expect(text).toContain('macOS'); + expect(text).toContain('.dmg'); + }); + + it('keeps the dev-build wording for a non-packaged run', () => { + expect(updateStatusText({ kind: 'unsupported', reason: 'not-packaged' }, t)).toBe( + 'Updates are available only in the installed build.', + ); + }); + + it('says the two apart — a shared sentence would be wrong for one of them', () => { + expect(updateStatusText({ kind: 'unsupported', reason: 'platform' }, t)).not.toBe( + updateStatusText({ kind: 'unsupported', reason: 'not-packaged' }, t), + ); + }); +}); + +describe('updateAction — nothing to press when self-update is impossible', () => { + it('offers no action for either unsupported reason', () => { + expect(updateAction({ kind: 'unsupported', reason: 'platform' }, t)).toBeNull(); + expect(updateAction({ kind: 'unsupported', reason: 'not-packaged' }, t)).toBeNull(); + }); + + it('still offers a check on an ordinary idle build', () => { + expect(updateAction({ kind: 'idle' }, t)?.kind).toBe('check'); + }); +}); diff --git a/test/sfx-limit.test.ts b/test/sfx-limit.test.ts new file mode 100644 index 00000000..959319d9 --- /dev/null +++ b/test/sfx-limit.test.ts @@ -0,0 +1,52 @@ +// The `limit` latch (sfx-limit.ts): one dead-end sound per series of blocked attempts. The series ends +// on a RELEASE, so the cases below are written with the real repeat cadences of both input devices — +// a test stepping at 110 ms only would miss the 350 ms gap the pad's hold delay opens. +import { describe, expect, it } from 'vitest'; +import { LIMIT_IDLE_MS, shouldPlayLimit } from '../src/renderer/sfx-limit'; + +/** Replays a series of attempts through the same state the AudioController keeps, and counts the sounds. */ +function soundsFor(attempts: readonly number[]): number { + let armed = true; + let last = Number.NEGATIVE_INFINITY; + let sounds = 0; + for (const now of attempts) { + if (shouldPlayLimit(armed, last, now)) { + armed = false; + sounds += 1; + } + last = now; + } + return sounds; +} + +describe('shouldPlayLimit — one sound per hold', () => { + it('sounds once for a held direction on the gamepad (HOLD_DELAY_MS 350, then NAV_REPEAT_MS 110)', () => { + expect(soundsFor([0, 350, 460, 570, 680])).toBe(1); + }); + + it('sounds once for a held key on a keyboard with a slow OS repeat delay', () => { + expect(soundsFor([0, 500, 610, 720])).toBe(1); + }); + + it('sounds again once the input was released (the latch re-armed)', () => { + let armed = true; + let last = Number.NEGATIVE_INFINITY; + expect(shouldPlayLimit(armed, last, 0)).toBe(true); + armed = false; + last = 0; + expect(shouldPlayLimit(armed, last, 110)).toBe(false); // still the same hold + armed = true; // released + expect(shouldPlayLimit(armed, last, 200)).toBe(true); // pressed again, well inside the idle window + }); + + it('re-arms itself when a release was missed — an idle gap longer than the threshold', () => { + expect(shouldPlayLimit(false, 0, LIMIT_IDLE_MS)).toBe(true); + expect(shouldPlayLimit(false, 0, LIMIT_IDLE_MS - 1)).toBe(false); + }); + + it('keeps a hold silent past the threshold: every attempt pushes the idle window forward', () => { + // 0 / 350 / 460 / 570 … reaches 1050 ms without a gap ever growing to LIMIT_IDLE_MS. + const held = [0, 350, ...Array.from({ length: 20 }, (_, i) => 460 + i * 110)]; + expect(soundsFor(held)).toBe(1); + }); +}); diff --git a/test/steam-locator-darwin.test.ts b/test/steam-locator-darwin.test.ts new file mode 100644 index 00000000..81cba438 --- /dev/null +++ b/test/steam-locator-darwin.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { libraryIndexPath, steamCandidateDirs } from '../src/main/platform/steam-locator.darwin'; + +describe('darwin SteamLocator — candidate paths', () => { + it('probes the single macOS Steam root (Д8)', () => { + expect(steamCandidateDirs('/Users/deck')).toEqual([ + '/Users/deck/Library/Application Support/Steam', + ]); + }); + + it('derives the library-index path used as the validity check', () => { + expect(libraryIndexPath('/Users/deck/Library/Application Support/Steam')).toBe( + '/Users/deck/Library/Application Support/Steam/steamapps/libraryfolders.vdf', + ); + }); +}); diff --git a/test/system-cards.test.ts b/test/system-cards.test.ts new file mode 100644 index 00000000..d1f62b67 --- /dev/null +++ b/test/system-cards.test.ts @@ -0,0 +1,32 @@ +// The launcher cards are a contract with the mockup: which three they are, and in what order they sit at +// the tail of the carousel. Nothing else in the renderer states that — carousel.ts just splices the list +// in — so a reordering (or a fourth card slipped in) would only ever be caught by eye on a Deck. +import { describe, expect, it } from 'vitest'; +import { SYSTEM_CARDS } from '../src/renderer/system-cards'; + +describe('SYSTEM_CARDS', () => { + it('holds exactly the four launcher cards, in the mockup order', () => { + expect(SYSTEM_CARDS.map((card) => card.id)).toEqual([ + 'library', + 'notifications', + 'settings', + 'power', + ]); + }); + + it('names all but the power card in the title line', () => { + expect(SYSTEM_CARDS.map((card) => card.titleKey)).toEqual([ + 'launcher.card.library', + 'launcher.card.notifications', + 'launcher.card.settings', + // The mockup shows no caption for it — app.ts writes an empty title line rather than a name. + null, + ]); + }); + + it('gives every card an aria-label key (the power card has nothing else to name it)', () => { + for (const card of SYSTEM_CARDS) { + expect(card.ariaKey.length, `${card.id} must carry an aria key`).toBeGreaterThan(0); + } + }); +}); diff --git a/test/tray.test.ts b/test/tray.test.ts index a02ee272..14ca8269 100644 --- a/test/tray.test.ts +++ b/test/tray.test.ts @@ -17,8 +17,8 @@ const t = createTranslator('en'); function callbacks(): TrayCallbacks & { readonly onToggleSteamShortcut: ReturnType<typeof vi.fn> } { return { onShow: vi.fn(), - onOpenConfigureGame: vi.fn(), - onOpenSettings: vi.fn(), + onOpenLogs: vi.fn(), + onOpenGamesFolder: vi.fn(), onToggleSteamShortcut: vi.fn(), onQuit: vi.fn(), }; @@ -35,7 +35,7 @@ const labels = (steam: TraySteamState): readonly (string | undefined)[] => describe('buildTrayMenu — Steam item visibility', () => { it('omits the item entirely when the feature is unavailable (Windows / non-AppImage run)', () => { const menu = labels({ visible: false, registered: false, busy: false }); - expect(menu).toEqual(['Show launcher', 'Configure game', 'Settings', undefined, 'Quit']); + expect(menu).toEqual(['Show launcher', 'Open logs', 'Open games folder', undefined, 'Quit']); expect(menu).not.toContain('Add to Steam'); }); @@ -43,8 +43,8 @@ describe('buildTrayMenu — Steam item visibility', () => { expect(labels({ visible: true, registered: false, busy: false })).toEqual([ 'Show launcher', 'Add to Steam', - 'Configure game', - 'Settings', + 'Open logs', + 'Open games folder', undefined, 'Quit', ]); diff --git a/tsconfig.json b/tsconfig.json index 80273aa3..3e933410 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,11 +13,15 @@ "esModuleInterop": true, "skipLibCheck": true, "resolveJsonModule": true, + // For the ONE .mjs import in the suite (test/audio-index.test.ts pulls scripts/audio-index.mjs, which + // has no declarations); without it the typecheck fails on TS7016. + "allowJs": true, "isolatedModules": true, "noEmit": true, "types": ["node"] }, - // Type-check ONLY config (noEmit) over the whole of src — including settings.ts, which the renderer - // build config excludes because esbuild bundles it (audit N5). This is the single typecheck gate. - "include": ["src/**/*.ts"] + // Type-check ONLY config (noEmit) over the whole of src AND test — including settings.ts, which the + // renderer build config excludes because esbuild bundles it (audit N5). This is the single typecheck + // gate; tsconfig.main.json / tsconfig.renderer.json do not extend it, so test/ never reaches a build. + "include": ["src/**/*.ts", "test/**/*.ts"] } diff --git a/tsconfig.renderer.json b/tsconfig.renderer.json index eed03ff4..22ddb9ea 100644 --- a/tsconfig.renderer.json +++ b/tsconfig.renderer.json @@ -16,15 +16,16 @@ "types": [] }, "include": ["src/renderer/**/*.ts", "src/shared/**/*.ts"], - // settings.ts / configure.ts AND the whole app.ts graph are bundled by esbuild (build:settings / - // build:configure / build:app), not emitted by tsc — so they're excluded here. The game renderer moved + // The whole app.ts graph is bundled by esbuild (build:app), not emitted by tsc — so it is excluded + // here. The game renderer moved // to esbuild because a runtime dictionary import from a tsc-emitted app.js would break (dist/shared is // overwritten as CJS by build:main). All of these are still type-checked via the root tsconfig.json's // `npm run typecheck` (audit N5). "exclude": [ - "src/renderer/settings.ts", - "src/renderer/configure.ts", "src/renderer/app.ts", + "src/renderer/settings-screen.ts", + "src/renderer/settings-form-model.ts", + "src/renderer/settings-form-view.ts", "src/renderer/audio.ts", "src/renderer/hero.ts", "src/renderer/controls.ts", diff --git a/vitest.config.ts b/vitest.config.ts index dd3ae35f..ba7749eb 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,5 +14,6 @@ export default defineConfig({ test: { include: ['test/**/*.test.ts'], environment: 'node', + environmentMatchGlobs: [['test/renderer/**', 'happy-dom']], }, });