feat(desktop): add remote dictionary sync engine to activity detector

Refactor loadDictionary to accept a file path and support both legacy
bare-array (version 0) and versioned object formats. Add background
sync that fetches the latest games.json from GitHub, uses ETag-based
conditional requests (304 support), atomic cache writes, and hot-swaps
processMap/gameEntries without resetting current detection state.
On startup, the best local source (cache vs bundled seed) is loaded
immediately for instant detection, then sync runs fire-and-forget.
This commit is contained in:
Jannis Braun
2026-03-21 04:12:21 +01:00
parent 228ffe4894
commit 948fa6de5c
+346 -41
View File
@@ -1,6 +1,8 @@
import { execFile } from 'child_process';
import https from 'https';
import path from 'path';
import fs from 'fs';
import { app } from 'electron';
// ─── Local Activity type (structural match with @backspace/shared Activity) ─
@@ -26,8 +28,14 @@ interface GameEntry {
type?: string;
}
interface VersionedDictionary {
version: number;
games: GameEntry[];
}
const VALID_TYPES = new Set(['playing', 'listening', 'watching', 'streaming']);
const POLL_INTERVAL_MS = 15_000;
const REMOTE_URL = 'https://raw.githubusercontent.com/TheZwiss/backspace/main/packages/desktop/resources/games.json';
// ─── Module state ───────────────────────────────────────────────────────────
@@ -42,73 +50,353 @@ let onChangeCallback: ((activity: Activity | null) => void) | null = null;
// ─── Dictionary loading ────────────────────────────────────────────────────
function loadDictionary(): boolean {
const dictPath = path.join(__dirname, '..', 'resources', 'games.json');
/**
* Parse and validate a game entry from the dictionary.
* Returns a valid GameEntry or null if the entry is malformed.
*/
function parseGameEntry(entry: unknown): GameEntry | null {
if (!entry || typeof entry !== 'object') return null;
const e = entry as Record<string, unknown>;
if (typeof e.id !== 'string' || !e.id || typeof e.name !== 'string' || !e.name ||
!Array.isArray(e.processes) || e.processes.length === 0 ||
!e.processes.every((p: unknown) => typeof p === 'string')) {
return null;
}
const type = typeof e.type === 'string' && VALID_TYPES.has(e.type) ? e.type : 'playing';
return {
id: e.id,
name: e.name,
processes: e.processes as string[],
type,
};
}
/**
* Load a game dictionary from a file path.
* Supports both formats:
* - Bare array (legacy, version 0): [{ id, name, processes }, ...]
* - Versioned object: { version: number, games: [...] }
*
* On success, populates `gameEntries` and `processMap` and returns the version.
* On failure, returns { success: false, version: 0 } without modifying state.
*/
function loadDictionary(filePath: string): { success: boolean; version: number } {
let raw: string;
try {
raw = fs.readFileSync(dictPath, 'utf-8');
raw = fs.readFileSync(filePath, 'utf-8');
} catch {
console.warn('[ActivityDetector] games.json not found at', dictPath, '— detection disabled');
return false;
console.warn('[ActivityDetector] Dictionary not found at', filePath);
return { success: false, version: 0 };
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
console.warn('[ActivityDetector] games.json contains malformed JSON — detection disabled');
return false;
console.warn('[ActivityDetector] Dictionary contains malformed JSON at', filePath);
return { success: false, version: 0 };
}
if (!Array.isArray(parsed)) {
console.warn('[ActivityDetector] games.json root must be an array — detection disabled');
return false;
// Determine format: bare array (version 0) vs versioned object
let version = 0;
let entries: unknown[];
if (Array.isArray(parsed)) {
// Legacy bare-array format
version = 0;
entries = parsed;
} else if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const obj = parsed as Record<string, unknown>;
if (typeof obj.version === 'number' && Array.isArray(obj.games)) {
version = obj.version;
entries = obj.games;
} else {
console.warn('[ActivityDetector] Dictionary has unknown structure at', filePath);
return { success: false, version: 0 };
}
} else {
console.warn('[ActivityDetector] Dictionary root must be an array or versioned object at', filePath);
return { success: false, version: 0 };
}
gameEntries = [];
processMap = new Map();
const newEntries: GameEntry[] = [];
const newMap = new Map<string, GameEntry>();
for (const entry of parsed) {
if (!entry || typeof entry !== 'object') {
console.warn('[ActivityDetector] Skipping invalid entry (not an object)');
continue;
}
const e = entry as Record<string, unknown>;
if (typeof e.id !== 'string' || !e.id || typeof e.name !== 'string' || !e.name ||
!Array.isArray(e.processes) || e.processes.length === 0 ||
!e.processes.every((p: unknown) => typeof p === 'string')) {
console.warn('[ActivityDetector] Skipping invalid entry:', e.id ?? JSON.stringify(e));
for (const entry of entries) {
const gameEntry = parseGameEntry(entry);
if (!gameEntry) {
const e = entry as Record<string, unknown> | null;
console.warn('[ActivityDetector] Skipping invalid entry:', e?.id ?? JSON.stringify(entry));
continue;
}
const type = typeof e.type === 'string' && VALID_TYPES.has(e.type) ? e.type : 'playing';
const gameEntry: GameEntry = {
id: e.id,
name: e.name,
processes: e.processes as string[],
type,
};
gameEntries.push(gameEntry);
newEntries.push(gameEntry);
for (const proc of gameEntry.processes) {
const key = proc.toLowerCase();
if (!processMap.has(key)) {
processMap.set(key, gameEntry);
if (!newMap.has(key)) {
newMap.set(key, gameEntry);
}
}
}
if (processMap.size === 0) {
console.warn('[ActivityDetector] No valid entries in games.json — detection disabled');
return false;
if (newMap.size === 0) {
console.warn('[ActivityDetector] No valid entries in dictionary at', filePath);
return { success: false, version: 0 };
}
console.log(`[ActivityDetector] Loaded ${gameEntries.length} games (${processMap.size} process names)`);
return true;
// Commit to module state
gameEntries = newEntries;
processMap = newMap;
console.log(`[ActivityDetector] Loaded ${newEntries.length} games (${newMap.size} process names) v${version} from ${filePath}`);
return { success: true, version };
}
/**
* Hot-swap the process map and game entries without resetting detection state.
* Used when a remote dictionary update is fetched in the background.
*/
function hotSwapDictionary(newEntries: GameEntry[], newMap: Map<string, GameEntry>): void {
gameEntries = newEntries;
processMap = newMap;
// Note: currentGameId and currentActivity are intentionally preserved
// so detection state survives dictionary updates.
}
// ─── Remote sync ────────────────────────────────────────────────────────────
function getCachePath(): string {
return path.join(app.getPath('userData'), 'games-cache.json');
}
function getETagPath(): string {
return path.join(app.getPath('userData'), 'games-cache-etag.txt');
}
function readETag(): string | null {
try {
return fs.readFileSync(getETagPath(), 'utf-8').trim() || null;
} catch {
return null;
}
}
function writeETag(etag: string): void {
try {
fs.writeFileSync(getETagPath(), etag, 'utf-8');
} catch {
// Non-critical — next sync will just re-download
}
}
/**
* Validate a parsed dictionary object without loading it into module state.
* Returns the validated entries and map, or null if invalid.
*/
function validateDictionary(parsed: unknown): {
version: number;
entries: GameEntry[];
map: Map<string, GameEntry>;
} | null {
let version = 0;
let rawEntries: unknown[];
if (Array.isArray(parsed)) {
version = 0;
rawEntries = parsed;
} else if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const obj = parsed as Record<string, unknown>;
if (typeof obj.version === 'number' && Array.isArray(obj.games)) {
version = obj.version;
rawEntries = obj.games;
} else {
return null;
}
} else {
return null;
}
const entries: GameEntry[] = [];
const map = new Map<string, GameEntry>();
for (const entry of rawEntries) {
const gameEntry = parseGameEntry(entry);
if (!gameEntry) continue;
entries.push(gameEntry);
for (const proc of gameEntry.processes) {
const key = proc.toLowerCase();
if (!map.has(key)) {
map.set(key, gameEntry);
}
}
}
if (map.size === 0) return null;
return { version, entries, map };
}
/**
* Fetch a URL over HTTPS and return the response body and headers.
* Supports conditional requests via If-None-Match.
*/
function fetchRemote(url: string, etag: string | null): Promise<{
status: number;
body: string;
etag: string | null;
} | null> {
return new Promise((resolve) => {
const headers: Record<string, string> = {
'User-Agent': 'Backspace-Desktop/1.0',
};
if (etag) {
headers['If-None-Match'] = etag;
}
const req = https.get(url, { headers, timeout: 10_000 }, (res) => {
if (res.statusCode === 304) {
resolve({ status: 304, body: '', etag: null });
res.resume(); // Drain the response
return;
}
// Follow single redirect (GitHub raw may 301/302)
if ((res.statusCode === 301 || res.statusCode === 302) && res.headers.location) {
res.resume();
fetchRemote(res.headers.location, etag).then(resolve).catch(() => resolve(null));
return;
}
if (res.statusCode !== 200) {
resolve(null);
res.resume();
return;
}
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
const body = Buffer.concat(chunks).toString('utf-8');
const responseEtag = typeof res.headers.etag === 'string' ? res.headers.etag : null;
resolve({ status: 200, body, etag: responseEtag });
});
res.on('error', () => resolve(null));
});
req.on('error', () => resolve(null));
req.on('timeout', () => {
req.destroy();
resolve(null);
});
});
}
/**
* Atomically write data to a file by writing to a .tmp file first,
* then renaming it into place.
*/
function atomicWrite(filePath: string, data: string): void {
const tmpPath = filePath + '.tmp';
fs.writeFileSync(tmpPath, data, 'utf-8');
fs.renameSync(tmpPath, filePath);
}
/**
* Sync the game dictionary from a remote source.
*
* Strategy:
* 1. Load the best local source (cache file or bundled seed), whichever has a higher version.
* 2. Fetch from GitHub in the background.
* 3. If the remote version is newer, atomically write to cache, save ETag, and hot-swap.
*/
async function syncDictionary(): Promise<void> {
const cachePath = getCachePath();
const seedPath = path.join(__dirname, '..', 'resources', 'games.json');
// Step 1: Determine best local version (cache vs. seed)
// The seed was already loaded by startActivityDetection before calling us,
// but the cache might have a higher version.
const cacheResult = loadDictionary(cachePath);
let currentVersion = cacheResult.success ? cacheResult.version : 0;
if (!cacheResult.success) {
// Cache missing or corrupt — seed was already loaded, read its version
const seedResult = loadDictionary(seedPath);
currentVersion = seedResult.success ? seedResult.version : 0;
}
// Step 2: Fetch remote
const etag = readETag();
let response: { status: number; body: string; etag: string | null } | null;
try {
response = await fetchRemote(REMOTE_URL, etag);
} catch {
console.log('[ActivityDetector] Remote sync fetch failed — continuing with local dictionary');
return;
}
if (!response) {
console.log('[ActivityDetector] Remote sync failed (network error) — continuing with local dictionary');
return;
}
// Step 3: Handle response
if (response.status === 304) {
console.log('[ActivityDetector] Remote dictionary unchanged (304)');
return;
}
if (response.status !== 200) {
console.log(`[ActivityDetector] Remote sync returned status ${response.status} — skipping`);
return;
}
// Parse and validate the remote dictionary
let parsed: unknown;
try {
parsed = JSON.parse(response.body);
} catch {
console.warn('[ActivityDetector] Remote dictionary contains malformed JSON — skipping');
return;
}
const validated = validateDictionary(parsed);
if (!validated) {
console.warn('[ActivityDetector] Remote dictionary validation failed — skipping');
return;
}
// Only apply if version is newer
if (validated.version <= currentVersion) {
console.log(`[ActivityDetector] Remote version (${validated.version}) is not newer than local (${currentVersion}) — skipping`);
// Still save ETag so we get 304 next time
if (response.etag) {
writeETag(response.etag);
}
return;
}
// Atomic write the cache
try {
atomicWrite(cachePath, response.body);
} catch (err) {
console.warn('[ActivityDetector] Failed to write cache:', err);
// Non-fatal — we can still hot-swap in memory
}
// Save ETag
if (response.etag) {
writeETag(response.etag);
}
// Hot-swap: rebuild processMap and gameEntries from validated data
hotSwapDictionary(validated.entries, validated.map);
console.log(`[ActivityDetector] Hot-swapped to remote dictionary v${validated.version} (${validated.entries.length} games, ${validated.map.size} process names)`);
}
// ─── Platform-specific process listing ──────────────────────────────────────
@@ -215,7 +503,19 @@ export function startActivityDetection(
): void {
if (intervalId) return; // Already running
if (!loadDictionary()) return; // Dictionary failed to load
// Load from best available local source: cache first, then bundled seed
const cachePath = path.join(app.getPath('userData'), 'games-cache.json');
const seedPath = path.join(__dirname, '..', 'resources', 'games.json');
const cacheResult = loadDictionary(cachePath);
if (!cacheResult.success) {
// Cache missing or corrupt — fall back to bundled seed
const seedResult = loadDictionary(seedPath);
if (!seedResult.success) {
console.warn('[ActivityDetector] No valid dictionary found — detection disabled');
return;
}
}
onChangeCallback = onActivityChange;
hasErrored = false;
@@ -225,6 +525,11 @@ export function startActivityDetection(
// Then poll every 15 seconds
intervalId = setInterval(poll, POLL_INTERVAL_MS);
// Fire-and-forget: sync from remote in background
syncDictionary().catch((err) => {
console.warn('[ActivityDetector] Sync failed:', err);
});
}
export function stopActivityDetection(): void {