From 0f4f129337612d917aa27f1f4c070d27f8fbea07 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 21 Mar 2026 02:57:30 +0100 Subject: [PATCH] feat(desktop): add automatic game/app activity detection Add cross-platform process scanning that detects running games and apps, then broadcasts them as Rich Presence activities via the existing Phase 1 pipeline (activityStore -> WS -> other users). - games.json dictionary with 26 entries (games, Spotify, VLC, OBS) - activityDetector module: polls OS process list every 15s via execFile, matches against dictionary, emits IPC on state change only - IPC bridge in main.ts/preload.ts with cleanup on quit - Frontend activityBridge subscribes to IPC and feeds activityStore - Supports Windows (tasklist CSV), macOS (ps -c), Linux (ps) --- packages/desktop/resources/games.json | 28 +++ packages/desktop/src/activityDetector.ts | 235 ++++++++++++++++++ packages/desktop/src/main.ts | 9 + packages/desktop/src/preload.ts | 8 + .../web/src/components/layout/AppLayout.tsx | 7 + packages/web/src/platform/activityBridge.ts | 30 +++ packages/web/src/platform/electron.d.ts | 4 + 7 files changed, 321 insertions(+) create mode 100644 packages/desktop/resources/games.json create mode 100644 packages/desktop/src/activityDetector.ts create mode 100644 packages/web/src/platform/activityBridge.ts diff --git a/packages/desktop/resources/games.json b/packages/desktop/resources/games.json new file mode 100644 index 00000000..a8a15dfd --- /dev/null +++ b/packages/desktop/resources/games.json @@ -0,0 +1,28 @@ +[ + { "id": "cs2", "name": "Counter-Strike 2", "processes": ["cs2.exe", "cs2"] }, + { "id": "valorant", "name": "Valorant", "processes": ["VALORANT-Win64-Shipping.exe", "VALORANT.exe"] }, + { "id": "minecraft", "name": "Minecraft", "processes": ["Minecraft.Windows.exe", "minecraft-launcher", "Minecraft"] }, + { "id": "fortnite", "name": "Fortnite", "processes": ["FortniteClient-Win64-Shipping.exe"] }, + { "id": "lol", "name": "League of Legends", "processes": ["League of Legends.exe", "LeagueClient.exe", "LeagueofLegends"] }, + { "id": "overwatch", "name": "Overwatch 2", "processes": ["Overwatch.exe"] }, + { "id": "apex", "name": "Apex Legends", "processes": ["r5apex.exe"] }, + { "id": "rocketleague", "name": "Rocket League", "processes": ["RocketLeague.exe", "RocketLeague"] }, + { "id": "gta5", "name": "Grand Theft Auto V", "processes": ["GTA5.exe", "PlayGTAV.exe"] }, + { "id": "rdr2", "name": "Red Dead Redemption 2", "processes": ["RDR2.exe"] }, + { "id": "dota2", "name": "Dota 2", "processes": ["dota2.exe", "dota2"] }, + { "id": "tf2", "name": "Team Fortress 2", "processes": ["tf2_linux", "tf2_osx", "tf_win64.exe"] }, + { "id": "rust", "name": "Rust", "processes": ["RustClient.exe", "rust"] }, + { "id": "terraria", "name": "Terraria", "processes": ["Terraria.exe", "Terraria"] }, + { "id": "among-us", "name": "Among Us", "processes": ["Among Us.exe"] }, + { "id": "wow", "name": "World of Warcraft", "processes": ["Wow.exe", "WowClassic.exe", "World of Warcraft"] }, + { "id": "diablo4", "name": "Diablo IV", "processes": ["Diablo IV.exe"] }, + { "id": "elden-ring", "name": "Elden Ring", "processes": ["eldenring.exe"] }, + { "id": "hollow-knight", "name": "Hollow Knight", "processes": ["hollow_knight.exe", "Hollow Knight"] }, + { "id": "celeste", "name": "Celeste", "processes": ["Celeste.exe", "Celeste"] }, + { "id": "stardew", "name": "Stardew Valley", "processes": ["Stardew Valley.exe", "StardewValley"] }, + { "id": "factorio", "name": "Factorio", "processes": ["factorio.exe", "factorio"] }, + { "id": "rimworld", "name": "RimWorld", "processes": ["RimWorldWin64.exe", "RimWorld"] }, + { "id": "spotify", "name": "Spotify", "processes": ["Spotify.exe", "Spotify", "spotify"], "type": "listening" }, + { "id": "vlc", "name": "VLC Media Player", "processes": ["vlc.exe", "vlc", "VLC"], "type": "watching" }, + { "id": "obs", "name": "OBS Studio", "processes": ["obs64.exe", "obs", "OBS"], "type": "streaming" } +] diff --git a/packages/desktop/src/activityDetector.ts b/packages/desktop/src/activityDetector.ts new file mode 100644 index 00000000..0fe1de4e --- /dev/null +++ b/packages/desktop/src/activityDetector.ts @@ -0,0 +1,235 @@ +import { execFile } from 'child_process'; +import path from 'path'; +import fs from 'fs'; + +// ─── Local Activity type (structural match with @backspace/shared Activity) ─ + +interface ActivityTimestamps { + start?: number; + end?: number; +} + +interface Activity { + type: string; + name: string; + details?: string; + state?: string; + timestamps?: ActivityTimestamps; +} + +// ─── Game dictionary types ────────────────────────────────────────────────── + +interface GameEntry { + id: string; + name: string; + processes: string[]; + type?: string; +} + +const VALID_TYPES = new Set(['playing', 'listening', 'watching', 'streaming']); +const POLL_INTERVAL_MS = 15_000; + +// ─── Module state ─────────────────────────────────────────────────────────── + +let processMap: Map = new Map(); +let gameEntries: GameEntry[] = []; +let currentGameId: string | null = null; +let currentActivity: Activity | null = null; +let intervalId: NodeJS.Timeout | null = null; +let isPolling = false; +let hasErrored = false; +let onChangeCallback: ((activity: Activity | null) => void) | null = null; + +// ─── Dictionary loading ──────────────────────────────────────────────────── + +function loadDictionary(): boolean { + const dictPath = path.join(__dirname, '..', 'resources', 'games.json'); + + let raw: string; + try { + raw = fs.readFileSync(dictPath, 'utf-8'); + } catch { + console.warn('[ActivityDetector] games.json not found at', dictPath, '— detection disabled'); + return false; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + console.warn('[ActivityDetector] games.json contains malformed JSON — detection disabled'); + return false; + } + + if (!Array.isArray(parsed)) { + console.warn('[ActivityDetector] games.json root must be an array — detection disabled'); + return false; + } + + gameEntries = []; + processMap = new Map(); + + for (const entry of parsed) { + if (!entry || typeof entry !== 'object') continue; + const e = entry as Record; + + if (typeof e.id !== 'string' || !e.id) continue; + if (typeof e.name !== 'string' || !e.name) continue; + if (!Array.isArray(e.processes) || e.processes.length === 0) continue; + if (!e.processes.every((p: unknown) => typeof p === 'string')) 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); + + for (const proc of gameEntry.processes) { + const key = proc.toLowerCase(); + if (!processMap.has(key)) { + processMap.set(key, gameEntry); + } + } + } + + if (processMap.size === 0) { + console.warn('[ActivityDetector] No valid entries in games.json — detection disabled'); + return false; + } + + console.log(`[ActivityDetector] Loaded ${gameEntries.length} games (${processMap.size} process names)`); + return true; +} + +// ─── Platform-specific process listing ────────────────────────────────────── + +function getProcessCommand(): { executable: string; args: string[] } { + if (process.platform === 'win32') { + return { executable: 'tasklist', args: ['/fo', 'csv', '/nh'] }; + } + if (process.platform === 'darwin') { + return { executable: 'ps', args: ['-c', '-A', '-o', 'comm'] }; + } + // Linux and other Unix + return { executable: 'ps', args: ['-A', '-o', 'comm'] }; +} + +function parseProcessList(stdout: string): Set { + const names = new Set(); + + if (process.platform === 'win32') { + // Windows tasklist CSV: "ImageName","PID","SessionName","Session#","MemUsage" + for (const line of stdout.split('\n')) { + const match = line.match(/^"([^"]+)"/); + if (match && match[1]) { + names.add(match[1].toLowerCase()); + } + } + } else { + // macOS/Linux: one process name per line, first line may be header + const lines = stdout.split('\n'); + for (let i = 0; i < lines.length; i++) { + const name = lines[i]?.trim(); + if (!name) continue; + // Skip header line (COMM or COMMAND) + if (i === 0 && (name === 'COMM' || name === 'COMMAND')) continue; + names.add(name.toLowerCase()); + } + } + + return names; +} + +// ─── Poll logic ───────────────────────────────────────────────────────────── + +function poll(): void { + if (isPolling) return; // Previous poll still in-flight + isPolling = true; + + const { executable, args } = getProcessCommand(); + + execFile(executable, args, { maxBuffer: 1024 * 1024 }, (error, stdout) => { + isPolling = false; + + if (error) { + if (!hasErrored) { + console.warn('[ActivityDetector] execFile failed:', error.message, '— stopping detection'); + hasErrored = true; + stopActivityDetection(); + } + return; + } + + const runningProcesses = parseProcessList(stdout); + + // Find first matching game (dictionary order = priority) + let matchedEntry: GameEntry | null = null; + for (const entry of gameEntries) { + for (const proc of entry.processes) { + if (runningProcesses.has(proc.toLowerCase())) { + matchedEntry = entry; + break; + } + } + if (matchedEntry) break; + } + + if (matchedEntry) { + if (matchedEntry.id !== currentGameId) { + // New game detected (or game changed) + currentGameId = matchedEntry.id; + currentActivity = { + type: matchedEntry.type ?? 'playing', + name: matchedEntry.name, + timestamps: { start: Date.now() }, + }; + onChangeCallback?.(currentActivity); + } + // Same game still running — no change, skip IPC + } else { + if (currentGameId !== null) { + // Game exited + currentGameId = null; + currentActivity = null; + onChangeCallback?.(null); + } + // No game was running before either — skip + } + }); +} + +// ─── Public API ───────────────────────────────────────────────────────────── + +export function startActivityDetection( + onActivityChange: (activity: Activity | null) => void, +): void { + if (intervalId) return; // Already running + + if (!loadDictionary()) return; // Dictionary failed to load + + onChangeCallback = onActivityChange; + hasErrored = false; + + // Run first poll immediately + poll(); + + // Then poll every 15 seconds + intervalId = setInterval(poll, POLL_INTERVAL_MS); +} + +export function stopActivityDetection(): void { + if (intervalId) { + clearInterval(intervalId); + intervalId = null; + } + onChangeCallback = null; +} + +export function getCurrentActivity(): Activity | null { + return currentActivity; +} diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index fda1e7e2..430f5640 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -13,6 +13,7 @@ import { } from 'electron'; import path from 'path'; import fs from 'fs'; +import { startActivityDetection, stopActivityDetection, getCurrentActivity } from './activityDetector'; let mainWindow: BrowserWindow | null = null; let tray: Tray | null = null; @@ -774,6 +775,13 @@ if (!gotTheLock) { createTray(); initAutoUpdater(); + // ─── Activity Detection ──────────────────────────────────────────────── + startActivityDetection((activity) => { + mainWindow?.webContents.send('activity-detected', activity); + }); + + ipcMain.handle('get-current-activity', () => getCurrentActivity()); + // Sync auto-launch settings with OS on startup (refreshes login item path for AppImage updates) const autoLaunchSettings = loadAutoLaunchSettings(); applyLoginItemSettings(autoLaunchSettings.openAtLogin, autoLaunchSettings.startMinimized); @@ -801,5 +809,6 @@ if (!gotTheLock) { app.on('before-quit', () => { isQuitting = true; + stopActivityDetection(); }); } diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index 7613ccd9..e186af24 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -68,4 +68,12 @@ contextBridge.exposeInMainWorld('backspace', { getAutoLaunchSettings: () => ipcRenderer.invoke('get-auto-launch-settings'), setAutoLaunchSettings: (settings: { openAtLogin?: boolean; startMinimized?: boolean }) => ipcRenderer.invoke('set-auto-launch-settings', settings), + + // Activity detection (game/app process scanning) + onActivityDetected: (callback: (activity: unknown) => void) => { + const handler = (_event: Electron.IpcRendererEvent, activity: unknown) => callback(activity); + ipcRenderer.on('activity-detected', handler); + return () => { ipcRenderer.removeListener('activity-detected', handler); }; + }, + getCurrentActivity: () => ipcRenderer.invoke('get-current-activity'), }); diff --git a/packages/web/src/components/layout/AppLayout.tsx b/packages/web/src/components/layout/AppLayout.tsx index b1cec3ee..8510faef 100644 --- a/packages/web/src/components/layout/AppLayout.tsx +++ b/packages/web/src/components/layout/AppLayout.tsx @@ -31,6 +31,7 @@ import { useWebSocket } from '../../hooks/useWebSocket'; import { useFederationToasts } from '../../hooks/useFederationToasts'; import { useLiveKit } from '../../hooks/useLiveKit'; import { useDeepLinkHandler } from '../../platform/deepLink'; +import { initActivityBridge, teardownActivityBridge } from '../../platform/activityBridge'; import { useSpaceStore } from '../../stores/spaceStore'; import { useChatStore } from '../../stores/chatStore'; import { useUIStore } from '../../stores/uiStore'; @@ -122,6 +123,12 @@ export function AppLayout() { // Deep link handler for Electron (backspace:// protocol) useDeepLinkHandler(); + // Electron activity detection bridge (game/app process scanning → activityStore) + useEffect(() => { + initActivityBridge(); + return () => teardownActivityBridge(); + }, []); + // Track the last channel we attempted to connect to, to prevent effect loops const lastAttemptedRef = React.useRef(null); diff --git a/packages/web/src/platform/activityBridge.ts b/packages/web/src/platform/activityBridge.ts new file mode 100644 index 00000000..08560434 --- /dev/null +++ b/packages/web/src/platform/activityBridge.ts @@ -0,0 +1,30 @@ +import type { Activity } from '@backspace/shared'; +import { useActivityStore } from '../stores/activityStore'; + +let unsubscribe: (() => void) | null = null; + +export function initActivityBridge(): void { + if (unsubscribe) return; // already initialized + if (!window.backspace?.onActivityDetected) return; // not Electron + + // Subscribe to future activity changes from main process + unsubscribe = window.backspace.onActivityDetected((activity) => { + if (activity) { + useActivityStore.getState().pushActivities([activity as Activity]); + } else { + useActivityStore.getState().pushActivities([]); + } + }); + + // Request current state (handles instance-switch: game was already running) + window.backspace.getCurrentActivity?.().then((activity: unknown) => { + if (activity) { + useActivityStore.getState().pushActivities([activity as Activity]); + } + }).catch(() => {}); +} + +export function teardownActivityBridge(): void { + unsubscribe?.(); + unsubscribe = null; +} diff --git a/packages/web/src/platform/electron.d.ts b/packages/web/src/platform/electron.d.ts index a06e9c03..9788d6cc 100644 --- a/packages/web/src/platform/electron.d.ts +++ b/packages/web/src/platform/electron.d.ts @@ -48,6 +48,10 @@ interface BackspaceElectronAPI { getAutoLaunchSettings: () => Promise<{ openAtLogin: boolean; startMinimized: boolean }>; setAutoLaunchSettings: (settings: { openAtLogin?: boolean; startMinimized?: boolean }) => Promise<{ openAtLogin: boolean; startMinimized: boolean }>; + + // Activity detection (game/app process scanning) + onActivityDetected: (callback: (activity: unknown) => void) => (() => void); + getCurrentActivity: () => Promise; } interface Window {