feat: Add game activity status (Experimental)
All checks were successful
Queue Release Build / prepare (push) Successful in 23s
Deploy Web Apps / deploy (push) Successful in 5m54s
Queue Release Build / build-windows (push) Successful in 16m19s
Queue Release Build / build-linux (push) Successful in 30m13s
Queue Release Build / finalize (push) Successful in 47s

This commit is contained in:
2026-04-27 05:46:33 +02:00
parent 3858beb28e
commit 53389ed3ad
50 changed files with 2007 additions and 104 deletions

View File

@@ -0,0 +1,261 @@
import {
Injector,
NgZone,
runInInjectionContext,
signal
} from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Store } from '@ngrx/store';
import { Subject, of } 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,
GameMatchResponse,
MatchedGame,
User
} from '../../../shared-kernel';
import { GameActivityService } from './game-activity.service';
const alice = createUser('alice-id', 'alice-oder', 'Alice');
const bob = createUser('bob-id', 'bob-oder', 'Bob');
const carol = createUser('carol-id', 'carol-oder', 'Carol');
let contexts: ServiceContext[] = [];
describe('GameActivityService sync', () => {
beforeEach(() => {
contexts = [];
installLocalStorageMock();
});
afterEach(() => {
for (const context of contexts) {
context.service.ngOnDestroy();
}
});
it('subscribes to incoming activity on browser clients without local process scanning', () => {
const context = createServiceContext({
currentUser: bob,
allUsers: [alice, bob],
electronApi: null
});
context.service.start();
context.incomingMessages.next({
type: 'game-activity',
fromPeerId: alice.oderId,
oderId: alice.oderId,
displayName: alice.displayName,
gameActivity: createActivity('game-1', 'Deep Rock Galactic')
} as ChatEvent);
expect(context.store.dispatch).toHaveBeenCalledWith(UsersActions.updateGameActivity({
userId: alice.id,
gameActivity: createActivity('game-1', 'Deep Rock Galactic')
}));
});
it('broadcasts local activity changes to peers already online', async () => {
const matchedGame = createMatchedGame('game-2', 'Stardew Valley', 'StardewValley.exe');
const context = createServiceContext({
currentUser: alice,
allUsers: [alice, bob],
processNames: ['StardewValley.exe'],
gameMatchResponse: { games: [matchedGame] }
});
context.service.start();
await vi.waitFor(() => expect(context.realtime.broadcastMessage).toHaveBeenCalledWith(expect.objectContaining({
type: 'game-activity',
oderId: alice.oderId,
displayName: alice.displayName,
gameActivity: expect.objectContaining({
id: matchedGame.id,
name: matchedGame.name,
iconUrl: matchedGame.iconUrl,
store: matchedGame.store
})
})));
});
it('sends current activity directly to peers that connect after the status was set', async () => {
const matchedGame = createMatchedGame('game-3', 'Hades', 'Hades.exe');
const context = createServiceContext({
currentUser: alice,
allUsers: [
alice,
bob,
carol
],
processNames: ['Hades.exe'],
gameMatchResponse: { games: [matchedGame] }
});
context.service.start();
await vi.waitFor(() => expect(context.realtime.broadcastMessage).toHaveBeenCalled());
context.realtime.sendToPeer.mockClear();
context.peerConnected.next(carol.oderId);
expect(context.realtime.sendToPeer).toHaveBeenCalledWith(carol.oderId, expect.objectContaining({
type: 'game-activity',
oderId: alice.oderId,
displayName: alice.displayName,
gameActivity: expect.objectContaining({
id: matchedGame.id,
name: matchedGame.name,
iconUrl: matchedGame.iconUrl,
store: matchedGame.store
})
}));
});
});
interface ServiceContextOptions {
currentUser: User;
allUsers: User[];
electronApi?: { getRunningProcessNames: () => Promise<string[]> } | null;
processNames?: string[];
gameMatchResponse?: GameMatchResponse;
}
interface ServiceContext {
incomingMessages: Subject<ChatEvent>;
peerConnected: Subject<string>;
realtime: {
broadcastMessage: ReturnType<typeof vi.fn>;
sendToPeer: ReturnType<typeof vi.fn>;
};
service: GameActivityService;
store: {
dispatch: ReturnType<typeof vi.fn>;
};
}
function createServiceContext(options: ServiceContextOptions): ServiceContext {
const currentUser = signal<User | null>(options.currentUser);
const allUsers = signal<User[]>(options.allUsers);
const incomingMessages = new Subject<ChatEvent>();
const peerConnected = new Subject<string>();
const realtime = {
onMessageReceived: incomingMessages.asObservable(),
onPeerConnected: peerConnected.asObservable(),
broadcastMessage: vi.fn(),
sendToPeer: vi.fn()
};
const store = {
dispatch: vi.fn(),
selectSignal: vi.fn((selector: unknown) => {
if (selector === selectCurrentUser) {
return currentUser;
}
if (selector === selectAllUsers) {
return allUsers;
}
throw new Error('Unexpected selector requested by GameActivityService test.');
})
};
const electronApi = options.electronApi === undefined
? { getRunningProcessNames: vi.fn(async () => options.processNames ?? []) }
: options.electronApi;
const injector = Injector.create({
providers: [
{
provide: ElectronBridgeService,
useValue: { getApi: () => electronApi }
},
{
provide: HttpClient,
useValue: {
post: vi.fn(() => of(options.gameMatchResponse ?? { games: [] }))
}
},
{
provide: NgZone,
useValue: {
run: (fn: () => void) => fn(),
runOutsideAngular: (fn: () => void) => fn()
}
},
{
provide: RealtimeSessionFacade,
useValue: realtime
},
{
provide: ServerDirectoryFacade,
useValue: { getApiBaseUrl: () => 'http://localhost:3001/api' }
},
{
provide: Store,
useValue: store
}
]
});
const service = runInInjectionContext(injector, () => new GameActivityService());
const context = {
incomingMessages,
peerConnected,
realtime,
service,
store
};
contexts.push(context);
return context;
}
function createUser(id: string, oderId: string, displayName: string): User {
return {
id,
oderId,
username: displayName.toLowerCase(),
displayName,
status: 'online',
role: 'member',
joinedAt: 1
};
}
function createActivity(id: string, name: string): GameActivity {
return {
id,
name,
startedAt: 1_000
};
}
function createMatchedGame(id: string, name: string, processName: string): MatchedGame {
return {
id,
name,
iconUrl: `https://img.example.test/${id}.jpg`,
store: {
name: 'Steam',
slug: 'steam',
url: `https://store.steampowered.com/search/?term=${encodeURIComponent(name)}`
},
processName
};
}
function installLocalStorageMock(): void {
const values = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
clear: () => values.clear()
});
}

View File

@@ -0,0 +1,581 @@
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 {
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);
}
}

View File

@@ -0,0 +1,14 @@
export function formatGameActivityElapsed(startedAt: number, now = Date.now()): string {
const elapsedSeconds = Math.max(0, Math.floor((now - startedAt) / 1000));
const hours = Math.floor(elapsedSeconds / 3600);
const minutes = Math.floor((elapsedSeconds % 3600) / 60);
const seconds = elapsedSeconds % 60;
return [
hours,
minutes,
seconds
]
.map((value) => value.toString().padStart(2, '0'))
.join(':');
}

View File

@@ -0,0 +1,3 @@
import type { GameActivity } from '../../../shared-kernel';
export type CurrentGameActivity = GameActivity | null;

View File

@@ -0,0 +1,3 @@
export * from './application/game-activity.service';
export * from './domain/game-activity.models';
export * from './domain/game-activity-time';