From eea63809171014caec0e22be06961b2d90242099 Mon Sep 17 00:00:00 2001 From: Alexander Date: Mon, 5 Oct 2026 12:33:32 +0500 Subject: [PATCH 1/8] Select: a Steam download no longer pins the selection to its game --- src/main/game-controller.ts | 3 +-- test/game-controller.test.ts | 27 --------------------------- 2 files changed, 1 insertion(+), 29 deletions(-) diff --git a/src/main/game-controller.ts b/src/main/game-controller.ts index b18572c..5c2d144 100644 --- a/src/main/game-controller.ts +++ b/src/main/game-controller.ts @@ -1072,8 +1072,7 @@ export class GameController { */ private async onSelectRequested(idRaw: unknown): Promise { if (typeof idRaw !== 'string') return; - const steamBusyElsewhere = (this.steamBusyId ?? idRaw) !== idRaw; - if (this.deps.state.get().kind !== 'ready' || this.sequences.isLocked || this.sequences.inFlight || this.reloadInFlight || steamBusyElsewhere) return; + if (this.deps.state.get().kind !== 'ready' || this.sequences.isLocked || this.sequences.inFlight || this.reloadInFlight) return; const manifest = this.games.find((m) => m.raw.id === idRaw); if (manifest === undefined) { log.warn(`[select] no game with id="${idRaw}" on the current card or in the PC library — ignoring`); diff --git a/test/game-controller.test.ts b/test/game-controller.test.ts index 720a32b..4ac81fa 100644 --- a/test/game-controller.test.ts +++ b/test/game-controller.test.ts @@ -850,33 +850,6 @@ describe('GameController sequences', () => { }); }); - describe('steam download in flight', () => { - it('refuses to select another game, so the download keeps its place in the state', async () => { - h = await harness({ - mode: 'steam', - extraGames: [{ id: 'other', lastPlayedAt: '2026-10-01T00:00:00.000Z' }], - }); - const built = h; - await fs.writeFile( - path.join(built.tmp, 'steam', 'steamapps', `appmanifest_${STEAM_APPID}.acf`), - `"AppState"\n{\n\t"appid"\t\t"${STEAM_APPID}"\n\t"StateFlags"\t\t"1026"\n}\n`, - ); - fire(IPC.actionSelect, 'g1'); - await waitFor(() => { - const state = built.state.get(); - return state.kind === 'ready' && state.game.steamInstalling === true; - }, 'the steam game to report its download'); - - fire(IPC.actionSelect, 'other'); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(built.state.get()).toMatchObject({ - kind: 'ready', - game: { id: 'g1', steamInstalling: true }, - }); - }); - }); - describe('prefix cleanup', () => { it('success: uninstalling → ready with Uninstall gone, then the window', async () => { h = await harness({ mode: 'prefix-cleanup' }); From 244a5dd766b02a0e91cc032022ebf488b27ebf3d Mon Sep 17 00:00:00 2001 From: Alexander Date: Mon, 5 Oct 2026 12:34:42 +0500 Subject: [PATCH 2/8] Add the per-game activity type and its registry --- src/main/activity-registry.ts | 50 ++++++++++++++++++++ src/shared/activity.ts | 29 ++++++++++++ test/activity-registry.test.ts | 85 ++++++++++++++++++++++++++++++++++ 3 files changed, 164 insertions(+) create mode 100644 src/main/activity-registry.ts create mode 100644 src/shared/activity.ts create mode 100644 test/activity-registry.test.ts diff --git a/src/main/activity-registry.ts b/src/main/activity-registry.ts new file mode 100644 index 0000000..5cf3321 --- /dev/null +++ b/src/main/activity-registry.ts @@ -0,0 +1,50 @@ +import { sameActivity, type ActivityMap, type GameActivity } from '../shared/activity'; + +type ActivityListener = (activities: ActivityMap) => void; + +/** Holds the background operation of every game by id and announces each real change as a full snapshot. */ +export class ActivityRegistry { + private activities: ActivityMap = {}; + private readonly listeners = new Set(); + + /** The activity of `id`, or undefined when the game is free. */ + get(id: string): GameActivity | undefined { + return this.activities[id]; + } + + /** Whether `id` carries any activity. */ + has(id: string): boolean { + return this.activities[id] !== undefined; + } + + /** Every game's activity right now. */ + snapshot(): ActivityMap { + return this.activities; + } + + /** Sets the activity of `id`; a value equal to the current one changes nothing and announces nothing. */ + set(id: string, activity: GameActivity): void { + if (sameActivity(this.activities[id], activity)) return; + this.activities = { ...this.activities, [id]: activity }; + this.emit(); + } + + /** Frees `id`; a game that carries no activity changes nothing and announces nothing. */ + clear(id: string): void { + if (this.activities[id] === undefined) return; + this.activities = Object.fromEntries(Object.entries(this.activities).filter(([key]) => key !== id)); + this.emit(); + } + + /** Calls `listener` with the full snapshot after every change. Returns the unsubscribe. */ + subscribe(listener: ActivityListener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + private emit(): void { + for (const listener of this.listeners) listener(this.activities); + } +} diff --git a/src/shared/activity.ts b/src/shared/activity.ts new file mode 100644 index 0000000..b775929 --- /dev/null +++ b/src/shared/activity.ts @@ -0,0 +1,29 @@ +/** + * A background operation bound to one game. Any number of games can carry one at a time, independently of + * the single game session AppState describes. A game absent from the ActivityMap is free. + */ +export type GameActivity = + | { readonly kind: 'queued' } + | { readonly kind: 'installing' } + | { readonly kind: 'configuringProton' } + | { readonly kind: 'uninstalling' } + | { readonly kind: 'steam-installing'; readonly paused: boolean; readonly pausedProgress?: number } + | { readonly kind: 'steam-updating'; readonly paused: boolean; readonly pausedProgress?: number } + | { readonly kind: 'steam-uninstalling' }; + +/** Every game's activity, keyed by game id. */ +export type ActivityMap = Readonly>; + +/** The activity as a plain field bag, for the field-by-field comparison below. */ +function fieldsOf(activity: GameActivity): Readonly> { + return activity; +} + +/** Whether two activities say the same thing field by field, so a repeated poll is not reported as a change. */ +export function sameActivity(a: GameActivity | undefined, b: GameActivity | undefined): boolean { + if (a === undefined || b === undefined) return a === b; + const left = fieldsOf(a); + const right = fieldsOf(b); + const keys = Object.keys(left); + return keys.length === Object.keys(right).length && keys.every((key) => Object.is(left[key], right[key])); +} diff --git a/test/activity-registry.test.ts b/test/activity-registry.test.ts new file mode 100644 index 0000000..e85b510 --- /dev/null +++ b/test/activity-registry.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; +import { ActivityRegistry } from '../src/main/activity-registry'; +import { sameActivity, type ActivityMap } from '../src/shared/activity'; + +function recorded(registry: ActivityRegistry): ActivityMap[] { + const pushes: ActivityMap[] = []; + registry.subscribe((activities) => pushes.push(activities)); + return pushes; +} + +describe('ActivityRegistry', () => { + it('starts empty: every game is free', () => { + const registry = new ActivityRegistry(); + expect(registry.snapshot()).toEqual({}); + expect(registry.get('a')).toBeUndefined(); + expect(registry.has('a')).toBe(false); + }); + + it('keeps one activity per game and pushes the full snapshot on each change', () => { + const registry = new ActivityRegistry(); + const pushes = recorded(registry); + registry.set('a', { kind: 'steam-installing', paused: false }); + registry.set('b', { kind: 'steam-uninstalling' }); + expect(registry.get('a')).toEqual({ kind: 'steam-installing', paused: false }); + expect(registry.has('b')).toBe(true); + expect(pushes).toEqual([ + { a: { kind: 'steam-installing', paused: false } }, + { a: { kind: 'steam-installing', paused: false }, b: { kind: 'steam-uninstalling' } }, + ]); + }); + + it('announces nothing when the value is equal to the current one', () => { + const registry = new ActivityRegistry(); + registry.set('a', { kind: 'steam-updating', paused: true, pausedProgress: 0.5 }); + const pushes = recorded(registry); + registry.set('a', { kind: 'steam-updating', paused: true, pausedProgress: 0.5 }); + expect(pushes).toEqual([]); + registry.set('a', { kind: 'steam-updating', paused: true, pausedProgress: 0.6 }); + expect(pushes).toEqual([{ a: { kind: 'steam-updating', paused: true, pausedProgress: 0.6 } }]); + }); + + it('clear frees the game and leaves the others; clearing a free game announces nothing', () => { + const registry = new ActivityRegistry(); + registry.set('a', { kind: 'installing' }); + registry.set('b', { kind: 'queued' }); + const pushes = recorded(registry); + registry.clear('a'); + registry.clear('a'); + expect(registry.snapshot()).toEqual({ b: { kind: 'queued' } }); + expect(pushes).toEqual([{ b: { kind: 'queued' } }]); + }); + + it('a snapshot handed out earlier is not mutated by later changes', () => { + const registry = new ActivityRegistry(); + registry.set('a', { kind: 'installing' }); + const before = registry.snapshot(); + registry.set('a', { kind: 'uninstalling' }); + registry.clear('a'); + expect(before).toEqual({ a: { kind: 'installing' } }); + }); + + it('unsubscribe stops the pushes', () => { + const registry = new ActivityRegistry(); + const pushes: ActivityMap[] = []; + const stop = registry.subscribe((activities) => pushes.push(activities)); + stop(); + registry.set('a', { kind: 'installing' }); + expect(pushes).toEqual([]); + }); +}); + +describe('sameActivity', () => { + it('compares field by field, including an absent optional field', () => { + expect(sameActivity(undefined, undefined)).toBe(true); + expect(sameActivity({ kind: 'installing' }, undefined)).toBe(false); + expect(sameActivity({ kind: 'installing' }, { kind: 'installing' })).toBe(true); + expect(sameActivity({ kind: 'installing' }, { kind: 'uninstalling' })).toBe(false); + expect( + sameActivity( + { kind: 'steam-installing', paused: true }, + { kind: 'steam-installing', paused: true, pausedProgress: 0.1 }, + ), + ).toBe(false); + }); +}); From 3a4f7cc3e6f4e80ea4b2f1fba10f9eb8c9800aea Mon Sep 17 00:00:00 2001 From: Alexander Date: Mon, 5 Oct 2026 12:45:58 +0500 Subject: [PATCH 3/8] Steam: track downloads and removals per game as activities --- src/main/activity-registry.ts | 8 + src/main/controller-deps.ts | 4 + src/main/game-controller.ts | 174 +++++++--------- src/main/game-sequences.ts | 33 +-- src/main/main.ts | 4 + src/main/steam-activity-watch.ts | 234 +++++++++++++++++++++ src/main/steam-install-watch.ts | 197 ------------------ src/main/steam.ts | 65 ++++-- src/preload/preload.ts | 11 + src/renderer/app.ts | 40 ++-- src/renderer/carousel.ts | 17 +- src/renderer/controls-deps.ts | 3 + src/renderer/controls.ts | 24 +-- src/renderer/details-menu.ts | 7 +- src/renderer/game-settings-screen.ts | 8 +- src/renderer/library-screen.ts | 17 +- src/renderer/popups.ts | 4 + src/renderer/state-view.ts | 72 +++++-- src/shared/i18n/en.ts | 3 +- src/shared/i18n/ru.ts | 4 +- src/shared/types.ts | 33 +-- test/game-controller.test.ts | 181 ++++++++++++++-- test/renderer/controls.test.ts | 73 ++++++- test/state-view.test.ts | 100 +++++++++ test/steam-activity-watch.test.ts | 299 +++++++++++++++++++++++++++ 25 files changed, 1167 insertions(+), 448 deletions(-) create mode 100644 src/main/steam-activity-watch.ts delete mode 100644 src/main/steam-install-watch.ts create mode 100644 test/state-view.test.ts create mode 100644 test/steam-activity-watch.test.ts diff --git a/src/main/activity-registry.ts b/src/main/activity-registry.ts index 5cf3321..1646bd2 100644 --- a/src/main/activity-registry.ts +++ b/src/main/activity-registry.ts @@ -1,3 +1,5 @@ +import { ipcMain } from 'electron'; +import { IPC } from '../shared/types'; import { sameActivity, type ActivityMap, type GameActivity } from '../shared/activity'; type ActivityListener = (activities: ActivityMap) => void; @@ -7,6 +9,12 @@ export class ActivityRegistry { private activities: ActivityMap = {}; private readonly listeners = new Set(); + /** Answers the renderer's seed request and pushes every change to the window as a full snapshot. */ + init(send: (channel: string, activities: ActivityMap) => void): void { + ipcMain.handle(IPC.activityRequest, (): ActivityMap => this.snapshot()); + this.subscribe((activities) => send(IPC.activityUpdate, activities)); + } + /** The activity of `id`, or undefined when the game is free. */ get(id: string): GameActivity | undefined { return this.activities[id]; diff --git a/src/main/controller-deps.ts b/src/main/controller-deps.ts index 9dc7b53..0e5d7f8 100644 --- a/src/main/controller-deps.ts +++ b/src/main/controller-deps.ts @@ -23,6 +23,7 @@ import { type Platform } from './platform'; import { type AppSettingsStore } from './app-settings'; import { type NotificationsService } from './notifications'; import { type focusGameWindow } from './window-finder'; +import { type ActivityRegistry } from './activity-registry'; /** * What the collision answer needs from the Customize backend (GameConfigService). Attached after @@ -88,6 +89,7 @@ export type ControllerPcLibrary = Pick; export type ControllerWatcher = Pick; export type ControllerSettings = Pick; export type ControllerNotifications = Pick; +export type ControllerActivities = Pick; export interface ControllerDeps { readonly state: ControllerState; @@ -107,6 +109,8 @@ export interface ControllerDeps { * `installing` at all, so "it finished" cannot be read off the state machine. */ readonly notifications: ControllerNotifications; + /** Every game's background activity by id (Steam downloads and removals), apart from the session. */ + readonly activities: ControllerActivities; /** Platform services (process monitor, Steam locator, launcher, save-path resolver, power) for the OS. */ readonly platform: Platform; /** The process waits + the koffi-bound helpers (see ProcessControl). */ diff --git a/src/main/game-controller.ts b/src/main/game-controller.ts index 5c2d144..b09bbc4 100644 --- a/src/main/game-controller.ts +++ b/src/main/game-controller.ts @@ -38,7 +38,7 @@ import { AssetReader } from './asset-reader'; import { BrowsePresenter } from './browse-presenter'; import { GameSequences } from './game-sequences'; import { SaveSyncFlow } from './save-sync-flow'; -import { SteamInstallWatch } from './steam-install-watch'; +import { SteamActivityWatch, type SteamWatchEntry } from './steam-activity-watch'; import { describe } from './util'; import { log } from './logger'; @@ -72,10 +72,6 @@ export class GameController { // True while a collision answer is being carried out. The merge writes the card and reloads it, which // re-enters loadCard — and the detection at the end of it would ask the very question being answered. private collisionInFlight = false; - // The id of the game with a Steam download/removal in flight, or null. Steam operations are the one - // kind of activity that leaves the state `ready`, so this is what stops a SECOND game from being - // launched or installed underneath them (see onLaunchRequested). - private steamBusyId: string | null = null; // Mirror of AppSettings.keepOpenWithoutCard (seeded at startup, toggled live from the settings // window): when true the launcher stays on screen with no card in instead of hiding to the tray. // Initialized to the SCHEMA's default so the sliver between constructing this controller and the seed @@ -112,28 +108,8 @@ export class GameController { readBrowseAssets: (id) => this.deps.library.readBrowseAssets(id), onlyGlobalAmbient: async () => (await this.deps.settings.read()).onlyGlobalAmbient, }); - // Steam-mode background re-detect poller (timer + tick + optimistic uninstall request), extracted from - // this controller. Reaches back only through the narrow accessor seam below. - private readonly steamWatch: SteamInstallWatch = new SteamInstallWatch({ - getManifest: () => this.current(), - isLaunchInFlight: () => this.sequences.inFlight, - getState: () => this.deps.state.get(), - isSourceAvailable: () => this.currentSourceAvailable(), - enterReady: (info) => this.enterReady(info), - onInstallCompleted: (game) => - this.deps.notifications.notify({ - kind: 'game-installed', - gameId: game.id, - gameTitle: game.title, - }), - onUninstallCompleted: (game) => - this.deps.notifications.notify({ - kind: 'game-uninstalled', - gameId: game.id, - gameTitle: game.title, - }), - steamLocator: () => this.deps.platform.steamLocator, - }); + // Polls Steam's .acf state of every available Steam game and keeps each one's activity in the registry. + private readonly steamWatch: SteamActivityWatch; // The process lifecycle (launch / install / uninstall / prefix cleanup, force-close, the save sync // around a session) — see game-sequences.ts. Dispatched to from the renderer's actions below; it // reaches back through the SequenceHost seam for the card session's answers and transitions. @@ -143,6 +119,26 @@ export class GameController { private readonly saveSync: SaveSyncFlow; constructor(private readonly deps: ControllerDeps) { + this.steamWatch = new SteamActivityWatch({ + listSteamGames: () => this.steamGames(), + registry: this.deps.activities, + sessionGameId: () => this.sessionGameId(), + onGameChanged: (id) => + void this.onGameChanged(id).catch((cause: unknown) => log.warn('[steam-watch] refresh failed:', describe(cause))), + onInstallCompleted: (game) => + this.deps.notifications.notify({ + kind: 'game-installed', + gameId: game.id, + gameTitle: game.title, + }), + onUninstallCompleted: (game) => + this.deps.notifications.notify({ + kind: 'game-uninstalled', + gameId: game.id, + gameTitle: game.title, + }), + steamLocator: () => this.deps.platform.steamLocator, + }); this.saveSync = new SaveSyncFlow({ store: this.deps.store, stats: this.deps.stats, @@ -159,6 +155,7 @@ export class GameController { platform: this.deps.platform, processControl: this.deps.processControl, getTranslator: this.deps.getTranslator, + activities: this.deps.activities, steamWatch: this.steamWatch, saveSync: this.saveSync, host: { @@ -167,7 +164,6 @@ export class GameController { enterReady: (info) => this.enterReady(info), sourceAvailable: (manifest) => this.sourceAvailable(manifest), currentSourceAvailable: () => this.currentSourceAvailable(), - sourceAvailableFor: (id) => this.sourceAvailableFor(id), cardGoneAfterSequence: () => this.cardGoneAfterSequence(), browseToUnlessPinned: (id) => this.browseToUnlessPinned(id), refreshLibrary: () => this.refreshLibrary(), @@ -244,14 +240,13 @@ export class GameController { return manifest !== null && this.sourceAvailable(manifest); } - /** - * sourceAvailable for a game named by id — for the callers that hold a GameInfo, not a manifest. An - * unknown id is `false` on purpose: `games` hides a local game shadowed by the card (see the getter), - * and there is nothing to poll about a game that cannot be acted on right now. - */ - private sourceAvailableFor(id: string): boolean { - const manifest = this.games.find((m) => m.raw.id === id); - return manifest !== undefined && this.sourceAvailable(manifest); + /** Every Steam game whose source is available right now — what the Steam activity watch polls. */ + private steamGames(): readonly SteamWatchEntry[] { + return this.games.flatMap((manifest) => + manifest.steam !== undefined && this.sourceAvailable(manifest) + ? [{ id: manifest.raw.id, title: manifest.raw.title, appid: manifest.steam.appid }] + : [], + ); } /** @@ -274,12 +269,6 @@ export class GameController { /** Clears all card-scoped state (games, selection, lock, audio/hero/library channels). The caller sets * the follow-up AppState (idle/error) and window visibility, exactly as before. */ private clearCard(): void { - // Only a CARD game's Steam operation stops being ours to guard when the card goes: a local game's - // download keeps running and must keep refusing a second launch/install on top of it. Read before the - // list is emptied — afterwards there is no way to tell whose id it was. - if (this.steamBusyId !== null && this.cardGames.some((m) => m.raw.id === this.steamBusyId)) { - this.steamBusyId = null; - } this.cardGames = []; // The selection falls back to whatever is still there (a local game), or to nothing — see current(). this.selectedId = null; @@ -295,6 +284,7 @@ export class GameController { // games, and the browse cursor moves onto whatever is left (a history entry, or nothing). this.refreshLibrary(); void this.reseedBrowse(); + void this.steamWatch.scanNow(); } /** @@ -412,25 +402,13 @@ export class GameController { this.presenter.dispose(); this.sequences.abortInFlight(); this.steamWatch.stop(); - this.steamWatch.clearUninstallRequest(); this.deps.watcher.stop(); } - // ── Ready transition + Steam re-detect poller ────────────────────────────── + // ── Ready transition ─────────────────────────────────────────────────────── - /** - * The single entry point for the `ready` state. Besides setting the state, it manages the Steam - * background re-detect poller: started when the current game is a Steam game still showing "Install" - * (and the card is present), stopped otherwise. ALL ready transitions go through here so the poller's - * lifecycle is governed in exactly one place (StateManager is not a controller hook). - */ + /** The single entry point for the `ready` state; also re-pushes the browse info of the same game. */ private enterReady(info: GameInfo): void { - // Remember WHICH game Steam is busy with. A Steam download/removal keeps the state `ready` (it is - // non-blocking by design — it can run for hours), so the usual `kind !== 'ready'` guard does not cover - // it; and switching to another game rebuilds AppState around THAT game, which would otherwise erase - // the only trace of the operation. Cleared by the same game reporting itself idle again. - if (info.steamInstalling === true || info.steamUninstalling === true) this.steamBusyId = info.id; - else if (this.steamBusyId === info.id) this.steamBusyId = null; this.deps.state.set({ kind: 'ready', game: info }); // AppState and BrowseInfo carry the SAME GameInfo whenever they are about the same game — and the // detail screen reads the BROWSE one (`browse.game.requiresInstall` decides whether Play is there, @@ -444,16 +422,25 @@ export class GameController { if (browse !== null && browse.id === info.id && browse.active) { this.presenter.pushBrowse({ ...browse, game: info }); } - // Poll for ANY steam game whose source is available: it catches install completion (Install→Play), - // uninstall completion (Play→Install) — incl. an uninstall the user triggers in Steam directly — and - // download progress. A LOCAL steam game's source is always available, so this poll is no longer bounded - // by how long a card stays in: the launcher sitting on such a game polls it for as long as it is shown. - // The .acf read is cheap enough for that to be an acceptable price. - if (info.installVia === 'steam' && this.sourceAvailableFor(info.id)) { - this.steamWatch.start(); - } else { - this.steamWatch.stop(); + } + + /** + * Steam changed what can be done with game `id`: rebuilds its GameInfo and puts it wherever it is shown. + * Re-checked after the await: the state is only replaced while it is still `ready` on this very game + * with nothing in flight, otherwise only the browse info of the game on screen is refreshed. + */ + private async onGameChanged(id: string): Promise { + const manifest = this.games.find((m) => m.raw.id === id); + if (manifest === undefined) return; + const info = await this.buildGameInfo(manifest, this.statsById.get(id) ?? (await this.deps.stats.read(id))); + if (!this.games.includes(manifest)) return; + const snapshot = this.deps.state.get(); + if (snapshot.kind === 'ready' && snapshot.game.id === id && !this.sequences.inFlight && !this.reloadInFlight) { + this.enterReady(info); + return; } + const browse = this.presenter.browse; + if (browse !== null && browse.id === id && browse.active) this.presenter.pushBrowse({ ...browse, game: info }); } // ── Reaction to card insertion ─────────────────────────────────────────── @@ -620,6 +607,7 @@ export class GameController { // Last, and only now: a game that lives on this card AND on this PC needs an answer from the user, // and asking for one is not allowed to hold the card up (see askAboutCollisions). this.askAboutCollisions(root); + void this.steamWatch.scanNow(); return { ok: true }; } @@ -650,6 +638,7 @@ export class GameController { this.statsById.set(manifest.raw.id, await this.deps.stats.read(manifest.raw.id)); } this.refreshLibrary(); + void this.steamWatch.scanNow(); // With no card in, the local games are what the launcher has to show: leave `idle` for the first of // them instead of the empty screen. A card (or any activity) present → don't touch the state machine. if (!this.cardPresent && this.deps.state.get().kind === 'idle' && !this.sequences.inFlight) { @@ -784,19 +773,27 @@ 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 (GameMoveTransaction.moveToCard): a move started while the - * game is mid-launch would race the launcher's own manifest handling. + * Without `id`: whether the session is busy (a game launching / running / installing / uninstalling). + * With `id`: whether that game is the session's or carries an activity of its own — main's server-side + * mirror of the renderer's own isBusy (app.ts), which gates Delete on the Customize screen and Move to + * card (GameMoveTransaction.moveToCard): a move started while the game is mid-launch would race the + * launcher's own manifest handling. */ - isBusy(): boolean { - const kind = this.deps.state.get().kind; + isBusy(id?: string): boolean { + const sessionId = this.sessionGameId(); + if (id === undefined) return sessionId !== null; + return sessionId === id || this.deps.activities.has(id); + } + + /** The id of the game the session is busy with, or null while the state is settled. */ + private sessionGameId(): string | null { + const snapshot = this.deps.state.get(); // Stated as the SETTLED states rather than the busy ones (the shape UpdaterService.isBusy uses): the // list of things a game can be in the middle of grew — launching, either save sync, the Proton prefix // — and an allow-list that has to be extended for each of them is the reason those four were missing // from the deny-list this replaced, JSDoc promise of mid-launch cover notwithstanding. - const settled = kind === 'idle' || kind === 'ready' || kind === 'error'; - return !settled || this.steamBusyId !== null; + if (snapshot.kind === 'idle' || snapshot.kind === 'ready' || snapshot.kind === 'error') return null; + return snapshot.game.id; } /** Logs the local games the inserted card currently shadows (same id — the card wins, see `games`). */ @@ -946,10 +943,6 @@ export class GameController { // = false and goes idle + hide on its own. return; } - // ready / error / idle → no card. Stop any Steam re-detect poller (the card is gone; a Steam game in - // `ready` reaches here since its kind is never running/installing). - this.steamWatch.stop(); - this.steamWatch.clearUninstallRequest(); this.clearCard(); // A local game is still playable with no card in, so pulling one must not collapse the launcher to the // empty screen: stay `ready` on the first card of the row clearCard just rebuilt (NOT the first entry @@ -1022,20 +1015,15 @@ export class GameController { this.sendError(this.t('launcher.state.launchNotConfigured')); return; } - // A Steam download/removal of ANOTHER game is in flight. Every other kind of activity moves the state - // out of `ready` and is caught by the guard above; a Steam operation deliberately does not (it can run - // for hours and the window stays usable), so it needs this explicit check — otherwise a second game - // could be launched or installed on top of it from the carousel. - if (this.steamBusyId !== null && this.steamBusyId !== manifest.raw.id) { - log.info(`[launch] refused id=${manifest.raw.id}: a Steam operation is in flight for id=${this.steamBusyId}`); - this.sendError(this.t('errors.steamBusyOther')); + if (this.deps.activities.has(manifest.raw.id)) { + log.info(`[launch] refused id=${manifest.raw.id}: the game has an activity in flight`); return; } // Steam mode: not yet installed → open steam://install (fire-and-forget); otherwise launch via // steam://rungameid. Both inside runSteamInstall / runLaunchSequence's steam branch. if (manifest.steam !== undefined) { if (snapshot.game.requiresInstall) { - void this.sequences.runSteamInstall(manifest, snapshot.game); + void this.sequences.runSteamInstall(manifest); } else { void this.sequences.runLaunchSequence(manifest, snapshot.game); } @@ -1095,10 +1083,10 @@ export class GameController { if (snapshot.kind !== 'ready' || this.sequences.inFlight || this.reloadInFlight) return; const manifest = this.current(); if (manifest === null) return; - if (!snapshot.game.canUninstall) return; // nothing installed to remove + if (!snapshot.game.canUninstall || this.deps.activities.has(manifest.raw.id)) return; // nothing installed to remove // Steam: delegate removal to Steam (steam://uninstall) — fire-and-forget, the poller flips to Install. if (manifest.steam !== undefined) { - void this.sequences.runSteamUninstall(manifest, snapshot.game); + void this.sequences.runSteamUninstall(manifest); return; } if (manifest.install === undefined) { @@ -1138,9 +1126,6 @@ export class GameController { let canUninstall: boolean; let installVia: 'steam' | 'copy' | undefined; let prefixCleanupOnly = false; - let steamInstalling = false; - let steamPaused = false; - let steamPausedProgress: number | undefined; if (manifest.steam !== undefined) { // Steam mode: "installed" is Steam's own .acf state; uninstall is managed in Steam (never here). const status = await steamInstallStatus(manifest.steam.appid, this.deps.platform.steamLocator); @@ -1148,11 +1133,6 @@ export class GameController { // Steam uninstall is delegated to Steam (steam://uninstall) — available once installed. canUninstall = status.state === 'installed'; installVia = 'steam'; - // Non-blocking "Installing…" indicator while Steam is downloading (no live percent — see types.ts); - // `paused` flips it to "Installing paused on N%…" using the snapshot percent. - steamInstalling = status.state === 'downloading'; - steamPaused = status.state === 'downloading' && status.paused; - steamPausedProgress = status.state === 'downloading' ? (status.progress ?? undefined) : undefined; } else if (manifest.install !== undefined) { // Card-install mode: installed ⇔ the resolved executable exists; that also enables Uninstall. const installed = await fse.pathExists(manifest.executablePath); @@ -1195,9 +1175,6 @@ export class GameController { ...(manifest.install !== undefined ? { installDir: manifest.install.installerDir } : {}), ...(installVia !== undefined ? { installVia } : {}), ...(prefixCleanupOnly ? { prefixCleanupOnly: true } : {}), - ...(steamInstalling ? { steamInstalling: true } : {}), - ...(steamPaused ? { steamPaused: true } : {}), - ...(steamPausedProgress !== undefined ? { steamPausedProgress } : {}), ...(unavailable ? { unavailable: true } : {}), ...(unconfigured ? { unconfigured: true } : {}), }; @@ -1431,7 +1408,6 @@ export class GameController { this.selectedId = null; this.presenter.setHero(null); this.presenter.setCardMusic(null); - this.steamWatch.stop(); this.deps.state.set({ kind: 'idle' }); } diff --git a/src/main/game-sequences.ts b/src/main/game-sequences.ts index d51e30c..f948cd8 100644 --- a/src/main/game-sequences.ts +++ b/src/main/game-sequences.ts @@ -21,7 +21,7 @@ import { LaunchAbortedError } from './launch-errors'; import { openSteamUri } from './steam-uri'; import { type GameProcess, type Platform, type ProcessMonitor } from './platform'; import { normalizeImageNames } from './image-names'; -import { type SteamInstallWatch } from './steam-install-watch'; +import { type SteamActivityWatch } from './steam-activity-watch'; import { type SaveSyncFlow } from './save-sync-flow'; import { removeWithRetry } from './remove-with-retry'; import { describe, delay } from './util'; @@ -54,7 +54,6 @@ export interface SequenceHost { enterReady(info: GameInfo): void; sourceAvailable(manifest: ResolvedManifest): boolean; currentSourceAvailable(): boolean; - sourceAvailableFor(id: string): boolean; /** The "the card went away while we were busy" landing. */ cardGoneAfterSequence(): void; browseToUnlessPinned(id: string): Promise; @@ -78,8 +77,9 @@ export type SequenceDeps = Pick< | 'platform' | 'processControl' | 'getTranslator' + | 'activities' > & { - readonly steamWatch: Pick; + readonly steamWatch: Pick; /** The card↔PC save sync the launch sequence brackets the game with. */ readonly saveSync: Pick; readonly host: SequenceHost; @@ -360,12 +360,12 @@ export class GameSequences { /** * Steam install action: fire-and-forget. Opens `steam://install/` (Steam shows its own dialog * and the download — possibly hours/GBs) and returns WITHOUT entering a blocking `installing` state. - * We stay on the `ready` ("Install") screen; the background re-detect poller (started by enterReady) - * flips the button to "Play" once Steam's .acf reports the game fully installed. Steam itself collapses + * We stay on the `ready` ("Install") screen; the Steam activity watch picks the download up on its next + * poll and flips the button to "Play" once Steam's .acf reports the game fully installed. Steam itself collapses * repeated `steam://install` calls, so no debounce is needed. Pre-checks `steamLocator.locateSteam()`: openExternal * doesn't reliably reject when steam:// is unregistered. */ - async runSteamInstall(manifest: ResolvedManifest, info: GameInfo): Promise { + async runSteamInstall(manifest: ResolvedManifest): Promise { const appid = manifest.steam?.appid; if (appid === undefined) return; // defensive: onLaunchRequested only calls this in steam mode if ((await this.deps.platform.steamLocator.locateSteam()) === null) { @@ -379,24 +379,16 @@ export class GameSequences { this.host.sendError(this.t('errors.steamOpenInstall', { cause: describe(cause) })); return; } - // Ensure the re-detect poller is running so the button flips to "Play" when the download completes - // (no-op if already running; info confirms this is a steam game still requiring install). - if ( - info.installVia === 'steam' && - info.requiresInstall && - this.host.sourceAvailableFor(info.id) - ) { - this.deps.steamWatch.start(); - } + void this.deps.steamWatch.scanNow(); } /** * Steam uninstall action: fire-and-forget, mirroring runSteamInstall. Opens `steam://uninstall/` * (Steam shows its own confirmation/removal UI) and returns WITHOUT a blocking `uninstalling` state. We - * stay on the `ready` ("Play"/"Uninstall") screen; the background poller flips the button back to + * stay on the `ready` ("Play"/"Uninstall") screen; the Steam activity watch flips the button back to * "Install" once Steam removes the .acf. Pre-checks `steamLocator.locateSteam()`. */ - async runSteamUninstall(manifest: ResolvedManifest, info: GameInfo): Promise { + async runSteamUninstall(manifest: ResolvedManifest): Promise { const appid = manifest.steam?.appid; if (appid === undefined) return; // defensive: onUninstallRequested only calls this in steam mode if ((await this.deps.platform.steamLocator.locateSteam()) === null) { @@ -410,11 +402,9 @@ export class GameSequences { this.host.sendError(this.t('errors.steamOpenUninstall', { cause: describe(cause) })); return; } - // Optimistically show "Uninstalling…": record the request and flip the UI. The poller clears it when - // the .acf is gone (→ Install) or on timeout (assumed cancel → back to Play/Uninstall). enterReady - // (re)arms the poller for the inserted steam card. this.deps.steamWatch.requestUninstall(appid); - this.host.enterReady({ ...info, steamUninstalling: true, canUninstall: false }); + this.deps.activities.set(manifest.raw.id, { kind: 'steam-uninstalling' }); + void this.deps.steamWatch.scanNow(); } // ── The blocking sequences ────────────────────────────────────────────────── @@ -955,7 +945,6 @@ export class GameSequences { private abandonWatchedLaunch(game: GameInfo): void { log.info('[launch] watched game never appeared — returning without recording a session'); if (!this.host.currentSourceAvailable()) { - this.deps.steamWatch.stop(); this.host.cardGoneAfterSequence(); return; } diff --git a/src/main/main.ts b/src/main/main.ts index 83439a7..2ee2555 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -14,6 +14,7 @@ import { LibraryStore } from './library-store'; import { PcLibraryStore } from './pc-library'; import { DriveWatcher } from './drive-watcher'; import { GameController } from './game-controller'; +import { ActivityRegistry } from './activity-registry'; import { waitForExit, waitForStart, @@ -280,8 +281,11 @@ async function bootstrap(): Promise { ); windowRef = window; + const activities = new ActivityRegistry(); + activities.init((channel, payload) => window.send(channel, payload)); const controller = new GameController({ state, + activities, window, store, stats, diff --git a/src/main/steam-activity-watch.ts b/src/main/steam-activity-watch.ts new file mode 100644 index 0000000..32d9157 --- /dev/null +++ b/src/main/steam-activity-watch.ts @@ -0,0 +1,234 @@ +import { type GameActivity } from '../shared/activity'; +import { type ActivityRegistry } from './activity-registry'; +import { steamInstallStatuses, type SteamInstallStatus } from './steam'; +import { type SteamLocator } from './platform'; +import { log } from './logger'; +import { describe } from './util'; + +const DEFAULT_STEAM_POLL_MS = 5000; + +const STEAM_UNINSTALL_TIMEOUT_MS = 60_000; + +/** The game one of the completion callbacks is about — everything a notification needs to name it. */ +export interface SteamWatchGame { + readonly id: string; + readonly title: string; +} + +/** One Steam game the watch polls: its id, its title and the appid Steam knows it by. */ +export interface SteamWatchEntry extends SteamWatchGame { + readonly appid: number; +} + +/** What the watch needs from the launcher. */ +export interface SteamActivityWatchDeps { + /** Every Steam game whose source is available right now. */ + listSteamGames(): readonly SteamWatchEntry[]; + readonly registry: Pick; + steamLocator(): SteamLocator; + /** The id of the game the busy session is about, or null when no session is in flight. */ + sessionGameId(): string | null; + /** Steam changed what the launcher can do with game `id` (Install / Play / Uninstall). */ + onGameChanged(id: string): void; + /** A download that started as an install finished. */ + onInstallCompleted(game: SteamWatchGame): void; + /** A steam://uninstall the launcher requested actually removed the game. */ + onUninstallCompleted(game: SteamWatchGame): void; +} + +/** Test seams: the poll cadence and the clock the uninstall timeout is measured with. */ +export interface SteamActivityWatchOptions { + readonly intervalMs?: number; + readonly now?: () => number; +} + +type SteamState = SteamInstallStatus['state']; + +interface TrackedGame { + readonly state: SteamState; + readonly updating: boolean; +} + +/** The poll cadence: PLAYHOOK_STEAM_POLL_MS when it is a positive integer, otherwise five seconds. */ +export function steamPollIntervalMs(env: NodeJS.ProcessEnv = process.env): number { + const parsed = Number.parseInt(env['PLAYHOOK_STEAM_POLL_MS'] ?? '', 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_STEAM_POLL_MS; +} + +/** The activity a download shows: an install or an update, with the snapshot percent while it is paused. */ +function downloadActivity( + kind: 'steam-installing' | 'steam-updating', + status: Extract, +): GameActivity { + return status.paused && status.progress !== null + ? { kind, paused: true, pausedProgress: status.progress } + : { kind, paused: status.paused }; +} + +/** Whether the activity is one this watch owns (it never touches a launcher job's activity). */ +function isSteamActivity(activity: GameActivity | undefined): boolean { + return activity !== undefined && activity.kind.startsWith('steam-'); +} + +/** + * Polls Steam's `.acf` state of every available Steam game and keeps each game's Steam activity in the + * registry: downloads, updates and removals the launcher asked for, for all games at once and independently + * of what the launcher has selected. + */ +export class SteamActivityWatch { + private timer: ReturnType | null = null; + private tickInFlight = false; + private rescan = false; + private stopped = false; + private lastTick: Promise = Promise.resolve(); + private readonly tracked = new Map(); + private readonly uninstallRequests = new Map(); + private readonly intervalMs: number; + private readonly now: () => number; + + constructor( + private readonly deps: SteamActivityWatchDeps, + options: SteamActivityWatchOptions = {}, + ) { + this.intervalMs = options.intervalMs ?? steamPollIntervalMs(); + this.now = options.now ?? Date.now; + } + + /** + * Polls right away instead of waiting for the next tick; a tick in flight is followed by another one. + * Resolves once that poll has been applied (it never rejects). + */ + scanNow(): Promise { + if (this.stopped) return Promise.resolve(); + if (this.tickInFlight) { + this.rescan = true; + return this.lastTick.then(() => this.lastTick); + } + this.clearTimer(); + return this.runTick(); + } + + /** Records a steam://uninstall the launcher opened, so the game reads as uninstalling until it is gone. */ + requestUninstall(appid: number): void { + this.uninstallRequests.set(appid, this.now()); + } + + /** Stops polling for good (application exit). */ + stop(): void { + this.stopped = true; + this.clearTimer(); + this.uninstallRequests.clear(); + } + + private clearTimer(): void { + if (this.timer === null) return; + clearTimeout(this.timer); + this.timer = null; + } + + private runTick(): Promise { + this.lastTick = this.tick(); + return this.lastTick; + } + + private async tick(): Promise { + this.timer = null; + const games = this.deps.listSteamGames(); + this.forgetGone(games); + if (games.length === 0) return; + this.tickInFlight = true; + try { + const statuses = await steamInstallStatuses( + games.map((game) => game.appid), + this.deps.steamLocator(), + ); + if (statuses === null || this.stopped) return; + const stillListed = new Set(this.deps.listSteamGames().map((game) => game.id)); + for (const game of games) { + if (!stillListed.has(game.id)) continue; + this.apply(game, statuses.get(game.appid) ?? { state: 'absent' }); + } + } catch (cause) { + log.warn('[steam-watch] tick failed:', describe(cause)); + } finally { + this.tickInFlight = false; + this.rearm(); + } + } + + private rearm(): void { + if (this.stopped) return; + if (this.rescan) { + this.rescan = false; + void this.runTick(); + return; + } + if (this.deps.listSteamGames().length === 0) return; + this.timer = setTimeout(() => void this.runTick(), this.intervalMs); + } + + private forgetGone(games: readonly SteamWatchEntry[]): void { + const ids = new Set(games.map((game) => game.id)); + const appids = new Set(games.map((game) => game.appid)); + for (const id of [...this.tracked.keys()]) { + if (ids.has(id)) continue; + this.tracked.delete(id); + if (isSteamActivity(this.deps.registry.get(id))) this.deps.registry.clear(id); + } + for (const appid of [...this.uninstallRequests.keys()]) { + if (!appids.has(appid)) this.uninstallRequests.delete(appid); + } + } + + private apply(game: SteamWatchEntry, status: SteamInstallStatus): void { + if (game.id === this.deps.sessionGameId()) return; + if (this.applyUninstallRequest(game, status)) return; + const before = this.tracked.get(game.id); + const updating = status.state === 'downloading' && (before?.state === 'installed' || before?.updating === true); + this.tracked.set(game.id, { state: status.state, updating }); + if (status.state === 'downloading') { + this.deps.registry.set(game.id, downloadActivity(updating ? 'steam-updating' : 'steam-installing', status)); + } else { + this.clearSteamActivity(game.id); + } + const changed = before === undefined ? status.state === 'downloading' : before.state !== status.state; + if (!changed) return; + log.info(`[steam-watch] appid=${game.appid} ${before?.state ?? 'unknown'} → ${status.state}`); + this.deps.onGameChanged(game.id); + if (before?.state === 'downloading' && !before.updating && status.state === 'installed') { + this.deps.onInstallCompleted({ id: game.id, title: game.title }); + } + } + + /** + * Handles a steam://uninstall the launcher opened for this game. True when the request decided this tick + * (the game is still being removed, or has just been); false when there is none, or it timed out and the + * ordinary transition takes over. + */ + private applyUninstallRequest(game: SteamWatchEntry, status: SteamInstallStatus): boolean { + const since = this.uninstallRequests.get(game.appid); + if (since === undefined) return false; + if (status.state === 'installed') { + if (this.now() - since <= STEAM_UNINSTALL_TIMEOUT_MS) { + this.tracked.set(game.id, { state: 'installed', updating: false }); + this.deps.registry.set(game.id, { kind: 'steam-uninstalling' }); + return true; + } + log.info(`[steam-uninstall] appid=${game.appid} still installed after timeout — assuming cancel`); + this.uninstallRequests.delete(game.appid); + this.clearSteamActivity(game.id); + return false; + } + log.info(`[steam-uninstall] appid=${game.appid} removed`); + this.uninstallRequests.delete(game.appid); + this.tracked.set(game.id, { state: status.state, updating: false }); + this.clearSteamActivity(game.id); + this.deps.onGameChanged(game.id); + this.deps.onUninstallCompleted({ id: game.id, title: game.title }); + return true; + } + + private clearSteamActivity(id: string): void { + if (isSteamActivity(this.deps.registry.get(id))) this.deps.registry.clear(id); + } +} diff --git a/src/main/steam-install-watch.ts b/src/main/steam-install-watch.ts deleted file mode 100644 index 4f87c4a..0000000 --- a/src/main/steam-install-watch.ts +++ /dev/null @@ -1,197 +0,0 @@ -// Steam-mode background re-detect poller (split out of the GameController god-object). -// While a Steam game sits on the `ready` screen with the card present, this polls Steam's .acf state -// so the UI flips Install↔Play↔Uninstall as a (possibly hours-long) download/uninstall completes — -// including changes the user makes in Steam directly. It owns its timer, the in-flight guard and the -// optimistic uninstall request; it reaches back into the controller only through the narrow `deps` -// seam below (accessors + enterReady), so the delicate re-arm/staleness logic stays in one place. -import { type AppState, type GameInfo } from '../shared/types'; -import type { ResolvedManifest } from './manifest-types'; -import { steamInstallStatus, type SteamInstallStatus } from './steam'; -import { type SteamLocator } from './platform'; -import { log } from './logger'; -import { describe } from './util'; - -// Steam-mode background re-detect cadence: while a Steam game shows "Install" (not yet installed in -// Steam), poll its .acf state so the button flips to "Play" once the (possibly hours-long) download -// finishes. Non-blocking — no `installing` state is entered. -const STEAM_INSTALL_WATCH_INTERVAL_MS = 5000; - -// How long to keep showing "Uninstalling…" after we open steam://uninstall before giving up. Steam's -// uninstall is fire-and-forget: we can't tell "user is reading the dialog / cancelled" from "removing". -// If the .acf is still present after this window, we assume a cancel and return to "Play"/"Uninstall". -// Safe either way — the poller keeps running and will flip to "Install" if removal completes later. -const STEAM_UNINSTALL_TIMEOUT_MS = 60_000; - -/** The game one of the completion callbacks is about — everything a notification needs to name it. */ -export interface SteamWatchGame { - readonly id: string; - readonly title: string; -} - -/** The narrow view of the controller the poller needs — accessors plus the single mutation it makes. */ -export interface SteamWatchDeps { - /** The current resolved manifest (null when no card / rejected). */ - getManifest(): ResolvedManifest | null; - /** Whether a launch/install/uninstall sequence is in flight (the poller must not race it). */ - isLaunchInFlight(): boolean; - /** The current AppState snapshot. */ - getState(): AppState; - /** Whether the current game's source is available: its card is in, or it is a local game (always). */ - isSourceAvailable(): boolean; - /** Transition to `ready` with the given info (also re-arms/stops the poller, exactly as before). */ - enterReady(info: GameInfo): void; - /** Fired once when a Steam download completes (requiresInstall flips true→false) — the launcher - * notifies about the game. Not fired for an already-installed card (that never enters this poller). */ - onInstallCompleted(game: SteamWatchGame): void; - /** Fired once when a steam://uninstall WE requested has actually removed the game (its .acf is gone). - * A cancel (the timeout branch) never reaches here — nothing was removed. */ - onUninstallCompleted(game: SteamWatchGame): void; - /** Platform Steam locator (win32 registry / linux known paths), used for the .acf state read. */ - steamLocator(): SteamLocator; -} - -export class SteamInstallWatch { - // Recursive setTimeout (no overlap). Non-null only while a Steam game whose source is available is on - // the ready screen. - private timer: ReturnType | null = null; - // True while a tick is mid-flight (between nulling the timer and finishing). Prevents a concurrent - // start() (e.g. an Install/Uninstall action landing during the tick's await) from spinning up a SECOND - // poller. The tick re-arms itself in its finally. - private tickInFlight = false; - // A steam://uninstall we requested (appid + when), driving the optimistic "Uninstalling…" indicator - // until the .acf disappears or STEAM_UNINSTALL_TIMEOUT_MS elapses (assumed cancel). Null = none. - private uninstallRequest: { readonly appid: number; readonly since: number } | null = null; - - constructor(private readonly deps: SteamWatchDeps) {} - - /** (Re)starts the recursive re-detect timer. No-op if already running or a tick is in flight. */ - start(): void { - if (this.timer !== null || this.tickInFlight) return; - this.timer = setTimeout(() => void this.tick(), STEAM_INSTALL_WATCH_INTERVAL_MS); - } - - /** Stops the re-detect timer. */ - stop(): void { - if (this.timer === null) return; - clearTimeout(this.timer); - this.timer = null; - } - - /** Records an optimistic steam://uninstall request so the next tick shows "Uninstalling…". */ - requestUninstall(appid: number): void { - this.uninstallRequest = { appid, since: Date.now() }; - } - - /** Clears any pending uninstall request (card removed / shutdown). */ - clearUninstallRequest(): void { - this.uninstallRequest = null; - } - - /** - * One re-detect tick: captures the current manifest's appid, reads Steam's .acf state, and — only if - * the card/state is still the same after the await (same steam game, ready, no launch in flight) — - * reconciles the UI flags (requiresInstall/canUninstall/progress/uninstalling) by PATCHING the current - * GameInfo in place (cheap — no hero re-read). Catches install completion, uninstall completion (incl. - * an uninstall done in Steam directly), live download progress, and the uninstall-cancel timeout. - */ - private async tick(): Promise { - this.timer = null; // consumed; the finally re-arms it if we should still be polling - const manifest = this.deps.getManifest(); - const appid = manifest?.steam?.appid; - if (manifest === null || appid === undefined) return; // not steam → stop - this.tickInFlight = true; - try { - let status: SteamInstallStatus = { state: 'absent' }; - try { - status = await steamInstallStatus(appid, this.deps.steamLocator()); - } catch (cause) { - log.warn('[steam-watch] detect failed:', describe(cause)); - } - - // Re-validate everything that could have changed during the await (card swap, launch in flight). - const snapshot = this.deps.getState(); - if ( - this.deps.isLaunchInFlight() || - this.deps.getManifest() !== manifest || - snapshot.kind !== 'ready' || - snapshot.game.installVia !== 'steam' - ) { - return; // stale — drop this result (the finally re-arms iff still a steam card on ready) - } - const prev = snapshot.game; - - // Reconcile the UI flags with Steam's fresh .acf state. Only these flags change on install/uninstall; - // the rest of GameInfo (title/hero/stats) is unaffected, so we patch `prev` in place — no hero re-read. - const requiresInstall = status.state !== 'installed'; - let canUninstall = status.state === 'installed'; - const steamInstalling = status.state === 'downloading'; - const steamPaused = status.state === 'downloading' && status.paused; - const steamPausedProgress = - status.state === 'downloading' ? (status.progress ?? undefined) : undefined; - let steamUninstalling = false; - // Whether THIS tick is the one that saw our uninstall request actually take effect — the moment - // the notification belongs to. The flag is needed because the completion is decided here but the - // announcement waits for the `changed` block below, which is what makes it fire exactly once. - let uninstalled = false; - - // A requested steam://uninstall is in flight for this game. - const req = this.uninstallRequest; - if (req !== null && req.appid === appid) { - if (status.state === 'installed') { - // .acf still present: either Steam is removing files, or the user is still on / cancelled the - // dialog. Keep "Uninstalling…" until the timeout, then assume cancel and restore Play/Uninstall. - if (Date.now() - req.since > STEAM_UNINSTALL_TIMEOUT_MS) { - log.info(`[steam-uninstall] appid=${appid} still installed after timeout — assuming cancel`); - this.uninstallRequest = null; - } else { - steamUninstalling = true; - canUninstall = false; // hide Uninstall while the indicator is up - } - } else { - // .acf gone (absent/downloading) → Steam removed the game; finish the uninstall. - log.info(`[steam-uninstall] appid=${appid} removed — flipping to Install`); - this.uninstallRequest = null; - uninstalled = true; - } - } - - const changed = - prev.requiresInstall !== requiresInstall || - prev.canUninstall !== canUninstall || - (prev.steamInstalling ?? false) !== steamInstalling || - (prev.steamPaused ?? false) !== steamPaused || - prev.steamPausedProgress !== steamPausedProgress || - (prev.steamUninstalling ?? false) !== steamUninstalling; - if (changed) { - if (!steamUninstalling) { - log.info( - `[steam-watch] appid=${appid} state=${status.state}${steamPaused ? ' (paused)' : ''} → requiresInstall=${requiresInstall} canUninstall=${canUninstall}`, - ); - } - this.deps.enterReady({ - ...prev, - requiresInstall, - canUninstall, - steamInstalling, - steamPaused, - steamPausedProgress, - steamUninstalling, - }); - // Download just finished (Install→Play): notify, like the installer/copy path. Guarded by the - // prev→now transition so it fires once, not on every post-install poll. - const game = { id: prev.id, title: prev.title }; - if (prev.requiresInstall && !requiresInstall) this.deps.onInstallCompleted(game); - // …and its mirror: the game we asked Steam to remove is gone. Announced from here rather than - // from the flag flip above so it shares the once-per-transition guarantee. - if (uninstalled) this.deps.onUninstallCompleted(game); - } - } finally { - this.tickInFlight = false; - // Re-arm iff we should still be watching this steam game (mirrors enterReady's start condition). - const s = this.deps.getState(); - if (s.kind === 'ready' && s.game.installVia === 'steam' && this.deps.isSourceAvailable()) { - this.start(); - } - } - } -} diff --git a/src/main/steam.ts b/src/main/steam.ts index 9a38be8..b6bb399 100644 --- a/src/main/steam.ts +++ b/src/main/steam.ts @@ -119,6 +119,28 @@ async function readAcfState(acfPath: string): Promise { }; } +/** + * Where `appid` stands across the given Steam library roots: the `appmanifest_.acf` walk shared by + * the single lookup and the batch one below. + */ +async function statusInLibraries(appid: number, libs: readonly string[]): Promise { + let downloading: SteamInstallStatus | null = null; + for (const lib of libs) { + const acfPath = path.join(lib, 'steamapps', `appmanifest_${appid}.acf`); + const acf = await readAcfState(acfPath); + if (acf === null) continue; + if (acf.fullyInstalled) return { state: 'installed' }; + // .acf exists but not fully installed → downloading/updating in this library. The percent is only + // trustworthy when paused (byte counters are stale while actively downloading). + downloading = { + state: 'downloading', + paused: acf.paused, + progress: acf.paused ? acf.progress : null, + }; + } + return downloading ?? { state: 'absent' }; +} + /** * Where the given Steam app stands locally (per Steam's own .acf state), walking every Steam library for * `appmanifest_.acf`. Best-effort: Steam not found, no manifest, or any error → `absent`. The @@ -131,22 +153,7 @@ export async function steamInstallStatus( try { const steamPath = await locator.locateSteam(); if (steamPath === null) return { state: 'absent' }; - const libs = await steamLibraryDirs(steamPath); - let downloading: SteamInstallStatus | null = null; - for (const lib of libs) { - const acfPath = path.join(lib, 'steamapps', `appmanifest_${appid}.acf`); - const acf = await readAcfState(acfPath); - if (acf === null) continue; - if (acf.fullyInstalled) return { state: 'installed' }; - // .acf exists but not fully installed → downloading/updating in this library. The percent is only - // trustworthy when paused (byte counters are stale while actively downloading). - downloading = { - state: 'downloading', - paused: acf.paused, - progress: acf.paused ? acf.progress : null, - }; - } - return downloading ?? { state: 'absent' }; + return await statusInLibraries(appid, await steamLibraryDirs(steamPath)); } catch (cause) { log.warn( '[steam] install check failed:', @@ -155,3 +162,29 @@ export async function steamInstallStatus( return { state: 'absent' }; } } + +/** + * Where each of `appids` stands locally, from ONE Steam lookup and ONE library listing for the whole batch. + * Null when Steam is not found or the lookup fails, so a caller polling many games keeps what it last knew + * instead of reading every one of them as removed. + */ +export async function steamInstallStatuses( + appids: readonly number[], + locator: SteamLocator, +): Promise | null> { + try { + const steamPath = await locator.locateSteam(); + if (steamPath === null) return null; + const libs = await steamLibraryDirs(steamPath); + const statuses = await Promise.all( + appids.map(async (appid) => [appid, await statusInLibraries(appid, libs)] as const), + ); + return new Map(statuses); + } catch (cause) { + log.warn( + '[steam] batch install check failed:', + cause instanceof Error ? cause.message : String(cause), + ); + return null; + } +} diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 7dee398..84c6125 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -55,10 +55,13 @@ import type { } from '../shared/types'; import type { IPC } from '../shared/types'; import type { Locale } from '../shared/i18n/index'; +import type { ActivityMap } from '../shared/activity'; const CHANNELS = { stateUpdate: 'state:update', stateRequest: 'state:request', + activityUpdate: 'activity:update', + activityRequest: 'activity:request', actionLaunch: 'action:launch', actionUninstall: 'action:uninstall', actionHide: 'action:hide', @@ -170,6 +173,14 @@ const api: RendererApi = { requestState(): Promise { return ipcRenderer.invoke(CHANNELS.stateRequest) as Promise; }, + onActivityUpdate(callback: (activities: ActivityMap) => void): void { + ipcRenderer.on(CHANNELS.activityUpdate, (_event: IpcRendererEvent, activities: ActivityMap) => { + callback(activities); + }); + }, + requestActivities(): Promise { + return ipcRenderer.invoke(CHANNELS.activityRequest) as Promise; + }, requestLaunch(): void { ipcRenderer.send(CHANNELS.actionLaunch); }, diff --git a/src/renderer/app.ts b/src/renderer/app.ts index f6449e7..d41d87a 100644 --- a/src/renderer/app.ts +++ b/src/renderer/app.ts @@ -6,6 +6,7 @@ // the bits that don't belong to any one subsystem (phase attribute, info panel, title slide, music gating). // IMPORTANT: title/data come from the card (untrusted) — rendered via textContent, never innerHTML. import type { AppNotification, AppState, BrowseInfo, LibraryEntry, Stats } from '../shared/types.js'; +import type { ActivityMap } from '../shared/activity.js'; import { createTranslator, type Locale, type Translator } from '../shared/i18n/index.js'; import { localizeDocument } from './i18n-dom.js'; import { AUTO_CHAIN_MS, NAV_REPEAT_MS } from './auto-repeat.js'; @@ -24,7 +25,7 @@ import { createCardArtCache } from './card-art.js'; import { createLibraryScreen } from './library-screen.js'; import { createToast } from './toast.js'; import { formatDate, formatNotification, formatPlaytime } from './format.js'; -import { busyKindOf, gameOf, phaseOf, statusOf, steamBusy } from './state-view.js'; +import { activityBusyKind, activityStatus, busyIds, gameOf, phaseOf, screenActivityOf, statusOf } from './state-view.js'; import { req } from './dom.js'; const app = req('app'); @@ -38,6 +39,7 @@ let currentState: AppState = { kind: 'idle' }; // stays the truth for the PHASE (busy/ready/error) and the status text. null = neither card nor history, // i.e. the genuine "Insert a game card" screen. See BrowseInfo in shared/types. let currentBrowse: BrowseInfo | null = null; +let currentActivities: ActivityMap = {}; // The carousel hid the title + status for a pending selection change; the next render reveals the new one. let textSwapPending = false; // A direction is being held. While it is, the title/status stay hidden rather than being re-revealed on @@ -224,11 +226,11 @@ const gameSettingsScreen = createGameSettingsScreen({ showError: (text) => controls.showError(text), // 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: () => + isBusy: (id) => currentState.kind === 'running' || currentState.kind === 'installing' || currentState.kind === 'uninstalling' || - steamBusy(currentState), + currentActivities[id] !== undefined, }); // ── The notification toast (see toast.ts) ──────────────────────────────────── @@ -293,6 +295,7 @@ const controls = createControls({ // first press cannot arrive before it exists. isBooting: () => !boot.isRevealed(), getBrowse: () => currentBrowse, + getScreenActivity: () => screenActivityOf(currentBrowse, currentActivities), audio, getTranslator, settings: settingsScreen, @@ -557,7 +560,7 @@ function buildInfoPanel(stats: Stats): void { // ── Title / status busy layout ────────────────────────────────────────────── // While busy (or during a Steam install/uninstall indicator) the title drops DOWN and the status line // fades in above it — a two-line block that keeps the long title fully visible (it no longer slides -// right into the More button). Both moves are pure CSS, keyed off #app[data-phase]/[data-steam-busy] +// right into the More button). Both moves are pure CSS, keyed off #app[data-phase]/[data-status] // (see styles.css), so there's no JS measurement here anymore. // ── Background music gating ────────────────────────────────────────────────── @@ -588,6 +591,8 @@ function applyStatus(): void { /** The status line for what is ON SCREEN — empty while looking at a game the state isn't about. */ function statusText(): string { + const activity = screenActivityOf(currentBrowse, currentActivities); + if (activity !== undefined) return activityStatus(activity, translator); const subject = gameOf(currentState)?.id; // Nothing on screen is a state of its own now — a launcher card — and the state's status belongs to a // game, so it says nothing there: "Installing…" under "Settings" would be a lie, and the line's mere @@ -650,14 +655,12 @@ function render(state: AppState): void { // screen; stop otherwise, e.g. on the idle screen). Idempotent — see hero.startRotation. hero.startRotation(); - // Steam non-blocking install/uninstall indicator: reuse the busy visuals (loader/status/slid title) - // via a dedicated attribute, while the logical phase stays 'ready' (window hideable, card pullable). - const busySteam = steamBusy(state); - if (busySteam) app.dataset['steamBusy'] = 'true'; - else delete app.dataset['steamBusy']; + const screenActivity = screenActivityOf(browse, currentActivities); + if (screenActivity !== undefined) app.dataset['activity'] = screenActivity.kind; + else delete app.dataset['activity']; // Play-button busy visual: gear (system activity) vs spinner (game phases). Absent when not busy. - const busyKind = busyKindOf(state); + const busyKind = activityBusyKind(screenActivity, state); if (busyKind !== 'none') app.dataset['busy'] = busyKind; else delete app.dataset['busy']; @@ -677,7 +680,7 @@ function render(state: AppState): void { browse.active && browse.game?.unavailable !== true && browse.game?.unconfigured !== true && - !(phase === 'ready' && browse.game?.requiresInstall === true && !busySteam); + !(phase === 'ready' && browse.game?.requiresInstall === true && screenActivity === undefined); app.dataset['cardMorph'] = hasPlay ? 'on' : 'off'; // Only on the detail screen: in the carousel Play is hidden anyway, and the attribute's `.title{left:0}` @@ -688,9 +691,9 @@ function render(state: AppState): void { // The busy game keeps a pulsing dot on its own card, so "game A is installing" stays visible while you // browse game B (whose status line is blank — see applyStatus). - const busyGame = phase === 'busy' || busySteam ? (gameOf(state)?.id ?? null) : null; - carousel.setBusyGame(busyGame); - libraryScreen.setBusyGame(busyGame); + const busy = busyIds(state, currentActivities); + carousel.setBusyGames(busy); + libraryScreen.setBusyGames(busy); chatter.sync(state); applyStatus(); @@ -741,6 +744,15 @@ function applyLocale(locale: Locale): void { window.api.onLanguageUpdate(applyLocale); void window.api.getLanguage().then(applyLocale); +window.api.onActivityUpdate((activities) => { + currentActivities = activities; + render(currentState); +}); +void window.api.requestActivities().then((activities) => { + currentActivities = activities; + render(currentState); +}); + window.api.onStateUpdate(render); void window.api.requestState().then((state) => { render(state); diff --git a/src/renderer/carousel.ts b/src/renderer/carousel.ts index 5837408..68f34f4 100644 --- a/src/renderer/carousel.ts +++ b/src/renderer/carousel.ts @@ -27,6 +27,7 @@ import { systemCardIcon } from './system-card-icons.js'; import { req, reqCanvas } from './dom.js'; import { FALLBACK_COLOUR, JELLY, createFocusJelly, jellyBoxOf } from './focus-jelly.js'; import { pxUnit } from './screen-scroller.js'; +import { sameIds } from './state-view.js'; /** The two levels of the launcher screen (mirrors `#app[data-screen]`). */ export type Screen = 'carousel' | 'detail'; @@ -98,8 +99,8 @@ export interface Carousel { * never arrive, and the row sits there under the launcher's idle background. */ announce(): void; - /** Marks the game AppState is busy with, so its card can pulse wherever it sits in the list. */ - setBusyGame(id: string | null): void; + /** Marks the games that are busy (an activity or the session), so their cards pulse wherever they sit. */ + setBusyGames(ids: ReadonlySet): void; /** Whether the inbox holds anything unread — the Notifications card wears the same dot a game does. */ setUnread(unread: boolean): void; /** @@ -153,7 +154,7 @@ export function createCarousel(deps: CarouselDeps): Carousel { let items: readonly CarouselItem[] = [...systemItems]; let index = 0; let screen: Screen = 'detail'; - let busyId: string | null = null; + let busyIds: ReadonlySet = new Set(); // Whether the inbox holds unread entries (main's push, relayed by app.ts) — the Notifications card's dot. let unread = false; // While the strip is coming back from the detail screen the selected card is still growing out of the @@ -202,7 +203,7 @@ export function createCarousel(deps: CarouselDeps): Carousel { */ function showsDot(item: CarouselItem): boolean { if (item.kind === 'system') return item.card.id === 'notifications' && unread; - return (item.game.active && item.game.unconfigured !== true) || item.game.id === busyId; + return (item.game.active && item.game.unconfigured !== true) || busyIds.has(item.game.id); } /** @@ -269,7 +270,7 @@ export function createCarousel(deps: CarouselDeps): Carousel { const card = cards.get(key); if (card === undefined) return; card.classList.toggle('is-selected', key === currentKey); - card.classList.toggle('is-busy', item.kind === 'game' && item.game.id === busyId); + card.classList.toggle('is-busy', item.kind === 'game' && busyIds.has(item.game.id)); card.classList.toggle('shows-dot', showsDot(item)); // Past the shown window (see VISIBLE_CARDS): still laid out — the strip's offset is positional and // a removed node would shift every card after it — but faded out, so it slides in softly when the @@ -599,9 +600,9 @@ export function createCarousel(deps: CarouselDeps): Carousel { announce(): void { announceSelection(); }, - setBusyGame(id: string | null): void { - if (id === busyId) return; - busyId = id; + setBusyGames(ids: ReadonlySet): void { + if (sameIds(ids, busyIds)) return; + busyIds = ids; applyLayout(); }, }; diff --git a/src/renderer/controls-deps.ts b/src/renderer/controls-deps.ts index 5bc5fb4..9a25c35 100644 --- a/src/renderer/controls-deps.ts +++ b/src/renderer/controls-deps.ts @@ -8,6 +8,7 @@ import type { ConfigSaveResult, GameCollisionAnswer, } from '../shared/types.js'; +import type { GameActivity } from '../shared/activity.js'; import type { Locale, MessageKey, Translator } from '../shared/i18n/index.js'; import type { AudioController } from './audio.js'; import type { MoveResult } from './carousel.js'; @@ -53,6 +54,8 @@ export interface ControlsDeps { * be showing a history game — pressing Play there would launch someone else. */ getBrowse(): BrowseInfo | null; + /** The activity of the game on screen (a Steam download, a removal), or undefined when it is free. */ + getScreenActivity(): GameActivity | undefined; /** The shared audio controller (UI sounds). */ audio: AudioController; /** The current translator (read live so menu/confirm copy follows the language). */ diff --git a/src/renderer/controls.ts b/src/renderer/controls.ts index 990f631..a891dd1 100644 --- a/src/renderer/controls.ts +++ b/src/renderer/controls.ts @@ -15,7 +15,7 @@ import { createPopups } from './popups.js'; import { createHoverGuard } from './hover-guard.js'; import type { NavSurface } from './nav-surface.js'; import type { SystemCardId } from './system-cards.js'; -import { gameOf, phaseOf, steamBusy } from './state-view.js'; +import { gameOf, opensSteamDownloads, phaseOf } from './state-view.js'; import { pressFlash, req } from './dom.js'; import type { GameCollision } from '../shared/types.js'; import type { ControlsDeps } from './controls-deps.js'; @@ -211,6 +211,7 @@ export function createControls(deps: ControlsDeps): Controls { hover, screenGame, screenIsActionable, + screenActivity: () => deps.getScreenActivity(), openGameDetail: (id) => deps.openGameDetail(id), onPopupClosed: () => deps.onPopupClosed(), onFocusChanged: () => applyFocus(), @@ -236,7 +237,7 @@ export function createControls(deps: ControlsDeps): Controls { if (deps.carousel.screen() === 'carousel') return []; // Steam install/uninstall indicator up (phase stays 'ready'): the gear opens Steam's Downloads page // and More opens Details — both focusable. - if (steamBusy(state())) return [playButton, moreButton]; + if (deps.getScreenActivity() !== undefined) return [playButton, moreButton]; // Running with the launcher summoned over the game: Play returns to the game, so it's focusable too — // EXCEPT while a force-close is in flight (killing), when Play is a non-interactive loading spinner. const running = state(); @@ -317,6 +318,14 @@ export function createControls(deps: ControlsDeps): Controls { function triggerPlay(): void { if (!focusActive()) return; // the bar is not the surface driving the press — not a dead end + // Steam download in progress: the gear opens Steam's Downloads page, where the user can + // pause/resume (we can't control that programmatically). + if (opensSteamDownloads(deps.getScreenActivity())) { + audio.play('button'); + return deps.api.openSteamDownloads(); + } + // Steam uninstall in progress (gear) → nothing useful to do, ignore the press. + if (deps.getScreenActivity() !== undefined) return audio.playLimit(); // Play acts on the game AppState is about, so it must be the one on screen: a history game has // nothing to launch, and while you browse game B, "Play" must not start game A behind your back. if (!screenIsActionable()) return audio.playLimit(); @@ -325,15 +334,6 @@ export function createControls(deps: ControlsDeps): Controls { if (game?.unavailable === true) return audio.playLimit(); // A local game with no launch method configured yet: same dead end, different reason. if (game?.unconfigured === true) return audio.playLimit(); - // Steam download in progress: the gear opens Steam's Downloads page, where the user can - // pause/resume (we can't control that programmatically). - if (game?.steamInstalling === true) { - audio.play('button'); - deps.api.openSteamDownloads(); - return; - } - // Steam uninstall in progress (gear) → nothing useful to do, ignore the press. - if (game?.steamUninstalling === true) return audio.playLimit(); // Force-close in flight: Play is a loading spinner, not return-to-game — ignore the press. const s = state(); if (s.kind === 'running' && s.killing === true) return audio.playLimit(); @@ -730,7 +730,7 @@ export function createControls(deps: ControlsDeps): Controls { popups.refresh(); // When an active state (install / launch / uninstall / steam) APPEARS, drop the bar highlight so it // doesn't sit on a button the user didn't choose. It wakes again on a gamepad move or a mouse hover. - const active = phaseOf(state()) === 'busy' || steamBusy(state()); + const active = phaseOf(state()) === 'busy' || deps.getScreenActivity() !== undefined; if (active && !wasActive) focusRevealed = false; wasActive = active; applyFocus(); diff --git a/src/renderer/details-menu.ts b/src/renderer/details-menu.ts index b6aee87..edfdb58 100644 --- a/src/renderer/details-menu.ts +++ b/src/renderer/details-menu.ts @@ -3,10 +3,11 @@ // has no tray for. Their visibility rules live here; the popup (popups.ts) owns the stack they sit in, // which is why it can hold them still during its fade-out (`isFrozen`). import type { AppState, BrowseInfo, GameInfo } from '../shared/types.js'; +import type { GameActivity } from '../shared/activity.js'; import type { Translator } from '../shared/i18n/index.js'; import type { CarouselNav } from './controls-deps.js'; import { req } from './dom.js'; -import { phaseOf, steamBusy } from './state-view.js'; +import { phaseOf } from './state-view.js'; export interface DetailsMenuDeps { getState(): AppState; @@ -15,6 +16,8 @@ export interface DetailsMenuDeps { readonly carousel: Pick; screenGame(): GameInfo | undefined; screenIsActionable(): boolean; + /** The activity of the game on screen, or undefined when it is free. */ + screenActivity(): GameActivity | undefined; /** The popup is fading out: its items must not rewrite themselves in view (see popups.ts). */ isFrozen(): boolean; } @@ -61,7 +64,7 @@ export function createDetailsMenu(deps: DetailsMenuDeps): DetailsMenu { const game = screenIsActionable() ? screenGame() : undefined; // While an install/uninstall (card or Steam) is in flight, the Install/Uninstall item is hidden — // acting on it mid-operation makes no sense (Details still opens for the stats + power actions). - const busy = phaseOf(state()) === 'busy' || steamBusy(state()); + const busy = phaseOf(state()) === 'busy' || deps.screenActivity() !== undefined; const showInstall = !busy && game?.requiresInstall === true; const showUninstall = !busy && game?.canUninstall === true; const show = showInstall || showUninstall; diff --git a/src/renderer/game-settings-screen.ts b/src/renderer/game-settings-screen.ts index 0ae7b89..a92b93b 100644 --- a/src/renderer/game-settings-screen.ts +++ b/src/renderer/game-settings-screen.ts @@ -191,8 +191,8 @@ export interface GameSettingsScreenDeps { * title with X?" is about a candidate the popup has never heard of. */ onConfirmRequested(kind: GameSettingsConfirm, options?: { readonly title?: string }): void; - /** Whether the game is running / installing / being force-closed — Delete is hidden then. */ - isBusy(): boolean; + /** Whether game `id` is running / installing / carries an activity — Delete is hidden then. */ + isBusy(id: string): boolean; /** A game was added AND applied: the launcher's library has it now, so the carousel goes to it. */ onAdded(id: string): void; /** @@ -531,7 +531,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett // Deleting a history game would mean deleting it from a card that is not here. "Remove from history" // in the carousel menu is the action that DOES apply there, and it is a different thing entirely. const at = mediaOrigin(); - if (at === null || deps.isBusy()) return false; + if (at === null || deps.isBusy(gameId)) return false; return at.source === 'pc' ? true : slots.length >= 2; } @@ -540,7 +540,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett function canMove(): boolean { if (pendingMove !== null) return false; const at = mediaOrigin(); - if (at === null || deps.isBusy()) return false; + if (at === null || deps.isBusy(gameId)) return false; return at.source === 'pc'; } diff --git a/src/renderer/library-screen.ts b/src/renderer/library-screen.ts index aa67367..8f6d66b 100644 --- a/src/renderer/library-screen.ts +++ b/src/renderer/library-screen.ts @@ -26,6 +26,7 @@ import { import type { NavSurface } from './nav-surface.js'; import { createScroller, pxUnit } from './screen-scroller.js'; import { createSidebar, type SidebarEntry } from './screen-sidebar.js'; +import { sameIds } from './state-view.js'; /** * How long the grid takes to scroll one row on a SINGLE press — the morph's own duration, so the glide @@ -70,8 +71,8 @@ export interface LibraryScreen extends NavSurface { close(silent?: boolean): void; /** A new game list from main (a card went in or out) — the grid re-flows, the selection stays put. */ setGames(games: readonly LibraryEntry[]): void; - /** The game AppState is busy with, so its dot pulses here as it does on the carousel. */ - setBusyGame(id: string | null): void; + /** The busy games (an activity or the session), so their dots pulse here as they do on the carousel. */ + setBusyGames(ids: ReadonlySet): void; /** A direction is HELD: artwork loading waits it out, exactly as it does in the carousel. */ setFlipping(flipping: boolean): void; /** The cover this screen already decoded, for the play button's morph (see carousel.primeArt). */ @@ -100,7 +101,7 @@ export function createLibraryScreen(deps: LibraryScreenDeps): LibraryScreen { let shown: readonly LibraryEntry[] = []; let index = 0; let cols = 1; - let busyId: string | null = null; + let busyIds: ReadonlySet = new Set(); let flipping = false; // A list arrived while the screen was away. The grid is NOT re-flowed then: it is still on screen, // fading out under the detail screen, and cards moving during that fade is what read as a twitch. @@ -329,9 +330,9 @@ export function createLibraryScreen(deps: LibraryScreenDeps): LibraryScreen { node.classList.toggle('is-selected', active && at === index); node.classList.toggle( 'shows-dot', - (game.active && game.unconfigured !== true) || game.id === busyId, + (game.active && game.unconfigured !== true) || busyIds.has(game.id), ); - node.classList.toggle('is-busy', game.id === busyId); + node.classList.toggle('is-busy', busyIds.has(game.id)); }); placeJelly(instant); const selectedNode = active && current !== undefined ? nodeOf(current) : undefined; @@ -749,9 +750,9 @@ export function createLibraryScreen(deps: LibraryScreenDeps): LibraryScreen { // …unless this list is the one carrying the game the screen was opened for. focusPending(); }, - setBusyGame: (id) => { - if (id === busyId) return; - busyId = id; + setBusyGames: (ids) => { + if (sameIds(ids, busyIds)) return; + busyIds = ids; if (open) applyLayout(); }, setFlipping: (next) => { diff --git a/src/renderer/popups.ts b/src/renderer/popups.ts index a81a538..2cd72e6 100644 --- a/src/renderer/popups.ts +++ b/src/renderer/popups.ts @@ -13,6 +13,7 @@ import type { GameCollision, GameInfo, } from '../shared/types.js'; +import type { GameActivity } from '../shared/activity.js'; import type { Locale, MessageKey, Translator } from '../shared/i18n/index.js'; import type { AudioController } from './audio.js'; import { @@ -72,6 +73,8 @@ export interface PopupsDeps { */ screenGame(): GameInfo | undefined; screenIsActionable(): boolean; + /** The activity of the game on screen, or undefined when it is free. */ + screenActivity(): GameActivity | undefined; /** Opens a game's detail screen (a notification about a game leads there). Owned by app.ts. */ openGameDetail(id: string): void; /** The popup finished closing. The toast shares this corner and holds its queue while it is up. */ @@ -125,6 +128,7 @@ export function createPopups(deps: PopupsDeps): Popups { carousel: deps.carousel, screenGame, screenIsActionable, + screenActivity: () => deps.screenActivity(), isFrozen: () => menuFrozen(), }); diff --git a/src/renderer/state-view.ts b/src/renderer/state-view.ts index ac01c18..bf6368e 100644 --- a/src/renderer/state-view.ts +++ b/src/renderer/state-view.ts @@ -1,7 +1,8 @@ // Pure views over AppState shared by the renderer modules. No DOM — just the mapping from a -// state to the UI phase, the status label, the Steam-busy flag and the current game. Kept in one place +// state to the UI phase, the status label and the current game. Kept in one place // so app.ts (render/title-slide) and controls.ts (focus/actions) read the same derivations. -import type { AppState, GameInfo } from '../shared/types.js'; +import type { AppState, BrowseInfo, GameInfo } from '../shared/types.js'; +import type { ActivityMap, GameActivity } from '../shared/activity.js'; import type { Translator } from '../shared/i18n/index.js'; export type Phase = 'idle' | 'ready' | 'busy' | 'error'; @@ -52,16 +53,6 @@ export function statusOf(state: AppState, t: Translator): string { // A local (PC) game whose files are gone: the card stays in the library, but there is nothing to // launch, so say so instead of leaving an empty status under a dead Play button. if (state.game.unavailable === true) return t('launcher.state.gameFilesMissing'); - // Steam non-blocking install/uninstall indicators on the ready screen (the window stays usable). - // No install percent: Steam exposes no reliable live progress in the files we read (see main). - if (state.game.steamUninstalling === true) return t('launcher.state.uninstalling'); - if (state.game.steamInstalling === true) { - if (state.game.steamPaused !== true) return t('launcher.state.installing'); - const progress = state.game.steamPausedProgress; - return progress === undefined - ? t('launcher.state.installingPaused') - : t('launcher.state.installingPausedPercent', { percent: Math.round(progress * 100) }); - } return ''; } default: @@ -71,7 +62,7 @@ export function statusOf(state: AppState, t: Translator): string { // Which busy visual the Play button shows, by the design's semantics: a rotating GEAR for system // activity (install/uninstall, incl. Steam), a SPINNER arc for game phases (launch/save-sync/running). -// 'none' → not busy (the play triangle). Drives #app[data-busy] in app.ts. (steamBusy is hoisted.) +// 'none' → not busy (the play triangle). Drives #app[data-busy] in app.ts. export type BusyKind = 'none' | 'system' | 'game' | 'running'; export function busyKindOf(state: AppState): BusyKind { @@ -89,20 +80,57 @@ export function busyKindOf(state: AppState): BusyKind { // is in flight (killing) — then Play is a loading spinner, like the other game phases. See app.ts / styles.css. case 'running': return state.killing === true ? 'game' : 'running'; - case 'ready': - // Steam download/uninstall is non-blocking system activity on the (still) ready screen. - return steamBusy(state) ? 'system' : 'none'; default: return 'none'; } } -// True while a Steam install (download) or uninstall is in progress: a non-blocking indicator on the -// (still) ready screen — the busy visuals (loader + status + slid title) are reused via -// #app[data-steam-busy], NOT the busy phase. -export function steamBusy(state: AppState): boolean { - if (state.kind !== 'ready') return false; - return state.game.steamInstalling === true || state.game.steamUninstalling === true; +/** The status line of a game's activity. No live install percent: Steam exposes none (see main). */ +export function activityStatus(activity: GameActivity, t: Translator): string { + switch (activity.kind) { + case 'queued': + return ''; + case 'installing': + return t('launcher.state.installing'); + case 'configuringProton': + return t('launcher.protonConfig1'); + case 'uninstalling': + case 'steam-uninstalling': + return t('launcher.state.uninstalling'); + case 'steam-updating': + return t(activity.paused ? 'launcher.state.updatingPaused' : 'launcher.state.updating'); + case 'steam-installing': + if (!activity.paused) return t('launcher.state.installing'); + return activity.pausedProgress === undefined + ? t('launcher.state.installingPaused') + : t('launcher.state.installingPausedPercent', { percent: Math.round(activity.pausedProgress * 100) }); + } +} + +/** The Play button's busy visual for the game on screen: the gear for its activity, else the session's. */ +export function activityBusyKind(activity: GameActivity | undefined, state: AppState): BusyKind { + return activity === undefined ? busyKindOf(state) : 'system'; +} + +/** The activity of the game on screen, or undefined when it is free or nothing is on screen. */ +export function screenActivityOf(browse: BrowseInfo | null, activities: ActivityMap): GameActivity | undefined { + return browse === null ? undefined : activities[browse.id]; +} + +/** Whether Play on a game with this activity opens Steam's Downloads page (pause/resume live there). */ +export function opensSteamDownloads(activity: GameActivity | undefined): boolean { + return activity?.kind === 'steam-installing' || activity?.kind === 'steam-updating'; +} + +/** The games whose cards pulse: every game with an activity plus the game of a busy session. */ +export function busyIds(state: AppState, activities: ActivityMap): ReadonlySet { + const session = phaseOf(state) === 'busy' ? gameOf(state)?.id : undefined; + return new Set([...Object.keys(activities), ...(session === undefined ? [] : [session])]); +} + +/** Whether two id sets hold the same ids. */ +export function sameIds(a: ReadonlySet, b: ReadonlySet): boolean { + return a.size === b.size && [...a].every((id) => b.has(id)); } export function gameOf(state: AppState): GameInfo | undefined { diff --git a/src/shared/i18n/en.ts b/src/shared/i18n/en.ts index 6e9ec18..1dc9af2 100644 --- a/src/shared/i18n/en.ts +++ b/src/shared/i18n/en.ts @@ -150,6 +150,8 @@ export const en = { 'launcher.state.syncingOut': 'Saving progress...', 'launcher.state.installingPaused': 'Installing paused...', 'launcher.state.installingPausedPercent': 'Installing paused on {percent}%...', + 'launcher.state.updating': 'Updating...', + 'launcher.state.updatingPaused': 'Updating paused...', // ── Display formatters (format.ts) ────────────────────────────────────────── 'format.never': 'never', @@ -474,7 +476,6 @@ export const en = { 'errors.finishBeforeApply': 'Finish what’s running before applying the config', 'errors.reloadInProgress': 'a reload is already in progress', 'errors.steamNotInstalled': 'Steam is not installed', - 'errors.steamBusyOther': 'Another game is being downloaded or removed in Steam. Wait for it to finish.', 'errors.steamOpenInstall': 'failed to open Steam install: {cause}', 'errors.steamOpenDownloads': 'failed to open Steam downloads: {cause}', 'errors.steamOpenUninstall': 'failed to open Steam uninstall: {cause}', diff --git a/src/shared/i18n/ru.ts b/src/shared/i18n/ru.ts index dbaddf0..735732e 100644 --- a/src/shared/i18n/ru.ts +++ b/src/shared/i18n/ru.ts @@ -123,6 +123,8 @@ export const ru: Record = { 'launcher.state.syncingOut': 'Сохранение прогресса...', 'launcher.state.installingPaused': 'Установка приостановлена...', 'launcher.state.installingPausedPercent': 'Установка приостановлена на {percent}%...', + 'launcher.state.updating': 'Обновление...', + 'launcher.state.updatingPaused': 'Обновление приостановлено...', // ── Display formatters ─────────────────────────────────────────────────────── 'format.never': 'никогда', @@ -427,8 +429,6 @@ export const ru: Record = { 'errors.finishBeforeApply': 'Завершите текущие операции перед применением конфигурации', 'errors.reloadInProgress': 'перезагрузка уже выполняется', 'errors.steamNotInstalled': 'Steam не установлен', - 'errors.steamBusyOther': - 'В Steam сейчас качается или удаляется другая игра. Дождитесь завершения.', 'errors.steamOpenInstall': 'не удалось открыть установку в Steam: {cause}', 'errors.steamOpenDownloads': 'не удалось открыть загрузки Steam: {cause}', 'errors.steamOpenUninstall': 'не удалось открыть удаление в Steam: {cause}', diff --git a/src/shared/types.ts b/src/shared/types.ts index 5b0d0c0..9f12b27 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -4,6 +4,7 @@ // the file-name constants) lives next to its implementation under `src/main/` instead. import type { Locale } from './i18n/index'; import type { ArtworkQuality } from './artwork-filter'; +import type { ActivityMap } from './activity'; // ── Card format: the few facts both sides must agree on ─────────────────────────────────────── @@ -202,31 +203,6 @@ export interface GameInfo { * the "uninstall from PC" one. Undefined for install/steam/copy modes and on Windows. */ readonly prefixCleanupOnly?: boolean; - /** - * Steam mode only: a download/update is in progress (the `.acf` exists but isn't fully installed). - * Drives a non-blocking "Installing…" indicator — NOT a blocking `installing` state (a Steam download - * can run for hours; the window stays usable). No percent: Steam exposes no reliable real-time - * progress in the files we can read (see steam.ts AcfState). Undefined when not downloading. - */ - readonly steamInstalling?: boolean; - /** - * Steam mode only: the in-progress download is PAUSED (Steam's `UpdateResult` is non-zero). Only - * meaningful together with `steamInstalling` — flips the indicator text to "Installing paused…". - */ - readonly steamPaused?: boolean; - /** - * Steam mode only: completion fraction (0..1) captured at pause. Steam's byte counters are only fresh - * while paused, so this is present ONLY with `steamPaused` (and may still be absent if uncomputable) — - * renders as "Installing paused on N%…". - */ - readonly steamPausedProgress?: number; - /** - * Steam mode only: a Steam uninstall we requested is in progress. Drives a non-blocking - * "Uninstalling…" indicator (no percentage — removal isn't a download). Set optimistically right - * after opening `steam://uninstall`; cleared when Steam drops the `.acf` (→ "Install") or, if the - * user cancelled Steam's dialog, by a timeout in the background poller (→ back to "Play"/"Uninstall"). - */ - readonly steamUninstalling?: boolean; /** * PC mode only: the game's executable is not on disk right now (deleted, or an external drive is * unplugged). The card stays in the library with its art, stats and save backup — only Play is @@ -486,6 +462,10 @@ export const IPC = { stateUpdate: 'state:update', /** renderer → main: request the current state (on window startup). */ stateRequest: 'state:request', + /** main → renderer: every game's background activity (ActivityMap), pushed in full on each change. */ + activityUpdate: 'activity:update', + /** renderer → main (invoke): the current ActivityMap, seeded on window startup. */ + activityRequest: 'activity:request', /** main → game-renderer: the launcher window gained (true) / lost (false) OS focus. The renderer gates * gamepad input on this so a BACKGROUND launcher (e.g. under gamescope, where Chromium keeps feeding the * unfocused window gamepad input) doesn't act on presses meant for the running game. */ @@ -1196,6 +1176,9 @@ export interface RendererApi { /** Launcher window focus changes (true = foreground). Used to gate gamepad input while backgrounded. */ onWindowFocus(callback: (focused: boolean) => void): void; requestState(): Promise; + /** Every game's background activity, pushed in full on each change. */ + onActivityUpdate(callback: (activities: ActivityMap) => void): void; + requestActivities(): Promise; requestLaunch(): void; requestUninstall(): void; requestHide(): void; diff --git a/test/game-controller.test.ts b/test/game-controller.test.ts index 4ac81fa..f198147 100644 --- a/test/game-controller.test.ts +++ b/test/game-controller.test.ts @@ -24,6 +24,8 @@ import { type ProcessControl, } from '../src/main/controller-deps'; import { StateManager } from '../src/main/state'; +import { ActivityRegistry } from '../src/main/activity-registry'; +import type { ActivityMap } from '../src/shared/activity'; import { LaunchAbortedError } from '../src/main/launch-errors'; import { DEFAULT_SETTINGS } from '../src/main/app-settings'; import { createTranslator } from '../src/shared/i18n/index'; @@ -113,8 +115,14 @@ interface Harness { readonly browsedId: () => string | null; /** Drops a game from what the fake PC library reads next — the file after a delete was saved. */ readonly removeLocalGame: (id: string) => void; + /** Every game's activity (the controller's registry). */ + readonly activities: ActivityRegistry; + /** Rewrites the Steam game's `.acf` — the way Steam reports a download starting or finishing. */ + readonly setSteamState: (state: SteamAcfState) => Promise; } +type SteamAcfState = 'installed' | 'downloading'; + type Mode = 'normal' | 'install' | 'prefix-cleanup' | 'steam'; /** @@ -134,18 +142,26 @@ interface HarnessOptions { * the row orders by that, so the file's first game need not be the row's. */ readonly extraGames?: readonly { readonly id: string; readonly lastPlayedAt: string }[]; + /** What the Steam game's `.acf` says at startup (steam mode only; installed by default). */ + readonly steamState?: SteamAcfState; } const STEAM_APPID = 480; -/** A Steam root whose one library reports `appid` fully installed — enough for steamInstallStatus. */ -async function fakeSteamRoot(tmp: string, appid: number): Promise { - const root = path.join(tmp, 'steam'); - await fs.mkdir(path.join(root, 'steamapps'), { recursive: true }); +/** Writes the one library's `.acf` of `appid`: StateFlags 4 is fully installed, 1026 a download. */ +async function writeSteamAcf(root: string, appid: number, state: SteamAcfState): Promise { + const flags = state === 'installed' ? 4 : 1026; await fs.writeFile( path.join(root, 'steamapps', `appmanifest_${appid}.acf`), - `"AppState"\n{\n\t"appid"\t\t"${appid}"\n\t"StateFlags"\t\t"4"\n}\n`, + `"AppState"\n{\n\t"appid"\t\t"${appid}"\n\t"StateFlags"\t\t"${flags}"\n}\n`, ); +} + +/** A Steam root whose one library reports `appid` in `state` — enough for steamInstallStatus. */ +async function fakeSteamRoot(tmp: string, appid: number, state: SteamAcfState): Promise { + const root = path.join(tmp, 'steam'); + await fs.mkdir(path.join(root, 'steamapps'), { recursive: true }); + await writeSteamAcf(root, appid, state); return root; } @@ -159,7 +175,8 @@ async function harness(opts: HarnessOptions): Promise { if (opts.mode === 'prefix-cleanup' && opts.sweepDir === undefined) { await fs.mkdir(installDir, { recursive: true }); } - const steamRoot = opts.mode === 'steam' ? await fakeSteamRoot(tmp, STEAM_APPID) : null; + const steamRoot = + opts.mode === 'steam' ? await fakeSteamRoot(tmp, STEAM_APPID, opts.steamState ?? 'installed') : null; const executablePath = opts.mode === 'install' ? path.join(installDir, 'game.exe') : path.join(gameDir, 'game.exe'); const baseRaw = { @@ -221,6 +238,15 @@ async function harness(opts: HarnessOptions): Promise { const journal: string[] = []; const state = new StateManager(); state.subscribe((next) => journal.push(`state:${next.kind}`)); + const activities = new ActivityRegistry(); + let lastActivities: ActivityMap = {}; + activities.subscribe((next) => { + for (const id of new Set([...Object.keys(lastActivities), ...Object.keys(next)])) { + const kind = next[id]?.kind ?? 'clear'; + if (kind !== (lastActivities[id]?.kind ?? 'clear')) journal.push(`activity:${id}:${kind}`); + } + lastActivities = next; + }); const h: { failStats: boolean; exitRequested: boolean; @@ -389,6 +415,7 @@ async function harness(opts: HarnessOptions): Promise { }; const deps: ControllerDeps = { state, + activities, window, store, stats, @@ -442,6 +469,10 @@ async function harness(opts: HarnessOptions): Promise { removeLocalGame: (id) => { localGames = localGames.filter((game) => game.raw.id !== id); }, + activities, + setSteamState: async (next) => { + if (steamRoot !== null) await writeSteamAcf(steamRoot, STEAM_APPID, next); + }, }; } @@ -835,18 +866,140 @@ describe('GameController sequences', () => { ]); }); - it('uninstall is fire-and-forget: opens steam://uninstall and shows "Uninstalling…" without a blocking state', async () => { + it('uninstall is fire-and-forget: opens steam://uninstall and shows "Uninstalling…" as an activity, not a state', async () => { h = await harness({ mode: 'steam' }); + const built = h; fire(IPC.actionUninstall); await waitFor(() => shell.opened.length > 0, 'the steam://uninstall URI'); expect(shell.opened).toEqual([`steam://uninstall/${STEAM_APPID}`]); - const uninstalling = (): boolean => { - const state = h?.state.get(); - return state?.kind === 'ready' && state.game.steamUninstalling === true; - }; - await waitFor(uninstalling, 'the optimistic steamUninstalling flag'); - expect(h.journal).toEqual(['state:ready']); - expect(h.state.get()).toMatchObject({ kind: 'ready', game: { canUninstall: false } }); + await waitFor(() => built.activities.has('g1'), 'the optimistic steam-uninstalling activity'); + expect(built.activities.get('g1')).toEqual({ kind: 'steam-uninstalling' }); + expect(built.journal).toEqual(['activity:g1:steam-uninstalling']); + expect(built.state.get()).toMatchObject({ kind: 'ready', game: { canUninstall: true } }); + }); + + it('a second Uninstall while Steam is removing the game is ignored', async () => { + h = await harness({ mode: 'steam' }); + const built = h; + fire(IPC.actionUninstall); + await waitFor(() => built.activities.has('g1'), 'the steam-uninstalling activity'); + fire(IPC.actionUninstall); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(shell.opened).toEqual([`steam://uninstall/${STEAM_APPID}`]); + }); + }); + + describe('steam activities', () => { + beforeEach(() => { + process.env['PLAYHOOK_STEAM_POLL_MS'] = '20'; + }); + afterEach(() => { + delete process.env['PLAYHOOK_STEAM_POLL_MS']; + }); + + const downloading = (built: Harness): boolean => built.activities.get('g1')?.kind === 'steam-installing'; + const pause = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + + it('a download in flight at startup shows up as an activity without selecting the game', async () => { + h = await harness({ + mode: 'steam', + steamState: 'downloading', + extraGames: [{ id: 'other', lastPlayedAt: '2026-10-01T00:00:00.000Z' }], + }); + const built = h; + await waitFor(() => downloading(built), 'the steam-installing activity'); + expect(built.activities.get('g1')).toEqual({ kind: 'steam-installing', paused: false }); + expect(built.journal.filter((entry) => entry.startsWith('state:'))).toEqual([]); + }); + + it('selecting another game while one downloads goes through, and the download keeps its activity', async () => { + h = await harness({ + mode: 'steam', + steamState: 'downloading', + extraGames: [{ id: 'other', lastPlayedAt: '2026-10-01T00:00:00.000Z' }], + }); + const built = h; + fire(IPC.actionSelect, 'g1'); + await waitFor(() => built.state.get().kind === 'ready' && built.browsedId() === 'g1', 'g1 selected'); + await waitFor(() => downloading(built), 'the steam-installing activity'); + + fire(IPC.actionSelect, 'other'); + await waitFor(() => { + const state = built.state.get(); + return state.kind === 'ready' && state.game.id === 'other'; + }, 'the selection to move onto the other game'); + + expect(downloading(built)).toBe(true); + }); + + it('another game launches and runs while one downloads; the finished download notifies without touching the session', async () => { + h = await harness({ + mode: 'steam', + steamState: 'downloading', + extraGames: [{ id: 'other', lastPlayedAt: '2026-10-01T00:00:00.000Z' }], + }); + const built = h; + await waitFor(() => downloading(built), 'the steam-installing activity'); + built.journal.length = 0; + fire(IPC.actionSelect, 'other'); + await reached(built.journal, 'state:ready'); + await pause(50); + expect(built.state.get()).toMatchObject({ kind: 'ready', game: { id: 'other' } }); + built.journal.length = 0; + + fire(IPC.actionLaunch); + await reached(built.journal, 'state:running'); + await built.setSteamState('installed'); + await reached(built.journal, 'notify:game-installed'); + await pause(100); + + expect(built.journal).toEqual([ + 'state:syncing-in', + 'state:launching', + 'state:running', + 'activity:g1:clear', + 'notify:game-installed', + ]); + expect(built.state.get()).toMatchObject({ kind: 'running', game: { id: 'other' } }); + built.exit(); + await settled(built.journal, 'proc:dispose'); + }); + + it('a game with an activity is not launched', async () => { + h = await harness({ mode: 'steam', steamState: 'downloading' }); + const built = h; + await waitFor(() => downloading(built), 'the steam-installing activity'); + built.journal.length = 0; + fire(IPC.actionLaunch); + await pause(50); + expect(shell.opened).toEqual([]); + expect(built.journal).toEqual([]); + }); + + it('the selected game finishing its download flips to Play and notifies once', async () => { + h = await harness({ mode: 'steam', steamState: 'downloading' }); + const built = h; + await waitFor(() => downloading(built), 'the steam-installing activity'); + expect(built.state.get()).toMatchObject({ kind: 'ready', game: { id: 'g1', requiresInstall: true } }); + await built.setSteamState('installed'); + await reached(built.journal, 'notify:game-installed'); + await waitFor(() => { + const state = built.state.get(); + return state.kind === 'ready' && !state.game.requiresInstall; + }, 'the game to flip to Play'); + await pause(100); + expect(built.journal.filter((entry) => entry.startsWith('notify:'))).toEqual(['notify:game-installed']); + }); + + it('an update of an installed game reads as updating and finishes without a notification', async () => { + h = await harness({ mode: 'steam' }); + const built = h; + await built.setSteamState('downloading'); + await waitFor(() => built.activities.get('g1')?.kind === 'steam-updating', 'the steam-updating activity'); + await built.setSteamState('installed'); + await waitFor(() => !built.activities.has('g1'), 'the update to finish'); + await pause(100); + expect(built.journal.filter((entry) => entry.startsWith('notify:'))).toEqual([]); }); }); diff --git a/test/renderer/controls.test.ts b/test/renderer/controls.test.ts index f04a0cd..8b25852 100644 --- a/test/renderer/controls.test.ts +++ b/test/renderer/controls.test.ts @@ -3,7 +3,9 @@ import { createControls, type Controls } from '../../src/renderer/controls'; import type { ControlsApi, ControlsDeps } from '../../src/renderer/controls-deps'; import { req } from '../../src/renderer/dom'; import { createTranslator } from '../../src/shared/i18n/index'; -import type { BrowseInfo, GameInfo } from '../../src/shared/types'; +import type { AppState, BrowseInfo, GameInfo } from '../../src/shared/types'; +import type { ActivityMap } from '../../src/shared/activity'; +import { screenActivityOf } from '../../src/renderer/state-view'; import { loadFixture } from './helpers/fixture'; import { fakeAudio, fakeKeyboard, type FakeAudio } from './helpers/fakes'; import { installRafHarness } from './helpers/raf'; @@ -60,6 +62,9 @@ interface Harness { /** What the carousel reports it is showing, and what browse model the bar is drawn from. */ screen: 'carousel' | 'detail'; browse: BrowseInfo | null; + /** Every game's activity, as app.ts would hold it. */ + activities: ActivityMap; + state: AppState; /** Every `carousel.move(delta)` the router made. */ readonly carouselMoves: number[]; /** The primitives routed into the Settings overlay while it reports itself open. */ @@ -94,6 +99,8 @@ beforeEach(() => { const own: Harness = { screen: 'carousel', browse: null, + activities: {}, + state: { kind: 'idle' }, carouselMoves: [], settingsNav: [], settingsOpen: false, @@ -113,8 +120,9 @@ beforeEach(() => { }; controls = createControls({ api, - getState: () => ({ kind: 'idle' }), + getState: () => own.state, getBrowse: () => own.browse, + getScreenActivity: () => screenActivityOf(own.browse, own.activities), audio, getTranslator: () => createTranslator('en'), getLocale: () => 'en', @@ -311,3 +319,64 @@ describe('controls focus ring on the detail screen', () => { expect(focusedMain()).toEqual(['more-button']); }); }); + +describe('controls Play with per-game activities', () => { + const focusedMain = (): string[] => + ['play-button', 'more-button'].filter((id) => req(id).classList.contains('is-focused')); + const STEAM_GAME: GameInfo = { ...LOCAL_GAME, id: 'steam', title: 'Steam', requiresInstall: true, installVia: 'steam' }; + + it('keeps the gear of a download on screen focusable, and Play opens the Steam downloads', () => { + harness.screen = 'detail'; + harness.browse = browsing(STEAM_GAME); + harness.activities = { steam: { kind: 'steam-installing', paused: false } }; + controls.refresh(); + press('ArrowLeft'); + + expect(focusedMain()).toEqual(['play-button']); + + req('play-button').click(); + + expect(api.openSteamDownloads).toHaveBeenCalledTimes(1); + expect(api.requestLaunch).not.toHaveBeenCalled(); + }); + + it('opens the Steam downloads for an update too', () => { + harness.screen = 'detail'; + harness.browse = browsing({ ...STEAM_GAME, requiresInstall: false }); + harness.activities = { steam: { kind: 'steam-updating', paused: true } }; + controls.refresh(); + + req('play-button').click(); + + expect(api.openSteamDownloads).toHaveBeenCalledTimes(1); + expect(api.requestLaunch).not.toHaveBeenCalled(); + }); + + it('refuses Play while the game on screen is being removed', () => { + harness.screen = 'detail'; + harness.browse = browsing({ ...STEAM_GAME, requiresInstall: false, canUninstall: true }); + harness.activities = { steam: { kind: 'steam-uninstalling' } }; + controls.refresh(); + + req('play-button').click(); + + expect(audio.limits()).toBe(1); + expect(api.requestLaunch).not.toHaveBeenCalled(); + expect(api.openSteamDownloads).not.toHaveBeenCalled(); + }); + + it("launches the game on screen while ANOTHER game downloads: someone else's activity is not this game's", () => { + harness.screen = 'detail'; + harness.browse = browsing(LOCAL_GAME); + harness.state = { kind: 'ready', game: LOCAL_GAME }; + harness.activities = { steam: { kind: 'steam-installing', paused: false } }; + controls.refresh(); + + expect(focusedMain()).toEqual(['play-button']); + + req('play-button').click(); + + expect(api.requestLaunch).toHaveBeenCalledTimes(1); + expect(api.openSteamDownloads).not.toHaveBeenCalled(); + }); +}); diff --git a/test/state-view.test.ts b/test/state-view.test.ts new file mode 100644 index 0000000..334dabc --- /dev/null +++ b/test/state-view.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; +import { + activityBusyKind, + activityStatus, + busyIds, + opensSteamDownloads, + sameIds, + screenActivityOf, + statusOf, +} from '../src/renderer/state-view'; +import { createTranslator } from '../src/shared/i18n/index'; +import type { BrowseInfo, GameInfo } from '../src/shared/types'; + +const t = createTranslator('en'); + +const GAME: GameInfo = { + id: 'g', + title: 'G', + lastPlayedAt: null, + totalPlaySeconds: 0, + launchCount: 0, + requiresInstall: false, + canUninstall: false, +}; + +const BROWSE: BrowseInfo = { + id: 'g', + title: 'G', + active: true, + stats: { schemaVersion: 1, totalPlaySeconds: 0, lastPlayedAt: null, launchCount: 0 }, + game: GAME, +}; + +describe('activityStatus', () => { + it('names every Steam activity, with the paused percent when Steam gave one', () => { + expect(activityStatus({ kind: 'steam-installing', paused: false }, t)).toBe('Installing...'); + expect(activityStatus({ kind: 'steam-installing', paused: true }, t)).toBe('Installing paused...'); + expect(activityStatus({ kind: 'steam-installing', paused: true, pausedProgress: 0.426 }, t)).toBe( + 'Installing paused on 43%...', + ); + expect(activityStatus({ kind: 'steam-updating', paused: false }, t)).toBe('Updating...'); + expect(activityStatus({ kind: 'steam-updating', paused: true, pausedProgress: 0.5 }, t)).toBe( + 'Updating paused...', + ); + expect(activityStatus({ kind: 'steam-uninstalling' }, t)).toBe('Uninstalling...'); + }); + + it('a ready game says nothing about Steam on its own any more', () => { + expect(statusOf({ kind: 'ready', game: GAME }, t)).toBe(''); + }); +}); + +describe('activityBusyKind', () => { + it('shows the gear for an activity and falls back to the session otherwise', () => { + expect(activityBusyKind({ kind: 'steam-uninstalling' }, { kind: 'ready', game: GAME })).toBe('system'); + expect(activityBusyKind(undefined, { kind: 'ready', game: GAME })).toBe('none'); + expect(activityBusyKind(undefined, { kind: 'launching', game: GAME })).toBe('game'); + expect(activityBusyKind({ kind: 'steam-installing', paused: false }, { kind: 'running', game: GAME, since: 0 })).toBe( + 'system', + ); + }); +}); + +describe('screenActivityOf', () => { + it("is the activity of the game on screen, never another game's", () => { + const activities = { other: { kind: 'steam-uninstalling' } } as const; + expect(screenActivityOf(BROWSE, activities)).toBeUndefined(); + expect(screenActivityOf(null, activities)).toBeUndefined(); + expect(screenActivityOf(BROWSE, { g: { kind: 'steam-uninstalling' } })).toEqual({ kind: 'steam-uninstalling' }); + }); +}); + +describe('opensSteamDownloads', () => { + it('holds for a download or an update only', () => { + expect(opensSteamDownloads({ kind: 'steam-installing', paused: false })).toBe(true); + expect(opensSteamDownloads({ kind: 'steam-updating', paused: true })).toBe(true); + expect(opensSteamDownloads({ kind: 'steam-uninstalling' })).toBe(false); + expect(opensSteamDownloads(undefined)).toBe(false); + }); +}); + +describe('busyIds', () => { + it('collects every game with an activity plus the game of a busy session', () => { + const activities = { + a: { kind: 'steam-installing', paused: false }, + b: { kind: 'steam-updating', paused: false }, + } as const; + expect([...busyIds({ kind: 'running', game: GAME, since: 0 }, activities)].sort()).toEqual(['a', 'b', 'g']); + expect([...busyIds({ kind: 'ready', game: GAME }, activities)].sort()).toEqual(['a', 'b']); + expect(busyIds({ kind: 'idle' }, {}).size).toBe(0); + }); +}); + +describe('sameIds', () => { + it('compares the ids regardless of order', () => { + expect(sameIds(new Set(['a', 'b']), new Set(['b', 'a']))).toBe(true); + expect(sameIds(new Set(['a']), new Set(['a', 'b']))).toBe(false); + expect(sameIds(new Set(['a']), new Set(['b']))).toBe(false); + }); +}); diff --git a/test/steam-activity-watch.test.ts b/test/steam-activity-watch.test.ts new file mode 100644 index 0000000..5038f4d --- /dev/null +++ b/test/steam-activity-watch.test.ts @@ -0,0 +1,299 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ActivityRegistry } from '../src/main/activity-registry'; +import { + SteamActivityWatch, + steamPollIntervalMs, + type SteamWatchEntry, +} from '../src/main/steam-activity-watch'; + +type AcfState = 'installed' | 'downloading' | 'paused' | 'absent'; + +interface Fixture { + readonly watch: SteamActivityWatch; + readonly registry: ActivityRegistry; + readonly events: string[]; + games: SteamWatchEntry[]; + session: string | null; + clock: number; + acf(appid: number, state: AcfState, progress?: number): Promise; +} + +const A: SteamWatchEntry = { id: 'a', title: 'Game A', appid: 10 }; +const B: SteamWatchEntry = { id: 'b', title: 'Game B', appid: 20 }; + +let root: string; +let fixture: Fixture; + +function acfText(appid: number, state: Exclude, progress: number): string { + const flags = state === 'installed' ? 4 : 1026; + const result = state === 'paused' ? 4 : 0; + const staged = Math.round(progress * 100); + return [ + '"AppState"', + '{', + `\t"appid"\t\t"${appid}"`, + `\t"StateFlags"\t\t"${flags}"`, + `\t"UpdateResult"\t\t"${result}"`, + `\t"BytesStaged"\t\t"${staged}"`, + `\t"BytesToStage"\t\t"100"`, + '}', + '', + ].join('\n'); +} + +function build(): Fixture { + const registry = new ActivityRegistry(); + const events: string[] = []; + const state = { + games: [A, B], + session: null as string | null, + clock: 1_000_000, + }; + const watch = new SteamActivityWatch( + { + listSteamGames: () => state.games, + registry, + steamLocator: () => ({ locateSteam: () => Promise.resolve(root) }), + sessionGameId: () => state.session, + onGameChanged: (id) => events.push(`changed:${id}`), + onInstallCompleted: (game) => events.push(`installed:${game.id}`), + onUninstallCompleted: (game) => events.push(`uninstalled:${game.id}`), + }, + { intervalMs: 3_600_000, now: () => state.clock }, + ); + return { + watch, + registry, + events, + get games() { + return state.games; + }, + set games(next) { + state.games = next; + }, + get session() { + return state.session; + }, + set session(next) { + state.session = next; + }, + get clock() { + return state.clock; + }, + set clock(next) { + state.clock = next; + }, + acf: async (appid, acfState, progress = 0.5) => { + const file = path.join(root, 'steamapps', `appmanifest_${appid}.acf`); + if (acfState === 'absent') await fs.rm(file, { force: true }); + else await fs.writeFile(file, acfText(appid, acfState, progress)); + }, + }; +} + +beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'playhook-steam-watch-')); + await fs.mkdir(path.join(root, 'steamapps'), { recursive: true }); + fixture = build(); +}); + +afterEach(async () => { + fixture.watch.stop(); + await fs.rm(root, { recursive: true, force: true }); +}); + +describe('SteamActivityWatch transitions', () => { + it('first sight: a download becomes an install activity and a change; installed or absent stay silent', async () => { + await fixture.acf(A.appid, 'downloading'); + await fixture.acf(B.appid, 'installed'); + await fixture.watch.scanNow(); + expect(fixture.registry.snapshot()).toEqual({ a: { kind: 'steam-installing', paused: false } }); + expect(fixture.events).toEqual(['changed:a']); + }); + + it('absent → downloading → installed: an install, one change each way and exactly one notification', async () => { + await fixture.watch.scanNow(); + expect(fixture.events).toEqual([]); + await fixture.acf(A.appid, 'downloading'); + await fixture.watch.scanNow(); + await fixture.watch.scanNow(); + expect(fixture.registry.get('a')).toEqual({ kind: 'steam-installing', paused: false }); + await fixture.acf(A.appid, 'installed'); + await fixture.watch.scanNow(); + await fixture.watch.scanNow(); + expect(fixture.registry.has('a')).toBe(false); + expect(fixture.events).toEqual(['changed:a', 'changed:a', 'installed:a']); + }); + + it('installed → downloading is an update, and finishing it notifies nobody', async () => { + await fixture.acf(A.appid, 'installed'); + await fixture.watch.scanNow(); + await fixture.acf(A.appid, 'downloading'); + await fixture.watch.scanNow(); + expect(fixture.registry.get('a')).toEqual({ kind: 'steam-updating', paused: false }); + await fixture.acf(A.appid, 'paused', 0.25); + await fixture.watch.scanNow(); + expect(fixture.registry.get('a')).toEqual({ kind: 'steam-updating', paused: true, pausedProgress: 0.25 }); + await fixture.acf(A.appid, 'installed'); + await fixture.watch.scanNow(); + expect(fixture.registry.has('a')).toBe(false); + expect(fixture.events).toEqual(['changed:a', 'changed:a']); + }); + + it('a pause and its percent update the activity without a change event', async () => { + await fixture.acf(A.appid, 'downloading'); + await fixture.watch.scanNow(); + await fixture.acf(A.appid, 'paused', 0.4); + await fixture.watch.scanNow(); + expect(fixture.registry.get('a')).toEqual({ kind: 'steam-installing', paused: true, pausedProgress: 0.4 }); + expect(fixture.events).toEqual(['changed:a']); + }); + + it('absent → installed (installed in Steam directly) is a change without a notification', async () => { + await fixture.watch.scanNow(); + await fixture.acf(A.appid, 'installed'); + await fixture.watch.scanNow(); + expect(fixture.events).toEqual(['changed:a']); + expect(fixture.registry.snapshot()).toEqual({}); + }); + + it('installed → absent without a request of ours is a change without a notification', async () => { + await fixture.acf(A.appid, 'installed'); + await fixture.watch.scanNow(); + await fixture.acf(A.appid, 'absent'); + await fixture.watch.scanNow(); + expect(fixture.events).toEqual(['changed:a']); + }); + + it('two games download at once, and each completion notifies only for its own game', async () => { + await fixture.acf(A.appid, 'downloading'); + await fixture.acf(B.appid, 'downloading'); + await fixture.watch.scanNow(); + expect(Object.keys(fixture.registry.snapshot()).sort()).toEqual(['a', 'b']); + await fixture.acf(B.appid, 'installed'); + await fixture.watch.scanNow(); + expect(fixture.registry.snapshot()).toEqual({ a: { kind: 'steam-installing', paused: false } }); + expect(fixture.events.filter((event) => event.startsWith('installed:'))).toEqual(['installed:b']); + await fixture.acf(A.appid, 'installed'); + await fixture.watch.scanNow(); + expect(fixture.events.filter((event) => event.startsWith('installed:'))).toEqual(['installed:b', 'installed:a']); + }); +}); + +describe('SteamActivityWatch uninstall requests', () => { + it('shows uninstalling while the game is still installed, then notifies once it is gone', async () => { + await fixture.acf(A.appid, 'installed'); + await fixture.watch.scanNow(); + fixture.watch.requestUninstall(A.appid); + await fixture.watch.scanNow(); + expect(fixture.registry.get('a')).toEqual({ kind: 'steam-uninstalling' }); + await fixture.acf(A.appid, 'absent'); + await fixture.watch.scanNow(); + await fixture.watch.scanNow(); + expect(fixture.registry.has('a')).toBe(false); + expect(fixture.events).toEqual(['changed:a', 'uninstalled:a']); + }); + + it('a request still installed after the timeout is taken as a cancel', async () => { + await fixture.acf(A.appid, 'installed'); + await fixture.watch.scanNow(); + fixture.watch.requestUninstall(A.appid); + await fixture.watch.scanNow(); + fixture.clock += 60_001; + await fixture.watch.scanNow(); + expect(fixture.registry.has('a')).toBe(false); + expect(fixture.events).toEqual([]); + await fixture.acf(A.appid, 'absent'); + await fixture.watch.scanNow(); + expect(fixture.events).toEqual(['changed:a']); + }); +}); + +describe('SteamActivityWatch and the session', () => { + it('the session game gets no activity; its status lands once the session is over', async () => { + await fixture.acf(A.appid, 'installed'); + await fixture.watch.scanNow(); + fixture.session = 'a'; + await fixture.acf(A.appid, 'downloading'); + await fixture.watch.scanNow(); + expect(fixture.registry.has('a')).toBe(false); + expect(fixture.events).toEqual([]); + fixture.session = null; + await fixture.watch.scanNow(); + expect(fixture.registry.get('a')).toEqual({ kind: 'steam-updating', paused: false }); + expect(fixture.events).toEqual(['changed:a']); + }); + + it('an update Steam ran and finished during the session leaves no trace', async () => { + await fixture.acf(A.appid, 'installed'); + await fixture.watch.scanNow(); + fixture.session = 'a'; + await fixture.acf(A.appid, 'downloading'); + await fixture.watch.scanNow(); + await fixture.acf(A.appid, 'installed'); + await fixture.watch.scanNow(); + fixture.session = null; + await fixture.watch.scanNow(); + expect(fixture.registry.snapshot()).toEqual({}); + expect(fixture.events).toEqual([]); + }); +}); + +describe('SteamActivityWatch bookkeeping', () => { + it('a game that leaves the list is cleaned out of the registry, the statuses and the requests', async () => { + await fixture.acf(A.appid, 'downloading'); + await fixture.acf(B.appid, 'installed'); + await fixture.watch.scanNow(); + fixture.watch.requestUninstall(B.appid); + fixture.games = []; + await fixture.watch.scanNow(); + expect(fixture.registry.snapshot()).toEqual({}); + fixture.games = [A, B]; + await fixture.acf(B.appid, 'absent'); + await fixture.watch.scanNow(); + expect(fixture.events).toEqual(['changed:a', 'changed:a']); + }); + + it("never clears an activity that is not Steam's", async () => { + fixture.registry.set('a', { kind: 'installing' }); + await fixture.acf(A.appid, 'installed'); + await fixture.watch.scanNow(); + fixture.games = []; + await fixture.watch.scanNow(); + expect(fixture.registry.get('a')).toEqual({ kind: 'installing' }); + }); + + it('keeps what it knew when Steam cannot be found', async () => { + await fixture.acf(A.appid, 'downloading'); + await fixture.watch.scanNow(); + await fs.rm(root, { recursive: true, force: true }); + root = path.join(os.tmpdir(), 'playhook-steam-watch-missing'); + const missing = new SteamActivityWatch( + { + listSteamGames: () => [A], + registry: fixture.registry, + steamLocator: () => ({ locateSteam: () => Promise.resolve(null) }), + sessionGameId: () => null, + onGameChanged: () => undefined, + onInstallCompleted: () => undefined, + onUninstallCompleted: () => undefined, + }, + { intervalMs: 3_600_000 }, + ); + await missing.scanNow(); + missing.stop(); + expect(fixture.registry.get('a')).toEqual({ kind: 'steam-installing', paused: false }); + }); +}); + +describe('steamPollIntervalMs', () => { + it('reads PLAYHOOK_STEAM_POLL_MS and falls back to five seconds', () => { + expect(steamPollIntervalMs({})).toBe(5000); + expect(steamPollIntervalMs({ PLAYHOOK_STEAM_POLL_MS: '250' })).toBe(250); + expect(steamPollIntervalMs({ PLAYHOOK_STEAM_POLL_MS: '0' })).toBe(5000); + expect(steamPollIntervalMs({ PLAYHOOK_STEAM_POLL_MS: 'soon' })).toBe(5000); + }); +}); From af6a9a6626226dd4b706f215c14c03a73d69d66f Mon Sep 17 00:00:00 2001 From: Alexander Date: Mon, 5 Oct 2026 12:59:33 +0500 Subject: [PATCH 4/8] Test: tolerate the late ready of the startup Steam scan --- test/game-controller.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/game-controller.test.ts b/test/game-controller.test.ts index f198147..b6bbf2e 100644 --- a/test/game-controller.test.ts +++ b/test/game-controller.test.ts @@ -973,7 +973,8 @@ describe('GameController sequences', () => { fire(IPC.actionLaunch); await pause(50); expect(shell.opened).toEqual([]); - expect(built.journal).toEqual([]); + expect(built.journal.filter((entry) => entry !== 'state:ready')).toEqual([]); + expect(built.state.get()).toMatchObject({ kind: 'ready', game: { id: 'g1' } }); }); it('the selected game finishing its download flips to Play and notifies once', async () => { From 615ddb4a6124f40e16ae0c0e0321840638cf8333 Mon Sep 17 00:00:00 2001 From: Alexander Date: Mon, 5 Oct 2026 13:09:01 +0500 Subject: [PATCH 5/8] Bump version to 0.9.0-alpha.6 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index aa2eb86..0ac38d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "playhook", - "version": "0.9.0-alpha.5", + "version": "0.9.0-alpha.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "playhook", - "version": "0.9.0-alpha.5", + "version": "0.9.0-alpha.6", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index fa8b0ce..fb4d7c6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "playhook", - "version": "0.9.0-alpha.5", + "version": "0.9.0-alpha.6", "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", From a7ed9061ed620c5ea48a6b07d000a40510cb658d Mon Sep 17 00:00:00 2001 From: Alexander Date: Mon, 5 Oct 2026 14:43:35 +0500 Subject: [PATCH 6/8] Details: Force close only on the running game's own screen --- src/renderer/details-menu.ts | 3 ++- test/renderer/controls.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/renderer/details-menu.ts b/src/renderer/details-menu.ts index edfdb58..ad0aeed 100644 --- a/src/renderer/details-menu.ts +++ b/src/renderer/details-menu.ts @@ -87,7 +87,8 @@ export function createDetailsMenu(deps: DetailsMenuDeps): DetailsMenu { // Shown only while a game is running AND a force-close isn't already in flight (during killing the // status reads "Force closing…" and the button would be a no-op — main guards a repeat anyway). const s = state(); - const running = onGameScreen() && s.kind === 'running' && s.killing !== true; + const running = + onGameScreen() && s.kind === 'running' && s.killing !== true && deps.getBrowse()?.id === s.game.id; menuKill.classList.toggle('is-hidden', !running); if (running) menuKill.textContent = t()('launcher.menu.forceClose'); } diff --git a/test/renderer/controls.test.ts b/test/renderer/controls.test.ts index 8b25852..0856971 100644 --- a/test/renderer/controls.test.ts +++ b/test/renderer/controls.test.ts @@ -380,3 +380,26 @@ describe('controls Play with per-game activities', () => { expect(api.openSteamDownloads).not.toHaveBeenCalled(); }); }); + +describe('controls Force close', () => { + const OTHER_GAME: GameInfo = { ...LOCAL_GAME, id: 'other', title: 'Other' }; + const killHidden = (): boolean => req('menu-kill').classList.contains('is-hidden'); + + it('is offered on the detail screen of the running game', () => { + harness.screen = 'detail'; + harness.browse = browsing(LOCAL_GAME); + harness.state = { kind: 'running', game: LOCAL_GAME, since: 0 }; + req('more-button').click(); + + expect(killHidden()).toBe(false); + }); + + it("is not offered on another game's detail screen while one runs", () => { + harness.screen = 'detail'; + harness.browse = browsing(OTHER_GAME); + harness.state = { kind: 'running', game: LOCAL_GAME, since: 0 }; + req('more-button').click(); + + expect(killHidden()).toBe(true); + }); +}); From bfdbad5a7f1073c13f55c426c3f5d9078d8e41d5 Mon Sep 17 00:00:00 2001 From: Alexander Date: Mon, 5 Oct 2026 16:28:57 +0500 Subject: [PATCH 7/8] Steam: show a finished pre-load instead of a paused 100% --- src/main/steam-activity-watch.ts | 13 ++++++--- src/main/steam.ts | 13 ++++++++- src/renderer/state-view.ts | 1 + src/shared/activity.ts | 7 ++++- src/shared/i18n/en.ts | 1 + src/shared/i18n/ru.ts | 1 + test/state-view.test.ts | 9 ++++++ test/steam-activity-watch.test.ts | 46 +++++++++++++++++++++++++++++++ 8 files changed, 85 insertions(+), 6 deletions(-) diff --git a/src/main/steam-activity-watch.ts b/src/main/steam-activity-watch.ts index 32d9157..98ffff5 100644 --- a/src/main/steam-activity-watch.ts +++ b/src/main/steam-activity-watch.ts @@ -55,14 +55,19 @@ export function steamPollIntervalMs(env: NodeJS.ProcessEnv = process.env): numbe return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_STEAM_POLL_MS; } -/** The activity a download shows: an install or an update, with the snapshot percent while it is paused. */ +/** + * The activity a download shows: an install or an update, with the snapshot percent while it is paused, + * and an install marked as a finished pre-load while the game waits for its release. + */ function downloadActivity( kind: 'steam-installing' | 'steam-updating', status: Extract, ): GameActivity { - return status.paused && status.progress !== null - ? { kind, paused: true, pausedProgress: status.progress } - : { kind, paused: status.paused }; + const base: GameActivity = + status.paused && status.progress !== null + ? { kind, paused: true, pausedProgress: status.progress } + : { kind, paused: status.paused }; + return base.kind === 'steam-installing' && status.preloaded ? { ...base, preloaded: true } : base; } /** Whether the activity is one this watch owns (it never touches a launcher job's activity). */ diff --git a/src/main/steam.ts b/src/main/steam.ts index b6bb399..589db36 100644 --- a/src/main/steam.ts +++ b/src/main/steam.ts @@ -20,6 +20,8 @@ import { log } from './logger'; /** StateFlags bit set by Steam once an app is fully installed (not merely downloading/updating). */ const STATE_FULLY_INSTALLED = 4; +const UPDATE_RESULT_NOT_RELEASED = 25; + /** * Where a Steam app stands locally, derived from its `.acf`: * - `installed` — fully installed (StateFlags bit 4), ready to launch. @@ -31,7 +33,12 @@ const STATE_FULLY_INSTALLED = 4; */ export type SteamInstallStatus = | { readonly state: 'installed' } - | { readonly state: 'downloading'; readonly paused: boolean; readonly progress: number | null } + | { + readonly state: 'downloading'; + readonly paused: boolean; + readonly progress: number | null; + readonly preloaded: boolean; + } | { readonly state: 'absent' }; /** @@ -89,6 +96,7 @@ type AcfState = { readonly paused: boolean; /** Snapshot completion fraction 0..1 (staged ?? downloaded), or null if not computable. */ readonly progress: number | null; + readonly preloaded: boolean; }; /** num/den clamped to 0..1, or null when not computable. */ @@ -112,10 +120,12 @@ async function readAcfState(acfPath: string): Promise { fraction(readAcfNumber(content, 'BytesDownloaded'), readAcfNumber(content, 'BytesToDownload')); // EResult: 0 (none/in-progress) and 1 (OK) are "active/fine"; >=2 means the download isn't moving. const updateResult = readAcfNumber(content, 'UpdateResult') ?? 0; + const downloaded = fraction(readAcfNumber(content, 'BytesDownloaded'), readAcfNumber(content, 'BytesToDownload')); return { fullyInstalled: (flags & STATE_FULLY_INSTALLED) === STATE_FULLY_INSTALLED, paused: updateResult >= 2, progress, + preloaded: updateResult === UPDATE_RESULT_NOT_RELEASED && downloaded === 1, }; } @@ -136,6 +146,7 @@ async function statusInLibraries(appid: number, libs: readonly string[]): Promis state: 'downloading', paused: acf.paused, progress: acf.paused ? acf.progress : null, + preloaded: acf.preloaded, }; } return downloading ?? { state: 'absent' }; diff --git a/src/renderer/state-view.ts b/src/renderer/state-view.ts index bf6368e..25822bf 100644 --- a/src/renderer/state-view.ts +++ b/src/renderer/state-view.ts @@ -100,6 +100,7 @@ export function activityStatus(activity: GameActivity, t: Translator): string { case 'steam-updating': return t(activity.paused ? 'launcher.state.updatingPaused' : 'launcher.state.updating'); case 'steam-installing': + if (activity.preloaded === true) return t('launcher.state.preloaded'); if (!activity.paused) return t('launcher.state.installing'); return activity.pausedProgress === undefined ? t('launcher.state.installingPaused') diff --git a/src/shared/activity.ts b/src/shared/activity.ts index b775929..9d5d110 100644 --- a/src/shared/activity.ts +++ b/src/shared/activity.ts @@ -7,7 +7,12 @@ export type GameActivity = | { readonly kind: 'installing' } | { readonly kind: 'configuringProton' } | { readonly kind: 'uninstalling' } - | { readonly kind: 'steam-installing'; readonly paused: boolean; readonly pausedProgress?: number } + | { + readonly kind: 'steam-installing'; + readonly paused: boolean; + readonly pausedProgress?: number; + readonly preloaded?: true; + } | { readonly kind: 'steam-updating'; readonly paused: boolean; readonly pausedProgress?: number } | { readonly kind: 'steam-uninstalling' }; diff --git a/src/shared/i18n/en.ts b/src/shared/i18n/en.ts index 1dc9af2..254f661 100644 --- a/src/shared/i18n/en.ts +++ b/src/shared/i18n/en.ts @@ -152,6 +152,7 @@ export const en = { 'launcher.state.installingPausedPercent': 'Installing paused on {percent}%...', 'launcher.state.updating': 'Updating...', 'launcher.state.updatingPaused': 'Updating paused...', + 'launcher.state.preloaded': 'Pre-load complete', // ── Display formatters (format.ts) ────────────────────────────────────────── 'format.never': 'never', diff --git a/src/shared/i18n/ru.ts b/src/shared/i18n/ru.ts index 735732e..d54aa0c 100644 --- a/src/shared/i18n/ru.ts +++ b/src/shared/i18n/ru.ts @@ -125,6 +125,7 @@ export const ru: Record = { 'launcher.state.installingPausedPercent': 'Установка приостановлена на {percent}%...', 'launcher.state.updating': 'Обновление...', 'launcher.state.updatingPaused': 'Обновление приостановлено...', + 'launcher.state.preloaded': 'Предзагрузка завершена', // ── Display formatters ─────────────────────────────────────────────────────── 'format.never': 'никогда', diff --git a/test/state-view.test.ts b/test/state-view.test.ts index 334dabc..0fefd4f 100644 --- a/test/state-view.test.ts +++ b/test/state-view.test.ts @@ -45,6 +45,15 @@ describe('activityStatus', () => { expect(activityStatus({ kind: 'steam-uninstalling' }, t)).toBe('Uninstalling...'); }); + it('a finished pre-load says so instead of a paused percent', () => { + expect( + activityStatus({ kind: 'steam-installing', paused: true, pausedProgress: 1, preloaded: true }, t), + ).toBe('Pre-load complete'); + expect( + activityStatus({ kind: 'steam-installing', paused: true, pausedProgress: 1, preloaded: true }, createTranslator('ru')), + ).toBe('Предзагрузка завершена'); + }); + it('a ready game says nothing about Steam on its own any more', () => { expect(statusOf({ kind: 'ready', game: GAME }, t)).toBe(''); }); diff --git a/test/steam-activity-watch.test.ts b/test/steam-activity-watch.test.ts index 5038f4d..d912842 100644 --- a/test/steam-activity-watch.test.ts +++ b/test/steam-activity-watch.test.ts @@ -183,6 +183,52 @@ describe('SteamActivityWatch transitions', () => { }); }); +describe('SteamActivityWatch pre-loads', () => { + const preloadAcf = (appid: number, downloaded: number): string => + [ + '"AppState"', + '{', + `\t"appid"\t\t"${appid}"`, + '\t"StateFlags"\t\t"1026"', + '\t"buildid"\t\t"0"', + '\t"UpdateResult"\t\t"25"', + '\t"BytesToDownload"\t\t"86248618160"', + `\t"BytesDownloaded"\t\t"${downloaded}"`, + '\t"BytesToStage"\t\t"128606066"', + '\t"BytesStaged"\t\t"128606066"', + '}', + '', + ].join('\n'); + const writeAcf = (appid: number, text: string): Promise => + fs.writeFile(path.join(root, 'steamapps', `appmanifest_${appid}.acf`), text); + + it('a finished pre-load of an unreleased game (UpdateResult 25, every byte in) is marked as one', async () => { + await writeAcf(A.appid, preloadAcf(A.appid, 86248618160)); + await fixture.watch.scanNow(); + expect(fixture.registry.get('a')).toEqual({ + kind: 'steam-installing', + paused: true, + pausedProgress: 1, + preloaded: true, + }); + }); + + it('the same result before every byte is in stays an ordinary paused download', async () => { + await writeAcf(A.appid, preloadAcf(A.appid, 1000)); + await fixture.watch.scanNow(); + expect(fixture.registry.get('a')).toEqual({ kind: 'steam-installing', paused: true, pausedProgress: 1 }); + }); + + it('the release turns the pre-load into an install, with its notification', async () => { + await writeAcf(A.appid, preloadAcf(A.appid, 86248618160)); + await fixture.watch.scanNow(); + await fixture.acf(A.appid, 'installed'); + await fixture.watch.scanNow(); + expect(fixture.registry.has('a')).toBe(false); + expect(fixture.events).toEqual(['changed:a', 'changed:a', 'installed:a']); + }); +}); + describe('SteamActivityWatch uninstall requests', () => { it('shows uninstalling while the game is still installed, then notifies once it is gone', async () => { await fixture.acf(A.appid, 'installed'); From bc0c09113f0d036c6ec1f108d046ba231f12b7e6 Mon Sep 17 00:00:00 2001 From: Alexander Date: Mon, 5 Oct 2026 16:29:17 +0500 Subject: [PATCH 8/8] Bump version to 0.9.0-alpha.7 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0ac38d0..726d8e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "playhook", - "version": "0.9.0-alpha.6", + "version": "0.9.0-alpha.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "playhook", - "version": "0.9.0-alpha.6", + "version": "0.9.0-alpha.7", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index fb4d7c6..1cfc23f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "playhook", - "version": "0.9.0-alpha.6", + "version": "0.9.0-alpha.7", "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",