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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions src/main/steam-activity-watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down
30 changes: 20 additions & 10 deletions src/main/steam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AcfState | null> {
async function readAcfState(acfPath: string): Promise<AcfState | 'missing' | 'unreadable'> {
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')) ??
Expand All @@ -131,14 +131,20 @@ async function readAcfState(acfPath: string): Promise<AcfState | null> {

/**
* Where `appid` stands across the given Steam library roots: the `appmanifest_<appid>.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<SteamInstallStatus> {
async function statusInLibraries(appid: number, libs: readonly string[]): Promise<SteamInstallStatus | null> {
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).
Expand All @@ -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' };
}

/**
Expand All @@ -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:',
Expand All @@ -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[],
Expand All @@ -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:',
Expand Down
44 changes: 39 additions & 5 deletions test/steam-activity-watch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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']);
});

Expand Down Expand Up @@ -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',
]);
});
});

Expand Down Expand Up @@ -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 () => {
Expand All @@ -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');
Expand Down
Loading