diff --git a/src/main/steam-activity-watch.ts b/src/main/steam-activity-watch.ts index 98ffff5..5c9bce9 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)); @@ -189,14 +190,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 +226,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/main/steam.ts b/src/main/steam.ts index 589db36..55c5fa7 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/test/steam-activity-watch.test.ts b/test/steam-activity-watch.test.ts index d912842..17c2c60 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 () => { @@ -229,6 +246,23 @@ 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');