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:
@@ -1,6 +1,8 @@
|
|||||||
import { execFile } from 'child_process';
|
import { execFile } from 'child_process';
|
||||||
|
import https from 'https';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import { app } from 'electron';
|
||||||
|
|
||||||
// ─── Local Activity type (structural match with @backspace/shared Activity) ─
|
// ─── Local Activity type (structural match with @backspace/shared Activity) ─
|
||||||
|
|
||||||
@@ -26,8 +28,14 @@ interface GameEntry {
|
|||||||
type?: string;
|
type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface VersionedDictionary {
|
||||||
|
version: number;
|
||||||
|
games: GameEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
const VALID_TYPES = new Set(['playing', 'listening', 'watching', 'streaming']);
|
const VALID_TYPES = new Set(['playing', 'listening', 'watching', 'streaming']);
|
||||||
const POLL_INTERVAL_MS = 15_000;
|
const POLL_INTERVAL_MS = 15_000;
|
||||||
|
const REMOTE_URL = 'https://raw.githubusercontent.com/TheZwiss/backspace/main/packages/desktop/resources/games.json';
|
||||||
|
|
||||||
// ─── Module state ───────────────────────────────────────────────────────────
|
// ─── Module state ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -42,73 +50,353 @@ let onChangeCallback: ((activity: Activity | null) => void) | null = null;
|
|||||||
|
|
||||||
// ─── Dictionary loading ────────────────────────────────────────────────────
|
// ─── 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;
|
let raw: string;
|
||||||
try {
|
try {
|
||||||
raw = fs.readFileSync(dictPath, 'utf-8');
|
raw = fs.readFileSync(filePath, 'utf-8');
|
||||||
} catch {
|
} catch {
|
||||||
console.warn('[ActivityDetector] games.json not found at', dictPath, '— detection disabled');
|
console.warn('[ActivityDetector] Dictionary not found at', filePath);
|
||||||
return false;
|
return { success: false, version: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
let parsed: unknown;
|
let parsed: unknown;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(raw);
|
parsed = JSON.parse(raw);
|
||||||
} catch {
|
} catch {
|
||||||
console.warn('[ActivityDetector] games.json contains malformed JSON — detection disabled');
|
console.warn('[ActivityDetector] Dictionary contains malformed JSON at', filePath);
|
||||||
return false;
|
return { success: false, version: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Array.isArray(parsed)) {
|
// Determine format: bare array (version 0) vs versioned object
|
||||||
console.warn('[ActivityDetector] games.json root must be an array — detection disabled');
|
let version = 0;
|
||||||
return false;
|
let entries: unknown[];
|
||||||
}
|
|
||||||
|
|
||||||
gameEntries = [];
|
if (Array.isArray(parsed)) {
|
||||||
processMap = new Map();
|
// Legacy bare-array format
|
||||||
|
version = 0;
|
||||||
for (const entry of parsed) {
|
entries = parsed;
|
||||||
if (!entry || typeof entry !== 'object') {
|
} else if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
console.warn('[ActivityDetector] Skipping invalid entry (not an object)');
|
const obj = parsed as Record<string, unknown>;
|
||||||
continue;
|
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<string, unknown>;
|
} 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 ||
|
const newEntries: GameEntry[] = [];
|
||||||
!Array.isArray(e.processes) || e.processes.length === 0 ||
|
const newMap = new Map<string, GameEntry>();
|
||||||
!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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const type = typeof e.type === 'string' && VALID_TYPES.has(e.type) ? e.type : 'playing';
|
newEntries.push(gameEntry);
|
||||||
|
|
||||||
const gameEntry: GameEntry = {
|
|
||||||
id: e.id,
|
|
||||||
name: e.name,
|
|
||||||
processes: e.processes as string[],
|
|
||||||
type,
|
|
||||||
};
|
|
||||||
|
|
||||||
gameEntries.push(gameEntry);
|
|
||||||
|
|
||||||
for (const proc of gameEntry.processes) {
|
for (const proc of gameEntry.processes) {
|
||||||
const key = proc.toLowerCase();
|
const key = proc.toLowerCase();
|
||||||
if (!processMap.has(key)) {
|
if (!newMap.has(key)) {
|
||||||
processMap.set(key, gameEntry);
|
newMap.set(key, gameEntry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (processMap.size === 0) {
|
if (newMap.size === 0) {
|
||||||
console.warn('[ActivityDetector] No valid entries in games.json — detection disabled');
|
console.warn('[ActivityDetector] No valid entries in dictionary at', filePath);
|
||||||
return false;
|
return { success: false, version: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[ActivityDetector] Loaded ${gameEntries.length} games (${processMap.size} process names)`);
|
// Commit to module state
|
||||||
return true;
|
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 ──────────────────────────────────────
|
// ─── Platform-specific process listing ──────────────────────────────────────
|
||||||
@@ -215,7 +503,19 @@ export function startActivityDetection(
|
|||||||
): void {
|
): void {
|
||||||
if (intervalId) return; // Already running
|
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;
|
onChangeCallback = onActivityChange;
|
||||||
hasErrored = false;
|
hasErrored = false;
|
||||||
@@ -225,6 +525,11 @@ export function startActivityDetection(
|
|||||||
|
|
||||||
// Then poll every 15 seconds
|
// Then poll every 15 seconds
|
||||||
intervalId = setInterval(poll, POLL_INTERVAL_MS);
|
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 {
|
export function stopActivityDetection(): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user