From 948fa6de5c36d8f68944f1e6ca0215abb1cd097f Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 21 Mar 2026 04:12:21 +0100 Subject: [PATCH] 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. --- packages/desktop/src/activityDetector.ts | 387 ++++++++++++++++++++--- 1 file changed, 346 insertions(+), 41 deletions(-) diff --git a/packages/desktop/src/activityDetector.ts b/packages/desktop/src/activityDetector.ts index 3c6683d5..e25f5454 100644 --- a/packages/desktop/src/activityDetector.ts +++ b/packages/desktop/src/activityDetector.ts @@ -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; + 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[]; - gameEntries = []; - processMap = new Map(); - - for (const entry of parsed) { - if (!entry || typeof entry !== 'object') { - console.warn('[ActivityDetector] Skipping invalid entry (not an object)'); - continue; + 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; + 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 }; } - const e = entry as Record; + } else { + console.warn('[ActivityDetector] Dictionary root must be an array or versioned object at', filePath); + return { success: false, version: 0 }; + } - 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)); + const newEntries: GameEntry[] = []; + const newMap = new Map(); + + for (const entry of entries) { + const gameEntry = parseGameEntry(entry); + if (!gameEntry) { + const e = entry as Record | 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): 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; +} | 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; + 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(); + + 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 = { + '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 { + 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 {