Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,29 @@ mandate to rewrite what already works.
browser, so a `node:*` import there breaks the build, not just the convention. See
`src/shared/asset-move-names.ts` (move-to-card asset names, computed identically in main and renderer).

## Two state models: the session and per-game activities

- **`AppState` is the game session only** (`idle | ready | configuringProton | syncing-in | launching |
running | syncing-out | error`): at most one game is launched, played and synced at a time. Its locks
live in `GameSequences` (`game-sequences.ts`).
- **Everything that runs in the background is a per-game activity** (`shared/activity.ts`,
`ActivityMap` keyed by game id), held by `ActivityRegistry` (`activity-registry.ts`) and pushed in full
on `activity:update` (seeded by `activity:request`). Writers: `SteamActivityWatch`
(`steam-activity-watch.ts`, Steam downloads / updates / removals of every available Steam game) and
`GameJobs` (`game-jobs.ts`, copy installs, installer runs, uninstalls, prefix cleanups, with their own
queues and session rules in the pure `startVerdict`).
- **A game never has both.** A game with an activity is not launched; the session's game gets no
activity (Steam updating it before launch must not hide "Launching...").
- **Actions are addressed by game id** (`action:launch`, `action:uninstall`, `action:cancel-job`).
`GameActions` (`game-actions.ts`) resolves the target with the pure `resolveActionTarget`
(`action-target.ts`): with the session busy, another game may be installed or removed, never launched.
- **The renderer judges the game on screen by itself**: `screenActions` in `state-view.ts` decides Play,
Install, Uninstall, Cancel and Force close from the session, the browse model and that game's activity.
`#app[data-phase]` stays global; `data-busy`, `data-activity` and `data-play-locked` describe the game
on screen.
- **A managed exit goes through `QuitGate`** (`quit-gate.ts`): with jobs running, Quit / Shutdown / Reboot
ask first (`quit:confirm` / `quit:confirm-reply`, a native dialog when the window does not answer).

## Error-handling convention

