From 3b2a57820fc6ca4804fbca1d8a609f9d0d0f2c53 Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 11 Sep 2026 11:11:34 +0500 Subject: [PATCH 01/85] Add check.yml: typecheck, lint, test on PRs and main across 3 OSes --- .github/workflows/check.yml | 74 +++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 .github/workflows/check.yml diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..c2a50ce --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,74 @@ +name: Check + +# Gate every pull request and every push to main on typecheck + lint + tests, on all three OSes. The +# build-*.yml workflows run the same steps, but only on a v* tag or by hand — so between releases nothing +# checked main, and rule violations (a bare `path.join` in a *.linux.ts module, which only fails on the +# Windows runner) reached main and sat there until the next release build went red. +# +# Windows is not optional here: it is the only runner where the `path.posix` rule (CLAUDE.md, platform +# layer) fails loudly. + +on: + pull_request: + push: + branches: + - main + +# A new push to the same PR supersedes the run in flight — three OSes × ~5 minutes is too much to spend +# on a commit nobody will look at. +concurrency: + group: check-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + name: ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + electron-cache: ~/.cache/electron + # Pinned to windows-2022 for the same reason as build-windows.yml: windows-latest ships Visual + # Studio 18, which the bundled node-gyp cannot recognize, so the native drivelist build fails + # during `npm ci`. + - os: windows-2022 + electron-cache: ~\AppData\Local\electron\Cache + - os: macos-latest + electron-cache: ~/Library/Caches/electron + + steps: + - name: Checkout + uses: actions/checkout@v5 + + # Node 22.12+ is required by electron@43 and @electron/rebuild@4 (their `engines`). `cache: npm` + # keeps ~/.npm between runs. + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: '22' + cache: 'npm' + + # The electron binary is downloaded by electron's own postinstall on every `npm ci`; caching its + # download directory saves ~100 MB per run. + - name: Cache electron download + uses: actions/cache@v4 + with: + path: ${{ matrix.electron-cache }} + key: electron-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + + # Deliberately WITHOUT --ignore-scripts: the postinstall (electron-builder install-app-deps) builds + # the native drivelist, and the game-config-* / game-move-transaction tests reach it through + # game-config.ts → drive-watcher.ts. + - name: Install dependencies + run: npm ci + + - name: Type check + run: npm run typecheck + + - name: Lint + run: npm run lint + + - name: Unit tests + run: npm test From 7fb0dd292c8e694945d33cf559bf8ca5ac507dc7 Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 11 Sep 2026 11:13:17 +0500 Subject: [PATCH 02/85] Guard path.posix in platform layer with a source-as-text test; fix 6 hits --- src/main/platform/game-launcher.linux.ts | 10 +-- src/main/platform/save-path.linux.ts | 2 +- test/platform-posix-paths.test.ts | 82 ++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 6 deletions(-) create mode 100644 test/platform-posix-paths.test.ts diff --git a/src/main/platform/game-launcher.linux.ts b/src/main/platform/game-launcher.linux.ts index de4eb55..8aaed16 100644 --- a/src/main/platform/game-launcher.linux.ts +++ b/src/main/platform/game-launcher.linux.ts @@ -46,7 +46,7 @@ export interface LinuxGameLauncherDeps { function protonLogDir(userData: string): string | undefined { const flag = process.env['PLAYHOOK_PROTON_LOG']; if (flag === undefined || flag === '' || flag === '0') return undefined; - return path.join(userData, 'proton-logs'); + return path.posix.join(userData, 'proton-logs'); } /** Ensures the Proton-log dir exists (Proton won't create PROTON_LOG_DIR itself). No-op when off. */ @@ -64,7 +64,7 @@ async function collectProtonLog(logDir: string): Promise { const names = (await fse.readdir(logDir)).filter((name) => name.endsWith('.log')); let newest: { readonly path: string; readonly mtimeMs: number } | null = null; for (const name of names) { - const full = path.join(logDir, name); + const full = path.posix.join(logDir, name); const stat = await fse.stat(full); if (newest === null || stat.mtimeMs > newest.mtimeMs) newest = { path: full, mtimeMs: stat.mtimeMs }; } @@ -176,7 +176,7 @@ const WINETRICKS_SENTINEL = '.playhook-winetricks'; /** Reads the verbs already provisioned in `prefix` (empty if the sentinel is missing/unreadable). */ async function readDoneVerbs(prefix: string): Promise { try { - const text = await fse.readFile(path.join(prefix, WINETRICKS_SENTINEL), 'utf8'); + const text = await fse.readFile(path.posix.join(prefix, WINETRICKS_SENTINEL), 'utf8'); return text.split('\n').map((line) => line.trim()).filter((line) => line.length > 0); } catch { return []; // missing sentinel = nothing provisioned yet (normal first install) @@ -254,7 +254,7 @@ async function ensurePrefixDeps( // Persist the union of previously-done and newly-applied verbs so re-installs skip this step. const union = [...new Set([...done, ...pending])]; try { - await fse.writeFile(path.join(prefix, WINETRICKS_SENTINEL), `${union.join('\n')}\n`); + await fse.writeFile(path.posix.join(prefix, WINETRICKS_SENTINEL), `${union.join('\n')}\n`); } catch (cause) { // Non-fatal: without the sentinel the next install re-runs winetricks (idempotent, just slower). log.warn(`[install] failed to write winetricks sentinel: ${cause instanceof Error ? cause.message : String(cause)}`); @@ -332,7 +332,7 @@ export function createLinuxGameLauncher(deps: LinuxGameLauncherDeps): GameProces // on linux — umu surfaces the Wine window whenever the installer isn't running silently). const installerArgs = buildInstallerArgs(install.type, install.installerDir, install.args, false, silent); const args = buildUmuArgs(deps.umuRunPath, install.installerPath, installerArgs); - const cwd = path.dirname(install.installerPath); + const cwd = path.posix.dirname(install.installerPath); log.info( `[install] umu-run installer silent=${silent} prefix="${prefix}" installer="${install.installerPath}" dir="${install.installerDir}"`, ); diff --git a/src/main/platform/save-path.linux.ts b/src/main/platform/save-path.linux.ts index 0408bb3..dbbc843 100644 --- a/src/main/platform/save-path.linux.ts +++ b/src/main/platform/save-path.linux.ts @@ -88,7 +88,7 @@ async function findCompatdataPrefix(appid: number, steamLocator: SteamLocator): if (steamPath === null) return null; const libs = await steamLibraryDirs(steamPath); for (const lib of libs) { - const pfx = path.join(lib, 'steamapps', 'compatdata', String(appid), 'pfx'); + const pfx = path.posix.join(lib, 'steamapps', 'compatdata', String(appid), 'pfx'); if (await fse.pathExists(pfx)) return pfx; } return null; diff --git a/test/platform-posix-paths.test.ts b/test/platform-posix-paths.test.ts new file mode 100644 index 0000000..f2380e5 --- /dev/null +++ b/test/platform-posix-paths.test.ts @@ -0,0 +1,82 @@ +// The Linux/macOS side of the platform layer must build paths with `path.posix`, never bare `path.*`. +// +// `path.join` follows the OS the code RUNS on. A Linux path built with it comes out as `\home\deck\...` on +// the Windows CI runner and fails a test that (correctly) expects `/home/deck/...` — and when the value is +// DERIVED from a path (the Steam shortcut appid is a CRC32 of it) nothing fails at all, the number is just +// wrong. CLAUDE.md states the rule and offers a grep; this test is that grep with teeth, in the same +// source-as-text style as daemon-imports.test.ts. Only the win32 modules are exempt: there plain `path` +// IS the right choice. +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +const REPO_ROOT = path.resolve(__dirname, '..'); +const PLATFORM_DIR = path.join(REPO_ROOT, 'src/main/platform'); + +/** The win32 bundle and any `*.win32.ts` helper: the only files where bare `path.*` is correct. */ +function isWin32Module(file: string): boolean { + return file === 'win32.ts' || file.endsWith('.win32.ts'); +} + +/** Every `.ts` under `src/main/platform/`, recursively, as paths relative to that directory. */ +function platformSources(dir = PLATFORM_DIR): readonly string[] { + const found: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) found.push(...platformSources(full)); + else if (entry.name.endsWith('.ts')) found.push(path.relative(PLATFORM_DIR, full)); + } + return found.sort(); +} + +/** + * A bare `path.(` or `path.sep` — `path.posix.join(` does not match because `posix` follows the dot, + * which is exactly the distinction the rule draws. + */ +const BARE_PATH_CALL = /\bpath\.(?:join|dirname|basename|resolve|relative|normalize|isAbsolute)\(|\bpath\.sep\b/; + +/** `file:line: source` for every offending line in a source text. */ +function offendingLines(file: string, source: string): readonly string[] { + return source + .split('\n') + .flatMap((line, index) => (BARE_PATH_CALL.test(line) ? [`${file}:${index + 1}: ${line.trim()}`] : [])); +} + +/** All violations across the posix-side platform modules. */ +function violations(): readonly string[] { + return platformSources() + .filter((file) => !isWin32Module(file)) + .flatMap((file) => offendingLines(file, fs.readFileSync(path.join(PLATFORM_DIR, file), 'utf8'))); +} + +describe('the posix side of the platform layer', () => { + it('builds every path with `path.posix`, never bare `path.*`', () => { + // A failure here would surface as a red Windows job with `\home\deck\...` in the assertion diff — or + // not surface at all when the path only feeds a checksum. Fix the SOURCE (path.posix.join), never the + // test expectation. + expect(violations()).toEqual([]); + }); + + it('actually scans the modules the rule is about (guards the test against matching nothing)', () => { + const scanned = platformSources().filter((file) => !isWin32Module(file)); + expect(scanned).toContain('umu.ts'); + expect(scanned).toContain('game-launcher.linux.ts'); + expect(scanned).toContain('save-path.darwin.ts'); + expect(scanned).not.toContain('win32.ts'); + }); + + it('would catch a violation if one were introduced', () => { + const sample = [ + "const a = path.join(home, 'x');", + 'const b = path.posix.join(home, "x");', + 'const c = path.dirname(file);', + 'const d = path.sep;', + 'const e = path.posix.sep;', + ].join('\n'); + expect(offendingLines('sample.ts', sample)).toEqual([ + "sample.ts:1: const a = path.join(home, 'x');", + 'sample.ts:3: const c = path.dirname(file);', + 'sample.ts:4: const d = path.sep;', + ]); + }); +}); From 5bdd68d55560c7d3cc882e419ff7bd7155d3b251 Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 11 Sep 2026 11:14:24 +0500 Subject: [PATCH 03/85] Lint: warn on process.platform outside the platform layer (allowlist) --- eslint.config.mjs | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/eslint.config.mjs b/eslint.config.mjs index 49c0c88..80c5870 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -41,6 +41,43 @@ export default tseslint.config( ], }, }, + { + // CLAUDE.md's platform-layer rule ("all OS-specific behaviour lives behind the `Platform` bundle, not + // scattered `process.platform` checks"), enforced. The allowlist below is every file where a direct + // check is the RIGHT thing, each with its reason; anything else is a behavioural branch that belongs + // on a `Platform` interface. `warn` until the five remaining behavioural hits (ipc.ts, + // game-launcher.ts ×2, registry.ts ×2) move into `Platform`, then `error`. A per-line + // `eslint-disable` is not the way out — the repo has none, and that is worth keeping. + files: ['src/main/**/*.ts'], + ignores: [ + // The platform layer itself: this is where the OS is meant to be asked. + 'src/main/platform/**', + // Bootstrap and electron-UI glue that select the bundle or an OS-specific electron behaviour once + // (application menu, dock hide, tray icon format, simple-fullscreen, log/config directories). + 'src/main/main.ts', + 'src/main/window.ts', + 'src/main/tray.ts', + 'src/main/logger.ts', + 'src/main/config-paths.ts', + // electron-updater environment detection (AppImage vs macOS) — about the updater's runtime, not + // about a game. + 'src/main/updater.ts', + // Self-guards of the win32-only FFI modules: they must refuse to bind koffi anywhere but Windows + // BEFORE any Platform object exists. + 'src/main/foreground.ts', + 'src/main/window-finder.ts', + ], + rules: { + 'no-restricted-syntax': [ + 'warn', + { + selector: "MemberExpression[object.name='process'][property.name='platform']", + message: + 'OS-specific behaviour belongs on a `Platform` interface (src/main/platform/types.ts), not on a process.platform check here. See CLAUDE.md "Platform layer".', + }, + ], + }, + }, { files: ['test/**/*.ts'], rules: { From 19709c0a8361068c5b5b2293a424bf9716a71c9c Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 11 Sep 2026 11:16:03 +0500 Subject: [PATCH 04/85] Add file-size ratchet test over src/** and the four screen factories --- test/file-size-ratchet.test.ts | 141 +++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 test/file-size-ratchet.test.ts diff --git a/test/file-size-ratchet.test.ts b/test/file-size-ratchet.test.ts new file mode 100644 index 0000000..b67e93b --- /dev/null +++ b/test/file-size-ratchet.test.ts @@ -0,0 +1,141 @@ +// A ratchet on the size of the largest source files and screen factories: they may only shrink. +// +// The July 2026 audit split ipc.ts (→ 953 lines) and app.ts (→ 195). Six hundred commits later they were +// 2833 and 1168, and game-settings-screen.ts had grown from nothing to 2942 — a split that is not held in +// place mechanically does not hold. A `max-lines` threshold would not do: it measures with `skipComments` +// (different numbers from every plan and PR description), a threshold invites arguing about the number, +// and it cannot see a 3000-line file split into four files of 1400. A ratchet has no threshold to argue +// about: nobody may grow, and whoever shrinks a file records the win here so it cannot be given back. +// +// Both baselines are RAW line counts (`wc -l`, comments included) — the one metric everybody can reproduce +// from the shell. When you shrink a file, lower its entry; when a file drops below the free limit, delete +// the entry. When you grow one, split it instead. +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +const REPO_ROOT = path.resolve(__dirname, '..'); +const SRC_ROOT = path.join(REPO_ROOT, 'src'); + +/** Any `src/**` file NOT listed in `FILE_BASELINE` must stay at or under this. */ +const FREE_LIMIT = 1000; + +/** A file that shrank by more than this since its entry must have the entry lowered (keeps the ratchet honest). */ +const SLACK = 50; + +/** Baseline `wc -l` per file over the free limit at the time of writing (playhook v0.8.1). */ +const FILE_BASELINE: Readonly> = { + 'src/main/game-config.ts': 1478, + 'src/main/ipc.ts': 2833, + 'src/main/manifest.ts': 1125, + 'src/renderer/app.ts': 1168, + 'src/renderer/controls.ts': 2188, + 'src/renderer/game-settings-screen.ts': 2942, + 'src/renderer/online-picker.ts': 1322, + 'src/renderer/settings-screen.ts': 1193, + 'src/shared/types.ts': 1693, +}; + +interface FactoryBaseline { + readonly file: string; + readonly name: string; + readonly lines: number; +} + +/** + * The screen factories that are each a single closure: measured from `export function (` to the + * next `}` alone on a line at column zero. Anything moved out of the closure into a module of its own + * (a menu stack, a lightbox, a popup) lowers the number here. + */ +const FACTORY_BASELINE: readonly FactoryBaseline[] = [ + { file: 'src/renderer/game-settings-screen.ts', name: 'createGameSettingsScreen', lines: 2627 }, + { file: 'src/renderer/controls.ts', name: 'createControls', lines: 1936 }, + { file: 'src/renderer/online-picker.ts', name: 'createOnlinePicker', lines: 1174 }, + { file: 'src/renderer/settings-screen.ts', name: 'createSettingsScreen', lines: 998 }, +]; + +/** `wc -l`: the number of newline characters. */ +function lineCount(source: string): number { + let count = 0; + for (const char of source) if (char === '\n') count += 1; + return count; +} + +/** Every `.ts` under `src/`, recursively, as repo-relative POSIX paths. */ +function sourceFiles(dir = SRC_ROOT): readonly string[] { + const found: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) found.push(...sourceFiles(full)); + else if (entry.name.endsWith('.ts')) found.push(path.relative(REPO_ROOT, full).split(path.sep).join('/')); + } + return found.sort(); +} + +function readSource(file: string): string { + return fs.readFileSync(path.join(REPO_ROOT, file), 'utf8'); +} + +/** + * Lines spanned by the top-level function `name`, from its `export function` line to the first `}` on a + * line of its own at column zero after it; null when the function is not found. + */ +function functionSpan(source: string, name: string): number | null { + const lines = source.split('\n'); + const start = lines.findIndex((line) => line.startsWith(`export function ${name}(`)); + if (start === -1) return null; + const end = lines.findIndex((line, index) => index > start && line === '}'); + return end === -1 ? null : end - start + 1; +} + +/** One `file: actual > allowed` line per file that grew past its baseline (or past the free limit). */ +function grownFiles(): readonly string[] { + return sourceFiles().flatMap((file) => { + const actual = lineCount(readSource(file)); + const allowed = FILE_BASELINE[file] ?? FREE_LIMIT; + return actual > allowed ? [`${file}: ${actual} > ${allowed}`] : []; + }); +} + +/** Baseline entries that no longer describe the file: stale (file gone / below the free limit) or too generous. */ +function staleFileEntries(): readonly string[] { + return Object.entries(FILE_BASELINE).flatMap(([file, baseline]) => { + if (!fs.existsSync(path.join(REPO_ROOT, file))) return [`${file}: file is gone — delete its entry`]; + const actual = lineCount(readSource(file)); + if (actual <= FREE_LIMIT) return [`${file}: ${actual} ≤ ${FREE_LIMIT} — delete its entry, the free limit covers it`]; + if (actual < baseline - SLACK) return [`${file}: ${actual}, entry says ${baseline} — lower the entry`]; + return []; + }); +} + +describe('file size ratchet (raw lines, wc -l)', () => { + it('no src/** file grew past its baseline (or past the free limit when it has none)', () => { + // Split the file; do not raise the number. The baseline exists to be lowered. + expect(grownFiles()).toEqual([]); + }); + + it('every baseline entry still describes its file (lower it after a split; delete it under the free limit)', () => { + expect(staleFileEntries()).toEqual([]); + }); + + it('the screen factories did not grow, and a shrink was recorded', () => { + const problems = FACTORY_BASELINE.flatMap(({ file, name, lines }) => { + const actual = functionSpan(readSource(file), name); + if (actual === null) return [`${file}: ${name} not found — update or delete its entry`]; + if (actual > lines) return [`${file}: ${name} is ${actual} lines > ${lines}`]; + if (actual < lines - SLACK) return [`${file}: ${name} is ${actual} lines, entry says ${lines} — lower the entry`]; + return []; + }); + expect(problems).toEqual([]); + }); + + it('measures like wc -l and finds a function span (guards the detectors themselves)', () => { + expect(lineCount('a\nb\n')).toBe(2); + expect(lineCount('a\nb')).toBe(1); + expect(lineCount('')).toBe(0); + const sample = ['import x from "y";', '', 'export function createThing(deps: Deps): Thing {', ' return {};', '}', ''].join('\n'); + expect(functionSpan(sample, 'createThing')).toBe(3); + expect(functionSpan(sample, 'createOther')).toBeNull(); + expect(sourceFiles().length).toBeGreaterThan(50); + }); +}); From 22238e6c2978723256f7cbdf751c935dae811fa6 Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 11 Sep 2026 11:16:35 +0500 Subject: [PATCH 05/85] Docs: Prettier is not a CI gate; Tooling section names check.yml --- CLAUDE.md | 10 +++++++--- README.md | 3 ++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 249dd21..476b88b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -174,7 +174,10 @@ build does not self-update. **All OS-specific behaviour lives behind the `Platfo "fix" such a failure by rewriting the expectation with `path.join`: that makes the test assert whatever the code does and stops testing anything at all. -## Tooling (all run in CI before build) +## Tooling + +The first three run in CI on every PR and push to `main` (`check.yml`) and again before every release +build. Prettier is deliberately NOT a gate. - `npm run typecheck` — strict `tsc`, no `any`. Covers `test/` as well as `src/`. - `npm run lint` — ESLint with type-aware rules (`no-non-null-assertion` — the "no `!`" rule, which @@ -182,5 +185,6 @@ build does not self-update. **All OS-specific behaviour lives behind the `Platfo `strict-boolean-expressions`), over `src` and `test`. Tests switch off `require-await` and `unbound-method` (both only ever fire on test doubles) and allow a `_`-prefixed unused parameter. - `npm test` — vitest. -- `npm run format` / `format:check` — Prettier (available for new code; the existing hand-aligned - files are intentionally not mass-reformatted). +- `npm run format` / `format:check` — Prettier, available for new code and not run in CI: the existing + hand-aligned files are intentionally not mass-reformatted, so `format:check` is red on most of the repo + by design. Format the file you are adding; do not reformat the one you are touching. diff --git a/README.md b/README.md index a65a541..552e847 100644 --- a/README.md +++ b/README.md @@ -994,7 +994,8 @@ import graph) live in [`CLAUDE.md`](CLAUDE.md). PRs welcome. The codebase is **strict TypeScript** (no `any`, no non-null `!`, explicit return types, functional style). Please run `npm run typecheck`, `npm run lint` and `npm test` before opening a PR — -CI runs all three, on Windows, Linux **and** macOS. +CI runs all three on every PR, on Windows, Linux **and** macOS. Prettier (`npm run format`) is there for +new files, not a gate: the existing hand-aligned sources are intentionally left as they are. --- From f952cc60e71399e76680f35878efda30e7cdb467 Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 11 Sep 2026 11:31:10 +0500 Subject: [PATCH 06/85] Fix Windows runner: CRLF-safe ratchet span, separator-agnostic compatdata assert --- test/file-size-ratchet.test.ts | 8 +++++++- test/save-path-linux.test.ts | 6 ++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/test/file-size-ratchet.test.ts b/test/file-size-ratchet.test.ts index b67e93b..7135fd6 100644 --- a/test/file-size-ratchet.test.ts +++ b/test/file-size-ratchet.test.ts @@ -54,6 +54,11 @@ const FACTORY_BASELINE: readonly FactoryBaseline[] = [ { file: 'src/renderer/settings-screen.ts', name: 'createSettingsScreen', lines: 998 }, ]; +/** Source lines regardless of line endings (a Windows checkout without .gitattributes has CRLF). */ +function splitLines(source: string): readonly string[] { + return source.split(/\r?\n/); +} + /** `wc -l`: the number of newline characters. */ function lineCount(source: string): number { let count = 0; @@ -81,7 +86,7 @@ function readSource(file: string): string { * line of its own at column zero after it; null when the function is not found. */ function functionSpan(source: string, name: string): number | null { - const lines = source.split('\n'); + const lines = splitLines(source); const start = lines.findIndex((line) => line.startsWith(`export function ${name}(`)); if (start === -1) return null; const end = lines.findIndex((line, index) => index > start && line === '}'); @@ -136,6 +141,7 @@ describe('file size ratchet (raw lines, wc -l)', () => { const sample = ['import x from "y";', '', 'export function createThing(deps: Deps): Thing {', ' return {};', '}', ''].join('\n'); expect(functionSpan(sample, 'createThing')).toBe(3); expect(functionSpan(sample, 'createOther')).toBeNull(); + expect(functionSpan(sample.replace(/\n/g, '\r\n'), 'createThing')).toBe(3); expect(sourceFiles().length).toBeGreaterThan(50); }); }); diff --git a/test/save-path-linux.test.ts b/test/save-path-linux.test.ts index 9968981..d9933f2 100644 --- a/test/save-path-linux.test.ts +++ b/test/save-path-linux.test.ts @@ -161,8 +161,10 @@ describe('createLinuxSavePathResolver — a local game vs a local Steam game', ( expect(location).not.toBeNull(); // Asserted as "under compatdata + the Windows-profile tail" rather than one literal, because the // compatdata root is a real temp dir whose separators follow the OS the suite runs on (CI runs it on - // Windows too) — the tail below drive_c is the part this mapping owns. - expect(location?.path.startsWith(compat)).toBe(true); + // Windows too) — the tail below drive_c is the part this mapping owns. The resolver joins with + // `path.posix` (the platform-layer rule), so on the Windows runner the result is mixed-separator; + // compare with both sides normalised. + expect(location?.path.replace(/\\/g, '/').startsWith(compat.replace(/\\/g, '/'))).toBe(true); expect(location?.path.endsWith('drive_c/users/steamuser/AppData/Roaming/Hades/Saves')).toBe(true); expect(location?.containerExists).toBe(true); }); From e6cb1d53b8cda119c487b9467b06b8b961a674c1 Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 11 Sep 2026 11:47:39 +0500 Subject: [PATCH 07/85] README: Settings section, Logs pointer and project structure match the code --- README.md | 43 ++++++++++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 552e847..94785a1 100644 --- a/README.md +++ b/README.md @@ -266,19 +266,21 @@ the Steam Deck they work in **Game Mode** as well as on the desktop. ### Settings -- **Updates** — automatic mode (*download and install* / *download, install manually* / *off*), the - **pre-release (beta)** channel, and a manual *Check for updates* with the current version. -- **Appearance** — theme (system / light / dark), **language** (system / English / Russian), and a - custom **background for the empty screen** (any image; *Reset* restores the bundled wallpaper). +- **Updates** — the current version and a *Check for updates* button, the automatic mode (*download + and install* / *download, install manually* / *off*) and the **pre-release (beta)** channel. On macOS, + where the app cannot update itself, only the status line is shown. +- **Language** — the interface language (system / English / Russian). - **General** — the gamepad summon hotkey, *keep the screen awake while the launcher is open*, - *always show the no-card screen* (instead of hiding to the tray), *disable silent installer mode* - (see [Install mode](#install-mode-heavy-games-on-slow-media)), and, on the Steam Deck, the Game Mode - auto-launch on card insertion. + *keep the launcher open without a card* (instead of hiding to the tray), *disable silent installer + mode* (see [Install mode](#install-mode-heavy-games-on-slow-media)), and, on the Steam Deck, the Game + Mode auto-launch on card insertion. +- **Game metadata** — an optional **SteamGridDB API key**: with it, *Find online* also offers covers and + backgrounds from SteamGridDB (see [Credits](#credits)). - **Audio** — the **navigation sound set** and the **background ambience** shipped with the app, a volume slider for each, and an *only global ambience* switch that makes the app's ambience win over whatever the card carries in `backgroundMusic`. The navigation sounds always come from the chosen set: a card cannot supply its own. -- **Advanced** — *Open logs*, *Open games folder* (the install-mode directory), *Reset to defaults*. +- **Reset to defaults** and **Close** at the bottom. The logs folder is opened from the tray menu. Settings live in `settings.json` next to the rest of the app state (`%APPDATA%\playhook\` on Windows, `~/.config/playhook/` on Linux, `~/Library/Application Support/playhook/` on macOS); a missing or @@ -916,7 +918,7 @@ exists while you are in Game Mode; **Remove from Steam** deletes it. The main process writes a timestamped log, split **per calendar day** into `%APPDATA%\playhook\logs\main-YYYY-MM-DD.log` (`~/.config/playhook/logs/` on Linux, `~/Library/Application Support/playhook/logs/` on macOS); files older than -**14 days** are pruned on startup. Open the folder from **Settings → Advanced → Open logs**. +**14 days** are pruned on startup. Open the folder from the tray menu (**Open logs**). It records card insertions, manifest validation, the stats reconcile / card-copy result, and launch/exit — useful when a save or stats copy to the card silently fails. @@ -977,14 +979,29 @@ launch/exit — useful when a save or stats copy to the card silently fails. ``` src/ main/ # all work with the FS/processes/disks (Electron main) + metadata/ # "Find online": the MetadataProvider seam and one module per source (Steam, GOG, ...) platform/ # everything OS-specific (win32 / linux + Proton, umu / darwin) preload/ # the typed contextBridge bridge renderer/ # UI + gamepad/keyboard input (launcher, settings, customize — no Node) - shared/ # shared contract of types/IPC channels + the i18n dictionaries + shared/ # shared contract of types/IPC channels + i18n/ # the en / ru dictionaries and plural rules test/ # vitest suites (plain Node, no electron) + renderer/ # DOM tests of the screens, run under happy-dom against the real index.html + stubs/ # the inert `electron` stub the node suites are aliased to +scripts/ # build-time helpers, plain ESM (see below) ``` -npm scripts: `typecheck`, `lint`, `test`, `format`, `build`, `start`, `rebuild`, `build:umu`, `dist`. +npm scripts: `typecheck`, `lint`, `test`, `format`, `format:check`, `build` (= `build:renderer`, +`build:app`, `build:main`, `build:assets`), `build:umu`, `start`, `rebuild`, `dist`; `postinstall` runs +`electron-builder install-app-deps`. + +`scripts/*.mjs`: + +- `copy-assets.mjs` — copies html/css/fonts/audio into `dist/` after tsc and writes `dist/audio/index.json`. +- `audio-index.mjs` — the pure, unit-tested helpers `copy-assets.mjs` uses to list sound sets and ambience. +- `fetch-umu.mjs` — downloads the pinned umu-launcher zipapp into `resources/umu/` for the Linux build. +- `after-pack.mjs` — electron-builder `afterPack` hook (Linux): wraps the executable so Game Mode gets `--no-sandbox`. + Contributor conventions (layers, error handling, adding an IPC channel, the daemon's electron-free import graph) live in [`CLAUDE.md`](CLAUDE.md). @@ -1026,8 +1043,8 @@ new files, not a gate: the existing hand-aligned sources are intentionally left client (the card is just a pointer by `appid`). For a **non-Steam** wrapper/launcher `.exe`, point it at the exe and use [`watchProcesses`](#rules-and-security-the-card-is-untrusted-input) so exit detection works. -- **Is there a Russian UI?** Yes — *Settings → Appearance* has English and Russian (plus a light/dark/system - theme), and by default the app follows the system language. +- **Is there a Russian UI?** Yes — *Settings → Language* has English and Russian, and by default the app + follows the system language. --- From 18f7a5c8c8de46ce644626b67e4a17659311e3b7 Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 11 Sep 2026 11:47:39 +0500 Subject: [PATCH 08/85] CLAUDE.md: DI reference, renderer state wording, mechanical guards section --- CLAUDE.md | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 476b88b..4473837 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,9 @@ mandate to rewrite what already works. ## Layers (do not blur) -- **main** owns all game logic (fs, registry, process control, FFI). **renderer** is stateless UI. +- **main** owns all game logic (fs, registry, process control, FFI). **renderer** holds no domain state: + UI state (a manifest draft, a menu stack, the boot sequence) lives inside the screen factories, and + everything about games, cards and settings is pushed from main via the `on*` subscriptions. - They talk **only over IPC**. The renderer never touches fs/registry; main never touches the DOM. - Preload bridges are typed and sandboxed (`contextIsolation: true`, `sandbox: true`). - A **pure** function BOTH sides must compute identically (no fs/electron either way) lives in @@ -48,11 +50,11 @@ Pick per situation, matching the existing patterns: ## Adding a new service -Follow the **interface-DI** shape of `StatsService` / `UpdaterService` (dependencies passed via a -typed `…Deps` interface), not the bare-primitive-constructor or free-function styles that predate it. -Interface-DI is the most testable: it lets a unit test inject fakes without electron/fs. Bootstrap the -service in `main.ts`; wire IPC through `GameController` (or the service's own `init()`, the way -`GameConfigService` and `MetadataService` register their channels). +Follow the **interface-DI** shape of `UpdaterService` / `NotificationsService` / `MetadataService` +(dependencies passed via a typed `…Deps` interface), not the bare-primitive-constructor or free-function +styles that predate it. Interface-DI is the most testable: it lets a unit test inject fakes without +electron/fs. Bootstrap the service in `main.ts`; wire IPC through `GameController` (or the service's own +`init()`, the way `GameConfigService` and `MetadataService` register their channels). ## Adding a new IPC channel @@ -133,6 +135,7 @@ build does not self-update. **All OS-specific behaviour lives behind the `Platfo it), a wrong separator does not fail loudly — it produces a wrong value. Quick check before pushing: `grep -rn "path\.\(join\|dirname\|basename\|resolve\)(" src/main/platform/*.{linux,darwin}.ts` + — and `test/platform-posix-paths.test.ts` is that grep with teeth (see "Mechanical guards"). ## Tests @@ -174,6 +177,27 @@ build does not self-update. **All OS-specific behaviour lives behind the `Platfo "fix" such a failure by rewriting the expectation with `path.join`: that makes the test assert whatever the code does and stops testing anything at all. +## Mechanical guards + +Rules that live only in this file get broken between releases, so the ones that matter are enforced by +tests and lint, in the same source-as-text style as `test/daemon-imports.test.ts`: + +- **`path.posix` in the platform layer** — `test/platform-posix-paths.test.ts` reads every module under + `src/main/platform/` except `win32.ts` / `*.win32.ts` and fails on a bare `path.join` / `dirname` / + `basename` / `resolve` / `relative` / `normalize` / `isAbsolute` / `path.sep`. +- **`process.platform` outside `platform/`** — ESLint `no-restricted-syntax` over `src/main/**` + (`eslint.config.mjs`), with an explicit allowlist and a reason per file (bootstrap / electron-UI glue, + electron-updater environment detection, the win32-only FFI modules' self-guards). It is `warn` until the + remaining behavioural checks move onto `Platform`, then `error`. A per-line `eslint-disable` is not the + way out: the repo has none. +- **File and factory size ratchet** — `test/file-size-ratchet.test.ts` keeps a baseline of raw `wc -l` + per `src/**` file over 1000 lines and per screen factory (`createControls`, `createGameSettingsScreen`, + …). Nothing may grow past its entry; a file that shrank by more than 50 lines must have its entry + lowered; a file not in the map may not exceed 1000. When you grow one, split it instead. + +All three run on every PR and every push to `main` on Windows, Linux and macOS +(`.github/workflows/check.yml`), so a rule violation no longer waits for the next release build to surface. + ## Tooling The first three run in CI on every PR and push to `main` (`check.yml`) and again before every release From 070b27938c930c4faf8be98a84f35ebecea53e7b Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 11 Sep 2026 11:50:19 +0500 Subject: [PATCH 09/85] Fix stale comments: HOLD_DELAY_MS, types.ts header, locateSteam, Settings screen --- scripts/audio-index.mjs | 2 +- scripts/copy-assets.mjs | 2 +- src/main/ipc.ts | 16 ++++++++-------- src/main/main.ts | 2 +- src/main/steam-uri.ts | 2 +- src/main/steam.ts | 2 +- src/main/updater.ts | 2 +- src/main/util.ts | 4 ++-- src/main/window-hide-guard.ts | 4 ++-- src/renderer/app.ts | 4 ++-- src/renderer/settings-form-model.ts | 3 +-- src/renderer/sfx-limit.ts | 6 +++--- src/shared/i18n/en.ts | 1 - src/shared/types.ts | 14 +++++++------- test/audio-index.test.ts | 2 +- 15 files changed, 32 insertions(+), 34 deletions(-) diff --git a/scripts/audio-index.mjs b/scripts/audio-index.mjs index 16758b0..3199499 100644 --- a/scripts/audio-index.mjs +++ b/scripts/audio-index.mjs @@ -1,5 +1,5 @@ // Pure helpers for building dist/audio/index.json — the bundled sound-set + ambience listing the -// settings window reads at runtime (one readFile) instead of a readdir over the asar, which has no +// Settings screen reads at runtime (one readFile) instead of a readdir over the asar, which has no // precedent in this codebase and whose withFileTypes/isDirectory was historically weak in the asar shim. // Side-effect-free so a unit test can exercise the .DS_Store / incomplete-set / non-audio filtering // without touching the filesystem. diff --git a/scripts/copy-assets.mjs b/scripts/copy-assets.mjs index 1862658..0f69fd3 100644 --- a/scripts/copy-assets.mjs +++ b/scripts/copy-assets.mjs @@ -40,7 +40,7 @@ for (const name of icons) { await cp(resolve(root, 'audio'), resolve(outDist, 'audio'), { recursive: true }); // Enumerate the sound sets + ambience tracks HERE (real FS, where readdir/withFileTypes always works) and -// write dist/audio/index.json, so the settings window reads one file instead of a readdir over the asar — +// write dist/audio/index.json, so the Settings screen reads one file instead of a readdir over the asar — // which has no precedent in this codebase. .DS_Store and incomplete/non-audio entries are filtered out. const uiDir = resolve(root, 'audio/ui'); const ambienceDir = resolve(root, 'audio/ambience'); diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 29b5e22..d90c861 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1341,7 +1341,7 @@ export class GameController { /** * 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 + * Settings screen). Besides caching the flag it reconciles the launcher NOW when we're idle with no * card: bring it up when turning it on, or hide back to the tray when turning it off. When a card is * present (ready/busy) nothing changes — the launcher is already visible for the game. */ @@ -1660,7 +1660,7 @@ export class GameController { * and the download — possibly hours/GBs) and returns WITHOUT entering a blocking `installing` state. * We stay on the `ready` ("Install") screen; the background re-detect poller (started by enterReady) * flips the button to "Play" once Steam's .acf reports the game fully installed. Steam itself collapses - * repeated `steam://install` calls, so no debounce is needed. Pre-checks getSteamPath: openExternal + * repeated `steam://install` calls, so no debounce is needed. Pre-checks `steamLocator.locateSteam()`: openExternal * doesn't reliably reject when steam:// is unregistered. */ private async runSteamInstall(manifest: ResolvedManifest, info: GameInfo): Promise { @@ -1684,12 +1684,6 @@ export class GameController { } } - /** - * Steam uninstall action: fire-and-forget, mirroring runSteamInstall. Opens `steam://uninstall/` - * (Steam shows its own confirmation/removal UI) and returns WITHOUT a blocking `uninstalling` state. We - * stay on the `ready` ("Play"/"Uninstall") screen; the background poller flips the button back to - * "Install" once Steam removes the .acf. Pre-checks getSteamPath. - */ /** * Opens Steam's Downloads page (steam://open/downloads). Triggered by the Play button while a Steam * download is in progress (its loader is otherwise a no-op) so the user can pause/resume in Steam — @@ -1703,6 +1697,12 @@ export class GameController { } } + /** + * Steam uninstall action: fire-and-forget, mirroring runSteamInstall. Opens `steam://uninstall/` + * (Steam shows its own confirmation/removal UI) and returns WITHOUT a blocking `uninstalling` state. We + * stay on the `ready` ("Play"/"Uninstall") screen; the background poller flips the button back to + * "Install" once Steam removes the .acf. Pre-checks `steamLocator.locateSteam()`. + */ private async runSteamUninstall(manifest: ResolvedManifest, info: GameInfo): Promise { const appid = manifest.steam?.appid; if (appid === undefined) return; // defensive: onUninstallRequested only calls this in steam mode diff --git a/src/main/main.ts b/src/main/main.ts index 92ba8c2..dd7837c 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -565,7 +565,7 @@ async function bootstrap(): Promise { const globalGamepad = new GlobalGamepad(); globalGamepadRef = globalGamepad; globalGamepad.onChord(() => { - if (!summonHotkeyEnabled) return; // toggled off in the settings window + if (!summonHotkeyEnabled) return; // toggled off on the Settings screen window.showAndFocus(true); }); globalGamepad.start(); diff --git a/src/main/steam-uri.ts b/src/main/steam-uri.ts index 61dccad..1f45ccb 100644 --- a/src/main/steam-uri.ts +++ b/src/main/steam-uri.ts @@ -7,7 +7,7 @@ import { shell } from 'electron'; /** * Opens a `steam://` URI (rungameid/install) via Electron's shell.openExternal. NOTE: openExternal does * NOT reliably reject when `steam://` is unregistered (Steam not installed) — callers must gate on - * getSteamPath() !== null BEFORE calling this. Here we only guarantee that any sync/async failure + * `steamLocator.locateSteam()` !== null BEFORE calling this. Here we only guarantee that any sync/async failure * propagates as a rejected promise so the caller can surface it. */ export async function openSteamUri(uri: string): Promise { diff --git a/src/main/steam.ts b/src/main/steam.ts index 843916b..9a38be8 100644 --- a/src/main/steam.ts +++ b/src/main/steam.ts @@ -1,6 +1,6 @@ // Steam-mode backend: detecting whether a Steam app is installed (Steam's own .acf state) and opening // steam:// URIs for launch/install. A separate backend from install mode — no card installer, no -// app-controlled dir. Windows-only in practice (getSteamPath is null off-Windows ⇒ "not installed"); +// app-controlled dir. Wherever the platform's SteamLocator finds no Steam, the game reads as "not installed"; // dev builds on macOS degrade gracefully rather than crashing. // // Why .acf and not a registry DWORD: Steam's `appmanifest_.acf` (in each library's `steamapps`) diff --git a/src/main/updater.ts b/src/main/updater.ts index 88ff8ba..8ec16f0 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -473,7 +473,7 @@ export class UpdaterService { autoUpdater.quitAndInstall(); } - // ── Pushing status to the settings window ────────────────────────────────── + // ── Pushing status to the Settings screen ────────────────────────────────── private setStatus(next: UpdateStatus): void { this.status = next; diff --git a/src/main/util.ts b/src/main/util.ts index 60cc78a..cc0de87 100644 --- a/src/main/util.ts +++ b/src/main/util.ts @@ -1,5 +1,5 @@ -// Tiny shared helpers used across main-process modules (de-duplicates the copies of -// `delay`/`describe` that had drifted into individual files). +// Tiny shared helpers used across main-process modules (the copies of `delay`/`describe` that had +// drifted into individual files were folded in here; `manifest.ts` still carries its own `describe`). /** Human-readable message for an unknown thrown value. */ export function describe(cause: unknown): string { diff --git a/src/main/window-hide-guard.ts b/src/main/window-hide-guard.ts index 9baa663..732b3be 100644 --- a/src/main/window-hide-guard.ts +++ b/src/main/window-hide-guard.ts @@ -1,7 +1,7 @@ // Shared "X hides to tray instead of quitting" window behavior. GameWindow and // SettingsWindow both had an identical forceClosing flag + on('close')→preventDefault/hide + allowClose -// pair; this centralizes it. `onClose` runs on every close attempt regardless of the guard (e.g. the -// settings window detaches its updater there). +// pair; this centralizes it. `onClose` runs on every close attempt regardless of the guard (the +// settings window used it to detach its updater; today no caller passes one). import { type BrowserWindow } from 'electron'; export interface HideOnCloseGuard { diff --git a/src/renderer/app.ts b/src/renderer/app.ts index 9177f98..d4630f6 100644 --- a/src/renderer/app.ts +++ b/src/renderer/app.ts @@ -1042,7 +1042,7 @@ void window.api.requestCardMusic().then((url) => { syncMusic(); }); -// The default ambience is app-wide (set in the settings window) and delivered on its own channel; the +// The default ambience is app-wide (set on the Settings screen) and delivered on its own channel; the // audio engine plays it only while the card has no music of its own (a game's music always wins) and // crossfades between the two. Seed on startup and update live. syncMusic re-asserts the gate so a seed // arriving before the first visibility sync still starts (or stays paused) correctly. @@ -1059,7 +1059,7 @@ void window.api.requestAmbient().then((url) => { window.api.onSfxSet((set) => audio.setSounds(set)); void window.api.requestSfxSet().then((set) => audio.setSounds(set)); -// Audio volumes are app-wide (set in the settings window): seed them on startup and update live. +// Audio volumes are app-wide (set on the Settings screen): seed them on startup and update live. const applyVolumes = (volumes: { music: number; sfx: number }): void => { audio.setMusicVolume(volumes.music); audio.setSfxVolume(volumes.sfx); diff --git a/src/renderer/settings-form-model.ts b/src/renderer/settings-form-model.ts index 51000e3..d38c742 100644 --- a/src/renderer/settings-form-model.ts +++ b/src/renderer/settings-form-model.ts @@ -82,8 +82,7 @@ export function volumePercent(volume: number): number { /** * Cosmetic label for a raw set/track name: split on '-', capitalize each word, join with spaces - * (`steam-big-picture` → `Steam Big Picture`). These are proper names of bundled files — not translated. Mirrors the - * settings window's own prettifyName. + * (`steam-big-picture` → `Steam Big Picture`). These are proper names of bundled files — not translated. */ export function prettifyName(raw: string): string { return raw diff --git a/src/renderer/sfx-limit.ts b/src/renderer/sfx-limit.ts index 4f15b28..fd2889b 100644 --- a/src/renderer/sfx-limit.ts +++ b/src/renderer/sfx-limit.ts @@ -10,9 +10,9 @@ * two attempts re-arms the latch on its own. * * It cannot be the main mechanism, and it has to sit above every repeat cadence in the app: the pad's - * HOLD_DELAY_MS is 350, and the keyboard's first repeat comes after an OS-configured 250-500 ms. A - * threshold below those would split one hold into two sounds 350 ms apart, which is worse than one; a - * deliberate re-tap is heard because of the release, not because of this number. + * HOLD_DELAY_MS is 175, and the keyboard's first repeat comes after an OS-configured 250-500 ms. A + * threshold below those would split one hold into two sounds a few hundred ms apart, which is worse than + * one; a deliberate re-tap is heard because of the release, not because of this number. */ export const LIMIT_IDLE_MS = 700; diff --git a/src/shared/i18n/en.ts b/src/shared/i18n/en.ts index b9d65ad..9f6d2ac 100644 --- a/src/shared/i18n/en.ts +++ b/src/shared/i18n/en.ts @@ -185,7 +185,6 @@ export const en = { 'settings.languageSystem': 'Match system', 'settings.sectionGeneral': 'General', 'settings.summonHotkey': 'Show the launcher with a gamepad shortcut', - // The launcher screen states the chord in one line (the settings window splits it around a ). 'settings.summonHint': 'Hold Menu + View on your gamepad to bring the launcher to the front.', 'settings.preventScreensaver': 'Keep the screen awake while the launcher is open', 'settings.keepOpenWithoutCard': 'Keep the launcher open without a card', diff --git a/src/shared/types.ts b/src/shared/types.ts index b0a20c4..706ad1d 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,6 +1,6 @@ -// Shared contract between main, preload and renderer. -// Types only — the file compiles to empty JS and creates no runtime dependencies, -// so the renderer can import from here via `import type` without require. +// Shared contract between main, preload and renderer: the types, plus the IPC channel table and a few +// constants (`IPC`, `APP_NAME`, `MANIFEST_FILENAME`), which ARE runtime values — preload imports them. +// Everything else is erased; the renderer imports it via `import type`. import type { Locale } from './i18n/index'; import type { ArtworkQuality } from './artwork-filter'; @@ -557,7 +557,7 @@ export type AppState = | { readonly kind: 'error'; readonly game?: GameInfo; readonly message: string }; /** - * Update state for the settings window (discriminated union). The UpdaterService owns the current + * Update state for the Settings screen (discriminated union). The UpdaterService owns the current * snapshot, returns it on request and pushes it on every change. Maps 1:1 onto electron-updater * events (see updater.ts). `unsupported` is set immediately when this build cannot self-update at all — * in dev / non-packaged, and on macOS (unsigned bundle, see UpdateUnsupportedReason) — and the settings @@ -592,7 +592,7 @@ export type UpdateUnsupportedReason = 'not-packaged' | 'platform'; */ export type AutoUpdateMode = 'download' | 'download-install' | 'off'; -/** UI theme for the settings window. `system` follows the OS light/dark preference. */ +/** UI theme, kept only so an older settings.json still parses: no screen offers a selector any more. */ export type ThemeMode = 'system' | 'light' | 'dark'; /** @@ -679,7 +679,7 @@ export interface AppSettings { readonly steamGridDbApiKey: string; } -/** The bundled UI sound sets + ambience tracks available to pick in the settings window. */ +/** The bundled UI sound sets + ambience tracks available to pick on the Settings screen. */ export interface AudioOptions { /** Sound-set folder names under `audio/ui/` (e.g. `playhook-abyss`, `ps5`); the default is always present. */ readonly soundSets: readonly string[]; @@ -871,7 +871,7 @@ export const IPC = { startupSoundRequest: 'audio:startup-request', /** game-renderer → main (invoke): request the current audio volumes (on window startup). */ volumeRequest: 'volume:request', - /** main → game-renderer: updated audio volumes (pushed when changed in the settings window). */ + /** main → game-renderer: updated audio volumes (pushed when changed on the Settings screen). */ volumeUpdate: 'volume:update', /** game-renderer → main (invoke): request the current effective UI locale (on window startup). */ languageRequest: 'app:language-request', diff --git a/test/audio-index.test.ts b/test/audio-index.test.ts index 970e609..7dff4fc 100644 --- a/test/audio-index.test.ts +++ b/test/audio-index.test.ts @@ -1,5 +1,5 @@ // The build-time enumeration that produces dist/audio/index.json (the sound-set + ambience listing the -// settings window reads at runtime). Guards the filtering: .DS_Store and other non-directories are not +// Settings screen reads at runtime). Guards the filtering: .DS_Store and other non-directories are not // sound sets, an incomplete set (no move.wav) is skipped, and only supported audio files are ambience. import type { Dirent } from 'node:fs'; import { describe, expect, it } from 'vitest'; From 10cad8bfa1a5cfd40834054e48854810d652d8ce Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 11 Sep 2026 11:59:09 +0500 Subject: [PATCH 10/85] Replace references to external plan/audit documents with self-contained notes --- .github/workflows/build-linux.yml | 2 +- .github/workflows/build-macos.yml | 4 +-- .gitignore | 3 +- electron-builder.yml | 16 ++++----- eslint.config.mjs | 4 +-- scripts/fetch-umu.mjs | 2 +- src/main/asset-file-names.ts | 3 +- src/main/asset-import.ts | 2 +- src/main/asset-reader.ts | 2 +- src/main/config-paths.ts | 2 +- src/main/drive-watcher.ts | 6 ++-- src/main/game-config.ts | 23 ++++++------ src/main/game-launcher.ts | 2 +- src/main/game-move.ts | 20 +++++------ src/main/gamescope.ts | 4 +-- src/main/history-config.ts | 2 +- src/main/history-sync.ts | 2 +- src/main/ipc.ts | 40 ++++++++++----------- src/main/launch-args.ts | 2 +- src/main/library-index.ts | 2 +- src/main/library-store.ts | 8 ++--- src/main/main.ts | 8 ++--- src/main/manifest.ts | 34 +++++++++--------- src/main/pc-store.ts | 2 +- src/main/platform/app-bundle.darwin.ts | 2 +- src/main/platform/darwin.ts | 16 ++++----- src/main/platform/game-launcher.darwin.ts | 8 ++--- src/main/platform/game-launcher.linux.ts | 34 +++++++++--------- src/main/platform/linux.ts | 20 +++++------ src/main/platform/mount.linux.ts | 2 +- src/main/platform/proc.ts | 8 ++--- src/main/platform/process-monitor.darwin.ts | 8 ++--- src/main/platform/save-path.darwin.ts | 4 +-- src/main/platform/save-path.linux.ts | 10 +++--- src/main/platform/steam-locator.darwin.ts | 2 +- src/main/platform/steam-locator.linux.ts | 2 +- src/main/platform/steam-shortcuts.linux.ts | 2 +- src/main/platform/steam-vdf.ts | 6 ++-- src/main/platform/types.ts | 24 ++++++------- src/main/platform/umu.ts | 16 ++++----- src/main/platform/win32.ts | 4 +-- src/main/power.ts | 4 +-- src/main/save-sync.ts | 2 +- src/main/tray.ts | 2 +- src/main/updater.ts | 4 +-- src/main/window.ts | 2 +- src/renderer/app.ts | 8 ++--- src/renderer/configure-form-model.ts | 14 ++++---- src/renderer/controls.ts | 8 ++--- src/renderer/file-picker.ts | 2 +- src/renderer/game-settings-model.ts | 12 +++---- src/renderer/game-settings-screen.ts | 40 ++++++++++----------- src/renderer/index.html | 4 +-- src/renderer/settings-form-view.ts | 2 +- src/renderer/settings-screen.ts | 6 ++-- src/renderer/state-view.ts | 2 +- src/renderer/styles.css | 2 +- src/shared/asset-move-names.ts | 4 +-- src/shared/i18n/en.ts | 2 +- src/shared/types.ts | 36 +++++++++---------- test/config-paths.test.ts | 4 +-- test/configure-form-model.test.ts | 6 ++-- test/game-settings-model.test.ts | 2 +- test/ipc-channels.test.ts | 2 +- test/launch-args.test.ts | 2 +- test/manifest.test.ts | 10 +++--- test/mount-linux.test.ts | 2 +- test/pc-library.test.ts | 2 +- test/save-path-darwin.test.ts | 2 +- test/save-path-linux.test.ts | 4 +-- test/steam-locator-darwin.test.ts | 2 +- test/steam-shortcuts-vdf.test.ts | 4 +-- test/stubs/electron.ts | 2 +- test/umu.test.ts | 6 ++-- tsconfig.json | 2 +- tsconfig.renderer.json | 2 +- vitest.config.ts | 2 +- 77 files changed, 287 insertions(+), 288 deletions(-) diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 1dea79b..50a13da 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -53,7 +53,7 @@ jobs: - name: Compile TypeScript + assets run: npm run build - # Fetch the umu-launcher zipapp into resources/umu (extraResources) for the Proton launcher (Р1). + # Fetch the umu-launcher zipapp into resources/umu (extraResources) for the Proton launcher. - name: Fetch umu-launcher run: npm run build:umu diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index 024f37a..f179af8 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -1,10 +1,10 @@ name: Build macOS # Build the macOS (Apple Silicon) distributable in the cloud, mirroring build-linux.yml. The runner is -# macos-14 — an arm64 image — because the build is arm64-only (Д7): Intel Macs get no artifact, and a +# macos-14 — an arm64 image — because the build is arm64-only: Intel Macs get no artifact, and a # universal build would additionally need a universal native `drivelist`. # -# The dmg is NOT signed with a Developer ID and NOT notarized (Д6 — no Apple Developer account), so it +# The dmg is NOT signed with a Developer ID and NOT notarized (no Apple Developer account), so it # does NOT self-update: the macOS build reports the updater as unsupported and the user re-downloads by # hand. See README (macOS section) for the Gatekeeper steps a first launch needs. # diff --git a/.gitignore b/.gitignore index a994f5b..01db4f8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ release/ # umu-launcher zipapp is fetched at build time (scripts/fetch-umu.mjs), never committed. resources/umu/ -# Build artifacts must never live next to their .ts sources in src/ (see audit C3). +# Build artifacts must never live next to their .ts sources in src/: a stale .js beside its .ts shadows +# the source for every tool that resolves extensionless imports. src/**/*.js src/**/*.js.map diff --git a/electron-builder.yml b/electron-builder.yml index 4031a3d..2b9a6c7 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -18,7 +18,7 @@ files: # drivelist is a native module. electron-builder usually detects native code and # unpacks it from asar on its own, but we pin an explicit unpack so the .node binary is -# available in the packaged build (verify on a real nsis/portable build — step 0/11 of the plan). +# available in the packaged build (verify on a real nsis/portable build). asar: true asarUnpack: - "**/node_modules/drivelist/**" @@ -39,7 +39,7 @@ nsis: # any distro, and electron-updater supports AppImage in-place updates (blockmap deltas). `icon.png` is the # 256×256 app icon (electron-builder may warn about size but it works). `category: Game` sets the .desktop # category. The native drivelist/koffi unpack above applies to Linux too (koffi is unused there but stays -# a dependency of the win32 build). The umu-launcher zipapp is bundled via extraResources (added in Э4). +# a dependency of the win32 build). The umu-launcher zipapp is bundled via extraResources. linux: icon: assets/icon.png category: Game @@ -51,30 +51,30 @@ linux: artifactName: ${productName}.${ext} target: - AppImage - # Bundle the umu-launcher zipapp OUTSIDE asar (it is run as `python3 umu-run …` — Р1). Fetched into + # Bundle the umu-launcher zipapp OUTSIDE asar (it is run as `python3 umu-run …`). Fetched into # resources/umu by `npm run build:umu` (scripts/fetch-umu.mjs) before the Linux pack. Lands at # /umu/umu-run in the AppImage. extraResources: - from: resources/umu to: umu -# macOS build (arm64 only — Д7: the runner and the developer machine are both Apple Silicon, and a +# macOS build (arm64 only: the runner and the developer machine are both Apple Silicon, and a # universal build would additionally require a universal `drivelist`). The dmg is what a user downloads; # the zip is there because electron-updater's macOS channel expects one, and it is the easier artifact to # script around. # -# NOT signed with a Developer ID and NOT notarized: there is no Apple Developer account (Д6). `identity: -` +# NOT signed with a Developer ID and NOT notarized: there is no Apple Developer account. `identity: -` # asks for an AD-HOC signature, which is not the same as skipping signing: on Apple Silicon an app whose # signature is broken (which repacking a signed Electron binary does) refuses to launch at all, so the # ad-hoc one is what makes the build runnable once the user clears Gatekeeper. Without a Developer ID the -# first launch still goes through System Settings → Privacy & Security → "Open Anyway" (Р3), and the app +# first launch still goes through System Settings → Privacy & Security → "Open Anyway", and the app # does not auto-update (Squirrel.Mac requires a real signature — see updater.ts). # # `extendInfo` is NOT optional: the default Electron Info.plist declares neither key, and macOS silently # denies the capability when its usage string is missing — Apple Events would fail with -1743 in the # PACKAGED build only (in dev the responsible process is the terminal, which already has consent, so -# leaving these out yields a false green). NSAppleEvents backs the power menu (Д4); NSRemovableVolumes -# backs reading a card under /Volumes (Р7). +# leaving these out yields a false green). NSAppleEvents backs the power menu; NSRemovableVolumes +# backs reading a card under /Volumes. # # koffi stays in the mac bundle on purpose: `gamepad-global.ts` does a top-level `import koffi` on every # OS, so dropping it would break the app at startup. The `asarUnpack` above already covers it — do not diff --git a/eslint.config.mjs b/eslint.config.mjs index 80c5870..b1b3cf9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,5 +1,5 @@ -// Flat ESLint config (audit I4). Type-aware linting over src/ with the high-value async-safety rules -// the audit calls out: no-floating-promises / no-misused-promises catch forgotten awaits, and +// Flat ESLint config. Type-aware linting over src/ with the high-value async-safety rules: +// no-floating-promises / no-misused-promises catch forgotten awaits, and // strict-boolean-expressions catches implicit nullable/number truthiness. eslint-config-prettier is // applied last so no lint rule fights the formatter. Tests are linted too (they are part of the // typechecked program), minus two rules that only ever fire on their fakes; build output is not. diff --git a/scripts/fetch-umu.mjs b/scripts/fetch-umu.mjs index cd9c687..a5608be 100644 --- a/scripts/fetch-umu.mjs +++ b/scripts/fetch-umu.mjs @@ -1,4 +1,4 @@ -// Fetches the umu-launcher zipapp into resources/umu/ for the Linux (Proton) build (Р1). The zipapp is a +// Fetches the umu-launcher zipapp into resources/umu/ for the Linux (Proton) build. The zipapp is a // ~420 KB python zipapp (umu-run) + umu_run.py; it is NOT committed to the repo — this runs before the // Linux electron-builder pack (see build-linux.yml / `npm run build:umu`). Uses curl + tar (present on // macOS and the ubuntu CI runner). Idempotent: skips the download if resources/umu/umu-run already exists. diff --git a/src/main/asset-file-names.ts b/src/main/asset-file-names.ts index d8d7a8d..039e94f 100644 --- a/src/main/asset-file-names.ts +++ b/src/main/asset-file-names.ts @@ -1,6 +1,5 @@ // The file name an asset picked from the user's filesystem gets INSIDE the app's own storage — the PC -// library's `assets/`, and the history staging directory that reuses the same rules (see the -// history-config plan, Р9). +// library's `assets/`, and the history staging directory that reuses the same rules. // // Two properties matter and they pull against each other. The name comes from a foreign filesystem, so // everything outside a conservative ASCII set must go (traversal, leading dots, exFAT-hostile characters diff --git a/src/main/asset-import.ts b/src/main/asset-import.ts index a04df8f..a022fbc 100644 --- a/src/main/asset-import.ts +++ b/src/main/asset-import.ts @@ -1,5 +1,5 @@ // The refusals every asset import passes through — the PC library's `assets/`, and the history's staging -// directory that reuses them (see the history-config plan, Р9). +// directory that reuses them. // // They were implicit while the only way in was a native dialog whose filters the OS enforced; the // in-launcher picker names the path from the renderer instead, so the limits are stated here, in the one diff --git a/src/main/asset-reader.ts b/src/main/asset-reader.ts index 51c5b73..b47f67b 100644 --- a/src/main/asset-reader.ts +++ b/src/main/asset-reader.ts @@ -44,7 +44,7 @@ export const AUDIO_EXTENSIONS: readonly string[] = Object.keys(AUDIO_MIME).map(( * An extension this reader does not know is REFUSED rather than served as `application/octet-stream`. * That fallback used to be harmless (only manifest-referenced files reached it), but the in-launcher * picker lets the renderer name the path — and "read any file on the machine as base64" is exactly what - * the octet-stream branch would have granted (see the plan, Р5.1). + * the octet-stream branch would have granted. */ export async function readImageDataUrl(filePath: string): Promise { const mime = IMAGE_MIME[path.extname(filePath).toLowerCase()]; diff --git a/src/main/config-paths.ts b/src/main/config-paths.ts index 0c1919b..5959afa 100644 --- a/src/main/config-paths.ts +++ b/src/main/config-paths.ts @@ -1,7 +1,7 @@ // The path decisions behind picking a file for a manifest field, as pure functions: what a field ACCEPTS, // what a picked path becomes in the manifest, and where its picker opens. Electron-free and fs-free (the // caller does the stat and passes what it found), so the rules that used to be enforced by an OS dialog -// are unit-testable now that a renderer-driven picker enforces them instead — see the plan, Р5.1/Р5.2. +// are unit-testable now that a renderer-driven picker enforces them instead. // // These are HOST paths (a card root is `E:\` on Windows and `/run/media/deck/…` on the Deck), so they are // built with the native `path`, not `path.posix`: the posix rule in CLAUDE.md is about paths that describe diff --git a/src/main/drive-watcher.ts b/src/main/drive-watcher.ts index 24579ab..7934246 100644 --- a/src/main/drive-watcher.ts +++ b/src/main/drive-watcher.ts @@ -86,7 +86,7 @@ export async function listDriveCandidates( * Every mounted volume on the machine, as plain paths — the STARTING points the in-launcher file picker * offers in its left column. Deliberately unfiltered, unlike listDriveCandidates: a game is installed * wherever the user installed it (the usual `C:\Program Files (x86)\Steam\steamapps\common\…` is a system - * disk by any definition), and where to browse is the user's call, not ours. See the plan, Р5.2. + * disk by any definition), and where to browse is the user's call, not ours. */ export async function listAllMountpoints(): Promise { const drives = await list(); @@ -193,8 +193,8 @@ export class DriveWatcher { private errorHandler: ((error: Error) => void) | null = null; /** - * @param automount Optional sweep that mounts an inserted-but-unmounted removable card before scanning - * (Р10). Wired ONLY in a SteamOS Game Mode session, as a SAFETY NET: the session normally mounts the + * @param automount Optional sweep that mounts an inserted-but-unmounted removable card before scanning. + * Wired ONLY in a SteamOS Game Mode session, as a SAFETY NET: the session normally mounts the * card itself, but a card that arrives without a mountpoint has no path for scan() to look under and * would stay invisible. null everywhere else (Windows and the KDE desktop session mount on their * own). Must never throw. diff --git a/src/main/game-config.ts b/src/main/game-config.ts index 2dce211..3b06979 100644 --- a/src/main/game-config.ts +++ b/src/main/game-config.ts @@ -21,7 +21,7 @@ // A third stance arrived with the in-launcher file browser, which replaced the native dialog. That dialog // used to be the CONSENT GATE: an absolute path could only reach this file because the OS handed it over. // Now the renderer names it, so acceptPickedPaths re-checks what the dialog used to guarantee — the path -// exists, is not a symlink, and its type matches the field (see the plan, Р5.1). +// exists, is not a symlink, and its type matches the field. import path from 'node:path'; import fs from 'node:fs/promises'; import os from 'node:os'; @@ -241,7 +241,7 @@ export interface GameConfigDeps { readonly getTranslator: () => Translator; /** * Reverse-maps an absolute PC folder (from the pcSavePath browse) to a `%PREFIX%/…` manifest - * string via the platform SavePathResolver (Р5), or null when it lives under none of the allowed bases. + * string via the platform SavePathResolver, or null when it lives under none of the allowed bases. * win32 uses the env-based table; linux returns null (the user types the Windows-dictionary string). */ readonly toManifestPcSavePath: (absolute: string) => string | null; @@ -249,7 +249,7 @@ export interface GameConfigDeps { * Where one game's manifest lives, BY ID (GameController.findGameSource) — the bridge from what the * carousel shows to the file the Customize screen edits. An index is deliberately not part of the * answer: the controller's list is a filtered, reordered union of two sources, so its position says - * nothing about the slot's position in the text (see the plan, Р2). + * nothing about the slot's position in the text. */ readonly findGameSource: ( id: string, @@ -269,11 +269,11 @@ export interface GameConfigDeps { 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). + * own re-check of the guard the "Move to card…" menu item already applies in the renderer. */ 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. */ + * a game leaves the library: the local backup ↔ save-folder pairing it described is gone. */ readonly pcStore: Pick; /** * The game HISTORY — where a game with no card in gets its stored manifest, its pending edits and its @@ -295,7 +295,7 @@ export interface GameConfigDeps { readonly refreshLibrary: () => void; /** Resolves a manifest's `pcSavePath` to the LIVE save folder on this machine (platform.savePathResolver) * — moveToCard copies from there, not from the PC-library backup, so a stale backup can never - * overwrite a fresher save (see the plan, Р2.5 step 3). */ + * overwrite a fresher save. */ readonly savePathResolver: Pick; } @@ -643,7 +643,7 @@ export class GameConfigService { /** * The media's identity — the same sorted-ids signature a DriveCandidate carries. It answers the one * question `isAllowedRoot` cannot: a card swapped into the same mountpoint keeps the root valid while - * the FILE underneath is someone else's (see the plan, Р6.2). Our own edits do not move it (the ids + * the FILE underneath is someone else's. Our own edits do not move it (the ids * stay), so a second save after the first still goes through. */ private async signatureOf(root: string): Promise { @@ -882,7 +882,7 @@ export class GameConfigService { return this.savePcLibrary(without, t); } - // ── Move to card (Р2.5): a local game leaves the PC library and lands on a card, in one transaction ── + // ── Move to card: 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 @@ -955,7 +955,7 @@ export class GameConfigService { 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). + // having an invalid game.json the instant it is inserted. const expectedFile = expectedGameFilePath(targetRaw); if (expectedFile !== null) { const resolvedFile = resolveInside(request.toRoot, expectedFile); @@ -1070,8 +1070,7 @@ export class GameConfigService { } // 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, + // its pre-move bytes — `fromText` is NEVER applied when this happens: applying it would make the game disappear from the PC library without landing on the card, // which is worse than the duplicate a failed rollback leaves behind. const fromWrite = await this.writePcLibraryText(fromText, t); if (!fromWrite.ok) { @@ -1265,7 +1264,7 @@ export class GameConfigService { * 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 + * stated here instead — the root must be a live candidate, the path must exist and * not be a symlink, and its TYPE must match the field: an `~/.ssh/id_rsa` offered as a hero image is * refused before anything reads or copies it. */ diff --git a/src/main/game-launcher.ts b/src/main/game-launcher.ts index 6723ecf..0846eed 100644 --- a/src/main/game-launcher.ts +++ b/src/main/game-launcher.ts @@ -355,7 +355,7 @@ function launchElevated(target: LaunchTarget, mode: LaunchMode): GameProcess { if (!disposed) { const ok = kernel.TerminateProcess(handle, KILL_EXIT_CODE); if (ok === 0) { - // Undocumented whether SEE_MASK_NOCLOSEPROCESS grants PROCESS_TERMINATE (see K-Д1) — leave a + // Undocumented whether SEE_MASK_NOCLOSEPROCESS grants PROCESS_TERMINATE — leave a // breadcrumb; the controller's control poll turns a still-alive game into errors.killFailed. log.warn(`[kill] TerminateProcess failed (GetLastError=${kernel.GetLastError()})`); } diff --git a/src/main/game-move.ts b/src/main/game-move.ts index c3a3974..e051a0d 100644 --- a/src/main/game-move.ts +++ b/src/main/game-move.ts @@ -1,6 +1,6 @@ -// The electron-free half of "Move to card…" (see the plan, Р2.4/Р2.5/Р2.8): moving a local (PC-library) -// game onto a card. Pure so it can be unit-tested — the transaction itself (GameConfigService.moveToCard) -// touches fs and cannot be imported in vitest, the same reason game-config-add.ts was carved out. +// The electron-free half of "Move to card…": 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, @@ -18,11 +18,11 @@ function isRecord(value: unknown): value is Record { * library's post-move text (the "from" side — never sent by the renderer, always derived here; the same * never-trust-the-renderer's-derived-text stance the rest of GameConfigService takes for a write), and the * target card's PRE-move text, recovered by removing the just-inserted slot back out of `toText` (used to - * best-effort restore the card if the library write fails after the card's already been written — see the - * plan, Р2.5's rollback note). Mirrors `gamesToText`'s shape rules (configure-form-model.ts) so the result - * round-trips through the SAME reader every other write does: a lone object for exactly one game left, an - * array for more than one, `'[]\n'` for none. Returns null only if `text` is not valid JSON, which never - * happens for text this function is actually called with (always already schema-validated beforehand). + * best-effort restore the card if the library write fails after the card's already been written). + * Mirrors `gamesToText`'s shape rules (configure-form-model.ts) so the result round-trips through the + * SAME reader every other write does: a lone object for exactly one game left, an array for more than + * one, `'[]\n'` for none. Returns null only if `text` is not valid JSON, which never happens for text + * this function is actually called with (always already schema-validated beforehand). */ export function removeGameFromManifestText(id: string, text: string): string | null { let parsed: unknown; @@ -53,8 +53,8 @@ export function findGameInText(id: string, text: string): Record { - // Linux (Р7f): uninstall removes the WHOLE per-game Wine prefix (see GameProcessLauncher.uninstallDir), + // Linux: uninstall removes the WHOLE per-game Wine prefix (see GameProcessLauncher.uninstallDir), // so running the game's own in-prefix uninstaller first is pointless (its registry/shortcut cleanup // lives in the prefix we're about to delete). Skip it — win32 still runs it (no prefix; it must clean // the shared system before the install dir is removed). @@ -382,8 +382,8 @@ export class GameController { // A force-close (onKillRequested) is underway. Local try/finally flag (mirrors reloadInFlight, NOT the // launch sequence's finally — a kill has its own short-lived lifecycle) so a double Yes / repeat is a no-op. private killInFlight = false; - // The installing/launching state to restore once winetricks provisioning ends (Р7g). Null when not - // provisioning. The "Configuring Proton" screen + its rotating funny suffix are the renderer's job (Р7j). + // The installing/launching state to restore once winetricks provisioning ends. Null when not + // provisioning. The "Configuring Proton" screen + its rotating funny suffix are the renderer's job. private protonConfigPriorState: AppState | null = null; // The inserted card's background music, sent on its own channel (not on every AppState) — it is the // card's only audio contribution. Null when there is no card, or when "only global ambience" mutes it. @@ -466,7 +466,7 @@ export class GameController { /** * Linux prefix provisioning (winetricks) started/finished — the launcher's onProvisioning callback - * (Р7g). On start: stash the current installing/launching state and show the rotating "Configuring + * On start: stash the current installing/launching state and show the rotating "Configuring * Proton" screen. On finish: stop the rotation and restore the stashed state (the launch/install * sequence then continues from where it was). No-op on win32 (the launcher never fires this). */ @@ -615,7 +615,7 @@ export class GameController { /** * 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" + * no tray to hide into — keeps the empty "insert a card" screen up instead. Used at every "no card" * exit point. On Windows/desktop this is a plain hide (unchanged behaviour). */ private hideToTrayOrKeepEmpty(): void { @@ -651,7 +651,7 @@ export class GameController { // 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). + // — that is what keeps the list channel light enough to re-push on every change. ipcMain.handle(IPC.libraryGridRequest, (_event, id: unknown): Promise => { if (typeof id !== 'string') return Promise.resolve(null); return this.deps.library.readGridThumb(id); @@ -762,7 +762,7 @@ export class GameController { // uninstall completion (Play→Install) — incl. an uninstall the user triggers in Steam directly — and // download progress. A LOCAL steam game's source is always available, so this poll is no longer bounded // by how long a card stays in: the launcher sitting on such a game polls it for as long as it is shown. - // The .acf read is cheap enough for that to be an acceptable price (see the plan, §9.3). + // The .acf read is cheap enough for that to be an acceptable price. if (info.installVia === 'steam' && this.sourceAvailableFor(info.id)) { this.steamWatch.start(); } else { @@ -849,7 +849,7 @@ export class GameController { this.deps.state.set({ kind: 'error', message: result.message }); // Desktop/Windows: keep the window hidden (background app — the error is in the log and only shows // if the user summons the window). Game Mode: there is no tray to hide into, so surface the manifest - // error on screen instead of hiding (Р8, point 1). + // error on screen instead of hiding. if (this.deps.isGamescope) this.deps.window.showAndFocus(); else this.deps.window.hide(); return { ok: false, message: result.message }; @@ -1083,7 +1083,7 @@ export class GameController { * 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. + * `id` instead. */ findGameSource(id: string): { readonly root: string; readonly source: ManifestSource } | null { const manifest = this.games.find((game) => game.raw.id === id); @@ -1100,7 +1100,7 @@ export class GameController { /** * 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 + * screen and — new here — Move to card (GameConfigService.moveToCard): a move started while the * game is mid-launch would race the launcher's own manifest handling. */ isBusy(): boolean { @@ -1124,7 +1124,7 @@ export class GameController { this.collisionResolver = resolver; } - /** The PC library's own manifest for `id`, even while a card of the same id shadows it (Р10). */ + /** The PC library's own manifest for `id`, even while a card of the same id shadows it. */ findPcManifest(id: string): ResolvedManifest | null { return this.pcGames.find((manifest) => manifest.raw.id === id) ?? null; } @@ -1250,7 +1250,7 @@ export class GameController { 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 + // Direct, NOT change-based (deliberate): the snapshot exists precisely because // the card was yanked mid-game and we are OBLIGED to top up the promised PC progress onto the card. // LWW here would silently drop that flush if the card looked "unchanged"/newer, so keep it a plain // snapshot→card replace. @@ -1265,7 +1265,7 @@ export class GameController { } /** - * Resolves the manifest's DEFERRED pcSavePath (Р5/Э6) to this game's save location via the platform + * Resolves the manifest's DEFERRED pcSavePath to this game's save location via the platform * SavePathResolver, or null when there's nothing to sync (no pcSavePath declared, or a steam game with * no compatdata yet). win32 keeps the exact env-based expansion the manifest used to do eagerly; linux * maps inside the game's prefix. `containerExists` tells whether that prefix is actually there — see @@ -1461,7 +1461,7 @@ export class GameController { * `killing` sub-state (the launcher shows "Force closing…" and hides the Force close button), kills the * main executable AND every watchProcess, then lets the EXISTING exit waiters (waitForExit / * waitForWatchedExit) notice the processes vanish and carry the flow through syncing-out → sync → ready - * (K-Д3) — no state machine of its own. Guarded by the running state + a killInFlight flag (double Yes / + * — no state machine of its own. Guarded by the running state + a killInFlight flag (double Yes / * repeat is a no-op). * * A non-elevated launcher can't terminate a runAsAdmin game's high-integrity processes (taskkill → @@ -1741,7 +1741,7 @@ export class GameController { // the first-run fallback (no baseline yet). state.set({ kind: 'syncing-in', game: info }); if (manifest.pcSavePath !== undefined && manifest.saveOnCardPath !== undefined) { - // Resolve the deferred pcSavePath to this game's save location (Р5/Э6). null → nothing to sync + // Resolve the deferred pcSavePath to this game's save location. null → nothing to sync // with at all (a steam game with no compatdata) — a logged no-op. const pcSave = await this.resolvePcSavePath(manifest); if (pcSave === null) { @@ -2121,7 +2121,7 @@ export class GameController { // Sweep the platform's uninstall target — after the uninstaller, and as the fallback when no target // was resolved (custom / nothing found). win32: the install dir. linux: the whole per-game Wine - // prefix (game files + provisioned runtimes), so the full disk footprint is reclaimed (Р7f). + // prefix (game files + provisioned runtimes), so the full disk footprint is reclaimed. const uninstallDir = this.launcher.uninstallDir(install); await removeWithRetry(uninstallDir, abort.signal); @@ -2248,7 +2248,7 @@ export class GameController { private async performSyncOut(manifest: ResolvedManifest, stats: Stats): Promise { const id = manifest.raw.id; - // Resolve the deferred pcSavePath once for this game (Р5/Э6). The game just ran, so its prefix exists + // Resolve the deferred pcSavePath once for this game. The game just ran, so its prefix exists // and (on win32) the env expansion always succeeds — this matches the pre-port physical path exactly. // A prefix that is somehow absent here means the game wrote nothing we could carry back: there is no // source to copy from, so treat it as "no PC side" rather than syncing an emptiness onto the card. @@ -2380,7 +2380,7 @@ export class GameController { launchCount: stats.launchCount, requiresInstall, canUninstall, - // Installer-view dir (Р7): on linux this is the `C:\playhook\games\` the user would paste into a + // Installer-view dir: on linux this is the `C:\playhook\games\` the user would paste into a // non-silent Wine picker; on win32 it equals the host dir. ...(manifest.install !== undefined ? { installDir: manifest.install.installerDir } : {}), ...(installVia !== undefined ? { installVia } : {}), @@ -2504,7 +2504,7 @@ export class GameController { /** * Rebuilds and pushes the carousel list: the inserted card's games first (they are the ones that can be * launched right now), then the played history — each group most recently played first. No stats are - * read from disk here: the index caches launchCount/lastPlayedAt for exactly this (Р1), and the card's + * read from disk here: the index caches launchCount/lastPlayedAt for exactly this, and the card's * own games use the reconciled stats already in memory (statsById), falling back to the index for a * game whose reconcile hasn't happened yet. * diff --git a/src/main/launch-args.ts b/src/main/launch-args.ts index 606be89..769ccac 100644 --- a/src/main/launch-args.ts +++ b/src/main/launch-args.ts @@ -43,7 +43,7 @@ export function buildParameters(args: readonly string[]): string { * - `nsis` → `/S` … `/D=` — `/D=` MUST be last and always UNQUOTED (NSIS reads everything after * it, to end of line, as the path — even with spaces), on both platforms. * - `inno` → `/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /DIR=` … — Inno's `/DIR=` quoting is the - * ONLY platform-varying piece (Р7): see `quoteDir`. + * ONLY platform-varying piece: see `quoteDir`. * - `custom`→ the card author's own args, with `{dir}` substituted; they own the quoting/flags. * Extra `customArgs` for nsis/inno are appended (after the silent flags, before the trailing `/D=` for nsis). * diff --git a/src/main/library-index.ts b/src/main/library-index.ts index 64b0f8b..4ad43af 100644 --- a/src/main/library-index.ts +++ b/src/main/library-index.ts @@ -58,7 +58,7 @@ export interface LibraryIndex { export const EMPTY_LIBRARY_INDEX: LibraryIndex = { schemaVersion: 1, entries: [] }; /** Result of an upsert: the new index plus whether an entry for this id already existed under a - * DIFFERENT TITLE — a card-id collision between two cards, which the store logs (Р3). */ + * DIFFERENT TITLE — a card-id collision between two cards, which the store logs. */ export interface UpsertResult { readonly index: LibraryIndex; readonly replacedForeign: boolean; diff --git a/src/main/library-store.ts b/src/main/library-store.ts index 5de93e4..13cca20 100644 --- a/src/main/library-store.ts +++ b/src/main/library-store.ts @@ -110,7 +110,7 @@ const indexSchema = z.object({ export interface LibraryStoreDeps { /** app.getPath('userData') — `library/` is created inside it. */ readonly baseDir: string; - /** Reads a game's authoritative stats (PcStore). The index only CACHES these numbers — see Р1. */ + /** Reads a game's authoritative stats (PcStore). The index only CACHES these numbers. */ readonly readStats: (id: string) => Promise; } @@ -158,7 +158,7 @@ export class LibraryStore { } } - /** The carousel order for the given card ids: the card's games first, then the played history (Р1). */ + /** The carousel order for the given card ids: the card's games first, then the played history. */ entriesForCarousel(activeIds: readonly string[]): readonly LibraryEntryRecord[] { return orderForCarousel(this.index.entries, activeIds); } @@ -204,7 +204,7 @@ export class LibraryStore { // (a false warning, and a full re-copy of every asset on every insert). const pristineTitle = (await this.readCardSlot(id))?.['title']; // The snapshot belongs to the CARD path alone: a PC-library game's slot speaks the `pc` dialect and - // would make a card unreadable if it were ever applied to one (see the plan, Р1). + // would make a card unreadable if it were ever applied to one. const cardSlotHash = manifest.source === 'card' && cardSlot !== undefined ? await this.takeCardSlot(id, cardSlot) @@ -311,7 +311,7 @@ export class LibraryStore { }); if (replacedForeign) { // Two cards sharing a manifest id now overwrite each other's COVER AND NAME, not just their stats - // numbers — a new, visible class of mistake, so it gets a breadcrumb (Р3). + // numbers — a new, visible class of mistake, so it gets a breadcrumb. log.warn( `[library] id="${id}" already existed with a different title/source — the history entry was overwritten (colliding manifest ids across cards)`, ); diff --git a/src/main/main.ts b/src/main/main.ts index dd7837c..444b756 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -84,7 +84,7 @@ function configureAutoLaunch(): void { } /** - * Linux autostart (Р11). Electron's app.setLoginItemSettings is macOS/Windows-only, so we write an XDG + * Linux autostart. Electron's app.setLoginItemSettings is macOS/Windows-only, so we write an XDG * autostart entry by hand. Only meaningful for the packaged AppImage (Exec points at $APPIMAGE) and only * in Desktop Mode — in Game Mode the app runs as a non-Steam game with no autostart mechanism, so we skip * it there. Best-effort: any failure is logged, never fatal. @@ -262,7 +262,7 @@ async function bootstrap(): Promise { getTranslator, }); - // Game Mode only (Р10), as a safety net: the gamescope session normally mounts an inserted card itself, + // Game Mode only, as a safety net: the gamescope session normally mounts an inserted card itself, // but one that arrives unmounted is invisible to the scan (it has no path to look under). Sweeping it // into /run/media keeps hot-swap working in that case. Windows and the KDE desktop session mount on // their own → no sweep wired there. @@ -427,7 +427,7 @@ async function bootstrap(): Promise { recomputeKeepAwake(); }, // Game Mode: no tray to hide into, and Steam closes a non-Steam game by closing its window → let the - // close through and quit on window-all-closed (Р8, point 5). Desktop/Windows keep the hide-to-tray guard. + // close through and quit on window-all-closed. Desktop/Windows keep the hide-to-tray guard. { hideToTrayOnClose: !gameModeSession }, ); // The update status is pushed to the launcher, which is where the Settings screen lives now. Attached @@ -597,7 +597,7 @@ if (!gotSingleInstanceLock) { // A background app doesn't quit when the window is closed/hidden — it lives in the tray. Exception: // SteamOS Game Mode has no tray and the window's close isn't guarded, so a real close means the user - // ended the (non-Steam) game → quit (Р8, point 5). + // ended the (non-Steam) game → quit. app.on('window-all-closed', () => { if (quitting || gameModeSession) app.quit(); }); diff --git a/src/main/manifest.ts b/src/main/manifest.ts index b38fa33..854452d 100644 --- a/src/main/manifest.ts +++ b/src/main/manifest.ts @@ -40,7 +40,7 @@ const installSchema = z // For `custom`: the full argv with exactly one {dir} token. For nsis/inno: optional extra flags. // Forbidden for `copy` (no process is started). args: z.array(z.string()).default([]), - // Linux-only (Р7b): extra winetricks verbs/settings provisioned into the game's Wine prefix BEFORE the + // Linux-only: extra winetricks verbs/settings provisioned into the game's Wine prefix BEFORE the // installer runs, on top of the app's baseline set. Lets a card cover runtimes its installer needs // (e.g. a skinned Inno installer needing mfc42/gdiplus) or a setting like `vd=1920x1080`. Ignored on // Windows. Strictly validated (`=` allowed for `key=value` settings; shell-less execFile — defense in depth). @@ -126,7 +126,7 @@ const manifestSchema = z // guard against accidental generic names. `.min(1)` rejects an empty array (defense in depth vs the // `?.length` branch in ipc). Names are compared case-insensitively (lower-cased) at match time. // - // The `.exe` suffix is OPTIONAL rather than required (Д5): a native macOS binary is not called + // The `.exe` suffix is OPTIONAL rather than required: a native macOS binary is not called // `*.exe`, and steam mode REQUIRES watchProcesses, so demanding the suffix would make steam mode // impossible on macOS. The convention that goes with it: a CROSS-PLATFORM card stores `*.exe` names // (that is what Windows and Proton both run, and the darwin matcher normalizes the suffix away, so @@ -178,7 +178,7 @@ const manifestSchema = z .optional() .catch(undefined), platforms: z.array(z.enum(['windows', 'mac', 'linux'])).optional().catch(undefined), - // Linux-only (Р7b): extra winetricks verbs/settings provisioned into the game's Wine prefix BEFORE the + // Linux-only: extra winetricks verbs/settings provisioned into the game's Wine prefix BEFORE the // game launches, on top of the app's baseline set — a runtime a game needs on a bare Proton prefix // (e.g. `d3dx9`) OR a winetricks SETTING like `vd=1920x1080` (virtual desktop — fixes old games that // crash on a fullscreen display-mode change). Ignored on Windows. Strictly validated (`=` allowed for @@ -186,7 +186,7 @@ const manifestSchema = z winetricks: z .array(z.string().regex(/^[A-Za-z0-9_.=-]+$/, 'manifest.winetricksName')) .default([]), - // Linux-only (Р7i): the umu `GAMEID` used when LAUNCHING the game — a Steam appid (e.g. `814380`) or a + // Linux-only: the umu `GAMEID` used when LAUNCHING the game — a Steam appid (e.g. `814380`) or a // custom UMU_ID (e.g. `umu-nfsu2`), so umu applies that game's protonfix instead of the generic // `umu-default`. Absent → `umu-default` (current behaviour). Ignored on Windows. umuGameId: z.string().regex(/^[A-Za-z0-9_-]+$/, 'manifest.umuGameIdName').optional(), @@ -319,7 +319,7 @@ const ENV_PREFIXES = ['APPDATA', 'LOCALAPPDATA', 'USERPROFILE'] as const; export function resolveInside(root: string, relative: string): string | null { // Card-relative manifest paths are authored on Windows and may use `\` (e.g. `"bin\\game.exe"`). On // Linux `\` is NOT a separator, so `path.resolve` would treat the whole thing as one filename with - // literal backslashes → "not found" and the card falls apart (Р12). Normalize `\`→`/` on BOTH platforms + // literal backslashes → "not found" and the card falls apart. Normalize `\`→`/` on BOTH platforms // (Windows `path.resolve` already accepts both, so its behaviour is unchanged) before resolving. const normalized = relative.replaceAll('\\', '/'); if (path.isAbsolute(normalized)) return null; @@ -336,7 +336,7 @@ export function resolveInside(root: string, relative: string): string | null { * `executable` must be relative to that directory. But a card author (or the Configure form on manual * entry, unlike its Browse picker which already trims) often gives it card-root-relative — INCLUDING the * source dir, e.g. source `game`, executable `game/game.exe`. Strip a leading `/` so both spellings - * resolve to the same copied file. Separators are normalized first (Р12: a Windows card uses `\`). No-op + * resolve to the same copied file. Separators are normalized first (a Windows card uses `\`). No-op * when the executable isn't under the source (already relative to it, or an unrelated path). Exported for * unit tests. */ @@ -349,7 +349,7 @@ export function stripCopySourcePrefix(executable: string, source: string): strin } /** - * One-level case-insensitive lookup for a diagnostic hint when a card-relative file isn't found (Р12). + * One-level case-insensitive lookup for a diagnostic hint when a card-relative file isn't found. * On a case-sensitive FS (ext4/Linux) a Windows-authored `"Game.EXE"` won't match the on-disk `game.exe` * → the existence check fails. This scans the parent directory for an entry that differs from the wanted * name only in case and returns it, so the error can say "found game.exe, fix the case". Platform-agnostic @@ -467,7 +467,7 @@ type InstallResolveResult = | { readonly ok: false; readonly message: string }; /** - * The app-controlled install directory in BOTH views (Р7). On win32 they are identical + * The app-controlled install directory in BOTH views. On win32 they are identical * (`%LOCALAPPDATA%\playhook\games\`); on linux they diverge: * - `hostDir` — the real filesystem path inside the game's Wine prefix * (`/drive_c/playhook/games/`): every fs op and the resolved `executable` live under it; @@ -480,7 +480,7 @@ export interface InstallDir { } /** - * Platform install-dir resolution, injected into readManifests (Р7): maps a game `id` to both views of + * Platform install-dir resolution, injected into readManifests: maps a game `id` to both views of * its app-controlled install dir, or null when install mode is unsupported on this platform/config * (win32 with `%LOCALAPPDATA%` unset). `id` is already validated as a safe single path segment. */ @@ -489,7 +489,7 @@ export type InstallDirResolver = (id: string) => InstallDir | null; /** * Resolves the install-mode block: verifies the installer exists on the card, derives the * app-controlled install dir via the platform `resolveInstallDir` (win32 `%LOCALAPPDATA%\…`; linux the - * game's Wine prefix — Р7), and resolves `executable` RELATIVE to its HOST view (traversal forbidden, + * game's Wine prefix), and resolves `executable` RELATIVE to its HOST view (traversal forbidden, * existence NOT checked — its absence is exactly the "not installed" state). */ async function resolveInstall( @@ -513,7 +513,7 @@ async function resolveInstall( return { ok: false, message: t('manifest.installerNotFound', { path: install.installer }) }; } - // The install dir is platform-specific (Р7): win32 derives `%LOCALAPPDATA%\playhook\games\`; + // The install dir is platform-specific: win32 derives `%LOCALAPPDATA%\playhook\games\`; // linux places it inside the game's Wine prefix. null → install mode is unsupported here (e.g. // `%LOCALAPPDATA%` absent) and the card is rejected, exactly as the pre-port Windows-only check did. const dirs = resolveInstallDir(id); @@ -572,7 +572,7 @@ function isNotFound(cause: unknown): boolean { * single-game — behaves exactly as before) or a non-empty array of game objects (multi-game). Reads the * file once, normalizes to a list, and resolves each entry. * - * Failure policy (see the plan): a STRUCTURAL problem (unreadable file, top-level not an object/array, + * Failure policy: a STRUCTURAL problem (unreadable file, top-level not an object/array, * empty array) is fatal. A single game that fails to resolve (e.g. a normal-mode executable missing on * disk) is SKIPPED with a breadcrumb so one broken entry doesn't kill a multi-game card; if NONE resolve, * the card is fatal with the first skip reason (so a single-game card keeps its precise message — BC). @@ -718,7 +718,7 @@ async function resolveOne( steamResolved = { appid: raw.steam.appid }; } else if (source === 'pc') { // Draft: no pc, no steam, and (per the checks above) no executable/install either — the game is - // visible in the PC library but has no configured way to launch it yet (Р1 — unconfigured launch). + // visible in the PC library but has no configured way to launch it yet. executablePath = ''; cwd = ''; unconfigured = true; @@ -734,7 +734,7 @@ async function resolveOne( } if (!(await fse.pathExists(resolved))) { // On ext4 a wrong-case executable (Windows-authored `Game.EXE` vs on-disk `game.exe`) reads as - // missing — add a case-fix hint when exactly that is the case (Р12). + // missing — add a case-fix hint when exactly that is the case. const found = await findCaseInsensitiveName(resolved); if (found !== null) { return { @@ -810,7 +810,7 @@ async function resolveOne( let pcSavePath: string | undefined; if (raw.pcSavePath !== undefined) { - // Deferred resolution (Р5/Э6): validate only the SYNTAX here (prefix allowlist + no traversal). The + // Deferred resolution: validate only the SYNTAX here (prefix allowlist + no traversal). The // physical folder is resolved per-game at sync time via the platform SavePathResolver — on Linux a // location inside the game's Wine prefix / Steam compatdata that may not exist until first launch, // which must NOT reject the card at read time. The stored value is the Windows-dictionary string. @@ -936,7 +936,7 @@ function pushIfEscapes( * Beyond the traversal / pcSave / pairing checks that mirror readManifest, this enforces the multi-game * POLICY that every game must carry ≥1 heroImage. That is intentionally editor-only (it gates Save) and * NOT in the runtime readManifests path, which stays lenient (a hero-less legacy card still loads via the - * wallpaper fallback) — see the plan, decision 3. + * wallpaper fallback). */ function pushGameSemanticIssues( issues: ManifestValidationIssue[], @@ -998,7 +998,7 @@ function pushGameSemanticIssues( // Multi-game policy: a hero image is required for every game (editor-only gate — see above). issues.push({ path: field('heroImage'), message: t('manifest.heroRequired') }); } - // gridImage stays OPTIONAL (Р12): only its traversal is checked. The "without it the carousel card is + // gridImage stays OPTIONAL: only its traversal is checked. The "without it the carousel card is // cropped from hero" advice is a static hint under the field in Configure — an issue here would gate // Save and turn every existing card invalid, which is exactly what the optionality is for. if (raw.gridImage !== undefined) { diff --git a/src/main/pc-store.ts b/src/main/pc-store.ts index 6e84bd7..e8f171e 100644 --- a/src/main/pc-store.ts +++ b/src/main/pc-store.ts @@ -204,7 +204,7 @@ export class PcStore { } /** - * Drops a game's sync baseline. Used when a local game moves to a card (Р2.5): its `pc` baseline + * Drops a game's sync baseline. Used when a local game moves to a card: its `pc` baseline * partnered the PC-library backup with the local save folder, and that pairing no longer exists once * the game leaves the library — keeping it would read as a stale baseline and could report a false * conflict if the game is ever moved back to the PC. Silent on an already-absent file (the normal case diff --git a/src/main/platform/app-bundle.darwin.ts b/src/main/platform/app-bundle.darwin.ts index 65ca32a..57e5f8f 100644 --- a/src/main/platform/app-bundle.darwin.ts +++ b/src/main/platform/app-bundle.darwin.ts @@ -1,4 +1,4 @@ -// Resolving the real executable inside a macOS `.app` bundle (Д2). A `.app` is a DIRECTORY, so it cannot +// Resolving the real executable inside a macOS `.app` bundle. A `.app` is a DIRECTORY, so it cannot // be spawned — the binary lives at `Contents/MacOS/`, named by the bundle's Info.plist. // Spawning that binary directly (rather than `open -a`) is what keeps the pid valid, so the existing // pid-based tracking works for a local mac game exactly as it does for a Windows .exe. diff --git a/src/main/platform/darwin.ts b/src/main/platform/darwin.ts index c2bf152..de1de70 100644 --- a/src/main/platform/darwin.ts +++ b/src/main/platform/darwin.ts @@ -3,14 +3,14 @@ // mode are out of scope and refuse with their own message rather than failing obscurely. // // Per-service notes: -// ProcessMonitor → `ps` (process-monitor.darwin.ts; Д1 — no readable env of foreign processes, so a Steam +// ProcessMonitor → `ps` (process-monitor.darwin.ts — no readable env of foreign processes, so a Steam // game is matched by the watched image names, as on win32) -// SteamLocator → `~/Library/Application Support/Steam` (steam-locator.darwin.ts; Д8) -// GameLauncher → direct spawn / `.app` bundle resolution (game-launcher.darwin.ts; Д2) -// SavePathResolver → the Windows dictionary mapped onto the mac profile (save-path.darwin.ts; Д3) -// PowerBackend → `pmset sleepnow` + System Events via osascript (Д4) +// SteamLocator → `~/Library/Application Support/Steam` (steam-locator.darwin.ts) +// GameLauncher → direct spawn / `.app` bundle resolution (game-launcher.darwin.ts) +// SavePathResolver → the Windows dictionary mapped onto the mac profile (save-path.darwin.ts) +// PowerBackend → `pmset sleepnow` + System Events via osascript // SteamShortcuts → unsupported (Game Mode is a Steam Deck thing) -// RemovableMounter → no-op: macOS automounts removable volumes into /Volumes (Д9) +// RemovableMounter → no-op: macOS automounts removable volumes into /Volumes // resolveInstallDir → null: install mode is unsupported, so no card can resolve one import os from 'node:os'; import { execFile } from 'node:child_process'; @@ -36,7 +36,7 @@ const execFileAsync = promisify(execFile); // Shutdown/restart have no such command (`shutdown` itself does require root), so they ask System Events // via osascript, the same route the Apple menu takes. // -// Д4: sending Apple Events from a PACKAGED app requires `NSAppleEventsUsageDescription` in Info.plist +// Sending Apple Events from a PACKAGED app requires `NSAppleEventsUsageDescription` in Info.plist // (electron-builder `mac.extendInfo`) AND the user's consent in the TCC prompt. In dev the responsible // process is the terminal, which already has that consent — so this path can only be finally verified on a // packaged build. A refusal comes back as osascript error -1743, reported here in words the user can act on. @@ -89,7 +89,7 @@ function createSteamShortcuts(): SteamShortcuts { } // ── RemovableMounter (no-op) ───────────────────────────────────────────────── -// Д9: macOS mounts removable media itself (an exFAT card appears under /Volumes, executable), so there is +// macOS mounts removable media itself (an exFAT card appears under /Volumes, executable), so there is // nothing to sweep — same situation as Windows. function createRemovableMounter(): RemovableMounter { diff --git a/src/main/platform/game-launcher.darwin.ts b/src/main/platform/game-launcher.darwin.ts index 95e1db5..617fa01 100644 --- a/src/main/platform/game-launcher.darwin.ts +++ b/src/main/platform/game-launcher.darwin.ts @@ -1,4 +1,4 @@ -// macOS GameProcessLauncher: run a NATIVE mac game — a bare mach-o binary or a `.app` bundle (Д2). +// macOS GameProcessLauncher: run a NATIVE mac game — a bare mach-o binary or a `.app` bundle. // // A `.app` is a directory, so it cannot be spawned; the real binary at `Contents/MacOS/` // is resolved first and spawned directly. `open -a` would be the obvious alternative and is deliberately @@ -8,7 +8,7 @@ // What this launcher REFUSES, each with its own message rather than a generic failure: // • a Windows `*.exe` — the card is cross-platform, macOS is not (Wine/CrossOver is out of scope); // • install mode — `resolveInstallDir` is null on darwin, so an install-mode card never resolves anyway; -// • a Gatekeeper-blocked binary (Р6) — a quarantined game downloaded from the internet is SIGKILLed by +// • a Gatekeeper-blocked binary — a quarantined game downloaded from the internet is SIGKILLed by // syspolicyd with no UI at all, so an instant death right after spawn is reported as what it is. import { spawn } from 'node:child_process'; import type { GameProcessLauncher, ProcessMonitor } from './types'; @@ -20,7 +20,7 @@ import { log } from '../logger'; /** * How long a freshly spawned game is watched for the instant, UI-less SIGKILL that Gatekeeper delivers to a - * quarantined binary (Р6). Only the RESOLVE of the launch is delayed by this, never the game itself; a real + * quarantined binary. Only the RESOLVE of the launch is delayed by this, never the game itself; a real * game is still alive when the window closes. */ const GATEKEEPER_PROBE_MS = 500; @@ -102,7 +102,7 @@ export function createDarwinGameLauncher(deps: DarwinGameLauncherDeps): GameProc async launchGame(manifest): Promise { const t = deps.getTranslator(); if (manifest.raw.runAsAdmin) { - // Symmetric with linux (Р6): there is no elevation to ask for here, and refusing would break a + // Symmetric with linux: there is no elevation to ask for here, and refusing would break a // legitimate two-platform card that sets runAsAdmin for its Windows side. log.warn(`[launch] runAsAdmin ignored on macOS (no elevation) id=${manifest.raw.id}`); } diff --git a/src/main/platform/game-launcher.linux.ts b/src/main/platform/game-launcher.linux.ts index 8aaed16..18d8df3 100644 --- a/src/main/platform/game-launcher.linux.ts +++ b/src/main/platform/game-launcher.linux.ts @@ -1,8 +1,8 @@ -// Linux GameProcessLauncher: run the card's Windows .exe through umu-launcher / Proton (Р1/Р2). The +// Linux GameProcessLauncher: run the card's Windows .exe through umu-launcher / Proton. The // bundled umu-run zipapp is invoked as `python3 ` in the game's own Wine prefix. // umu-run stays alive for the whole session, so the launched CHILD is the completion signal (its exit -// event) — no /proc snapshots needed for the plain exe path (Р3); watchProcesses games still track via the -// ProcessMonitor. Install/uninstall run the same way (Р7): the installer/uninstaller .exe is launched +// event) — no /proc snapshots needed for the plain exe path; watchProcesses games still track via the +// ProcessMonitor. Install/uninstall run the same way: the installer/uninstaller .exe is launched // through umu-run in the game's OWN Wine prefix, so what it writes to `C:\playhook\games\` lands in // that prefix's `drive_c` — exactly where the game later launches from. import path from 'node:path'; @@ -79,7 +79,7 @@ async function collectProtonLog(logDir: string): Promise { let python3Available = false; /** - * Verifies a system python3 is present (the umu zipapp needs it — Р1). SteamOS ships it; on desktop + * Verifies a system python3 is present (the umu zipapp needs it). SteamOS ships it; on desktop * distros it is almost always there. Throws a clear, user-facing error otherwise so the controller can * surface it instead of a cryptic spawn failure. */ @@ -163,7 +163,7 @@ function spawnUmuProcess( }); } -// ── Prefix dependency provisioning (Р7b) ───────────────────────────────────── +// ── Prefix dependency provisioning ─────────────────────────────────────────── // Before an installer runs, its Wine prefix gets a baseline set of runtimes (mfc42/gdiplus/vcrun/…) plus // any card-specific extras, via `umu-run winetricks`. A skinned Inno installer (isskin.dll) fails to load // on a bare prefix without these; games in the same prefix benefit too. Idempotent: the applied verbs are @@ -225,11 +225,11 @@ function runWinetricks( } /** - * Ensures the baseline + card-`extra` winetricks verbs are provisioned in `prefix` (Р7b). Idempotent via + * Ensures the baseline + card-`extra` winetricks verbs are provisioned in `prefix`. Idempotent via * the per-prefix sentinel; a no-op when everything is already applied. Best-effort: a winetricks failure * is logged and provisioning does NOT record success (so the next attempt retries), but the install is * allowed to proceed regardless (chosen behaviour — the installer surfaces its own error if deps are - * truly missing). Needs network the first time (like the GE-Proton download), same as game launch in Э4. + * truly missing). Needs network the first time (like the GE-Proton download), same as a game launch. */ async function ensurePrefixDeps( umuRunPath: string, @@ -241,7 +241,7 @@ async function ensurePrefixDeps( const done = await readDoneVerbs(prefix); const pending = pendingWinetricks(extra, done); if (pending.length === 0) return; // nothing to do → no "Configuring Proton" status - // Signal the provisioning window ONLY when winetricks actually runs (Р7g). `finally` guarantees the + // Signal the provisioning window ONLY when winetricks actually runs. `finally` guarantees the // status is torn down even if the run throws, so the launch screen never gets stuck on it. onProvisioning?.(true); try { @@ -266,7 +266,7 @@ async function ensurePrefixDeps( /** * Brings the Wine prefix that backs an install dir into a ready state: created, Proton-initialized and - * provisioned with the baseline runtimes plus the card's `install.winetricks` (Р7b). Shared by + * provisioned with the baseline runtimes plus the card's `install.winetricks`. Shared by * launchInstaller (which needs it before the installer .exe runs) and prepareInstallDir (which needs it * before `copy` writes the game's files in) — the two must init the prefix the SAME way, so a copied game * lands in the same environment an installed one would. @@ -283,7 +283,7 @@ async function preparePrefixForInstall( await fse.ensureDir(prefix); const logDir = protonLogDir(deps.userData); await ensureLogDir(logDir); - // Р7b: provision the runtimes the installer/game need (baseline + card extras). Also initializes the + // Provision the runtimes the installer/game need (baseline + card extras). Also initializes the // prefix (the first `umu-run` here does the Proton upgrade). await ensurePrefixDeps(deps.umuRunPath, prefix, install.winetricks, logDir, onProvisioning); return { prefix, logDir }; @@ -294,7 +294,7 @@ export function createLinuxGameLauncher(deps: LinuxGameLauncherDeps): GameProces return { async launchGame(manifest, onProvisioning): Promise { if (manifest.raw.runAsAdmin) { - // Р6: there is no elevation under Proton — everything runs as the user. Ignore rather than reject, + // There is no elevation under Proton — everything runs as the user. Ignore rather than reject, // so a legitimate two-platform card (runAsAdmin for Windows) still launches on Linux. log.warn(`[launch] runAsAdmin ignored on Linux (no elevation under Proton) id=${manifest.raw.id}`); } @@ -303,12 +303,12 @@ export function createLinuxGameLauncher(deps: LinuxGameLauncherDeps): GameProces await fse.ensureDir(prefix); const logDir = protonLogDir(deps.userData); await ensureLogDir(logDir); - // Р7b: provision the game's own winetricks verbs (baseline + card `winetricks`) before launch — only + // Provision the game's own winetricks verbs (baseline + card `winetricks`) before launch — only // when the card lists any, so an ordinary game with no verbs launches unchanged (no extra step). if (manifest.raw.winetricks.length > 0) { await ensurePrefixDeps(deps.umuRunPath, prefix, manifest.raw.winetricks, logDir, onProvisioning); } - // Р7i: a card-specified umu GAMEID (Steam appid / UMU_ID) → umu applies that game's protonfix; absent + // A card-specified umu GAMEID (Steam appid / UMU_ID) → umu applies that game's protonfix; absent // → `umu-default`. Only for the game launch (the installer/winetricks steps stay generic). const gameId = manifest.raw.umuGameId ?? UMU_GAMEID; const env = buildUmuEnv(process.env, { prefix, proton: DEFAULT_PROTON, protonLogDir: logDir, gameId }); @@ -318,10 +318,10 @@ export function createLinuxGameLauncher(deps: LinuxGameLauncherDeps): GameProces ); return spawnUmuProcess(args, manifest.cwd, env, deps.monitor); }, - // Install mode (Р7): run the card's installer .exe through umu-run in the game's Wine prefix, feeding + // Install mode: run the card's installer .exe through umu-run in the game's Wine prefix, feeding // it the app-controlled dir via the family's silent dir-key (unquoted on linux — see buildInstallerArgs). // cwd is the installer's own folder on the card (host path); the install dir may not exist yet (the - // installer creates it, and the controller pre-cleaned it). runAsAdmin is ignored (no elevation — Р6). + // installer creates it, and the controller pre-cleaned it). runAsAdmin is ignored (no elevation). async launchInstaller(install, silent, onProvisioning): Promise { if (install.runAsAdmin) { log.warn('[install] runAsAdmin ignored on Linux (no elevation under Proton)'); @@ -346,7 +346,7 @@ export function createLinuxGameLauncher(deps: LinuxGameLauncherDeps): GameProces const { prefix } = await preparePrefixForInstall(deps, install, onProvisioning); log.info(`[install] prefix ready for copy prefix="${prefix}" dir="${install.dir}"`); }, - // Uninstall (Р7): the target (uninstaller .exe found in the install dir + silent flags) is resolved by + // Uninstall: the target (uninstaller .exe found in the install dir + silent flags) is resolved by // the controller; there is no registry fallback on linux. Run it through umu-run in the same prefix. async launchUninstaller(target): Promise { if (target.runAsAdmin) { @@ -363,7 +363,7 @@ export function createLinuxGameLauncher(deps: LinuxGameLauncherDeps): GameProces log.info(`[uninstall] umu-run uninstaller prefix="${prefix}" file="${target.file}"`); return spawnUmuProcess(args, target.cwd, env, deps.monitor); }, - // Р7f: uninstall removes the WHOLE per-game prefix (it contains the install dir + the game's runtimes), + // Uninstall removes the WHOLE per-game prefix (it contains the install dir + the game's runtimes), // reclaiming the full disk footprint — not just the game files under drive_c/playhook/games/. uninstallDir: (install) => prefixForInstall(install.dir), // A normal executable game creates its prefix on first launch; offer to clear it once it exists. Same diff --git a/src/main/platform/linux.ts b/src/main/platform/linux.ts index 9e7867a..d483263 100644 --- a/src/main/platform/linux.ts +++ b/src/main/platform/linux.ts @@ -1,9 +1,9 @@ -// Linux (SteamOS / Proton) implementations of the platform services. Built up stage by stage per the -// SteamOS port plan; in the foundation stage (Э0) these replicate the pre-port graceful degradation on -// non-Windows (null Steam path, no visible processes, no power actions) so createPlatform is complete and -// behaviour on the untouched paths is unchanged. Each service is filled in by its own stage: -// ProcessMonitor → Э2 (/proc scan) SteamLocator → Э3 (known paths) GameProcessLauncher → Э4 (umu-run) -// SavePathResolver → Э5/Э6 (Wine prefix / compatdata) PowerBackend → Э7 (systemctl / logind) +// Linux (SteamOS / Proton) implementations of the platform services. Built up service by service: the +// foundation replicated the pre-port graceful degradation on non-Windows (null Steam path, no visible +// processes, no power actions) so createPlatform was complete and behaviour on the untouched paths +// unchanged, and each service was then filled in on its own: +// ProcessMonitor → /proc scan SteamLocator → known paths GameProcessLauncher → umu-run +// SavePathResolver → Wine prefix / compatdata PowerBackend → systemctl / logind import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import type { @@ -21,9 +21,9 @@ import { installDirs } from './umu'; const execFileAsync = promisify(execFile); -// ── PowerBackend — Э7 (systemctl poweroff/reboot/suspend via logind). ── +// ── PowerBackend — systemctl poweroff/reboot/suspend via logind. ── // SteamOS runs logind, which lets the active session's user issue poweroff/reboot/suspend without root -// (the standard polkit policy for a logged-in seat — Р9). No `--no-wall`/root needed; `systemctl` is on +// (the standard polkit policy for a logged-in seat). No `--no-wall`/root needed; `systemctl` is on // PATH. Rejects propagate to PowerService, which surfaces the error copy and does NOT quit. function createPowerBackend(): PowerBackend { return { @@ -55,10 +55,10 @@ export function createLinuxPlatform(deps: PlatformDeps): Platform { }), savePathResolver: createLinuxSavePathResolver({ userData: deps.userData, steamLocator }), powerBackend: createPowerBackend(), - // Game Mode automount (Р10): the caller only sweeps in a gamescope session — the KDE desktop session + // Game Mode automount: the caller only sweeps in a gamescope session — the KDE desktop session // mounts on its own, so wiring it there would be redundant work. removableMounter: createLinuxRemovableMounter(), - // Install mode is always supported on linux — the prefix is created on demand (Р7). Both views come + // Install mode is always supported on linux — the prefix is created on demand. Both views come // from umu.installDirs (host path inside the prefix + the `C:\playhook\games\` installer view). resolveInstallDir: (id) => installDirs(deps.userData, id), }; diff --git a/src/main/platform/mount.linux.ts b/src/main/platform/mount.linux.ts index c14deb4..de73215 100644 --- a/src/main/platform/mount.linux.ts +++ b/src/main/platform/mount.linux.ts @@ -1,4 +1,4 @@ -// Automounting removable cards in SteamOS Game Mode (Р10). A SAFETY NET, not the primary path: current +// Automounting removable cards in SteamOS Game Mode. A SAFETY NET, not the primary path: current // SteamOS mounts an inserted card itself (exFAT included), and this sweep only covers the case where one // arrives WITHOUT a mountpoint — an older/other gamescope session, a filesystem the session skips, or a // card the automounter simply didn't pick up. Such a card is enumerated as a block device with no diff --git a/src/main/platform/proc.ts b/src/main/platform/proc.ts index 77c6953..fddbb0d 100644 --- a/src/main/platform/proc.ts +++ b/src/main/platform/proc.ts @@ -1,4 +1,4 @@ -// Linux ProcessMonitor backed by /proc (Р3). Watched-game tracking on Linux can't use `comm` +// Linux ProcessMonitor backed by /proc. Watched-game tracking on Linux can't use `comm` // (/proc//comm is truncated to 15 chars, and names like `Game-Win64-Shipping.exe` are longer), so we // read /proc//cmdline and take the basename of argv[0]. A Proton/Wine process shows its exe path there // (often `Z:\...\Game.exe` or `C:\...`), so we split on BOTH separators and match the bare `*.exe` name @@ -6,7 +6,7 @@ // // The pure parsing/matching helpers below carry no electron/koffi/fs-at-import baggage, so they are unit- // tested directly (test/proc.test.ts). The /proc read (scanProc) and the ProcessMonitor it powers use -// node:fs + process signals and are exercised on the device (smoke test — Р3, допущение §5.9). +// node:fs + process signals and are exercised on the device (smoke test). import fs from 'node:fs/promises'; import type { ProcessMonitor, ProcessSnapshot } from './types'; @@ -80,7 +80,7 @@ export function buildProcIndex(entries: readonly ProcEntry[]): { } /** Builds a ProcessSnapshot from indexed processes. A watched name matches by exact (case-insensitive) - * basename — more precise than the win32 substring match, on purpose (Р3). Pure so it is unit-tested. */ + * basename — more precise than the win32 substring match, on purpose. Pure so it is unit-tested. */ export function snapshotFromEntries(entries: readonly ProcEntry[]): ProcessSnapshot { const { names, pids } = buildProcIndex(entries); return { @@ -173,7 +173,7 @@ export function createLinuxProcessMonitor(): ProcessMonitor { } }, killTree(pid): Promise { - // Best-effort group kill (Р3): a child spawned with detached:true is its own process-group leader, so + // Best-effort group kill: a child spawned with detached:true is its own process-group leader, so // `-pid` hits the group. wineserver does setsid and escapes the group, so the by-name sweep // (killByName) is the real mechanism; this is the cheap first step. Also signal the pid directly. for (const signal of KILL_SIGNALS) { diff --git a/src/main/platform/process-monitor.darwin.ts b/src/main/platform/process-monitor.darwin.ts index 866f4ee..a548284 100644 --- a/src/main/platform/process-monitor.darwin.ts +++ b/src/main/platform/process-monitor.darwin.ts @@ -1,11 +1,11 @@ -// macOS ProcessMonitor backed by `ps` (Д1). There is no /proc on darwin and no way to read another +// macOS ProcessMonitor backed by `ps`. There is no /proc on darwin and no way to read another // process's environment without privileges, so a Steam game is identified the way win32 identifies it — // by the watched image names — rather than by the `SteamAppId` tag the linux monitor keys on. // // The snapshot comes from `ps -axwwo pid=,comm=`: `-ww` disables the column truncation that would cut long // bundle paths, and `comm` is the executable's full path, so the basename is a reliable image name. // -// Name matching normalizes the `.exe` suffix away on BOTH sides (Д5): a card written for Windows/Deck +// Name matching normalizes the `.exe` suffix away on BOTH sides: a card written for Windows/Deck // stores `valheim.exe`, while the native mac process is `valheim`. A mac-only record may store the bare // name; both spellings therefore match the same process. // @@ -37,7 +37,7 @@ export interface DarwinProcParent { /** * A comparable image name: the basename (both separators, so a Windows-dictionary `dir\game.exe` also * reduces), lower-cased, with a trailing `.exe` dropped. Dropping the suffix is what lets a card written - * for Windows match the native mac binary of the same game (Д5/Р1). Pure. + * for Windows match the native mac binary of the same game. Pure. */ export function normalizeImageName(name: string): string { return pathBasename(name).toLowerCase().replace(/\.exe$/, ''); @@ -196,7 +196,7 @@ export function createDarwinProcessMonitor(): ProcessMonitor { // and a pid whose process honoured SIGTERM may by now belong to something else entirely. signalPids(stillAlive(pids), 'SIGKILL'); }, - // Д1: a mac process carries no readable Steam tag, so the watched image names ARE the running signal — + // A mac process carries no readable Steam tag, so the watched image names ARE the running signal — // the same rule win32 uses. The appid is unused here. async isSteamGameRunning(_appid, watchNames): Promise { if (watchNames.length === 0) return false; diff --git a/src/main/platform/save-path.darwin.ts b/src/main/platform/save-path.darwin.ts index 9173b03..d3894b4 100644 --- a/src/main/platform/save-path.darwin.ts +++ b/src/main/platform/save-path.darwin.ts @@ -1,4 +1,4 @@ -// macOS SavePathResolver (Д3): maps the card's Windows-dictionary `pcSavePath` onto a real folder in the +// macOS SavePathResolver: maps the card's Windows-dictionary `pcSavePath` onto a real folder in the // user's home. There is no Wine prefix here — a mac game writes into the mac profile — so the mapping is a // best-effort translation of the Windows known folders: // @@ -8,7 +8,7 @@ // // The three AppData prefixes collapsing onto one base is deliberate: Unity on macOS writes to // `~/Library/Application Support//`, which is exactly what it puts in LocalLow on -// Windows, and for everything else this is the closest approximation available. Р2: when a game keeps its +// Windows, and for everything else this is the closest approximation available. When a game keeps its // saves elsewhere, the sync simply reports a missing folder — nothing destructive. // // The REVERSE mapping is therefore ambiguous, and that ambiguity is contained rather than papered over: diff --git a/src/main/platform/save-path.linux.ts b/src/main/platform/save-path.linux.ts index dbbc843..6eede6f 100644 --- a/src/main/platform/save-path.linux.ts +++ b/src/main/platform/save-path.linux.ts @@ -1,4 +1,4 @@ -// Linux SavePathResolver (Р5/Э6): resolves the Windows-dictionary `pcSavePath` to a physical folder +// Linux SavePathResolver: resolves the Windows-dictionary `pcSavePath` to a physical folder // INSIDE the game's Wine prefix (exe/install modes) or its Steam compatdata prefix (steam mode). The // moment of resolution is sync-time, not manifest-read, so a prefix that doesn't exist yet is a no-op // sync (null), not a rejected card. The prefix→subpath mapping is a pure function (unit-tested without @@ -12,7 +12,7 @@ import { steamLibraryDirs } from '../steam'; import { log } from '../logger'; /** - * Where each Windows env-prefix lives inside a Wine prefix, relative to `drive_c` (Р5). Wine lays the + * Where each Windows env-prefix lives inside a Wine prefix, relative to `drive_c`. Wine lays the * steamuser home out exactly like a Windows profile, so the mapping is a fixed table. `%USERPROFILE%` is * the steamuser home itself (empty tail). Modern Proton uses the Vista+ `AppData\…` / `Documents` layout * (not the XP `My Documents` one), matching the prefixes we create and the compatdata Steam maintains. @@ -45,7 +45,7 @@ export function resolveInsideWinePrefix(pfx: string, pcSavePath: string): string } /** - * Reverse of resolveInsideWinePrefix, for the Configure window's pcSavePath Browse (Р5). Takes an + * Reverse of resolveInsideWinePrefix, for the Customize screen's pcSavePath Browse. Takes an * ABSOLUTE host folder the user picked and expresses it as a `%PREFIX%/…` manifest string, or null when it * lives in no Wine prefix at all (then it cannot be a Windows game's save location and the picker rejects * it). Pure. @@ -97,7 +97,7 @@ async function findCompatdataPrefix(appid: number, steamLocator: SteamLocator): /** * The Wine prefix that owns this game's saves, and whether it exists yet. * - * exe/install: the path is DETERMINISTIC (`/prefixes/` — Р2), so it is always returned, even + * exe/install: the path is DETERMINISTIC (`/prefixes/`), so it is always returned, even * before the prefix exists. That is what lets sync-in restore card saves into a prefix that a launch is * about to create (launchGame ensureDir's it anyway); `exists: false` tells the caller the PC side has no * authority, so a stale baseline must not turn the empty prefix into a phantom deletion on the card. @@ -130,7 +130,7 @@ export function createLinuxSavePathResolver(deps: LinuxSavePathDeps): SavePathRe const prefix = await prefixForManifest(manifest, deps); if (prefix === null) { // Steam mode with no compatdata: the game has never run under Proton (or isn't installed), so - // there is no location to sync with — a logged no-op, not an error (Р5). + // there is no location to sync with — a logged no-op, not an error. log.info(`[save-sync] no Steam compatdata for "${manifest.raw.id}" yet — pcSavePath "${pcSavePath}" unresolved`); return null; } diff --git a/src/main/platform/steam-locator.darwin.ts b/src/main/platform/steam-locator.darwin.ts index 09c967a..8720776 100644 --- a/src/main/platform/steam-locator.darwin.ts +++ b/src/main/platform/steam-locator.darwin.ts @@ -1,4 +1,4 @@ -// macOS SteamLocator (Д8): Steam keeps its data under `~/Library/Application Support/Steam`, and the +// macOS SteamLocator: Steam keeps its data under `~/Library/Application Support/Steam`, and the // validity check is the same one the linux locator uses — the presence of `steamapps/libraryfolders.vdf`, // Steam's own library index (the file the `.acf` walk in steam.ts reads). There is only one candidate on // macOS: no flatpak, no snap, and the App Store carries no Steam. diff --git a/src/main/platform/steam-locator.linux.ts b/src/main/platform/steam-locator.linux.ts index e8279b6..a01468e 100644 --- a/src/main/platform/steam-locator.linux.ts +++ b/src/main/platform/steam-locator.linux.ts @@ -1,4 +1,4 @@ -// Linux SteamLocator (Р4): probe the well-known Steam data directories and accept the first that carries a +// Linux SteamLocator: probe the well-known Steam data directories and accept the first that carries a // `steamapps/libraryfolders.vdf` (Steam's own library index — the same file the .acf walk reads). Covers // the native install, the `~/.steam/steam` symlink, the flatpak install and the snap install. The candidate // list + validity path are pure (unit-tested); locateSteam does the fs probe. diff --git a/src/main/platform/steam-shortcuts.linux.ts b/src/main/platform/steam-shortcuts.linux.ts index cdc3992..41863ff 100644 --- a/src/main/platform/steam-shortcuts.linux.ts +++ b/src/main/platform/steam-shortcuts.linux.ts @@ -2,7 +2,7 @@ // Game Mode tile. This is the one place in the app that writes a file Steam owns, so the whole module is // built around not damaging it. // -// Safety rules (from the plan's §3.5, several of them established by experiment on a real Deck): +// Safety rules (several of them established by experiment on a real Deck): // - Steam does NOT need to be closed: it does not rewrite shortcuts.vdf on exit (verified — a Steam that // was killed by a mode switch left an externally-modified file untouched). // - Read-modify-write, never a blind overwrite: foreign shortcuts are carried through verbatim. diff --git a/src/main/platform/steam-vdf.ts b/src/main/platform/steam-vdf.ts index 51ae3a8..d5d339d 100644 --- a/src/main/platform/steam-vdf.ts +++ b/src/main/platform/steam-vdf.ts @@ -1,9 +1,9 @@ // Binary VDF (Valve's key-value binary format) reader/writer, scoped to what `shortcuts.vdf` uses. // Pure and electron-free (unit-tested in test/steam-shortcuts-vdf.test.ts). // -// Why hand-rolled and not a dependency: the plan's candidate `steam-binary-vdf` is a single 0.1.0 release -// over a year old that drags in six transitive deps (hex2dec, polycrc, buffer-reader, node-int64) — it -// failed both liveness and dependency checks, so the plan's explicit fallback applies. The format is small +// Why hand-rolled and not a dependency: the obvious candidate, `steam-binary-vdf`, is a single 0.1.0 +// release over a year old that drags in six transitive deps (hex2dec, polycrc, buffer-reader, node-int64) +// — it failed both liveness and dependency checks. The format is small // and frozen; a round-trip test covers it far more cheaply than auditing that tree. // // Format: a stream of `` entries, terminated by 0x08. diff --git a/src/main/platform/types.ts b/src/main/platform/types.ts index 159e143..74b9b0d 100644 --- a/src/main/platform/types.ts +++ b/src/main/platform/types.ts @@ -1,8 +1,8 @@ // Platform abstraction layer (interface-DI). The launcher's OS-specific behaviour — process // monitoring, Steam discovery, game/installer spawning, save-path resolution and power actions — lives // behind these interfaces so a win32 and a linux (SteamOS/Proton) implementation can be swapped wholesale -// by createPlatform(process.platform). See the SteamOS port plan (decisions Р3/Р4/Р5/Р9) and CLAUDE.md -// ("Adding a new service": interface-DI is the testable shape). +// by createPlatform(process.platform). See CLAUDE.md ("Adding a new service": interface-DI is the +// testable shape). // // Types only — no koffi/electron here, so this file is import-safe from anywhere (incl. unit tests). The // concrete win32/linux implementations (which DO pull koffi on Windows) live in ./win32 and ./linux and @@ -33,7 +33,7 @@ export interface ProcessSnapshot { /** * Platform process control: the snapshot-based watched-process tracking plus targeted liveness and - * force-kill. win32 wraps `tasklist`/`taskkill`; linux walks `/proc` and sends signals (Р3). + * force-kill. win32 wraps `tasklist`/`taskkill`; linux walks `/proc` and sends signals. */ export interface ProcessMonitor { /** One atomic snapshot of all visible processes. */ @@ -63,7 +63,7 @@ export interface SteamLocator { /** * The Steam install root (the dir containing `steamapps/`), or null when Steam isn't found. On win32 * this reads the registry (Valve\Steam); on linux it probes the well-known data dirs — native, - * flatpak, snap (Р4). Best-effort: any error → null. + * flatpak, snap. Best-effort: any error → null. */ locateSteam(): Promise; } @@ -71,7 +71,7 @@ export interface SteamLocator { /** * Spawns the game / installer / uninstaller. win32 dispatches to a direct `spawn` or an elevated * ShellExecuteEx per manifest.runAsAdmin; linux runs everything through umu-run in the game's Wine - * prefix (Р1/Р7) with no elevation (runAsAdmin is a no-op there — Р6). + * prefix with no elevation (runAsAdmin is a no-op there). */ export interface GameProcessLauncher { /** @@ -109,7 +109,7 @@ export interface GameProcessLauncher { /** Launches a resolved uninstaller target silently. Throws on failure. */ launchUninstaller(target: LaunchTarget): Promise; /** - * The directory whose removal fully uninstalls the game (removed best-effort by the controller — Р7f). + * The directory whose removal fully uninstalls the game (removed best-effort by the controller). * win32: the app-controlled install dir. linux: the WHOLE per-game Wine prefix — it holds the install * dir AND the game's provisioned runtimes (dotnet/GE-Proton env), so uninstall reclaims all of it. */ @@ -124,7 +124,7 @@ export interface GameProcessLauncher { } /** - * Resolves the Windows-dictionary `pcSavePath` to a physical folder for THIS game (Р5). The moment of + * Resolves the Windows-dictionary `pcSavePath` to a physical folder for THIS game. The moment of * resolution moved from manifest-read to sync-time so an unresolvable location (a Wine/compatdata prefix * that doesn't exist yet) is a no-op sync, not a rejected card. win32 keeps the env-based mapping; linux * maps every prefix inside the game's Wine prefix (exe/install) or the Steam compatdata prefix (steam). @@ -161,8 +161,8 @@ export interface SavePathResolver { } /** - * Executes the OS power actions behind the launcher's Shutdown/Reboot/Sleep menu (Р9). win32 uses the - * `shutdown` command + the powrprof SetSuspendState FFI; linux uses logind via `systemctl` (Э7). The + * Executes the OS power actions behind the launcher's Shutdown/Reboot/Sleep menu. win32 uses the + * `shutdown` command + the powrprof SetSuspendState FFI; linux uses logind via `systemctl`. The * PowerService (power.ts) owns the user-facing flow (confirm, quit, error copy) — this is only the OS bit. */ export interface PowerBackend { @@ -175,7 +175,7 @@ export interface PowerBackend { } /** - * Mounts inserted-but-unmounted removable volumes so the drive watcher can see them (Р10). Only SteamOS + * Mounts inserted-but-unmounted removable volumes so the drive watcher can see them. Only SteamOS * **Game Mode** wires this, as a safety net: the session normally mounts the card itself, but one that * lands there unmounted is just a block device with no mountpoint, and `scan()` (which walks mountpoints) * can never find it. win32 and the KDE desktop session mount on their own → no-op there. The caller @@ -268,7 +268,7 @@ export interface Platform { readonly powerBackend: PowerBackend; readonly removableMounter: RemovableMounter; /** - * Resolves the app-controlled install directory for an install-mode game id (Р7), injected into + * Resolves the app-controlled install directory for an install-mode game id, injected into * readManifests. win32 derives `%LOCALAPPDATA%\playhook\games\`; linux the game's Wine prefix. */ readonly resolveInstallDir: InstallDirResolver; @@ -280,7 +280,7 @@ export interface PlatformDeps { readonly getDocuments: () => string; /** app.getPath('userData') — the base for per-game Wine prefixes on linux (`/prefixes/`). */ readonly userData: string; - /** Absolute path to the bundled umu-run zipapp (extraResources), run via system python3 on linux (Р1). + /** Absolute path to the bundled umu-run zipapp (extraResources), run via system python3 on linux. * Unused on win32. */ readonly umuRunPath: string; /** diff --git a/src/main/platform/umu.ts b/src/main/platform/umu.ts index 3260a2c..d01ee71 100644 --- a/src/main/platform/umu.ts +++ b/src/main/platform/umu.ts @@ -1,7 +1,7 @@ -// Pure helpers for launching Windows games through umu-launcher / Proton on Linux (Р1/Р2). No electron, +// Pure helpers for launching Windows games through umu-launcher / Proton on Linux. No electron, // no child_process — just the path/env/argv construction — so they are unit-tested directly. The bundled // umu-run is a python zipapp; we invoke it as `python3 ` (no reliance on the -// executable bit surviving packaging — Р11) with the WINEPREFIX/GAMEID/PROTONPATH env umu expects. +// executable bit surviving packaging) with the WINEPREFIX/GAMEID/PROTONPATH env umu expects. import path from 'node:path'; /** Default PROTONPATH: umu downloads and caches the latest GE-Proton into Steam's compatibilitytools.d. */ @@ -11,7 +11,7 @@ export const DEFAULT_PROTON = 'GE-Proton' as const; export const UMU_GAMEID = 'umu-default' as const; /** - * The per-game Wine prefix directory: `/prefixes/` (Р2). `id` is already validated as + * The per-game Wine prefix directory: `/prefixes/`. `id` is already validated as * `[A-Za-z0-9._-]` (no separators, not `.`/`..`), so it is a safe single path segment. */ export function prefixDir(userData: string, id: string): string { @@ -24,12 +24,12 @@ export function prefixDir(userData: string, id: string): string { const INSTALL_HOST_SUBPATH = ['drive_c', 'playhook', 'games'] as const; /** - * The app-controlled install directory for an install-mode game, in BOTH views (Р7): + * The app-controlled install directory for an install-mode game, in BOTH views: * - `hostDir` — the real path inside the game's Wine prefix (`/drive_c/playhook/games/`), * where the installed files physically land (all fs ops + the resolved executable); * - `installerDir` — the SAME place as the installer sees it under Wine (`C:\playhook\games\`), * fed to the silent dir-arg. The path has no spaces by construction (`id` ∈ `[A-Za-z0-9._-]`), so the - * Linux dir-arg can be passed unquoted (Р7). + * Linux dir-arg can be passed unquoted. */ export function installDirs( userData: string, @@ -41,7 +41,7 @@ export function installDirs( } /** - * Baseline winetricks verbs provisioned into every install-mode prefix before the installer runs (Р7b). + * Baseline winetricks verbs provisioned into every install-mode prefix before the installer runs. * These runtimes are what skinned Inno installers (isskin.dll) and many games need under a bare Proton * prefix; installing them proactively makes install mode work out of the box. Card-specific extras * (`install.winetricks`) are appended on top. @@ -95,7 +95,7 @@ export function buildUmuEnv( readonly prefix: string; readonly proton: string; readonly protonLogDir?: string; - /** umu GAMEID — a Steam appid or custom UMU_ID for the game's protonfix (Р7i). Defaults to `umu-default`. */ + /** umu GAMEID — a Steam appid or custom UMU_ID for the game's protonfix. Defaults to `umu-default`. */ readonly gameId?: string; }, ): NodeJS.ProcessEnv { @@ -114,7 +114,7 @@ export function buildUmuEnv( } // The Electron AppImage injects LD_LIBRARY_PATH / LD_PRELOAD pointing at its OWN bundled libraries. A // spawned system binary (python3 → umu → Proton) that inherits them loads mismatched libs and dies - // instantly (§5.1). Strip them so umu-run runs against the clean system libraries. umu/Proton set up + // instantly. Strip them so umu-run runs against the clean system libraries. umu/Proton set up // their own library environment from scratch, so nothing of ours needs to survive here. for (const key of ENV_STRIP_KEYS) delete env[key]; return env; diff --git a/src/main/platform/win32.ts b/src/main/platform/win32.ts index eec54bc..02bfae5 100644 --- a/src/main/platform/win32.ts +++ b/src/main/platform/win32.ts @@ -1,7 +1,7 @@ // Windows implementations of the platform services. These wrap the existing win32 code (tasklist/taskkill // process control, the registry Steam lookup, the spawn/ShellExecuteEx launchers, the env-based save-path // mapping, the `shutdown` command + powrprof suspend). Behaviour is 1:1 with the pre-port code — the port -// only routes it through the interfaces so a linux implementation can take its place (Р3/Р4/Р5/Р9). +// only routes it through the interfaces so a linux implementation can take its place. import path from 'node:path'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; @@ -141,7 +141,7 @@ function createGameLauncher(monitor: ProcessMonitor): GameProcessLauncher { } // ── SavePathResolver (env-based %PREFIX% mapping) ──────────────────────────── -// Reuses the existing pure expanders. The resolver contract returns null on failure (Р5: "nothing to +// Reuses the existing pure expanders. The resolver contract returns null on failure ("nothing to // sync"), so the translator only matters for messages we never surface here — a fixed 'en' one suffices. const noopTranslator = createTranslator('en'); diff --git a/src/main/power.ts b/src/main/power.ts index 6418d27..a155ef5 100644 --- a/src/main/power.ts +++ b/src/main/power.ts @@ -1,6 +1,6 @@ // Power actions for the launcher's Shutdown/Reboot/Sleep menu. Interface-DI (like StatsService / // UpdaterService): the OS-touching bits are delegated to the platform PowerBackend (win32 `shutdown` + -// powrprof FFI; linux `systemctl` via logind — Р9), and the app quit + error channel are injected, so +// powrprof FFI; linux `systemctl` via logind), and the app quit + error channel are injected, so // this module is pure/electron/koffi-free on import and unit-testable in vitest with a fake backend (see // test/power.test.ts). main.ts bootstraps it with the real backend and registers the IPC channels (NOT // GameController — power isn't part of the game flow). @@ -16,7 +16,7 @@ export type PowerAction = 'shutdown' | 'reboot' | 'sleep'; export interface PowerServiceDeps { /** OS backend for the actual power commands (platform.powerBackend). `supported=false` → we surface - * "unsupported" instead of running anything, replacing the old win32-only guard (Р9). */ + * "unsupported" instead of running anything, replacing the old win32-only guard. */ readonly backend: PowerBackend; /** Quits the app after a successful shutdown/reboot (real: the bootstrap quit(), which drops close-guards). */ quit(): void; diff --git a/src/main/save-sync.ts b/src/main/save-sync.ts index 85a61b1..372711d 100644 --- a/src/main/save-sync.ts +++ b/src/main/save-sync.ts @@ -68,7 +68,7 @@ export async function syncDir(src: string, dest: string): Promise { // compare is unreliable across FAT32/DST). A side "changed" iff a file was added/removed or a common // file's mtime grew beyond a tolerance. Deletions are caught for free: a file present in the snapshot but // missing now means that side changed → it becomes the source, and the folder-level replace carries the -// deletion across. See the plan (part B) for the full rationale. +// deletion across. /** A snapshot of a save folder: relative file path (POSIX-normalized) → mtimeMs. Empty for a missing folder. */ export type TreeSnapshot = Record; diff --git a/src/main/tray.ts b/src/main/tray.ts index 0ae0f1e..ea6ff8b 100644 --- a/src/main/tray.ts +++ b/src/main/tray.ts @@ -83,7 +83,7 @@ function menuBarImage(source: NativeImage): NativeImage { export function createTray(t: Translator, callbacks: TrayCallbacks, steam: TraySteamState): Tray { // The app icon doubles as the tray icon (the separate icon-tray.* files are gone), copied into dist by // copy-assets. Windows uses the .ico; Linux (Desktop Mode/KDE) and macOS need a PNG — a .ico yields an - // empty image via nativeImage there (Р8). Falls back to an empty image if the file is missing. + // empty image via nativeImage there. Falls back to an empty image if the file is missing. const iconFile = process.platform === 'win32' ? '../icon.ico' : '../icon.png'; const iconPath = path.join(__dirname, iconFile); const loaded = nativeImage.createFromPath(iconPath); diff --git a/src/main/updater.ts b/src/main/updater.ts index 8ec16f0..8470ebb 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -49,7 +49,7 @@ const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // re-check every 6h for long-runn /** * Whether this build can self-update at all: it must be PACKAGED, and it must not be the macOS one — * Squirrel.Mac only applies an update to a code-signed bundle, and this project ships an unsigned dmg - * (Д6). Everywhere that would otherwise touch `autoUpdater` asks this first, so the macOS build cannot + * (no Apple Developer account). Everywhere that would otherwise touch `autoUpdater` asks this first, so the macOS build cannot * start a check whose install step is guaranteed to fail. */ function updatesSupported(): boolean { @@ -117,7 +117,7 @@ export class UpdaterService { this.registerIpc(); // macOS FIRST, before the packaged check: Squirrel.Mac refuses to apply an update to an app bundle - // that is not code-signed, and this build is not (no Apple Developer account — Д6). Wiring autoUpdater + // that is not code-signed, and this build is not (no Apple Developer account). Wiring autoUpdater // anyway would mean a check that finds a version, downloads it and then fails at install — so the // Settings screen is told to explain manual updating instead. // diff --git a/src/main/window.ts b/src/main/window.ts index f22e65d..a44445a 100644 --- a/src/main/window.ts +++ b/src/main/window.ts @@ -113,7 +113,7 @@ export class GameWindow { // Closing the window with the X doesn't quit the app — we hide it to the tray. In SteamOS Game Mode // there is no tray and Steam ends a non-Steam game by closing its window, so the guard is skipped - // (`hideToTrayOnClose: false`): the close proceeds and main quits on window-all-closed (see Р8, point 5). + // (`hideToTrayOnClose: false`): the close proceeds and main quits on window-all-closed. if (opts.hideToTrayOnClose ?? true) { this.closeGuard = installHideOnClose(window); } diff --git a/src/renderer/app.ts b/src/renderer/app.ts index d4630f6..b85c5ec 100644 --- a/src/renderer/app.ts +++ b/src/renderer/app.ts @@ -226,7 +226,7 @@ const gameSettingsScreen = createGameSettingsScreen({ // declared below, and no message can arrive before the user has opened this screen. notify: (text) => toast.show(text), showError: (text) => controls.showError(text), - // Editing while the game runs is legal (Р3); DELETING it is not — the launcher would be left holding a + // Editing while the game runs is legal; DELETING it is not — the launcher would be left holding a // manifest the file no longer has. isBusy: () => currentState.kind === 'running' || @@ -558,7 +558,7 @@ function syncMusic(): void { audio.setMusicPlaying(visible && !running); } -// ── "Chatter": a rotating funny suffix for long busy phases (install / Proton config — Р7j) ────────── +// ── "Chatter": a rotating funny suffix for long busy phases (install / Proton config) ──────────────── // The base status ("Установка..." / "Конфигурация Proton...") shows alone for the first MINUTE; after that // a random funny suffix is APPENDED and swapped every 20s, so a long silent install/provision doesn't feel // stuck. Renderer-owned (pure presentation) — main only sets the base state. @@ -762,7 +762,7 @@ function render(state: AppState): void { controls.refresh(); syncMusic(); - // Empty-screen error (Р8, point 1): a card that fails to load sets state=error with no game. In Game + // Empty-screen error: a card that fails to load sets state=error with no game. In Game // Mode the window is shown (no tray to hide into), so surface the reason over the idle screen via the // error popup. Only on ENTERING the error (prev not already error) so a locale/wallpaper re-render // doesn't re-pop a popup the user has closed. Desktop/Windows keep hiding, so this rarely fires there. @@ -940,7 +940,7 @@ function applyBrowse(browse: BrowseInfo | null): void { currentBrowse = browse; // The Customize screen is about ONE game's file. When the card carrying it is pulled or swapped — // everything under the screen is rebuilt by then — there is nothing left to edit, so it closes rather - // than staying open over a game that is gone (see the plan, Р6.2). + // than staying open over a game that is gone. gameSettingsScreen.applyBrowse(browse); // The game the screen was about is GONE — the last history entry was forgotten, the card was pulled — // and main has nothing to put in its place. A detail screen is one game's screen, so with no game there diff --git a/src/renderer/configure-form-model.ts b/src/renderer/configure-form-model.ts index bac0de9..3fd65a9 100644 --- a/src/renderer/configure-form-model.ts +++ b/src/renderer/configure-form-model.ts @@ -64,7 +64,7 @@ export interface InstallModel { readonly type: InstallType; readonly runAsAdmin: boolean; readonly args: readonly string[]; - /** Extra winetricks verbs provisioned into the prefix before the installer runs (Linux; Р7b). */ + /** Extra winetricks verbs provisioned into the prefix before the installer runs (Linux). */ readonly winetricks: readonly string[]; readonly rest: Readonly>; } @@ -103,9 +103,9 @@ export interface ManifestFormModel { readonly launchTimeoutSec: string; readonly killTimeoutSec: string; readonly backgroundMusic: string; - /** Extra winetricks verbs provisioned into the prefix before the GAME launches (Linux; Р7b). */ + /** Extra winetricks verbs provisioned into the prefix before the GAME launches (Linux). */ readonly winetricks: readonly string[]; - /** umu GAMEID for launch — a Steam appid or custom UMU_ID (Linux; Р7i). '' = default `umu-default`. */ + /** umu GAMEID for launch — a Steam appid or custom UMU_ID (Linux). '' = default `umu-default`. */ readonly umuGameId: string; /** The `install` block for INSTALLER mode (types nsis/inno/custom). Never holds `copy`. */ readonly install: InstallModel; @@ -584,11 +584,11 @@ function buildManifestObject( // Executable checkbox a `type: 'copy'` one (its `installer` being the game directory to copy). if (model.launchMode === 'installer') out.install = buildInstall(model.install); else if (model.copyToPc) out.install = buildInstall(model.copyInstall); - // Game-launch prefix provisioning (Linux; Р7b) — applies to our own prefix (executable/installer + // Game-launch prefix provisioning (Linux) — applies to our own prefix (executable/installer // modes), not steam (which runs in Steam's compatdata). const winetricks = nonEmpty(model.winetricks); if (winetricks.length > 0) out.winetricks = winetricks; - // umu GAMEID for the launch (Linux; Р7i) — same scope: our own umu-run, not steam://. + // umu GAMEID for the launch (Linux) — same scope: our own umu-run, not steam://. if (model.umuGameId !== '') out.umuGameId = model.umuGameId; } @@ -639,7 +639,7 @@ export interface FormGameSlot { * may carry several games, `readManifests` SKIPS the ones that do not resolve, and the rest stay * perfectly playable — so the user edits game B while game A sits in the same file, unrepresentable. With * only `FormGameSlot` to serialize from, saving B would have to drop A. Preserving it verbatim is not a - * nicety; it is the difference between editing a game and destroying its neighbour (see the plan, Р2). + * nicety; it is the difference between editing a game and destroying its neighbour. */ export interface RawGameSlot { readonly raw: unknown; @@ -734,7 +734,7 @@ export function slotsWithNewGame(text: string | null, launchMode: LaunchMode): N /** * Serializes a LIST of game form states back to manifest TEXT: exactly one game → a single object (legacy - * shape, maximal backwards compatibility), more than one → an array (see the plan, decision 2). + * shape, maximal backwards compatibility), more than one → an array. * * An EMPTY list serializes to `[]` — the PC library's "there are no local games any more", which main * turns into deleting game.json. A card never reaches this (its last game cannot be removed). diff --git a/src/renderer/controls.ts b/src/renderer/controls.ts index 145d5fd..4631aa2 100644 --- a/src/renderer/controls.ts +++ b/src/renderer/controls.ts @@ -294,7 +294,7 @@ export function createControls(deps: ControlsDeps): Controls { * The full-screen overlays, as a set rather than as a named one. Every mechanism that has to stand down * while a screen is up — the idle timer, the wheel, Y, and all six primitives — asks THESE two * questions instead of `settings.isOpen()`, so the next screen is one entry in this list rather than an - * eleventh edit in every primitive (see the plan, Р1). + * eleventh edit in every primitive. * * At most one is ever open: a screen is entered from the Details menu, which closes on the way in, and * a surface that opens on top of a screen (the keyboard, the file picker) belongs to that screen's own @@ -386,7 +386,7 @@ export function createControls(deps: ControlsDeps): Controls { // itself just set (see the mousemove handler). armHover(); // Only the FIRST view is an opening; switching views keeps the popup on screen and keeps the - // button/back sounds the callers already play (Р4). + // button/back sounds the callers already play. if (popupView === 'none') audio.play('popup-open'); popupView = view; popup.dataset['view'] = view; @@ -425,7 +425,7 @@ export function createControls(deps: ControlsDeps): Controls { function closePopup(options?: { readonly silent?: boolean }): void { if (popupView === 'none') return; // `silent` is for a close that is only half of a bigger move — the popup handing over to a screen, - // where the destination's own popup-open is the single sound of that gesture (Р5). + // where the destination's own popup-open is the single sound of that gesture. if (options?.silent !== true) audio.play('popup-close'); popupView = 'none'; popup.classList.remove('is-open'); @@ -613,7 +613,7 @@ export function createControls(deps: ControlsDeps): Controls { const item = deps.getNotifications().find((candidate) => candidate.id === id); window.api.dismissNotification(id); // Muted when the entry leads to Settings — that screen's popup-open is the sound of the whole - // gesture (Р5). With nowhere to go, the popup simply closes and says so. + // gesture. With nowhere to go, the popup simply closes and says so. closePopup({ silent: item?.kind === 'update-ready' }); if (item === undefined) return; if (item.kind === 'update-ready') { diff --git a/src/renderer/file-picker.ts b/src/renderer/file-picker.ts index 561b775..0d1b0b1 100644 --- a/src/renderer/file-picker.ts +++ b/src/renderer/file-picker.ts @@ -5,7 +5,7 @@ // It is READ-ONLY and unrestricted on purpose: where to browse is the user's business, and the commonest // install path there is (`…/steamapps/common`) is a system directory by any definition. What is guarded // is what main ACCEPTS back — the type/extension checks and the import limits live there, where a -// renderer cannot talk its way past them (see the plan, Р5.1/Р5.2). +// renderer cannot talk its way past them. // // Two columns: the starting points on the left (the card, this PC, the home folder, every mounted // volume), the current directory on the right. Left/right move between them, up/down inside one, A enters diff --git a/src/renderer/game-settings-model.ts b/src/renderer/game-settings-model.ts index 2d8a1b9..c1dc926 100644 --- a/src/renderer/game-settings-model.ts +++ b/src/renderer/game-settings-model.ts @@ -116,7 +116,7 @@ export interface GameSettingsEnv { * inferring all of that from the length of a list reads like a puzzle. */ readonly mode: 'edit' | 'add'; - /** A "Move to card…" target has been chosen (Р2.2) — Save is labelled and routed for a move instead of + /** A "Move to card…" target has been chosen — Save is labelled and routed for a move instead of * an ordinary edit; Reset/Delete make no sense mid-move and are left out by the screen's own canDelete. */ readonly move: boolean; /** Where a NEW game may go — the roots offered by the source row. Empty in edit mode (no such row). */ @@ -141,7 +141,7 @@ export interface GameSettingsEnv { readonly mixed: boolean; /** This game's validation problems, by the field path the validator reported (already localized). */ readonly issues: ReadonlyMap; - /** Problems in OTHER games of a multi-game file, already worded for display (see the plan, Э4). */ + /** Problems in OTHER games of a multi-game file, already worded for display. */ readonly otherIssues: readonly string[]; /** A status line under the actions: what the last save did, or why Save is unavailable. */ readonly status: string | null; @@ -215,8 +215,8 @@ function lockedForHistory(rows: readonly GameSettingsRow[]): readonly GameSettin /** * The launch modes a source allows. A card cannot host a `pc` game (or the `none` draft state — a card - * is portable and must stay resolvable on its own, see the plan's assumption 3); a local game is only - * ever one, but may also be a draft with none configured yet (Р1). + * is portable and must stay resolvable on its own); a local game is only ever one, but may also be a + * draft with none configured yet. */ export function launchModesFor(source: ManifestSource): readonly LaunchMode[] { return source === 'pc' ? ['pc', 'steam', 'none'] : ['executable', 'installer', 'steam']; @@ -363,7 +363,7 @@ export function buildGameSettingsModel( }); // Absent while a move is pending: the id is what BOTH halves of the move are addressed by (which slot // leaves the PC library, which stats/saves follow the game), so a move that also renames would orphan - // all of it — see the plan's assumption 4, and the matching refusal in GameConfigService.moveToCard. + // all of it — hence the matching refusal in GameConfigService.moveToCard. // Renaming stays available as an ordinary edit, before or after the move. if (!env.move) { basics.push({ @@ -815,7 +815,7 @@ export function hasSourceBoundValues(form: ManifestFormModel): boolean { * radio button was changed. */ /** - * Moves a LOADED PC-library form onto a card (Р2.2 — the pure half of "Move to card…"). Unlike + * Moves a LOADED PC-library form onto a card (the pure half of "Move to card…"). Unlike * `carryFormAcrossSources` (which starts a NEW, half-filled ADD form and has nothing of the old root's to * keep), this carries a REAL game's data across: everything the card dialect can express survives, * including the artwork/music, whose paths become the DETERMINISTIC names the game gets on the diff --git a/src/renderer/game-settings-screen.ts b/src/renderer/game-settings-screen.ts index f980002..6169fa1 100644 --- a/src/renderer/game-settings-screen.ts +++ b/src/renderer/game-settings-screen.ts @@ -6,8 +6,8 @@ // Three things are worth knowing before reading the rest: // // • THE FILE IS THE UNIT, THE GAME IS THE SLOT. gameConfig:read hands over the whole game.json text; the -// screen finds ITS slot by `id` (never by an index from main — see the plan, Р2), edits that one, and -// serializes every slot back. A neighbour the form cannot represent is carried through verbatim as a +// screen finds ITS slot by `id` (never by an index from main: the file can change under it), edits +// that one, and serializes every slot back. A neighbour the form cannot represent is carried through verbatim as a // raw slot, so saving one game never destroys another. // // • SAVING IS EXPLICIT. Unlike Settings, a value change writes nothing: every keystroke would mean a @@ -134,7 +134,7 @@ export interface GameSettingsScreenApi { * ever sent after the game has left the manifest: main refuses to forget a game that is available. */ forgetHistory(id: string): void; - /** Moves a local (PC-library) game onto a card in one transaction (see the plan, Р2.5). */ + /** Moves a local (PC-library) game onto a card in one transaction. */ moveToCard(request: GameMoveRequest): Promise; /** The same conversion the in-launcher picker uses (main re-checks/converts a picked path) — used * outside a Browse to carry an absolute PC-side pcSavePath over as a %PREFIX% string when moving a @@ -210,9 +210,9 @@ export interface GameSettingsScreenDeps { readonly audio: AudioController; getTranslator(): Translator; readonly api: GameSettingsScreenApi; - /** The on-screen keyboard — without it there is no way to type on a gamepad (see the plan, Р4). */ + /** The on-screen keyboard — without it there is no way to type on a gamepad. */ readonly keyboard: TextEntrySurface; - /** The in-launcher file browser — the native dialog cannot be driven in Game Mode (Р5). */ + /** The in-launcher file browser — the native dialog cannot be driven in Game Mode. */ readonly picker: FilePickerSurface; /** The online artwork gallery — the surface "Find online" picks a cover or a background in. */ readonly onlinePicker: OnlinePickerSurface; @@ -225,7 +225,7 @@ export interface GameSettingsScreenDeps { * title with X?" is about a candidate the popup has never heard of. */ onConfirmRequested(kind: GameSettingsConfirm, options?: { readonly title?: string }): void; - /** Whether the game is running / installing / being force-closed — Delete is hidden then (Р3). */ + /** Whether the game is running / installing / being force-closed — Delete is hidden then. */ isBusy(): boolean; /** A game was added AND applied: the launcher's library has it now, so the carousel goes to it. */ onAdded(id: string): void; @@ -251,7 +251,7 @@ export interface GameSettingsScreen extends NavSurface { /** Opens the same screen with no game behind it — the form CREATES one (see `mode`). */ openNew(): void; close(): void; - /** browse:update arrived: the screen closes when its game is gone or no longer playable (Р6.2). */ + /** browse:update arrived: the screen closes when its game is gone or no longer playable. */ applyBrowse(browse: BrowseInfo | null): void; /** The confirm popup answered yes for `kind`. */ confirmAccepted(kind: GameSettingsConfirm): void; @@ -308,7 +308,7 @@ interface MenuEntry { readonly current?: boolean; /** Which sound this entry makes. One runner plays it, so a press and a click sound identical. * 'none' is for an entry whose own surface speaks for it — opening the file browser or the lightbox, - * where the primitive plays popup-open (Р5). */ + * where the primitive plays popup-open. */ readonly sound?: SfxName | 'none'; readonly run: () => void; } @@ -357,7 +357,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett * reach: * * `media` — the ordinary case: a real root (a card, or the PC library) plus the content signature - * it was read against, which is the swap guard every save is checked against (Р6.2). + * it was read against, which is the swap guard every save is checked against. * `history` — a game whose card is NOT in. There is no root to browse, no signature to guard and no * file to write; everything is addressed by the game's ID instead, and the edits are * stored on this PC until that card comes back (see history-config.ts). @@ -386,7 +386,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett let slotIndex = -1; /** * A SECOND, PARALLEL set of "which file, which slot" — active only while moving a local game onto a - * card (Р2.2). The screen still works against one file at a time, but which one flips: `currentText` / + * card. The screen still works against one file at a time, but which one flips: `currentText` / * `runValidate` / `canSave` all read `pendingMove` first and fall back to `origin`/`slots`/`slotIndex` * only when it is null. Nothing is written to disk while this is set — see beginMove/adoptMoveTarget. */ @@ -428,7 +428,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett /** * The problems that were ALREADY in the other games' slots when the screen opened. Save is allowed * while they are there — the file is not ours to fix from a per-game screen, and a game that failed to - * resolve is not even in the carousel — but a NEW one means we introduced it (see the plan, Э4). + * resolve is not even in the carousel — but a NEW one means we introduced it. */ let baselineOtherIssues: ReadonlySet = new Set(); /** @@ -600,7 +600,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett ? move.target.label : (sources.find((candidate) => candidate.root === at)?.label ?? null), // While a move is pending the form is edited AS THE TARGET CARD would read it — the whole point of - // "the form expands" (see the plan, Р2.2/Р2.3): rows, launch modes and pickers all key off this. + // "the form expands": rows, launch modes and pickers all key off this. // A history game is a card's game by definition — its manifest came off one. source: move !== null || media === null ? 'card' : media.source, platform: where.platform, @@ -1133,7 +1133,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett function pushMenu(level: MenuLevel): void { hover.arm(); - // Only the FIRST level is a surface appearing; going deeper is a step inside one already open (Р4). + // Only the FIRST level is a surface appearing; going deeper is a step inside one already open. if (menuStack.length === 0) deps.audio.play('popup-open'); menuStack.push(level); paintMenu(); @@ -1160,7 +1160,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett } function closeMenus(options?: { readonly silent?: boolean }): void { - // `silent` for a cascade — the screen closing, or a surface that already played its own close (Р5). + // `silent` for a cascade — the screen closing, or a surface that already played its own close. if (menuStack.length > 0 && options?.silent !== true) deps.audio.play('popup-close'); menuStack.length = 0; stopMetadataWork(); @@ -1839,7 +1839,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett } /** - * "Move to card…" (Р2.1): lists the cards a local game may move to and lets the user pick one. Called + * "Move to card…": lists the cards a local game may move to and lets the user pick one. Called * once `load` has landed — re-checks the source itself, since the menu item's own visibility rule * (controls.ts) can go stale between the press and the read completing. */ @@ -2088,7 +2088,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett baseline = text; // A save while the game is RUNNING writes the file but cannot reload the manifest (the launcher // refuses mid-play). That is not a failure — the file on disk is already right and the launcher picks - // it up on the next read — so it is reported as what it is (see the plan, Р3). + // it up on the next read — so it is reported as what it is. if (result.applied === 'applied') notifyDone(t()('gameSettings.savedApplied')); else if (result.applied === 'deferred') notifyDone(t()('gameSettings.savedDeferred')); else notifyDone(t()('gameSettings.savedNotApplied')); @@ -2096,7 +2096,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett } /** - * The Save button while a move is pending (Р2.5) — one IPC, the whole transaction runs in main (see + * The Save button while a move is pending — one IPC, the whole transaction runs in main (see * GameConfigService.moveToCard). Closes on success exactly like `runAdd`: the game left the PC library, * so there is nothing here to keep editing. `deferred`/a skipped save folder are reported to the user as * NOTIFICATIONS main files itself (game-moved-deferred / game-move-save-skipped), not as screen status — @@ -2324,7 +2324,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett * Fills the manifest's non-picture facts in the background: the description, and the genres, release * date and platforms a future library view will sort by. main deliberately never writes them itself — * the manifest TEXT belongs to this form while the screen is open, so a write from the other side - * would be overwritten by the next Save (see configure-form-model.ts and 4.5 of the plan). + * would be overwritten by the next Save (see configure-form-model.ts). */ async function fetchMetadataDescriptions(candidate: GameCandidate): Promise { const token = metadataToken; @@ -2631,7 +2631,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett adoptToken += 1; pendingMove = null; deps.audio.play('back'); - // The lightbox, the menu and the keyboard go WITH the screen — one close, one sound (Р5). + // The lightbox, the menu and the keyboard go WITH the screen — one close, one sound. closeImage({ silent: true }); closeMenus({ silent: true }); deps.keyboard.close(); @@ -2890,7 +2890,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett if (origin?.kind === 'history') return; // The card was pulled, or swapped, or the game stopped being playable: the screen is about a file // that is no longer reachable, and everything under it (the carousel, the detail screen) has been - // rebuilt already. Leaving would be worse than closing, so it closes — see the plan, Р6.2. + // rebuilt already. Leaving would be worse than closing, so it closes. if (browse !== null && browse.id === gameId && browse.active) return; close(); }, diff --git a/src/renderer/index.html b/src/renderer/index.html index 7088f36..c1c4de7 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -224,7 +224,7 @@ + display:none, so the fade in and out can run. -->