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';
|
} from 'electron';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import { startActivityDetection, stopActivityDetection, getCurrentActivity } from './activityDetector';
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
let tray: Tray | null = null;
|
let tray: Tray | null = null;
|
||||||
@@ -774,6 +775,13 @@ if (!gotTheLock) {
|
|||||||
createTray();
|
createTray();
|
||||||
initAutoUpdater();
|
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)
|
// Sync auto-launch settings with OS on startup (refreshes login item path for AppImage updates)
|
||||||
const autoLaunchSettings = loadAutoLaunchSettings();
|
const autoLaunchSettings = loadAutoLaunchSettings();
|
||||||
applyLoginItemSettings(autoLaunchSettings.openAtLogin, autoLaunchSettings.startMinimized);
|
applyLoginItemSettings(autoLaunchSettings.openAtLogin, autoLaunchSettings.startMinimized);
|
||||||
@@ -801,5 +809,6 @@ if (!gotTheLock) {
|
|||||||
|
|
||||||
app.on('before-quit', () => {
|
app.on('before-quit', () => {
|
||||||
isQuitting = true;
|
isQuitting = true;
|
||||||
|
stopActivityDetection();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,4 +68,12 @@ contextBridge.exposeInMainWorld('backspace', {
|
|||||||
getAutoLaunchSettings: () => ipcRenderer.invoke('get-auto-launch-settings'),
|
getAutoLaunchSettings: () => ipcRenderer.invoke('get-auto-launch-settings'),
|
||||||
setAutoLaunchSettings: (settings: { openAtLogin?: boolean; startMinimized?: boolean }) =>
|
setAutoLaunchSettings: (settings: { openAtLogin?: boolean; startMinimized?: boolean }) =>
|
||||||
ipcRenderer.invoke('set-auto-launch-settings', settings),
|
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 { useFederationToasts } from '../../hooks/useFederationToasts';
|
||||||
import { useLiveKit } from '../../hooks/useLiveKit';
|
import { useLiveKit } from '../../hooks/useLiveKit';
|
||||||
import { useDeepLinkHandler } from '../../platform/deepLink';
|
import { useDeepLinkHandler } from '../../platform/deepLink';
|
||||||
|
import { initActivityBridge, teardownActivityBridge } from '../../platform/activityBridge';
|
||||||
import { useSpaceStore } from '../../stores/spaceStore';
|
import { useSpaceStore } from '../../stores/spaceStore';
|
||||||
import { useChatStore } from '../../stores/chatStore';
|
import { useChatStore } from '../../stores/chatStore';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
@@ -122,6 +123,12 @@ export function AppLayout() {
|
|||||||
// Deep link handler for Electron (backspace:// protocol)
|
// Deep link handler for Electron (backspace:// protocol)
|
||||||
useDeepLinkHandler();
|
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
|
// Track the last channel we attempted to connect to, to prevent effect loops
|
||||||
const lastAttemptedRef = React.useRef<string | null>(null);
|
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 }>;
|
getAutoLaunchSettings: () => Promise<{ openAtLogin: boolean; startMinimized: boolean }>;
|
||||||
setAutoLaunchSettings: (settings: { openAtLogin?: boolean; startMinimized?: boolean }) =>
|
setAutoLaunchSettings: (settings: { openAtLogin?: boolean; startMinimized?: boolean }) =>
|
||||||
Promise<{ 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 {
|
interface Window {
|
||||||
|
|||||||
Reference in New Issue
Block a user