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.
-
+
+
@@ -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.
-
-
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.
+
+
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 {
+ 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 {
- // 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();
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 {
+ 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 `/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
+> = {
+ '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;
+ /** Resolves a manifest's `pcSavePath` to the LIVE save folder on this machine (platform.savePathResolver)
+ * — moveToCard copies from there, not from the PC-library backup, so a stale backup can never
+ * overwrite a fresher save (see the plan, Р2.5 step 3). */
+ readonly savePathResolver: Pick;
}
export class GameConfigService {
- private window: BrowserWindow | null = null;
- private pollTimer: ReturnType | 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 =>
- listDriveCandidates(this.deps.getActiveRoot(), this.deps.getTranslator()),
- );
- ipcMain.handle(IPC.configRead, (_event, root: string): Promise =>
- this.readConfig(root),
- );
- ipcMain.handle(IPC.configValidate, (_event, text: string): ConfigValidationResult =>
- validateManifestText(text, this.deps.getTranslator()),
+ ipcMain.handle(IPC.gameConfigRead, (_event, id: unknown): Promise =>
+ this.readGame(typeof id === 'string' ? id : ''),
);
ipcMain.handle(
- IPC.configSave,
- (
- _event,
- payload: { readonly root: string; readonly text: string },
- ): Promise => 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 =>
- this.pickPath(event.sender, payload.root, payload.kind),
+ IPC.gameConfigSave,
+ (_event, payload: GameConfigSaveRequest): Promise =>
+ this.saveChecked(payload),
);
ipcMain.handle(
- IPC.configImagePreview,
+ IPC.gameConfigImagePreview,
(_event, payload: { readonly root: string; readonly path: string }): Promise =>
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 =>
- this.deps.settings.read(),
+ ipcMain.handle(
+ IPC.gameConfigAcceptPath,
+ (_event, payload: GameConfigAcceptRequest): Promise =>
+ this.acceptPickedPaths(payload.root, payload.kind, payload.paths, payload.base),
+ );
+ ipcMain.handle(
+ IPC.gameConfigListDir,
+ (_event, payload: GameConfigListDirRequest): Promise => this.listDir(payload),
+ );
+ ipcMain.handle(IPC.gameConfigSources, (): Promise =>
+ this.candidates(),
+ );
+ ipcMain.handle(IPC.gameConfigReadRoot, (_event, root: unknown): Promise =>
+ this.readRoot(typeof root === 'string' ? root : ''),
+ );
+ ipcMain.handle(
+ IPC.gameConfigMoveToCard,
+ (_event, payload: GameMoveRequest): Promise => this.moveToCard(payload),
+ );
+ }
+
+ // ── 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 {
+ 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 {
+ 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 => 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 {
- 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 {
+ 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 {
+ 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
+ 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 {
+ 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 {
+ 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 {
+ private async save(
+ root: string,
+ text: string,
+ signatureBefore: string,
+ ): Promise {
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 {
+ 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 {
+ const t = this.deps.getTranslator();
+
+ // 1. Checks — nothing is written until every one of these passes.
+ if (
+ !(await this.isAllowedRoot(request.fromRoot)) ||
+ !(await this.isAllowedRoot(request.toRoot))
+ ) {
+ return { moved: false, message: t('errors.driveUnavailable') };
+ }
+ if (
+ (await this.signatureOf(request.fromRoot)) !== request.fromSignature ||
+ (await this.signatureOf(request.toRoot)) !== request.toSignature
+ ) {
+ return { moved: false, message: t('errors.mediaChanged') };
+ }
+ if (this.deps.isBusy()) {
+ return { moved: false, message: t('gameConfig.moveGameBusy') };
+ }
+ // A move must not rename: everything this PC remembers about the game is keyed by id (stats, the
+ // history record, the pending-flush queue), so a rename mid-move would orphan the lot. The renderer
+ // hides the id row while a move is pending; this is the server-side half of that rule, and it is what
+ // makes addressing the two sides by two different ids below provably equivalent.
+ if (request.fromId !== request.id) {
+ return { moved: false, message: t('gameConfig.moveIdChanged') };
+ }
+ if (countGamesWithId(request.id, request.toText) !== 1) {
+ return { moved: false, message: t('gameConfig.moveIdTaken') };
+ }
+ const toValidation = validateManifestText(request.toText, t, 'card');
+ if (!toValidation.ok) {
+ return { moved: false, message: this.firstIssueMessage(toValidation.issues, t) };
+ }
+ // fromText is derived HERE, from a fresh read — never trusted from the renderer (see game-move.ts).
+ // Addressed by `fromId` (what the game was READ with), never by the editable `id`.
+ const fromRead = await this.readConfig(request.fromRoot);
+ if (!fromRead.ok) return { moved: false, message: fromRead.message };
+ // removeGameFromManifestText is a silent no-op for an id that isn't there, which would write the
+ // library back UNCHANGED and report a successful move — so the game's presence is asserted first.
+ if (countGamesWithId(request.fromId, fromRead.text) !== 1) {
+ return { moved: false, message: t('errors.gameNotFound') };
+ }
+ const fromText = removeGameFromManifestText(request.fromId, fromRead.text);
+ if (fromText === null) return { moved: false, message: t('errors.configInvalid') };
+ const fromValidation = validateManifestText(fromText, t, 'pc');
+ if (!fromValidation.ok) {
+ // Our own slot is gone from this text, so whatever is wrong belongs to a game that stays behind —
+ // say so, or the user reads it as a complaint about the game they are moving.
+ return {
+ moved: false,
+ message: t('gameConfig.moveLibraryInvalid', {
+ reason: this.firstIssueMessage(fromValidation.issues, t),
+ }),
+ };
+ }
+ const manifest = this.deps.resolveManifest(request.fromId);
+ if (manifest === null || manifest.source !== 'pc') {
+ return { moved: false, message: t('errors.gameNotFound') };
+ }
+ const targetRaw = findGameInText(request.id, request.toText);
+ if (targetRaw === null) return { moved: false, message: t('errors.gameNotFound') };
+ // The game's OWN files (its exe) must already be on the card — otherwise the card would read as
+ // having an invalid game.json the instant it is inserted (see the plan, Р2.6).
+ const expectedFile = expectedGameFilePath(targetRaw);
+ if (expectedFile !== null) {
+ const resolvedFile = resolveInside(request.toRoot, expectedFile);
+ if (resolvedFile === null || !(await fse.pathExists(resolvedFile))) {
+ return { moved: false, message: t('gameConfig.moveFilesNotOnCard') };
+ }
+ }
+ // The card's game.json exactly as it stands right now — the ONLY faithful "before" picture for the
+ // step-5 rollback. Reconstructing it by subtracting our slot back out of `toText` would restore a
+ // re-serialization of the renderer's making instead (different top-level shape for a single-game
+ // card), and would lean on `toSignature === ''` to tell "there was no file" from "there was one".
+ const toBefore = await this.readManifestSnapshot(request.toRoot);
+ if (!toBefore.ok) return { moved: false, message: toBefore.message };
+
+ const gameTitle = typeof targetRaw['title'] === 'string' ? targetRaw['title'] : request.id;
+ // What went not-quite-right, as DATA rather than as prose: the outcomes below drive both the
+ // notifications and the result's `warning`, and more than one of them can happen in a single move.
+ const warnings: MoveWarning[] = [];
+
+ // 2. Copy assets (hero/grid/music), under the deterministic names the renderer already wrote into
+ // toText — see asset-move-names.ts. `existedBefore` is recorded per destination so a rollback removes
+ // only what this move created: deleting a file that was already there would destroy it outright,
+ // since the overwrite has no undo.
+ const copied: AssetCopyRecord[] = [];
+ const saveCopy: SaveCopyRecord = { dir: null, existedBefore: false };
+ const undo = async (): Promise => {
+ await this.rollbackMoveCopies(copied, saveCopy);
+ };
+ try {
+ for (const plan of planAssetCopies(manifest, request.id, request.toRoot)) {
+ // Checked BEFORE the copy so a file the user deleted since the game was configured is reported as
+ // what it is. Left to fse.copy it would surface as an ENOENT inside the catch below, and the user
+ // would be told that game.json could not be written — a file nothing has tried to touch yet.
+ if (!(await fse.pathExists(plan.from))) {
+ await undo();
+ return { moved: false, message: t('gameConfig.moveAssetMissing', { path: plan.from }) };
+ }
+ const existedBefore = await fse.pathExists(plan.to);
+ await fse.ensureDir(path.dirname(plan.to));
+ await fse.copy(plan.from, plan.to, { overwrite: true });
+ copied.push({ to: plan.to, existedBefore });
+ }
+ } catch (cause) {
+ await undo();
+ return {
+ moved: false,
+ message: t('errors.cannotWriteManifest', {
+ file: MANIFEST_FILENAME,
+ cause: describe(cause),
+ }),
+ };
+ }
+
+ // 3. Copy saves — only when the TARGET manifest actually names a saveOnCard folder, and PREFERABLY
+ // from the LIVE save location rather than the pc-games/saves/ backup: with no card-side baseline
+ // yet, the first sync-in falls back to the deterministic card→pc direction (save-sync.ts), so what
+ // travels here is what that fallback will write back over the live folder. The backup is used only
+ // when the live location has nothing to offer at all (no folder, or a Wine prefix that does not exist
+ // yet) — there the fallback has nothing to overwrite, so a stale backup beats no saves.
+ const targetSaveOnCard =
+ typeof targetRaw['saveOnCard'] === 'string' ? targetRaw['saveOnCard'] : undefined;
+ if (targetSaveOnCard !== undefined) {
+ const saveTargetDir = resolveInside(request.toRoot, targetSaveOnCard);
+ if (saveTargetDir === null) {
+ await undo();
+ return { moved: false, message: t('gameConfig.pickOutsideCard') };
+ }
+ const sourceDir = await this.liveOrBackupSaveDir(manifest);
+ if (sourceDir !== null) {
+ const targetExisted = await fse.pathExists(saveTargetDir);
+ const targetNonEmpty = targetExisted && (await fse.readdir(saveTargetDir)).length > 0;
+ if (targetNonEmpty) {
+ warnings.push('save-skipped');
+ } else {
+ try {
+ await fse.ensureDir(path.dirname(saveTargetDir));
+ await fse.copy(sourceDir, saveTargetDir, { overwrite: true });
+ // Recorded even when the folder was already there (empty): what has to be undone is what we
+ // PUT IN it, not merely the folder we may or may not have created.
+ saveCopy.dir = saveTargetDir;
+ saveCopy.existedBefore = targetExisted;
+ } catch (cause) {
+ await undo();
+ return {
+ moved: false,
+ message: t('errors.cannotWriteManifest', {
+ file: MANIFEST_FILENAME,
+ cause: describe(cause),
+ }),
+ };
+ }
+ }
+ }
+ }
+
+ // 4. Write the card. On disk to this exact moment on, `fromText` is the only thing left to apply —
+ // everything above only touched the CARD side.
+ try {
+ await writeFileAtomicEnsuringDir(
+ path.join(request.toRoot, MANIFEST_FILENAME),
+ request.toText,
+ );
+ } catch (cause) {
+ await undo();
+ return {
+ moved: false,
+ message: t('errors.cannotWriteManifest', {
+ file: MANIFEST_FILENAME,
+ cause: describe(cause),
+ }),
+ };
+ }
+
+ // 5. Write the library. A failure here triggers a best-effort rollback of the card (step 4) back to
+ // its pre-move bytes — `fromText` is NEVER applied when this happens (see the plan, Р2.5's rollback
+ // note): applying it would make the game disappear from the PC library without landing on the card,
+ // which is worse than the duplicate a failed rollback leaves behind.
+ const fromWrite = await this.writePcLibraryText(fromText, t);
+ if (!fromWrite.ok) {
+ const restored = await this.rollbackCardWrite(request.toRoot, toBefore.text);
+ if (restored) {
+ // The card is back to its pre-move bytes, so the copies of steps 2/3 are now referenced by
+ // nothing — and only NOW may they go. Undoing them while the card still names them (the branch
+ // below) would leave the target manifest pointing at art and saves that are no longer there.
+ await undo();
+ return { moved: false, message: fromWrite.message };
+ }
+ // The card write is stuck AND the library still has the game too — a defined outcome (a card game
+ // shadows its local twin, README.md "Local games"), not corruption, but worth a loud log: two
+ // independent writes failed back to back to get here. The copies STAY: the card's game.json still
+ // refers to them, and a duplicate whose art and saves are intact is the whole point of calling this
+ // "a defined outcome" rather than damage.
+ log.error(
+ `[game-move] id=${request.id}: card write kept but the library write failed and the card` +
+ ` rollback ALSO failed — the game now exists in both places (${fromWrite.message})`,
+ );
+ warnings.push('duplicate');
+ }
+
+ // 6. Apply / defer, exactly like an ordinary card save.
+ this.invalidateCandidates();
+ let applied: 'applied' | 'deferred';
+ 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/`) as a fallback. Null when neither has anything to copy. */
+ private async liveOrBackupSaveDir(manifest: ResolvedManifest): Promise {
+ if (manifest.pcSavePath !== undefined) {
+ const live = await this.deps.savePathResolver.resolvePcSavePath(
+ manifest,
+ manifest.pcSavePath,
+ );
+ if (live !== null && live.containerExists && (await fse.pathExists(live.path))) {
+ return live.path;
+ }
+ }
+ if (manifest.saveOnCardPath !== undefined && (await fse.pathExists(manifest.saveOnCardPath))) {
+ return manifest.saveOnCardPath;
+ }
+ return null;
+ }
+
+ /**
+ * Undoes what steps 2/3 put on the CARD — never touches the PC library.
+ *
+ * An asset whose destination was ALREADY occupied is deliberately left alone: the copy overwrote it and
+ * there is nothing to restore, so removing it would turn "the move failed" into "and your file is gone
+ * too". The saves folder is emptied rather than removed when it predates the move.
+ */
+ private async rollbackMoveCopies(
+ copied: readonly AssetCopyRecord[],
+ saveCopy: SaveCopyRecord,
+ ): Promise {
+ for (const asset of copied) {
+ if (asset.existedBefore) continue;
+ try {
+ await fse.remove(asset.to);
+ } catch (cause) {
+ log.warn(`[game-move] failed to roll back copied asset "${asset.to}":`, describe(cause));
+ }
+ }
+ if (saveCopy.dir === null) return;
+ try {
+ await fse.remove(saveCopy.dir);
+ if (saveCopy.existedBefore) await fse.ensureDir(saveCopy.dir);
+ } catch (cause) {
+ log.warn(
+ `[game-move] failed to roll back the copied save folder "${saveCopy.dir}":`,
+ describe(cause),
+ );
+ }
+ }
/**
- * 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 {
+ 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 {
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 {
+ const t = this.deps.getTranslator();
+ let stat: Parameters[2] = null;
+ try {
+ const stats = await fs.lstat(absolute);
+ stat = {
+ isSymbolicLink: stats.isSymbolicLink(),
+ isDirectory: stats.isDirectory(),
+ isFile: stats.isFile(),
+ };
+ } catch {
+ stat = null;
+ }
+ const rejection = checkPickedType(absolute, kind, stat, extensionsFor(kind));
+ return rejection === null ? null : rejectionMessage(rejection, t);
+ }
+
+ // ── Directory listing for the in-launcher file picker ──────────────────────
+
+ /**
+ * One directory's contents, plus the starting points offered beside it. READ-ONLY and deliberately
+ * unrestricted: where to browse is the user's business (the most common install path of all,
+ * `C:\Program Files (x86)\Steam\steamapps\common\…`, is a system directory by any definition). What is
+ * guarded is the ACCEPTANCE of a path, not the looking — see acceptPickedPaths.
+ */
+ private async listDir(request: GameConfigListDirRequest): Promise {
+ const t = this.deps.getTranslator();
+ const roots = await this.pickerRoots();
+ // A field measured from a sub-directory browses from there; an unresolvable one falls back to the
+ // root rather than failing — this is where the picker OPENS, not what it will accept.
+ const baseDir =
+ request.root !== undefined && request.base !== undefined && request.base !== ''
+ ? resolveInside(request.root, request.base)
+ : null;
+ const target =
+ request.path ??
+ startDirFor(
+ { ...request, ...(baseDir !== null ? { baseDir } : {}) },
+ {
+ homeDir: os.homedir(),
+ appDataDir: app.getPath('appData'),
+ downloadsDir: app.getPath('downloads'),
+ rootIsCard: request.root !== undefined && this.sourceOf(request.root) === 'card',
+ },
+ );
+ let names: readonly string[];
+ try {
+ names = await fs.readdir(target);
+ } catch (cause) {
+ log.warn(`[game-config] cannot list "${target}":`, describe(cause));
+ return { ok: false, message: t('gameConfig.listFailed'), roots };
+ }
+ const entries: DirEntry[] = [];
+ for (const name of names) {
+ if (name.startsWith('.')) continue; // dotfiles are noise in a picker for games and artwork
+ try {
+ // stat, not lstat: a symlinked folder is a folder to browse. Accepting what is INSIDE it is a
+ // separate decision, made by acceptPickedPaths, which refuses symlinks on its own.
+ const full = path.join(target, name);
+ const stats = await fs.stat(full);
+ // A macOS `.app` bundle is a directory the user means as one file — see listsAsFile.
+ const isDir = stats.isDirectory() && !listsAsFile(full, true, request.kind);
+ entries.push({ name, kind: isDir ? 'dir' : 'file' });
+ } catch {
+ continue; // a dangling link or an unreadable entry — simply not offered
+ }
+ }
+ entries.sort((a, b) =>
+ a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === 'dir' ? -1 : 1,
+ );
+ const parent = path.dirname(target);
+ return {
+ ok: true,
+ path: target,
+ parent: parent === target ? null : parent,
+ entries,
+ roots,
+ };
+ }
+
+ /** The left column: the card, this machine's library, the home folder and every mounted volume. */
+ private async pickerRoots(): Promise {
+ const t = this.deps.getTranslator();
+ const roots: DirRoot[] = [];
+ const card = this.deps.getActiveRoot();
+ if (card !== null) roots.push({ path: card, label: card, kind: 'card' });
+ roots.push({ path: this.deps.pcLibrary.root, label: t('gameConfig.thisPc'), kind: 'pc' });
+ roots.push({ path: os.homedir(), label: t('gameConfig.homeFolder'), kind: 'home' });
+ try {
+ for (const mount of await listAllMountpoints()) {
+ if (roots.some((entry) => entry.path === mount)) continue;
+ roots.push({ path: mount, label: mount, kind: 'drive' });
+ }
+ } catch (cause) {
+ log.warn('[game-config] enumerating volumes for the picker failed:', describe(cause));
+ }
+ return roots;
+ }
+
/**
* Reads a card-relative image into a data URL for the hero preview. Reuses the manifest's anti-traversal
* (`resolveInside`) and the untrusted-root check, so the preview can only read files INSIDE the card.
@@ -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 {
- 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 {
+ return this.isAllowedRoot(root);
}
- private async pushDrives(): Promise {
- 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 {
+ 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 {
+ 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 | 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 | 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,
+ 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
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 | null = null;
// The reconciled Stats per game id, captured in loadCard so onSelectRequested can rebuild the selected
// game's GameInfo without re-reading stats (buildGameInfo still re-reads the .acf for a steam game).
private statsById = new Map();
- // Reads card assets (hero/audio/wallpaper) into data URLs; owns the 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 => {
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 => 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 => 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 => 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 {
+ 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 {
+ const id = this.currentBrowse?.id;
+ if (id === undefined) return;
+ // The EFFECTIVE manifest, not the library's own: a local game whose id is also on the card is served
+ // by the card (see `games`), and the card's reload speaks for that one.
+ const manifest = this.games.find((game) => game.raw.id === id);
+ if (manifest === undefined || manifest.source !== 'pc') return;
+ await this.browseTo(id, true);
+ }
+
+ /**
+ * Enters `ready` on a local game with its assets, without touching the window's visibility: this runs
+ * when a card was pulled, and a launcher the user had hidden must stay hidden (the same intent
+ * onRemove's hide/show branch respects).
+ */
+ private async enterReadyForLocal(manifest: ResolvedManifest): Promise {
+ this.selectedId = manifest.raw.id;
+ const stats = this.statsById.get(manifest.raw.id) ?? (await this.deps.stats.read(manifest.raw.id));
+ this.setHero(await this.assets.readHeroAssets(manifest));
+ this.setCardMusic(await this.cardMusicFor(manifest));
+ this.enterReady(await this.buildGameInfo(manifest, stats));
+ await this.browseToUnlessPinned(manifest.raw.id);
+ }
+
+ /**
+ * Where one game's manifest lives, by id — the bridge the Customize screen crosses from "the game I am
+ * looking at" to "the file that describes it". Only games that can be acted on right now are answered
+ * for (`games`), which is the same rule the screen's menu item is gated on.
+ *
+ * The INDEX is deliberately not part of the answer: `games` is a filtered, reordered union of the card
+ * and the library (a shadowed local game is hidden, the carousel order is applied elsewhere), so a
+ * position here says nothing about the slot's position inside game.json. The screen finds its slot by
+ * `id` instead — see the plan, Р2.
+ */
+ findGameSource(id: string): { readonly root: string; readonly source: ManifestSource } | null {
+ const manifest = this.games.find((game) => game.raw.id === id);
+ if (manifest === undefined) return null;
+ return { root: manifest.root, source: manifest.source };
+ }
+
+ /** The full RESOLVED manifest of one game, by id — what moveToCard needs to plan its asset/save copies
+ * (findGameSource only answers where the file lives, not what it resolves to). */
+ findManifest(id: string): ResolvedManifest | null {
+ return this.games.find((game) => game.raw.id === id) ?? null;
+ }
+
+ /**
+ * Whether ANY game is currently running/installing/uninstalling (incl. a Steam op in flight) —
+ * main's server-side mirror of the renderer's own isBusy (app.ts), which gates Delete on the Customize
+ * screen and — new here — Move to card (GameConfigService.moveToCard, Р2.5): a move started while the
+ * game is mid-launch would race the launcher's own manifest handling.
+ */
+ isBusy(): boolean {
+ const kind = this.deps.state.get().kind;
+ 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 {
- 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 {
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 {
@@ -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 {
+ 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 {
- 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 {
- 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 {
+ 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 {
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