Pick per situation, matching the existing patterns:
Expand Down
61 changes: 57 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -446,8 +446,9 @@ How it works:
- While the game isn't installed there is **no Play button**: **Install** lives in the **More (⋯)**
menu. Pressing it asks for confirmation (the popup also shows the destination path, handy if the
installer isn't fully silent), then runs the installer **silently** and shows an **"Installing..."**
indicator. When the executable appears **Play** comes back, and from then on the game launches
**from the install location on the PC**.
indicator. The install runs in the background (see
[Background installs and removals](#background-installs-and-removals)). When the executable appears
**Play** comes back, and from then on the game launches **from the install location on the PC**.
- **`type: "copy"` — "move game to PC" without an installer.** `installer` points at the game's own
directory on the card and Playhook simply **copies its contents** into the install dir (`executable`
is relative to that copy; a leading `<source>/` is stripped, so both spellings work). Nothing is
Expand Down Expand Up @@ -568,8 +569,13 @@ for a normal game.
- **Not installed → no Play button; "Install" sits in the More (⋯) menu.** Pressing it confirms ("Open
Steam to install this game?") and opens `steam://install/<appid>` — Steam shows its own install dialog.
Playhook does **not** block on a wizard: it stays on the screen, shows a non-blocking
**"Installing…"** indicator, and a background poll (~5s) brings **Play** back once Steam reports the
game fully installed. The window stays usable meanwhile — a Steam download can run for hours.
**"Installing…"** indicator, and a background poll of every Steam game (~5s) brings **Play** back once
Steam reports the game fully installed. The window stays usable meanwhile - a Steam download can run for
hours, several at once, each shown on its own game (see
[Background installs and removals](#background-installs-and-removals)).
- **Updates and pre-loads:** Steam updating an installed game reads **"Updating…"** and ends without an
"installed" notification; a pre-load of a game that is not released yet reads **"Pre-load complete"**.
A game Steam updates right before you launch it shows "Launching…", not "Updating…".
- **Pause:** if you pause the download in Steam, the indicator becomes **"Installing paused on N%…"**
(the percent is only available while paused — see limitations). While a download is in progress the
**activity button (the spinning gear) opens Steam's Downloads page** so you can pause/resume there —
Expand All @@ -586,6 +592,50 @@ If Steam isn't installed on the PC, Install/Play report **"Steam is not installe
a URI. Steam's install and uninstall dialogs **cannot be made silent** — there is no `steam://` flag to
suppress them (just one confirmation; the rest of the flow is automatic).

### Background installs and removals

Installing and uninstalling never take the launcher over: every game carries its own **activity**, and
any number of games can be busy at once while the others stay playable.

- **Per game, side by side.** Copy installs, card installers, uninstalls, prefix cleanups and Steam
downloads each belong to one game. Its card pulses in the carousel and the Library, and its detail
screen shows its own status: "Installing...", "Waiting to install...", "Updating...",
"Pre-load complete" and so on. The status shows from the moment Playhook starts, without opening the game.
- **While a game is running** you can bring Playhook up (Start+Back on Windows, the tray or window
switching in desktop Linux, Steam's window switcher in Game Mode) and install, cancel or uninstall
**other** games. A second game is never launched on top of the first: its Play stays visible but
inactive. A Steam install opens Steam's own dialog, which may appear over the game (not yet checked in
Game Mode).
- **Queues.** At most two copy installs and **one installer** run at once (parallel installers are not
safe on Windows: MSI's global lock fails a second one, and repacks eat the machine); the rest show
"Waiting to install...". An interactive installer (silent mode off), an elevated one (`runAsAdmin`) and a
removal that runs the game's own uninstaller elevated wait until you quit the game you are playing
("Will install after you quit the game"), so no wizard or UAC prompt pops up over it. Under gamescope
(Game Mode) no installer and no winetricks prefix setup starts while a game runs at all - the game has
the only window there.
- **Cancel:** More (⋯) → **Cancel installation** stops a queued or running install of the game on screen.
A stopped copy leaves nothing behind: the files are staged in `<install dir>.partial` and moved into place
only once complete.
- **Notifications instead of a popping window.** A finished install or removal no longer raises the
launcher; it is reported in the notifications (a failure too, with its reason - the error popup shows
only while the launcher has focus).
- **Quitting with work in progress** - Quit, Shutdown or Reboot, from the launcher or the tray - asks
first; confirming cancels the running jobs and cleans up after them. If the launcher window does not
answer, a native dialog asks instead. A launcher killed outright (crash, logout) sweeps leftover staging
folders on the next start, and an interrupted installer run never leaves a game that looks installed.
- **Pulling the card** stops the installs that read from it (reported in the notifications); removals
target the PC and finish.

Tuning (environment variables, read at start):

| Variable | Default | What it sets |
|---|---|---|
| `PLAYHOOK_STEAM_POLL_MS` | `5000` | How often Steam's `.acf` state of every Steam game is polled |
| `PLAYHOOK_MAX_PARALLEL_COPY_INSTALLS` | `2` | Copy installs running at once (reading a card while a game runs from it competes for it) |
| `PLAYHOOK_MAX_PARALLEL_INSTALLERS` | `1` | Installer runs at once - more is at your own risk on Windows |
| `PLAYHOOK_QUIT_CONFIRM_ACK_MS` | `2000` | How long the launcher window has to show the quit question before the native dialog takes over |
| `PLAYHOOK_QUIT_GRACE_MS` | `3000` | How long a quit waits for running jobs to clean up |

### Statistics: one card, many PCs

Statistics (hours / last played / launch count) are **unified across machines** with the card as
Expand Down Expand Up @@ -698,6 +748,9 @@ These are ignored on Windows, so a dual-platform card can carry them safely:
yanked into Playhook on every entry would be obnoxious. Launch it from the library in that case.
An ordinary USB stick or a card without `game.json` never triggers it. The whole behaviour can be
turned off in **Settings → General**.
- **Installing beside a running game:** switch to Playhook with Steam's window switcher, start installs or
removals of other games, and switch back. Installers and winetricks prefix setups wait until you quit
the game (gamescope gives the game the only window); a copy whose prefix is already set up runs at once.
- **No autostart in Game Mode** — the app lives as a non-Steam game, started by Steam (or by the service
above). In Desktop Mode it writes a `~/.config/autostart/playhook.desktop` entry instead.

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "playhook",
"version": "0.9.0-alpha.10",
"version": "0.9.0-alpha.11",
"description": "Background launcher that detects installed game on external device, syncs saves and tracks playtime. Bring console vibes to your PC.",
"private": true,
"main": "dist/main/main.js",
Expand Down
42 changes: 42 additions & 0 deletions src/main/action-target.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/** What a Play / Install or Uninstall pressed for one game id resolves to. */
export type ActionTarget =
| 'launch-or-install-selected'
| 'select-then-act'
| 'install-only'
| 'uninstall-only'
| 'resume-session'
| 'refuse';

export interface ActionTargetInput {
readonly action: 'launch' | 'uninstall';
/** The game the action was pressed for. */
readonly id: string;
/** The game AppState is about (the selection), or null. */
readonly selectedId: string | null;
/** A session is in flight: launching, running, syncing, or a sequence still unwinding. */
readonly sessionBusy: boolean;
/** The game that session is about, or null. */
readonly sessionGameId: string | null;
/** The target already has an activity (an install, a removal, a Steam download). */
readonly hasActivity: boolean;
/** The target's source is available: a local game always, a card game only with its card in. */
readonly sourceAvailable: boolean;
readonly reloadInFlight: boolean;
}

/**
* Where an action for game `id` goes. With the session free it acts on the selected game as it always
* did, selecting the target first when it is another one. With a session in flight the session's own game
* only resumes, and any other game may be installed or removed but never launched: one game runs at a time.
* A game that is busy, whose card is gone, or that is being reloaded is refused.
*/
export function resolveActionTarget(input: ActionTargetInput): ActionTarget {
if (input.hasActivity || !input.sourceAvailable || input.reloadInFlight) return 'refuse';
if (!input.sessionBusy) {
if (input.id !== input.selectedId) return 'select-then-act';
return input.action === 'launch' ? 'launch-or-install-selected' : 'uninstall-only';
}
if (input.id === input.sessionGameId)
return input.action === 'launch' ? 'resume-session' : 'refuse';
return input.action === 'launch' ? 'install-only' : 'uninstall-only';
}
4 changes: 3 additions & 1 deletion src/main/activity-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ export class ActivityRegistry {
/** Frees `id`; a game that carries no activity changes nothing and announces nothing. */
clear(id: string): void {
if (this.activities[id] === undefined) return;
this.activities = Object.fromEntries(Object.entries(this.activities).filter(([key]) => key !== id));
this.activities = Object.fromEntries(
Object.entries(this.activities).filter(([key]) => key !== id),
);
this.emit();
}

Expand Down
2 changes: 1 addition & 1 deletion src/main/controller-deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export interface ProcessControl {
// unit test can stand in a fake per seam (see test/game-controller.test.ts) — the classes themselves reach
// for electron and the disk.
export type ControllerState = Pick<StateManager, 'get' | 'set' | 'subscribe'>;
export type ControllerWindow = Pick<GameWindow, 'send' | 'showAndFocus' | 'hide' | 'isShown'>;
export type ControllerWindow = Pick<GameWindow, 'send' | 'showAndFocus' | 'hide' | 'isShown' | 'isFocused'>;
export type ControllerStore = Pick<
PcStore,
| 'getPending'
Expand Down
Loading
Loading