import { Injectable, NgZone, OnDestroy, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Store } from '@ngrx/store'; import { Subscription, firstValueFrom } from 'rxjs'; import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service'; import { RealtimeSessionFacade } from '../../../core/realtime'; import { ServerDirectoryFacade } from '../../server-directory'; import { UsersActions } from '../../../store/users/users.actions'; import { selectAllUsers, selectCurrentUser } from '../../../store/users/users.selectors'; import type { ChatEvent, GameActivity, GameStoreLink, GameMatchResponse, MatchedGame, User } from '../../../shared-kernel'; const DEFAULT_SCAN_INTERVAL_MS = 10_000; const MIN_SCAN_INTERVAL_MS = 5_000; const MAX_SCAN_INTERVAL_MS = 60_000; const MAX_PROCESS_NAMES_PER_REQUEST = 256; const MAX_CANDIDATE_PROCESSES_PER_REQUEST = 12; const POSITIVE_CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000; const NEGATIVE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; const MAX_LOCAL_CACHE_ENTRIES = 128; const SCAN_INTERVAL_STORAGE_KEY = 'metoyou_game_scan_interval_ms'; const GAME_MATCH_CACHE_STORAGE_KEY = 'metoyou_game_match_cache_v1'; interface CachedGameMatch { expiresAt: number; game: MatchedGame | null; } interface CandidateProcess { processName: string; score: number; } const IGNORED_PROCESS_NAMES = new Set([ 'agent', 'bash', 'baloorunner', 'chrome', 'code', 'conhost', 'cursor', 'csrss', 'dbus daemon', 'discord', 'dwm', 'electron', 'explorer', 'firefox', 'gameoverlayui', 'gamemoded', 'gamescopereaper', 'gnome shell', 'metoyou', 'node', 'npm', 'powershell', 'pulseaudio', 'steam', 'steamwebhelper', 'systemd', 'taskhostw', 'wininit', 'winlogon', 'xorg' ]); const IGNORED_PROCESS_PATTERNS = [ new RegExp('(^|\\s)(agent|browser|daemon|desktop|helper|indexer|launcher|monitor|renderer|runner)(\\s|$)'), new RegExp('(^|\\s)(service|settings|shell|tray|updater|utility|watcher|worker)(\\s|$)'), new RegExp('(^|\\s)(audio|bluetooth|clipboard|crash|dbus|file|gpu|input|network|notification)(\\s|$)'), new RegExp('(^|\\s)(portal|proxy|screen|session|sync|system|tracker|web|window)(\\s|$)'), /^(appimage|at spi|baloo|dconf|gvfs|ibus|kde|kworker)/, /^(pipewire|plasmashell|pulseaudio|xdg|xwayland|zeitgeist)/, /(helper|service|daemon|runner|tracker|portal|updater|worker)$/ ]; @Injectable({ providedIn: 'root' }) export class GameActivityService implements OnDestroy { private readonly electron = inject(ElectronBridgeService); private readonly http = inject(HttpClient); private readonly ngZone = inject(NgZone); private readonly serverDirectory = inject(ServerDirectoryFacade); private readonly store = inject(Store); private readonly webrtc = inject(RealtimeSessionFacade); private readonly currentUser = this.store.selectSignal(selectCurrentUser); private readonly allUsers = this.store.selectSignal(selectAllUsers); private readonly subscriptions = new Subscription(); private scanTimer: ReturnType | null = null; private lastProcessHash = ''; private currentActivity: GameActivity | null = null; private scanInFlight = false; private started = false; start(): void { if (this.started) { return; } this.started = true; this.subscriptions.add( this.webrtc.onMessageReceived.subscribe((event) => this.handlePeerEvent(event)) ); this.subscriptions.add( this.webrtc.onPeerConnected.subscribe((peerId) => this.sendCurrentActivityToPeer(peerId)) ); const api = this.electron.getApi(); if (!api?.getRunningProcessNames) { return; } this.ngZone.runOutsideAngular(() => { this.scanTimer = setInterval(() => { void this.scanRunningProcesses(); }, this.getScanIntervalMs()); }); void this.scanRunningProcesses(); } ngOnDestroy(): void { this.stop(); } private stop(): void { if (this.scanTimer) { clearInterval(this.scanTimer); this.scanTimer = null; } this.subscriptions.unsubscribe(); this.started = false; } private async scanRunningProcesses(): Promise { if (this.scanInFlight || !this.currentUser()) { return; } const api = this.electron.getApi(); if (!api?.getRunningProcessNames) { return; } this.scanInFlight = true; try { const processNames = (await api.getRunningProcessNames()).slice(0, MAX_PROCESS_NAMES_PER_REQUEST); const processHash = this.buildProcessHash(processNames); if (processHash === this.lastProcessHash) { return; } this.lastProcessHash = processHash; const matchedGame = await this.matchRunningGame(processNames); this.ngZone.run(() => this.applyMatchedGame(matchedGame)); } catch (error) { console.warn('[GameActivity] Failed to scan running processes', error); return; } finally { this.scanInFlight = false; } } private async matchRunningGame(processes: string[]): Promise { const candidates = this.selectCandidateProcesses(processes); const cachedGame = this.findCachedGame(candidates); if (cachedGame !== undefined) { return cachedGame; } const unknownCandidates = candidates .filter((candidate) => !this.hasFreshCacheEntry(candidate.processName)) .slice(0, MAX_CANDIDATE_PROCESSES_PER_REQUEST); if (unknownCandidates.length === 0) { return null; } const apiBase = this.serverDirectory.getApiBaseUrl(); const currentUser = this.currentUser(); const response = await firstValueFrom( this.http.post(`${apiBase}/games/match`, { processes: unknownCandidates.map((candidate) => candidate.processName), userId: currentUser?.id ?? currentUser?.oderId }) ); this.storeMatchResponse(unknownCandidates, response); return response.games[0] ?? null; } private selectCandidateProcesses(processes: string[]): CandidateProcess[] { const candidates = new Map(); for (const processName of processes.slice(0, MAX_PROCESS_NAMES_PER_REQUEST)) { const normalized = this.normalizeProcessName(processName); if (!normalized) { continue; } const cacheKey = this.normalizeCacheKey(normalized); const existing = candidates.get(cacheKey); const candidate = { processName, score: this.scoreCandidateProcess(processName, normalized) }; if (!existing || candidate.score > existing.score) { candidates.set(cacheKey, candidate); } } return Array.from(candidates.values()) .sort((left, right) => right.score - left.score || left.processName.localeCompare(right.processName)); } private normalizeProcessName(value: string): string { const normalized = value.trim() .replace(/\.exe$/i, '') .replace(/[_-]+/g, ' ') .replace(/\s+/g, ' ') .trim(); const cacheKey = this.normalizeCacheKey(normalized); if (normalized.length < 4 || normalized.length > 96 || this.shouldIgnoreProcessName(cacheKey)) { return ''; } return normalized; } private shouldIgnoreProcessName(cacheKey: string): boolean { return IGNORED_PROCESS_NAMES.has(cacheKey) || IGNORED_PROCESS_PATTERNS.some((pattern) => pattern.test(cacheKey)); } private scoreCandidateProcess(rawValue: string, normalized: string): number { let score = 0; if (/\.exe$/i.test(rawValue.trim())) { score += 12; } if (/[A-Z]/.test(normalized) && /[a-z]/.test(normalized)) { score += 4; } if (/\d/.test(normalized)) { score += 1; } if (normalized.length >= 5 && normalized.length <= 32) { score += 2; } if (normalized.includes(' ')) { score -= 2; } return score; } private findCachedGame(candidates: CandidateProcess[]): MatchedGame | null | undefined { if (candidates.length === 0) { return null; } let hasCachedMissForEveryCandidate = true; for (const candidate of candidates) { const cached = this.getCachedMatch(candidate.processName); if (cached === undefined) { hasCachedMissForEveryCandidate = false; continue; } if (cached) { return cached; } } return hasCachedMissForEveryCandidate ? null : undefined; } private storeMatchResponse(candidates: CandidateProcess[], response: GameMatchResponse): void { for (const game of response.games) { this.setCachedMatch(game.processName, game, POSITIVE_CACHE_TTL_MS); } if (response.rateLimited) { return; } const matchedProcessKeys = new Set(response.games.map((game) => this.normalizeCacheKey(game.processName))); for (const candidate of candidates) { if (!matchedProcessKeys.has(this.normalizeCacheKey(candidate.processName))) { this.setCachedMatch(candidate.processName, null, NEGATIVE_CACHE_TTL_MS); } } } private hasFreshCacheEntry(processName: string): boolean { return this.getCachedMatch(processName) !== undefined; } private getCachedMatch(processName: string): MatchedGame | null | undefined { const cache = this.readMatchCache(); const cacheKey = this.normalizeCacheKey(processName); const cached = cache[cacheKey]; if (!cached) { return undefined; } if (cached.expiresAt <= Date.now()) { this.writeMatchCache(Object.fromEntries( Object.entries(cache).filter(([key]) => key !== cacheKey) )); return undefined; } return cached.game; } private setCachedMatch(processName: string, game: MatchedGame | null, ttlMs: number): void { const cache = this.readMatchCache(); cache[this.normalizeCacheKey(processName)] = { expiresAt: Date.now() + ttlMs, game }; this.writeMatchCache(cache); } private readMatchCache(): Record { try { const parsed = JSON.parse(localStorage.getItem(GAME_MATCH_CACHE_STORAGE_KEY) ?? '{}') as unknown; return this.normalizeMatchCache(parsed); } catch { return {}; } } private normalizeMatchCache(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) { return {}; } const cache: Record = {}; for (const [key, entry] of Object.entries(value)) { if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { continue; } const cached = entry as Partial; if (typeof cached.expiresAt === 'number') { cache[key] = { expiresAt: cached.expiresAt, game: this.normalizeCachedGame(cached.game) }; } } return cache; } private normalizeCachedGame(value: unknown): MatchedGame | null { if (!value || typeof value !== 'object' || Array.isArray(value)) { return null; } const game = value as Partial; if (typeof game.id !== 'string' || typeof game.name !== 'string' || typeof game.processName !== 'string') { return null; } return { id: game.id, name: game.name, iconUrl: typeof game.iconUrl === 'string' ? game.iconUrl : undefined, store: this.normalizeGameStore(game.store), processName: game.processName }; } private normalizeGameStore(value: unknown): GameStoreLink | undefined { if (!value || typeof value !== 'object' || Array.isArray(value)) { return undefined; } const store = value as Partial; if (typeof store.name !== 'string' || typeof store.url !== 'string' || !this.isExternalUrl(store.url)) { return undefined; } return { id: typeof store.id === 'string' ? store.id : undefined, name: store.name, slug: typeof store.slug === 'string' ? store.slug : undefined, domain: typeof store.domain === 'string' ? store.domain : undefined, url: store.url }; } private writeMatchCache(cache: Record): void { const entries = Object.entries(cache) .filter(([, entry]) => entry.expiresAt > Date.now()) .sort((left, right) => right[1].expiresAt - left[1].expiresAt) .slice(0, MAX_LOCAL_CACHE_ENTRIES); localStorage.setItem(GAME_MATCH_CACHE_STORAGE_KEY, JSON.stringify(Object.fromEntries(entries))); } private normalizeCacheKey(value: string): string { return value.trim() .replace(/\.exe$/i, '') .replace(/[_-]+/g, ' ') .replace(/\s+/g, ' ') .toLowerCase(); } private applyMatchedGame(game: MatchedGame | null): void { if (!game) { this.setCurrentActivity(null); return; } const previous = this.currentActivity; const activity: GameActivity = { id: game.id, name: game.name, iconUrl: game.iconUrl, store: game.store, startedAt: previous?.id === game.id ? previous.startedAt : Date.now() }; this.setCurrentActivity(activity); } private setCurrentActivity(activity: GameActivity | null): void { if (this.isSameActivity(this.currentActivity, activity)) { return; } this.currentActivity = activity; const user = this.currentUser(); if (user) { this.store.dispatch(UsersActions.updateGameActivity({ userId: user.id, gameActivity: activity })); } this.webrtc.broadcastMessage({ type: 'game-activity', oderId: user?.oderId || user?.id, displayName: user?.displayName || 'User', gameActivity: activity }); } private handlePeerEvent(event: ChatEvent): void { if (event.type !== 'game-activity') { return; } const peerIdentifier = event.fromPeerId ?? event.oderId; if (!peerIdentifier) { return; } const currentUser = this.currentUser(); if (peerIdentifier === currentUser?.id || peerIdentifier === currentUser?.oderId) { return; } const user = this.findUser(peerIdentifier); if (!user) { return; } this.store.dispatch(UsersActions.updateGameActivity({ userId: user.id, gameActivity: this.normalizeIncomingActivity(event.gameActivity) })); } private sendCurrentActivityToPeer(peerId: string): void { const user = this.currentUser(); if (!user) { return; } this.webrtc.sendToPeer(peerId, { type: 'game-activity', oderId: user.oderId || user.id, displayName: user.displayName || 'User', gameActivity: this.currentActivity }); } private findUser(identifier: string): User | null { return this.allUsers().find((user) => user.id === identifier || user.oderId === identifier) ?? null; } private normalizeIncomingActivity(value: GameActivity | null | undefined): GameActivity | null { if (!value || typeof value.id !== 'string' || typeof value.name !== 'string' || typeof value.startedAt !== 'number') { return null; } return { id: value.id, name: value.name, iconUrl: typeof value.iconUrl === 'string' ? value.iconUrl : undefined, store: this.normalizeGameStore(value.store), startedAt: value.startedAt }; } private isSameActivity(previous: GameActivity | null, next: GameActivity | null): boolean { return previous?.id === next?.id && previous?.name === next?.name && previous?.iconUrl === next?.iconUrl && previous?.store?.url === next?.store?.url && previous?.startedAt === next?.startedAt; } private isExternalUrl(value: string): boolean { return value.startsWith('http://') || value.startsWith('https://'); } private buildProcessHash(processNames: string[]): string { return processNames.map((name) => name.trim().toLowerCase()) .sort() .join('|'); } private getScanIntervalMs(): number { const storedValue = Number.parseInt(localStorage.getItem(SCAN_INTERVAL_STORAGE_KEY) ?? '', 10); const interval = Number.isFinite(storedValue) ? storedValue : DEFAULT_SCAN_INTERVAL_MS; return Math.min(Math.max(interval, MIN_SCAN_INTERVAL_MS), MAX_SCAN_INTERVAL_MS); } }