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)
This commit is contained in:
@@ -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" }
|
||||
]
|
||||
@@ -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<string, GameEntry> = 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<string, unknown>;
|
||||
|
||||
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<string> {
|
||||
const names = new Set<string>();
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+4
@@ -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<unknown>;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
|
||||
Reference in New Issue
Block a user