feat: Add game activity status (Experimental)
All checks were successful
Queue Release Build / prepare (push) Successful in 21s
Deploy Web Apps / deploy (push) Successful in 5m14s
Queue Release Build / build-windows (push) Successful in 16m18s
Queue Release Build / build-linux (push) Successful in 29m20s
Queue Release Build / finalize (push) Successful in 36s
All checks were successful
Queue Release Build / prepare (push) Successful in 21s
Deploy Web Apps / deploy (push) Successful in 5m14s
Queue Release Build / build-windows (push) Successful in 16m18s
Queue Release Build / build-linux (push) Successful in 29m20s
Queue Release Build / finalize (push) Successful in 36s
This commit is contained in:
@@ -0,0 +1,582 @@
|
||||
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<typeof setInterval> | 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<void> {
|
||||
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<MatchedGame | null> {
|
||||
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<GameMatchResponse>(`${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<string, CandidateProcess>();
|
||||
|
||||
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<string, CachedGameMatch> {
|
||||
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<string, CachedGameMatch> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const cache: Record<string, CachedGameMatch> = {};
|
||||
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const cached = entry as Partial<CachedGameMatch>;
|
||||
|
||||
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<MatchedGame>;
|
||||
|
||||
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<GameStoreLink>;
|
||||
|
||||
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<string, CachedGameMatch>): 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user