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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "playhook",
"version": "0.9.0-alpha.5",
"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",
Expand Down
58 changes: 58 additions & 0 deletions src/main/activity-registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { ipcMain } from 'electron';
import { IPC } from '../shared/types';
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<ActivityListener>();

/** 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];
}

/** 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);
}
}
4 changes: 4 additions & 0 deletions src/main/controller-deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -88,6 +89,7 @@ export type ControllerPcLibrary = Pick<PcLibraryStore, 'read' | 'gcOrphans'>;
export type ControllerWatcher = Pick<DriveWatcher, 'onInsert' | 'onRemove' | 'onError' | 'stop'>;
export type ControllerSettings = Pick<AppSettingsStore, 'read'>;
export type ControllerNotifications = Pick<NotificationsService, 'notify'>;
export type ControllerActivities = Pick<ActivityRegistry, 'get' | 'has' | 'set' | 'clear'>;

export interface ControllerDeps {
readonly state: ControllerState;
Expand All @@ -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). */
Expand Down
177 changes: 76 additions & 101 deletions src/main/game-controller.ts

Large diffs are not rendered by default.

33 changes: 11 additions & 22 deletions src/main/game-sequences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<void>;
Expand All @@ -78,8 +77,9 @@ export type SequenceDeps = Pick<
| 'platform'
| 'processControl'
| 'getTranslator'
| 'activities'
> & {
readonly steamWatch: Pick<SteamInstallWatch, 'start' | 'stop' | 'requestUninstall'>;
readonly steamWatch: Pick<SteamActivityWatch, 'scanNow' | 'requestUninstall'>;
/** The card↔PC save sync the launch sequence brackets the game with. */
readonly saveSync: Pick<SaveSyncFlow, 'resolvePcSavePath' | 'runSaveSync' | 'performSyncOut'>;
readonly host: SequenceHost;
Expand Down Expand Up @@ -360,12 +360,12 @@ export class GameSequences {
/**
* Steam install action: fire-and-forget. Opens `steam://install/<appid>` (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<void> {
async runSteamInstall(manifest: ResolvedManifest): Promise<void> {
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) {
Expand All @@ -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/<appid>`
* (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<void> {
async runSteamUninstall(manifest: ResolvedManifest): Promise<void> {
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) {
Expand All @@ -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 ──────────────────────────────────────────────────
Expand Down Expand Up @@ -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;
}
Expand Down
4 changes: 4 additions & 0 deletions src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -280,8 +281,11 @@ async function bootstrap(): Promise<void> {
);

windowRef = window;
const activities = new ActivityRegistry();
activities.init((channel, payload) => window.send(channel, payload));
const controller = new GameController({
state,
activities,
window,
store,
stats,
Expand Down
Loading
Loading