From 2bd9ad1ef31b461a5407aa777da6f13b4d5d5868 Mon Sep 17 00:00:00 2001 From: Alexander Date: Mon, 5 Oct 2026 17:18:39 +0500 Subject: [PATCH 01/10] Linux: serialize winetricks provisioning across games --- src/main/platform/game-launcher.linux.ts | 18 ++++++++ src/main/platform/provision-lock.ts | 23 ++++++++++ test/provision-lock.test.ts | 58 ++++++++++++++++++++++++ 3 files changed, 99 insertions(+) create mode 100644 src/main/platform/provision-lock.ts create mode 100644 test/provision-lock.test.ts diff --git a/src/main/platform/game-launcher.linux.ts b/src/main/platform/game-launcher.linux.ts index 2c2d206..3318b16 100644 --- a/src/main/platform/game-launcher.linux.ts +++ b/src/main/platform/game-launcher.linux.ts @@ -22,6 +22,7 @@ import { } from './umu'; import { buildInstallerArgs } from '../launch-args'; import { log } from '../logger'; +import { createProvisionLock } from './provision-lock'; const execFileAsync = promisify(execFile); @@ -169,6 +170,8 @@ function spawnUmuProcess( // recorded in a per-prefix sentinel so re-installs skip the (slow) step; the winetricks DOWNLOADS are // globally cached by winetricks, so a new prefix re-applies without re-downloading. +const provisionLock = createProvisionLock(); + /** Per-prefix marker file listing the winetricks verbs already provisioned (newline-separated). */ const WINETRICKS_SENTINEL = '.playhook-winetricks'; @@ -236,6 +239,21 @@ async function ensurePrefixDeps( extra: readonly string[], logDir: string | undefined, onProvisioning?: (active: boolean) => void, +): Promise { + if (pendingWinetricks(extra, await readDoneVerbs(prefix)).length === 0) return; + await provisionLock.run(() => provisionPrefix(umuRunPath, prefix, extra, logDir, onProvisioning)); +} + +/** + * The body of ensurePrefixDeps, run under the provision lock: re-reads what is already applied (an earlier + * holder of the lock may have provisioned this very prefix) and runs winetricks for the rest. + */ +async function provisionPrefix( + umuRunPath: string, + prefix: string, + extra: readonly string[], + logDir: string | undefined, + onProvisioning?: (active: boolean) => void, ): Promise { const done = await readDoneVerbs(prefix); const pending = pendingWinetricks(extra, done); diff --git a/src/main/platform/provision-lock.ts b/src/main/platform/provision-lock.ts new file mode 100644 index 0000000..bcb8681 --- /dev/null +++ b/src/main/platform/provision-lock.ts @@ -0,0 +1,23 @@ +/** An async queue: sections handed to `run` execute one at a time, in the order they were handed in. */ +export interface ProvisionLock { + run(section: () => Promise): Promise; +} + +/** + * Creates a lock for the steps that must not overlap between games: winetricks shares one download cache + * (`~/.cache/winetricks`) across every prefix and takes no lock of its own. A section that throws releases + * the lock like one that succeeds. + */ +export function createProvisionLock(): ProvisionLock { + let tail: Promise = Promise.resolve(); + return { + run(section: () => Promise): Promise { + const result = tail.then(section); + tail = result.then( + () => undefined, + () => undefined, + ); + return result; + }, + }; +} diff --git a/test/provision-lock.test.ts b/test/provision-lock.test.ts new file mode 100644 index 0000000..513043b --- /dev/null +++ b/test/provision-lock.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { createProvisionLock } from '../src/main/platform/provision-lock'; + +/** A promise the test settles by hand. */ +function deferred(): { readonly promise: Promise; resolve(): void; reject(error: Error): void } { + let resolve: () => void = () => undefined; + let reject: (error: Error) => void = () => undefined; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve: () => resolve(), reject: (error) => reject(error) }; +} + +const flush = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('ProvisionLock', () => { + it('runs the sections one at a time, in the order they were handed in', async () => { + const lock = createProvisionLock(); + const log: string[] = []; + const first = deferred(); + const second = deferred(); + const a = lock.run(async () => { + log.push('a:start'); + await first.promise; + log.push('a:end'); + }); + const b = lock.run(async () => { + log.push('b:start'); + await second.promise; + log.push('b:end'); + }); + const c = lock.run(async () => { + log.push('c'); + return 42; + }); + await flush(); + expect(log).toEqual(['a:start']); + first.resolve(); + await a; + await flush(); + expect(log).toEqual(['a:start', 'a:end', 'b:start']); + second.resolve(); + await b; + await expect(c).resolves.toBe(42); + expect(log).toEqual(['a:start', 'a:end', 'b:start', 'b:end', 'c']); + }); + + it('releases the lock when a section throws, and hands the error to its own caller only', async () => { + const lock = createProvisionLock(); + const failing = deferred(); + const a = lock.run(() => failing.promise); + const b = lock.run(async () => 'next'); + failing.reject(new Error('winetricks boom')); + await expect(a).rejects.toThrow('winetricks boom'); + await expect(b).resolves.toBe('next'); + }); +}); From 3171fe6e8177c02f31c79b13cd3f7c899289d3f7 Mon Sep 17 00:00:00 2001 From: Alexander Date: Mon, 5 Oct 2026 17:23:11 +0500 Subject: [PATCH 02/10] Add GameJobs: background copy installs, uninstalls, prefix cleanups --- src/main/game-jobs.ts | 382 ++++++++++++++++++++++++++++++++ src/main/notifications-store.ts | 18 ++ src/main/window.ts | 6 + src/preload/preload.ts | 4 + src/renderer/format.ts | 4 + src/shared/activity.ts | 7 + src/shared/i18n/en.ts | 4 + src/shared/i18n/ru.ts | 4 + src/shared/types.ts | 8 +- test/game-jobs.test.ts | 327 +++++++++++++++++++++++++++ 10 files changed, 763 insertions(+), 1 deletion(-) create mode 100644 src/main/game-jobs.ts create mode 100644 test/game-jobs.test.ts diff --git a/src/main/game-jobs.ts b/src/main/game-jobs.ts new file mode 100644 index 0000000..99cac63 --- /dev/null +++ b/src/main/game-jobs.ts @@ -0,0 +1,382 @@ +import path from 'node:path'; +import fse from 'fs-extra'; +import { ipcMain } from 'electron'; +import { IPC, type ManifestSource } from '../shared/types'; +import { type GameActivity } from '../shared/activity'; +import { type Translator } from '../shared/i18n/index'; +import type { ResolvedCopyInstall, ResolvedInstall, ResolvedManifest } from './manifest-types'; +import { type ControllerActivities, type ControllerNotifications, type ProcessControl } from './controller-deps'; +import { type GameProcess, type GameProcessLauncher } from './platform'; +import { findCaseInsensitiveName } from './manifest'; +import { LaunchAbortedError } from './launch-errors'; +import { removeWithRetry } from './remove-with-retry'; +import { describe } from './util'; +import { log } from './logger'; + +const DEFAULT_MAX_PARALLEL_COPY_INSTALLS = 2; + +const PARTIAL_SUFFIX = '.partial'; + +export type GameJobKind = 'install' | 'uninstall' | 'prefix-cleanup'; + +/** Why a job was stopped before it finished; `user` and `shutdown` are never reported as a failure. */ +export type JobAbortReason = 'user' | 'card-removed' | 'shutdown' | 'game-gone'; + +/** What a caller may know about a job: whose it is, what it does, and where the game comes from. */ +export interface GameJob { + readonly id: string; + readonly kind: GameJobKind; + readonly source: ManifestSource; +} + +interface Job extends GameJob { + readonly title: string; + readonly abort: AbortController; + readonly body: (job: Job) => Promise; + readonly done: Promise; + readonly settle: () => void; + proc: GameProcess | null; + abortReason: JobAbortReason | null; + running: boolean; +} + +/** What the jobs need back from the launcher. */ +export interface GameJobsHost { + /** The id of the game the busy session is about, or null when no session is in flight. */ + sessionGameId(): string | null; + /** A job changed what can be done with game `id` (Install / Play / Uninstall). */ + onGameChanged(id: string): void; + isWindowFocused(): boolean; + sendError(message: string): void; +} + +export interface GameJobsDeps { + readonly registry: ControllerActivities; + readonly launcher: Pick< + GameProcessLauncher, + 'prepareInstallDir' | 'resolveUninstaller' | 'launchUninstaller' | 'uninstallDir' | 'prefixCleanupDir' + >; + readonly processControl: Pick; + readonly notifications: ControllerNotifications; + readonly getTranslator: () => Translator; + readonly host: GameJobsHost; + /** How many copy installs run at once; the rest wait as `queued`. */ + readonly maxParallelCopies?: number; +} + +/** A failure the job reports with its own message rather than the cause's. */ +class JobFailure extends Error {} + +/** The copy-install limit: PLAYHOOK_MAX_PARALLEL_COPY_INSTALLS when it is a positive integer, otherwise 2. */ +export function maxParallelCopyInstalls(env: NodeJS.ProcessEnv = process.env): number { + const parsed = Number.parseInt(env['PLAYHOOK_MAX_PARALLEL_COPY_INSTALLS'] ?? '', 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_PARALLEL_COPY_INSTALLS; +} + +/** Where a copy install stages its files until they are complete: a sibling of the install dir. */ +export function partialDirOf(installDir: string): string { + return `${installDir}${PARTIAL_SUFFIX}`; +} + +function throwIfAborted(job: Job): void { + if (job.abort.signal.aborted) throw new LaunchAbortedError(); +} + +/** + * Background installs and uninstalls, one per game and any number of games at once. Each job keeps its + * own abort controller and process, shows itself as the game's activity in the registry, and never + * touches the session's AppState or the window. + */ +export class GameJobs { + private readonly jobs = new Map(); + private readonly maxParallelCopies: number; + + constructor(private readonly deps: GameJobsDeps) { + this.maxParallelCopies = deps.maxParallelCopies ?? maxParallelCopyInstalls(); + } + + private get t(): Translator { + return this.deps.getTranslator(); + } + + /** Registers the renderer's Cancel for a queued or running install. */ + init(): void { + ipcMain.on(IPC.actionCancelJob, (_event, id: unknown) => { + if (typeof id === 'string') this.cancel(id); + }); + } + + /** Starts (or queues) a copy install. False when the game is busy already or is not a copy install. */ + startInstall(manifest: ResolvedManifest): boolean { + const install = manifest.install; + if (install?.type !== 'copy') return false; + return this.enqueue(manifest, 'install', (job) => this.runCopy(job, manifest, install)); + } + + /** Starts removing an installed game from the PC. False when the game is busy already. */ + startUninstall(manifest: ResolvedManifest): boolean { + const install = manifest.install; + if (install === undefined) return false; + return this.enqueue(manifest, 'uninstall', (job) => this.runUninstall(job, install)); + } + + /** Starts clearing a plain game's Wine prefix. False when the game is busy already. */ + startPrefixCleanup(manifest: ResolvedManifest): boolean { + return this.enqueue(manifest, 'prefix-cleanup', (job) => this.runPrefixCleanup(job)); + } + + /** The user's Cancel: stops a queued or running install of `id`. False when there is none. */ + cancel(id: string): boolean { + const job = this.jobs.get(id); + if (job?.kind !== 'install') return false; + this.stop(job, 'user'); + return true; + } + + /** Stops every job `predicate` picks, queued or running, for `reason`. */ + abortWhere(predicate: (job: GameJob) => boolean, reason: JobAbortReason): void { + for (const job of [...this.jobs.values()]) { + if (predicate(job)) this.stop(job, reason); + } + } + + /** Stops every job and resolves once each has run its cleanup. */ + async abortAll(reason: JobAbortReason): Promise { + const pending = [...this.jobs.values()].map((job) => job.done); + this.abortWhere(() => true, reason); + await Promise.all(pending); + } + + has(id: string): boolean { + return this.jobs.has(id); + } + + /** Whether any job is queued or running. */ + anyActive(): boolean { + return this.jobs.size > 0; + } + + /** How many jobs are queued or running. */ + activeCount(): number { + return this.jobs.size; + } + + private enqueue(manifest: ResolvedManifest, kind: GameJobKind, body: (job: Job) => Promise): boolean { + const id = manifest.raw.id; + if (this.jobs.has(id) || this.deps.registry.has(id) || this.deps.host.sessionGameId() === id) { + log.info(`[jobs] refused ${kind} id=${id}: the game is busy`); + return false; + } + let settle: () => void = () => undefined; + const done = new Promise((resolve) => { + settle = resolve; + }); + const job: Job = { + id, + kind, + source: manifest.source, + title: manifest.raw.title, + abort: new AbortController(), + body, + done, + settle: () => settle(), + proc: null, + abortReason: null, + running: false, + }; + this.jobs.set(id, job); + if (kind === 'install' && this.runningCopies() >= this.maxParallelCopies) { + this.deps.registry.set(id, { kind: 'queued' }); + log.info(`[jobs] queued install id=${id}`); + return true; + } + this.launch(job); + return true; + } + + private runningCopies(): number { + return [...this.jobs.values()].filter((job) => job.kind === 'install' && job.running).length; + } + + private launch(job: Job): void { + job.running = true; + this.deps.registry.set(job.id, { kind: job.kind === 'install' ? 'installing' : 'uninstalling' }); + log.info(`[jobs] started ${job.kind} id=${job.id}`); + void this.execute(job); + } + + private stop(job: Job, reason: JobAbortReason): void { + if (job.abortReason !== null) return; + job.abortReason = reason; + job.abort.abort(); + log.info(`[jobs] stopping ${job.kind} id=${job.id} reason=${reason}`); + if (job.running) { + void job.proc?.kill().catch((cause: unknown) => log.warn(`[jobs] kill failed id=${job.id}:`, describe(cause))); + return; + } + this.failed(job, new LaunchAbortedError()); + this.finish(job); + } + + private pump(): void { + for (const job of this.jobs.values()) { + if (this.runningCopies() >= this.maxParallelCopies) return; + if (job.kind === 'install' && !job.running) this.launch(job); + } + } + + private finish(job: Job): void { + job.proc?.dispose(); + this.deps.registry.clear(job.id); + this.jobs.delete(job.id); + job.settle(); + this.pump(); + } + + private setActivity(job: Job, activity: GameActivity): void { + if (job.abortReason === null) this.deps.registry.set(job.id, activity); + } + + private async execute(job: Job): Promise { + try { + await job.body(job); + throwIfAborted(job); + this.succeeded(job); + } catch (cause) { + this.failed(job, cause); + } finally { + this.finish(job); + } + } + + private succeeded(job: Job): void { + log.info(`[jobs] ${job.kind} completed id=${job.id}`); + this.deps.registry.clear(job.id); + this.deps.host.onGameChanged(job.id); + if (job.kind === 'prefix-cleanup') return; + this.deps.notifications.notify({ + kind: job.kind === 'install' ? 'game-installed' : 'game-uninstalled', + gameId: job.id, + gameTitle: job.title, + }); + } + + private failed(job: Job, cause: unknown): void { + this.deps.registry.clear(job.id); + this.deps.host.onGameChanged(job.id); + const reason = this.failureReason(job, cause); + if (reason === null) { + log.info(`[jobs] ${job.kind} stopped id=${job.id} reason=${job.abortReason ?? 'aborted'}`); + return; + } + log.warn(`[jobs] ${job.kind} failed id=${job.id}: ${reason}`); + this.deps.notifications.notify({ + kind: job.kind === 'install' ? 'game-install-failed' : 'game-uninstall-failed', + gameId: job.id, + gameTitle: job.title, + reason, + }); + if (this.deps.host.isWindowFocused()) this.deps.host.sendError(reason); + } + + /** What to tell the user about a job that did not finish, or null when it is not worth a word. */ + private failureReason(job: Job, cause: unknown): string | null { + switch (job.abortReason) { + case 'user': + case 'shutdown': + return null; + case 'card-removed': + return this.t('errors.jobCardRemoved'); + case 'game-gone': + return this.t('errors.jobGameGone'); + case null: + if (cause instanceof LaunchAbortedError) return null; + if (cause instanceof JobFailure) return cause.message; + return job.kind === 'install' ? this.t('errors.copyGameFailed', { cause: describe(cause) }) : describe(cause); + } + } + + private async dropPartial(job: Job, install: ResolvedCopyInstall): Promise { + try { + await removeWithRetry(partialDirOf(install.dir)); + } catch (cause) { + log.warn(`[jobs] leftover ${PARTIAL_SUFFIX} of id=${job.id} could not be removed:`, describe(cause)); + } + } + + /** + * Copies the card's game directory to the PC. The files land in `.partial` first and move into + * `dir` only once complete and holding the executable, so a stopped copy never leaves a playable-looking + * game behind (`requiresInstall` is "the executable is missing"). + */ + private async runCopy(job: Job, manifest: ResolvedManifest, install: ResolvedCopyInstall): Promise { + try { + const partial = partialDirOf(install.dir); + await fse.remove(install.dir); + await fse.remove(partial); + await this.deps.launcher.prepareInstallDir(install, (active) => + this.setActivity(job, { kind: active ? 'configuringProton' : 'installing' }), + ); + throwIfAborted(job); + try { + await fse.copy(install.installerPath, partial, { + dereference: false, + filter: () => { + throwIfAborted(job); + return true; + }, + }); + } catch (cause) { + throwIfAborted(job); + throw new JobFailure(this.t('errors.copyGameFailed', { cause: describe(cause) })); + } + throwIfAborted(job); + const staged = path.join(partial, path.relative(install.dir, manifest.executablePath)); + if (!(await fse.pathExists(staged))) throw new JobFailure(await this.missingExeMessage(manifest, staged)); + await fse.remove(install.dir); + await fse.move(partial, install.dir); + log.info(`[install] copied id=${job.id} from="${install.installerPath}" to="${install.dir}"`); + } finally { + await this.dropPartial(job, install); + } + } + + private async missingExeMessage(manifest: ResolvedManifest, staged: string): Promise { + const shown = manifest.raw.executable ?? path.basename(manifest.executablePath); + const found = await findCaseInsensitiveName(staged); + return found !== null + ? this.t('errors.copyExeNotFoundCase', { path: shown, found }) + : this.t('errors.copyExeNotFound', { path: shown }); + } + + /** + * Removes an installed game: its own uninstaller first (never for `copy` — a copied directory's + * uninstaller belongs to an install made on another machine), then a sweep of the uninstall dir. + */ + private async runUninstall(job: Job, install: ResolvedInstall): Promise { + if (install.type !== 'copy') { + const target = await this.deps.launcher.resolveUninstaller(install); + if (target !== null) { + try { + job.proc = await this.deps.launcher.launchUninstaller(target); + await this.deps.processControl.waitForExit(job.proc, job.abort.signal); + } catch (cause) { + if (cause instanceof LaunchAbortedError) throw cause; + log.warn(`[uninstall] uninstaller failed, continuing to cleanup: ${describe(cause)}`); + } + } + } + throwIfAborted(job); + const dir = this.deps.launcher.uninstallDir(install); + await removeWithRetry(dir, job.abort.signal); + log.info(`[uninstall] removed id=${job.id} dir="${dir}"`); + } + + /** Clears a plain game's Wine prefix (Linux): the game stays on its card, only the prefix goes. */ + private async runPrefixCleanup(job: Job): Promise { + const dir = await this.deps.launcher.prefixCleanupDir(job.id); + if (dir === null) return; + await removeWithRetry(dir, job.abort.signal); + log.info(`[prefix-cleanup] removed "${dir}" id=${job.id}`); + } +} diff --git a/src/main/notifications-store.ts b/src/main/notifications-store.ts index 6c07a5b..dfd31ba 100644 --- a/src/main/notifications-store.ts +++ b/src/main/notifications-store.ts @@ -36,6 +36,24 @@ const notificationSchema = z.discriminatedUnion('kind', [ gameId: z.string(), gameTitle: z.string(), }), + z.object({ + kind: z.literal('game-install-failed'), + id: z.string(), + at: z.number(), + read: z.boolean(), + gameId: z.string(), + gameTitle: z.string(), + reason: z.string(), + }), + z.object({ + kind: z.literal('game-uninstall-failed'), + id: z.string(), + at: z.number(), + read: z.boolean(), + gameId: z.string(), + gameTitle: z.string(), + reason: z.string(), + }), z.object({ kind: z.literal('game-added-deferred'), id: z.string(), diff --git a/src/main/window.ts b/src/main/window.ts index fd0e064..183d2ab 100644 --- a/src/main/window.ts +++ b/src/main/window.ts @@ -194,6 +194,12 @@ export class GameWindow { return window !== null && window.isVisible() && !window.isMinimized(); } + /** Whether the launcher window has the OS focus right now. */ + isFocused(): boolean { + const window = this.window; + return window !== null && !window.isDestroyed() && window.isFocused(); + } + /** Allows the window to actually close (when quitting the app). */ allowClose(): void { this.closeGuard?.allowClose(); diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 84c6125..520e292 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -63,6 +63,7 @@ const CHANNELS = { activityUpdate: 'activity:update', activityRequest: 'activity:request', actionLaunch: 'action:launch', + actionCancelJob: 'action:cancel-job', actionUninstall: 'action:uninstall', actionHide: 'action:hide', actionQuit: 'action:quit', @@ -187,6 +188,9 @@ const api: RendererApi = { requestUninstall(): void { ipcRenderer.send(CHANNELS.actionUninstall); }, + cancelJob(id: string): void { + ipcRenderer.send(CHANNELS.actionCancelJob, id); + }, requestHide(): void { ipcRenderer.send(CHANNELS.actionHide); }, diff --git a/src/renderer/format.ts b/src/renderer/format.ts index 7f0b8a1..61c28a2 100644 --- a/src/renderer/format.ts +++ b/src/renderer/format.ts @@ -37,6 +37,10 @@ export function formatNotification(item: AppNotification, t: Translator): string return t('notifications.gameInstalled', { title: item.gameTitle }); case 'game-uninstalled': return t('notifications.gameUninstalled', { title: item.gameTitle }); + case 'game-install-failed': + return t('notifications.gameInstallFailed', { title: item.gameTitle, reason: item.reason }); + case 'game-uninstall-failed': + return t('notifications.gameUninstallFailed', { title: item.gameTitle, reason: item.reason }); case 'game-added-deferred': return t('notifications.gameAddedDeferred', { title: item.gameTitle }); case 'game-moved-deferred': diff --git a/src/shared/activity.ts b/src/shared/activity.ts index 9d5d110..294513f 100644 --- a/src/shared/activity.ts +++ b/src/shared/activity.ts @@ -16,6 +16,13 @@ export type GameActivity = | { readonly kind: 'steam-updating'; readonly paused: boolean; readonly pausedProgress?: number } | { readonly kind: 'steam-uninstalling' }; +/** What a failed install or uninstall notification names: the game, and why it failed. */ +export interface GameJobFailure { + readonly gameId: string; + readonly gameTitle: string; + readonly reason: string; +} + /** Every game's activity, keyed by game id. */ export type ActivityMap = Readonly>; diff --git a/src/shared/i18n/en.ts b/src/shared/i18n/en.ts index 254f661..ca8321e 100644 --- a/src/shared/i18n/en.ts +++ b/src/shared/i18n/en.ts @@ -264,6 +264,8 @@ export const en = { 'notifications.updateReady': 'Update {version} is ready — it will be installed on restart', 'notifications.gameInstalled': '{title} is installed', 'notifications.gameUninstalled': '{title} has been removed', + 'notifications.gameInstallFailed': '{title} could not be installed: {reason}', + 'notifications.gameUninstallFailed': '{title} could not be removed: {reason}', 'notifications.gameAddedDeferred': '{title} was written to the card. It shows up once that card is the active one.', 'notifications.gameMovedDeferred': @@ -486,6 +488,8 @@ export const en = { 'errors.startInstaller': 'failed to start the installer: {cause}', 'errors.installIncomplete': 'installation did not complete (the game executable did not appear)', 'errors.copyGameFailed': 'failed to copy the game to the PC: {cause}', + 'errors.jobCardRemoved': 'the card was removed before it finished', + 'errors.jobGameGone': 'the game is no longer in the library', 'errors.copyExeNotFound': 'the game was copied, but the executable is not there: {path} — check that the game directory points at the game’s own root', 'errors.copyExeNotFoundCase': diff --git a/src/shared/i18n/ru.ts b/src/shared/i18n/ru.ts index d54aa0c..4787a57 100644 --- a/src/shared/i18n/ru.ts +++ b/src/shared/i18n/ru.ts @@ -222,6 +222,8 @@ export const ru: Record = { 'notifications.updateReady': 'Обновление {version} готово - установится при перезапуске', 'notifications.gameInstalled': '{title} установлена', 'notifications.gameUninstalled': '{title} удалена', + 'notifications.gameInstallFailed': 'Не удалось установить {title}: {reason}', + 'notifications.gameUninstallFailed': 'Не удалось удалить {title}: {reason}', 'notifications.gameAddedDeferred': '{title} записана на карту. Появится, когда эта карта станет активной.', 'notifications.gameMovedDeferred': @@ -438,6 +440,8 @@ export const ru: Record = { 'errors.gameDidNotStart': 'игра не запустилась (истекло время ожидания процесса)', 'errors.startInstaller': 'не удалось запустить установщик: {cause}', 'errors.installIncomplete': 'установка не завершена (исполняемый файл игры не появился)', + 'errors.jobCardRemoved': 'карту вынули до завершения', + 'errors.jobGameGone': 'игры больше нет в библиотеке', 'errors.copyGameFailed': 'не удалось скопировать игру на ПК: {cause}', 'errors.copyExeNotFound': 'игра скопирована, но исполняемого файла на месте нет: {path} - проверьте, что директория игры указывает на её собственный корень', diff --git a/src/shared/types.ts b/src/shared/types.ts index 9f12b27..0342591 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -4,7 +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'; +import type { ActivityMap, GameJobFailure } from './activity'; // ── Card format: the few facts both sides must agree on ─────────────────────────────────────── @@ -408,6 +408,8 @@ export type AppNotification = readonly gameId: string; readonly gameTitle: string; }) + | (NotificationBase & GameJobFailure & { readonly kind: 'game-install-failed' }) + | (NotificationBase & GameJobFailure & { readonly kind: 'game-uninstall-failed' }) /** * A game was added to a card that is NOT the active one, so it was written to disk and nothing else * happened: the launcher's library cannot show it until that card becomes active. There is no `gameId` @@ -474,6 +476,8 @@ export const IPC = { actionLaunch: 'action:launch', /** renderer → main: the user confirmed "Uninstall" — remove the installed install-mode game. */ actionUninstall: 'action:uninstall', + /** renderer → main: cancel the queued or running install of the game with the given id. */ + actionCancelJob: 'action:cancel-job', /** renderer → main: hide the launcher window to the tray (the "Hide" button on the empty screen). */ actionHide: 'action:hide', /** renderer → main: quit the whole app. In Game Mode (gamescope) the power menu's primary item becomes @@ -1181,6 +1185,8 @@ export interface RendererApi { requestActivities(): Promise; requestLaunch(): void; requestUninstall(): void; + /** Cancel the queued or running install of game `id`. */ + cancelJob(id: string): void; requestHide(): void; /** Quit the whole app (Game Mode's "Close Playhook" — no tray to minimize into). */ requestQuit(): void; diff --git a/test/game-jobs.test.ts b/test/game-jobs.test.ts new file mode 100644 index 0000000..88ee9f0 --- /dev/null +++ b/test/game-jobs.test.ts @@ -0,0 +1,327 @@ +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 { GameJobs, maxParallelCopyInstalls, partialDirOf, type GameJobsHost } from '../src/main/game-jobs'; +import { createTranslator } from '../src/shared/i18n/index'; +import type { ResolvedManifest } from '../src/main/manifest-types'; +import type { GameProcess } from '../src/main/platform/types'; + +interface Gate { + readonly promise: Promise; + open(): void; + fail(error: Error): void; +} + +function gate(): Gate { + let open: () => void = () => undefined; + let fail: (error: Error) => void = () => undefined; + const promise = new Promise((resolve, reject) => { + open = resolve; + fail = reject; + }); + promise.catch(() => undefined); + return { promise, open: () => open(), fail: (error) => fail(error) }; +} + +async function waitFor(condition: () => boolean, what: string): Promise { + const deadline = Date.now() + 4000; + while (!condition()) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`); + await new Promise((resolve) => setTimeout(resolve, 5)); + } +} + +const exists = (file: string): Promise => + fs.access(file).then( + () => true, + () => false, + ); + +let tmp: string; + +interface Fixture { + readonly jobs: GameJobs; + readonly registry: ActivityRegistry; + readonly journal: string[]; + readonly gates: Map; + focused: boolean; + session: string | null; +} + +let fixture: Fixture; + +function build(maxParallelCopies = 2): Fixture { + const registry = new ActivityRegistry(); + const journal: string[] = []; + const gates = new Map(); + const state = { focused: false, session: null as string | null }; + const host: GameJobsHost = { + sessionGameId: () => state.session, + onGameChanged: (id) => journal.push(`changed:${id}`), + isWindowFocused: () => state.focused, + sendError: (message) => journal.push(`error:${message}`), + }; + const proc: GameProcess = { + pid: 1, + isAlive: () => Promise.resolve(true), + kill: () => Promise.resolve(), + dispose: () => journal.push('proc:dispose'), + }; + const jobs = new GameJobs({ + registry, + launcher: { + prepareInstallDir: (install) => { + const id = path.basename(install.dir); + journal.push(`prepare:${id}`); + return gates.get(id)?.promise ?? Promise.resolve(); + }, + resolveUninstaller: () => Promise.resolve(null), + launchUninstaller: () => Promise.resolve(proc), + uninstallDir: (install) => install.dir, + prefixCleanupDir: (id) => Promise.resolve(path.join(tmp, 'prefixes', id)), + }, + processControl: { waitForExit: () => Promise.resolve() }, + notifications: { + notify: (input) => { + journal.push(`notify:${input.kind}${'reason' in input ? `:${input.reason}` : ''}`); + }, + }, + getTranslator: () => createTranslator('en'), + host, + maxParallelCopies, + }); + return { + jobs, + registry, + journal, + gates, + get focused() { + return state.focused; + }, + set focused(value: boolean) { + state.focused = value; + }, + get session() { + return state.session; + }, + set session(value: string | null) { + state.session = value; + }, + }; +} + +/** A card game installed by copying `source/` (holding game.exe) into `installed/`. */ +async function copyGame(id: string, source: 'card' | 'pc' = 'card'): Promise { + const from = path.join(tmp, 'source', id); + await fs.mkdir(path.join(from, 'data'), { recursive: true }); + await fs.writeFile(path.join(from, 'game.exe'), 'exe'); + await fs.writeFile(path.join(from, 'data', 'pak.bin'), 'data'); + const dir = path.join(tmp, 'installed', id); + return { + raw: { + schemaVersion: 1, + id, + title: `Game ${id}`, + executable: 'game.exe', + args: [], + runAsAdmin: false, + launchTimeoutSec: 1, + killTimeoutSec: 1, + winetricks: [], + }, + root: tmp, + source, + executablePath: path.join(dir, 'game.exe'), + cwd: dir, + install: { + type: 'copy', + installerPath: from, + runAsAdmin: false, + args: [], + winetricks: [], + dir, + installerDir: dir, + }, + }; +} + +const installedExe = (id: string): string => path.join(tmp, 'installed', id, 'game.exe'); + +beforeEach(async () => { + tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'playhook-jobs-')); + fixture = build(); +}); + +afterEach(async () => { + await fixture.jobs.abortAll('shutdown'); + await fs.rm(tmp, { recursive: true, force: true }); +}); + +describe('GameJobs copy installs', () => { + it('two copies run side by side and finish independently, each with its own notification', async () => { + const a = await copyGame('a'); + const b = await copyGame('b'); + fixture.gates.set('a', gate()); + expect(fixture.jobs.startInstall(a)).toBe(true); + expect(fixture.jobs.startInstall(b)).toBe(true); + expect(fixture.registry.get('a')).toEqual({ kind: 'installing' }); + await waitFor(() => !fixture.jobs.has('b'), 'b to finish'); + expect(fixture.registry.has('b')).toBe(false); + expect(await exists(installedExe('b'))).toBe(true); + expect(fixture.registry.get('a')).toEqual({ kind: 'installing' }); + fixture.gates.get('a')?.open(); + await waitFor(() => !fixture.jobs.anyActive(), 'a to finish'); + expect(await exists(installedExe('a'))).toBe(true); + expect(fixture.journal.filter((entry) => entry.startsWith('notify:'))).toEqual([ + 'notify:game-installed', + 'notify:game-installed', + ]); + expect(await exists(partialDirOf(path.join(tmp, 'installed', 'a')))).toBe(false); + }); + + it('a third copy waits as queued under the limit of two and starts when one finishes', async () => { + const games = await Promise.all(['a', 'b', 'c'].map((id) => copyGame(id))); + for (const id of ['a', 'b']) fixture.gates.set(id, gate()); + for (const game of games) expect(fixture.jobs.startInstall(game)).toBe(true); + expect(fixture.registry.get('c')).toEqual({ kind: 'queued' }); + expect(fixture.journal).not.toContain('prepare:c'); + fixture.gates.get('a')?.open(); + await waitFor(() => fixture.journal.includes('prepare:c'), 'c to start'); + await waitFor(() => !fixture.jobs.has('c'), 'c to finish'); + expect(fixture.jobs.has('b')).toBe(true); + fixture.gates.get('b')?.open(); + }); + + it('cancelling a queued copy frees the game at once, silently', async () => { + const games = await Promise.all(['a', 'b', 'c'].map((id) => copyGame(id))); + for (const id of ['a', 'b']) fixture.gates.set(id, gate()); + for (const game of games) fixture.jobs.startInstall(game); + expect(fixture.jobs.cancel('c')).toBe(true); + expect(fixture.registry.has('c')).toBe(false); + expect(fixture.jobs.has('c')).toBe(false); + expect(fixture.journal.filter((entry) => entry.startsWith('notify:'))).toEqual([]); + for (const id of ['a', 'b']) fixture.gates.get(id)?.open(); + }); + + it('cancelling a running copy leaves no executable and no staging dir behind, and says nothing', async () => { + const a = await copyGame('a'); + fixture.gates.set('a', gate()); + fixture.jobs.startInstall(a); + fixture.jobs.cancel('a'); + fixture.gates.get('a')?.open(); + await waitFor(() => !fixture.jobs.has('a'), 'the cancelled copy to unwind'); + expect(await exists(installedExe('a'))).toBe(false); + expect(await exists(partialDirOf(path.join(tmp, 'installed', 'a')))).toBe(false); + expect(fixture.registry.has('a')).toBe(false); + expect(fixture.journal.filter((entry) => entry.startsWith('notify:'))).toEqual([]); + }); + + it('a copy stopped because its card was pulled is reported with that reason', async () => { + const a = await copyGame('a'); + fixture.gates.set('a', gate()); + fixture.jobs.startInstall(a); + fixture.jobs.abortWhere((job) => job.kind === 'install' && job.source === 'card', 'card-removed'); + fixture.gates.get('a')?.open(); + await waitFor(() => !fixture.jobs.has('a'), 'the aborted copy to unwind'); + expect(await exists(installedExe('a'))).toBe(false); + expect(fixture.journal).toContain('notify:game-install-failed:the card was removed before it finished'); + }); + + it('a failure in the body clears the activity, notifies, and shows the error only while the window has focus', async () => { + const a = await copyGame('a'); + fixture.gates.set('a', gate()); + fixture.focused = true; + fixture.jobs.startInstall(a); + fixture.gates.get('a')?.fail(new Error('prefix boom')); + await waitFor(() => !fixture.jobs.has('a'), 'the failed copy to unwind'); + expect(fixture.registry.has('a')).toBe(false); + expect(fixture.journal).toContain('notify:game-install-failed:failed to copy the game to the PC: prefix boom'); + expect(fixture.journal).toContain('error:failed to copy the game to the PC: prefix boom'); + + const b = await copyGame('b'); + fixture.focused = false; + fixture.gates.set('b', gate()); + fixture.jobs.startInstall(b); + fixture.gates.get('b')?.fail(new Error('again')); + await waitFor(() => !fixture.jobs.has('b'), 'the second failed copy to unwind'); + expect(fixture.journal.filter((entry) => entry.startsWith('error:'))).toHaveLength(1); + }); + + it('a copy whose executable is not in the copied files fails without moving anything into place', async () => { + const a = await copyGame('a'); + await fs.rm(path.join(tmp, 'source', 'a', 'game.exe')); + fixture.jobs.startInstall(a); + await waitFor(() => !fixture.jobs.has('a'), 'the copy to fail'); + expect(await exists(path.join(tmp, 'installed', 'a'))).toBe(false); + expect(fixture.journal.some((entry) => entry.startsWith('notify:game-install-failed:'))).toBe(true); + }); + + it('refuses a game that already has an activity, a job, or is the session game', async () => { + const a = await copyGame('a'); + fixture.registry.set('a', { kind: 'steam-uninstalling' }); + expect(fixture.jobs.startInstall(a)).toBe(false); + fixture.registry.clear('a'); + fixture.session = 'a'; + expect(fixture.jobs.startInstall(a)).toBe(false); + fixture.session = null; + fixture.gates.set('a', gate()); + expect(fixture.jobs.startInstall(a)).toBe(true); + expect(fixture.jobs.startInstall(a)).toBe(false); + fixture.gates.get('a')?.open(); + }); +}); + +describe('GameJobs uninstall and prefix cleanup', () => { + it('uninstall sweeps the install dir and notifies', async () => { + const a = await copyGame('a'); + await fs.mkdir(path.join(tmp, 'installed', 'a'), { recursive: true }); + await fs.writeFile(installedExe('a'), 'exe'); + expect(fixture.jobs.startUninstall(a)).toBe(true); + expect(fixture.registry.get('a')).toEqual({ kind: 'uninstalling' }); + await waitFor(() => !fixture.jobs.has('a'), 'the uninstall to finish'); + expect(await exists(path.join(tmp, 'installed', 'a'))).toBe(false); + expect(fixture.journal).toEqual(['changed:a', 'notify:game-uninstalled']); + }); + + it('an uninstall is not something the user cancels from the launcher', async () => { + const a = await copyGame('a'); + fixture.jobs.startUninstall(a); + expect(fixture.jobs.cancel('a')).toBe(false); + await waitFor(() => !fixture.jobs.has('a'), 'the uninstall to finish'); + }); + + it('prefix cleanup removes the prefix without a notification', async () => { + const a = await copyGame('a'); + const prefix = path.join(tmp, 'prefixes', 'a'); + await fs.mkdir(prefix, { recursive: true }); + fixture.jobs.startPrefixCleanup(a); + await waitFor(() => !fixture.jobs.has('a'), 'the cleanup to finish'); + expect(await exists(prefix)).toBe(false); + expect(fixture.journal).toEqual(['changed:a']); + }); +}); + +describe('GameJobs shutdown', () => { + it('abortAll stops everything and resolves only once each job has cleaned up, silently', async () => { + const games = await Promise.all(['a', 'b', 'c'].map((id) => copyGame(id))); + for (const id of ['a', 'b']) fixture.gates.set(id, gate()); + for (const game of games) fixture.jobs.startInstall(game); + const stopped = fixture.jobs.abortAll('shutdown'); + for (const id of ['a', 'b']) fixture.gates.get(id)?.open(); + await stopped; + expect(fixture.jobs.anyActive()).toBe(false); + expect(fixture.registry.snapshot()).toEqual({}); + expect(fixture.journal.filter((entry) => entry.startsWith('notify:'))).toEqual([]); + for (const id of ['a', 'b']) expect(await exists(partialDirOf(path.join(tmp, 'installed', id)))).toBe(false); + }); +}); + +describe('maxParallelCopyInstalls', () => { + it('reads PLAYHOOK_MAX_PARALLEL_COPY_INSTALLS and falls back to two', () => { + expect(maxParallelCopyInstalls({})).toBe(2); + expect(maxParallelCopyInstalls({ PLAYHOOK_MAX_PARALLEL_COPY_INSTALLS: '3' })).toBe(3); + expect(maxParallelCopyInstalls({ PLAYHOOK_MAX_PARALLEL_COPY_INSTALLS: '0' })).toBe(2); + }); +}); From 1bdffc8c1bc7c677db83946756aff1cb8ae51b67 Mon Sep 17 00:00:00 2001 From: Alexander Date: Mon, 5 Oct 2026 17:32:06 +0500 Subject: [PATCH 03/10] Run copy installs, uninstalls and prefix cleanups as per-game jobs --- src/main/activity-registry.ts | 4 +- src/main/controller-deps.ts | 2 +- src/main/game-controller.ts | 40 +++- src/main/game-jobs.ts | 91 +++++--- src/main/game-move-transaction.ts | 6 +- src/main/game-sequences.ts | 170 +-------------- src/main/main.ts | 10 +- src/main/steam-activity-watch.ts | 16 +- src/renderer/app.ts | 19 +- src/renderer/chatter.ts | 16 +- src/renderer/controls-deps.ts | 2 + src/renderer/details-menu.ts | 8 +- src/renderer/game-settings-screen.ts | 4 +- src/renderer/popups.ts | 19 +- src/renderer/state-view.ts | 7 +- src/shared/activity.ts | 5 +- src/shared/i18n/en.ts | 2 + src/shared/i18n/ru.ts | 2 + test/game-controller.test.ts | 306 +++++++++++++++++++-------- test/game-jobs.test.ts | 58 +++-- test/provision-lock.test.ts | 6 +- test/renderer/controls.test.ts | 38 ++++ test/state-view.test.ts | 41 +++- test/steam-activity-watch.test.ts | 27 ++- 24 files changed, 531 insertions(+), 368 deletions(-) diff --git a/src/main/activity-registry.ts b/src/main/activity-registry.ts index 1646bd2..3b33cab 100644 --- a/src/main/activity-registry.ts +++ b/src/main/activity-registry.ts @@ -40,7 +40,9 @@ export class ActivityRegistry { /** Frees `id`; a game that carries no activity changes nothing and announces nothing. */ clear(id: string): void { if (this.activities[id] === undefined) return; - this.activities = Object.fromEntries(Object.entries(this.activities).filter(([key]) => key !== id)); + this.activities = Object.fromEntries( + Object.entries(this.activities).filter(([key]) => key !== id), + ); this.emit(); } diff --git a/src/main/controller-deps.ts b/src/main/controller-deps.ts index 0e5d7f8..41a22f0 100644 --- a/src/main/controller-deps.ts +++ b/src/main/controller-deps.ts @@ -58,7 +58,7 @@ export interface ProcessControl { // unit test can stand in a fake per seam (see test/game-controller.test.ts) — the classes themselves reach // for electron and the disk. export type ControllerState = Pick; -export type ControllerWindow = Pick; +export type ControllerWindow = Pick; export type ControllerStore = Pick< PcStore, | 'getPending' diff --git a/src/main/game-controller.ts b/src/main/game-controller.ts index b09bbc4..3ef39e2 100644 --- a/src/main/game-controller.ts +++ b/src/main/game-controller.ts @@ -39,6 +39,7 @@ import { BrowsePresenter } from './browse-presenter'; import { GameSequences } from './game-sequences'; import { SaveSyncFlow } from './save-sync-flow'; import { SteamActivityWatch, type SteamWatchEntry } from './steam-activity-watch'; +import { GameJobs } from './game-jobs'; import { describe } from './util'; import { log } from './logger'; @@ -108,8 +109,8 @@ export class GameController { readBrowseAssets: (id) => this.deps.library.readBrowseAssets(id), onlyGlobalAmbient: async () => (await this.deps.settings.read()).onlyGlobalAmbient, }); - // Polls Steam's .acf state of every available Steam game and keeps each one's activity in the registry. private readonly steamWatch: SteamActivityWatch; + readonly jobs: GameJobs; // 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. @@ -123,8 +124,7 @@ export class GameController { 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))), + onGameChanged: (id) => this.refreshGame(id), onInstallCompleted: (game) => this.deps.notifications.notify({ kind: 'game-installed', @@ -139,6 +139,15 @@ export class GameController { }), steamLocator: () => this.deps.platform.steamLocator, }); + this.jobs = new GameJobs({ + ...this.deps, + host: { + sessionGameId: () => this.sessionGameId(), + onGameChanged: (id) => this.refreshGame(id), + isWindowFocused: () => this.deps.window.isFocused(), + sendError: (message) => this.sendError(message), + }, + }); this.saveSync = new SaveSyncFlow({ store: this.deps.store, stats: this.deps.stats, @@ -318,6 +327,7 @@ export class GameController { watcher.onInsert((root) => void this.onInsert(root)); watcher.onRemove(() => this.onRemove()); watcher.onError((error) => log.error('[drive-watcher]', error)); + this.jobs.init(); ipcMain.handle(IPC.stateRequest, (): AppState => state.get()); // Static for the process lifetime — seeds the renderer's Game Mode UI (e.g. "Close Playhook"). @@ -402,6 +412,7 @@ export class GameController { this.presenter.dispose(); this.sequences.abortInFlight(); this.steamWatch.stop(); + void this.jobs.abortAll('shutdown'); this.deps.watcher.stop(); } @@ -424,8 +435,13 @@ export class GameController { } } + /** onGameChanged for the watch and the jobs, which fire and forget it. */ + private refreshGame(id: string): void { + void this.onGameChanged(id).catch((cause: unknown) => log.warn(`[refresh] id=${id} failed:`, describe(cause))); + } + /** - * Steam changed what can be done with game `id`: rebuilds its GameInfo and puts it wherever it is shown. + * Steam or a job 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. */ @@ -706,6 +722,7 @@ export class GameController { await this.browseToUnlessPinned(selected.raw.id); } await this.refreshBrowsedLocalGame(); + this.jobs.abortGone(this.games.map((manifest) => manifest.raw.id)); return { ok: true }; } finally { this.reloadInFlight = false; @@ -916,7 +933,9 @@ export class GameController { if (this.reloadInFlight) return { ok: false, message: this.t('errors.reloadInProgress') }; this.reloadInFlight = true; try { - return await this.loadCard(root, { focus: false }); + const result = await this.loadCard(root, { focus: false }); + this.jobs.abortGone(this.games.map((manifest) => manifest.raw.id)); + return result; } finally { this.reloadInFlight = false; } @@ -925,6 +944,7 @@ export class GameController { // ── Reaction to card removal ───────────────────────────────────────────── private onRemove(): void { + this.jobs.abortWhere((job) => job.kind === 'install' && job.source === 'card', 'card-removed'); this.cardPresent = false; const kind = this.deps.state.get().kind; // During play/sync, removal is expected: the flow continues, sync-out @@ -1031,7 +1051,9 @@ export class GameController { } // Card-install mode + not yet installed → run the installer; otherwise it's an ordinary launch // (this includes a fully-installed game, whose executable now exists → requiresInstall=false). - if (manifest.install !== undefined && snapshot.game.requiresInstall) { + if (manifest.install?.type === 'copy' && snapshot.game.requiresInstall) { + this.jobs.startInstall(manifest); + } else if (manifest.install !== undefined && snapshot.game.requiresInstall) { void this.sequences.runInstallSequence(manifest, snapshot.game); } else { void this.sequences.runLaunchSequence(manifest, snapshot.game); @@ -1092,12 +1114,10 @@ export class GameController { if (manifest.install === undefined) { // Normal executable game: the only "uninstall" is clearing its Wine prefix (Linux; the game stays on // the card). canUninstall is set only when that prefix exists — see buildGameInfo / prefixCleanupOnly. - if (snapshot.game.prefixCleanupOnly === true) { - void this.sequences.runPrefixCleanupSequence(manifest, snapshot.game); - } + if (snapshot.game.prefixCleanupOnly === true) this.jobs.startPrefixCleanup(manifest); return; } - void this.sequences.runUninstallSequence(manifest, snapshot.game); + this.jobs.startUninstall(manifest); } /** diff --git a/src/main/game-jobs.ts b/src/main/game-jobs.ts index 99cac63..a49b928 100644 --- a/src/main/game-jobs.ts +++ b/src/main/game-jobs.ts @@ -5,8 +5,8 @@ import { IPC, type ManifestSource } from '../shared/types'; import { type GameActivity } from '../shared/activity'; import { type Translator } from '../shared/i18n/index'; import type { ResolvedCopyInstall, ResolvedInstall, ResolvedManifest } from './manifest-types'; -import { type ControllerActivities, type ControllerNotifications, type ProcessControl } from './controller-deps'; -import { type GameProcess, type GameProcessLauncher } from './platform'; +import { type ControllerDeps } from './controller-deps'; +import { type GameProcess } from './platform'; import { findCaseInsensitiveName } from './manifest'; import { LaunchAbortedError } from './launch-errors'; import { removeWithRetry } from './remove-with-retry'; @@ -50,19 +50,25 @@ export interface GameJobsHost { sendError(message: string): void; } -export interface GameJobsDeps { - readonly registry: ControllerActivities; - readonly launcher: Pick< - GameProcessLauncher, - 'prepareInstallDir' | 'resolveUninstaller' | 'launchUninstaller' | 'uninstallDir' | 'prefixCleanupDir' - >; - readonly processControl: Pick; - readonly notifications: ControllerNotifications; - readonly getTranslator: () => Translator; +export type GameJobsDeps = Pick< + ControllerDeps, + 'activities' | 'notifications' | 'getTranslator' +> & { + readonly platform: { + readonly gameLauncher: Pick< + ControllerDeps['platform']['gameLauncher'], + | 'prepareInstallDir' + | 'resolveUninstaller' + | 'launchUninstaller' + | 'uninstallDir' + | 'prefixCleanupDir' + >; + }; + readonly processControl: Pick; readonly host: GameJobsHost; /** How many copy installs run at once; the rest wait as `queued`. */ readonly maxParallelCopies?: number; -} +}; /** A failure the job reports with its own message rather than the cause's. */ class JobFailure extends Error {} @@ -140,6 +146,11 @@ export class GameJobs { } } + /** Stops the jobs of games no longer among `ids`: a reload dropped them, and nothing names them now. */ + abortGone(ids: readonly string[]): void { + this.abortWhere((job) => !ids.includes(job.id), 'game-gone'); + } + /** Stops every job and resolves once each has run its cleanup. */ async abortAll(reason: JobAbortReason): Promise { const pending = [...this.jobs.values()].map((job) => job.done); @@ -161,9 +172,17 @@ export class GameJobs { return this.jobs.size; } - private enqueue(manifest: ResolvedManifest, kind: GameJobKind, body: (job: Job) => Promise): boolean { + private enqueue( + manifest: ResolvedManifest, + kind: GameJobKind, + body: (job: Job) => Promise, + ): boolean { const id = manifest.raw.id; - if (this.jobs.has(id) || this.deps.registry.has(id) || this.deps.host.sessionGameId() === id) { + if ( + this.jobs.has(id) || + this.deps.activities.has(id) || + this.deps.host.sessionGameId() === id + ) { log.info(`[jobs] refused ${kind} id=${id}: the game is busy`); return false; } @@ -186,7 +205,7 @@ export class GameJobs { }; this.jobs.set(id, job); if (kind === 'install' && this.runningCopies() >= this.maxParallelCopies) { - this.deps.registry.set(id, { kind: 'queued' }); + this.deps.activities.set(id, { kind: 'queued' }); log.info(`[jobs] queued install id=${id}`); return true; } @@ -200,7 +219,9 @@ export class GameJobs { private launch(job: Job): void { job.running = true; - this.deps.registry.set(job.id, { kind: job.kind === 'install' ? 'installing' : 'uninstalling' }); + this.deps.activities.set(job.id, { + kind: job.kind === 'install' ? 'installing' : 'uninstalling', + }); log.info(`[jobs] started ${job.kind} id=${job.id}`); void this.execute(job); } @@ -211,7 +232,9 @@ export class GameJobs { job.abort.abort(); log.info(`[jobs] stopping ${job.kind} id=${job.id} reason=${reason}`); if (job.running) { - void job.proc?.kill().catch((cause: unknown) => log.warn(`[jobs] kill failed id=${job.id}:`, describe(cause))); + void job.proc + ?.kill() + .catch((cause: unknown) => log.warn(`[jobs] kill failed id=${job.id}:`, describe(cause))); return; } this.failed(job, new LaunchAbortedError()); @@ -227,14 +250,14 @@ export class GameJobs { private finish(job: Job): void { job.proc?.dispose(); - this.deps.registry.clear(job.id); this.jobs.delete(job.id); + this.deps.activities.clear(job.id); job.settle(); this.pump(); } private setActivity(job: Job, activity: GameActivity): void { - if (job.abortReason === null) this.deps.registry.set(job.id, activity); + if (job.abortReason === null) this.deps.activities.set(job.id, activity); } private async execute(job: Job): Promise { @@ -251,7 +274,6 @@ export class GameJobs { private succeeded(job: Job): void { log.info(`[jobs] ${job.kind} completed id=${job.id}`); - this.deps.registry.clear(job.id); this.deps.host.onGameChanged(job.id); if (job.kind === 'prefix-cleanup') return; this.deps.notifications.notify({ @@ -262,7 +284,6 @@ export class GameJobs { } private failed(job: Job, cause: unknown): void { - this.deps.registry.clear(job.id); this.deps.host.onGameChanged(job.id); const reason = this.failureReason(job, cause); if (reason === null) { @@ -292,7 +313,9 @@ export class GameJobs { case null: if (cause instanceof LaunchAbortedError) return null; if (cause instanceof JobFailure) return cause.message; - return job.kind === 'install' ? this.t('errors.copyGameFailed', { cause: describe(cause) }) : describe(cause); + return job.kind === 'install' + ? this.t('errors.copyGameFailed', { cause: describe(cause) }) + : describe(cause); } } @@ -300,7 +323,10 @@ export class GameJobs { try { await removeWithRetry(partialDirOf(install.dir)); } catch (cause) { - log.warn(`[jobs] leftover ${PARTIAL_SUFFIX} of id=${job.id} could not be removed:`, describe(cause)); + log.warn( + `[jobs] leftover ${PARTIAL_SUFFIX} of id=${job.id} could not be removed:`, + describe(cause), + ); } } @@ -309,12 +335,16 @@ export class GameJobs { * `dir` only once complete and holding the executable, so a stopped copy never leaves a playable-looking * game behind (`requiresInstall` is "the executable is missing"). */ - private async runCopy(job: Job, manifest: ResolvedManifest, install: ResolvedCopyInstall): Promise { + private async runCopy( + job: Job, + manifest: ResolvedManifest, + install: ResolvedCopyInstall, + ): Promise { try { const partial = partialDirOf(install.dir); await fse.remove(install.dir); await fse.remove(partial); - await this.deps.launcher.prepareInstallDir(install, (active) => + await this.deps.platform.gameLauncher.prepareInstallDir(install, (active) => this.setActivity(job, { kind: active ? 'configuringProton' : 'installing' }), ); throwIfAborted(job); @@ -332,7 +362,8 @@ export class GameJobs { } throwIfAborted(job); const staged = path.join(partial, path.relative(install.dir, manifest.executablePath)); - if (!(await fse.pathExists(staged))) throw new JobFailure(await this.missingExeMessage(manifest, staged)); + if (!(await fse.pathExists(staged))) + throw new JobFailure(await this.missingExeMessage(manifest, staged)); await fse.remove(install.dir); await fse.move(partial, install.dir); log.info(`[install] copied id=${job.id} from="${install.installerPath}" to="${install.dir}"`); @@ -355,10 +386,10 @@ export class GameJobs { */ private async runUninstall(job: Job, install: ResolvedInstall): Promise { if (install.type !== 'copy') { - const target = await this.deps.launcher.resolveUninstaller(install); + const target = await this.deps.platform.gameLauncher.resolveUninstaller(install); if (target !== null) { try { - job.proc = await this.deps.launcher.launchUninstaller(target); + job.proc = await this.deps.platform.gameLauncher.launchUninstaller(target); await this.deps.processControl.waitForExit(job.proc, job.abort.signal); } catch (cause) { if (cause instanceof LaunchAbortedError) throw cause; @@ -367,14 +398,14 @@ export class GameJobs { } } throwIfAborted(job); - const dir = this.deps.launcher.uninstallDir(install); + const dir = this.deps.platform.gameLauncher.uninstallDir(install); await removeWithRetry(dir, job.abort.signal); log.info(`[uninstall] removed id=${job.id} dir="${dir}"`); } /** Clears a plain game's Wine prefix (Linux): the game stays on its card, only the prefix goes. */ private async runPrefixCleanup(job: Job): Promise { - const dir = await this.deps.launcher.prefixCleanupDir(job.id); + const dir = await this.deps.platform.gameLauncher.prefixCleanupDir(job.id); if (dir === null) return; await removeWithRetry(dir, job.abort.signal); log.info(`[prefix-cleanup] removed "${dir}" id=${job.id}`); diff --git a/src/main/game-move-transaction.ts b/src/main/game-move-transaction.ts index 4f3a3e9..373dcd1 100644 --- a/src/main/game-move-transaction.ts +++ b/src/main/game-move-transaction.ts @@ -87,10 +87,10 @@ export interface GameMoveDeps { */ readonly resolveManifest: (id: string) => ResolvedManifest | null; /** - * Whether ANY game is currently running/installing/uninstalling (GameController.isBusy) — moveToCard's + * Whether game `id` is the session's or carries an activity (GameController.isBusy) — moveToCard's * own re-check of the guard the "Move to card…" menu item already applies in the renderer. */ - readonly isBusy: () => boolean; + readonly isBusy: (id: string) => boolean; /** Drops a game's sync-state baseline (PcStore.removeSyncState) — moveToCard clears the "pc" slot once * a game leaves the library: the local backup ↔ save-folder pairing it described is gone. */ readonly pcStore: Pick; @@ -137,7 +137,7 @@ export class GameMoveTransaction { ) { return { moved: false, message: t('errors.mediaChanged') }; } - if (this.deps.isBusy()) { + if (this.deps.isBusy(request.fromId)) { return { moved: false, message: t('gameConfig.moveGameBusy') }; } // A move must not rename: everything this PC remembers about the game is keyed by id (stats, the diff --git a/src/main/game-sequences.ts b/src/main/game-sequences.ts index f948cd8..1c034bd 100644 --- a/src/main/game-sequences.ts +++ b/src/main/game-sequences.ts @@ -10,20 +10,17 @@ // `failSequence` for anything else, and a finally that releases the owned process, clears the flags and // replays a card that was swapped in mid-flight. Launch alone adds to the finally (the lock and the // running-game fields) through `onFinally`. -import path from 'node:path'; import fse from 'fs-extra'; import { type AppState, type GameInfo, type Stats } from '../shared/types'; -import type { ResolvedCopyInstall, ResolvedManifest } from './manifest-types'; +import type { ResolvedManifest } from './manifest-types'; import { type Translator } from '../shared/i18n/index'; import { type ControllerDeps } from './controller-deps'; -import { findCaseInsensitiveName } from './manifest'; 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 SteamActivityWatch } from './steam-activity-watch'; import { type SaveSyncFlow } from './save-sync-flow'; -import { removeWithRetry } from './remove-with-retry'; import { describe, delay } from './util'; import { log } from './logger'; @@ -444,34 +441,6 @@ export class GameSequences { } } - /** - * Clears a normal executable game's Wine prefix (Linux). No installer/uninstaller is involved — the game - * lives on the card, its only PC footprint is the prefix — so this is just the directory sweep + the same - * card-swap / rebuild-info handling as runUninstallSequence, minus the uninstaller run. - */ - async runPrefixCleanupSequence(manifest: ResolvedManifest, info: GameInfo): Promise { - const dir = await this.deps.platform.gameLauncher.prefixCleanupDir(manifest.raw.id); - if (dir === null) return; // defensive: canUninstall was set only when the prefix existed - const { window, stats } = this.deps; - await this.runSequence('uninstall', 'uninstalling', info, async (abort) => { - await removeWithRetry(dir, abort.signal); - if (abort.signal.aborted) return; - // Card yanked mid-cleanup (this targets the PC, so it completed): idle + hide, like runUninstall. - // A local game's source cannot go away, so it always continues to the rebuild below. - if (!this.host.sourceAvailable(manifest)) { - this.host.cardGoneAfterSequence(); - return; - } - // Prefix gone → prefixCleanupDir now returns null → canUninstall recomputes false → "Uninstall" - // disappears, leaving just "Play". - const currentStats = await stats.read(manifest.raw.id); - const updatedInfo = await this.host.buildGameInfo(manifest, currentStats); - log.info(`[prefix-cleanup] removed "${dir}" id=${manifest.raw.id}`); - this.host.enterReady(updatedInfo); - window.showAndFocus(); - }); - } - async runLaunchSequence(manifest: ResolvedManifest, info: GameInfo): Promise { const { state, window, stats } = this.deps; // Lock the launcher on this game for the launching→running span: a game switch is refused and the @@ -705,10 +674,7 @@ export class GameSequences { // a bogus "Play". We're (re)installing anyway, so a clean directory is safe. await fse.remove(install.dir); - if (install.type === 'copy') { - // "Move game to PC": no installer to run — copy the card's game directory into the install dir. - if (!(await this.runCopyInstall(install, manifest, info, abort))) return; - } else { + if (install.type !== 'copy') { // Silent by default; a user who enabled "disable silent installer mode" gets the visible wizard // (needed for repacks that skip a crack/patch step under silent — `skipifsilent`). const silent = !(await this.deps.settings.read()).disableSilentInstall; @@ -759,72 +725,6 @@ export class GameSequences { }); } - /** - * The `copy` install type ("move game to PC"): instead of running an installer, copy the game - * directory from the card into the app-controlled install dir. Called by runInstallSequence, which - * owns the state/abort infrastructure and the shared tail — this only covers copy's own steps. - * - * Returns true when the game is in place and the caller should finish the sequence; false when it must - * stop (a failure was already surfaced, or the sequence was aborted and must unwind silently). - */ - private async runCopyInstall( - install: ResolvedCopyInstall, - manifest: ResolvedManifest, - info: GameInfo, - abort: AbortController, - ): Promise { - // Prepare the destination's environment BEFORE the files land in it (linux: create + provision the - // Wine prefix; win32: no-op). This is what launchInstaller does implicitly on the installer path — - // without it a copied game would sit in a bare prefix with none of the baseline runtimes that the - // installer it originally came from would have pulled in. A failure here propagates to the caller's - // catch (it is an environment fault, like a failed installer launch). - await this.launcher.prepareInstallDir(install, (active) => this.setProvisioning(active, info)); - - try { - // `dereference: false` — copy symlinks as symlinks (a game's own internal links stay internal). - await fse.copy(install.installerPath, install.dir, { dereference: false }); - } catch (cause) { - // fse.copy takes no AbortSignal, so a card swap mid-copy surfaces as a plain ENOENT (the source - // vanished) rather than a LaunchAbortedError. Check the flag before reporting: the new card is - // already on screen, and an error popup about the old one over it would be nonsense. - if (abort.signal.aborted) return false; - this.failSequence( - 'install', - info, - this.t('errors.copyGameFailed', { cause: describe(cause) }), - ); - return false; - } - - // Same reason as in runUninstallSequence: the copy itself isn't interruptible, so check the abort - // flag manually before touching any state. - if (abort.signal.aborted) return false; - - // A single existence check, not pollForExecutable: the grace-poll exists for installers that fork a - // child and exit early, whereas fse.copy is done when it resolves. Polling would only add - // launchTimeoutSec of waiting on an already-known-bad path. - if (!(await fse.pathExists(manifest.executablePath))) { - // The usual cause is a wrong source root: `executable` is card-relative in the form, but here it - // resolves inside the copied directory. Second most likely on linux: a Windows-authored card whose - // exe case doesn't match the files copied onto a case-sensitive FS — say so instead of "not found". - const shown = manifest.raw.executable ?? path.basename(manifest.executablePath); - const found = await findCaseInsensitiveName(manifest.executablePath); - this.failSequence( - 'install', - info, - found !== null - ? this.t('errors.copyExeNotFoundCase', { path: shown, found }) - : this.t('errors.copyExeNotFound', { path: shown }), - ); - return false; - } - - log.info( - `[install] copied id=${manifest.raw.id} from="${install.installerPath}" to="${install.dir}"`, - ); - return true; - } - /** * Polls for the game executable to appear within `timeoutSec` (grace window after the installer * exits). Throws LaunchAbortedError if aborted, so a mid-install card swap unwinds WITHOUT @@ -844,72 +744,6 @@ export class GameSequences { } } - /** - * Uninstalls an installed install-mode game (mirrors runInstallSequence's infrastructure: - * launchInFlight/abort, the LaunchAbortedError guard, the pendingRoot replay). Runs the game's own - * uninstaller (best-effort — it cleans the registry/shortcuts), then ALWAYS sweeps the app-controlled - * install dir, so on success the executable is gone → requiresInstall recomputes true → "Install". - */ - async runUninstallSequence(manifest: ResolvedManifest, info: GameInfo): Promise { - const install = manifest.install; - if (install === undefined) return; // defensive: onUninstallRequested only calls this in install mode - const { window, stats } = this.deps; - await this.runSequence('uninstall', 'uninstalling', info, async (abort, owned) => { - // Run the game's own uninstaller if we can resolve one (FS search → registry fallback). Any - // launch/wait failure is NON-fatal: we log it and fall through to the directory sweep. Only a - // LaunchAbortedError (from waitForExit on a card swap) propagates to unwind cleanly. - // - // `copy` is skipped entirely: nothing was installed, so there is no uninstaller of OURS to run. - // A copied game directory is one that was installed on some OTHER machine, so any `unins*.exe` - // inside it belongs to that install — running it would clean a foreign registry and might pop a - // wizard. Straight to the sweep instead (which is the whole uninstall for copy). - if (install.type !== 'copy') { - const target = await this.launcher.resolveUninstaller(install); - if (target !== null) { - try { - const proc = await this.launcher.launchUninstaller(target); - owned.proc = proc; - await this.deps.processControl.waitForExit(proc, abort.signal); - } catch (cause) { - if (cause instanceof LaunchAbortedError) throw cause; - log.warn(`[uninstall] uninstaller failed, continuing to cleanup: ${describe(cause)}`); - } - } - } - - // Sweep the platform's uninstall target — after the uninstaller, and as the fallback when no target - // was resolved (custom / nothing found). win32: the install dir. linux: the whole per-game Wine - // prefix (game files + provisioned runtimes), so the full disk footprint is reclaimed. - const uninstallDir = this.launcher.uninstallDir(install); - await removeWithRetry(uninstallDir, abort.signal); - - // fse.remove is NOT interrupted by the signal (unlike waitForExit), so check the abort flag - // manually — strictly BEFORE reading cardPresent / rebuilding info — so a mid-uninstall card swap - // doesn't set state over the new card (the finally → resumePendingInsert handles it). - if (abort.signal.aborted) return; - - // The card may have been yanked during the uninstall (it targets the PC, so it completed): no card - // → idle + hide, mirroring abandonWatchedLaunch / onRemove's cleanup. - if (!this.host.sourceAvailable(manifest)) { - this.host.cardGoneAfterSequence(); - return; - } - - // Done: rebuild GameInfo so requiresInstall recomputes true and canUninstall false (the executable - // is gone) → the button flips back to "Install" and "Uninstall" disappears. - const currentStats = await stats.read(manifest.raw.id); - const updatedInfo = await this.host.buildGameInfo(manifest, currentStats); - log.info(`[uninstall] completed id=${manifest.raw.id} removed="${uninstallDir}"`); - this.host.enterReady(updatedInfo); - this.deps.notifications.notify({ - kind: 'game-uninstalled', - gameId: manifest.raw.id, - gameTitle: updatedInfo.title, - }); - window.showAndFocus(); - }); - } - /** Replays a card insertion deferred during an in-flight launch/install. No-op if none pending. */ private resumePendingInsert(): void { const root = this.pendingRoot; diff --git a/src/main/main.ts b/src/main/main.ts index 2ee2555..ea0fa79 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -325,11 +325,12 @@ async function bootstrap(): Promise { // `|| running` holds the blocker even if a game minimized our window into exclusive-fullscreen // (windowVisible would be false there), which is the whole point of covering the running state. const running = state.get().kind === 'running'; - keepAwake.setActive(preventScreensaverEnabled && (windowVisible || running)); + keepAwake.setActive(preventScreensaverEnabled && (windowVisible || running || controller.jobs.anyActive())); }; // Recompute on every state change so entering/leaving `running` toggles the blocker (the window-visibility // and setting sources push their own recompute). A second subscriber alongside the controller's replicator. state.subscribe(() => recomputeKeepAwake()); + activities.subscribe(() => recomputeKeepAwake()); // Update service. isBusy covers ALL in-flight states (not just a running game), so a manual install // can't tear down a save-sync / game install. beforeInstall drops the windows' close-guards @@ -337,10 +338,7 @@ async function bootstrap(): Promise { const updater = new UpdaterService({ settings, notifications, - isBusy: () => { - const kind = state.get().kind; - return kind !== 'idle' && kind !== 'ready' && kind !== 'error'; - }, + isBusy: () => controller.isBusy() || controller.jobs.anyActive(), beforeInstall: () => { quitting = true; window.allowClose(); @@ -410,7 +408,7 @@ async function bootstrap(): Promise { reloadManifest: (root) => controller.reloadManifest(root), notify: (input) => notifications.notify(input), resolveManifest: (id) => controller.findManifest(id), - isBusy: () => controller.isBusy(), + isBusy: (id) => controller.isBusy(id), pcStore: store, savePathResolver: platform.savePathResolver, }); diff --git a/src/main/steam-activity-watch.ts b/src/main/steam-activity-watch.ts index 98ffff5..221b0a9 100644 --- a/src/main/steam-activity-watch.ts +++ b/src/main/steam-activity-watch.ts @@ -189,14 +189,20 @@ export class SteamActivityWatch { 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); + 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)); + 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; + 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); @@ -219,7 +225,9 @@ export class SteamActivityWatch { this.deps.registry.set(game.id, { kind: 'steam-uninstalling' }); return true; } - log.info(`[steam-uninstall] appid=${game.appid} still installed after timeout — assuming cancel`); + log.info( + `[steam-uninstall] appid=${game.appid} still installed after timeout — assuming cancel`, + ); this.uninstallRequests.delete(game.appid); this.clearSteamActivity(game.id); return false; diff --git a/src/renderer/app.ts b/src/renderer/app.ts index d41d87a..06f1de2 100644 --- a/src/renderer/app.ts +++ b/src/renderer/app.ts @@ -224,12 +224,8 @@ const gameSettingsScreen = createGameSettingsScreen({ // declared below, and no message can arrive before the user has opened this screen. notify: (text) => toast.show(text), showError: (text) => controls.showError(text), - // Editing while the game runs is legal; DELETING it is not — the launcher would be left holding a - // manifest the file no longer has. isBusy: (id) => - currentState.kind === 'running' || - currentState.kind === 'installing' || - currentState.kind === 'uninstalling' || + (phaseOf(currentState) === 'busy' && gameOf(currentState)?.id === id) || currentActivities[id] !== undefined, }); @@ -273,6 +269,7 @@ const controls = createControls({ api: { requestLaunch: () => window.api.requestLaunch(), requestUninstall: () => window.api.requestUninstall(), + cancelJob: (id) => window.api.cancelJob(id), requestKill: () => window.api.requestKill(), forgetGame: (id) => window.api.forgetGame(id), openSteamDownloads: () => window.api.openSteamDownloads(), @@ -592,14 +589,18 @@ 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); + if (activity !== undefined) return withChatter(activityStatus(activity, translator), activity.kind); 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 // presence shifts the title (see [data-status] in styles.css). if (currentBrowse === null || (subject !== undefined && subject !== currentBrowse.id)) return ''; - const base = statusOf(currentState, translator); - const suffix = chatter.suffixFor(currentState); + return withChatter(statusOf(currentState, translator), currentState.kind); +} + +/** The status line plus the current funny suffix, when one is running for `kind`. */ +function withChatter(base: string, kind: string): string { + const suffix = chatter.suffixFor(kind); return suffix !== null ? `${base} ${translator(suffix)}` : base; } @@ -695,7 +696,7 @@ function render(state: AppState): void { carousel.setBusyGames(busy); libraryScreen.setBusyGames(busy); - chatter.sync(state); + chatter.sync(screenActivity?.kind ?? state.kind); applyStatus(); // Force-close popups off the ready screen, then re-apply the focus highlight (see controls.refresh). diff --git a/src/renderer/chatter.ts b/src/renderer/chatter.ts index 49914f9..09fea53 100644 --- a/src/renderer/chatter.ts +++ b/src/renderer/chatter.ts @@ -5,7 +5,6 @@ // stuck. Renderer-owned (pure presentation) — main only sets the base state. This module owns the timers // and the current suffix; app.ts composes the line (the base status belongs to what is ON SCREEN, which // only app.ts knows). -import type { AppState } from '../shared/types.js'; import type { MessageKey } from '../shared/i18n/index.js'; const CHATTER_DELAY_MS = 60_000; // base-only for the first minute @@ -45,10 +44,10 @@ export interface ChatterDeps { } export interface Chatter { - /** (Re)starts / stops the timers as the state enters / leaves a long busy phase. */ - sync(state: AppState): void; - /** The suffix to append for `state`, or null while the base label shows alone (or for another phase). */ - suffixFor(state: AppState): MessageKey | null; + /** (Re)starts / stops the timers as the shown phase (a session state or an activity) enters / leaves a long busy one. */ + sync(kind: string): void; + /** The suffix to append for phase `kind`, or null while the base label shows alone (or for another phase). */ + suffixFor(kind: string): MessageKey | null; } function chatterPool(kind: ChatterKind): readonly MessageKey[] { @@ -80,9 +79,8 @@ export function createChatter(deps: ChatterDeps): Chatter { // First suffix appears at the first tick (~1 min); base-only before that. A phase change resets it // (each phase gets its minute). - function sync(state: AppState): void { - const next: ChatterKind | null = - state.kind === 'installing' || state.kind === 'configuringProton' ? state.kind : null; + function sync(of: string): void { + const next: ChatterKind | null = of === 'installing' || of === 'configuringProton' ? of : null; if (next === kind) return; // same phase (or same non-phase) — keep the running timers stopTimers(); kind = next; @@ -98,6 +96,6 @@ export function createChatter(deps: ChatterDeps): Chatter { return { sync, - suffixFor: (state) => (suffix !== null && state.kind === kind ? suffix : null), + suffixFor: (of) => (suffix !== null && of === kind ? suffix : null), }; } diff --git a/src/renderer/controls-deps.ts b/src/renderer/controls-deps.ts index 9a25c35..321f6f1 100644 --- a/src/renderer/controls-deps.ts +++ b/src/renderer/controls-deps.ts @@ -22,6 +22,8 @@ export interface ControlsApi { /** Play / the install confirm's Yes — main decides install vs launch from `requiresInstall`. */ requestLaunch(): void; requestUninstall(): void; + /** "Cancel installation": stops the queued or running install of game `id`. */ + cancelJob(id: string): void; requestKill(): void; /** Drops a history game's record — the id is captured when the confirm opens (see openConfirm). */ forgetGame(id: string): void; diff --git a/src/renderer/details-menu.ts b/src/renderer/details-menu.ts index ad0aeed..0540bb9 100644 --- a/src/renderer/details-menu.ts +++ b/src/renderer/details-menu.ts @@ -7,7 +7,7 @@ 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 } from './state-view.js'; +import { cancellableInstall, phaseOf } from './state-view.js'; export interface DetailsMenuDeps { getState(): AppState; @@ -61,6 +61,12 @@ export function createDetailsMenu(deps: DetailsMenuDeps): DetailsMenu { menuInstallToggle.classList.add('is-hidden'); return; } + if (cancellableInstall(deps.screenActivity())) { + menuInstallToggle.classList.remove('is-hidden'); + menuInstallToggle.textContent = t()('launcher.menu.cancelInstall'); + menuInstallToggle.dataset['action'] = 'cancel'; + return; + } 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). diff --git a/src/renderer/game-settings-screen.ts b/src/renderer/game-settings-screen.ts index a92b93b..ba5b627 100644 --- a/src/renderer/game-settings-screen.ts +++ b/src/renderer/game-settings-screen.ts @@ -191,7 +191,7 @@ export interface GameSettingsScreenDeps { * title with X?" is about a candidate the popup has never heard of. */ onConfirmRequested(kind: GameSettingsConfirm, options?: { readonly title?: string }): void; - /** Whether game `id` is running / installing / carries an activity — Delete is hidden then. */ + /** Whether game `id` is the session's game or carries an activity — Delete, Move and Save stand down. */ 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; @@ -575,7 +575,7 @@ export function createGameSettingsScreen(deps: GameSettingsScreenDeps): GameSett // A write of this screen's is in flight. Nothing here is idempotent — main's swap guard rejects the // second Save of the same signature AFTER the first has already landed, so the user is shown an error // for a save that worked — and Add/Move remove things the retry then cannot find. - if (writing) return false; + if (writing || deps.isBusy(gameId)) return false; const move = pendingMove; if (move !== null) { if (unreadable !== null) return false; diff --git a/src/renderer/popups.ts b/src/renderer/popups.ts index 2cd72e6..1aed6d1 100644 --- a/src/renderer/popups.ts +++ b/src/renderer/popups.ts @@ -490,6 +490,8 @@ export function createPopups(deps: PopupsDeps): Popups { // Confirm view — install/uninstall (from Details) or a power action (from Power). Yes runs the action // and closes the whole stack; No/back returns to where it came from. + let confirmBrowseId: string | null = null; + function openConfirm(mode: ConfirmMode): void { const copy = describeConfirm( mode, @@ -515,12 +517,21 @@ export function createPopups(deps: PopupsDeps): Popups { if (copy.path !== undefined) confirmPath.textContent = copy.path; if (copy.note !== undefined) deleteNote.textContent = copy.note; if (copy.forgetId !== undefined) forgetId = copy.forgetId; + confirmBrowseId = deps.getBrowse()?.id ?? null; confirmMode = mode; setView('confirm'); focusStackBottom(); // default focus: No (safe default) deps.onFocusChanged(); } + /** "Cancel installation" from Details: stops the install of the game on screen, by its id. */ + function cancelScreenInstall(): void { + const id = deps.getBrowse()?.id; + audio.play('button'); + closePopup(); + if (id !== undefined) deps.api.cancelJob(id); + } + /** * Work in progress — a message and a Stop, in the same column everything else speaks through. Opened * by a surface that started something long (a download that becomes a file beside a game) and closed @@ -722,7 +733,9 @@ export function createPopups(deps: PopupsDeps): Popups { // Dispatch a stack button (shared by gamepad A and mouse click). Each opener/back plays its own sound. function triggerStackButton(btn: HTMLButtonElement): void { - if (btn === menuInstallToggle) { + if (btn === menuInstallToggle && menuInstallToggle.dataset['action'] === 'cancel') { + cancelScreenInstall(); + } else if (btn === menuInstallToggle) { audio.play('button'); openConfirm(menuInstallToggle.dataset['action'] === 'install' ? 'install' : 'uninstall'); } else if (btn === menuKill) { @@ -957,7 +970,9 @@ export function createPopups(deps: PopupsDeps): Popups { if ( popupView === 'confirm' && (confirmMode === 'install' || confirmMode === 'uninstall') && - screenGame() === undefined + (screenGame() === undefined || + deps.screenActivity() !== undefined || + (deps.getBrowse()?.id ?? null) !== confirmBrowseId) ) { closePopup(); } diff --git a/src/renderer/state-view.ts b/src/renderer/state-view.ts index 25822bf..2246241 100644 --- a/src/renderer/state-view.ts +++ b/src/renderer/state-view.ts @@ -89,7 +89,7 @@ export function busyKindOf(state: AppState): BusyKind { export function activityStatus(activity: GameActivity, t: Translator): string { switch (activity.kind) { case 'queued': - return ''; + return t('launcher.state.queued'); case 'installing': return t('launcher.state.installing'); case 'configuringProton': @@ -129,6 +129,11 @@ export function busyIds(state: AppState, activities: ActivityMap): ReadonlySet, b: ReadonlySet): boolean { return a.size === b.size && [...a].every((id) => b.has(id)); diff --git a/src/shared/activity.ts b/src/shared/activity.ts index 294513f..a3d942d 100644 --- a/src/shared/activity.ts +++ b/src/shared/activity.ts @@ -37,5 +37,8 @@ export function sameActivity(a: GameActivity | undefined, b: GameActivity | unde 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])); + return ( + keys.length === Object.keys(right).length && + keys.every((key) => Object.is(left[key], right[key])) + ); } diff --git a/src/shared/i18n/en.ts b/src/shared/i18n/en.ts index ca8321e..e2b7f7b 100644 --- a/src/shared/i18n/en.ts +++ b/src/shared/i18n/en.ts @@ -58,6 +58,7 @@ export const en = { 'launcher.menu.close': 'Close', 'launcher.menu.install': 'Install', 'launcher.menu.uninstall': 'Uninstall', + 'launcher.menu.cancelInstall': 'Cancel installation', 'launcher.menu.shutdown': 'Shutdown', 'launcher.menu.reboot': 'Reboot', 'launcher.menu.sleep': 'Sleep', @@ -150,6 +151,7 @@ export const en = { 'launcher.state.syncingOut': 'Saving progress...', 'launcher.state.installingPaused': 'Installing paused...', 'launcher.state.installingPausedPercent': 'Installing paused on {percent}%...', + 'launcher.state.queued': 'Waiting to install...', 'launcher.state.updating': 'Updating...', 'launcher.state.updatingPaused': 'Updating paused...', 'launcher.state.preloaded': 'Pre-load complete', diff --git a/src/shared/i18n/ru.ts b/src/shared/i18n/ru.ts index 4787a57..c8c4594 100644 --- a/src/shared/i18n/ru.ts +++ b/src/shared/i18n/ru.ts @@ -47,6 +47,7 @@ export const ru: Record = { 'launcher.menu.close': 'Закрыть', 'launcher.menu.install': 'Установить', 'launcher.menu.uninstall': 'Удалить', + 'launcher.menu.cancelInstall': 'Отменить установку', 'launcher.menu.shutdown': 'Выключить', 'launcher.menu.reboot': 'Перезагрузить', 'launcher.menu.sleep': 'Спящий режим', @@ -123,6 +124,7 @@ export const ru: Record = { 'launcher.state.syncingOut': 'Сохранение прогресса...', 'launcher.state.installingPaused': 'Установка приостановлена...', 'launcher.state.installingPausedPercent': 'Установка приостановлена на {percent}%...', + 'launcher.state.queued': 'Ожидает установки...', 'launcher.state.updating': 'Обновление...', 'launcher.state.updatingPaused': 'Обновление приостановлено...', 'launcher.state.preloaded': 'Предзагрузка завершена', diff --git a/test/game-controller.test.ts b/test/game-controller.test.ts index b6bbf2e..44c41f6 100644 --- a/test/game-controller.test.ts +++ b/test/game-controller.test.ts @@ -10,7 +10,7 @@ import fs from 'node:fs/promises'; import fsSync from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { ipcMain, shell } from './stubs/electron'; import { GameController } from '../src/main/game-controller'; import { @@ -117,13 +117,17 @@ interface Harness { readonly removeLocalGame: (id: string) => void; /** Every game's activity (the controller's registry). */ readonly activities: ActivityRegistry; + /** Holds every copy install at its prepare step until the returned release is called. */ + readonly holdCopies: () => () => void; + /** What the fake watcher was given for `onRemove` — the card being pulled. */ + readonly remove: () => void; /** 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'; +type Mode = 'normal' | 'install' | 'copy' | 'prefix-cleanup' | 'steam'; /** * `launch`: launchGame throws. `start`: the game never appears (waitForStart false). `installer`: @@ -144,6 +148,8 @@ interface HarnessOptions { 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; + /** Where the copy-mode game comes from (`pc` by default): a `card` one is stopped when the card goes. */ + readonly copySource?: 'card' | 'pc'; } const STEAM_APPID = 480; @@ -175,10 +181,13 @@ async function harness(opts: HarnessOptions): Promise { if (opts.mode === 'prefix-cleanup' && opts.sweepDir === undefined) { await fs.mkdir(installDir, { recursive: true }); } + if (opts.mode === 'copy') await fs.writeFile(path.join(gameDir, 'game.exe'), ''); 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'); + opts.mode === 'install' || opts.mode === 'copy' + ? path.join(installDir, 'game.exe') + : path.join(gameDir, 'game.exe'); const baseRaw = { schemaVersion: 1, id: 'g1', @@ -195,7 +204,7 @@ async function harness(opts: HarnessOptions): Promise { ...(opts.mode === 'steam' ? { steam: { appid: STEAM_APPID } } : { executable: 'game.exe' }), }, root: gameDir, - source: 'pc', + source: opts.mode === 'copy' ? (opts.copySource ?? 'pc') : 'pc', executablePath: opts.mode === 'steam' ? '' : executablePath, cwd: path.dirname(executablePath), ...(opts.mode === 'steam' ? { steam: { appid: STEAM_APPID } } : {}), @@ -212,6 +221,19 @@ async function harness(opts: HarnessOptions): Promise { }, } : {}), + ...(opts.mode === 'copy' + ? { + install: { + type: 'copy' as const, + installerPath: gameDir, + runAsAdmin: false, + args: [], + winetricks: [], + dir: installDir, + installerDir: installDir, + }, + } + : {}), }; // The extra games are never Steam ones, whatever the mode: two games sharing one appid would both be @@ -254,6 +276,8 @@ async function harness(opts: HarnessOptions): Promise { failAt: FailPoint | null; release: (() => void) | null; insert: (root: string) => void; + remove: () => void; + copyHold: Promise; } = { failStats: false, exitRequested: false, @@ -261,6 +285,8 @@ async function harness(opts: HarnessOptions): Promise { failAt: null, release: null, insert: () => undefined, + remove: () => undefined, + copyHold: Promise.resolve(), }; const failing = (point: FailPoint): boolean => { if (h.failAt !== point) return false; @@ -296,6 +322,7 @@ async function harness(opts: HarnessOptions): Promise { showAndFocus: () => journal.push('window:showAndFocus'), hide: () => journal.push('window:hide'), isShown: () => true, + isFocused: () => false, }; const store: ControllerStore = { getPending: () => Promise.resolve(null), @@ -340,7 +367,9 @@ async function harness(opts: HarnessOptions): Promise { onInsert: (handler) => { h.insert = handler; }, - onRemove: () => undefined, + onRemove: (handler) => { + h.remove = () => handler(''); + }, onError: () => undefined, stop: () => undefined, }; @@ -381,7 +410,7 @@ async function harness(opts: HarnessOptions): Promise { } return proc; }, - prepareInstallDir: () => Promise.resolve(), + prepareInstallDir: () => h.copyHold, launchUninstaller: () => { journal.push('uninstaller:launch'); return failing('uninstaller') ? Promise.reject(new Error('uninstaller boom')) : Promise.resolve(proc); @@ -470,6 +499,14 @@ async function harness(opts: HarnessOptions): Promise { localGames = localGames.filter((game) => game.raw.id !== id); }, activities, + holdCopies: () => { + let release: () => void = () => undefined; + h.copyHold = new Promise((resolve) => { + release = resolve; + }); + return () => release(); + }, + remove: () => h.remove(), setSteamState: async (next) => { if (steamRoot !== null) await writeSteamAcf(steamRoot, STEAM_APPID, next); }, @@ -714,96 +751,187 @@ describe('GameController sequences', () => { return built; } - it('success: uninstalling → ready, a game-uninstalled notification, then the window', async () => { + const flippedToInstall = (built: Harness): boolean => { + const state = built.state.get(); + return state.kind === 'ready' && state.game.requiresInstall; + }; + + it('success: a background job, a game-uninstalled notification, no session state and no window', async () => { h = await installed(); + const built = h; fire(IPC.actionUninstall); - await settled(h.journal, 'window:showAndFocus'); - expect(h.journal).toEqual([ - 'state:uninstalling', - 'state:ready', + await waitFor(() => flippedToInstall(built), 'the game to flip back to Install'); + expect(built.journal).toEqual([ + 'activity:g1:uninstalling', 'notify:game-uninstalled', - 'window:showAndFocus', + 'activity:g1:clear', + 'state:ready', ]); - expect(h.state.get()).toMatchObject({ kind: 'ready', game: { requiresInstall: true } }); }); - it('error in the body: failSequence returns to ready with Uninstall still offered', async () => { - h = await installed(); - h.failStats = true; + it('a sweep that keeps failing is reported as a failed uninstall, and Uninstall stays offered', async () => { + h = await installed(unremovable(os.tmpdir())); + const built = h; fire(IPC.actionUninstall); - await settled(h.journal, 'send:error'); - expect(h.journal).toEqual([ - 'state:uninstalling', - 'state:ready', - 'window:showAndFocus', - 'send:error', + await reached(built.journal, 'activity:g1:clear'); + expect(built.journal.filter((entry) => !entry.startsWith('state:'))).toEqual([ + 'activity:g1:uninstalling', + 'notify:game-uninstall-failed', + 'activity:g1:clear', ]); - expect(h.state.get()).toMatchObject({ kind: 'ready', game: { canUninstall: true } }); + expect(built.state.get()).toMatchObject({ kind: 'ready', game: { canUninstall: true } }); }); - it("with an uninstaller: it runs first and is released by the finally, then the sweep", async () => { + it('with an uninstaller: it runs first and is released by the job, then the sweep', async () => { h = await installed(undefined, true); + const built = h; fire(IPC.actionUninstall); - await reached(h.journal, 'uninstaller:launch'); - h.exit(); - await settled(h.journal, 'proc:dispose'); - expect(h.journal).toEqual([ - 'state:uninstalling', + await reached(built.journal, 'uninstaller:launch'); + built.exit(); + await waitFor(() => flippedToInstall(built), 'the game to flip back to Install'); + expect(built.journal).toEqual([ + 'activity:g1:uninstalling', 'uninstaller:launch', - 'state:ready', 'notify:game-uninstalled', - 'window:showAndFocus', 'proc:dispose', + 'activity:g1:clear', + 'state:ready', ]); - expect(h.state.get()).toMatchObject({ kind: 'ready', game: { requiresInstall: true } }); }); it('an uninstaller that fails to start is non-fatal: the sweep still runs and the game is gone', async () => { h = await installed(undefined, true); + const built = h; h.failAt = 'uninstaller'; fire(IPC.actionUninstall); - await settled(h.journal, 'window:showAndFocus'); - expect(h.journal).toEqual([ - 'state:uninstalling', - 'uninstaller:launch', - 'state:ready', - 'notify:game-uninstalled', - 'window:showAndFocus', - ]); - expect(h.state.get()).toMatchObject({ kind: 'ready', game: { requiresInstall: true } }); + await waitFor(() => flippedToInstall(built), 'the game to flip back to Install'); + expect(built.journal).toContain('notify:game-uninstalled'); }); - it('card swap while the uninstaller runs: the abort is rethrown past the non-fatal catch, nothing is set, the insert replays', async () => { + it('a card swap does not stop an uninstall: it targets the PC and finishes on its own', async () => { h = await installed(undefined, true); - // The logger mirrors to the console: the catch that swallows an uninstaller failure warns, and the - // abort must not be reported as one (the sweep's own abort check would otherwise hide the difference). - const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const built = h; fire(IPC.actionUninstall); - await reached(h.journal, 'uninstaller:launch'); - h.insert(swapRoot); - await settled(h.journal, 'window:hide'); - expect(h.journal).toEqual([ - 'state:uninstalling', - 'uninstaller:launch', - 'proc:dispose', - 'state:error', - 'window:hide', - ]); - const lines = warned.mock.calls.map((call) => String(call[0])); - warned.mockRestore(); - expect(lines.filter((line) => line.includes('[uninstall] uninstaller failed'))).toEqual([]); - await expect(fs.stat(path.join(h.tmp, 'installed', 'game.exe'))).resolves.toBeDefined(); + await reached(built.journal, 'uninstaller:launch'); + built.insert(swapRoot); + await reached(built.journal, 'state:error'); + built.exit(); + await reached(built.journal, 'activity:g1:clear'); + expect(built.journal).toContain('notify:game-uninstalled'); + await expect(fs.stat(path.join(built.tmp, 'installed', 'game.exe'))).rejects.toThrow(); }); - it('card swap during the directory sweep: the retry loop sees the signal, nothing is set, the insert replays', async () => { + it('shutdown during the directory sweep stops the job silently', async () => { h = await installed(unremovable(os.tmpdir())); + const built = h; fire(IPC.actionUninstall); - await reached(h.journal, 'state:uninstalling'); - expect(h.journal).toEqual(['state:uninstalling']); - h.insert(swapRoot); - // The sweep backs off 300 ms between attempts and only then reads the signal. - await settled(h.journal, 'window:hide'); - expect(h.journal).toEqual(['state:uninstalling', 'state:error', 'window:hide']); + await reached(built.journal, 'activity:g1:uninstalling'); + built.controller.shutdown(); + await reached(built.journal, 'activity:g1:clear'); + expect(built.journal.filter((entry) => !entry.startsWith('state:'))).toEqual([ + 'activity:g1:uninstalling', + 'activity:g1:clear', + ]); + }); + }); + + describe('copy install', () => { + const otherSelected = (built: Harness): boolean => { + const state = built.state.get(); + return state.kind === 'ready' && state.game.id === 'other'; + }; + + async function selectThenLaunch(built: Harness, id: string): Promise { + built.journal.length = 0; + fire(IPC.actionSelect, id); + await reached(built.journal, 'state:ready'); + await new Promise((resolve) => setTimeout(resolve, 30)); + fire(IPC.actionLaunch); + } + + it('runs as a background job: an activity, a notification, no session state and no window', async () => { + h = await harness({ mode: 'copy' }); + const built = h; + fire(IPC.actionLaunch); + await waitFor(() => { + const state = built.state.get(); + return state.kind === 'ready' && !state.game.requiresInstall; + }, 'the game to flip to Play'); + expect(built.journal).toEqual([ + 'activity:g1:installing', + 'notify:game-installed', + 'activity:g1:clear', + 'state:ready', + ]); + await expect(fs.stat(path.join(built.tmp, 'installed', 'game.exe'))).resolves.toBeDefined(); + }); + + it('another game launches and runs a full session while the copy is in flight, untouched by its end', async () => { + h = await harness({ + mode: 'copy', + extraGames: [{ id: 'other', lastPlayedAt: '2026-10-01T00:00:00.000Z' }], + }); + const built = h; + const release = built.holdCopies(); + await selectThenLaunch(built, 'g1'); + await reached(built.journal, 'activity:g1:installing'); + await selectThenLaunch(built, 'other'); + await reached(built.journal, 'state:running'); + release(); + await reached(built.journal, 'activity:g1:clear'); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(built.journal).toEqual([ + 'state:ready', + 'state:syncing-in', + 'state:launching', + 'state:running', + 'notify:game-installed', + 'activity:g1:clear', + ]); + expect(built.state.get()).toMatchObject({ kind: 'running', game: { id: 'other' } }); + built.exit(); + await settled(built.journal, 'proc:dispose'); + expect(built.journal).not.toContain('state:installing'); + expect(otherSelected(built)).toBe(true); + }); + + it('pulling the card during a busy session still stops the card copy', async () => { + h = await harness({ + mode: 'copy', + copySource: 'card', + extraGames: [{ id: 'other', lastPlayedAt: '2026-10-01T00:00:00.000Z' }], + }); + const built = h; + const release = built.holdCopies(); + await selectThenLaunch(built, 'g1'); + await reached(built.journal, 'activity:g1:installing'); + await selectThenLaunch(built, 'other'); + await reached(built.journal, 'state:running'); + built.remove(); + release(); + await reached(built.journal, 'activity:g1:clear'); + expect(built.journal).toContain('notify:game-install-failed'); + expect(built.state.get()).toMatchObject({ kind: 'running', game: { id: 'other' } }); + await expect(fs.stat(path.join(built.tmp, 'installed', 'game.exe'))).rejects.toThrow(); + built.exit(); + await settled(built.journal, 'proc:dispose'); + }); + + it('a reload that drops the game stops its copy', async () => { + h = await harness({ + mode: 'copy', + extraGames: [{ id: 'other', lastPlayedAt: '2026-09-01T00:00:00.000Z' }], + }); + const built = h; + const release = built.holdCopies(); + await selectThenLaunch(built, 'g1'); + await reached(built.journal, 'activity:g1:installing'); + built.removeLocalGame('g1'); + await built.controller.reloadPcLibrary(); + release(); + await reached(built.journal, 'activity:g1:clear'); + expect(built.journal).toContain('notify:game-install-failed'); + await expect(fs.stat(path.join(built.tmp, 'installed', 'game.exe'))).rejects.toThrow(); }); }); @@ -1005,37 +1133,41 @@ describe('GameController sequences', () => { }); describe('prefix cleanup', () => { - it('success: uninstalling → ready with Uninstall gone, then the window', async () => { + it('success: a background job, Uninstall gone afterwards, no notification and no window', async () => { h = await harness({ mode: 'prefix-cleanup' }); - expect(h.state.get()).toMatchObject({ kind: 'ready', game: { prefixCleanupOnly: true } }); + const built = h; + expect(built.state.get()).toMatchObject({ kind: 'ready', game: { prefixCleanupOnly: true } }); fire(IPC.actionUninstall); - await settled(h.journal, 'window:showAndFocus'); - expect(h.journal).toEqual(['state:uninstalling', 'state:ready', 'window:showAndFocus']); - // The prefix is gone, so the rebuilt info no longer offers Uninstall. - expect(h.state.get()).toMatchObject({ kind: 'ready', game: { canUninstall: false } }); + await waitFor(() => { + const state = built.state.get(); + return state.kind === 'ready' && !state.game.canUninstall; + }, 'Uninstall to disappear'); + expect(built.journal).toEqual(['activity:g1:uninstalling', 'activity:g1:clear', 'state:ready']); }); - it('error in the body: failSequence returns to ready and reports the cause', async () => { - h = await harness({ mode: 'prefix-cleanup' }); - h.failStats = true; + it('a prefix that cannot be removed is reported as a failed uninstall', async () => { + h = await harness({ mode: 'prefix-cleanup', sweepDir: unremovable(os.tmpdir()) }); + const built = h; fire(IPC.actionUninstall); - await settled(h.journal, 'send:error'); - expect(h.journal).toEqual([ - 'state:uninstalling', - 'state:ready', - 'window:showAndFocus', - 'send:error', + await reached(built.journal, 'activity:g1:clear'); + expect(built.journal.filter((entry) => !entry.startsWith('state:'))).toEqual([ + 'activity:g1:uninstalling', + 'notify:game-uninstall-failed', + 'activity:g1:clear', ]); }); - it('card swap during the sweep: the retry loop sees the signal, nothing is set, the insert replays', async () => { + it('shutdown during the sweep stops the job silently', async () => { h = await harness({ mode: 'prefix-cleanup', sweepDir: unremovable(os.tmpdir()) }); + const built = h; fire(IPC.actionUninstall); - await reached(h.journal, 'state:uninstalling'); - expect(h.journal).toEqual(['state:uninstalling']); - h.insert(swapRoot); - await settled(h.journal, 'window:hide'); - expect(h.journal).toEqual(['state:uninstalling', 'state:error', 'window:hide']); + await reached(built.journal, 'activity:g1:uninstalling'); + built.controller.shutdown(); + await reached(built.journal, 'activity:g1:clear'); + expect(built.journal.filter((entry) => !entry.startsWith('state:'))).toEqual([ + 'activity:g1:uninstalling', + 'activity:g1:clear', + ]); }); }); }); diff --git a/test/game-jobs.test.ts b/test/game-jobs.test.ts index 88ee9f0..feaed3a 100644 --- a/test/game-jobs.test.ts +++ b/test/game-jobs.test.ts @@ -3,7 +3,12 @@ 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 { GameJobs, maxParallelCopyInstalls, partialDirOf, type GameJobsHost } from '../src/main/game-jobs'; +import { + GameJobs, + maxParallelCopyInstalls, + partialDirOf, + type GameJobsHost, +} from '../src/main/game-jobs'; import { createTranslator } from '../src/shared/i18n/index'; import type { ResolvedManifest } from '../src/main/manifest-types'; import type { GameProcess } from '../src/main/platform/types'; @@ -70,17 +75,19 @@ function build(maxParallelCopies = 2): Fixture { dispose: () => journal.push('proc:dispose'), }; const jobs = new GameJobs({ - registry, - launcher: { - prepareInstallDir: (install) => { - const id = path.basename(install.dir); - journal.push(`prepare:${id}`); - return gates.get(id)?.promise ?? Promise.resolve(); + activities: registry, + platform: { + gameLauncher: { + prepareInstallDir: (install) => { + const id = path.basename(install.dir); + journal.push(`prepare:${id}`); + return gates.get(id)?.promise ?? Promise.resolve(); + }, + resolveUninstaller: () => Promise.resolve(null), + launchUninstaller: () => Promise.resolve(proc), + uninstallDir: (install) => install.dir, + prefixCleanupDir: (id) => Promise.resolve(path.join(tmp, 'prefixes', id)), }, - resolveUninstaller: () => Promise.resolve(null), - launchUninstaller: () => Promise.resolve(proc), - uninstallDir: (install) => install.dir, - prefixCleanupDir: (id) => Promise.resolve(path.join(tmp, 'prefixes', id)), }, processControl: { waitForExit: () => Promise.resolve() }, notifications: { @@ -222,11 +229,16 @@ describe('GameJobs copy installs', () => { const a = await copyGame('a'); fixture.gates.set('a', gate()); fixture.jobs.startInstall(a); - fixture.jobs.abortWhere((job) => job.kind === 'install' && job.source === 'card', 'card-removed'); + fixture.jobs.abortWhere( + (job) => job.kind === 'install' && job.source === 'card', + 'card-removed', + ); fixture.gates.get('a')?.open(); await waitFor(() => !fixture.jobs.has('a'), 'the aborted copy to unwind'); expect(await exists(installedExe('a'))).toBe(false); - expect(fixture.journal).toContain('notify:game-install-failed:the card was removed before it finished'); + expect(fixture.journal).toContain( + 'notify:game-install-failed:the card was removed before it finished', + ); }); it('a failure in the body clears the activity, notifies, and shows the error only while the window has focus', async () => { @@ -237,7 +249,9 @@ describe('GameJobs copy installs', () => { fixture.gates.get('a')?.fail(new Error('prefix boom')); await waitFor(() => !fixture.jobs.has('a'), 'the failed copy to unwind'); expect(fixture.registry.has('a')).toBe(false); - expect(fixture.journal).toContain('notify:game-install-failed:failed to copy the game to the PC: prefix boom'); + expect(fixture.journal).toContain( + 'notify:game-install-failed:failed to copy the game to the PC: prefix boom', + ); expect(fixture.journal).toContain('error:failed to copy the game to the PC: prefix boom'); const b = await copyGame('b'); @@ -255,7 +269,9 @@ describe('GameJobs copy installs', () => { fixture.jobs.startInstall(a); await waitFor(() => !fixture.jobs.has('a'), 'the copy to fail'); expect(await exists(path.join(tmp, 'installed', 'a'))).toBe(false); - expect(fixture.journal.some((entry) => entry.startsWith('notify:game-install-failed:'))).toBe(true); + expect(fixture.journal.some((entry) => entry.startsWith('notify:game-install-failed:'))).toBe( + true, + ); }); it('refuses a game that already has an activity, a job, or is the session game', async () => { @@ -304,6 +320,15 @@ describe('GameJobs uninstall and prefix cleanup', () => { }); describe('GameJobs shutdown', () => { + it('the activity is gone by the time anyone hears the job ended', async () => { + const a = await copyGame('a'); + const seen: boolean[] = []; + fixture.registry.subscribe(() => seen.push(fixture.jobs.anyActive())); + fixture.jobs.startInstall(a); + await waitFor(() => !fixture.jobs.has('a'), 'the copy to finish'); + expect(seen).toEqual([true, false]); + }); + it('abortAll stops everything and resolves only once each job has cleaned up, silently', async () => { const games = await Promise.all(['a', 'b', 'c'].map((id) => copyGame(id))); for (const id of ['a', 'b']) fixture.gates.set(id, gate()); @@ -314,7 +339,8 @@ describe('GameJobs shutdown', () => { expect(fixture.jobs.anyActive()).toBe(false); expect(fixture.registry.snapshot()).toEqual({}); expect(fixture.journal.filter((entry) => entry.startsWith('notify:'))).toEqual([]); - for (const id of ['a', 'b']) expect(await exists(partialDirOf(path.join(tmp, 'installed', id)))).toBe(false); + for (const id of ['a', 'b']) + expect(await exists(partialDirOf(path.join(tmp, 'installed', id)))).toBe(false); }); }); diff --git a/test/provision-lock.test.ts b/test/provision-lock.test.ts index 513043b..8fbd85b 100644 --- a/test/provision-lock.test.ts +++ b/test/provision-lock.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from 'vitest'; import { createProvisionLock } from '../src/main/platform/provision-lock'; /** A promise the test settles by hand. */ -function deferred(): { readonly promise: Promise; resolve(): void; reject(error: Error): void } { +function deferred(): { + readonly promise: Promise; + resolve(): void; + reject(error: Error): void; +} { let resolve: () => void = () => undefined; let reject: (error: Error) => void = () => undefined; const promise = new Promise((res, rej) => { diff --git a/test/renderer/controls.test.ts b/test/renderer/controls.test.ts index 0856971..598e910 100644 --- a/test/renderer/controls.test.ts +++ b/test/renderer/controls.test.ts @@ -36,6 +36,7 @@ function fakeControlsApi(): ControlsApi { return { requestLaunch: vi.fn(), requestUninstall: vi.fn(), + cancelJob: vi.fn(), requestKill: vi.fn(), forgetGame: vi.fn(), openSteamDownloads: vi.fn(), @@ -403,3 +404,40 @@ describe('controls Force close', () => { expect(killHidden()).toBe(true); }); }); + +describe('controls background installs', () => { + const COPY_GAME: GameInfo = { ...LOCAL_GAME, id: 'copy', title: 'Copy', requiresInstall: true, installVia: 'copy' }; + const toggle = (): HTMLButtonElement => req('menu-install-toggle'); + + it('offers Cancel installation for an install of the game on screen, and cancels it by id', () => { + harness.screen = 'detail'; + harness.browse = browsing(COPY_GAME); + harness.state = { kind: 'ready', game: COPY_GAME }; + harness.activities = { copy: { kind: 'queued' } }; + req('more-button').click(); + + expect(toggle().classList.contains('is-hidden')).toBe(false); + expect(toggle().textContent).toBe('Cancel installation'); + + toggle().click(); + + expect(api.cancelJob).toHaveBeenCalledWith('copy'); + }); + + it('closes an open install question once the game on screen starts installing', () => { + harness.screen = 'detail'; + harness.browse = browsing(COPY_GAME); + harness.state = { kind: 'ready', game: COPY_GAME }; + req('more-button').click(); + toggle().click(); + + expect(popup().dataset['mode']).toBe('install'); + expect(popup().classList.contains('is-open')).toBe(true); + + harness.activities = { copy: { kind: 'installing' } }; + controls.refresh(); + + expect(popup().classList.contains('is-open')).toBe(false); + }); +}); + diff --git a/test/state-view.test.ts b/test/state-view.test.ts index 0fefd4f..0c80710 100644 --- a/test/state-view.test.ts +++ b/test/state-view.test.ts @@ -34,10 +34,12 @@ const BROWSE: BrowseInfo = { 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-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...', @@ -47,10 +49,16 @@ describe('activityStatus', () => { it('a finished pre-load says so instead of a paused percent', () => { expect( - activityStatus({ kind: 'steam-installing', paused: true, pausedProgress: 1, preloaded: true }, t), + 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')), + activityStatus( + { kind: 'steam-installing', paused: true, pausedProgress: 1, preloaded: true }, + createTranslator('ru'), + ), ).toBe('Предзагрузка завершена'); }); @@ -61,12 +69,17 @@ describe('activityStatus', () => { 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( + 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'); }); }); @@ -75,7 +88,9 @@ describe('screenActivityOf', () => { 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' }); + expect(screenActivityOf(BROWSE, { g: { kind: 'steam-uninstalling' } })).toEqual({ + kind: 'steam-uninstalling', + }); }); }); @@ -94,7 +109,11 @@ describe('busyIds', () => { 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: '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); }); diff --git a/test/steam-activity-watch.test.ts b/test/steam-activity-watch.test.ts index d912842..b62e771 100644 --- a/test/steam-activity-watch.test.ts +++ b/test/steam-activity-watch.test.ts @@ -136,7 +136,11 @@ describe('SteamActivityWatch transitions', () => { 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 }); + 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); @@ -148,7 +152,11 @@ describe('SteamActivityWatch transitions', () => { 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.registry.get('a')).toEqual({ + kind: 'steam-installing', + paused: true, + pausedProgress: 0.4, + }); expect(fixture.events).toEqual(['changed:a']); }); @@ -176,10 +184,15 @@ describe('SteamActivityWatch transitions', () => { 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']); + 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']); + expect(fixture.events.filter((event) => event.startsWith('installed:'))).toEqual([ + 'installed:b', + 'installed:a', + ]); }); }); @@ -216,7 +229,11 @@ describe('SteamActivityWatch pre-loads', () => { 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 }); + 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 () => { From 189befc6f77a56264c36c7f5d84ff01bfe9b58c0 Mon Sep 17 00:00:00 2001 From: Alexander Date: Mon, 5 Oct 2026 17:41:17 +0500 Subject: [PATCH 04/10] Quit, shutdown and reboot ask first while jobs run --- src/main/game-controller.ts | 7 +- src/main/game-jobs.ts | 26 +++++ src/main/game-sequences.ts | 3 + src/main/main.ts | 43 +++++++-- src/main/quit-gate.ts | 109 +++++++++++++++++++++ src/preload/preload.ts | 23 +++-- src/renderer/app.ts | 11 ++- src/renderer/confirm.ts | 33 ++++--- src/renderer/controls-deps.ts | 14 ++- src/renderer/controls.ts | 18 +--- src/renderer/deferred-questions.ts | 93 ++++++++++++++++++ src/renderer/popups.ts | 62 ++++++------ src/renderer/state-view.ts | 5 + src/shared/i18n/en-plural.ts | 12 +++ src/shared/i18n/en.ts | 1 + src/shared/i18n/ru-plural.ts | 18 ++++ src/shared/i18n/ru.ts | 1 + src/shared/quit.ts | 19 ++++ src/shared/types.ts | 13 ++- test/confirm-quit.test.ts | 69 +++++++++++++ test/game-controller.test.ts | 38 ++++++++ test/quit-gate.test.ts | 149 +++++++++++++++++++++++++++++ test/renderer/controls.test.ts | 83 +++++++++++++++- 23 files changed, 773 insertions(+), 77 deletions(-) create mode 100644 src/main/quit-gate.ts create mode 100644 src/renderer/deferred-questions.ts create mode 100644 src/shared/quit.ts create mode 100644 test/confirm-quit.test.ts create mode 100644 test/quit-gate.test.ts diff --git a/src/main/game-controller.ts b/src/main/game-controller.ts index 3ef39e2..c18758b 100644 --- a/src/main/game-controller.ts +++ b/src/main/game-controller.ts @@ -39,7 +39,7 @@ import { BrowsePresenter } from './browse-presenter'; import { GameSequences } from './game-sequences'; import { SaveSyncFlow } from './save-sync-flow'; import { SteamActivityWatch, type SteamWatchEntry } from './steam-activity-watch'; -import { GameJobs } from './game-jobs'; +import { GameJobs, installMarkerOf } from './game-jobs'; import { describe } from './util'; import { log } from './logger'; @@ -548,6 +548,7 @@ export class GameController { const keepSelection = !opts.focus && manifests.some((manifest) => manifest.raw.id === this.selectedId); this.cardGames = manifests; + void this.jobs.sweepPartials(manifests); if (!keepSelection) this.selectedId = manifests[0]?.raw.id ?? null; this.warnShadowedLocalGames(); this.sequences.unlock(); @@ -641,6 +642,7 @@ export class GameController { const env: ManifestEnv = { documents: app.getPath('documents'), t: this.t }; const read = await this.deps.pcLibrary.read(env, this.deps.platform.resolveInstallDir); this.pcGames = [...read.manifests]; + void this.jobs.sweepPartials(read.manifests); log.info(`[pc-library] ${read.manifests.length} local game(s) ids=[${read.manifests.map((m) => m.raw.id).join(',')}]`); this.warnShadowedLocalGames(); // A local game that is gone takes its collision answer with it: there is nothing left to collide, @@ -1155,7 +1157,8 @@ export class GameController { installVia = 'steam'; } else if (manifest.install !== undefined) { // Card-install mode: installed ⇔ the resolved executable exists; that also enables Uninstall. - const installed = await fse.pathExists(manifest.executablePath); + const installed = + (await fse.pathExists(manifest.executablePath)) && !(await fse.pathExists(installMarkerOf(manifest.install.dir))); requiresInstall = !installed; canUninstall = installed; // `copy` shares this branch but not its install-confirm copy: no installer runs, so the silent-mode diff --git a/src/main/game-jobs.ts b/src/main/game-jobs.ts index a49b928..01c8165 100644 --- a/src/main/game-jobs.ts +++ b/src/main/game-jobs.ts @@ -17,6 +17,8 @@ const DEFAULT_MAX_PARALLEL_COPY_INSTALLS = 2; const PARTIAL_SUFFIX = '.partial'; +const INSTALL_MARKER = '.playhook-installing'; + export type GameJobKind = 'install' | 'uninstall' | 'prefix-cleanup'; /** Why a job was stopped before it finished; `user` and `shutdown` are never reported as a failure. */ @@ -84,6 +86,11 @@ export function partialDirOf(installDir: string): string { return `${installDir}${PARTIAL_SUFFIX}`; } +/** The file an installer run leaves in its install dir until it finished: there, the game is not installed. */ +export function installMarkerOf(installDir: string): string { + return path.join(installDir, INSTALL_MARKER); +} + function throwIfAborted(job: Job): void { if (job.abort.signal.aborted) throw new LaunchAbortedError(); } @@ -146,6 +153,25 @@ export class GameJobs { } } + /** + * Removes the staging dirs a copy left behind when the launcher was killed mid-way (a quit through the + * tray or the menu waits for the cleanup; a crash or an OS logout does not). Skips games with a job. + */ + async sweepPartials(manifests: readonly ResolvedManifest[]): Promise { + for (const manifest of manifests) { + const install = manifest.install; + if (install?.type !== 'copy' || this.jobs.has(manifest.raw.id)) continue; + const partial = partialDirOf(install.dir); + if (!(await fse.pathExists(partial))) continue; + log.info(`[jobs] removing an orphaned ${PARTIAL_SUFFIX} of id=${manifest.raw.id}`); + await fse + .remove(partial) + .catch((cause: unknown) => + log.warn(`[jobs] could not remove "${partial}":`, describe(cause)), + ); + } + } + /** Stops the jobs of games no longer among `ids`: a reload dropped them, and nothing names them now. */ abortGone(ids: readonly string[]): void { this.abortWhere((job) => !ids.includes(job.id), 'game-gone'); diff --git a/src/main/game-sequences.ts b/src/main/game-sequences.ts index 1c034bd..2108bfc 100644 --- a/src/main/game-sequences.ts +++ b/src/main/game-sequences.ts @@ -16,6 +16,7 @@ import type { ResolvedManifest } from './manifest-types'; import { type Translator } from '../shared/i18n/index'; import { type ControllerDeps } from './controller-deps'; import { LaunchAbortedError } from './launch-errors'; +import { installMarkerOf } from './game-jobs'; import { openSteamUri } from './steam-uri'; import { type GameProcess, type Platform, type ProcessMonitor } from './platform'; import { normalizeImageNames } from './image-names'; @@ -673,6 +674,7 @@ export class GameSequences { // Pre-clean: a partial install left by a previous failed attempt could carry a stale → // a bogus "Play". We're (re)installing anyway, so a clean directory is safe. await fse.remove(install.dir); + await fse.outputFile(installMarkerOf(install.dir), ''); if (install.type !== 'copy') { // Silent by default; a user who enabled "disable silent installer mode" gets the visible wizard @@ -710,6 +712,7 @@ export class GameSequences { // Installed: rebuild GameInfo so requiresInstall recomputes to false (the executable now exists), // flipping the button back to "Play". The next press launches normally from the install dir. const currentStats = await stats.read(manifest.raw.id); + await fse.remove(installMarkerOf(install.dir)); const installedInfo = await this.host.buildGameInfo(manifest, currentStats); log.info(`[install] completed id=${manifest.raw.id} dir="${install.dir}"`); this.host.enterReady(installedInfo); diff --git a/src/main/main.ts b/src/main/main.ts index ea0fa79..0d8da70 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -15,6 +15,8 @@ import { PcLibraryStore } from './pc-library'; import { DriveWatcher } from './drive-watcher'; import { GameController } from './game-controller'; import { ActivityRegistry } from './activity-registry'; +import { QuitGate, quitGraceMs, withGrace } from './quit-gate'; +import { quitQuestion } from '../shared/quit'; import { waitForExit, waitForStart, @@ -148,8 +150,11 @@ function openGamesFolder(): void { void shell.openPath(dir); } -function quit(): void { +/** The managed exit: waits a bounded grace for the running jobs to clean up, then leaves. */ +async function quit(): Promise { quitting = true; + const jobs = controllerRef?.jobs; + if (jobs?.anyActive() === true) await withGrace(jobs.abortAll('shutdown'), quitGraceMs()); controllerRef?.shutdown(); globalGamepadRef?.stop(); keepAwakeRef?.dispose(); @@ -310,6 +315,26 @@ async function bootstrap(): Promise { controllerRef = controller; controller.init(); + const quitGate = new QuitGate({ + activeJobs: () => controller.jobs.activeCount(), + showAndFocus: () => window.showAndFocus(), + askInWindow: (action) => window.send(IPC.quitConfirm, action), + askNatively: async (action, jobs) => { + const t = getTranslator(); + const { response } = await dialog.showMessageBox({ + type: 'question', + buttons: [t('common.no'), t('common.yes')], + defaultId: 0, + cancelId: 0, + message: quitQuestion(t, action, jobs), + }); + return response === 1; + }, + }); + ipcMain.on(IPC.quitConfirmReply, (_event, reply: unknown) => { + if (reply === 'shown' || reply === 'dismissed') quitGate.reply(reply); + }); + // Keep the display awake while the launcher owns the session. Single recompute point over two flags // (the setting + whether the window is on screen) plus the running AppState — main is single-threaded, // so the three sources (visibility / setting / state) can't race. The blocker itself is idempotent. @@ -547,7 +572,7 @@ async function bootstrap(): Promise { onToggleSteamShortcut: () => { void (steamShortcut.isRegistered() ? steamShortcut.remove() : steamShortcut.add()); }, - onQuit: () => quit(), + onQuit: () => quitGate.request('quit', false, () => void quit()), }; // SteamOS Game Mode has no system tray (gamescope). Skip it there — the window is always shown and Steam // manages the app as a non-Steam game. Desktop Mode (KDE) and Windows keep the tray (refreshTrayMenu @@ -576,16 +601,22 @@ async function bootstrap(): Promise { // the bootstrap quit() (drops the window close-guards), sleep suspends in place. const power = createPowerService({ backend: platform.powerBackend, - quit: () => quit(), + quit: () => void quit(), showError: (message) => window.send(IPC.errorShow, message), getTranslator, }); - ipcMain.on(IPC.actionShutdown, () => void power.perform('shutdown')); - ipcMain.on(IPC.actionReboot, () => void power.perform('reboot')); + ipcMain.on(IPC.actionShutdown, (_event, confirmed: unknown) => + quitGate.request('shutdown', confirmed === true, () => void power.perform('shutdown')), + ); + ipcMain.on(IPC.actionReboot, (_event, confirmed: unknown) => + quitGate.request('reboot', confirmed === true, () => void power.perform('reboot')), + ); ipcMain.on(IPC.actionSleep, () => void power.perform('sleep')); // Game Mode "Close Playhook": full quit via the same bootstrap path as the tray Quit (drops the window // close-guards, disposes services). Only ever sent from the Game Mode power menu — Desktop keeps hiding. - ipcMain.on(IPC.actionQuit, () => quit()); + ipcMain.on(IPC.actionQuit, (_event, confirmed: unknown) => + quitGate.request('quit', confirmed === true, () => void quit()), + ); // Applies a language change everywhere: re-resolve the locale, rebuild the tray menu, re-title the // window and push the effective locale to the launcher. diff --git a/src/main/quit-gate.ts b/src/main/quit-gate.ts new file mode 100644 index 0000000..87075f5 --- /dev/null +++ b/src/main/quit-gate.ts @@ -0,0 +1,109 @@ +import type { QuitAction, QuitConfirmReply } from '../shared/quit'; +import { delay } from './util'; +import { log } from './logger'; + +const DEFAULT_QUIT_CONFIRM_ACK_MS = 2000; + +const DEFAULT_QUIT_GRACE_MS = 3000; + +/** What the gate needs from the app: the job count, the launcher window, and a native fallback. */ +export interface QuitGateDeps { + /** How many background installs / uninstalls are queued or running. */ + activeJobs(): number; + showAndFocus(): void; + /** Asks the launcher window to show its own quit question for `action`. */ + askInWindow(action: QuitAction): void; + /** The emergency question when the window never confirms it is asking; true means "go ahead". */ + askNatively(action: QuitAction, jobs: number): Promise; + /** How long the window has to confirm it is asking before the native fallback. */ + readonly ackTimeoutMs?: number; +} + +interface PendingQuit { + readonly action: QuitAction; + readonly run: () => void; + timer: ReturnType | null; +} + +/** A positive integer number of milliseconds from `env[name]`, otherwise `fallback`. */ +function envMs(env: NodeJS.ProcessEnv, name: string, fallback: number): number { + const parsed = Number.parseInt(env[name] ?? '', 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +/** PLAYHOOK_QUIT_CONFIRM_ACK_MS, two seconds by default. */ +export function quitConfirmAckMs(env: NodeJS.ProcessEnv = process.env): number { + return envMs(env, 'PLAYHOOK_QUIT_CONFIRM_ACK_MS', DEFAULT_QUIT_CONFIRM_ACK_MS); +} + +/** PLAYHOOK_QUIT_GRACE_MS, three seconds by default. */ +export function quitGraceMs(env: NodeJS.ProcessEnv = process.env): number { + return envMs(env, 'PLAYHOOK_QUIT_GRACE_MS', DEFAULT_QUIT_GRACE_MS); +} + +/** Waits for `work`, but never longer than `ms`: the exit must not hang on a cleanup that does. */ +export async function withGrace(work: Promise, ms: number): Promise { + await Promise.race([work.then(() => undefined), delay(ms)]); +} + +/** + * The single gate every managed way out goes through (Quit from the launcher or the tray, Shutdown, + * Reboot). With installs or uninstalls running, an unconfirmed request does not leave: the launcher window + * asks first, and when it never confirms it is asking (destroyed, hung, reloading), a native dialog does. + */ +export class QuitGate { + private pending: PendingQuit | null = null; + private readonly ackTimeoutMs: number; + + constructor(private readonly deps: QuitGateDeps) { + this.ackTimeoutMs = deps.ackTimeoutMs ?? quitConfirmAckMs(); + } + + /** Runs `run` now when confirmed or nothing is running; otherwise asks first. True when it ran. */ + request(action: QuitAction, confirmed: boolean, run: () => void): boolean { + if (confirmed || this.deps.activeJobs() === 0) { + this.settle(); + run(); + return true; + } + this.ask(action, run); + return false; + } + + /** The window's answer to the question: shown (or queued), or closed without a Yes. */ + reply(reply: QuitConfirmReply): void { + const pending = this.pending; + if (pending === null) return; + if (reply === 'dismissed') { + this.settle(); + return; + } + if (pending.timer !== null) clearTimeout(pending.timer); + pending.timer = null; + } + + private ask(action: QuitAction, run: () => void): void { + if (this.pending !== null) return; + log.info(`[quit] ${action} waits for a confirmation: ${this.deps.activeJobs()} job(s) running`); + this.deps.showAndFocus(); + this.deps.askInWindow(action); + const pending: PendingQuit = { action, run, timer: null }; + pending.timer = setTimeout(() => void this.askNatively(pending), this.ackTimeoutMs); + this.pending = pending; + } + + private async askNatively(pending: PendingQuit): Promise { + pending.timer = null; + log.warn('[quit] the launcher window did not confirm the question — asking natively'); + const go = await this.deps.askNatively(pending.action, this.deps.activeJobs()); + if (this.pending !== pending) return; + this.pending = null; + if (go) pending.run(); + } + + private settle(): void { + const timer = this.pending?.timer ?? null; + if (timer !== null) clearTimeout(timer); + this.pending = null; + } +} diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 520e292..82eee3c 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -56,6 +56,7 @@ import type { import type { IPC } from '../shared/types'; import type { Locale } from '../shared/i18n/index'; import type { ActivityMap } from '../shared/activity'; +import type { QuitAction, QuitConfirmReply } from '../shared/quit'; const CHANNELS = { stateUpdate: 'state:update', @@ -67,6 +68,8 @@ const CHANNELS = { actionUninstall: 'action:uninstall', actionHide: 'action:hide', actionQuit: 'action:quit', + quitConfirm: 'quit:confirm', + quitConfirmReply: 'quit:confirm-reply', gameModeRequest: 'app:game-mode-request', actionOpenSteamDownloads: 'action:open-steam-downloads', actionShutdown: 'action:shutdown', @@ -194,8 +197,16 @@ const api: RendererApi = { requestHide(): void { ipcRenderer.send(CHANNELS.actionHide); }, - requestQuit(): void { - ipcRenderer.send(CHANNELS.actionQuit); + requestQuit(confirmed?: boolean): void { + ipcRenderer.send(CHANNELS.actionQuit, confirmed === true); + }, + onQuitConfirm(callback: (action: QuitAction) => void): void { + ipcRenderer.on(CHANNELS.quitConfirm, (_event: IpcRendererEvent, action: QuitAction) => { + callback(action); + }); + }, + quitConfirmReply(reply: QuitConfirmReply): void { + ipcRenderer.send(CHANNELS.quitConfirmReply, reply); }, requestGameMode(): Promise { return ipcRenderer.invoke(CHANNELS.gameModeRequest) as Promise; @@ -203,11 +214,11 @@ const api: RendererApi = { openSteamDownloads(): void { ipcRenderer.send(CHANNELS.actionOpenSteamDownloads); }, - requestShutdown(): void { - ipcRenderer.send(CHANNELS.actionShutdown); + requestShutdown(confirmed?: boolean): void { + ipcRenderer.send(CHANNELS.actionShutdown, confirmed === true); }, - requestReboot(): void { - ipcRenderer.send(CHANNELS.actionReboot); + requestReboot(confirmed?: boolean): void { + ipcRenderer.send(CHANNELS.actionReboot, confirmed === true); }, requestSleep(): void { ipcRenderer.send(CHANNELS.actionSleep); diff --git a/src/renderer/app.ts b/src/renderer/app.ts index 06f1de2..a4e05da 100644 --- a/src/renderer/app.ts +++ b/src/renderer/app.ts @@ -25,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 { activityBusyKind, activityStatus, busyIds, gameOf, phaseOf, screenActivityOf, statusOf } from './state-view.js'; +import { activityBusyKind, activityStatus, busyIds, gameOf, jobCountOf, phaseOf, screenActivityOf, statusOf } from './state-view.js'; import { req } from './dom.js'; const app = req('app'); @@ -273,11 +273,12 @@ const controls = createControls({ requestKill: () => window.api.requestKill(), forgetGame: (id) => window.api.forgetGame(id), openSteamDownloads: () => window.api.openSteamDownloads(), - requestShutdown: () => window.api.requestShutdown(), - requestReboot: () => window.api.requestReboot(), + requestShutdown: (confirmed) => window.api.requestShutdown(confirmed), + requestReboot: (confirmed) => window.api.requestReboot(confirmed), requestSleep: () => window.api.requestSleep(), requestHide: () => window.api.requestHide(), - requestQuit: () => window.api.requestQuit(), + requestQuit: (confirmed) => window.api.requestQuit(confirmed), + quitConfirmReply: (reply) => window.api.quitConfirmReply(reply), resolveGameCollision: (answer) => window.api.resolveGameCollision(answer), markNotificationsRead: () => window.api.markNotificationsRead(), dismissNotification: (id) => window.api.dismissNotification(id), @@ -293,6 +294,7 @@ const controls = createControls({ isBooting: () => !boot.isRevealed(), getBrowse: () => currentBrowse, getScreenActivity: () => screenActivityOf(currentBrowse, currentActivities), + getJobCount: () => jobCountOf(currentActivities), audio, getTranslator, settings: settingsScreen, @@ -754,6 +756,7 @@ void window.api.requestActivities().then((activities) => { render(currentState); }); +window.api.onQuitConfirm((action) => controls.askQuit(action)); window.api.onStateUpdate(render); void window.api.requestState().then((state) => { render(state); diff --git a/src/renderer/confirm.ts b/src/renderer/confirm.ts index ad82751..4e6622d 100644 --- a/src/renderer/confirm.ts +++ b/src/renderer/confirm.ts @@ -5,6 +5,7 @@ import type { AudioController } from './audio.js'; import type { ControlsApi, GameSettingsNav, SettingsNav } from './controls-deps.js'; import type { BrowseInfo, GameCollision, GameInfo } from '../shared/types.js'; import type { Translator } from '../shared/i18n/index.js'; +import { quitQuestion, type QuitAction } from '../shared/quit.js'; // Which action the confirm view is asking about (only meaningful while the popup shows 'confirm'). export type ConfirmMode = @@ -32,11 +33,13 @@ export type ConfirmMode = | 'replace-game-title' // The same game turned up on the card AND on this PC. Both answers are answers — "No" means "leave // them as they are", not "never mind" — and both are remembered (see GameCollision). - | 'game-collision'; + | 'game-collision' + // Quit with background installs / uninstalls running: they would be cancelled, so it is asked first. + | 'quit-with-jobs'; /** Where B/Esc/veil returns FROM the confirm view: install/uninstall come from Details, the power * actions come from Power; a screen's question returns to that screen, which is still open underneath. */ -export type ConfirmReturnTo = 'details' | 'power' | 'settings' | 'game-settings'; +export type ConfirmReturnTo = 'details' | 'power' | 'settings' | 'game-settings' | 'none'; /** What the confirm view shows for one question. Fields left undefined leave the DOM as it was. */ export interface ConfirmCopy { @@ -65,6 +68,8 @@ export interface ConfirmContext { deletesLocalGame(): boolean; /** The game name the "replace the title?" question quotes. */ readonly title: string; + /** How many background installs / uninstalls a quit, shutdown or reboot would cancel. */ + readonly jobCount: number; } /** @@ -195,13 +200,15 @@ export function describeConfirm( return { returnTo: 'details', message: t('launcher.confirm.kill'), installVia: null }; } // Power action: a single-question confirm, no path note (data-mode ≠ 'install' hides it). - const key = - mode === 'shutdown' - ? 'launcher.confirm.shutdown' - : mode === 'reboot' - ? 'launcher.confirm.reboot' - : 'launcher.confirm.sleep'; - return { returnTo: 'power', message: t(key), installVia: null }; + if (mode === 'sleep') return { returnTo: 'power', message: t('launcher.confirm.sleep'), installVia: null }; + const action = mode === 'quit-with-jobs' ? 'quit' : mode; + return { returnTo: 'power', message: quitQuestion(t, action, ctx.jobCount), installVia: null }; +} + +/** The QuitAction a quit / shutdown / reboot question is about, or null for any other question. */ +export function quitModeAction(mode: ConfirmMode): QuitAction | null { + if (mode === 'quit-with-jobs') return 'quit'; + return mode === 'shutdown' || mode === 'reboot' ? mode : null; } export interface ConfirmActionDeps { @@ -245,11 +252,15 @@ export function runConfirmedAction( } case 'shutdown': audio.play('button'); - deps.api.requestShutdown(); + deps.api.requestShutdown(true); break; case 'reboot': audio.play('button'); - deps.api.requestReboot(); + deps.api.requestReboot(true); + break; + case 'quit-with-jobs': + audio.play('button'); + deps.api.requestQuit(true); break; case 'sleep': audio.play('button'); diff --git a/src/renderer/controls-deps.ts b/src/renderer/controls-deps.ts index 321f6f1..989dbba 100644 --- a/src/renderer/controls-deps.ts +++ b/src/renderer/controls-deps.ts @@ -9,6 +9,7 @@ import type { GameCollisionAnswer, } from '../shared/types.js'; import type { GameActivity } from '../shared/activity.js'; +import type { QuitConfirmReply } from '../shared/quit.js'; import type { Locale, MessageKey, Translator } from '../shared/i18n/index.js'; import type { AudioController } from './audio.js'; import type { MoveResult } from './carousel.js'; @@ -29,13 +30,16 @@ export interface ControlsApi { forgetGame(id: string): void; /** The gear on a Steam download: opens Steam's own Downloads page, the only pause/resume there is. */ openSteamDownloads(): void; - requestShutdown(): void; - requestReboot(): void; + /** `confirmed`: the user already said yes to it, with the running jobs named in the question. */ + requestShutdown(confirmed?: boolean): void; + requestReboot(confirmed?: boolean): void; requestSleep(): void; /** "Minimize Playhook" — hide to the tray. */ requestHide(): void; - /** "Close Playhook" — the full quit. */ - requestQuit(): void; + /** "Close Playhook" — the full quit; `confirmed` as for requestShutdown. */ + requestQuit(confirmed?: boolean): void; + /** Tells main its quit question is up (or queued), or was closed without a Yes. */ + quitConfirmReply(reply: QuitConfirmReply): void; /** The answer to a card-vs-PC collision question; fails when the card is no longer the one asked about. */ resolveGameCollision(answer: GameCollisionAnswer): Promise; /** Opening the inbox IS reading it. */ @@ -58,6 +62,8 @@ export interface ControlsDeps { getBrowse(): BrowseInfo | null; /** The activity of the game on screen (a Steam download, a removal), or undefined when it is free. */ getScreenActivity(): GameActivity | undefined; + /** How many background installs / uninstalls are queued or running — a quit asks first while any are. */ + getJobCount(): number; /** 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 a891dd1..5a100fe 100644 --- a/src/renderer/controls.ts +++ b/src/renderer/controls.ts @@ -18,6 +18,7 @@ import type { SystemCardId } from './system-cards.js'; import { gameOf, opensSteamDownloads, phaseOf } from './state-view.js'; import { pressFlash, req } from './dom.js'; import type { GameCollision } from '../shared/types.js'; +import type { QuitAction } from '../shared/quit.js'; import type { ControlsDeps } from './controls-deps.js'; export interface Controls { @@ -60,6 +61,8 @@ export interface Controls { * card it is about is already loaded. */ askGameCollision(collision: GameCollision): void; + /** main asks before a quit / shutdown / reboot while background jobs run (tray Quit, a raced Quit). */ + askQuit(action: QuitAction): void; /** Per-render refresh: force-close the popup off the ready screen (or while steam-busy), then re-apply focus. */ refresh(): void; /** Opens the error popup with the given message (a failed launch/action from main). */ @@ -197,23 +200,11 @@ export function createControls(deps: ControlsDeps): Controls { let wasActive = false; // The popup column (popups.ts): every view of #popup, its vertical stack and what its buttons do. const popups = createPopups({ - api: deps.api, - audio, - getState: () => deps.getState(), - getBrowse: () => deps.getBrowse(), - getTranslator: () => deps.getTranslator(), - getLocale: () => deps.getLocale(), - getNotifications: () => deps.getNotifications(), - carousel: deps.carousel, - settings: deps.settings, - gameSettings: deps.gameSettings, - library: deps.library, + ...deps, hover, screenGame, screenIsActionable, screenActivity: () => deps.getScreenActivity(), - openGameDetail: (id) => deps.openGameDetail(id), - onPopupClosed: () => deps.onPopupClosed(), onFocusChanged: () => applyFocus(), }); @@ -744,6 +735,7 @@ export function createControls(deps: ControlsDeps): Controls { confirmResetSettings: () => popups.confirmResetSettings(), confirmGameSettings: (kind, options) => popups.confirmGameSettings(kind, options), askGameCollision: (collision) => popups.askGameCollision(collision), + askQuit: (action) => popups.askQuit(action), showBusy: (message, onStop) => popups.showBusy(message, onStop), closeBusy: () => popups.closeBusy(), openSystemCard: (id) => popups.openSystemCard(id), diff --git a/src/renderer/deferred-questions.ts b/src/renderer/deferred-questions.ts new file mode 100644 index 0000000..60453a2 --- /dev/null +++ b/src/renderer/deferred-questions.ts @@ -0,0 +1,93 @@ +import type { GameCollision } from '../shared/types.js'; +import type { QuitAction, QuitConfirmReply } from '../shared/quit.js'; + +/** What the queue needs from the popup column it waits on. */ +export interface DeferredQuestionsDeps { + /** Which view the popup column shows, 'none' while it is closed. */ + popupView(): string; + /** A full-screen surface with questions of its own is up (Customize, Settings). */ + isScreenAsking(): boolean; + openCollision(collision: GameCollision): void; + /** Opens main's quit question for `action`; true once it is on screen. */ + openQuit(action: QuitAction): boolean; + replyQuit(reply: QuitConfirmReply): void; + readonly fadeMs: number; +} + +/** The questions main raises on its own, held back while the popup column is in use. */ +export interface DeferredQuestions { + askCollision(collision: GameCollision): void; + /** main asks before `action` while jobs run; it hears at once that the question is shown or queued. */ + askQuit(action: QuitAction): void; + /** main's quit question got its Yes: closing the column is not a dismissal then. */ + quitAnswered(): void; + /** The column closed: reports an unanswered quit question, then raises whatever waited. */ + columnClosed(): void; +} + +/** + * Creates the queue of main's own questions: the card-vs-PC collision and the quit question asked while + * background jobs run. Neither may wipe out a question, an error or work in progress the user is looking + * at; each waits for the column to close and comes up after its fade. + */ +export function createDeferredQuestions(deps: DeferredQuestionsDeps): DeferredQuestions { + let queuedCollision: GameCollision | null = null; + let queuedQuit: QuitAction | null = null; + let quitAsked = false; + + function after(fade: () => void): void { + window.setTimeout(() => { + if (deps.popupView() === 'none') fade(); + }, deps.fadeMs); + } + + /** Raises a collision question that arrived while the column was busy, once it is free again. */ + function flushCollision(): void { + const waiting = queuedCollision; + if (waiting === null || deps.popupView() !== 'none' || deps.isScreenAsking()) return; + queuedCollision = null; + // After the fade, or the question would open into a column still fading the previous one out. + after(() => deps.openCollision(waiting)); + } + + function flushQuit(): void { + const waiting = queuedQuit; + if (waiting === null || deps.popupView() !== 'none') return; + queuedQuit = null; + after(() => { + quitAsked = deps.openQuit(waiting); + }); + } + + return { + askCollision: (collision) => { + // One at a time, and never over something the user is in the middle of: a confirm, the Customize + // screen's own question, the power menu. It is picked up the moment the surface clears. + if (deps.popupView() !== 'none' || deps.isScreenAsking()) { + queuedCollision = collision; + return; + } + deps.openCollision(collision); + }, + askQuit: (action) => { + deps.replyQuit('shown'); + const view = deps.popupView(); + if (view === 'confirm' || view === 'busy' || view === 'error') { + queuedQuit = action; + return; + } + quitAsked = deps.openQuit(action); + }, + quitAnswered: () => { + quitAsked = false; + }, + columnClosed: () => { + if (quitAsked) { + quitAsked = false; + deps.replyQuit('dismissed'); + } + flushCollision(); + flushQuit(); + }, + }; +} diff --git a/src/renderer/popups.ts b/src/renderer/popups.ts index 1aed6d1..633f7db 100644 --- a/src/renderer/popups.ts +++ b/src/renderer/popups.ts @@ -14,10 +14,13 @@ import type { GameInfo, } from '../shared/types.js'; import type { GameActivity } from '../shared/activity.js'; +import { quitQuestion, type QuitAction } from '../shared/quit.js'; +import { createDeferredQuestions } from './deferred-questions.js'; import type { Locale, MessageKey, Translator } from '../shared/i18n/index.js'; import type { AudioController } from './audio.js'; import { describeConfirm, + quitModeAction, runConfirmedAction, type ConfirmMode, type ConfirmReturnTo, @@ -75,6 +78,8 @@ export interface PopupsDeps { screenIsActionable(): boolean; /** The activity of the game on screen, or undefined when it is free. */ screenActivity(): GameActivity | undefined; + /** How many background installs / uninstalls are queued or running. */ + getJobCount(): number; /** 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. */ @@ -94,6 +99,8 @@ export interface Popups extends NavSurface { confirmGameSettings(kind: GameSettingsQuestion, options?: { readonly title?: string }): void; /** main found the same game on the card and on this PC: asks what should happen to it (queued while busy). */ askGameCollision(collision: GameCollision): void; + /** main asks before `action` while jobs run (tray Quit): queued behind a question, error or work popup. */ + askQuit(action: QuitAction): void; /** Work in progress: a message and a Stop. `closeBusy` takes it away when the work answers. */ showBusy(message: string, onStop: () => void): void; closeBusy(): void; @@ -190,7 +197,21 @@ export function createPopups(deps: PopupsDeps): Popups { let forgetId: string | null = null; /** The collision the open question is about, and the one waiting for the popup to free up. */ let askedCollision: GameCollision | null = null; - let queuedCollision: GameCollision | null = null; + const deferred = createDeferredQuestions({ + popupView: () => popupView, + isScreenAsking: () => deps.gameSettings.isOpen() || deps.settings.isOpen(), + openCollision: (collision) => { + askedCollision = collision; + openConfirm('game-collision'); + }, + openQuit: (action) => { + openConfirm(action === 'quit' ? 'quit-with-jobs' : action); + if (popupView === 'confirm') confirmReturnTo = 'none'; + return popupView === 'confirm'; + }, + replyQuit: (reply) => deps.api.quitConfirmReply(reply), + fadeMs: POPUP_FADE_MS, + }); // ── Popup machine ──────────────────────────────────────────────────────────── // One #popup element; opening = add .is-open + set data-view; switching views keeps .is-open (so the @@ -251,21 +272,7 @@ export function createPopups(deps: PopupsDeps): Popups { freezeMenuDuringFade(); applyStackFocus(); // clear the stack highlight (stackActive becomes false) deps.onFocusChanged(); // restore the main bar highlight - flushQueuedCollision(); - } - - /** Raises a collision question that arrived while the column was busy, once it is free again. */ - function flushQueuedCollision(): void { - const waiting = queuedCollision; - if (waiting === null || popupView !== 'none') return; - if (deps.gameSettings.isOpen() || deps.settings.isOpen()) return; - queuedCollision = null; - askedCollision = waiting; - // After the fade, or the question would open into a column still fading the previous one out. - window.setTimeout(() => { - if (popupView !== 'none' || askedCollision === null) return; - openConfirm('game-collision'); - }, POPUP_FADE_MS); + deferred.columnClosed(); } // Details menu (from More): game stats on top + Shutdown / Install|Uninstall / Close stack. Works on @@ -501,6 +508,7 @@ export function createPopups(deps: PopupsDeps): Popups { collision: askedCollision, deletesLocalGame: () => deps.gameSettings.deletesLocalGame(), title: confirmTitle, + jobCount: deps.getJobCount(), }, t(), ); @@ -579,7 +587,7 @@ export function createPopups(deps: PopupsDeps): Popups { case 'confirm': // Neither 'settings' nor 'game-settings' is a popup view: that screen is already open // underneath, so the popup just goes and the screen has the focus again. - if (confirmReturnTo === 'settings' || confirmReturnTo === 'game-settings') { + if (confirmReturnTo === 'settings' || confirmReturnTo === 'game-settings' || confirmReturnTo === 'none') { closePopup(); break; } @@ -791,6 +799,11 @@ export function createPopups(deps: PopupsDeps): Popups { // The full quit. No confirm either: it is as recoverable as relaunching from the Steam library — // and in Game Mode this is the only way out, so a confirm would sit between the user and the exit // every single time. + if (deps.getJobCount() > 0) { + audio.play('button'); + openConfirm('quit-with-jobs'); + return; + } closePopup(); deps.api.requestQuit(); } else if (btn === confirmYes) { @@ -858,6 +871,7 @@ export function createPopups(deps: PopupsDeps): Popups { return; } const mode = confirmMode; + if (quitModeAction(mode) !== null) deferred.quitAnswered(); closePopup(); runConfirmedAction(mode, { api: deps.api, @@ -935,16 +949,8 @@ export function createPopups(deps: PopupsDeps): Popups { : 'discard-game-settings', ); }, - askGameCollision: (collision) => { - // One at a time, and never over something the user is in the middle of: a confirm, the Customize - // screen's own question, the power menu. It is picked up the moment the surface clears. - if (popupView !== 'none' || deps.gameSettings.isOpen() || deps.settings.isOpen()) { - queuedCollision = collision; - return; - } - askedCollision = collision; - openConfirm('game-collision'); - }, + askQuit: (action) => deferred.askQuit(action), + askGameCollision: (collision) => deferred.askCollision(collision), showBusy: openBusy, closeBusy: () => { if (popupView !== 'busy') return; @@ -976,6 +982,8 @@ export function createPopups(deps: PopupsDeps): Popups { ) { closePopup(); } + const quitAction = quitModeAction(confirmMode); + if (popupView === 'confirm' && quitAction !== null) confirmMessage.textContent = quitQuestion(t(), quitAction, deps.getJobCount()); menuItems.applyPowerItems(gameMode); applyStackFocus(); }, diff --git a/src/renderer/state-view.ts b/src/renderer/state-view.ts index 2246241..c73ff69 100644 --- a/src/renderer/state-view.ts +++ b/src/renderer/state-view.ts @@ -134,6 +134,11 @@ export function cancellableInstall(activity: GameActivity | undefined): boolean return activity?.kind === 'queued' || activity?.kind === 'installing' || activity?.kind === 'configuringProton'; } +/** How many background installs / uninstalls are queued or running (Steam's own activities aside). */ +export function jobCountOf(activities: ActivityMap): number { + return Object.values(activities).filter((activity) => !activity.kind.startsWith('steam-')).length; +} + /** 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)); diff --git a/src/shared/i18n/en-plural.ts b/src/shared/i18n/en-plural.ts index 454e16a..b0f060e 100644 --- a/src/shared/i18n/en-plural.ts +++ b/src/shared/i18n/en-plural.ts @@ -13,6 +13,18 @@ export const enPlural = { // The single summary plate shown instead of a queue of them: after a game exits, or when the user // comes back to a launcher that has been collecting notifications while they were away. 'notifications.unread': { one: '{n} unread notification', other: '{n} unread notifications' }, + 'launcher.confirm.quitWithJobs': { + one: '{n} operation is in progress and will be cancelled. Quit anyway?', + other: '{n} operations are in progress and will be cancelled. Quit anyway?', + }, + 'launcher.confirm.shutdownWithJobs': { + one: '{n} operation is in progress and will be cancelled. Shut down the PC anyway?', + other: '{n} operations are in progress and will be cancelled. Shut down the PC anyway?', + }, + 'launcher.confirm.rebootWithJobs': { + one: '{n} operation is in progress and will be cancelled. Reboot the PC anyway?', + other: '{n} operations are in progress and will be cancelled. Reboot the PC anyway?', + }, } as const satisfies Record; /** Every plural key — the compile-time contract the Russian plural mirror indexes against. */ diff --git a/src/shared/i18n/en.ts b/src/shared/i18n/en.ts index e2b7f7b..82f6c02 100644 --- a/src/shared/i18n/en.ts +++ b/src/shared/i18n/en.ts @@ -108,6 +108,7 @@ export const en = { 'launcher.confirm.shutdown': 'Shut down the PC?', 'launcher.confirm.reboot': 'Reboot the PC?', 'launcher.confirm.sleep': 'Put the PC to sleep?', + 'launcher.confirm.quit': 'Quit Playhook?', 'launcher.installPathNote': 'Since not all installers support silent mode, during installation you need to specify the following path:', // The copy variant of the note: no installer runs, so neither the silent-mode caveat nor the diff --git a/src/shared/i18n/ru-plural.ts b/src/shared/i18n/ru-plural.ts index 1f717d9..0768ea3 100644 --- a/src/shared/i18n/ru-plural.ts +++ b/src/shared/i18n/ru-plural.ts @@ -13,4 +13,22 @@ export const ruPlural: Partial> = { many: '{n} непрочитанных уведомлений', other: '{n} непрочитанных уведомления', }, + 'launcher.confirm.quitWithJobs': { + one: 'Выполняется {n} операция, она будет отменена. Всё равно выйти?', + few: 'Выполняются {n} операции, они будут отменены. Всё равно выйти?', + many: 'Выполняется {n} операций, они будут отменены. Всё равно выйти?', + other: 'Выполняется {n} операции, они будут отменены. Всё равно выйти?', + }, + 'launcher.confirm.shutdownWithJobs': { + one: 'Выполняется {n} операция, она будет отменена. Всё равно выключить компьютер?', + few: 'Выполняются {n} операции, они будут отменены. Всё равно выключить компьютер?', + many: 'Выполняется {n} операций, они будут отменены. Всё равно выключить компьютер?', + other: 'Выполняется {n} операции, они будут отменены. Всё равно выключить компьютер?', + }, + 'launcher.confirm.rebootWithJobs': { + one: 'Выполняется {n} операция, она будет отменена. Всё равно перезагрузить компьютер?', + few: 'Выполняются {n} операции, они будут отменены. Всё равно перезагрузить компьютер?', + many: 'Выполняется {n} операций, они будут отменены. Всё равно перезагрузить компьютер?', + other: 'Выполняется {n} операции, они будут отменены. Всё равно перезагрузить компьютер?', + }, }; diff --git a/src/shared/i18n/ru.ts b/src/shared/i18n/ru.ts index c8c4594..fc0a7a3 100644 --- a/src/shared/i18n/ru.ts +++ b/src/shared/i18n/ru.ts @@ -85,6 +85,7 @@ export const ru: Record = { 'launcher.confirm.forget': 'Убрать «{title}» из библиотеки? Сейвы и статистика останутся — вставьте карту, и игра вернётся.', 'launcher.confirm.sleep': 'Перевести компьютер в спящий режим?', + 'launcher.confirm.quit': 'Выйти из Playhook?', 'launcher.installPathNote': 'Не все установщики поддерживают тихий режим, поэтому при установке нужно указать следующий путь:', 'launcher.copyNote': diff --git a/src/shared/quit.ts b/src/shared/quit.ts new file mode 100644 index 0000000..ecdba46 --- /dev/null +++ b/src/shared/quit.ts @@ -0,0 +1,19 @@ +import type { Translator } from './i18n/index'; + +/** A managed way out of the launcher: quitting it, or shutting the PC down or rebooting it. */ +export type QuitAction = 'quit' | 'shutdown' | 'reboot'; + +/** The launcher window's answer to main's quit question: shown (or queued), or closed without a Yes. */ +export type QuitConfirmReply = 'shown' | 'dismissed'; + +/** The question asked before `action` while `jobs` installs / uninstalls are running; 0 asks the plain one. */ +export function quitQuestion(t: Translator, action: QuitAction, jobs: number): string { + if (jobs > 0) { + if (action === 'shutdown') return t.tp('launcher.confirm.shutdownWithJobs', jobs); + if (action === 'reboot') return t.tp('launcher.confirm.rebootWithJobs', jobs); + return t.tp('launcher.confirm.quitWithJobs', jobs); + } + if (action === 'shutdown') return t('launcher.confirm.shutdown'); + if (action === 'reboot') return t('launcher.confirm.reboot'); + return t('launcher.confirm.quit'); +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 0342591..4238444 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -5,6 +5,7 @@ import type { Locale } from './i18n/index'; import type { ArtworkQuality } from './artwork-filter'; import type { ActivityMap, GameJobFailure } from './activity'; +import type { QuitAction, QuitConfirmReply } from './quit'; // ── Card format: the few facts both sides must agree on ─────────────────────────────────────── @@ -483,6 +484,10 @@ export const IPC = { /** renderer → main: quit the whole app. In Game Mode (gamescope) the power menu's primary item becomes * "Close Playhook" (there is no tray to minimize into), which sends this instead of actionHide. */ actionQuit: 'action:quit', + /** main → renderer: background jobs are running — ask before this QuitAction (tray Quit, a raced Quit). */ + quitConfirm: 'quit:confirm', + /** renderer → main: the quit question was shown (or queued), or closed without a Yes (QuitConfirmReply). */ + quitConfirmReply: 'quit:confirm-reply', /** renderer → main (invoke): whether this is a SteamOS Game Mode (gamescope) session. Seeded once at * startup so the renderer can adapt the UI (e.g. "Minimize" → "Close Playhook"). */ gameModeRequest: 'app:game-mode-request', @@ -1189,15 +1194,17 @@ export interface RendererApi { cancelJob(id: string): void; requestHide(): void; /** Quit the whole app (Game Mode's "Close Playhook" — no tray to minimize into). */ - requestQuit(): void; + requestQuit(confirmed?: boolean): void; + onQuitConfirm(callback: (action: QuitAction) => void): void; + quitConfirmReply(reply: QuitConfirmReply): void; /** Whether this is a SteamOS Game Mode (gamescope) session, seeded once at startup. */ requestGameMode(): Promise; /** Open Steam's Downloads page so the user can pause/resume a Steam download from Steam itself. */ openSteamDownloads(): void; /** Power off the PC (after the in-launcher confirm). */ - requestShutdown(): void; + requestShutdown(confirmed?: boolean): void; /** Restart the PC (after the in-launcher confirm). */ - requestReboot(): void; + requestReboot(confirmed?: boolean): void; /** Put the PC to sleep (after the in-launcher confirm). */ requestSleep(): void; /** Force-close the running game (after the in-launcher confirm). */ diff --git a/test/confirm-quit.test.ts b/test/confirm-quit.test.ts new file mode 100644 index 0000000..831a342 --- /dev/null +++ b/test/confirm-quit.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { describeConfirm, quitModeAction, type ConfirmContext } from '../src/renderer/confirm'; +import { createTranslator } from '../src/shared/i18n/index'; + +function context(jobCount: number): ConfirmContext { + return { + game: undefined, + browse: null, + collision: null, + deletesLocalGame: () => false, + title: '', + jobCount, + }; +} + +const en = createTranslator('en'); +const ru = createTranslator('ru'); + +describe('the quit question with background jobs', () => { + it('names how many operations would be cancelled, with plural forms', () => { + expect(describeConfirm('quit-with-jobs', context(1), en)?.message).toBe( + '1 operation is in progress and will be cancelled. Quit anyway?', + ); + expect(describeConfirm('quit-with-jobs', context(3), en)?.message).toBe( + '3 operations are in progress and will be cancelled. Quit anyway?', + ); + expect(describeConfirm('quit-with-jobs', context(1), ru)?.message).toBe( + 'Выполняется 1 операция, она будет отменена. Всё равно выйти?', + ); + expect(describeConfirm('quit-with-jobs', context(3), ru)?.message).toBe( + 'Выполняются 3 операции, они будут отменены. Всё равно выйти?', + ); + expect(describeConfirm('quit-with-jobs', context(5), ru)?.message).toBe( + 'Выполняется 5 операций, они будут отменены. Всё равно выйти?', + ); + }); + + it('falls back to the plain question once nothing is running any more', () => { + expect(describeConfirm('quit-with-jobs', context(0), en)?.message).toBe('Quit Playhook?'); + }); + + it('returns to the Power menu it was asked from', () => { + expect(describeConfirm('quit-with-jobs', context(2), en)?.returnTo).toBe('power'); + }); +}); + +describe('Shutdown and Reboot', () => { + it('keep their own question without jobs and name the jobs when there are some', () => { + expect(describeConfirm('shutdown', context(0), en)?.message).toBe('Shut down the PC?'); + expect(describeConfirm('reboot', context(0), en)?.message).toBe('Reboot the PC?'); + expect(describeConfirm('shutdown', context(2), en)?.message).toBe( + '2 operations are in progress and will be cancelled. Shut down the PC anyway?', + ); + expect(describeConfirm('reboot', context(1), en)?.message).toBe( + '1 operation is in progress and will be cancelled. Reboot the PC anyway?', + ); + expect(describeConfirm('sleep', context(4), en)?.message).toBe('Put the PC to sleep?'); + }); +}); + +describe('quitModeAction', () => { + it('maps the three exit questions onto their action and nothing else', () => { + expect(quitModeAction('quit-with-jobs')).toBe('quit'); + expect(quitModeAction('shutdown')).toBe('shutdown'); + expect(quitModeAction('reboot')).toBe('reboot'); + expect(quitModeAction('sleep')).toBeNull(); + expect(quitModeAction('install')).toBeNull(); + }); +}); diff --git a/test/game-controller.test.ts b/test/game-controller.test.ts index 44c41f6..7e22e06 100644 --- a/test/game-controller.test.ts +++ b/test/game-controller.test.ts @@ -739,6 +739,34 @@ describe('GameController sequences', () => { }); }); + describe('install marker', () => { + it('an installer run that never finished leaves the game on Install even with its executable there', async () => { + h = await harness({ mode: 'install' }); + const built = h; + await fs.mkdir(path.join(built.tmp, 'installed'), { recursive: true }); + await fs.writeFile(path.join(built.tmp, 'installed', 'game.exe'), ''); + await fs.writeFile(path.join(built.tmp, 'installed', '.playhook-installing'), ''); + await built.controller.reloadPcLibrary(); + expect(built.state.get()).toMatchObject({ kind: 'ready', game: { requiresInstall: true } }); + await fs.rm(path.join(built.tmp, 'installed', '.playhook-installing')); + await built.controller.reloadPcLibrary(); + expect(built.state.get()).toMatchObject({ kind: 'ready', game: { requiresInstall: false } }); + }); + + it('a finished installer run takes its marker away', async () => { + h = await harness({ mode: 'install' }); + h.installerWritesExe = true; + fire(IPC.actionLaunch); + await reached(h.journal, 'state:installing'); + const marker = path.join(h.tmp, 'installed', '.playhook-installing'); + await waitFor(() => fsSync.existsSync(marker), 'the install marker'); + h.exit(); + await settled(h.journal, 'proc:dispose'); + expect(fsSync.existsSync(path.join(h.tmp, 'installed', '.playhook-installing'))).toBe(false); + expect(h.state.get()).toMatchObject({ kind: 'ready', game: { requiresInstall: false } }); + }); + }); + describe('uninstall', () => { async function installed(sweepDir?: string, uninstaller = false): Promise { const built = await harness({ mode: 'install', sweepDir, uninstaller }); @@ -917,6 +945,16 @@ describe('GameController sequences', () => { await settled(built.journal, 'proc:dispose'); }); + it('a staging dir left by a killed launcher is swept when the library is read', async () => { + h = await harness({ mode: 'copy' }); + const built = h; + const partial = path.join(built.tmp, 'installed.partial'); + await fs.mkdir(partial, { recursive: true }); + await fs.writeFile(path.join(partial, 'game.exe'), ''); + await built.controller.reloadPcLibrary(); + await waitFor(() => !fsSync.existsSync(partial), 'the orphaned staging dir to go'); + }); + it('a reload that drops the game stops its copy', async () => { h = await harness({ mode: 'copy', diff --git a/test/quit-gate.test.ts b/test/quit-gate.test.ts new file mode 100644 index 0000000..c4b678f --- /dev/null +++ b/test/quit-gate.test.ts @@ -0,0 +1,149 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { QuitGate, quitConfirmAckMs, quitGraceMs, withGrace } from '../src/main/quit-gate'; +import type { QuitAction } from '../src/shared/quit'; + +interface Fixture { + readonly gate: QuitGate; + readonly log: string[]; + jobs: number; + nativeAnswer: boolean; +} + +function build(): Fixture { + const log: string[] = []; + const state = { jobs: 0, nativeAnswer: false }; + const gate = new QuitGate({ + activeJobs: () => state.jobs, + showAndFocus: () => log.push('show'), + askInWindow: (action: QuitAction) => log.push(`ask:${action}`), + askNatively: (action: QuitAction, jobs: number) => { + log.push(`native:${action}:${jobs}`); + return Promise.resolve(state.nativeAnswer); + }, + ackTimeoutMs: 2000, + }); + return { + gate, + log, + get jobs() { + return state.jobs; + }, + set jobs(value: number) { + state.jobs = value; + }, + get nativeAnswer() { + return state.nativeAnswer; + }, + set nativeAnswer(value: boolean) { + state.nativeAnswer = value; + }, + }; +} + +let fixture: Fixture; + +beforeEach(() => { + vi.useFakeTimers(); + fixture = build(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('QuitGate', () => { + it('leaves at once when nothing is running', () => { + const run = vi.fn(); + expect(fixture.gate.request('quit', false, run)).toBe(true); + expect(run).toHaveBeenCalledTimes(1); + expect(fixture.log).toEqual([]); + }); + + it('does not leave unconfirmed while jobs run: it brings the window up and asks there', () => { + fixture.jobs = 2; + const run = vi.fn(); + expect(fixture.gate.request('shutdown', false, run)).toBe(false); + expect(run).not.toHaveBeenCalled(); + expect(fixture.log).toEqual(['show', 'ask:shutdown']); + }); + + it('leaves when the request carries the confirmation', () => { + fixture.jobs = 2; + const run = vi.fn(); + expect(fixture.gate.request('quit', true, run)).toBe(true); + expect(run).toHaveBeenCalledTimes(1); + }); + + it('a second request while the question is up does not ask twice', () => { + fixture.jobs = 1; + fixture.gate.request('quit', false, vi.fn()); + fixture.gate.request('quit', false, vi.fn()); + expect(fixture.log.filter((entry) => entry.startsWith('ask:'))).toHaveLength(1); + }); + + it('a window that never confirms the question gets the native dialog after the timeout', async () => { + fixture.jobs = 3; + fixture.nativeAnswer = true; + const run = vi.fn(); + fixture.gate.request('quit', false, run); + await vi.advanceTimersByTimeAsync(1999); + expect(fixture.log).not.toContain('native:quit:3'); + await vi.advanceTimersByTimeAsync(1); + expect(fixture.log).toContain('native:quit:3'); + expect(run).toHaveBeenCalledTimes(1); + }); + + it('a native No keeps the launcher running and lets the next request ask again', async () => { + fixture.jobs = 1; + const run = vi.fn(); + fixture.gate.request('quit', false, run); + await vi.advanceTimersByTimeAsync(2000); + expect(run).not.toHaveBeenCalled(); + fixture.gate.request('quit', false, run); + expect(fixture.log.filter((entry) => entry.startsWith('ask:'))).toHaveLength(2); + }); + + it('a window that confirms it is asking gets no native dialog', async () => { + fixture.jobs = 1; + fixture.gate.request('quit', false, vi.fn()); + fixture.gate.reply('shown'); + await vi.advanceTimersByTimeAsync(10_000); + expect(fixture.log.some((entry) => entry.startsWith('native:'))).toBe(false); + }); + + it('a dismissed question is over: the next request asks again', () => { + fixture.jobs = 1; + fixture.gate.request('quit', false, vi.fn()); + fixture.gate.reply('shown'); + fixture.gate.reply('dismissed'); + fixture.gate.request('reboot', false, vi.fn()); + expect(fixture.log).toEqual(['show', 'ask:quit', 'show', 'ask:reboot']); + }); +}); + +describe('withGrace', () => { + it('waits for the cleanup, but never longer than the grace', async () => { + let finished = false; + const done = withGrace(new Promise(() => undefined), 3000).then(() => { + finished = true; + }); + await vi.advanceTimersByTimeAsync(2999); + expect(finished).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await done; + expect(finished).toBe(true); + }); + + it('returns as soon as the cleanup is done', async () => { + await expect(withGrace(Promise.resolve(), 3000)).resolves.toBeUndefined(); + }); +}); + +describe('quit timings from env', () => { + it('reads PLAYHOOK_QUIT_CONFIRM_ACK_MS and PLAYHOOK_QUIT_GRACE_MS with their defaults', () => { + expect(quitConfirmAckMs({})).toBe(2000); + expect(quitConfirmAckMs({ PLAYHOOK_QUIT_CONFIRM_ACK_MS: '500' })).toBe(500); + expect(quitGraceMs({})).toBe(3000); + expect(quitGraceMs({ PLAYHOOK_QUIT_GRACE_MS: 'never' })).toBe(3000); + }); +}); diff --git a/test/renderer/controls.test.ts b/test/renderer/controls.test.ts index 598e910..745dff7 100644 --- a/test/renderer/controls.test.ts +++ b/test/renderer/controls.test.ts @@ -5,7 +5,7 @@ import { req } from '../../src/renderer/dom'; import { createTranslator } from '../../src/shared/i18n/index'; import type { AppState, BrowseInfo, GameInfo } from '../../src/shared/types'; import type { ActivityMap } from '../../src/shared/activity'; -import { screenActivityOf } from '../../src/renderer/state-view'; +import { jobCountOf, screenActivityOf } from '../../src/renderer/state-view'; import { loadFixture } from './helpers/fixture'; import { fakeAudio, fakeKeyboard, type FakeAudio } from './helpers/fakes'; import { installRafHarness } from './helpers/raf'; @@ -45,6 +45,7 @@ function fakeControlsApi(): ControlsApi { requestSleep: vi.fn(), requestHide: vi.fn(), requestQuit: vi.fn(), + quitConfirmReply: vi.fn(), resolveGameCollision: vi.fn(() => Promise.resolve({ saved: true, applied: 'applied' } as const), ), @@ -124,6 +125,7 @@ beforeEach(() => { getState: () => own.state, getBrowse: () => own.browse, getScreenActivity: () => screenActivityOf(own.browse, own.activities), + getJobCount: () => jobCountOf(own.activities), audio, getTranslator: () => createTranslator('en'), getLocale: () => 'en', @@ -441,3 +443,82 @@ describe('controls background installs', () => { }); }); + +describe('controls quit with background jobs', () => { + const view = (): string | undefined => popup().dataset['view']; + const message = (): string | null => req('confirm-message').textContent; + + it('Quit from the Power menu asks first while jobs run, and its No goes back to Power', () => { + harness.activities = { a: { kind: 'installing' }, b: { kind: 'queued' } }; + controls.openSystemCard('power'); + req('power-quit').click(); + + expect(view()).toBe('confirm'); + expect(message()).toBe('2 operations are in progress and will be cancelled. Quit anyway?'); + expect(api.requestQuit).not.toHaveBeenCalled(); + + req('confirm-no').click(); + + expect(view()).toBe('power'); + }); + + it('Quit from the Power menu leaves at once with nothing running', () => { + harness.activities = { a: { kind: 'steam-installing', paused: false } }; + controls.openSystemCard('power'); + req('power-quit').click(); + + expect(api.requestQuit).toHaveBeenCalledTimes(1); + expect(api.requestQuit).toHaveBeenCalledWith(); + }); + + it("main's question opens on its own, says it is shown, and its No just closes — no Power menu", () => { + harness.activities = { a: { kind: 'installing' } }; + controls.askQuit('quit'); + + expect(api.quitConfirmReply).toHaveBeenCalledWith('shown'); + expect(view()).toBe('confirm'); + + req('confirm-no').click(); + + expect(popup().classList.contains('is-open')).toBe(false); + expect(api.quitConfirmReply).toHaveBeenLastCalledWith('dismissed'); + }); + + it("main's question answered Yes quits confirmed and is not reported as dismissed", () => { + harness.activities = { a: { kind: 'installing' } }; + controls.askQuit('quit'); + req('confirm-yes').click(); + + expect(api.requestQuit).toHaveBeenCalledWith(true); + expect(api.quitConfirmReply).not.toHaveBeenCalledWith('dismissed'); + }); + + it("main's question waits behind an open error and comes up once it is closed", () => { + harness.activities = { a: { kind: 'installing' } }; + controls.showError('Something broke'); + controls.askQuit('reboot'); + + expect(api.quitConfirmReply).toHaveBeenCalledWith('shown'); + expect(view()).toBe('error'); + + req('error-close').click(); + vi.advanceTimersByTime(POPUP_FADE_MS); + + expect(view()).toBe('confirm'); + expect(message()).toBe('1 operation is in progress and will be cancelled. Reboot the PC anyway?'); + }); + + it('an open quit question follows the job count, and falls back to the plain question at zero', () => { + harness.activities = { a: { kind: 'installing' }, b: { kind: 'installing' } }; + controls.askQuit('quit'); + harness.activities = { a: { kind: 'installing' } }; + controls.refresh(); + + expect(message()).toBe('1 operation is in progress and will be cancelled. Quit anyway?'); + + harness.activities = {}; + controls.refresh(); + + expect(message()).toBe('Quit Playhook?'); + }); +}); From 7be2d84358544d5105bc7abe030076e953058568 Mon Sep 17 00:00:00 2001 From: Alexander Date: Mon, 5 Oct 2026 17:50:08 +0500 Subject: [PATCH 05/10] Run card installers as background jobs with session-aware queues --- src/main/game-controller.ts | 4 +- src/main/game-jobs.ts | 224 ++++++++++++++-- src/main/game-sequences.ts | 92 ------- src/main/platform/game-launcher.darwin.ts | 1 + src/main/platform/game-launcher.linux.ts | 4 + src/main/platform/types.ts | 6 + src/main/platform/win32.ts | 1 + test/game-controller.test.ts | 116 ++++---- test/game-jobs.test.ts | 313 +++++++++++++++++++++- 9 files changed, 572 insertions(+), 189 deletions(-) diff --git a/src/main/game-controller.ts b/src/main/game-controller.ts index c18758b..adda345 100644 --- a/src/main/game-controller.ts +++ b/src/main/game-controller.ts @@ -1053,10 +1053,8 @@ export class GameController { } // Card-install mode + not yet installed → run the installer; otherwise it's an ordinary launch // (this includes a fully-installed game, whose executable now exists → requiresInstall=false). - if (manifest.install?.type === 'copy' && snapshot.game.requiresInstall) { + if (manifest.install !== undefined && snapshot.game.requiresInstall) { this.jobs.startInstall(manifest); - } else if (manifest.install !== undefined && snapshot.game.requiresInstall) { - void this.sequences.runInstallSequence(manifest, snapshot.game); } else { void this.sequences.runLaunchSequence(manifest, snapshot.game); } diff --git a/src/main/game-jobs.ts b/src/main/game-jobs.ts index 01c8165..84ddcf3 100644 --- a/src/main/game-jobs.ts +++ b/src/main/game-jobs.ts @@ -1,20 +1,29 @@ import path from 'node:path'; import fse from 'fs-extra'; import { ipcMain } from 'electron'; -import { IPC, type ManifestSource } from '../shared/types'; +import { IPC, type AppState, type ManifestSource } from '../shared/types'; import { type GameActivity } from '../shared/activity'; import { type Translator } from '../shared/i18n/index'; -import type { ResolvedCopyInstall, ResolvedInstall, ResolvedManifest } from './manifest-types'; +import type { + ResolvedCopyInstall, + ResolvedInstall, + ResolvedInstallerRun, + ResolvedManifest, +} from './manifest-types'; import { type ControllerDeps } from './controller-deps'; import { type GameProcess } from './platform'; import { findCaseInsensitiveName } from './manifest'; import { LaunchAbortedError } from './launch-errors'; import { removeWithRetry } from './remove-with-retry'; -import { describe } from './util'; +import { delay, describe } from './util'; import { log } from './logger'; const DEFAULT_MAX_PARALLEL_COPY_INSTALLS = 2; +const DEFAULT_MAX_PARALLEL_INSTALLERS = 1; + +const INSTALL_POLL_INTERVAL_MS = 1000; + const PARTIAL_SUFFIX = '.partial'; const INSTALL_MARKER = '.playhook-installing'; @@ -24,6 +33,61 @@ export type GameJobKind = 'install' | 'uninstall' | 'prefix-cleanup'; /** Why a job was stopped before it finished; `user` and `shutdown` are never reported as a failure. */ export type JobAbortReason = 'user' | 'card-removed' | 'shutdown' | 'game-gone'; +/** Which queue a job waits in: copies and installer runs have limits of their own, removals none. */ +export type JobLane = 'copy' | 'installer' | 'uninstall' | 'prefix-cleanup'; + +/** What the session is doing, as far as starting a job goes. */ +export type SessionPhase = 'free' | 'active' | 'running'; + +/** What is known about an install before it starts; undefined until it has been looked up. */ +export interface JobFacts { + /** The installer shows its wizard (silent installs are switched off in Settings). */ + readonly interactive?: boolean; + /** Preparing the install's prefix would run winetricks. */ + readonly provisions?: boolean; +} + +export interface StartContext { + readonly session: SessionPhase; + readonly gamescope: boolean; + /** The install runs elevated (Windows: a UAC prompt and a blocking ShellExecuteEx). */ + readonly elevated: boolean; + readonly runningInLane: number; + readonly laneLimit: number; +} + +/** Start now, wait in the queue, or look the facts up first and then decide. */ +export type StartVerdict = 'start' | 'wait' | 'learn'; + +/** + * Whether a queued job may start. Installer runs: at most `laneLimit` at once (MSI's global mutex fails a + * second one with 1618, repacks eat the machine), never an interactive or elevated one during a session + * (a wizard or a UAC prompt over the game), and under gamescope none at all while a game runs — the game has + * the only surface there, and even a silent installer opens windows. A copy waits under gamescope only when + * its prefix still needs winetricks, for the same reason. Removals always start. + */ +export function startVerdict(lane: JobLane, facts: JobFacts, ctx: StartContext): StartVerdict { + if (lane === 'uninstall' || lane === 'prefix-cleanup') return 'start'; + if (ctx.runningInLane >= ctx.laneLimit) return 'wait'; + const gameUnderGamescope = ctx.gamescope && ctx.session === 'running'; + if (lane === 'copy') { + if (!gameUnderGamescope) return 'start'; + if (facts.provisions === undefined) return 'learn'; + return facts.provisions ? 'wait' : 'start'; + } + if (gameUnderGamescope) return 'wait'; + if (ctx.session === 'free') return 'start'; + if (ctx.elevated) return 'wait'; + if (facts.interactive === undefined) return 'learn'; + return facts.interactive ? 'wait' : 'start'; +} + +/** The session phase of an AppState: settled, busy around a game, or with the game itself running. */ +export function sessionPhaseOf(kind: AppState['kind']): SessionPhase { + if (kind === 'idle' || kind === 'ready' || kind === 'error') return 'free'; + return kind === 'running' ? 'running' : 'active'; +} + /** What a caller may know about a job: whose it is, what it does, and where the game comes from. */ export interface GameJob { readonly id: string; @@ -32,7 +96,11 @@ export interface GameJob { } interface Job extends GameJob { + readonly lane: JobLane; + readonly install: ResolvedInstall | null; readonly title: string; + facts: JobFacts; + learning: boolean; readonly abort: AbortController; readonly body: (job: Job) => Promise; readonly done: Promise; @@ -54,12 +122,15 @@ export interface GameJobsHost { export type GameJobsDeps = Pick< ControllerDeps, - 'activities' | 'notifications' | 'getTranslator' + 'activities' | 'notifications' | 'getTranslator' | 'settings' | 'isGamescope' > & { + readonly state: Pick; readonly platform: { readonly gameLauncher: Pick< ControllerDeps['platform']['gameLauncher'], | 'prepareInstallDir' + | 'needsProvisioning' + | 'launchInstaller' | 'resolveUninstaller' | 'launchUninstaller' | 'uninstallDir' @@ -70,6 +141,8 @@ export type GameJobsDeps = Pick< readonly host: GameJobsHost; /** How many copy installs run at once; the rest wait as `queued`. */ readonly maxParallelCopies?: number; + /** How many installer runs go at once; the rest wait as `queued`. */ + readonly maxParallelInstallers?: number; }; /** A failure the job reports with its own message rather than the cause's. */ @@ -81,6 +154,12 @@ export function maxParallelCopyInstalls(env: NodeJS.ProcessEnv = process.env): n return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_PARALLEL_COPY_INSTALLS; } +/** The installer-run limit: PLAYHOOK_MAX_PARALLEL_INSTALLERS when it is a positive integer, otherwise 1. */ +export function maxParallelInstallers(env: NodeJS.ProcessEnv = process.env): number { + const parsed = Number.parseInt(env['PLAYHOOK_MAX_PARALLEL_INSTALLERS'] ?? '', 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_PARALLEL_INSTALLERS; +} + /** Where a copy install stages its files until they are complete: a sibling of the install dir. */ export function partialDirOf(installDir: string): string { return `${installDir}${PARTIAL_SUFFIX}`; @@ -103,39 +182,56 @@ function throwIfAborted(job: Job): void { export class GameJobs { private readonly jobs = new Map(); private readonly maxParallelCopies: number; + private readonly maxParallelInstallers: number; constructor(private readonly deps: GameJobsDeps) { this.maxParallelCopies = deps.maxParallelCopies ?? maxParallelCopyInstalls(); + this.maxParallelInstallers = deps.maxParallelInstallers ?? maxParallelInstallers(); } private get t(): Translator { return this.deps.getTranslator(); } - /** Registers the renderer's Cancel for a queued or running install. */ + /** + * Registers the renderer's Cancel for a queued or running install, and re-checks the queue on every + * session change: what waits for the game to end starts once the state settles. + */ init(): void { ipcMain.on(IPC.actionCancelJob, (_event, id: unknown) => { if (typeof id === 'string') this.cancel(id); }); + this.deps.state.subscribe(() => this.pump()); } - /** Starts (or queues) a copy install. False when the game is busy already or is not a copy install. */ + /** Starts (or queues) an install: a copy or an installer run. False when the game is busy already. */ startInstall(manifest: ResolvedManifest): boolean { const install = manifest.install; - if (install?.type !== 'copy') return false; - return this.enqueue(manifest, 'install', (job) => this.runCopy(job, manifest, install)); + if (install === undefined) return false; + if (install.type === 'copy') { + return this.enqueue(manifest, 'install', 'copy', install, (job) => + this.runCopy(job, manifest, install), + ); + } + return this.enqueue(manifest, 'install', 'installer', install, (job) => + this.runInstaller(job, manifest, install), + ); } /** Starts removing an installed game from the PC. False when the game is busy already. */ startUninstall(manifest: ResolvedManifest): boolean { const install = manifest.install; if (install === undefined) return false; - return this.enqueue(manifest, 'uninstall', (job) => this.runUninstall(job, install)); + return this.enqueue(manifest, 'uninstall', 'uninstall', install, (job) => + this.runUninstall(job, install), + ); } /** Starts clearing a plain game's Wine prefix. False when the game is busy already. */ startPrefixCleanup(manifest: ResolvedManifest): boolean { - return this.enqueue(manifest, 'prefix-cleanup', (job) => this.runPrefixCleanup(job)); + return this.enqueue(manifest, 'prefix-cleanup', 'prefix-cleanup', null, (job) => + this.runPrefixCleanup(job), + ); } /** The user's Cancel: stops a queued or running install of `id`. False when there is none. */ @@ -201,6 +297,8 @@ export class GameJobs { private enqueue( manifest: ResolvedManifest, kind: GameJobKind, + lane: JobLane, + install: ResolvedInstall | null, body: (job: Job) => Promise, ): boolean { const id = manifest.raw.id; @@ -219,6 +317,10 @@ export class GameJobs { const job: Job = { id, kind, + lane, + install, + facts: {}, + learning: false, source: manifest.source, title: manifest.raw.title, abort: new AbortController(), @@ -230,17 +332,48 @@ export class GameJobs { running: false, }; this.jobs.set(id, job); - if (kind === 'install' && this.runningCopies() >= this.maxParallelCopies) { + this.pump(); + if (!job.running) { this.deps.activities.set(id, { kind: 'queued' }); - log.info(`[jobs] queued install id=${id}`); - return true; + log.info(`[jobs] queued ${lane} id=${id}`); } - this.launch(job); return true; } - private runningCopies(): number { - return [...this.jobs.values()].filter((job) => job.kind === 'install' && job.running).length; + private runningIn(lane: JobLane): number { + return [...this.jobs.values()].filter((job) => job.lane === lane && job.running).length; + } + + private verdict(job: Job): StartVerdict { + return startVerdict(job.lane, job.facts, { + session: sessionPhaseOf(this.deps.state.get().kind), + gamescope: this.deps.isGamescope, + elevated: job.install?.runAsAdmin === true, + runningInLane: this.runningIn(job.lane), + laneLimit: job.lane === 'copy' ? this.maxParallelCopies : this.maxParallelInstallers, + }); + } + + /** Looks up what a queued install's start depends on, then re-checks the queue. */ + private async learn(job: Job): Promise { + if (job.learning || job.install === null) return; + job.learning = true; + try { + const [settings, provisions] = await Promise.all([ + this.deps.settings.read(), + this.deps.platform.gameLauncher.needsProvisioning(job.install), + ]); + job.facts = { interactive: settings.disableSilentInstall, provisions }; + } catch (cause) { + log.warn( + `[jobs] could not look up how id=${job.id} installs — treating it as interactive:`, + describe(cause), + ); + job.facts = { interactive: true, provisions: true }; + } finally { + job.learning = false; + this.pump(); + } } private launch(job: Job): void { @@ -268,9 +401,11 @@ export class GameJobs { } private pump(): void { - for (const job of this.jobs.values()) { - if (this.runningCopies() >= this.maxParallelCopies) return; - if (job.kind === 'install' && !job.running) this.launch(job); + for (const job of [...this.jobs.values()]) { + if (job.running || job.abortReason !== null) continue; + const verdict = this.verdict(job); + if (verdict === 'start') this.launch(job); + else if (verdict === 'learn') void this.learn(job); } } @@ -339,7 +474,7 @@ export class GameJobs { case null: if (cause instanceof LaunchAbortedError) return null; if (cause instanceof JobFailure) return cause.message; - return job.kind === 'install' + return job.lane === 'copy' ? this.t('errors.copyGameFailed', { cause: describe(cause) }) : describe(cause); } @@ -398,6 +533,55 @@ export class GameJobs { } } + /** + * Runs a card installer into the install dir: a marker first (a launcher killed mid-run must not leave a + * playable-looking game behind), the installer, then a grace poll for the executable — some wrappers fork a + * child and exit early. A stopped or failed run sweeps the install dir. + */ + private async runInstaller( + job: Job, + manifest: ResolvedManifest, + install: ResolvedInstallerRun, + ): Promise { + try { + await fse.remove(install.dir); + await fse.outputFile(installMarkerOf(install.dir), ''); + const silent = !(await this.deps.settings.read()).disableSilentInstall; + try { + job.proc = await this.deps.platform.gameLauncher.launchInstaller( + install, + silent, + (active) => this.setActivity(job, { kind: active ? 'configuringProton' : 'installing' }), + ); + } catch (cause) { + throwIfAborted(job); + throw new JobFailure(this.t('errors.startInstaller', { cause: describe(cause) })); + } + await this.deps.processControl.waitForExit(job.proc, job.abort.signal); + if (!(await this.pollForExecutable(job, manifest))) { + throw new JobFailure(this.t('errors.installIncomplete')); + } + await fse.remove(installMarkerOf(install.dir)); + log.info(`[install] completed id=${job.id} dir="${install.dir}"`); + } catch (cause) { + await removeWithRetry(install.dir).catch((error: unknown) => + log.warn(`[jobs] the install dir of id=${job.id} could not be swept:`, describe(error)), + ); + throw cause; + } + } + + /** Waits up to launchTimeoutSec for the executable to appear; throws once the job is stopped. */ + private async pollForExecutable(job: Job, manifest: ResolvedManifest): Promise { + const deadline = Date.now() + manifest.raw.launchTimeoutSec * 1000; + for (;;) { + throwIfAborted(job); + if (await fse.pathExists(manifest.executablePath)) return true; + if (Date.now() >= deadline) return false; + await delay(INSTALL_POLL_INTERVAL_MS); + } + } + private async missingExeMessage(manifest: ResolvedManifest, staged: string): Promise { const shown = manifest.raw.executable ?? path.basename(manifest.executablePath); const found = await findCaseInsensitiveName(staged); diff --git a/src/main/game-sequences.ts b/src/main/game-sequences.ts index 2108bfc..1f3290a 100644 --- a/src/main/game-sequences.ts +++ b/src/main/game-sequences.ts @@ -10,13 +10,11 @@ // `failSequence` for anything else, and a finally that releases the owned process, clears the flags and // replays a card that was swapped in mid-flight. Launch alone adds to the finally (the lock and the // running-game fields) through `onFinally`. -import fse from 'fs-extra'; import { type AppState, type GameInfo, type Stats } from '../shared/types'; import type { ResolvedManifest } from './manifest-types'; import { type Translator } from '../shared/i18n/index'; import { type ControllerDeps } from './controller-deps'; import { LaunchAbortedError } from './launch-errors'; -import { installMarkerOf } from './game-jobs'; import { openSteamUri } from './steam-uri'; import { type GameProcess, type Platform, type ProcessMonitor } from './platform'; import { normalizeImageNames } from './image-names'; @@ -25,9 +23,6 @@ import { type SaveSyncFlow } from './save-sync-flow'; import { describe, delay } from './util'; import { log } from './logger'; -// Grace-poll cadence after the installer exits, waiting for the game executable to appear. -const INSTALL_POLL_INTERVAL_MS = 1000; - // Force-close verification: after issuing the kills, a `taskkill /F` (or TerminateProcess) returns // BEFORE the process actually leaves tasklist — a killed process in teardown still shows for a beat, and // a launcher/wrapper can take longer (the very reason the exit waiters debounce). So we don't judge on a @@ -660,93 +655,6 @@ export class GameSequences { ); } - /** - * Runs the installer for an install-mode game that isn't installed yet (mirrors runLaunchSequence's - * infrastructure: launchInFlight/abort, the LaunchAbortedError guard, the pendingRoot replay). - * Pre-cleans the install dir, runs the installer silently, then grace-polls for the executable — - * on success the button becomes "Play"; otherwise we stay on "Install" and surface the reason. - */ - async runInstallSequence(manifest: ResolvedManifest, info: GameInfo): Promise { - const install = manifest.install; - if (install === undefined) return; // defensive: onLaunchRequested only calls this in install mode - const { window, stats } = this.deps; - await this.runSequence('install', 'installing', info, async (abort, owned) => { - // Pre-clean: a partial install left by a previous failed attempt could carry a stale → - // a bogus "Play". We're (re)installing anyway, so a clean directory is safe. - await fse.remove(install.dir); - await fse.outputFile(installMarkerOf(install.dir), ''); - - if (install.type !== 'copy') { - // Silent by default; a user who enabled "disable silent installer mode" gets the visible wizard - // (needed for repacks that skip a crack/patch step under silent — `skipifsilent`). - const silent = !(await this.deps.settings.read()).disableSilentInstall; - let proc: GameProcess; - try { - proc = await this.launcher.launchInstaller(install, silent, (active) => - this.setProvisioning(active, info), - ); - } catch (cause) { - this.failSequence( - 'install', - info, - this.t('errors.startInstaller', { cause: describe(cause) }), - ); - return; - } - owned.proc = proc; - - // Wait for the installer to exit, then grace-poll for the executable: some installers (often - // custom wrappers) fork a child and exit early, so may appear shortly AFTER waitForExit. - await this.deps.processControl.waitForExit(proc, abort.signal); - const installed = await this.pollForExecutable( - manifest.executablePath, - manifest.raw.launchTimeoutSec, - abort.signal, - ); - if (!installed) { - this.failSequence('install', info, this.t('errors.installIncomplete')); - return; - } - } - - // Installed: rebuild GameInfo so requiresInstall recomputes to false (the executable now exists), - // flipping the button back to "Play". The next press launches normally from the install dir. - const currentStats = await stats.read(manifest.raw.id); - await fse.remove(installMarkerOf(install.dir)); - const installedInfo = await this.host.buildGameInfo(manifest, currentStats); - log.info(`[install] completed id=${manifest.raw.id} dir="${install.dir}"`); - this.host.enterReady(installedInfo); - // The "install finished" cue belongs to the notification now (its own `notify` sound). It used to - // be a bare "play" sound pushed straight to the renderer from here — two sounds would now land on - // the same moment, and that one also chirped from a hidden window while a game was running. - this.deps.notifications.notify({ - kind: 'game-installed', - gameId: manifest.raw.id, - gameTitle: installedInfo.title, - }); - window.showAndFocus(); - }); - } - - /** - * Polls for the game executable to appear within `timeoutSec` (grace window after the installer - * exits). Throws LaunchAbortedError if aborted, so a mid-install card swap unwinds WITHOUT - * setting state over the new card — never returns false on abort. - */ - private async pollForExecutable( - executablePath: string, - timeoutSec: number, - signal: AbortSignal, - ): Promise { - const deadline = Date.now() + timeoutSec * 1000; - for (;;) { - if (signal.aborted) throw new LaunchAbortedError(); - if (await fse.pathExists(executablePath)) return true; - if (Date.now() >= deadline) return false; - await delay(INSTALL_POLL_INTERVAL_MS); - } - } - /** Replays a card insertion deferred during an in-flight launch/install. No-op if none pending. */ private resumePendingInsert(): void { const root = this.pendingRoot; diff --git a/src/main/platform/game-launcher.darwin.ts b/src/main/platform/game-launcher.darwin.ts index 9fe2c3d..c973e75 100644 --- a/src/main/platform/game-launcher.darwin.ts +++ b/src/main/platform/game-launcher.darwin.ts @@ -125,6 +125,7 @@ export function createDarwinGameLauncher(deps: DarwinGameLauncherDeps): GameProc // null so an install-mode card is already rejected at manifest-read. These stay total and explicit. launchInstaller: () => refuseInstall(), prepareInstallDir: () => refuseInstall(), + needsProvisioning: () => Promise.resolve(false), launchUninstaller: () => refuseInstall(), resolveUninstaller: () => Promise.resolve(null), // Never reached (install mode never resolves on darwin); returns the same dir win32 would, so the diff --git a/src/main/platform/game-launcher.linux.ts b/src/main/platform/game-launcher.linux.ts index 3318b16..9265ee0 100644 --- a/src/main/platform/game-launcher.linux.ts +++ b/src/main/platform/game-launcher.linux.ts @@ -359,6 +359,10 @@ export function createLinuxGameLauncher(deps: LinuxGameLauncherDeps): GameProces // copied files are about to be written INTO (launchGame only provisions when the card lists top-level // winetricks, and never applies the install baseline). Do it here, before the copy — same order and // same environment as the installer path: prefix first, game files second. + async needsProvisioning(install): Promise { + const prefix = prefixForInstall(install.dir); + return pendingWinetricks(install.winetricks, await readDoneVerbs(prefix)).length > 0; + }, async prepareInstallDir(install, onProvisioning): Promise { const { prefix } = await preparePrefixForInstall(deps, install, onProvisioning); log.info(`[install] prefix ready for copy prefix="${prefix}" dir="${install.dir}"`); diff --git a/src/main/platform/types.ts b/src/main/platform/types.ts index 5dc45bb..c1ac83b 100644 --- a/src/main/platform/types.ts +++ b/src/main/platform/types.ts @@ -150,6 +150,12 @@ export interface GameProcessLauncher { install: ResolvedInstall, onProvisioning?: (active: boolean) => void, ): Promise; + /** + * Whether preparing `install`'s environment would run winetricks — linux: verbs still missing in the + * install's prefix; win32 / darwin: never. Background jobs use it to hold such a step back while a game + * runs under gamescope, where any window it opens would steal the game's only surface. + */ + needsProvisioning(install: ResolvedInstall): Promise; /** Launches a resolved uninstaller target silently. Throws on failure. */ launchUninstaller(target: LaunchTarget): Promise; /** diff --git a/src/main/platform/win32.ts b/src/main/platform/win32.ts index d02e610..4dd7ab0 100644 --- a/src/main/platform/win32.ts +++ b/src/main/platform/win32.ts @@ -135,6 +135,7 @@ function createGameLauncher(monitor: ProcessMonitor): GameProcessLauncher { // No Wine prefix on Windows: the install dir needs no preparation beyond the controller's own // pre-clean, and `copy` can write into it straight away. prepareInstallDir: () => Promise.resolve(), + needsProvisioning: () => Promise.resolve(false), launchUninstaller: (target) => launchUninstaller(target, monitor), // The game's own uninstaller first (FS search in the install dir → registry fallback): it must clean // the shared system before the install dir is removed. diff --git a/test/game-controller.test.ts b/test/game-controller.test.ts index 7e22e06..833ab1d 100644 --- a/test/game-controller.test.ts +++ b/test/game-controller.test.ts @@ -411,6 +411,7 @@ async function harness(opts: HarnessOptions): Promise { return proc; }, prepareInstallDir: () => h.copyHold, + needsProvisioning: () => Promise.resolve(false), launchUninstaller: () => { journal.push('uninstaller:launch'); return failing('uninstaller') ? Promise.reject(new Error('uninstaller boom')) : Promise.resolve(proc); @@ -668,74 +669,72 @@ describe('GameController sequences', () => { }); describe('install', () => { - it('success: installing → ready, a game-installed notification, then the window', async () => { + const flippedToPlay = (built: Harness): boolean => { + const state = built.state.get(); + return state.kind === 'ready' && !state.game.requiresInstall; + }; + + it('success: a background job, a game-installed notification, no session state and no window', async () => { h = await harness({ mode: 'install' }); - h.installerWritesExe = true; + const built = h; + built.installerWritesExe = true; fire(IPC.actionLaunch); - await reached(h.journal, 'state:installing'); - expect(h.journal).toEqual(['state:installing']); - h.exit(); - await settled(h.journal, 'proc:dispose'); - expect(h.journal).toEqual([ - 'state:installing', - 'state:ready', + await reached(built.journal, 'activity:g1:installing'); + built.exit(); + await waitFor(() => flippedToPlay(built), 'the game to flip to Play'); + expect(built.journal).toEqual([ + 'activity:g1:installing', 'notify:game-installed', - 'window:showAndFocus', 'proc:dispose', - ]); - }); - - it('error in the body: failSequence keeps the game on Install and reports the cause', async () => { - h = await harness({ mode: 'install' }); - h.installerWritesExe = true; - fire(IPC.actionLaunch); - await reached(h.journal, 'state:installing'); - h.failStats = true; - h.exit(); - await settled(h.journal, 'proc:dispose'); - expect(h.journal).toEqual([ - 'state:installing', + 'activity:g1:clear', 'state:ready', - 'window:showAndFocus', - 'send:error', - 'proc:dispose', ]); - expect(h.state.get()).toMatchObject({ kind: 'ready', game: { requiresInstall: true } }); }); - it('the installer cannot start: failSequence from inside the body, still on Install', async () => { + it('the installer cannot start: a failed-install notification, still on Install', async () => { h = await harness({ mode: 'install' }); - h.failAt = 'installer'; + const built = h; + built.failAt = 'installer'; fire(IPC.actionLaunch); - await settled(h.journal, 'send:error'); - expect(h.journal).toEqual(['state:installing', 'state:ready', 'window:showAndFocus', 'send:error']); - expect(h.state.get()).toMatchObject({ kind: 'ready', game: { requiresInstall: true } }); + await reached(built.journal, 'activity:g1:clear'); + expect(built.journal.filter((entry) => entry !== 'state:ready')).toEqual([ + 'activity:g1:installing', + 'notify:game-install-failed', + 'activity:g1:clear', + ]); + expect(built.state.get()).toMatchObject({ kind: 'ready', game: { requiresInstall: true } }); }); it('the installer exits without the executable: installIncomplete after the grace poll, still on Install', async () => { h = await harness({ mode: 'install' }); + const built = h; fire(IPC.actionLaunch); - await reached(h.journal, 'state:installing'); - h.exit(); + await reached(built.journal, 'activity:g1:installing'); + built.exit(); // launchTimeoutSec is 1: the poll for the executable gives up after a second. - await settled(h.journal, 'proc:dispose'); - expect(h.journal).toEqual([ - 'state:installing', - 'state:ready', - 'window:showAndFocus', - 'send:error', + await reached(built.journal, 'activity:g1:clear'); + expect(built.journal.filter((entry) => entry !== 'state:ready')).toEqual([ + 'activity:g1:installing', + 'notify:game-install-failed', 'proc:dispose', + 'activity:g1:clear', ]); - expect(h.state.get()).toMatchObject({ kind: 'ready', game: { requiresInstall: true } }); + expect(built.state.get()).toMatchObject({ kind: 'ready', game: { requiresInstall: true } }); }); - it('card swap mid-install: unwinds without touching the state, then replays the insert', async () => { + it('shutdown mid-install stops the job silently and releases the installer', async () => { h = await harness({ mode: 'install' }); + const built = h; fire(IPC.actionLaunch); - await reached(h.journal, 'state:installing'); - h.insert(swapRoot); - await settled(h.journal, 'window:hide'); - expect(h.journal).toEqual(['state:installing', 'proc:dispose', 'state:error', 'window:hide']); + await reached(built.journal, 'activity:g1:installing'); + await waitFor(() => fsSync.existsSync(path.join(built.tmp, 'installed', '.playhook-installing')), 'the installer run'); + built.controller.shutdown(); + await reached(built.journal, 'activity:g1:clear'); + expect(built.journal.filter((entry) => entry !== 'state:ready')).toEqual([ + 'activity:g1:installing', + 'proc:dispose', + 'activity:g1:clear', + ]); }); }); @@ -755,15 +754,17 @@ describe('GameController sequences', () => { it('a finished installer run takes its marker away', async () => { h = await harness({ mode: 'install' }); - h.installerWritesExe = true; + const built = h; + built.installerWritesExe = true; fire(IPC.actionLaunch); - await reached(h.journal, 'state:installing'); - const marker = path.join(h.tmp, 'installed', '.playhook-installing'); + const marker = path.join(built.tmp, 'installed', '.playhook-installing'); await waitFor(() => fsSync.existsSync(marker), 'the install marker'); - h.exit(); - await settled(h.journal, 'proc:dispose'); - expect(fsSync.existsSync(path.join(h.tmp, 'installed', '.playhook-installing'))).toBe(false); - expect(h.state.get()).toMatchObject({ kind: 'ready', game: { requiresInstall: false } }); + built.exit(); + await waitFor(() => { + const state = built.state.get(); + return state.kind === 'ready' && !state.game.requiresInstall; + }, 'the game to flip to Play'); + expect(fsSync.existsSync(marker)).toBe(false); }); }); @@ -1119,13 +1120,8 @@ describe('GameController sequences', () => { 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', - ]); + const afterRunning = built.journal.slice(built.journal.indexOf('state:running') + 1); + expect(afterRunning).toEqual(['activity:g1:clear', 'notify:game-installed']); expect(built.state.get()).toMatchObject({ kind: 'running', game: { id: 'other' } }); built.exit(); await settled(built.journal, 'proc:dispose'); diff --git a/test/game-jobs.test.ts b/test/game-jobs.test.ts index feaed3a..58b51c4 100644 --- a/test/game-jobs.test.ts +++ b/test/game-jobs.test.ts @@ -6,9 +6,15 @@ import { ActivityRegistry } from '../src/main/activity-registry'; import { GameJobs, maxParallelCopyInstalls, + maxParallelInstallers, partialDirOf, + startVerdict, type GameJobsHost, + type StartContext, } from '../src/main/game-jobs'; +import { StateManager } from '../src/main/state'; +import { LaunchAbortedError } from '../src/main/launch-errors'; +import { DEFAULT_SETTINGS } from '../src/main/app-settings'; import { createTranslator } from '../src/shared/i18n/index'; import type { ResolvedManifest } from '../src/main/manifest-types'; import type { GameProcess } from '../src/main/platform/types'; @@ -49,33 +55,59 @@ let tmp: string; interface Fixture { readonly jobs: GameJobs; readonly registry: ActivityRegistry; + readonly state: StateManager; readonly journal: string[]; readonly gates: Map; + /** Installer runs that have not exited yet, by game id: open() makes the installer exit. */ + readonly installers: Map; + /** Games whose installer writes the executable before it exits. */ + readonly writesExe: Set; + /** Games whose prefix still needs winetricks. */ + readonly provisions: Set; focused: boolean; session: string | null; + interactive: boolean; } let fixture: Fixture; -function build(maxParallelCopies = 2): Fixture { +interface BuildOptions { + readonly maxParallelCopies?: number; + readonly gamescope?: boolean; +} + +function build(options: BuildOptions = {}): Fixture { const registry = new ActivityRegistry(); + const state = new StateManager(); const journal: string[] = []; const gates = new Map(); - const state = { focused: false, session: null as string | null }; + const installers = new Map(); + const writesExe = new Set(); + const provisions = new Set(); + const flags = { focused: false, session: null as string | null, interactive: false }; const host: GameJobsHost = { - sessionGameId: () => state.session, + sessionGameId: () => flags.session, onGameChanged: (id) => journal.push(`changed:${id}`), - isWindowFocused: () => state.focused, + isWindowFocused: () => flags.focused, sendError: (message) => journal.push(`error:${message}`), }; - const proc: GameProcess = { + const procOf = (id: string): GameProcess => ({ pid: 1, isAlive: () => Promise.resolve(true), - kill: () => Promise.resolve(), + kill: () => { + journal.push(`kill:${id}`); + return Promise.resolve(); + }, dispose: () => journal.push('proc:dispose'), - }; + }); + const procIds = new WeakMap(); const jobs = new GameJobs({ activities: registry, + state, + settings: { + read: () => Promise.resolve({ ...DEFAULT_SETTINGS, disableSilentInstall: flags.interactive }), + }, + isGamescope: options.gamescope ?? false, platform: { gameLauncher: { prepareInstallDir: (install) => { @@ -83,13 +115,36 @@ function build(maxParallelCopies = 2): Fixture { journal.push(`prepare:${id}`); return gates.get(id)?.promise ?? Promise.resolve(); }, + needsProvisioning: (install) => Promise.resolve(provisions.has(path.basename(install.dir))), + launchInstaller: async (install) => { + const id = path.basename(install.dir); + journal.push(`installer:${id}`); + installers.set(id, gate()); + const proc = procOf(id); + procIds.set(proc, id); + return proc; + }, resolveUninstaller: () => Promise.resolve(null), - launchUninstaller: () => Promise.resolve(proc), + launchUninstaller: () => Promise.resolve(procOf('uninstaller')), uninstallDir: (install) => install.dir, prefixCleanupDir: (id) => Promise.resolve(path.join(tmp, 'prefixes', id)), }, }, - processControl: { waitForExit: () => Promise.resolve() }, + processControl: { + waitForExit: async (proc, signal) => { + const id = procIds.get(proc); + const exit = id === undefined ? undefined : installers.get(id); + if (id === undefined || exit === undefined) return; + await new Promise((resolve, reject) => { + signal?.addEventListener('abort', () => reject(new LaunchAbortedError()), { once: true }); + void exit.promise.then(resolve); + }); + if (writesExe.has(id)) { + await fs.mkdir(path.join(tmp, 'installed', id), { recursive: true }); + await fs.writeFile(path.join(tmp, 'installed', id, 'game.exe'), 'exe'); + } + }, + }, notifications: { notify: (input) => { journal.push(`notify:${input.kind}${'reason' in input ? `:${input.reason}` : ''}`); @@ -97,24 +152,54 @@ function build(maxParallelCopies = 2): Fixture { }, getTranslator: () => createTranslator('en'), host, - maxParallelCopies, + maxParallelCopies: options.maxParallelCopies ?? 2, }); + jobs.init(); return { jobs, registry, + state, journal, gates, + installers, + writesExe, + provisions, get focused() { - return state.focused; + return flags.focused; }, set focused(value: boolean) { - state.focused = value; + flags.focused = value; }, get session() { - return state.session; + return flags.session; }, set session(value: string | null) { - state.session = value; + flags.session = value; + }, + get interactive() { + return flags.interactive; + }, + set interactive(value: boolean) { + flags.interactive = value; + }, + }; +} + +/** A card game installed by running `source//setup.exe` into `installed/`. */ +async function installerGame(id: string, runAsAdmin = false): Promise { + const copy = await copyGame(id); + const dir = path.join(tmp, 'installed', id); + return { + ...copy, + raw: { ...copy.raw, launchTimeoutSec: 1 }, + install: { + type: 'nsis', + installerPath: path.join(tmp, 'source', id, 'setup.exe'), + runAsAdmin, + args: [], + winetricks: [], + dir, + installerDir: dir, }, }; } @@ -351,3 +436,203 @@ describe('maxParallelCopyInstalls', () => { expect(maxParallelCopyInstalls({ PLAYHOOK_MAX_PARALLEL_COPY_INSTALLS: '0' })).toBe(2); }); }); + +const SESSION_GAME = { + id: 'session', + title: 'Session', + lastPlayedAt: null, + totalPlaySeconds: 0, + launchCount: 0, + requiresInstall: false, + canUninstall: false, +} as const; + +const installed = (id: string): string => path.join(tmp, 'installed', id); + +describe('GameJobs installer runs', () => { + it('runs the installer in the background and finishes once the executable is there, marker gone', async () => { + const a = await installerGame('a'); + fixture.writesExe.add('a'); + expect(fixture.jobs.startInstall(a)).toBe(true); + await waitFor(() => fixture.installers.has('a'), 'the installer to start'); + expect(fixture.registry.get('a')).toEqual({ kind: 'installing' }); + expect(await exists(path.join(installed('a'), '.playhook-installing'))).toBe(true); + fixture.installers.get('a')?.open(); + await waitFor(() => !fixture.jobs.has('a'), 'the install to finish'); + expect(fixture.journal).toContain('notify:game-installed'); + expect(await exists(installedExe('a'))).toBe(true); + expect(await exists(path.join(installed('a'), '.playhook-installing'))).toBe(false); + }); + + it('an installer that exits without the executable fails after the grace poll and sweeps the dir', async () => { + const a = await installerGame('a'); + fixture.jobs.startInstall(a); + await waitFor(() => fixture.installers.has('a'), 'the installer to start'); + fixture.installers.get('a')?.open(); + await waitFor(() => !fixture.jobs.has('a'), 'the install to give up'); + expect(fixture.journal).toContain( + 'notify:game-install-failed:installation did not complete (the game executable did not appear)', + ); + expect(await exists(installed('a'))).toBe(false); + }); + + it('a second installer waits for the first under the default limit of one', async () => { + const a = await installerGame('a'); + const b = await installerGame('b'); + fixture.writesExe.add('a'); + fixture.writesExe.add('b'); + fixture.jobs.startInstall(a); + fixture.jobs.startInstall(b); + await waitFor(() => fixture.installers.has('a'), 'the first installer'); + expect(fixture.registry.get('b')).toEqual({ kind: 'queued' }); + expect(fixture.journal).not.toContain('installer:b'); + fixture.installers.get('a')?.open(); + await waitFor( + () => fixture.installers.has('b'), + 'the second installer to start after the first', + ); + fixture.installers.get('b')?.open(); + await waitFor(() => !fixture.jobs.anyActive(), 'both installs to finish'); + }); + + it('an interactive installer waits for the session to end, then starts on its own', async () => { + const a = await installerGame('a'); + fixture.interactive = true; + fixture.writesExe.add('a'); + fixture.state.set({ kind: 'running', game: SESSION_GAME, since: 0 }); + fixture.jobs.startInstall(a); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(fixture.registry.get('a')).toEqual({ kind: 'queued' }); + expect(fixture.journal).not.toContain('installer:a'); + fixture.state.set({ kind: 'syncing-out', game: SESSION_GAME }); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(fixture.journal).not.toContain('installer:a'); + fixture.state.set({ kind: 'ready', game: SESSION_GAME }); + await waitFor( + () => fixture.installers.has('a'), + 'the installer to start once the session ended', + ); + fixture.installers.get('a')?.open(); + }); + + it('an elevated installer waits for the session even when silent', async () => { + const a = await installerGame('a', true); + fixture.state.set({ kind: 'launching', game: SESSION_GAME }); + fixture.jobs.startInstall(a); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(fixture.journal).not.toContain('installer:a'); + fixture.state.set({ kind: 'ready', game: SESSION_GAME }); + await waitFor(() => fixture.installers.has('a'), 'the elevated installer after the session'); + fixture.installers.get('a')?.open(); + }); + + it('a silent installer starts during a session away from gamescope', async () => { + const a = await installerGame('a'); + fixture.state.set({ kind: 'running', game: SESSION_GAME, since: 0 }); + fixture.jobs.startInstall(a); + await waitFor( + () => fixture.installers.has('a'), + 'the silent installer to start during the session', + ); + fixture.installers.get('a')?.open(); + }); + + it('cancelling a running installer kills it and sweeps the install dir', async () => { + const a = await installerGame('a'); + fixture.jobs.startInstall(a); + await waitFor(() => fixture.installers.has('a'), 'the installer to start'); + fixture.jobs.cancel('a'); + await waitFor(() => !fixture.jobs.has('a'), 'the cancelled install to unwind'); + expect(fixture.journal).toContain('kill:a'); + expect(await exists(installed('a'))).toBe(false); + expect(fixture.journal.filter((entry) => entry.startsWith('notify:'))).toEqual([]); + }); +}); + +describe('GameJobs under gamescope', () => { + beforeEach(() => { + fixture = build({ gamescope: true }); + fixture.state.set({ kind: 'running', game: SESSION_GAME, since: 0 }); + }); + + it('holds every installer while a game runs, even a silent one', async () => { + const a = await installerGame('a'); + fixture.jobs.startInstall(a); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(fixture.journal).not.toContain('installer:a'); + fixture.state.set({ kind: 'ready', game: SESSION_GAME }); + await waitFor(() => fixture.installers.has('a'), 'the installer once the game is over'); + fixture.installers.get('a')?.open(); + }); + + it('holds a copy whose prefix still needs winetricks, and lets one that does not through', async () => { + const a = await copyGame('a'); + const b = await copyGame('b'); + fixture.provisions.add('a'); + fixture.jobs.startInstall(a); + fixture.jobs.startInstall(b); + await waitFor(() => !fixture.jobs.has('b'), 'the copy without winetricks to finish'); + expect(fixture.journal).not.toContain('prepare:a'); + fixture.state.set({ kind: 'ready', game: SESSION_GAME }); + await waitFor(() => !fixture.jobs.has('a'), 'the held copy once the game is over'); + }); +}); + +describe('startVerdict', () => { + const ctx = (patch: Partial = {}): StartContext => ({ + session: 'free', + gamescope: false, + elevated: false, + runningInLane: 0, + laneLimit: 1, + ...patch, + }); + + it('removals always start', () => { + expect(startVerdict('uninstall', {}, ctx({ session: 'running', gamescope: true }))).toBe( + 'start', + ); + expect(startVerdict('prefix-cleanup', {}, ctx({ runningInLane: 5 }))).toBe('start'); + }); + + it('a full lane waits', () => { + expect(startVerdict('copy', {}, ctx({ runningInLane: 1 }))).toBe('wait'); + expect(startVerdict('installer', {}, ctx({ runningInLane: 1 }))).toBe('wait'); + }); + + it('an installer asks whether it is interactive only when a session is up', () => { + expect(startVerdict('installer', {}, ctx())).toBe('start'); + expect(startVerdict('installer', {}, ctx({ session: 'active' }))).toBe('learn'); + expect(startVerdict('installer', { interactive: true }, ctx({ session: 'active' }))).toBe( + 'wait', + ); + expect(startVerdict('installer', { interactive: false }, ctx({ session: 'running' }))).toBe( + 'start', + ); + expect( + startVerdict('installer', { interactive: false }, ctx({ session: 'active', elevated: true })), + ).toBe('wait'); + }); + + it('under gamescope a running game holds every installer and a copy that provisions', () => { + const gamescope = ctx({ session: 'running', gamescope: true }); + expect(startVerdict('installer', { interactive: false }, gamescope)).toBe('wait'); + expect(startVerdict('copy', {}, gamescope)).toBe('learn'); + expect(startVerdict('copy', { provisions: true }, gamescope)).toBe('wait'); + expect(startVerdict('copy', { provisions: false }, gamescope)).toBe('start'); + expect( + startVerdict( + 'installer', + { interactive: false }, + ctx({ session: 'active', gamescope: true }), + ), + ).toBe('start'); + }); +}); + +describe('maxParallelInstallers', () => { + it('reads PLAYHOOK_MAX_PARALLEL_INSTALLERS and falls back to one', () => { + expect(maxParallelInstallers({})).toBe(1); + expect(maxParallelInstallers({ PLAYHOOK_MAX_PARALLEL_INSTALLERS: '2' })).toBe(2); + }); +}); From 3b00b6731ce1bb75a90a5cc58394c5a21bd22ac5 Mon Sep 17 00:00:00 2001 From: Alexander Date: Mon, 5 Oct 2026 18:03:47 +0500 Subject: [PATCH 06/10] Act on games by id: install or remove beside a running game --- src/main/action-target.ts | 42 ++++++ src/main/game-actions.ts | 221 ++++++++++++++++++++++++++++++ src/main/game-controller.ts | 107 +++------------ src/main/game-jobs.ts | 62 ++++++--- src/main/steam-activity-watch.ts | 3 +- src/main/steam.ts | 30 ++-- src/preload/preload.ts | 8 +- src/renderer/app.ts | 18 ++- src/renderer/confirm.ts | 6 +- src/renderer/controls-deps.ts | 4 +- src/renderer/controls.ts | 68 ++------- src/renderer/details-menu.ts | 30 ++-- src/renderer/popups.ts | 25 +--- src/renderer/state-view.ts | 73 +++++++++- src/renderer/styles.css | 3 + src/shared/activity.ts | 2 +- src/shared/i18n/en.ts | 2 + src/shared/i18n/ru.ts | 2 + src/shared/types.ts | 4 +- test/action-target.test.ts | 54 ++++++++ test/file-size-ratchet.test.ts | 4 +- test/game-controller.test.ts | 108 ++++++++++++++- test/game-jobs.test.ts | 52 +++++-- test/renderer/controls.test.ts | 59 ++++++++ test/state-view.test.ts | 105 +++++++++++--- test/steam-activity-watch.test.ts | 14 ++ 26 files changed, 833 insertions(+), 273 deletions(-) create mode 100644 src/main/action-target.ts create mode 100644 src/main/game-actions.ts create mode 100644 test/action-target.test.ts diff --git a/src/main/action-target.ts b/src/main/action-target.ts new file mode 100644 index 0000000..ce0c2f6 --- /dev/null +++ b/src/main/action-target.ts @@ -0,0 +1,42 @@ +/** What a Play / Install or Uninstall pressed for one game id resolves to. */ +export type ActionTarget = + | 'launch-or-install-selected' + | 'select-then-act' + | 'install-only' + | 'uninstall-only' + | 'resume-session' + | 'refuse'; + +export interface ActionTargetInput { + readonly action: 'launch' | 'uninstall'; + /** The game the action was pressed for. */ + readonly id: string; + /** The game AppState is about (the selection), or null. */ + readonly selectedId: string | null; + /** A session is in flight: launching, running, syncing, or a sequence still unwinding. */ + readonly sessionBusy: boolean; + /** The game that session is about, or null. */ + readonly sessionGameId: string | null; + /** The target already has an activity (an install, a removal, a Steam download). */ + readonly hasActivity: boolean; + /** The target's source is available: a local game always, a card game only with its card in. */ + readonly sourceAvailable: boolean; + readonly reloadInFlight: boolean; +} + +/** + * Where an action for game `id` goes. With the session free it acts on the selected game as it always + * did, selecting the target first when it is another one. With a session in flight the session's own game + * only resumes, and any other game may be installed or removed but never launched: one game runs at a time. + * A game that is busy, whose card is gone, or that is being reloaded is refused. + */ +export function resolveActionTarget(input: ActionTargetInput): ActionTarget { + if (input.hasActivity || !input.sourceAvailable || input.reloadInFlight) return 'refuse'; + if (!input.sessionBusy) { + if (input.id !== input.selectedId) return 'select-then-act'; + return input.action === 'launch' ? 'launch-or-install-selected' : 'uninstall-only'; + } + if (input.id === input.sessionGameId) + return input.action === 'launch' ? 'resume-session' : 'refuse'; + return input.action === 'launch' ? 'install-only' : 'uninstall-only'; +} diff --git a/src/main/game-actions.ts b/src/main/game-actions.ts new file mode 100644 index 0000000..9b2050d --- /dev/null +++ b/src/main/game-actions.ts @@ -0,0 +1,221 @@ +import { type GameInfo } from '../shared/types'; +import { type Translator } from '../shared/i18n/index'; +import type { ResolvedManifest } from './manifest-types'; +import { type ControllerDeps } from './controller-deps'; +import { type GameSequences } from './game-sequences'; +import { type GameJobs } from './game-jobs'; +import { resolveActionTarget, type ActionTarget } from './action-target'; +import { log } from './logger'; + +/** What the actions need back from the card session. */ +export interface GameActionsHost { + /** Every game that can be acted on right now (the card's and the PC library's). */ + games(): readonly ResolvedManifest[]; + /** The selected game, or the first one when the selection is gone. */ + current(): ResolvedManifest | null; + sourceAvailable(manifest: ResolvedManifest): boolean; + /** A manifest reload from the Customize screen is in flight. */ + isReloading(): boolean; + /** The id of the game the busy session is about, or null while the state is settled. */ + sessionGameId(): string | null; + /** The renderer's action:select for `id`; refused (left as it was) while the session is busy. */ + select(id: string): Promise; + /** A freshly built GameInfo for a game that is not the one AppState is about. */ + buildGameInfo(manifest: ResolvedManifest): Promise; + sendError(message: string): void; +} + +export type GameActionsDeps = Pick< + ControllerDeps, + 'state' | 'activities' | 'processControl' | 'getTranslator' +> & { + readonly sequences: Pick< + GameSequences, + | 'inFlight' + | 'runningGameImageNames' + | 'runSteamInstall' + | 'runSteamUninstall' + | 'runLaunchSequence' + >; + readonly jobs: Pick; + readonly host: GameActionsHost; +}; + +/** + * The renderer's Play / Install and Uninstall, addressed by game id. With the session free they act on the + * selected game exactly as before (selecting the target first when it is another one); with a game running + * they still install or remove OTHER games in the background, and never start a second one. + */ +export class GameActions { + constructor(private readonly deps: GameActionsDeps) {} + + private get t(): Translator { + return this.deps.getTranslator(); + } + + /** The id AppState is about while ready, else the selection's. */ + private selectedId(): string | null { + const snapshot = this.deps.state.get(); + return snapshot.kind === 'ready' + ? snapshot.game.id + : (this.deps.host.current()?.raw.id ?? null); + } + + /** The game an action names, or the selected one when it names none (an older caller). */ + private targetOf(idRaw: unknown): ResolvedManifest | null { + const id = typeof idRaw === 'string' ? idRaw : this.selectedId(); + return this.deps.host.games().find((manifest) => manifest.raw.id === id) ?? null; + } + + private resolve(action: 'launch' | 'uninstall', manifest: ResolvedManifest): ActionTarget { + const sessionGameId = this.deps.host.sessionGameId(); + return resolveActionTarget({ + action, + id: manifest.raw.id, + selectedId: this.selectedId(), + sessionBusy: sessionGameId !== null || this.deps.sequences.inFlight, + sessionGameId, + hasActivity: this.deps.activities.has(manifest.raw.id), + sourceAvailable: this.deps.host.sourceAvailable(manifest), + reloadInFlight: this.deps.host.isReloading(), + }); + } + + /** Play / the install confirm's Yes for game `idRaw`. */ + async launch(idRaw: unknown): Promise { + const manifest = this.targetOf(idRaw); + if (manifest === null) return; + const id = manifest.raw.id; + switch (this.resolve('launch', manifest)) { + case 'resume-session': + // Play pressed while a game is running (the launcher was summoned over it via the tray): return to the + // game instead of launching. No-op if we don't have the image names yet. + if (this.deps.state.get().kind === 'running') this.resumeRunningGame(); + return; + case 'select-then-act': + await this.deps.host.select(id); + if (this.selectedId() === id) await this.launch(id); + return; + case 'install-only': + await this.installInBackground(manifest); + return; + case 'launch-or-install-selected': + this.launchSelected(); + return; + case 'uninstall-only': + case 'refuse': + log.info(`[launch] refused id=${id}: the game is busy, unavailable or being reloaded`); + return; + } + } + + /** The uninstall confirm's Yes for game `idRaw`. */ + async uninstall(idRaw: unknown): Promise { + const manifest = this.targetOf(idRaw); + if (manifest === null) return; + const id = manifest.raw.id; + switch (this.resolve('uninstall', manifest)) { + case 'select-then-act': + await this.deps.host.select(id); + if (this.selectedId() === id) await this.uninstall(id); + return; + case 'uninstall-only': + await this.uninstallOne(manifest); + return; + default: + log.info(`[uninstall] refused id=${id}`); + } + } + + private launchSelected(): void { + const snapshot = this.deps.state.get(); + // Ignore input outside the ready state — this is the "ignore-gamepad" during play + // (harmless under any interpretation of the Gamepad API focus bug). + if (snapshot.kind !== 'ready' || this.deps.sequences.inFlight) return; + const manifest = this.targetOf(snapshot.game.id); + if (manifest === null) return; + // A local game whose .exe is gone (deleted, or an external drive unplugged). The renderer already + // disables Play, but a gamepad press must not slip past it into a launch that can only fail. + if (snapshot.game.unavailable === true) { + log.info( + `[launch] refused id=${manifest.raw.id}: "${manifest.executablePath}" is not on disk`, + ); + this.deps.host.sendError(this.t('launcher.state.gameFilesMissing')); + return; + } + // A local draft with no launch method chosen yet — same guard, different reason. The renderer already + // disables Play, but a gamepad press must not slip past it into runLaunchSequence. + if (snapshot.game.unconfigured === true) { + log.info(`[launch] refused id=${manifest.raw.id}: no launch method is configured`); + this.deps.host.sendError(this.t('launcher.state.launchNotConfigured')); + 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.deps.sequences.runSteamInstall(manifest); + } else { + void this.deps.sequences.runLaunchSequence(manifest, snapshot.game); + } + return; + } + // Card-install mode + not yet installed → run the installer; otherwise it's an ordinary launch + // (this includes a fully-installed game, whose executable now exists → requiresInstall=false). + if (manifest.install !== undefined && snapshot.game.requiresInstall) { + this.deps.jobs.startInstall(manifest); + } else { + void this.deps.sequences.runLaunchSequence(manifest, snapshot.game); + } + } + + /** Installs a game other than the session's: Steam's own download, or a background job. Never a launch. */ + private async installInBackground(manifest: ResolvedManifest): Promise { + const info = await this.deps.host.buildGameInfo(manifest); + if (!info.requiresInstall || info.unconfigured === true) { + log.info( + `[launch] refused id=${manifest.raw.id}: a second game is not launched while one runs`, + ); + return; + } + if (manifest.steam !== undefined) { + void this.deps.sequences.runSteamInstall(manifest); + return; + } + this.deps.jobs.startInstall(manifest); + } + + private async uninstallOne(manifest: ResolvedManifest): Promise { + const snapshot = this.deps.state.get(); + const info = + snapshot.kind === 'ready' && snapshot.game.id === manifest.raw.id + ? snapshot.game + : await this.deps.host.buildGameInfo(manifest); + if (!info.canUninstall) 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.deps.sequences.runSteamUninstall(manifest); + return; + } + if (manifest.install === undefined) { + // Normal executable game: the only "uninstall" is clearing its Wine prefix (Linux; the game stays on + // the card). canUninstall is set only when that prefix exists — see buildGameInfo / prefixCleanupOnly. + if (info.prefixCleanupOnly === true) this.deps.jobs.startPrefixCleanup(manifest); + return; + } + this.deps.jobs.startUninstall(manifest); + } + + /** + * Return-to-game: raise the running game's own window to the foreground (restoring it if it minimized + * when it lost focus). Best-effort — if the window isn't found (the game is already closing, a race with + * waitForExit) it's a silent no-op; the state machine will move to syncing-out → ready on its own. + */ + private resumeRunningGame(): void { + const names = this.deps.sequences.runningGameImageNames; + if (names === null) return; + if (!this.deps.processControl.focusGameWindow(names)) { + log.info('[resume] running game window not found — no-op (it may be closing)'); + } + } +} diff --git a/src/main/game-controller.ts b/src/main/game-controller.ts index adda345..dd8e141 100644 --- a/src/main/game-controller.ts +++ b/src/main/game-controller.ts @@ -40,6 +40,7 @@ import { GameSequences } from './game-sequences'; import { SaveSyncFlow } from './save-sync-flow'; import { SteamActivityWatch, type SteamWatchEntry } from './steam-activity-watch'; import { GameJobs, installMarkerOf } from './game-jobs'; +import { GameActions } from './game-actions'; import { describe } from './util'; import { log } from './logger'; @@ -111,6 +112,7 @@ export class GameController { }); private readonly steamWatch: SteamActivityWatch; readonly jobs: GameJobs; + private readonly actions: GameActions; // 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. @@ -181,6 +183,22 @@ export class GameController { onInsert: (root) => this.onInsert(root), }, }); + this.actions = new GameActions({ + ...this.deps, + sequences: this.sequences, + jobs: this.jobs, + host: { + games: () => this.games, + current: () => this.current(), + sourceAvailable: (manifest) => this.sourceAvailable(manifest), + isReloading: () => this.reloadInFlight, + sessionGameId: () => this.sessionGameId(), + select: (id) => this.onSelectRequested(id), + buildGameInfo: async (manifest) => + this.buildGameInfo(manifest, this.statsById.get(manifest.raw.id) ?? (await this.deps.stats.read(manifest.raw.id))), + sendError: (message) => this.sendError(message), + }, + }); } /** The current translator (a message is fixed at the language of the moment it is generated). */ @@ -362,8 +380,8 @@ export class GameController { IPC.startupSoundRequest, (): Promise => this.assets.readStartupSoundDataUrl(), ); - ipcMain.on(IPC.actionLaunch, () => void this.onLaunchRequested()); - ipcMain.on(IPC.actionUninstall, () => void this.onUninstallRequested()); + ipcMain.on(IPC.actionLaunch, (_event, id: unknown) => void this.actions.launch(id)); + ipcMain.on(IPC.actionUninstall, (_event, id: unknown) => void this.actions.uninstall(id)); // Game Mode: hiding is meaningless (no tray, and on Linux no summon hotkey) — ignore the Hide button // so the only window can't vanish with no way back. Desktop/Windows hide to the tray as before. ipcMain.on(IPC.actionHide, () => { @@ -1009,70 +1027,6 @@ export class GameController { // ── "Launch" action (the A button / click) ────────────────────────────── - private onLaunchRequested(): void { - const snapshot = this.deps.state.get(); - // Play pressed while a game is running (the launcher was summoned over it via the tray): return to the - // game instead of launching. Checked BEFORE the ready-guard — launchInFlight is true during running, - // but we never reach its check. No-op if we don't have the image names yet. - if (snapshot.kind === 'running') { - this.resumeRunningGame(); - return; - } - // Ignore input outside the ready state — this is the "ignore-gamepad" during play - // (harmless under any interpretation of the Gamepad API focus bug). - if (snapshot.kind !== 'ready' || this.sequences.inFlight || this.reloadInFlight) return; - const manifest = this.current(); - if (manifest === null) return; - // A local game whose .exe is gone (deleted, or an external drive unplugged). The renderer already - // disables Play, but a gamepad press must not slip past it into a launch that can only fail. - if (snapshot.game.unavailable === true) { - log.info(`[launch] refused id=${manifest.raw.id}: "${manifest.executablePath}" is not on disk`); - this.sendError(this.t('launcher.state.gameFilesMissing')); - return; - } - // A local draft with no launch method chosen yet — same guard, different reason. The renderer already - // disables Play, but a gamepad press must not slip past it into runLaunchSequence. - if (snapshot.game.unconfigured === true) { - log.info(`[launch] refused id=${manifest.raw.id}: no launch method is configured`); - this.sendError(this.t('launcher.state.launchNotConfigured')); - return; - } - 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); - } else { - void this.sequences.runLaunchSequence(manifest, snapshot.game); - } - return; - } - // Card-install mode + not yet installed → run the installer; otherwise it's an ordinary launch - // (this includes a fully-installed game, whose executable now exists → requiresInstall=false). - if (manifest.install !== undefined && snapshot.game.requiresInstall) { - this.jobs.startInstall(manifest); - } else { - void this.sequences.runLaunchSequence(manifest, snapshot.game); - } - } - - /** - * Return-to-game: raise the running game's own window to the foreground (restoring it if it minimized - * when it lost focus). Best-effort — if the window isn't found (the game is already closing, a race with - * waitForExit) it's a silent no-op; the state machine will move to syncing-out → ready on its own. - */ - private resumeRunningGame(): void { - const names = this.sequences.runningGameImageNames; - if (names === null) return; - if (!this.deps.processControl.focusGameWindow(names)) { - log.info('[resume] running game window not found — no-op (it may be closing)'); - } - } - /** * The carousel entered a game's detail screen (renderer sent action:select with the game id). Switches to * it: builds that game's hero/audio/GameInfo on demand (only the selected game ever gets heavy assets) @@ -1099,27 +1053,6 @@ export class GameController { await this.browseToUnlessPinned(manifest.raw.id); } - /** "Uninstall" action (the user confirmed in the popup). Only for an installed install-mode game. */ - private onUninstallRequested(): void { - const snapshot = this.deps.state.get(); - if (snapshot.kind !== 'ready' || this.sequences.inFlight || this.reloadInFlight) return; - const manifest = this.current(); - if (manifest === null) return; - 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); - return; - } - if (manifest.install === undefined) { - // Normal executable game: the only "uninstall" is clearing its Wine prefix (Linux; the game stays on - // the card). canUninstall is set only when that prefix exists — see buildGameInfo / prefixCleanupOnly. - if (snapshot.game.prefixCleanupOnly === true) this.jobs.startPrefixCleanup(manifest); - return; - } - this.jobs.startUninstall(manifest); - } - /** * Opens Steam's Downloads page (steam://open/downloads). Triggered by the Play button while a Steam * download is in progress (its loader is otherwise a no-op) so the user can pause/resume in Steam — diff --git a/src/main/game-jobs.ts b/src/main/game-jobs.ts index 84ddcf3..23ecb05 100644 --- a/src/main/game-jobs.ts +++ b/src/main/game-jobs.ts @@ -45,6 +45,8 @@ export interface JobFacts { readonly interactive?: boolean; /** Preparing the install's prefix would run winetricks. */ readonly provisions?: boolean; + /** A removal runs the game's own (third-party) uninstaller, not just a directory sweep. */ + readonly uninstaller?: boolean; } export interface StartContext { @@ -56,30 +58,36 @@ export interface StartContext { readonly laneLimit: number; } -/** Start now, wait in the queue, or look the facts up first and then decide. */ -export type StartVerdict = 'start' | 'wait' | 'learn'; +/** Start now, wait for a free slot, wait for the session to end, or look the facts up first. */ +export type StartVerdict = 'start' | 'wait' | 'wait-session' | 'learn'; /** * Whether a queued job may start. Installer runs: at most `laneLimit` at once (MSI's global mutex fails a * second one with 1618, repacks eat the machine), never an interactive or elevated one during a session * (a wizard or a UAC prompt over the game), and under gamescope none at all while a game runs — the game has * the only surface there, and even a silent installer opens windows. A copy waits under gamescope only when - * its prefix still needs winetricks, for the same reason. Removals always start. + * its prefix still needs winetricks, for the same reason. A removal that runs the game's own uninstaller + * follows the installer rules for the session (a window or a UAC prompt of its own); other removals start. */ export function startVerdict(lane: JobLane, facts: JobFacts, ctx: StartContext): StartVerdict { - if (lane === 'uninstall' || lane === 'prefix-cleanup') return 'start'; - if (ctx.runningInLane >= ctx.laneLimit) return 'wait'; + if (lane === 'prefix-cleanup') return 'start'; const gameUnderGamescope = ctx.gamescope && ctx.session === 'running'; + if (lane === 'uninstall') { + if (ctx.session === 'free' || !(gameUnderGamescope || ctx.elevated)) return 'start'; + if (facts.uninstaller === undefined) return 'learn'; + return facts.uninstaller ? 'wait-session' : 'start'; + } + if (ctx.runningInLane >= ctx.laneLimit) return 'wait'; if (lane === 'copy') { if (!gameUnderGamescope) return 'start'; if (facts.provisions === undefined) return 'learn'; - return facts.provisions ? 'wait' : 'start'; + return facts.provisions ? 'wait-session' : 'start'; } - if (gameUnderGamescope) return 'wait'; + if (gameUnderGamescope) return 'wait-session'; if (ctx.session === 'free') return 'start'; - if (ctx.elevated) return 'wait'; + if (ctx.elevated) return 'wait-session'; if (facts.interactive === undefined) return 'learn'; - return facts.interactive ? 'wait' : 'start'; + return facts.interactive ? 'wait-session' : 'start'; } /** The session phase of an AppState: settled, busy around a game, or with the game itself running. */ @@ -333,10 +341,7 @@ export class GameJobs { }; this.jobs.set(id, job); this.pump(); - if (!job.running) { - this.deps.activities.set(id, { kind: 'queued' }); - log.info(`[jobs] queued ${lane} id=${id}`); - } + if (!job.running) log.info(`[jobs] queued ${lane} id=${id}`); return true; } @@ -354,22 +359,31 @@ export class GameJobs { }); } - /** Looks up what a queued install's start depends on, then re-checks the queue. */ + /** Looks up what a queued job's start depends on, then re-checks the queue. */ private async learn(job: Job): Promise { - if (job.learning || job.install === null) return; + const install = job.install; + if (job.learning || install === null) return; job.learning = true; try { + if (job.lane === 'uninstall') { + job.facts = { + uninstaller: + install.type !== 'copy' && + (await this.deps.platform.gameLauncher.resolveUninstaller(install)) !== null, + }; + return; + } const [settings, provisions] = await Promise.all([ this.deps.settings.read(), - this.deps.platform.gameLauncher.needsProvisioning(job.install), + this.deps.platform.gameLauncher.needsProvisioning(install), ]); job.facts = { interactive: settings.disableSilentInstall, provisions }; } catch (cause) { log.warn( - `[jobs] could not look up how id=${job.id} installs — treating it as interactive:`, + `[jobs] could not look up how id=${job.id} runs — treating it as interactive:`, describe(cause), ); - job.facts = { interactive: true, provisions: true }; + job.facts = { interactive: true, provisions: true, uninstaller: true }; } finally { job.learning = false; this.pump(); @@ -404,8 +418,16 @@ export class GameJobs { for (const job of [...this.jobs.values()]) { if (job.running || job.abortReason !== null) continue; const verdict = this.verdict(job); - if (verdict === 'start') this.launch(job); - else if (verdict === 'learn') void this.learn(job); + if (verdict === 'start') { + this.launch(job); + continue; + } + this.deps.activities.set(job.id, { + kind: 'queued', + ...(verdict === 'wait-session' ? { reason: 'session' as const } : {}), + ...(job.kind === 'install' ? {} : { removal: true as const }), + }); + if (verdict === 'learn') void this.learn(job); } } diff --git a/src/main/steam-activity-watch.ts b/src/main/steam-activity-watch.ts index 221b0a9..973baf0 100644 --- a/src/main/steam-activity-watch.ts +++ b/src/main/steam-activity-watch.ts @@ -151,7 +151,8 @@ export class SteamActivityWatch { 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' }); + const status = statuses.get(game.appid); + if (status !== undefined) this.apply(game, status); } } catch (cause) { log.warn('[steam-watch] tick failed:', describe(cause)); diff --git a/src/main/steam.ts b/src/main/steam.ts index 589db36..5ff559f 100644 --- a/src/main/steam.ts +++ b/src/main/steam.ts @@ -105,15 +105,15 @@ function fraction(num: number | null, den: number | null): number | null { return Math.max(0, Math.min(1, num / den)); } -async function readAcfState(acfPath: string): Promise { +async function readAcfState(acfPath: string): Promise { let content: string; try { content = await fse.readFile(acfPath, 'utf8'); } catch { - return null; // no .acf in this library + return 'missing'; // no .acf in this library } const flags = readAcfNumber(content, 'StateFlags'); - if (flags === null) return null; + if (flags === null) return 'unreadable'; // Staged progress matches Steam's displayed percent best; fall back to download progress. const progress = fraction(readAcfNumber(content, 'BytesStaged'), readAcfNumber(content, 'BytesToStage')) ?? @@ -131,14 +131,20 @@ 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. + * the single lookup and the batch one below. Null when the only `.acf` found could not be parsed — most + * likely caught halfway through Steam rewriting it — so the state is unknown rather than "absent". */ -async function statusInLibraries(appid: number, libs: readonly string[]): Promise { +async function statusInLibraries(appid: number, libs: readonly string[]): Promise { let downloading: SteamInstallStatus | null = null; + let unreadable = false; 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 === 'missing') continue; + if (acf === 'unreadable') { + unreadable = true; + 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). @@ -149,7 +155,8 @@ async function statusInLibraries(appid: number, libs: readonly string[]): Promis preloaded: acf.preloaded, }; } - return downloading ?? { state: 'absent' }; + if (downloading !== null) return downloading; + return unreadable ? null : { state: 'absent' }; } /** @@ -164,7 +171,7 @@ export async function steamInstallStatus( try { const steamPath = await locator.locateSteam(); if (steamPath === null) return { state: 'absent' }; - return await statusInLibraries(appid, await steamLibraryDirs(steamPath)); + return (await statusInLibraries(appid, await steamLibraryDirs(steamPath))) ?? { state: 'absent' }; } catch (cause) { log.warn( '[steam] install check failed:', @@ -177,7 +184,8 @@ export async function steamInstallStatus( /** * 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. + * instead of reading every one of them as removed; an appid whose `.acf` could not be parsed is left out of + * the map for the same reason. */ export async function steamInstallStatuses( appids: readonly number[], @@ -190,7 +198,9 @@ export async function steamInstallStatuses( const statuses = await Promise.all( appids.map(async (appid) => [appid, await statusInLibraries(appid, libs)] as const), ); - return new Map(statuses); + return new Map( + statuses.flatMap(([appid, status]) => (status === null ? [] : [[appid, status] as const])), + ); } catch (cause) { log.warn( '[steam] batch install check failed:', diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 82eee3c..5cec3ec 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -185,11 +185,11 @@ const api: RendererApi = { requestActivities(): Promise { return ipcRenderer.invoke(CHANNELS.activityRequest) as Promise; }, - requestLaunch(): void { - ipcRenderer.send(CHANNELS.actionLaunch); + requestLaunch(id?: string): void { + ipcRenderer.send(CHANNELS.actionLaunch, id); }, - requestUninstall(): void { - ipcRenderer.send(CHANNELS.actionUninstall); + requestUninstall(id?: string): void { + ipcRenderer.send(CHANNELS.actionUninstall, id); }, cancelJob(id: string): void { ipcRenderer.send(CHANNELS.actionCancelJob, id); diff --git a/src/renderer/app.ts b/src/renderer/app.ts index a4e05da..d200f62 100644 --- a/src/renderer/app.ts +++ b/src/renderer/app.ts @@ -25,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 { activityBusyKind, activityStatus, busyIds, gameOf, jobCountOf, phaseOf, screenActivityOf, statusOf } from './state-view.js'; +import { activityStatus, busyIds, busyKindOfView, gameOf, jobCountOf, phaseOf, screenActions, screenActivityOf, statusOf } from './state-view.js'; import { req } from './dom.js'; const app = req('app'); @@ -267,8 +267,8 @@ const toast = createToast({ const controls = createControls({ api: { - requestLaunch: () => window.api.requestLaunch(), - requestUninstall: () => window.api.requestUninstall(), + requestLaunch: (id) => window.api.requestLaunch(id), + requestUninstall: (id) => window.api.requestUninstall(id), cancelJob: (id) => window.api.cancelJob(id), requestKill: () => window.api.requestKill(), forgetGame: (id) => window.api.forgetGame(id), @@ -663,7 +663,8 @@ function render(state: AppState): void { else delete app.dataset['activity']; // Play-button busy visual: gear (system activity) vs spinner (game phases). Absent when not busy. - const busyKind = activityBusyKind(screenActivity, state); + const actions = screenActions(state, browse, screenActivity); + const busyKind = busyKindOfView(actions.playView); if (busyKind !== 'none') app.dataset['busy'] = busyKind; else delete app.dataset['busy']; @@ -678,13 +679,10 @@ function render(state: AppState): void { // (c) a LOCAL game whose executable is no longer on disk: it is active (it is in the library and keeps // its art and stats) but there is nothing to start, so it gets the same title + More layout, with the // status line saying why. - const hasPlay = - browse !== null && - browse.active && - browse.game?.unavailable !== true && - browse.game?.unconfigured !== true && - !(phase === 'ready' && browse.game?.requiresInstall === true && screenActivity === undefined); + const hasPlay = browse !== null && browse.active && actions.playView !== 'hidden'; app.dataset['cardMorph'] = hasPlay ? 'on' : 'off'; + if (actions.playView === 'play' && !actions.canPlay) app.dataset['playLocked'] = 'true'; + else delete app.dataset['playLocked']; // Only on the detail screen: in the carousel Play is hidden anyway, and the attribute's `.title{left:0}` // half would fight the carousel's own title placement. diff --git a/src/renderer/confirm.ts b/src/renderer/confirm.ts index 4e6622d..2a1e794 100644 --- a/src/renderer/confirm.ts +++ b/src/renderer/confirm.ts @@ -220,6 +220,8 @@ export interface ConfirmActionDeps { takeForgetId(): string | null; /** The collision question's "yes". */ mergeCollision(): void; + /** The game an install / uninstall question was opened for — fixed then, never re-read on Yes. */ + readonly targetId: string | null; } /** @@ -234,11 +236,11 @@ export function runConfirmedAction( switch (mode) { case 'install': audio.play('play'); - deps.api.requestLaunch(); // main decides install vs launch from requiresInstall + deps.api.requestLaunch(deps.targetId ?? undefined); // main decides install vs launch from requiresInstall break; case 'uninstall': audio.play('button'); // neutral sound for the destructive confirm - deps.api.requestUninstall(); + deps.api.requestUninstall(deps.targetId ?? undefined); break; case 'kill': audio.play('button'); // neutral sound for the destructive confirm diff --git a/src/renderer/controls-deps.ts b/src/renderer/controls-deps.ts index 989dbba..89dd58d 100644 --- a/src/renderer/controls-deps.ts +++ b/src/renderer/controls-deps.ts @@ -21,8 +21,8 @@ import type { NavSurface } from './nav-surface.js'; */ export interface ControlsApi { /** Play / the install confirm's Yes — main decides install vs launch from `requiresInstall`. */ - requestLaunch(): void; - requestUninstall(): void; + requestLaunch(id?: string): void; + requestUninstall(id?: string): void; /** "Cancel installation": stops the queued or running install of game `id`. */ cancelJob(id: string): void; requestKill(): void; diff --git a/src/renderer/controls.ts b/src/renderer/controls.ts index 5a100fe..65eb5dd 100644 --- a/src/renderer/controls.ts +++ b/src/renderer/controls.ts @@ -5,7 +5,7 @@ // hover and clicks, the wheel, right-click-as-back. The idle countdown and the mouse's sleep live in // idle.ts. It reaches back into app.ts only through the narrow `deps` seam (controls-deps.ts); app.ts // drives it via applyGameButtons/clearGameButtons/refresh/showError and starts it with start(). -import type { AppState, GameInfo } from '../shared/types.js'; +import type { AppState } from '../shared/types.js'; import type { Translator } from '../shared/i18n/index.js'; import { NAV_REPEAT_MS, createAutoRepeatChain } from './auto-repeat.js'; import { createGamepadController } from './gamepad.js'; @@ -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, opensSteamDownloads, phaseOf } from './state-view.js'; +import { gameOf, opensSteamDownloads, phaseOf, screenActions, type ScreenActions } from './state-view.js'; import { pressFlash, req } from './dom.js'; import type { GameCollision } from '../shared/types.js'; import type { QuitAction } from '../shared/quit.js'; @@ -113,26 +113,9 @@ export function createControls(deps: ControlsDeps): Controls { const state = (): AppState => deps.getState(); const t = (): Translator => deps.getTranslator(); - /** - * The GameInfo of what is on screen, or undefined when the screen shows a history game (nothing to - * install, uninstall or launch there). Everything that used to read `gameOf(state())` for a SCREEN - * decision goes through here; `state()` is still read for PHASE decisions (busy / running / killing). - */ - const screenGame = (): GameInfo | undefined => { - const browse = deps.getBrowse(); - if (browse === null) return gameOf(state()); // no browse model yet (first paint) — behave as before - return browse.active ? (browse.game ?? gameOf(state())) : undefined; - }; - - /** Whether the launch/uninstall actions apply to what is on screen: it must be a card game AND the one - * AppState is currently about (you can browse game B while game A is busy — B is not actionable). */ - const screenIsActionable = (): boolean => { - const browse = deps.getBrowse(); - if (browse === null) return true; // pre-browse behaviour - if (!browse.active) return false; - const subject = gameOf(state())?.id; - return subject === undefined || subject === browse.id; - }; + /** What can be done with the game on screen, judged by that game alone (state-view's screenActions). */ + const screenActionsNow = (): ScreenActions => + screenActions(state(), deps.getBrowse(), deps.getScreenActivity()); /** * The full-screen overlays, as a set rather than as a named one. Every mechanism that has to stand down @@ -202,9 +185,7 @@ export function createControls(deps: ControlsDeps): Controls { const popups = createPopups({ ...deps, hover, - screenGame, - screenIsActionable, - screenActivity: () => deps.getScreenActivity(), + screenActions: screenActionsNow, onFocusChanged: () => applyFocus(), }); @@ -226,22 +207,7 @@ export function createControls(deps: ControlsDeps): Controls { // The carousel has no bar to focus at all: Play is the selected card's invisible stand-in for the // morph (styles.css) and More is hidden there — the launcher-level actions are cards in the row now. 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 (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(); - if (running.kind === 'running') - return running.killing === true ? [moreButton] : [playButton, moreButton]; - // Hard busy (install / uninstall / launch / save-sync): the Play button is a non-interactive activity - // indicator (spinner/gear), so only More is focusable — it still opens Details. - if (phaseOf(state()) === 'busy') return [moreButton]; - // Empty screen, a HISTORY game, a requiresInstall installer/steam game, or a local game with nothing - // to start (files gone / not configured) → Play is hidden (app.ts `hasPlay`), so the ring skips it too. - const game = screenIsActionable() ? screenGame() : undefined; - if (game === undefined || game.requiresInstall === true) return [moreButton]; - return game.unavailable === true || game.unconfigured === true ? [moreButton] : [playButton, moreButton]; + return screenActionsNow().canPlay ? [playButton, moreButton] : [moreButton]; } // Main focus is meaningful on every DETAIL screen (the More button is always present there) with the @@ -315,25 +281,9 @@ export function createControls(deps: ControlsDeps): Controls { 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(); - const game = screenGame(); - // A local game whose files are gone: there is nothing to start, and the status line already says so. - 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(); - // 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(); - // In a hard-busy phase the Play button is just an activity indicator (spinner/gear) — no launch. - // EXCEPT `running`: the launcher was summoned over the game and Play returns to it (main branches on - // the running state and raises the game's window instead of launching). - if (phaseOf(state()) !== 'ready' && state().kind !== 'running') return audio.playLimit(); + if (!screenActionsNow().canPlay) return audio.playLimit(); audio.play('play'); - deps.api.requestLaunch(); + deps.api.requestLaunch(deps.getBrowse()?.id ?? gameOf(state())?.id); } function triggerMore(): void { diff --git a/src/renderer/details-menu.ts b/src/renderer/details-menu.ts index 0540bb9..be87e4d 100644 --- a/src/renderer/details-menu.ts +++ b/src/renderer/details-menu.ts @@ -2,22 +2,20 @@ // Install/Uninstall, Force close, Home, Customize, Remove from history — and Minimize, which Game Mode // 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 { AppState, BrowseInfo } from '../shared/types.js'; import type { Translator } from '../shared/i18n/index.js'; import type { CarouselNav } from './controls-deps.js'; import { req } from './dom.js'; -import { cancellableInstall, phaseOf } from './state-view.js'; +import type { ScreenActions } from './state-view.js'; export interface DetailsMenuDeps { getState(): AppState; getBrowse(): BrowseInfo | null; getTranslator(): Translator; readonly carousel: Pick; - screenGame(): GameInfo | undefined; - screenIsActionable(): boolean; - /** The activity of the game on screen, or undefined when it is free. */ - screenActivity(): GameActivity | undefined; + + /** What can be done with the game on screen (state-view's screenActions). */ + screenActions(): ScreenActions; /** The popup is fading out: its items must not rewrite themselves in view (see popups.ts). */ isFrozen(): boolean; } @@ -32,10 +30,7 @@ export interface DetailsMenu { } export function createDetailsMenu(deps: DetailsMenuDeps): DetailsMenu { - const state = (): AppState => deps.getState(); const t = (): Translator => deps.getTranslator(); - const screenGame = (): GameInfo | undefined => deps.screenGame(); - const screenIsActionable = (): boolean => deps.screenIsActionable(); const menuInstallToggle = req('menu-install-toggle'); const menuKill = req('menu-kill'); const menuHome = req('menu-home'); @@ -61,18 +56,15 @@ export function createDetailsMenu(deps: DetailsMenuDeps): DetailsMenu { menuInstallToggle.classList.add('is-hidden'); return; } - if (cancellableInstall(deps.screenActivity())) { + const actions = deps.screenActions(); + if (actions.canCancel) { menuInstallToggle.classList.remove('is-hidden'); menuInstallToggle.textContent = t()('launcher.menu.cancelInstall'); menuInstallToggle.dataset['action'] = 'cancel'; return; } - 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' || deps.screenActivity() !== undefined; - const showInstall = !busy && game?.requiresInstall === true; - const showUninstall = !busy && game?.canUninstall === true; + const showInstall = actions.canInstall; + const showUninstall = actions.canUninstall; const show = showInstall || showUninstall; menuInstallToggle.classList.toggle('is-hidden', !show); if (show) { @@ -92,9 +84,7 @@ export function createDetailsMenu(deps: DetailsMenuDeps): DetailsMenu { if (deps.isFrozen()) return; // 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 && deps.getBrowse()?.id === s.game.id; + const running = onGameScreen() && deps.screenActions().canForceClose; menuKill.classList.toggle('is-hidden', !running); if (running) menuKill.textContent = t()('launcher.menu.forceClose'); } diff --git a/src/renderer/popups.ts b/src/renderer/popups.ts index 633f7db..b6df962 100644 --- a/src/renderer/popups.ts +++ b/src/renderer/popups.ts @@ -11,9 +11,8 @@ import type { AppState, BrowseInfo, GameCollision, - GameInfo, } from '../shared/types.js'; -import type { GameActivity } from '../shared/activity.js'; +import type { ScreenActions } from './state-view.js'; import { quitQuestion, type QuitAction } from '../shared/quit.js'; import { createDeferredQuestions } from './deferred-questions.js'; import type { Locale, MessageKey, Translator } from '../shared/i18n/index.js'; @@ -70,14 +69,8 @@ export interface PopupsDeps { readonly library: LibraryNav; /** The shared hover guard: every view change lays a new stack under the pointer (see setView). */ readonly hover: Pick; - /** - * The GameInfo of what is on screen, and whether the launch/uninstall actions apply to it — the two - * screen decisions controls.ts derives from the state and the browse model (see there). - */ - screenGame(): GameInfo | undefined; - screenIsActionable(): boolean; - /** The activity of the game on screen, or undefined when it is free. */ - screenActivity(): GameActivity | undefined; + /** What can be done with the game on screen (state-view's screenActions, derived in controls.ts). */ + screenActions(): ScreenActions; /** How many background installs / uninstalls are queued or running. */ getJobCount(): number; /** Opens a game's detail screen (a notification about a game leads there). Owned by app.ts. */ @@ -123,8 +116,6 @@ export interface Popups extends NavSurface { export function createPopups(deps: PopupsDeps): Popups { const { audio } = deps; const t = (): Translator => deps.getTranslator(); - const screenGame = (): GameInfo | undefined => deps.screenGame(); - const screenIsActionable = (): boolean => deps.screenIsActionable(); // SteamOS Game Mode (gamescope): no tray, so the power menu's primary item quits instead of minimizing. // Seeded once at startup (setGameMode); false until then — the power menu isn't reachable that early. let gameMode = false; @@ -133,9 +124,7 @@ export function createPopups(deps: PopupsDeps): Popups { getBrowse: () => deps.getBrowse(), getTranslator: () => deps.getTranslator(), carousel: deps.carousel, - screenGame, - screenIsActionable, - screenActivity: () => deps.screenActivity(), + screenActions: () => deps.screenActions(), isFrozen: () => menuFrozen(), }); @@ -503,7 +492,7 @@ export function createPopups(deps: PopupsDeps): Popups { const copy = describeConfirm( mode, { - game: screenIsActionable() ? screenGame() : undefined, + game: deps.screenActions().game, browse: deps.getBrowse(), collision: askedCollision, deletesLocalGame: () => deps.gameSettings.deletesLocalGame(), @@ -884,6 +873,7 @@ export function createPopups(deps: PopupsDeps): Popups { return id; }, mergeCollision: () => answerCollision('merge'), + targetId: confirmBrowseId, }); } @@ -976,8 +966,7 @@ export function createPopups(deps: PopupsDeps): Popups { if ( popupView === 'confirm' && (confirmMode === 'install' || confirmMode === 'uninstall') && - (screenGame() === undefined || - deps.screenActivity() !== undefined || + (!(confirmMode === 'install' ? deps.screenActions().canInstall : deps.screenActions().canUninstall) || (deps.getBrowse()?.id ?? null) !== confirmBrowseId) ) { closePopup(); diff --git a/src/renderer/state-view.ts b/src/renderer/state-view.ts index c73ff69..ecbb149 100644 --- a/src/renderer/state-view.ts +++ b/src/renderer/state-view.ts @@ -89,7 +89,8 @@ export function busyKindOf(state: AppState): BusyKind { export function activityStatus(activity: GameActivity, t: Translator): string { switch (activity.kind) { case 'queued': - return t('launcher.state.queued'); + if (activity.reason !== 'session') return t('launcher.state.queued'); + return t(activity.removal === true ? 'launcher.state.queuedRemovalUntilGameExit' : 'launcher.state.queuedUntilGameExit'); case 'installing': return t('launcher.state.installing'); case 'configuringProton': @@ -108,9 +109,70 @@ export function activityStatus(activity: GameActivity, t: Translator): string { } } -/** 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'; +/** What the Play button looks like for the game on screen. */ +export type PlayView = 'play' | 'resume' | 'gear' | 'spinner' | 'hidden'; + +/** Everything the bar and the Details menu may offer for the game on screen, derived from that game alone. */ +export interface ScreenActions { + /** The game on screen when it can be acted on (not a history game), else undefined. */ + readonly game: GameInfo | undefined; + readonly canPlay: boolean; + readonly canInstall: boolean; + readonly canUninstall: boolean; + readonly canCancel: boolean; + readonly canForceClose: boolean; + readonly playView: PlayView; +} + +const NO_ACTIONS: ScreenActions = { + game: undefined, + canPlay: false, + canInstall: false, + canUninstall: false, + canCancel: false, + canForceClose: false, + playView: 'hidden', +}; + +/** + * What can be done with the game on screen. Its own activity wins (the gear; Play opens Steam's downloads + * for a download). The session's own game shows the session (return to it, or its busy visual). Any other + * game is judged by itself: while a session runs it may still be installed or removed, but Play only shows, + * it never starts a second game. + */ +export function screenActions( + state: AppState, + browse: BrowseInfo | null, + activity: GameActivity | undefined, +): ScreenActions { + const sessionGame = gameOf(state); + const busy = phaseOf(state) === 'busy'; + const game = + browse === null + ? sessionGame + : browse.active + ? (browse.game ?? (sessionGame?.id === browse.id ? sessionGame : undefined)) + : undefined; + if (game === undefined) return NO_ACTIONS; + if (activity !== undefined) { + return { ...NO_ACTIONS, game, canPlay: opensSteamDownloads(activity), canCancel: cancellableInstall(activity), playView: 'gear' }; + } + if (busy && sessionGame?.id === game.id) { + const kind = busyKindOf(state); + const running = state.kind === 'running' && state.killing !== true; + const playView = kind === 'running' ? 'resume' : kind === 'system' ? 'gear' : 'spinner'; + return { ...NO_ACTIONS, game, canPlay: running, canForceClose: running, playView }; + } + if (game.unavailable === true || game.unconfigured === true) return { ...NO_ACTIONS, game }; + if (game.requiresInstall) return { ...NO_ACTIONS, game, canInstall: true }; + return { ...NO_ACTIONS, game, canPlay: !busy, canUninstall: game.canUninstall, playView: 'play' }; +} + +/** The #app[data-busy] value a Play view stands for. */ +export function busyKindOfView(view: PlayView): BusyKind { + if (view === 'gear') return 'system'; + if (view === 'spinner') return 'game'; + return view === 'resume' ? 'running' : 'none'; } /** The activity of the game on screen, or undefined when it is free or nothing is on screen. */ @@ -131,7 +193,8 @@ export function busyIds(state: AppState, activities: ActivityMap): ReadonlySet = { 'launcher.state.installingPaused': 'Установка приостановлена...', 'launcher.state.installingPausedPercent': 'Установка приостановлена на {percent}%...', 'launcher.state.queued': 'Ожидает установки...', + 'launcher.state.queuedUntilGameExit': 'Установится после выхода из игры', + 'launcher.state.queuedRemovalUntilGameExit': 'Удалится после выхода из игры', 'launcher.state.updating': 'Обновление...', 'launcher.state.updatingPaused': 'Обновление приостановлено...', 'launcher.state.preloaded': 'Предзагрузка завершена', diff --git a/src/shared/types.ts b/src/shared/types.ts index 4238444..d13bcfb 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1188,8 +1188,8 @@ export interface RendererApi { /** Every game's background activity, pushed in full on each change. */ onActivityUpdate(callback: (activities: ActivityMap) => void): void; requestActivities(): Promise; - requestLaunch(): void; - requestUninstall(): void; + requestLaunch(id?: string): void; + requestUninstall(id?: string): void; /** Cancel the queued or running install of game `id`. */ cancelJob(id: string): void; requestHide(): void; diff --git a/test/action-target.test.ts b/test/action-target.test.ts new file mode 100644 index 0000000..596d18f --- /dev/null +++ b/test/action-target.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { resolveActionTarget, type ActionTargetInput } from '../src/main/action-target'; + +function input(patch: Partial = {}): ActionTargetInput { + return { + action: 'launch', + id: 'd', + selectedId: 'd', + sessionBusy: false, + sessionGameId: null, + hasActivity: false, + sourceAvailable: true, + reloadInFlight: false, + ...patch, + }; +} + +describe('resolveActionTarget', () => { + it('session free, the selected game: launch or install as before, uninstall directly', () => { + expect(resolveActionTarget(input())).toBe('launch-or-install-selected'); + expect(resolveActionTarget(input({ action: 'uninstall' }))).toBe('uninstall-only'); + }); + + it('session free, another game: select it first, then act', () => { + expect(resolveActionTarget(input({ selectedId: 'c' }))).toBe('select-then-act'); + expect(resolveActionTarget(input({ action: 'uninstall', selectedId: 'c' }))).toBe( + 'select-then-act', + ); + expect(resolveActionTarget(input({ selectedId: null }))).toBe('select-then-act'); + }); + + it("session busy, the session's own game: Play resumes, Uninstall is refused", () => { + const busy = { sessionBusy: true, sessionGameId: 'd' }; + expect(resolveActionTarget(input(busy))).toBe('resume-session'); + expect(resolveActionTarget(input({ ...busy, action: 'uninstall' }))).toBe('refuse'); + }); + + it('session busy, another game: install or remove only, never a second launch', () => { + const busy = { sessionBusy: true, sessionGameId: 'c', selectedId: 'c' }; + expect(resolveActionTarget(input(busy))).toBe('install-only'); + expect(resolveActionTarget(input({ ...busy, action: 'uninstall' }))).toBe('uninstall-only'); + }); + + it('refuses a busy target, a game whose card is gone, and anything during a reload — in any session', () => { + for (const session of [{}, { sessionBusy: true, sessionGameId: 'c' }]) { + expect(resolveActionTarget(input({ ...session, hasActivity: true }))).toBe('refuse'); + expect(resolveActionTarget(input({ ...session, sourceAvailable: false }))).toBe('refuse'); + expect(resolveActionTarget(input({ ...session, reloadInFlight: true }))).toBe('refuse'); + expect( + resolveActionTarget(input({ ...session, action: 'uninstall', hasActivity: true })), + ).toBe('refuse'); + } + }); +}); diff --git a/test/file-size-ratchet.test.ts b/test/file-size-ratchet.test.ts index 8de87dc..f1aacb2 100644 --- a/test/file-size-ratchet.test.ts +++ b/test/file-size-ratchet.test.ts @@ -25,7 +25,7 @@ const SLACK = 50; /** Baseline `wc -l` per file over the free limit at the time of writing (playhook v0.8.1). */ const FILE_BASELINE: Readonly> = { - 'src/main/game-controller.ts': 1468, + 'src/main/game-controller.ts': 1397, 'src/main/manifest.ts': 1125, 'src/renderer/game-settings-screen.ts': 2047, 'src/renderer/online-picker.ts': 1322, @@ -46,7 +46,7 @@ interface FactoryBaseline { */ const FACTORY_BASELINE: readonly FactoryBaseline[] = [ { file: 'src/renderer/game-settings-screen.ts', name: 'createGameSettingsScreen', lines: 1795 }, - { file: 'src/renderer/controls.ts', name: 'createControls', lines: 672 }, + { file: 'src/renderer/controls.ts', name: 'createControls', lines: 611 }, { file: 'src/renderer/online-picker.ts', name: 'createOnlinePicker', lines: 1174 }, { file: 'src/renderer/settings-screen.ts', name: 'createSettingsScreen', lines: 848 }, ]; diff --git a/test/game-controller.test.ts b/test/game-controller.test.ts index 833ab1d..c24be0b 100644 --- a/test/game-controller.test.ts +++ b/test/game-controller.test.ts @@ -125,7 +125,7 @@ interface Harness { readonly setSteamState: (state: SteamAcfState) => Promise; } -type SteamAcfState = 'installed' | 'downloading'; +type SteamAcfState = 'installed' | 'downloading' | 'absent'; type Mode = 'normal' | 'install' | 'copy' | 'prefix-cleanup' | 'steam'; @@ -156,6 +156,10 @@ const STEAM_APPID = 480; /** 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 { + if (state === 'absent') { + await fs.rm(path.join(root, 'steamapps', `appmanifest_${appid}.acf`), { force: true }); + return; + } const flags = state === 'installed' ? 4 : 1026; await fs.writeFile( path.join(root, 'steamapps', `appmanifest_${appid}.acf`), @@ -932,7 +936,9 @@ describe('GameController sequences', () => { }); const built = h; const release = built.holdCopies(); - await selectThenLaunch(built, 'g1'); + const cardGame = built.controller.findManifest('g1'); + if (cardGame === null) throw new Error('the card game is missing'); + built.controller.jobs.startInstall(cardGame); await reached(built.journal, 'activity:g1:installing'); await selectThenLaunch(built, 'other'); await reached(built.journal, 'state:running'); @@ -1166,6 +1172,104 @@ describe('GameController sequences', () => { }); }); + describe('actions by id', () => { + const pause = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + + /** Launches the plain 'other' game and waits until it runs: the session the actions happen beside. */ + async function runOther(built: Harness): Promise { + built.journal.length = 0; + fire(IPC.actionSelect, 'other'); + await reached(built.journal, 'state:ready'); + await pause(30); + fire(IPC.actionLaunch, 'other'); + await reached(built.journal, 'state:running'); + built.journal.length = 0; + } + + const OTHER = [{ id: 'other', lastPlayedAt: '2026-10-01T00:00:00.000Z' }] as const; + + it('while another game runs, Install for an uninstalled game starts a background job and leaves the session alone', async () => { + h = await harness({ mode: 'install', extraGames: OTHER }); + const built = h; + await runOther(built); + fire(IPC.actionLaunch, 'g1'); + await reached(built.journal, 'activity:g1:installing'); + await pause(30); + expect(built.journal.filter((entry) => entry.startsWith('state:'))).toEqual([]); + expect(built.state.get()).toMatchObject({ kind: 'running', game: { id: 'other' } }); + }); + + it('two quick presses for the same game make one job', async () => { + h = await harness({ mode: 'install', extraGames: OTHER }); + const built = h; + await runOther(built); + fire(IPC.actionLaunch, 'g1'); + fire(IPC.actionLaunch, 'g1'); + await reached(built.journal, 'activity:g1:installing'); + await pause(50); + expect(built.journal.filter((entry) => entry === 'activity:g1:installing')).toHaveLength(1); + }); + + it('while another game runs, Play for an installed game does nothing at all', async () => { + h = await harness({ mode: 'normal', extraGames: OTHER }); + const built = h; + await runOther(built); + fire(IPC.actionLaunch, 'g1'); + await pause(50); + expect(built.journal).toEqual([]); + expect(built.state.get()).toMatchObject({ kind: 'running', game: { id: 'other' } }); + }); + + it("while another game runs, Uninstall works for that game and is refused for the session's own", async () => { + h = await harness({ mode: 'install', extraGames: OTHER }); + const built = h; + await fs.mkdir(path.join(built.tmp, 'installed'), { recursive: true }); + await fs.writeFile(path.join(built.tmp, 'installed', 'game.exe'), ''); + await built.controller.reloadPcLibrary(); + await runOther(built); + fire(IPC.actionUninstall, 'other'); + fire(IPC.actionUninstall, 'g1'); + await reached(built.journal, 'notify:game-uninstalled'); + expect(built.journal.some((entry) => entry.startsWith('activity:other'))).toBe(false); + expect(built.state.get()).toMatchObject({ kind: 'running', game: { id: 'other' } }); + }); + + it('a game whose source is gone is refused', async () => { + h = await harness({ mode: 'copy', copySource: 'card', extraGames: OTHER }); + const built = h; + await runOther(built); + fire(IPC.actionLaunch, 'g1'); + await pause(50); + expect(built.journal).toEqual([]); + }); + + it("while another game runs, a Steam game's Install and Uninstall go to Steam and leave the state alone", async () => { + h = await harness({ mode: 'steam', steamState: 'absent', extraGames: OTHER }); + const built = h; + await runOther(built); + fire(IPC.actionLaunch, 'g1'); + await waitFor(() => shell.opened.length > 0, 'the steam://install URI'); + expect(shell.opened).toEqual([`steam://install/${STEAM_APPID}`]); + await built.setSteamState('installed'); + await waitFor(() => built.journal.includes('activity:g1:clear') || !built.activities.has('g1'), 'Steam to settle'); + fire(IPC.actionUninstall, 'g1'); + await waitFor(() => shell.opened.length > 1, 'the steam://uninstall URI'); + expect(shell.opened[1]).toBe(`steam://uninstall/${STEAM_APPID}`); + await waitFor(() => built.activities.get('g1')?.kind === 'steam-uninstalling', 'the uninstalling activity'); + expect(built.journal.filter((entry) => entry.startsWith('state:'))).toEqual([]); + expect(built.state.get()).toMatchObject({ kind: 'running', game: { id: 'other' } }); + }); + + it('with the session free, an action for a game that is not selected selects it first', async () => { + h = await harness({ mode: 'install', extraGames: OTHER }); + const built = h; + expect(built.state.get()).toMatchObject({ kind: 'ready', game: { id: 'other' } }); + fire(IPC.actionLaunch, 'g1'); + await reached(built.journal, 'activity:g1:installing'); + expect(built.state.get()).toMatchObject({ kind: 'ready', game: { id: 'g1' } }); + }); + }); + describe('prefix cleanup', () => { it('success: a background job, Uninstall gone afterwards, no notification and no window', async () => { h = await harness({ mode: 'prefix-cleanup' }); diff --git a/test/game-jobs.test.ts b/test/game-jobs.test.ts index 58b51c4..3e12137 100644 --- a/test/game-jobs.test.ts +++ b/test/game-jobs.test.ts @@ -64,6 +64,8 @@ interface Fixture { readonly writesExe: Set; /** Games whose prefix still needs winetricks. */ readonly provisions: Set; + /** Games that come with an uninstaller of their own. */ + readonly uninstallers: Set; focused: boolean; session: string | null; interactive: boolean; @@ -84,6 +86,7 @@ function build(options: BuildOptions = {}): Fixture { const installers = new Map(); const writesExe = new Set(); const provisions = new Set(); + const uninstallers = new Set(); const flags = { focused: false, session: null as string | null, interactive: false }; const host: GameJobsHost = { sessionGameId: () => flags.session, @@ -124,7 +127,12 @@ function build(options: BuildOptions = {}): Fixture { procIds.set(proc, id); return proc; }, - resolveUninstaller: () => Promise.resolve(null), + resolveUninstaller: (install) => + Promise.resolve( + uninstallers.has(path.basename(install.dir)) + ? { file: path.join(install.dir, 'unins000.exe'), args: [], cwd: install.dir, runAsAdmin: false } + : null, + ), launchUninstaller: () => Promise.resolve(procOf('uninstaller')), uninstallDir: (install) => install.dir, prefixCleanupDir: (id) => Promise.resolve(path.join(tmp, 'prefixes', id)), @@ -164,6 +172,7 @@ function build(options: BuildOptions = {}): Fixture { installers, writesExe, provisions, + uninstallers, get focused() { return flags.focused; }, @@ -502,7 +511,7 @@ describe('GameJobs installer runs', () => { fixture.state.set({ kind: 'running', game: SESSION_GAME, since: 0 }); fixture.jobs.startInstall(a); await new Promise((resolve) => setTimeout(resolve, 30)); - expect(fixture.registry.get('a')).toEqual({ kind: 'queued' }); + expect(fixture.registry.get('a')).toEqual({ kind: 'queued', reason: 'session' }); expect(fixture.journal).not.toContain('installer:a'); fixture.state.set({ kind: 'syncing-out', game: SESSION_GAME }); await new Promise((resolve) => setTimeout(resolve, 30)); @@ -565,6 +574,22 @@ describe('GameJobs under gamescope', () => { fixture.installers.get('a')?.open(); }); + it('holds a removal that runs its own uninstaller, and lets a plain sweep through', async () => { + const a = await installerGame('a'); + const b = await installerGame('b'); + fixture.uninstallers.add('a'); + fixture.jobs.startUninstall(a); + fixture.jobs.startUninstall(b); + await waitFor(() => !fixture.jobs.has('b'), 'the plain sweep to finish'); + await waitFor( + () => fixture.registry.get('a')?.kind === 'queued', + 'the uninstaller to be held for the session', + ); + expect(fixture.registry.get('a')).toEqual({ kind: 'queued', reason: 'session', removal: true }); + fixture.state.set({ kind: 'ready', game: SESSION_GAME }); + await waitFor(() => !fixture.jobs.has('a'), 'the held removal once the game is over'); + }); + it('holds a copy whose prefix still needs winetricks, and lets one that does not through', async () => { const a = await copyGame('a'); const b = await copyGame('b'); @@ -588,11 +613,16 @@ describe('startVerdict', () => { ...patch, }); - it('removals always start', () => { - expect(startVerdict('uninstall', {}, ctx({ session: 'running', gamescope: true }))).toBe( - 'start', - ); - expect(startVerdict('prefix-cleanup', {}, ctx({ runningInLane: 5 }))).toBe('start'); + it('a removal starts unless its own uninstaller would interrupt the game', () => { + const gamescope = ctx({ session: 'running', gamescope: true }); + expect(startVerdict('prefix-cleanup', {}, gamescope)).toBe('start'); + expect(startVerdict('uninstall', {}, ctx({ runningInLane: 5 }))).toBe('start'); + expect(startVerdict('uninstall', {}, ctx({ session: 'running' }))).toBe('start'); + expect(startVerdict('uninstall', {}, gamescope)).toBe('learn'); + expect(startVerdict('uninstall', { uninstaller: false }, gamescope)).toBe('start'); + expect(startVerdict('uninstall', { uninstaller: true }, gamescope)).toBe('wait-session'); + const elevated = ctx({ session: 'active', elevated: true }); + expect(startVerdict('uninstall', { uninstaller: true }, elevated)).toBe('wait-session'); }); it('a full lane waits', () => { @@ -604,21 +634,21 @@ describe('startVerdict', () => { expect(startVerdict('installer', {}, ctx())).toBe('start'); expect(startVerdict('installer', {}, ctx({ session: 'active' }))).toBe('learn'); expect(startVerdict('installer', { interactive: true }, ctx({ session: 'active' }))).toBe( - 'wait', + 'wait-session', ); expect(startVerdict('installer', { interactive: false }, ctx({ session: 'running' }))).toBe( 'start', ); expect( startVerdict('installer', { interactive: false }, ctx({ session: 'active', elevated: true })), - ).toBe('wait'); + ).toBe('wait-session'); }); it('under gamescope a running game holds every installer and a copy that provisions', () => { const gamescope = ctx({ session: 'running', gamescope: true }); - expect(startVerdict('installer', { interactive: false }, gamescope)).toBe('wait'); + expect(startVerdict('installer', { interactive: false }, gamescope)).toBe('wait-session'); expect(startVerdict('copy', {}, gamescope)).toBe('learn'); - expect(startVerdict('copy', { provisions: true }, gamescope)).toBe('wait'); + expect(startVerdict('copy', { provisions: true }, gamescope)).toBe('wait-session'); expect(startVerdict('copy', { provisions: false }, gamescope)).toBe('start'); expect( startVerdict( diff --git a/test/renderer/controls.test.ts b/test/renderer/controls.test.ts index 745dff7..5dbb917 100644 --- a/test/renderer/controls.test.ts +++ b/test/renderer/controls.test.ts @@ -522,3 +522,62 @@ describe('controls quit with background jobs', () => { expect(message()).toBe('Quit Playhook?'); }); }); + +describe('controls while another game runs', () => { + const SESSION: GameInfo = { ...LOCAL_GAME, id: 'session', title: 'Session' }; + const UNINSTALLED: GameInfo = { ...LOCAL_GAME, id: 'later', title: 'Later', requiresInstall: true, installVia: 'copy' }; + const focusedMain = (): string[] => + ['play-button', 'more-button'].filter((id) => req(id).classList.contains('is-focused')); + + beforeEach(() => { + harness.state = { kind: 'running', game: SESSION, since: 0 }; + }); + + it("an installed game's Play is out of the ring and refuses — it never starts a second game", () => { + harness.screen = 'detail'; + harness.browse = browsing(LOCAL_GAME); + controls.refresh(); + press('ArrowRight'); + + expect(focusedMain()).toEqual(['more-button']); + + req('play-button').click(); + + expect(api.requestLaunch).not.toHaveBeenCalled(); + expect(audio.limits()).toBe(1); + }); + + it("the session game's Play returns to it, by its id", () => { + harness.screen = 'detail'; + harness.browse = browsing(SESSION); + controls.refresh(); + req('play-button').click(); + + expect(api.requestLaunch).toHaveBeenCalledWith('session'); + }); + + it("an uninstalled game's Install question sends its own id, fixed when the question opened", () => { + harness.screen = 'detail'; + harness.browse = browsing(UNINSTALLED); + req('more-button').click(); + req('menu-install-toggle').click(); + + expect(popup().dataset['mode']).toBe('install'); + + req('confirm-yes').click(); + + expect(api.requestLaunch).toHaveBeenCalledWith('later'); + }); + + it('closes the open question when main moves the screen onto another game, so its Yes cannot hit that one', () => { + harness.screen = 'detail'; + harness.browse = browsing(UNINSTALLED); + req('more-button').click(); + req('menu-install-toggle').click(); + harness.browse = browsing(SESSION); + controls.refresh(); + + expect(popup().classList.contains('is-open')).toBe(false); + expect(api.requestLaunch).not.toHaveBeenCalled(); + }); +}); diff --git a/test/state-view.test.ts b/test/state-view.test.ts index 0c80710..3487f10 100644 --- a/test/state-view.test.ts +++ b/test/state-view.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest'; import { - activityBusyKind, activityStatus, busyIds, opensSteamDownloads, sameIds, + screenActions, screenActivityOf, statusOf, } from '../src/renderer/state-view'; @@ -67,22 +67,6 @@ describe('activityStatus', () => { }); }); -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; @@ -126,3 +110,90 @@ describe('sameIds', () => { expect(sameIds(new Set(['a']), new Set(['b']))).toBe(false); }); }); + +describe('screenActions', () => { + const OTHER: GameInfo = { ...GAME, id: 'other', title: 'Other' }; + const UNINSTALLED: GameInfo = { ...GAME, requiresInstall: true }; + const browsing = (game: GameInfo): BrowseInfo => ({ ...BROWSE, id: game.id, title: game.title, game }); + const running = { kind: 'running', game: OTHER, since: 0 } as const; + + it('an installed free game: Play launches, Uninstall as offered', () => { + const actions = screenActions({ kind: 'ready', game: GAME }, browsing({ ...GAME, canUninstall: true }), undefined); + expect(actions).toMatchObject({ canPlay: true, canUninstall: true, canInstall: false, playView: 'play' }); + }); + + it('an uninstalled game: Play hidden, Install offered — also while another game runs', () => { + expect(screenActions({ kind: 'ready', game: UNINSTALLED }, browsing(UNINSTALLED), undefined)).toMatchObject({ + canInstall: true, + canPlay: false, + playView: 'hidden', + }); + expect(screenActions(running, browsing(UNINSTALLED), undefined)).toMatchObject({ + canInstall: true, + playView: 'hidden', + canForceClose: false, + }); + }); + + it('an installed game while another one runs: Play shows but never starts it, Uninstall still offered', () => { + expect(screenActions(running, browsing({ ...GAME, canUninstall: true }), undefined)).toMatchObject({ + canPlay: false, + canUninstall: true, + playView: 'play', + canForceClose: false, + }); + }); + + it("the session's own game: return to it while running, Force close there only", () => { + expect(screenActions(running, browsing(OTHER), undefined)).toMatchObject({ + canPlay: true, + canForceClose: true, + canInstall: false, + canUninstall: false, + playView: 'resume', + }); + expect(screenActions({ ...running, killing: true }, browsing(OTHER), undefined)).toMatchObject({ + canPlay: false, + canForceClose: false, + playView: 'spinner', + }); + expect(screenActions({ kind: 'launching', game: OTHER }, browsing(OTHER), undefined).playView).toBe('spinner'); + }); + + it("the game's own activity wins: the gear, Cancel for an install, Play only for Steam's downloads", () => { + expect(screenActions(running, browsing(GAME), { kind: 'installing' })).toMatchObject({ + playView: 'gear', + canCancel: true, + canPlay: false, + }); + expect(screenActions(running, browsing(GAME), { kind: 'queued', reason: 'session', removal: true })).toMatchObject({ + canCancel: false, + }); + expect(screenActions({ kind: 'ready', game: GAME }, browsing(GAME), { kind: 'steam-installing', paused: false })).toMatchObject({ + canPlay: true, + playView: 'gear', + }); + }); + + it('a history game or nothing on screen offers nothing', () => { + expect(screenActions({ kind: 'ready', game: GAME }, { ...BROWSE, active: false }, undefined)).toMatchObject({ + game: undefined, + playView: 'hidden', + }); + expect(screenActions({ kind: 'idle' }, null, undefined).game).toBeUndefined(); + }); + + it('never borrows the session game for another game whose info has not arrived', () => { + expect(screenActions(running, { ...BROWSE, game: undefined }, undefined).game).toBeUndefined(); + }); +}); + +describe('queued statuses', () => { + it('names what a queued job waits for', () => { + expect(activityStatus({ kind: 'queued' }, t)).toBe('Waiting to install...'); + expect(activityStatus({ kind: 'queued', reason: 'session' }, t)).toBe('Will install after you quit the game'); + expect(activityStatus({ kind: 'queued', reason: 'session', removal: true }, t)).toBe( + 'Will uninstall after you quit the game', + ); + }); +}); diff --git a/test/steam-activity-watch.test.ts b/test/steam-activity-watch.test.ts index b62e771..28bb0f5 100644 --- a/test/steam-activity-watch.test.ts +++ b/test/steam-activity-watch.test.ts @@ -246,6 +246,20 @@ describe('SteamActivityWatch pre-loads', () => { }); }); +describe('SteamActivityWatch and a half-written .acf', () => { + it('an .acf caught mid-rewrite changes nothing, so the finished download still notifies', async () => { + await fixture.acf(A.appid, 'downloading'); + await fixture.watch.scanNow(); + await fs.writeFile(path.join(root, 'steamapps', `appmanifest_${A.appid}.acf`), '"AppState"\n{\n'); + await fixture.watch.scanNow(); + expect(fixture.registry.get('a')).toEqual({ kind: 'steam-installing', paused: false }); + expect(fixture.events).toEqual(['changed:a']); + await fixture.acf(A.appid, 'installed'); + await fixture.watch.scanNow(); + 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 04d0ee5f9da7fcf8ff1e27054f34a349bb7146b4 Mon Sep 17 00:00:00 2001 From: Alexander Date: Mon, 5 Oct 2026 18:05:25 +0500 Subject: [PATCH 07/10] AppState is the session only: drop installing and uninstalling --- src/main/game-controller.ts | 6 ------ src/main/game-sequences.ts | 25 ++++++++++++------------- src/renderer/state-view.ts | 8 -------- src/shared/types.ts | 22 ++++++++++++---------- 4 files changed, 24 insertions(+), 37 deletions(-) diff --git a/src/main/game-controller.ts b/src/main/game-controller.ts index dd8e141..bdd7dde 100644 --- a/src/main/game-controller.ts +++ b/src/main/game-controller.ts @@ -972,15 +972,9 @@ export class GameController { if ( kind === 'running' || kind === 'launching' || - kind === 'installing' || - kind === 'uninstalling' || kind === 'syncing-in' || kind === 'syncing-out' ) { - // During install, removal is also expected: the installer reads from the card, so yanking - // it makes the install fail → won't appear → we stay on "Install"; next attempt pre-cleans. - // During uninstall it targets the PC, so it completes; runUninstallSequence then sees cardPresent - // = false and goes idle + hide on its own. return; } this.clearCard(); diff --git a/src/main/game-sequences.ts b/src/main/game-sequences.ts index 1f3290a..84f7291 100644 --- a/src/main/game-sequences.ts +++ b/src/main/game-sequences.ts @@ -1,11 +1,11 @@ -// The game's process lifecycle: the launch / install / uninstall / prefix-cleanup sequences, the Steam -// pair (fire-and-forget), the force-close, and the save sync around a session. Split out of +// The game's process lifecycle: the launch sequence, the Steam pair (fire-and-forget), the force-close, +// and the save sync around a session. Installs and removals are background jobs (game-jobs.ts). Split out of // GameController, which keeps the card session (insert / remove / collision) and DISPATCHES here from // the renderer's actions; what the sequences need back from it (GameInfo, the ready transition, "is this // game's source still there") comes through the SequenceHost seam. The save sync a session is bracketed // with lives in save-sync-flow.ts. // -// The four blocking sequences share one scaffold, `runSequence`: launchInFlight + an AbortController for +// The blocking launch sequence runs inside one scaffold, `runSequence`: launchInFlight + an AbortController for // the span, the entering state, the LaunchAbortedError guard (a card swap or shutdown unwinds silently), // `failSequence` for anything else, and a finally that releases the owned process, clears the flags and // replays a card that was swapped in mid-flight. Launch alone adds to the finally (the lock and the @@ -78,7 +78,7 @@ export type SequenceDeps = Pick< readonly host: SequenceHost; }; -type SequencePhase = 'launch' | 'install' | 'uninstall'; +type SequencePhase = 'launch'; /** The process a sequence spawned, if any — held by reference so the shared finally can dispose it. */ interface OwnedProcess { @@ -174,9 +174,9 @@ export class GameSequences { /** * Linux prefix provisioning (winetricks) started/finished — the launcher's onProvisioning callback - * On start: stash the current installing/launching state and show the rotating "Configuring - * Proton" screen. On finish: stop the rotation and restore the stashed state (the launch/install - * sequence then continues from where it was). No-op on win32 (the launcher never fires this). + * On start: stash the current launching state and show the rotating "Configuring Proton" screen. On + * finish: stop the rotation and restore the stashed state (the launch sequence then continues from where + * it was). No-op on win32 (the launcher never fires this). */ private setProvisioning(active: boolean, game: GameInfo): void { if (active) { @@ -411,7 +411,7 @@ export class GameSequences { */ private async runSequence( phase: SequencePhase, - entering: 'syncing-in' | 'installing' | 'uninstalling', + entering: 'syncing-in', info: GameInfo, body: (abort: AbortController, owned: OwnedProcess) => Promise, onFinally?: () => void, @@ -664,13 +664,12 @@ export class GameSequences { } /** - * A launch/install/uninstall attempt failed: return to the 'ready' screen with the SAME info and - * surface the reason in the error popup. The info is unchanged, so the flags recompute to the pre-attempt - * button (launch → "Play", failed install → still "Install", failed uninstall → still "Uninstall"); the - * user can read the error, close it (B / veil) and retry. Only the log prefix differs per phase. + * A launch attempt failed: return to the 'ready' screen with the SAME info and surface the reason in the + * error popup. The info is unchanged, so the button stays "Play"; the user can read the error, close it + * (B / veil) and retry. */ private failSequence( - phase: 'launch' | 'install' | 'uninstall', + phase: SequencePhase, game: GameInfo, message: string, ): void { diff --git a/src/renderer/state-view.ts b/src/renderer/state-view.ts index ecbb149..adc663a 100644 --- a/src/renderer/state-view.ts +++ b/src/renderer/state-view.ts @@ -15,8 +15,6 @@ export function phaseOf(state: AppState): Phase { return 'ready'; case 'error': return 'error'; - case 'installing': - case 'uninstalling': case 'configuringProton': case 'syncing-in': case 'launching': @@ -30,10 +28,6 @@ export function statusOf(state: AppState, t: Translator): string { // Plain "..." instead of the "…" glyph: in M PLUS Rounded 1c (a CJK font) the ellipsis // glyph is centered vertically (Japanese convention), which looks misaligned in a Latin UI. switch (state.kind) { - case 'installing': - return t('launcher.state.installing'); - case 'uninstalling': - return t('launcher.state.uninstalling'); case 'configuringProton': // Base label; the renderer appends a rotating funny suffix after a minute. return t('launcher.protonConfig1'); @@ -67,8 +61,6 @@ export type BusyKind = 'none' | 'system' | 'game' | 'running'; export function busyKindOf(state: AppState): BusyKind { switch (state.kind) { - case 'installing': - case 'uninstalling': case 'configuringProton': return 'system'; case 'syncing-in': diff --git a/src/shared/types.ts b/src/shared/types.ts index d13bcfb..11c1959 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -133,10 +133,10 @@ export interface GameLibrary { * What is currently ON SCREEN — the source of truth for the title, the stats, the background and the * music, whether that game is on the inserted card or only in the history. * - * It exists BECAUSE AppState cannot answer that question: AppState is the state machine of ONE game's - * process (idle/ready/installing/running…), so browsing game B while game A installs, or showing a - * history game with no card in (`kind: 'idle'`), has no representation there. AppState stays the truth - * for the PHASE and the STATUS text; BrowseInfo is the truth for what you are looking at. + * It exists BECAUSE AppState cannot answer that question: AppState is the state machine of the ONE game + * session (idle/ready/launching/running…), so browsing game B while game A runs, or showing a history game + * with no card in (`kind: 'idle'`), has no representation there. AppState is the truth for the session's + * phase, the ActivityMap for each game's installs and removals, and BrowseInfo for what you are looking at. */ export interface BrowseInfo { readonly id: string; @@ -220,16 +220,18 @@ export interface GameInfo { readonly unconfigured?: boolean; } -/** The flow state machine (discriminated union). */ +/** + * The game session's state machine (discriminated union): at most one game is launched, played and synced + * at a time. Installs, removals and Steam's downloads are not part of it — they are per-game activities + * (shared/activity.ts, pushed on activity:update) and run side by side with the session. + */ export type AppState = | { readonly kind: 'idle' } | { readonly kind: 'ready'; readonly game: GameInfo } - | { readonly kind: 'installing'; readonly game: GameInfo } - | { readonly kind: 'uninstalling'; readonly game: GameInfo } /** - * Linux-only: the game's Wine prefix is being provisioned (winetricks) before the installer/game - * runs. A transient screen shown WITHIN installing/launching; the renderer shows "Configuring Proton..." - * and appends a rotating funny suffix after a minute. Reverts to the prior state when done. + * Linux-only: the game's Wine prefix is being provisioned (winetricks) before the game runs. A transient + * screen shown WITHIN launching; the renderer shows "Configuring Proton..." and appends a rotating funny + * suffix after a minute. Reverts to the prior state when done. Installs provision as their own activity. */ | { readonly kind: 'configuringProton'; readonly game: GameInfo } | { readonly kind: 'syncing-in'; readonly game: GameInfo } From fb88a3e06a0ff03d7f37f26e64f289698cc5db4e Mon Sep 17 00:00:00 2001 From: Alexander Date: Mon, 5 Oct 2026 18:07:56 +0500 Subject: [PATCH 08/10] Docs: background installs, the session vs activity models --- CLAUDE.md | 23 +++++++++++ README.md | 61 ++++++++++++++++++++++++++-- src/main/game-controller.ts | 4 +- src/main/game-jobs.ts | 8 ++-- src/main/game-move-transaction.ts | 2 +- src/main/platform/types.ts | 2 +- src/main/quit-gate.ts | 2 +- src/main/steam-activity-watch.ts | 2 +- src/main/steam.ts | 4 +- src/renderer/confirm.ts | 2 +- src/renderer/controls-deps.ts | 4 +- src/renderer/game-settings-screen.ts | 2 +- src/shared/types.ts | 4 +- test/action-target.test.ts | 2 +- test/game-controller.test.ts | 6 +-- test/game-jobs.test.ts | 7 +++- test/renderer/controls.test.ts | 4 +- test/state-view.test.ts | 53 +++++++++++++++++++----- test/steam-activity-watch.test.ts | 5 ++- 19 files changed, 156 insertions(+), 41 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 99984a4..086af6d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,6 +32,29 @@ mandate to rewrite what already works. browser, so a `node:*` import there breaks the build, not just the convention. See `src/shared/asset-move-names.ts` (move-to-card asset names, computed identically in main and renderer). +## Two state models: the session and per-game activities + +- **`AppState` is the game session only** (`idle | ready | configuringProton | syncing-in | launching | + running | syncing-out | error`): at most one game is launched, played and synced at a time. Its locks + live in `GameSequences` (`game-sequences.ts`). +- **Everything that runs in the background is a per-game activity** (`shared/activity.ts`, + `ActivityMap` keyed by game id), held by `ActivityRegistry` (`activity-registry.ts`) and pushed in full + on `activity:update` (seeded by `activity:request`). Writers: `SteamActivityWatch` + (`steam-activity-watch.ts`, Steam downloads / updates / removals of every available Steam game) and + `GameJobs` (`game-jobs.ts`, copy installs, installer runs, uninstalls, prefix cleanups, with their own + queues and session rules in the pure `startVerdict`). +- **A game never has both.** A game with an activity is not launched; the session's game gets no + activity (Steam updating it before launch must not hide "Launching..."). +- **Actions are addressed by game id** (`action:launch`, `action:uninstall`, `action:cancel-job`). + `GameActions` (`game-actions.ts`) resolves the target with the pure `resolveActionTarget` + (`action-target.ts`): with the session busy, another game may be installed or removed, never launched. +- **The renderer judges the game on screen by itself**: `screenActions` in `state-view.ts` decides Play, + Install, Uninstall, Cancel and Force close from the session, the browse model and that game's activity. + `#app[data-phase]` stays global; `data-busy`, `data-activity` and `data-play-locked` describe the game + on screen. +- **A managed exit goes through `QuitGate`** (`quit-gate.ts`): with jobs running, Quit / Shutdown / Reboot + ask first (`quit:confirm` / `quit:confirm-reply`, a native dialog when the window does not answer). + ## Error-handling convention Pick per situation, matching the existing patterns: diff --git a/README.md b/README.md index a2b7e20..3139661 100644 --- a/README.md +++ b/README.md @@ -446,8 +446,9 @@ How it works: - While the game isn't installed there is **no Play button**: **Install** lives in the **More (⋯)** menu. Pressing it asks for confirmation (the popup also shows the destination path, handy if the installer isn't fully silent), then runs the installer **silently** and shows an **"Installing..."** - indicator. When the executable appears **Play** comes back, and from then on the game launches - **from the install location on the PC**. + indicator. The install runs in the background (see + [Background installs and removals](#background-installs-and-removals)). When the executable appears + **Play** comes back, and from then on the game launches **from the install location on the PC**. - **`type: "copy"` — "move game to PC" without an installer.** `installer` points at the game's own directory on the card and Playhook simply **copies its contents** into the install dir (`executable` is relative to that copy; a leading `/` is stripped, so both spellings work). Nothing is @@ -568,8 +569,13 @@ for a normal game. - **Not installed → no Play button; "Install" sits in the More (⋯) menu.** Pressing it confirms ("Open Steam to install this game?") and opens `steam://install/` — Steam shows its own install dialog. Playhook does **not** block on a wizard: it stays on the screen, shows a non-blocking - **"Installing…"** indicator, and a background poll (~5s) brings **Play** back once Steam reports the - game fully installed. The window stays usable meanwhile — a Steam download can run for hours. + **"Installing…"** indicator, and a background poll of every Steam game (~5s) brings **Play** back once + Steam reports the game fully installed. The window stays usable meanwhile - a Steam download can run for + hours, several at once, each shown on its own game (see + [Background installs and removals](#background-installs-and-removals)). +- **Updates and pre-loads:** Steam updating an installed game reads **"Updating…"** and ends without an + "installed" notification; a pre-load of a game that is not released yet reads **"Pre-load complete"**. + A game Steam updates right before you launch it shows "Launching…", not "Updating…". - **Pause:** if you pause the download in Steam, the indicator becomes **"Installing paused on N%…"** (the percent is only available while paused — see limitations). While a download is in progress the **activity button (the spinning gear) opens Steam's Downloads page** so you can pause/resume there — @@ -586,6 +592,50 @@ If Steam isn't installed on the PC, Install/Play report **"Steam is not installe a URI. Steam's install and uninstall dialogs **cannot be made silent** — there is no `steam://` flag to suppress them (just one confirmation; the rest of the flow is automatic). +### Background installs and removals + +Installing and uninstalling never take the launcher over: every game carries its own **activity**, and +any number of games can be busy at once while the others stay playable. + +- **Per game, side by side.** Copy installs, card installers, uninstalls, prefix cleanups and Steam + downloads each belong to one game. Its card pulses in the carousel and the Library, and its detail + screen shows its own status: "Installing...", "Waiting to install...", "Updating...", + "Pre-load complete" and so on. The status shows from the moment Playhook starts, without opening the game. +- **While a game is running** you can bring Playhook up (Start+Back on Windows, the tray or window + switching in desktop Linux, Steam's window switcher in Game Mode) and install, cancel or uninstall + **other** games. A second game is never launched on top of the first: its Play stays visible but + inactive. A Steam install opens Steam's own dialog, which may appear over the game (not yet checked in + Game Mode). +- **Queues.** At most two copy installs and **one installer** run at once (parallel installers are not + safe on Windows: MSI's global lock fails a second one, and repacks eat the machine); the rest show + "Waiting to install...". An interactive installer (silent mode off), an elevated one (`runAsAdmin`) and a + removal that runs the game's own uninstaller elevated wait until you quit the game you are playing + ("Will install after you quit the game"), so no wizard or UAC prompt pops up over it. Under gamescope + (Game Mode) no installer and no winetricks prefix setup starts while a game runs at all - the game has + the only window there. +- **Cancel:** More (⋯) → **Cancel installation** stops a queued or running install of the game on screen. + A stopped copy leaves nothing behind: the files are staged in `.partial` and moved into place + only once complete. +- **Notifications instead of a popping window.** A finished install or removal no longer raises the + launcher; it is reported in the notifications (a failure too, with its reason - the error popup shows + only while the launcher has focus). +- **Quitting with work in progress** - Quit, Shutdown or Reboot, from the launcher or the tray - asks + first; confirming cancels the running jobs and cleans up after them. If the launcher window does not + answer, a native dialog asks instead. A launcher killed outright (crash, logout) sweeps leftover staging + folders on the next start, and an interrupted installer run never leaves a game that looks installed. +- **Pulling the card** stops the installs that read from it (reported in the notifications); removals + target the PC and finish. + +Tuning (environment variables, read at start): + +| Variable | Default | What it sets | +|---|---|---| +| `PLAYHOOK_STEAM_POLL_MS` | `5000` | How often Steam's `.acf` state of every Steam game is polled | +| `PLAYHOOK_MAX_PARALLEL_COPY_INSTALLS` | `2` | Copy installs running at once (reading a card while a game runs from it competes for it) | +| `PLAYHOOK_MAX_PARALLEL_INSTALLERS` | `1` | Installer runs at once - more is at your own risk on Windows | +| `PLAYHOOK_QUIT_CONFIRM_ACK_MS` | `2000` | How long the launcher window has to show the quit question before the native dialog takes over | +| `PLAYHOOK_QUIT_GRACE_MS` | `3000` | How long a quit waits for running jobs to clean up | + ### Statistics: one card, many PCs Statistics (hours / last played / launch count) are **unified across machines** with the card as @@ -698,6 +748,9 @@ These are ignored on Windows, so a dual-platform card can carry them safely: yanked into Playhook on every entry would be obnoxious. Launch it from the library in that case. An ordinary USB stick or a card without `game.json` never triggers it. The whole behaviour can be turned off in **Settings → General**. +- **Installing beside a running game:** switch to Playhook with Steam's window switcher, start installs or + removals of other games, and switch back. Installers and winetricks prefix setups wait until you quit + the game (gamescope gives the game the only window); a copy whose prefix is already set up runs at once. - **No autostart in Game Mode** — the app lives as a non-Steam game, started by Steam (or by the service above). In Desktop Mode it writes a `~/.config/autostart/playhook.desktop` entry instead. diff --git a/src/main/game-controller.ts b/src/main/game-controller.ts index bdd7dde..1120a60 100644 --- a/src/main/game-controller.ts +++ b/src/main/game-controller.ts @@ -267,7 +267,7 @@ export class GameController { return manifest !== null && this.sourceAvailable(manifest); } - /** Every Steam game whose source is available right now — what the Steam activity watch polls. */ + /** 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) @@ -811,7 +811,7 @@ export class GameController { /** * 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 + * 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. diff --git a/src/main/game-jobs.ts b/src/main/game-jobs.ts index 23ecb05..b25601a 100644 --- a/src/main/game-jobs.ts +++ b/src/main/game-jobs.ts @@ -64,7 +64,7 @@ export type StartVerdict = 'start' | 'wait' | 'wait-session' | 'learn'; /** * Whether a queued job may start. Installer runs: at most `laneLimit` at once (MSI's global mutex fails a * second one with 1618, repacks eat the machine), never an interactive or elevated one during a session - * (a wizard or a UAC prompt over the game), and under gamescope none at all while a game runs — the game has + * (a wizard or a UAC prompt over the game), and under gamescope none at all while a game runs - the game has * the only surface there, and even a silent installer opens windows. A copy waits under gamescope only when * its prefix still needs winetricks, for the same reason. A removal that runs the game's own uninstaller * follows the installer rules for the session (a window or a UAC prompt of its own); other removals start. @@ -380,7 +380,7 @@ export class GameJobs { job.facts = { interactive: settings.disableSilentInstall, provisions }; } catch (cause) { log.warn( - `[jobs] could not look up how id=${job.id} runs — treating it as interactive:`, + `[jobs] could not look up how id=${job.id} runs - treating it as interactive:`, describe(cause), ); job.facts = { interactive: true, provisions: true, uninstaller: true }; @@ -557,7 +557,7 @@ export class GameJobs { /** * Runs a card installer into the install dir: a marker first (a launcher killed mid-run must not leave a - * playable-looking game behind), the installer, then a grace poll for the executable — some wrappers fork a + * playable-looking game behind), the installer, then a grace poll for the executable - some wrappers fork a * child and exit early. A stopped or failed run sweeps the install dir. */ private async runInstaller( @@ -613,7 +613,7 @@ export class GameJobs { } /** - * Removes an installed game: its own uninstaller first (never for `copy` — a copied directory's + * Removes an installed game: its own uninstaller first (never for `copy` - a copied directory's * uninstaller belongs to an install made on another machine), then a sweep of the uninstall dir. */ private async runUninstall(job: Job, install: ResolvedInstall): Promise { diff --git a/src/main/game-move-transaction.ts b/src/main/game-move-transaction.ts index 373dcd1..a8262e0 100644 --- a/src/main/game-move-transaction.ts +++ b/src/main/game-move-transaction.ts @@ -87,7 +87,7 @@ export interface GameMoveDeps { */ readonly resolveManifest: (id: string) => ResolvedManifest | null; /** - * Whether game `id` is the session's or carries an activity (GameController.isBusy) — moveToCard's + * Whether game `id` is the session's or carries an activity (GameController.isBusy) - moveToCard's * own re-check of the guard the "Move to card…" menu item already applies in the renderer. */ readonly isBusy: (id: string) => boolean; diff --git a/src/main/platform/types.ts b/src/main/platform/types.ts index c1ac83b..14f3e80 100644 --- a/src/main/platform/types.ts +++ b/src/main/platform/types.ts @@ -151,7 +151,7 @@ export interface GameProcessLauncher { onProvisioning?: (active: boolean) => void, ): Promise; /** - * Whether preparing `install`'s environment would run winetricks — linux: verbs still missing in the + * Whether preparing `install`'s environment would run winetricks - linux: verbs still missing in the * install's prefix; win32 / darwin: never. Background jobs use it to hold such a step back while a game * runs under gamescope, where any window it opens would steal the game's only surface. */ diff --git a/src/main/quit-gate.ts b/src/main/quit-gate.ts index 87075f5..ff8d0f1 100644 --- a/src/main/quit-gate.ts +++ b/src/main/quit-gate.ts @@ -94,7 +94,7 @@ export class QuitGate { private async askNatively(pending: PendingQuit): Promise { pending.timer = null; - log.warn('[quit] the launcher window did not confirm the question — asking natively'); + log.warn('[quit] the launcher window did not confirm the question - asking natively'); const go = await this.deps.askNatively(pending.action, this.deps.activeJobs()); if (this.pending !== pending) return; this.pending = null; diff --git a/src/main/steam-activity-watch.ts b/src/main/steam-activity-watch.ts index 973baf0..5c9bce9 100644 --- a/src/main/steam-activity-watch.ts +++ b/src/main/steam-activity-watch.ts @@ -227,7 +227,7 @@ export class SteamActivityWatch { return true; } log.info( - `[steam-uninstall] appid=${game.appid} still installed after timeout — assuming cancel`, + `[steam-uninstall] appid=${game.appid} still installed after timeout - assuming cancel`, ); this.uninstallRequests.delete(game.appid); this.clearSteamActivity(game.id); diff --git a/src/main/steam.ts b/src/main/steam.ts index 5ff559f..55c5fa7 100644 --- a/src/main/steam.ts +++ b/src/main/steam.ts @@ -131,8 +131,8 @@ async function readAcfState(acfPath: string): Promise.acf` walk shared by - * the single lookup and the batch one below. Null when the only `.acf` found could not be parsed — most - * likely caught halfway through Steam rewriting it — so the state is unknown rather than "absent". + * the single lookup and the batch one below. Null when the only `.acf` found could not be parsed - most + * likely caught halfway through Steam rewriting it - so the state is unknown rather than "absent". */ async function statusInLibraries(appid: number, libs: readonly string[]): Promise { let downloading: SteamInstallStatus | null = null; diff --git a/src/renderer/confirm.ts b/src/renderer/confirm.ts index 2a1e794..9e4b93c 100644 --- a/src/renderer/confirm.ts +++ b/src/renderer/confirm.ts @@ -220,7 +220,7 @@ export interface ConfirmActionDeps { takeForgetId(): string | null; /** The collision question's "yes". */ mergeCollision(): void; - /** The game an install / uninstall question was opened for — fixed then, never re-read on Yes. */ + /** The game an install / uninstall question was opened for - fixed then, never re-read on Yes. */ readonly targetId: string | null; } diff --git a/src/renderer/controls-deps.ts b/src/renderer/controls-deps.ts index 89dd58d..53bebc2 100644 --- a/src/renderer/controls-deps.ts +++ b/src/renderer/controls-deps.ts @@ -36,7 +36,7 @@ export interface ControlsApi { requestSleep(): void; /** "Minimize Playhook" — hide to the tray. */ requestHide(): void; - /** "Close Playhook" — the full quit; `confirmed` as for requestShutdown. */ + /** "Close Playhook" - the full quit; `confirmed` as for requestShutdown. */ requestQuit(confirmed?: boolean): void; /** Tells main its quit question is up (or queued), or was closed without a Yes. */ quitConfirmReply(reply: QuitConfirmReply): void; @@ -62,7 +62,7 @@ export interface ControlsDeps { getBrowse(): BrowseInfo | null; /** The activity of the game on screen (a Steam download, a removal), or undefined when it is free. */ getScreenActivity(): GameActivity | undefined; - /** How many background installs / uninstalls are queued or running — a quit asks first while any are. */ + /** How many background installs / uninstalls are queued or running - a quit asks first while any are. */ getJobCount(): number; /** The shared audio controller (UI sounds). */ audio: AudioController; diff --git a/src/renderer/game-settings-screen.ts b/src/renderer/game-settings-screen.ts index ba5b627..f2edfd5 100644 --- a/src/renderer/game-settings-screen.ts +++ b/src/renderer/game-settings-screen.ts @@ -191,7 +191,7 @@ export interface GameSettingsScreenDeps { * title with X?" is about a candidate the popup has never heard of. */ onConfirmRequested(kind: GameSettingsConfirm, options?: { readonly title?: string }): void; - /** Whether game `id` is the session's game or carries an activity — Delete, Move and Save stand down. */ + /** Whether game `id` is the session's game or carries an activity - Delete, Move and Save stand down. */ 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; diff --git a/src/shared/types.ts b/src/shared/types.ts index 11c1959..2d2248b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -222,7 +222,7 @@ export interface GameInfo { /** * The game session's state machine (discriminated union): at most one game is launched, played and synced - * at a time. Installs, removals and Steam's downloads are not part of it — they are per-game activities + * at a time. Installs, removals and Steam's downloads are not part of it - they are per-game activities * (shared/activity.ts, pushed on activity:update) and run side by side with the session. */ export type AppState = @@ -486,7 +486,7 @@ export const IPC = { /** renderer → main: quit the whole app. In Game Mode (gamescope) the power menu's primary item becomes * "Close Playhook" (there is no tray to minimize into), which sends this instead of actionHide. */ actionQuit: 'action:quit', - /** main → renderer: background jobs are running — ask before this QuitAction (tray Quit, a raced Quit). */ + /** main → renderer: background jobs are running - ask before this QuitAction (tray Quit, a raced Quit). */ quitConfirm: 'quit:confirm', /** renderer → main: the quit question was shown (or queued), or closed without a Yes (QuitConfirmReply). */ quitConfirmReply: 'quit:confirm-reply', diff --git a/test/action-target.test.ts b/test/action-target.test.ts index 596d18f..4a64a41 100644 --- a/test/action-target.test.ts +++ b/test/action-target.test.ts @@ -41,7 +41,7 @@ describe('resolveActionTarget', () => { expect(resolveActionTarget(input({ ...busy, action: 'uninstall' }))).toBe('uninstall-only'); }); - it('refuses a busy target, a game whose card is gone, and anything during a reload — in any session', () => { + it('refuses a busy target, a game whose card is gone, and anything during a reload - in any session', () => { for (const session of [{}, { sessionBusy: true, sessionGameId: 'c' }]) { expect(resolveActionTarget(input({ ...session, hasActivity: true }))).toBe('refuse'); expect(resolveActionTarget(input({ ...session, sourceAvailable: false }))).toBe('refuse'); diff --git a/test/game-controller.test.ts b/test/game-controller.test.ts index c24be0b..7c6a55b 100644 --- a/test/game-controller.test.ts +++ b/test/game-controller.test.ts @@ -119,9 +119,9 @@ interface Harness { readonly activities: ActivityRegistry; /** Holds every copy install at its prepare step until the returned release is called. */ readonly holdCopies: () => () => void; - /** What the fake watcher was given for `onRemove` — the card being pulled. */ + /** What the fake watcher was given for `onRemove` - the card being pulled. */ readonly remove: () => void; - /** Rewrites the Steam game's `.acf` — the way Steam reports a download starting or finishing. */ + /** Rewrites the Steam game's `.acf` - the way Steam reports a download starting or finishing. */ readonly setSteamState: (state: SteamAcfState) => Promise; } @@ -167,7 +167,7 @@ async function writeSteamAcf(root: string, appid: number, state: SteamAcfState): ); } -/** A Steam root whose one library reports `appid` in `state` — enough for steamInstallStatus. */ +/** 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 }); diff --git a/test/game-jobs.test.ts b/test/game-jobs.test.ts index 3e12137..ba359a6 100644 --- a/test/game-jobs.test.ts +++ b/test/game-jobs.test.ts @@ -130,7 +130,12 @@ function build(options: BuildOptions = {}): Fixture { resolveUninstaller: (install) => Promise.resolve( uninstallers.has(path.basename(install.dir)) - ? { file: path.join(install.dir, 'unins000.exe'), args: [], cwd: install.dir, runAsAdmin: false } + ? { + file: path.join(install.dir, 'unins000.exe'), + args: [], + cwd: install.dir, + runAsAdmin: false, + } : null, ), launchUninstaller: () => Promise.resolve(procOf('uninstaller')), diff --git a/test/renderer/controls.test.ts b/test/renderer/controls.test.ts index 5dbb917..3e2afd2 100644 --- a/test/renderer/controls.test.ts +++ b/test/renderer/controls.test.ts @@ -471,7 +471,7 @@ describe('controls quit with background jobs', () => { expect(api.requestQuit).toHaveBeenCalledWith(); }); - it("main's question opens on its own, says it is shown, and its No just closes — no Power menu", () => { + it("main's question opens on its own, says it is shown, and its No just closes - no Power menu", () => { harness.activities = { a: { kind: 'installing' } }; controls.askQuit('quit'); @@ -533,7 +533,7 @@ describe('controls while another game runs', () => { harness.state = { kind: 'running', game: SESSION, since: 0 }; }); - it("an installed game's Play is out of the ring and refuses — it never starts a second game", () => { + it("an installed game's Play is out of the ring and refuses - it never starts a second game", () => { harness.screen = 'detail'; harness.browse = browsing(LOCAL_GAME); controls.refresh(); diff --git a/test/state-view.test.ts b/test/state-view.test.ts index 3487f10..5d4164e 100644 --- a/test/state-view.test.ts +++ b/test/state-view.test.ts @@ -114,16 +114,32 @@ describe('sameIds', () => { describe('screenActions', () => { const OTHER: GameInfo = { ...GAME, id: 'other', title: 'Other' }; const UNINSTALLED: GameInfo = { ...GAME, requiresInstall: true }; - const browsing = (game: GameInfo): BrowseInfo => ({ ...BROWSE, id: game.id, title: game.title, game }); + const browsing = (game: GameInfo): BrowseInfo => ({ + ...BROWSE, + id: game.id, + title: game.title, + game, + }); const running = { kind: 'running', game: OTHER, since: 0 } as const; it('an installed free game: Play launches, Uninstall as offered', () => { - const actions = screenActions({ kind: 'ready', game: GAME }, browsing({ ...GAME, canUninstall: true }), undefined); - expect(actions).toMatchObject({ canPlay: true, canUninstall: true, canInstall: false, playView: 'play' }); + const actions = screenActions( + { kind: 'ready', game: GAME }, + browsing({ ...GAME, canUninstall: true }), + undefined, + ); + expect(actions).toMatchObject({ + canPlay: true, + canUninstall: true, + canInstall: false, + playView: 'play', + }); }); - it('an uninstalled game: Play hidden, Install offered — also while another game runs', () => { - expect(screenActions({ kind: 'ready', game: UNINSTALLED }, browsing(UNINSTALLED), undefined)).toMatchObject({ + it('an uninstalled game: Play hidden, Install offered - also while another game runs', () => { + expect( + screenActions({ kind: 'ready', game: UNINSTALLED }, browsing(UNINSTALLED), undefined), + ).toMatchObject({ canInstall: true, canPlay: false, playView: 'hidden', @@ -136,7 +152,9 @@ describe('screenActions', () => { }); it('an installed game while another one runs: Play shows but never starts it, Uninstall still offered', () => { - expect(screenActions(running, browsing({ ...GAME, canUninstall: true }), undefined)).toMatchObject({ + expect( + screenActions(running, browsing({ ...GAME, canUninstall: true }), undefined), + ).toMatchObject({ canPlay: false, canUninstall: true, playView: 'play', @@ -157,7 +175,9 @@ describe('screenActions', () => { canForceClose: false, playView: 'spinner', }); - expect(screenActions({ kind: 'launching', game: OTHER }, browsing(OTHER), undefined).playView).toBe('spinner'); + expect( + screenActions({ kind: 'launching', game: OTHER }, browsing(OTHER), undefined).playView, + ).toBe('spinner'); }); it("the game's own activity wins: the gear, Cancel for an install, Play only for Steam's downloads", () => { @@ -166,17 +186,26 @@ describe('screenActions', () => { canCancel: true, canPlay: false, }); - expect(screenActions(running, browsing(GAME), { kind: 'queued', reason: 'session', removal: true })).toMatchObject({ + expect( + screenActions(running, browsing(GAME), { kind: 'queued', reason: 'session', removal: true }), + ).toMatchObject({ canCancel: false, }); - expect(screenActions({ kind: 'ready', game: GAME }, browsing(GAME), { kind: 'steam-installing', paused: false })).toMatchObject({ + expect( + screenActions({ kind: 'ready', game: GAME }, browsing(GAME), { + kind: 'steam-installing', + paused: false, + }), + ).toMatchObject({ canPlay: true, playView: 'gear', }); }); it('a history game or nothing on screen offers nothing', () => { - expect(screenActions({ kind: 'ready', game: GAME }, { ...BROWSE, active: false }, undefined)).toMatchObject({ + expect( + screenActions({ kind: 'ready', game: GAME }, { ...BROWSE, active: false }, undefined), + ).toMatchObject({ game: undefined, playView: 'hidden', }); @@ -191,7 +220,9 @@ describe('screenActions', () => { describe('queued statuses', () => { it('names what a queued job waits for', () => { expect(activityStatus({ kind: 'queued' }, t)).toBe('Waiting to install...'); - expect(activityStatus({ kind: 'queued', reason: 'session' }, t)).toBe('Will install after you quit the game'); + expect(activityStatus({ kind: 'queued', reason: 'session' }, t)).toBe( + 'Will install after you quit the game', + ); expect(activityStatus({ kind: 'queued', reason: 'session', removal: true }, t)).toBe( 'Will uninstall after you quit the game', ); diff --git a/test/steam-activity-watch.test.ts b/test/steam-activity-watch.test.ts index 28bb0f5..17c2c60 100644 --- a/test/steam-activity-watch.test.ts +++ b/test/steam-activity-watch.test.ts @@ -250,7 +250,10 @@ describe('SteamActivityWatch and a half-written .acf', () => { it('an .acf caught mid-rewrite changes nothing, so the finished download still notifies', async () => { await fixture.acf(A.appid, 'downloading'); await fixture.watch.scanNow(); - await fs.writeFile(path.join(root, 'steamapps', `appmanifest_${A.appid}.acf`), '"AppState"\n{\n'); + await fs.writeFile( + path.join(root, 'steamapps', `appmanifest_${A.appid}.acf`), + '"AppState"\n{\n', + ); await fixture.watch.scanNow(); expect(fixture.registry.get('a')).toEqual({ kind: 'steam-installing', paused: false }); expect(fixture.events).toEqual(['changed:a']); From b021dfa1cfe6a2e91ca8d166aba827b2e6fd4832 Mon Sep 17 00:00:00 2001 From: Alexander Date: Mon, 5 Oct 2026 20:15:05 +0500 Subject: [PATCH 09/10] Bump version to 0.9.0-alpha.8 --- 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 726d8e5..cb78beb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "playhook", - "version": "0.9.0-alpha.7", + "version": "0.9.0-alpha.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "playhook", - "version": "0.9.0-alpha.7", + "version": "0.9.0-alpha.8", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 1cfc23f..bf163ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "playhook", - "version": "0.9.0-alpha.7", + "version": "0.9.0-alpha.8", "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 3d636d96230abb7c162c061e22a81214c1728710 Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 6 Oct 2026 22:48:47 +0500 Subject: [PATCH 10/10] Bump version to 0.9.0-alpha.11 --- 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 1de564f..37144d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "playhook", - "version": "0.9.0-alpha.10", + "version": "0.9.0-alpha.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "playhook", - "version": "0.9.0-alpha.10", + "version": "0.9.0-alpha.11", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 9b6311e..12d04e1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "playhook", - "version": "0.9.0-alpha.10", + "version": "0.9.0-alpha.11", "description": "Background launcher that detects installed game on external device, syncs saves and tracks playtime. Bring console vibes to your PC.", "private": true, "main": "dist/main/main.js",