From 53389ed3ad498c4af95e786be046fe5ca7d86d86 Mon Sep 17 00:00:00 2001 From: Myx Date: Mon, 27 Apr 2026 05:46:33 +0200 Subject: [PATCH] feat: Add game activity status (Experimental) --- e2e/pages/server-search.page.ts | 7 +- e2e/tests/chat/chat-message-features.spec.ts | 9 +- e2e/tests/chat/dm-flow.spec.ts | 17 +- e2e/tests/chat/notifications.spec.ts | 22 +- e2e/tests/chat/profile-avatar-sync.spec.ts | 5 +- e2e/tests/screen-share/screen-share.spec.ts | 7 +- .../settings/connectivity-warning.spec.ts | 12 +- .../settings/ice-server-settings.spec.ts | 5 +- e2e/tests/settings/stun-turn-fallback.spec.ts | 6 +- .../voice/mixed-signal-config-voice.spec.ts | 4 +- .../multi-signal-eight-user-voice.spec.ts | 4 +- e2e/tests/voice/voice-full-journey.spec.ts | 9 +- electron/README.md | 3 +- electron/ipc/system.ts | 3 + electron/preload.ts | 2 + electron/process-list.ts | 85 +++ server/README.md | 5 +- server/src/config/variables.ts | 15 + server/src/db/database.ts | 6 +- server/src/entities/GameMatchMissEntity.ts | 22 + server/src/entities/index.ts | 1 + .../1000000000006-GameMatchMisses.ts | 24 + server/src/migrations/index.ts | 4 +- server/src/routes/games.ts | 17 + server/src/routes/index.ts | 2 + server/src/services/game-matching.service.ts | 591 ++++++++++++++++++ server/src/websocket/handler.ts | 19 - toju-app/src/app/app.ts | 3 + .../platform/electron/electron-api.models.ts | 1 + toju-app/src/app/domains/README.md | 1 + .../application/game-activity.service.spec.ts | 261 ++++++++ .../application/game-activity.service.ts | 581 +++++++++++++++++ .../domain/game-activity-time.ts | 14 + .../domain/game-activity.models.ts | 3 + .../src/app/domains/game-activity/index.ts | 3 + .../server-endpoint-state.service.spec.ts | 8 + .../services/server-endpoint-state.service.ts | 27 +- .../services/server-directory-api.service.ts | 23 +- .../rooms-side-panel.component.html | 46 ++ .../rooms-side-panel.component.ts | 34 +- .../settings-modal.component.html | 8 +- toju-app/src/app/shared-kernel/README.md | 1 + toju-app/src/app/shared-kernel/chat-events.ts | 8 + .../app/shared-kernel/game-activity.models.ts | 28 + toju-app/src/app/shared-kernel/index.ts | 1 + toju-app/src/app/shared-kernel/user.models.ts | 2 + .../profile-card/profile-card.component.html | 88 +++ .../profile-card/profile-card.component.ts | 40 +- toju-app/src/app/store/users/users.actions.ts | 4 +- toju-app/src/app/store/users/users.reducer.ts | 20 +- 50 files changed, 2007 insertions(+), 104 deletions(-) create mode 100644 electron/process-list.ts create mode 100644 server/src/entities/GameMatchMissEntity.ts create mode 100644 server/src/migrations/1000000000006-GameMatchMisses.ts create mode 100644 server/src/routes/games.ts create mode 100644 server/src/services/game-matching.service.ts create mode 100644 toju-app/src/app/domains/game-activity/application/game-activity.service.spec.ts create mode 100644 toju-app/src/app/domains/game-activity/application/game-activity.service.ts create mode 100644 toju-app/src/app/domains/game-activity/domain/game-activity-time.ts create mode 100644 toju-app/src/app/domains/game-activity/domain/game-activity.models.ts create mode 100644 toju-app/src/app/domains/game-activity/index.ts create mode 100644 toju-app/src/app/shared-kernel/game-activity.models.ts diff --git a/e2e/pages/server-search.page.ts b/e2e/pages/server-search.page.ts index 1074905..82fadb1 100644 --- a/e2e/pages/server-search.page.ts +++ b/e2e/pages/server-search.page.ts @@ -80,6 +80,11 @@ export class ServerSearchPage { } async joinServerFromSearch(name: string) { - await this.page.locator('button', { hasText: name }).click(); + await this.searchInput.fill(name); + + const serverCard = this.page.locator('div[title]', { hasText: name }).first(); + + await expect(serverCard).toBeVisible({ timeout: 15_000 }); + await serverCard.dblclick(); } } diff --git a/e2e/tests/chat/chat-message-features.spec.ts b/e2e/tests/chat/chat-message-features.spec.ts index 548e25d..40ab2f6 100644 --- a/e2e/tests/chat/chat-message-features.spec.ts +++ b/e2e/tests/chat/chat-message-features.spec.ts @@ -44,9 +44,11 @@ test.describe('Chat messaging features', () => { await test.step('Opening first server once restores only its channels', async () => { await openSavedRoomByName(scenario.client.page, alphaServerName); + await expect( channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${alphaChannelName}"]`) ).toBeVisible({ timeout: 20_000 }); + await expect( channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${betaChannelName}"]`) ).toHaveCount(0); @@ -54,9 +56,11 @@ test.describe('Chat messaging features', () => { await test.step('Opening second server once restores only its channels', async () => { await openSavedRoomByName(scenario.client.page, betaServerName); + await expect( channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${betaChannelName}"]`) ).toBeVisible({ timeout: 20_000 }); + await expect( channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${alphaChannelName}"]`) ).toHaveCount(0); @@ -304,11 +308,8 @@ async function createChatScenario(createClient: () => Promise): Promise< await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 }); const bobSearchPage = new ServerSearchPage(bob.page); - const serverCard = bob.page.locator('button', { hasText: serverName }).first(); - await bobSearchPage.searchInput.fill(serverName); - await expect(serverCard).toBeVisible({ timeout: 15_000 }); - await serverCard.click(); + await bobSearchPage.joinServerFromSearch(serverName); await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 }); const aliceRoom = new ChatRoomPage(alice.page); diff --git a/e2e/tests/chat/dm-flow.spec.ts b/e2e/tests/chat/dm-flow.spec.ts index 5f95093..84c26bd 100644 --- a/e2e/tests/chat/dm-flow.spec.ts +++ b/e2e/tests/chat/dm-flow.spec.ts @@ -41,14 +41,16 @@ test.describe('Direct message flow', () => { await expect(scenario.alice.page).toHaveURL(/\/search/, { timeout: 20_000 }); await expect(scenario.alice.page.locator('app-server-search')).toBeVisible({ timeout: 20_000 }); await expect(scenario.alice.page.locator('app-user-search-list')).toBeVisible({ timeout: 20_000 }); - const bobPeopleCard = scenario.alice.page.locator(`app-user-search-list [data-testid="user-card-${scenario.bobUserId}"]`); + const bobPeopleCard = scenario.alice.page + .locator('app-user-search-list [data-testid$="-' + scenario.bobUserId + '"]', { hasText: 'Bob' }) + .first(); await expect(bobPeopleCard).toBeVisible({ timeout: 15_000 }); const friendButton = bobPeopleCard.locator(`[data-testid="friend-button-${scenario.bobUserId}"]`); - const messageButton = bobPeopleCard.locator(`[data-testid="message-user-${scenario.bobUserId}"]`); + const messageButton = bobPeopleCard.getByRole('button', { name: 'Message Bob' }); - await expect(friendButton).toBeVisible({ timeout: 15_000 }); - await expect(messageButton).toBeVisible({ timeout: 15_000 }); + await expect(friendButton).toBeAttached({ timeout: 15_000 }); + await expect(messageButton).toBeAttached({ timeout: 15_000 }); }); }); @@ -76,12 +78,7 @@ async function createDmScenario(createClient: () => Promise): Promise Promise): await aliceSearch.createServer(serverName, { description: 'E2E notification coverage server' }); + await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 }); const bobSearch = new ServerSearchPage(bob.page); - const serverCard = bob.page.locator('button', { hasText: serverName }).first(); - await bobSearch.searchInput.fill(serverName); - await expect(serverCard).toBeVisible({ timeout: 15_000 }); - await serverCard.click(); + await bobSearch.joinServerFromSearch(serverName); await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 }); const aliceRoom = new ChatRoomPage(alice.page); @@ -155,10 +150,6 @@ async function installDesktopNotificationSpy(page: Page): Promise { class MockNotification { static permission = 'granted'; - static async requestPermission(): Promise { - return 'granted'; - } - onclick: (() => void) | null = null; constructor(title: string, options?: NotificationOptions) { @@ -168,6 +159,10 @@ async function installDesktopNotificationSpy(page: Page): Promise { }); } + static async requestPermission(): Promise { + return 'granted'; + } + close(): void { return; } @@ -256,7 +251,8 @@ function getUnreadBadge(container: Locator): Locator { } function uniqueName(prefix: string): string { - return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + return `${prefix}-${Date.now()}-${Math.random().toString(36) + .slice(2, 8)}`; } interface WindowWithDesktopNotifications extends Window { diff --git a/e2e/tests/chat/profile-avatar-sync.spec.ts b/e2e/tests/chat/profile-avatar-sync.spec.ts index ca32335..0f32d6c 100644 --- a/e2e/tests/chat/profile-avatar-sync.spec.ts +++ b/e2e/tests/chat/profile-avatar-sync.spec.ts @@ -384,11 +384,8 @@ async function registerUser(client: PersistentClient): Promise { async function joinServerFromSearch(page: Page, serverName: string): Promise { const searchPage = new ServerSearchPage(page); - const serverCard = page.locator('button', { hasText: serverName }).first(); - await searchPage.searchInput.fill(serverName); - await expect(serverCard).toBeVisible({ timeout: 15_000 }); - await serverCard.click(); + await searchPage.joinServerFromSearch(serverName); await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 }); } diff --git a/e2e/tests/screen-share/screen-share.spec.ts b/e2e/tests/screen-share/screen-share.spec.ts index b05f949..f36a24a 100644 --- a/e2e/tests/screen-share/screen-share.spec.ts +++ b/e2e/tests/screen-share/screen-share.spec.ts @@ -56,12 +56,7 @@ async function setupServerWithBothUsers( // Bob joins server const bobSearch = new ServerSearchPage(bob.page); - await bobSearch.searchInput.fill(SERVER_NAME); - - const serverCard = bob.page.locator('button', { hasText: SERVER_NAME }).first(); - - await expect(serverCard).toBeVisible({ timeout: 10_000 }); - await serverCard.click(); + await bobSearch.joinServerFromSearch(SERVER_NAME); await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 }); } diff --git a/e2e/tests/settings/connectivity-warning.spec.ts b/e2e/tests/settings/connectivity-warning.spec.ts index 3233d72..c93b69b 100644 --- a/e2e/tests/settings/connectivity-warning.spec.ts +++ b/e2e/tests/settings/connectivity-warning.spec.ts @@ -117,22 +117,14 @@ test.describe('Connectivity warning', () => { await test.step('Bob joins the server', async () => { const search = new ServerSearchPage(bob.page); - await search.searchInput.fill(serverName); - const card = bob.page.locator('button', { hasText: serverName }).first(); - - await expect(card).toBeVisible({ timeout: 15_000 }); - await card.click(); + await search.joinServerFromSearch(serverName); await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 }); }); await test.step('Charlie joins the server', async () => { const search = new ServerSearchPage(charlie.page); - await search.searchInput.fill(serverName); - const card = charlie.page.locator('button', { hasText: serverName }).first(); - - await expect(card).toBeVisible({ timeout: 15_000 }); - await card.click(); + await search.joinServerFromSearch(serverName); await expect(charlie.page).toHaveURL(/\/room\//, { timeout: 15_000 }); }); diff --git a/e2e/tests/settings/ice-server-settings.spec.ts b/e2e/tests/settings/ice-server-settings.spec.ts index 23b4d50..97bc714 100644 --- a/e2e/tests/settings/ice-server-settings.spec.ts +++ b/e2e/tests/settings/ice-server-settings.spec.ts @@ -11,8 +11,9 @@ test.describe('ICE server settings', () => { await register.register(`user_${suffix}`, 'IceTestUser', 'TestPass123!'); await expect(page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 }); await page.getByTitle('Settings').click(); - await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole('button', { name: 'Network' })).toBeVisible({ timeout: 10_000 }); await page.getByRole('button', { name: 'Network' }).click(); + await expect(page.getByTestId('ice-server-settings')).toBeVisible({ timeout: 10_000 }); } test('allows adding, removing, and reordering ICE servers', async ({ createClient }) => { @@ -101,7 +102,7 @@ test.describe('ICE server settings', () => { await page.reload({ waitUntil: 'domcontentloaded' }); await page.getByTitle('Settings').click(); - await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole('button', { name: 'Network' })).toBeVisible({ timeout: 10_000 }); await page.getByRole('button', { name: 'Network' }).click(); await expect(page.getByText('stun:persist-test.example.com:3478')).toBeVisible({ timeout: 10_000 }); }); diff --git a/e2e/tests/settings/stun-turn-fallback.spec.ts b/e2e/tests/settings/stun-turn-fallback.spec.ts index d553d3f..a1ee180 100644 --- a/e2e/tests/settings/stun-turn-fallback.spec.ts +++ b/e2e/tests/settings/stun-turn-fallback.spec.ts @@ -109,11 +109,7 @@ test.describe('STUN/TURN fallback behaviour', () => { await test.step('Bob joins Alice server', async () => { const search = new ServerSearchPage(bob.page); - await search.searchInput.fill(serverName); - const serverCard = bob.page.locator('button', { hasText: serverName }).first(); - - await expect(serverCard).toBeVisible({ timeout: 15_000 }); - await serverCard.click(); + await search.joinServerFromSearch(serverName); await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 }); }); diff --git a/e2e/tests/voice/mixed-signal-config-voice.spec.ts b/e2e/tests/voice/mixed-signal-config-voice.spec.ts index 703e536..ef95f08 100644 --- a/e2e/tests/voice/mixed-signal-config-voice.spec.ts +++ b/e2e/tests/voice/mixed-signal-config-voice.spec.ts @@ -572,10 +572,10 @@ async function joinRoomFromSearch(page: Page, roomName: string): Promise { await expect(searchInput).toBeVisible({ timeout: 20_000 }); await searchInput.fill(roomName); - const roomCard = page.locator('button', { hasText: roomName }).first(); + const roomCard = page.locator('div[title]', { hasText: roomName }).first(); await expect(roomCard).toBeVisible({ timeout: 20_000 }); - await roomCard.click(); + await roomCard.dblclick(); await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 }); await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 }); await waitForCurrentRoomName(page, roomName); diff --git a/e2e/tests/voice/multi-signal-eight-user-voice.spec.ts b/e2e/tests/voice/multi-signal-eight-user-voice.spec.ts index 1858247..5f7dc95 100644 --- a/e2e/tests/voice/multi-signal-eight-user-voice.spec.ts +++ b/e2e/tests/voice/multi-signal-eight-user-voice.spec.ts @@ -335,10 +335,10 @@ async function joinRoomFromSearch(page: Page, roomName: string): Promise { await expect(searchInput).toBeVisible({ timeout: 20_000 }); await searchInput.fill(roomName); - const roomCard = page.locator('button', { hasText: roomName }).first(); + const roomCard = page.locator('div[title]', { hasText: roomName }).first(); await expect(roomCard).toBeVisible({ timeout: 20_000 }); - await roomCard.click(); + await roomCard.dblclick(); await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 }); await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 }); await waitForCurrentRoomName(page, roomName); diff --git a/e2e/tests/voice/voice-full-journey.spec.ts b/e2e/tests/voice/voice-full-journey.spec.ts index 91f7244..d0b5bf6 100644 --- a/e2e/tests/voice/voice-full-journey.spec.ts +++ b/e2e/tests/voice/voice-full-journey.spec.ts @@ -96,14 +96,7 @@ test.describe('Full user journey: register -> server -> voice chat', () => { await test.step('Bob finds and joins the server', async () => { const searchPage = new ServerSearchPage(bob.page); - // Search for the server - await searchPage.searchInput.fill(SERVER_NAME); - - // Wait for search results and click the server - const serverCard = bob.page.locator('button', { hasText: SERVER_NAME }).first(); - - await expect(serverCard).toBeVisible({ timeout: 10_000 }); - await serverCard.click(); + await searchPage.joinServerFromSearch(SERVER_NAME); // Bob should be in the room now await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 }); diff --git a/electron/README.md b/electron/README.md index e658ae7..45b2e5b 100644 --- a/electron/README.md +++ b/electron/README.md @@ -16,6 +16,7 @@ Electron main-process package for MetoYou / Toju. This directory owns desktop bo | --- | --- | | `main.ts` | Electron app bootstrap and process entry point | | `preload.ts` | Typed renderer-facing preload bridge | +| `process-list.ts` | Linux/Windows process-name scan used by now-playing game detection | | `app/` | App lifecycle and startup composition | | `ipc/` | Renderer-invoked IPC handlers | | `cqrs/` | Local database command/query handlers and mappings | @@ -28,4 +29,4 @@ Electron main-process package for MetoYou / Toju. This directory owns desktop bo - When adding a renderer-facing capability, update the Electron implementation, `preload.ts`, and the renderer bridge in `toju-app/` together. - Treat `dist/electron/` and `dist-electron/` as generated output. -- See [AGENTS.md](AGENTS.md) for package-level editing rules. \ No newline at end of file +- See [AGENTS.md](AGENTS.md) for package-level editing rules. diff --git a/electron/ipc/system.ts b/electron/ipc/system.ts index 01f8f4b..4bad4f5 100644 --- a/electron/ipc/system.ts +++ b/electron/ipc/system.ts @@ -55,6 +55,7 @@ import { importUserData, openCurrentDataFolder } from '../data-management'; +import { listRunningProcessNames } from '../process-list'; const DEFAULT_MIME_TYPE = 'application/octet-stream'; const FILE_CLIPBOARD_FORMATS = [ @@ -320,6 +321,8 @@ export function setupSystemHandlers(): void { } }); + ipcMain.handle('get-running-process-names', async () => await listRunningProcessNames()); + ipcMain.handle('prepare-linux-screen-share-audio-routing', async () => { return await prepareLinuxScreenShareAudioRouting(); }); diff --git a/electron/preload.ts b/electron/preload.ts index 15ec520..22a8ce0 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -167,6 +167,7 @@ export interface ElectronAPI { openExternal: (url: string) => Promise; getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>; + getRunningProcessNames: () => Promise; prepareLinuxScreenShareAudioRouting: () => Promise; activateLinuxScreenShareAudioRouting: () => Promise; deactivateLinuxScreenShareAudioRouting: () => Promise; @@ -252,6 +253,7 @@ const electronAPI: ElectronAPI = { openExternal: (url) => ipcRenderer.invoke('open-external', url), getSources: () => ipcRenderer.invoke('get-sources'), + getRunningProcessNames: () => ipcRenderer.invoke('get-running-process-names'), prepareLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('prepare-linux-screen-share-audio-routing'), activateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('activate-linux-screen-share-audio-routing'), deactivateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('deactivate-linux-screen-share-audio-routing'), diff --git a/electron/process-list.ts b/electron/process-list.ts new file mode 100644 index 0000000..81d87ec --- /dev/null +++ b/electron/process-list.ts @@ -0,0 +1,85 @@ +import { execFile } from 'child_process'; +import * as path from 'path'; +import { promisify } from 'util'; + +const execFileAsync = promisify(execFile); +const MAX_PROCESS_NAMES = 512; + +export async function listRunningProcessNames(): Promise { + if (process.platform === 'win32') { + return normalizeProcessNames(await listWindowsProcessNames()); + } + + if (process.platform === 'linux') { + return normalizeProcessNames(await listLinuxProcessNames()); + } + + return []; +} + +async function listLinuxProcessNames(): Promise { + const { stdout } = await execFileAsync('ps', ['-eo', 'comm='], { + maxBuffer: 1024 * 1024, + timeout: 5_000 + }); + + return stdout.split('\n'); +} + +async function listWindowsProcessNames(): Promise { + const { stdout } = await execFileAsync('tasklist', [ + '/FO', + 'CSV', + '/NH' + ], { + maxBuffer: 1024 * 1024, + timeout: 5_000, + windowsHide: true + }); + + return stdout + .split(/\r?\n/) + .map((line) => parseCsvFirstColumn(line)); +} + +function parseCsvFirstColumn(line: string): string { + const trimmed = line.trim(); + + if (!trimmed) { + return ''; + } + + if (!trimmed.startsWith('"')) { + return trimmed.split(',')[0] ?? ''; + } + + const endQuoteIndex = trimmed.indexOf('"', 1); + + return endQuoteIndex > 1 ? trimmed.slice(1, endQuoteIndex) : ''; +} + +function normalizeProcessNames(names: string[]): string[] { + const normalized = new Set(); + + for (const rawName of names) { + const name = normalizeProcessName(rawName); + + if (name) { + normalized.add(name); + } + } + + return Array.from(normalized) + .sort() + .slice(0, MAX_PROCESS_NAMES); +} + +function normalizeProcessName(rawName: string): string { + const baseName = path.basename(rawName.trim()).trim(); + + if (!baseName || baseName.length > 96) { + return ''; + } + + return baseName; +} diff --git a/server/README.md b/server/README.md index f5813f1..ac50250 100644 --- a/server/README.md +++ b/server/README.md @@ -19,7 +19,8 @@ Node/TypeScript signaling server for MetoYou / Toju. This package owns the publi - The server loads the repository-root `.env` file on startup. - `SSL` can override the effective HTTP protocol, and `PORT` can override the effective port. - `DB_PATH` can override the SQLite database file location. -- `data/variables.json` is normalized on startup and stores `klipyApiKey`, `releaseManifestUrl`, `serverPort`, `serverProtocol`, `serverHost`, and `linkPreview`. +- `data/variables.json` is normalized on startup and stores `klipyApiKey`, `rawgApiKey`, `releaseManifestUrl`, `serverPort`, `serverProtocol`, `serverHost`, and `linkPreview`. +- `RAWG_API_KEY` can override `rawgApiKey` for the `/api/games/match` now-playing metadata resolver. Successful matches include a preferred store link from RAWG store metadata, with Steam selected first when available. Negative game-match results are stored in the SQLite `game_match_misses` table so non-game process names do not repeatedly consume RAWG quota. - Packaged server builds store `metoyou.sqlite` in the OS app-data directory by default so upgrades do not overwrite runtime data. On first start, the server copies forward legacy packaged databases that still live beside the executable. - When HTTPS is enabled, certificates are read from the repository `.certs/` directory. @@ -39,4 +40,4 @@ Node/TypeScript signaling server for MetoYou / Toju. This package owns the publi ## Notes - `dist/` and `../dist-server/` are generated output. -- See [AGENTS.md](AGENTS.md) for package-specific editing guidance. \ No newline at end of file +- See [AGENTS.md](AGENTS.md) for package-specific editing guidance. diff --git a/server/src/config/variables.ts b/server/src/config/variables.ts index 764fbc4..ff95193 100644 --- a/server/src/config/variables.ts +++ b/server/src/config/variables.ts @@ -12,6 +12,7 @@ export interface LinkPreviewConfig { export interface ServerVariablesConfig { klipyApiKey: string; + rawgApiKey: string; releaseManifestUrl: string; serverPort: number; serverProtocol: ServerHttpProtocol; @@ -31,6 +32,10 @@ function normalizeKlipyApiKey(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } +function normalizeRawgApiKey(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + function normalizeReleaseManifestUrl(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } @@ -139,6 +144,7 @@ export function ensureVariablesConfig(): ServerVariablesConfig { const normalized = { ...remainingParsed, klipyApiKey: normalizeKlipyApiKey(remainingParsed.klipyApiKey), + rawgApiKey: normalizeRawgApiKey(remainingParsed.rawgApiKey), releaseManifestUrl: normalizeReleaseManifestUrl(remainingParsed.releaseManifestUrl), serverPort: normalizeServerPort(remainingParsed.serverPort), serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol), @@ -153,6 +159,7 @@ export function ensureVariablesConfig(): ServerVariablesConfig { return { klipyApiKey: normalized.klipyApiKey, + rawgApiKey: normalized.rawgApiKey, releaseManifestUrl: normalized.releaseManifestUrl, serverPort: normalized.serverPort, serverProtocol: normalized.serverProtocol, @@ -169,6 +176,14 @@ export function getKlipyApiKey(): string { return getVariablesConfig().klipyApiKey; } +export function getRawgApiKey(): string { + if (hasEnvironmentOverride(process.env.RAWG_API_KEY)) { + return process.env.RAWG_API_KEY.trim(); + } + + return getVariablesConfig().rawgApiKey; +} + export function hasKlipyApiKey(): boolean { return getKlipyApiKey().length > 0; } diff --git a/server/src/db/database.ts b/server/src/db/database.ts index 2252f83..6244a8a 100644 --- a/server/src/db/database.ts +++ b/server/src/db/database.ts @@ -14,7 +14,8 @@ import { JoinRequestEntity, ServerMembershipEntity, ServerInviteEntity, - ServerBanEntity + ServerBanEntity, + GameMatchMissEntity } from '../entities'; import { serverMigrations } from '../migrations'; import { @@ -202,7 +203,8 @@ export async function initDatabase(): Promise { JoinRequestEntity, ServerMembershipEntity, ServerInviteEntity, - ServerBanEntity + ServerBanEntity, + GameMatchMissEntity ], migrations: serverMigrations, synchronize: process.env.DB_SYNCHRONIZE === 'true', diff --git a/server/src/entities/GameMatchMissEntity.ts b/server/src/entities/GameMatchMissEntity.ts new file mode 100644 index 0000000..cbe3959 --- /dev/null +++ b/server/src/entities/GameMatchMissEntity.ts @@ -0,0 +1,22 @@ +import { + Column, + Entity, + Index, + PrimaryColumn +} from 'typeorm'; + +@Entity('game_match_misses') +export class GameMatchMissEntity { + @PrimaryColumn('text') + processKey!: string; + + @Column('text') + processName!: string; + + @Column('integer') + missedAt!: number; + + @Index() + @Column('integer') + expiresAt!: number; +} diff --git a/server/src/entities/index.ts b/server/src/entities/index.ts index 071d4db..b1d2a82 100644 --- a/server/src/entities/index.ts +++ b/server/src/entities/index.ts @@ -9,3 +9,4 @@ export { JoinRequestEntity } from './JoinRequestEntity'; export { ServerMembershipEntity } from './ServerMembershipEntity'; export { ServerInviteEntity } from './ServerInviteEntity'; export { ServerBanEntity } from './ServerBanEntity'; +export { GameMatchMissEntity } from './GameMatchMissEntity'; diff --git a/server/src/migrations/1000000000006-GameMatchMisses.ts b/server/src/migrations/1000000000006-GameMatchMisses.ts new file mode 100644 index 0000000..aa82a08 --- /dev/null +++ b/server/src/migrations/1000000000006-GameMatchMisses.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class GameMatchMisses1000000000006 implements MigrationInterface { + name = 'GameMatchMisses1000000000006'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "game_match_misses" ( + "processKey" TEXT PRIMARY KEY NOT NULL, + "processName" TEXT NOT NULL, + "missedAt" INTEGER NOT NULL, + "expiresAt" INTEGER NOT NULL + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "idx_game_match_misses_expiresAt" + ON "game_match_misses" ("expiresAt") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "game_match_misses"`); + } +} diff --git a/server/src/migrations/index.ts b/server/src/migrations/index.ts index 6047bfe..564089b 100644 --- a/server/src/migrations/index.ts +++ b/server/src/migrations/index.ts @@ -4,6 +4,7 @@ import { ServerChannels1000000000002 } from './1000000000002-ServerChannels'; import { RepairLegacyVoiceChannels1000000000003 } from './1000000000003-RepairLegacyVoiceChannels'; import { NormalizeServerArrays1000000000004 } from './1000000000004-NormalizeServerArrays'; import { ServerRoleAccessControl1000000000005 } from './1000000000005-ServerRoleAccessControl'; +import { GameMatchMisses1000000000006 } from './1000000000006-GameMatchMisses'; export const serverMigrations = [ InitialSchema1000000000000, @@ -11,5 +12,6 @@ export const serverMigrations = [ ServerChannels1000000000002, RepairLegacyVoiceChannels1000000000003, NormalizeServerArrays1000000000004, - ServerRoleAccessControl1000000000005 + ServerRoleAccessControl1000000000005, + GameMatchMisses1000000000006 ]; diff --git a/server/src/routes/games.ts b/server/src/routes/games.ts new file mode 100644 index 0000000..462c7cc --- /dev/null +++ b/server/src/routes/games.ts @@ -0,0 +1,17 @@ +import { Router } from 'express'; +import { matchRunningGames } from '../services/game-matching.service'; + +const router = Router(); + +router.post('/match', async (req, res) => { + try { + const result = await matchRunningGames(req.body?.processes, req.body?.userId ?? req.ip); + + res.json(result); + } catch (error) { + console.error('[Games] Failed to match running games', error); + res.status(500).json({ error: 'Failed to match running games' }); + } +}); + +export default router; diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index ec6c68b..874efc1 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -2,6 +2,7 @@ import { Express } from 'express'; import healthRouter from './health'; import klipyRouter from './klipy'; import linkMetadataRouter from './link-metadata'; +import gamesRouter from './games'; import proxyRouter from './proxy'; import usersRouter from './users'; import serversRouter from './servers'; @@ -12,6 +13,7 @@ export function registerRoutes(app: Express): void { app.use('/api', healthRouter); app.use('/api', klipyRouter); app.use('/api', linkMetadataRouter); + app.use('/api/games', gamesRouter); app.use('/api', proxyRouter); app.use('/api/users', usersRouter); app.use('/api/servers', serversRouter); diff --git a/server/src/services/game-matching.service.ts b/server/src/services/game-matching.service.ts new file mode 100644 index 0000000..f07f5cd --- /dev/null +++ b/server/src/services/game-matching.service.ts @@ -0,0 +1,591 @@ +import { getRawgApiKey } from '../config/variables'; +import { getDataSource } from '../db/database'; +import { GameMatchMissEntity } from '../entities'; + +export interface MatchedGame { + id: string; + name: string; + iconUrl?: string; + store?: GameStoreLink; + processName: string; +} + +export interface GameStoreLink { + id?: string; + name: string; + slug?: string; + domain?: string; + url: string; +} + +interface CacheEntry { + expiresAt: number; + game: Omit | null; +} + +interface RawgSearchResponse { + results?: RawgGameResult[]; +} + +interface RawgGameResult { + id?: number; + name?: string; + background_image?: string | null; + slug?: string; + stores?: RawgStoreEntry[] | null; +} + +interface RawgStoreEntry { + url?: string | null; + store?: RawgStore | null; +} + +interface RawgStore { + id?: number; + name?: string; + slug?: string; + domain?: string | null; +} + +interface CandidateProcess { + processName: string; + score: number; +} + +interface GameMatchResult { + games: MatchedGame[]; + rateLimited?: boolean; +} + +interface RawgLookupBudget { + used: number; + windowStartedAt: number; +} + +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; +const PERSISTED_MISS_TTL_MS = 30 * 24 * 60 * 60 * 1000; +const RAWG_LOOKUP_WINDOW_MS = 60 * 60 * 1000; +const RAWG_SEARCH_TIMEOUT_MS = 4_000; +const MAX_INCOMING_PROCESSES = 256; +const MAX_CANDIDATE_PROCESSES = 24; +const MAX_UNCACHED_LOOKUPS_PER_REQUEST = 4; +const MAX_RAWG_LOOKUPS_PER_USER_PER_WINDOW = 8; +const RAWG_SEARCH_URL = 'https://api.rawg.io/api/games'; +const MIN_SEARCH_QUERY_LENGTH = 4; +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', + 'init', + 'kernel_task', + 'metoyou', + 'nvidia-settings', + 'node', + 'npm', + 'obs', + 'powershell', + 'pulseaudio', + 'services', + 'steam', + 'steamwebhelper', + 'system', + '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)$/ +]; +const STORE_SEARCH_URL_BUILDERS: Record string> = { + steam: (query) => `https://store.steampowered.com/search/?term=${query}`, + 'epic-games': (query) => `https://store.epicgames.com/en-US/browse?q=${query}`, + gog: (query) => `https://www.gog.com/en/games?query=${query}`, + itch: (query) => `https://itch.io/search?q=${query}`, + 'xbox-store': (query) => `https://www.xbox.com/search?q=${query}`, + 'playstation-store': (query) => `https://store.playstation.com/search/${query}`, + nintendo: (query) => `https://www.nintendo.com/search/#q=${query}`, + 'apple-appstore': (query) => `https://apps.apple.com/us/search?term=${query}`, + 'google-play': (query) => `https://play.google.com/store/search?q=${query}&c=apps` +}; +const STORE_SEARCH_ALIASES = new Map([ + ['steam', 'steam'], + ['store.steampowered.com', 'steam'], + ['epic-games', 'epic-games'], + ['store.epicgames.com', 'epic-games'], + ['gog', 'gog'], + ['www.gog.com', 'gog'], + ['gog.com', 'gog'], + ['itch', 'itch'], + ['itch.io', 'itch'], + ['xbox-store', 'xbox-store'], + ['www.xbox.com', 'xbox-store'], + ['xbox.com', 'xbox-store'], + ['playstation-store', 'playstation-store'], + ['store.playstation.com', 'playstation-store'], + ['nintendo', 'nintendo'], + ['www.nintendo.com', 'nintendo'], + ['nintendo.com', 'nintendo'], + ['apple-appstore', 'apple-appstore'], + ['apps.apple.com', 'apple-appstore'], + ['google-play', 'google-play'], + ['play.google.com', 'google-play'] +]); +const STORE_PRIORITY = new Map([ + ['steam', 0], + ['gog', 10], + ['epic-games', 20], + ['itch', 30], + ['xbox-store', 80], + ['playstation-store', 90] +]); +const cache = new Map(); +const rawgLookupBudgets = new Map(); + +export async function matchRunningGames( + processNames: unknown, + requester: unknown = 'anonymous' +): Promise { + const candidates = normalizeProcessList(processNames).slice(0, MAX_CANDIDATE_PROCESSES); + const matches: MatchedGame[] = []; + const seenGameIds = new Set(); + const requesterKey = normalizeRequesterKey(requester); + const persistedMisses = await loadPersistedMissKeys(candidates.map((candidate) => candidate.processName)); + + let uncachedLookups = 0; + let rateLimited = false; + + for (const { processName } of candidates) { + const cacheKey = normalizeCacheKey(processName); + const cached = getCachedGame(cacheKey); + + if (cached !== undefined) { + appendMatch(matches, seenGameIds, processName, cached); + continue; + } + + if (persistedMisses.has(cacheKey)) { + setCachedGame(cacheKey, null); + continue; + } + + if (uncachedLookups >= MAX_UNCACHED_LOOKUPS_PER_REQUEST) { + rateLimited = true; + continue; + } + + if (!tryConsumeRawgLookup(requesterKey)) { + rateLimited = true; + continue; + } + + uncachedLookups += 1; + + const game = await resolveRawgGame(processName); + + setCachedGame(cacheKey, game); + + if (!game) { + await rememberPersistedMiss(cacheKey, processName); + } + + appendMatch(matches, seenGameIds, processName, game); + } + + return { + games: matches, + rateLimited: rateLimited || undefined + }; +} + +function normalizeProcessList(value: unknown): CandidateProcess[] { + if (!Array.isArray(value)) { + return []; + } + + const processes = new Map(); + + for (const entry of value.slice(0, MAX_INCOMING_PROCESSES)) { + const processName = normalizeProcessName(entry); + + if (processName) { + const cacheKey = normalizeCacheKey(processName); + + if (!processes.has(cacheKey)) { + processes.set(cacheKey, { + processName, + score: scoreCandidateProcess(String(entry), processName) + }); + } + } + } + + return Array.from(processes.values()) + .sort((left, right) => right.score - left.score || left.processName.localeCompare(right.processName)); +} + +function normalizeProcessName(value: unknown): string { + if (typeof value !== 'string') { + return ''; + } + + const normalized = value + .trim() + .replace(/\.exe$/i, '') + .replace(/[_-]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + const cacheKey = normalizeCacheKey(normalized); + + if (normalized.length < 3 || normalized.length > 96 || shouldIgnoreProcessName(cacheKey)) { + return ''; + } + + return normalized; +} + +function shouldIgnoreProcessName(cacheKey: string): boolean { + return IGNORED_PROCESS_NAMES.has(cacheKey) + || IGNORED_PROCESS_PATTERNS.some((pattern) => pattern.test(cacheKey)); +} + +function normalizeRequesterKey(value: unknown): string { + if (typeof value !== 'string') { + return 'anonymous'; + } + + const normalized = value.trim().toLowerCase(); + + return normalized || 'anonymous'; +} + +function tryConsumeRawgLookup(requesterKey: string): boolean { + const now = Date.now(); + const existing = rawgLookupBudgets.get(requesterKey); + + if (!existing || existing.windowStartedAt + RAWG_LOOKUP_WINDOW_MS <= now) { + rawgLookupBudgets.set(requesterKey, { + used: 1, + windowStartedAt: now + }); + + return true; + } + + if (existing.used >= MAX_RAWG_LOOKUPS_PER_USER_PER_WINDOW) { + return false; + } + + existing.used += 1; + + return true; +} + +function scoreCandidateProcess(rawValue: string, processName: string): number { + let score = 0; + + if (/\.exe$/i.test(rawValue.trim())) { + score += 12; + } + + if (/[A-Z]/.test(processName) && /[a-z]/.test(processName)) { + score += 4; + } + + if (/\d/.test(processName)) { + score += 1; + } + + if (processName.length >= 5 && processName.length <= 32) { + score += 2; + } + + if (processName.includes(' ')) { + score -= 2; + } + + return score; +} + +function normalizeCacheKey(value: string): string { + return value.trim() + .toLowerCase() + .replace(/\s+/g, ' '); +} + +function getCachedGame(cacheKey: string): Omit | null | undefined { + const cached = cache.get(cacheKey); + + if (!cached) { + return undefined; + } + + if (cached.expiresAt <= Date.now()) { + cache.delete(cacheKey); + return undefined; + } + + return cached.game; +} + +function setCachedGame(cacheKey: string, game: Omit | null): void { + cache.set(cacheKey, { + expiresAt: Date.now() + CACHE_TTL_MS, + game + }); +} + +async function loadPersistedMissKeys(processNames: string[]): Promise> { + const cacheKeys = Array.from(new Set(processNames.map((name) => normalizeCacheKey(name)))); + + if (cacheKeys.length === 0) { + return new Set(); + } + + try { + const repository = getDataSource().getRepository(GameMatchMissEntity); + const now = Date.now(); + + await repository.createQueryBuilder() + .delete() + .where('expiresAt <= :now', { now }) + .execute(); + + const rows = await repository.createQueryBuilder('miss') + .select('miss.processKey') + .where('miss.processKey IN (:...cacheKeys)', { cacheKeys }) + .andWhere('miss.expiresAt > :now', { now }) + .getMany(); + + return new Set(rows.map((row) => row.processKey)); + } catch { + return new Set(); + } +} + +async function rememberPersistedMiss(cacheKey: string, processName: string): Promise { + try { + const now = Date.now(); + + await getDataSource().getRepository(GameMatchMissEntity) + .save({ + processKey: cacheKey, + processName, + missedAt: now, + expiresAt: now + PERSISTED_MISS_TTL_MS + }); + } catch { + return; + } +} + +async function resolveRawgGame(processName: string): Promise | null> { + const apiKey = getRawgApiKey(); + + if (!apiKey) { + return null; + } + + const query = buildSearchQuery(processName); + + if (!query) { + return null; + } + + const url = new URL(RAWG_SEARCH_URL); + + url.searchParams.set('key', apiKey); + url.searchParams.set('search', query); + url.searchParams.set('search_precise', 'true'); + url.searchParams.set('exclude_additions', 'true'); + url.searchParams.set('page_size', '1'); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), RAWG_SEARCH_TIMEOUT_MS); + + try { + const response = await fetch(url, { signal: controller.signal }); + + if (!response.ok) { + return null; + } + + const body = await response.json() as RawgSearchResponse; + const result = body.results?.[0]; + + if (!isAcceptableRawgMatch(query, result)) { + return null; + } + + return { + id: String(result.id), + name: result.name.trim(), + iconUrl: result.background_image || undefined, + store: selectPreferredStore(result, result.name.trim()) + }; + } catch { + return null; + } finally { + clearTimeout(timeout); + } +} + +function selectPreferredStore(result: RawgGameResult, gameName: string): GameStoreLink | undefined { + const stores = Array.isArray(result.stores) ? result.stores : []; + const usableStores = stores + .map((entry) => buildStoreLink(entry, gameName)) + .filter((store): store is GameStoreLink => !!store); + + return usableStores.sort((left, right) => getStorePriority(left) - getStorePriority(right))[0]; +} + +function getStorePriority(store: GameStoreLink): number { + const storeKey = STORE_SEARCH_ALIASES.get(store.slug ?? '') + ?? STORE_SEARCH_ALIASES.get(store.domain ?? '') + ?? store.name.trim().toLowerCase(); + + return STORE_PRIORITY.get(storeKey) ?? 50; +} + +function buildStoreLink(entry: RawgStoreEntry, gameName: string): GameStoreLink | undefined { + const store = entry.store; + + if (!store || typeof store.name !== 'string' || !store.name.trim()) { + return undefined; + } + + const slug = typeof store.slug === 'string' && store.slug.trim() + ? store.slug.trim().toLowerCase() + : undefined; + const domain = typeof store.domain === 'string' && store.domain.trim() + ? store.domain.trim() + .replace(/^https?:\/\//i, '') + .replace(/\/$/, '') + : undefined; + const url = normalizeExternalUrl(entry.url) ?? buildStoreSearchUrl(slug, domain, gameName); + + if (!url) { + return undefined; + } + + return { + id: typeof store.id === 'number' ? String(store.id) : undefined, + name: store.name.trim(), + slug, + domain, + url + }; +} + +function normalizeExternalUrl(value: unknown): string | undefined { + if (typeof value !== 'string' || !value.trim()) { + return undefined; + } + + const trimmed = value.trim(); + + return trimmed.startsWith('http://') || trimmed.startsWith('https://') + ? trimmed + : undefined; +} + +function buildStoreSearchUrl(slug: string | undefined, domain: string | undefined, gameName: string): string | undefined { + const query = encodeURIComponent(gameName); + const storeKey = STORE_SEARCH_ALIASES.get(slug ?? '') ?? STORE_SEARCH_ALIASES.get(domain ?? ''); + const buildUrl = storeKey ? STORE_SEARCH_URL_BUILDERS[storeKey] : undefined; + + return buildUrl?.(query) ?? (domain ? `https://${domain}` : undefined); +} + +function buildSearchQuery(processName: string): string { + const query = processName + .replace(/\.exe$/i, '') + .replace(/\b(x64|x86|win64|win32|linux|shipping|client|launcher|game)\b/gi, ' ') + .replace(/\s+/g, ' ') + .trim(); + + return query.length >= MIN_SEARCH_QUERY_LENGTH ? query : ''; +} + +function isAcceptableRawgMatch( + query: string, + result: RawgGameResult | undefined +): result is Required> & RawgGameResult { + if (!result || typeof result.id !== 'number' || typeof result.name !== 'string' || !result.name.trim()) { + return false; + } + + const queryKey = normalizeComparableText(query); + const nameKey = normalizeComparableText(result.name); + const slugKey = normalizeComparableText(result.slug ?? ''); + const queryTokens = tokenizeComparableText(queryKey); + const nameTokens = tokenizeComparableText(nameKey); + const slugTokens = tokenizeComparableText(slugKey); + + if (queryKey.length < MIN_SEARCH_QUERY_LENGTH || queryTokens.length === 0) { + return false; + } + + if (queryKey === nameKey || queryKey === slugKey) { + return true; + } + + if (queryTokens.length === 1) { + const [queryToken] = queryTokens; + + return queryToken.length >= 5 + && (nameTokens.includes(queryToken) || slugTokens.includes(queryToken)); + } + + return queryTokens.every((token) => nameTokens.includes(token) || slugTokens.includes(token)); +} + +function normalizeComparableText(value: string): string { + return value.toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim(); +} + +function tokenizeComparableText(value: string): string[] { + return value.split(' ') + .filter((token) => token.length >= 2); +} + +function appendMatch( + matches: MatchedGame[], + seenGameIds: Set, + processName: string, + game: Omit | null +): void { + if (!game || seenGameIds.has(game.id)) { + return; + } + + seenGameIds.add(game.id); + matches.push({ + ...game, + processName + }); +} diff --git a/server/src/websocket/handler.ts b/server/src/websocket/handler.ts index 6d19786..1d6299e 100644 --- a/server/src/websocket/handler.ts +++ b/server/src/websocket/handler.ts @@ -71,25 +71,6 @@ function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: s const previousDescription = user.description; const previousProfileUpdatedAt = user.profileUpdatedAt; - // Close stale connections from the same identity AND the same connection - // scope so offer routing always targets the freshest socket (e.g. after - // page refresh). Connections with a *different* scope (= a different - // signal URL that happens to route to this server) are left untouched so - // multi-signal-URL setups don't trigger an eviction loop. - connectedUsers.forEach((existing, existingId) => { - if (existingId !== connectionId - && existing.oderId === newOderId - && existing.connectionScope === newScope) { - console.log(`Closing stale connection for ${newOderId} (old=${existingId}, new=${connectionId}, scope=${newScope ?? 'none'})`); - - try { - existing.ws.close(); - } catch { /* already closing */ } - - connectedUsers.delete(existingId); - } - }); - user.oderId = newOderId; user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName)); diff --git a/toju-app/src/app/app.ts b/toju-app/src/app/app.ts index 7ddc03b..cc88dc7 100644 --- a/toju-app/src/app/app.ts +++ b/toju-app/src/app/app.ts @@ -34,6 +34,7 @@ import { ExternalLinkService } from './core/platform'; import { SettingsModalService } from './core/services/settings-modal.service'; import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service'; import { UserStatusService } from './core/services/user-status.service'; +import { GameActivityService } from './domains/game-activity'; import { ServersRailComponent } from './features/servers/servers-rail/servers-rail.component'; import { TitleBarComponent } from './features/shell/title-bar/title-bar.component'; import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component'; @@ -95,6 +96,7 @@ export class App implements OnInit, OnDestroy { readonly externalLinks = inject(ExternalLinkService); readonly electronBridge = inject(ElectronBridgeService); readonly userStatus = inject(UserStatusService); + readonly gameActivity = inject(GameActivityService); readonly dismissedDesktopUpdateNoticeKey = signal(null); readonly themeStudioFullscreenComponent = signal | null>(null); readonly themeStudioControlsPosition = signal<{ x: number; y: number } | null>(null); @@ -246,6 +248,7 @@ export class App implements OnInit, OnDestroy { await this.setupDesktopDeepLinks(); this.userStatus.start(); + this.gameActivity.start(); const currentUrl = this.getCurrentRouteUrl(); if (!currentUserId) { diff --git a/toju-app/src/app/core/platform/electron/electron-api.models.ts b/toju-app/src/app/core/platform/electron/electron-api.models.ts index c4e9206..61cc2cc 100644 --- a/toju-app/src/app/core/platform/electron/electron-api.models.ts +++ b/toju-app/src/app/core/platform/electron/electron-api.models.ts @@ -175,6 +175,7 @@ export interface ElectronApi { closeWindow: () => void; openExternal: (url: string) => Promise; getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>; + getRunningProcessNames: () => Promise; prepareLinuxScreenShareAudioRouting: () => Promise; activateLinuxScreenShareAudioRouting: () => Promise; deactivateLinuxScreenShareAudioRouting: () => Promise; diff --git a/toju-app/src/app/domains/README.md b/toju-app/src/app/domains/README.md index 3457c92..b2e3abf 100644 --- a/toju-app/src/app/domains/README.md +++ b/toju-app/src/app/domains/README.md @@ -13,6 +13,7 @@ infrastructure adapters and UI. | **authentication** | Login / register HTTP orchestration, user-bar UI | `AuthenticationService` | | **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` | | **direct-message** | One-to-one WebRTC messages, offline queueing, delivery state, and friends | `DirectMessageService`, `FriendService` | +| **game-activity** | Local game detection, server metadata matching, P2P now-playing sync, and elapsed playtime formatting | `GameActivityService`, `formatGameActivityElapsed()` | | **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` | | **profile-avatar** | Profile picture upload, crop/zoom editing, processing, local persistence, and P2P avatar sync | `ProfileAvatarFacade` | | **screen-share** | Source picker, quality presets | `ScreenShareFacade` | diff --git a/toju-app/src/app/domains/game-activity/application/game-activity.service.spec.ts b/toju-app/src/app/domains/game-activity/application/game-activity.service.spec.ts new file mode 100644 index 0000000..053630e --- /dev/null +++ b/toju-app/src/app/domains/game-activity/application/game-activity.service.spec.ts @@ -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 } | null; + processNames?: string[]; + gameMatchResponse?: GameMatchResponse; +} + +interface ServiceContext { + incomingMessages: Subject; + peerConnected: Subject; + realtime: { + broadcastMessage: ReturnType; + sendToPeer: ReturnType; + }; + service: GameActivityService; + store: { + dispatch: ReturnType; + }; +} + +function createServiceContext(options: ServiceContextOptions): ServiceContext { + const currentUser = signal(options.currentUser); + const allUsers = signal(options.allUsers); + const incomingMessages = new Subject(); + const peerConnected = new Subject(); + 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(); + + 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() + }); +} diff --git a/toju-app/src/app/domains/game-activity/application/game-activity.service.ts b/toju-app/src/app/domains/game-activity/application/game-activity.service.ts new file mode 100644 index 0000000..f8be3a9 --- /dev/null +++ b/toju-app/src/app/domains/game-activity/application/game-activity.service.ts @@ -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 | 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 { + 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); + } +} diff --git a/toju-app/src/app/domains/game-activity/domain/game-activity-time.ts b/toju-app/src/app/domains/game-activity/domain/game-activity-time.ts new file mode 100644 index 0000000..53c1c7b --- /dev/null +++ b/toju-app/src/app/domains/game-activity/domain/game-activity-time.ts @@ -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(':'); +} diff --git a/toju-app/src/app/domains/game-activity/domain/game-activity.models.ts b/toju-app/src/app/domains/game-activity/domain/game-activity.models.ts new file mode 100644 index 0000000..c525c25 --- /dev/null +++ b/toju-app/src/app/domains/game-activity/domain/game-activity.models.ts @@ -0,0 +1,3 @@ +import type { GameActivity } from '../../../shared-kernel'; + +export type CurrentGameActivity = GameActivity | null; diff --git a/toju-app/src/app/domains/game-activity/index.ts b/toju-app/src/app/domains/game-activity/index.ts new file mode 100644 index 0000000..ce836e5 --- /dev/null +++ b/toju-app/src/app/domains/game-activity/index.ts @@ -0,0 +1,3 @@ +export * from './application/game-activity.service'; +export * from './domain/game-activity.models'; +export * from './domain/game-activity-time'; diff --git a/toju-app/src/app/domains/server-directory/application/services/server-endpoint-state.service.spec.ts b/toju-app/src/app/domains/server-directory/application/services/server-endpoint-state.service.spec.ts index 8937a93..ef15a4d 100644 --- a/toju-app/src/app/domains/server-directory/application/services/server-endpoint-state.service.spec.ts +++ b/toju-app/src/app/domains/server-directory/application/services/server-endpoint-state.service.spec.ts @@ -157,6 +157,14 @@ describe('ServerEndpointStateService', () => { expect(service.servers().find((candidate) => candidate.id === endpoint.id)?.isActive).toBe(true); }); + it('resolves legacy https source URLs to the local http default endpoint on the same host', () => { + const defaultServer = getConfiguredDefaultServer('default'); + const service = createService(); + const legacyHttpsUrl = defaultServer.url?.replace(/^http:\/\//, 'https://') ?? ''; + + expect(service.findServerByUrl(legacyHttpsUrl)?.url).toBe(defaultServer.url); + }); + it('persists turning a configured default endpoint off and back on', () => { const defaultServer = getConfiguredDefaultServer('toju-primary'); const service = createService(); diff --git a/toju-app/src/app/domains/server-directory/application/services/server-endpoint-state.service.ts b/toju-app/src/app/domains/server-directory/application/services/server-endpoint-state.service.ts index 0b78bdf..502a969 100644 --- a/toju-app/src/app/domains/server-directory/application/services/server-endpoint-state.service.ts +++ b/toju-app/src/app/domains/server-directory/application/services/server-endpoint-state.service.ts @@ -117,8 +117,9 @@ export class ServerEndpointStateService { findServerByUrl(url: string): ServerEndpoint | undefined { const sanitisedUrl = this.sanitiseUrl(url); + const exactEndpoint = this._servers().find((endpoint) => this.sanitiseUrl(endpoint.url) === sanitisedUrl); - return this._servers().find((endpoint) => this.sanitiseUrl(endpoint.url) === sanitisedUrl); + return exactEndpoint ?? this.findHttpEndpointForHttpsUrl(sanitisedUrl); } resolveCanonicalEndpoint(endpoint: ServerEndpoint | null | undefined): ServerEndpoint | null { @@ -447,4 +448,28 @@ export class ServerEndpointStateService { return false; } } + + private findHttpEndpointForHttpsUrl(url: string): ServerEndpoint | undefined { + const requestedUrl = this.parseUrl(url); + + if (requestedUrl?.protocol !== 'https:') { + return undefined; + } + + return this._servers().find((endpoint) => { + const endpointUrl = this.parseUrl(endpoint.url); + + return endpointUrl?.protocol === 'http:' + && endpointUrl.hostname === requestedUrl.hostname + && endpointUrl.port === requestedUrl.port; + }); + } + + private parseUrl(url: string): URL | null { + try { + return new URL(url); + } catch { + return null; + } + } } diff --git a/toju-app/src/app/domains/server-directory/infrastructure/services/server-directory-api.service.ts b/toju-app/src/app/domains/server-directory/infrastructure/services/server-directory-api.service.ts index ae27a9f..bc3acfa 100644 --- a/toju-app/src/app/domains/server-directory/infrastructure/services/server-directory-api.service.ts +++ b/toju-app/src/app/domains/server-directory/infrastructure/services/server-directory-api.service.ts @@ -8,7 +8,11 @@ import { of, throwError } from 'rxjs'; -import { catchError, map, scan } from 'rxjs/operators'; +import { + catchError, + map, + scan +} from 'rxjs/operators'; import { ChannelPermissionOverride, type Channel, @@ -32,6 +36,19 @@ import type { } from '../../domain/models/server-directory.model'; import type { RoomSignalSourceInput } from '../../domain/logic/room-signal-source.logic'; +interface ServerLookupError { + status?: number; + error?: { + errorCode?: unknown; + }; +} + +function isServerNotFoundError(error: unknown): boolean { + const lookupError = error as ServerLookupError; + + return lookupError?.status === 404 && lookupError.error?.errorCode === 'SERVER_NOT_FOUND'; +} + @Injectable({ providedIn: 'root' }) export class ServerDirectoryApiService { private readonly http = inject(HttpClient); @@ -90,6 +107,10 @@ export class ServerDirectoryApiService { return this.http.get(`${this.getApiBaseUrl(selector)}/servers/${serverId}`).pipe( map((server) => this.normalizeServerInfo(server, this.resolveEndpoint(selector))), catchError((error) => { + if (isServerNotFoundError(error)) { + return of(null); + } + console.error('Failed to get server:', error); return of(null); }) diff --git a/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html b/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html index bbaf4c1..4e952e7 100644 --- a/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html +++ b/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html @@ -277,6 +277,29 @@ />

{{ currentUser()?.displayName }}

+ @if (currentUser()?.gameActivity; as activity) { +

+ + @if (activity.store?.url) { + + } @else { + Playing {{ activity.name }} + } + {{ gameActivityElapsed(currentUser()) }} +

+ }
@if (currentUser()?.voiceState?.isConnected) {

@@ -340,6 +363,29 @@ Mod }

+ @if (user.gameActivity; as activity) { +

+ + @if (activity.store?.url) { + + } @else { + Playing {{ activity.name }} + } + {{ gameActivityElapsed(user) }} +

+ }
@if (user.voiceState?.isConnected) {

diff --git a/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts b/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts index 0663c65..0466fd6 100644 --- a/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts +++ b/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts @@ -4,6 +4,7 @@ import { inject, computed, input, + OnDestroy, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; @@ -22,7 +23,8 @@ import { lucideHash, lucideUsers, lucidePlus, - lucideVolumeX + lucideVolumeX, + lucideGamepad2 } from '@ng-icons/lucide'; import { selectOnlineUsers, selectCurrentUser } from '../../../store/users/users.selectors'; import { @@ -46,6 +48,8 @@ import { import { VoiceSessionFacade, VoiceWorkspaceService } from '../../../domains/voice-session'; import { DirectMessageService } from '../../../domains/direct-message'; import { VoicePlaybackService } from '../../../domains/voice-connection'; +import { formatGameActivityElapsed } from '../../../domains/game-activity'; +import { ExternalLinkService } from '../../../core/platform/external-link.service'; import { VoiceControlsComponent } from '../../../domains/voice-session/feature/voice-controls/voice-controls.component'; import { isChannelNameTaken, normalizeChannelName } from '../../../store/rooms/room-channels.rules'; import { @@ -64,6 +68,7 @@ import { import { Channel, ChatEvent, + GameActivity, RoomMember, Room, User @@ -98,12 +103,13 @@ type PanelMode = 'channels' | 'users'; lucideHash, lucideUsers, lucidePlus, - lucideVolumeX + lucideVolumeX, + lucideGamepad2 }) ], templateUrl: './rooms-side-panel.component.html' }) -export class RoomsSidePanelComponent { +export class RoomsSidePanelComponent implements OnDestroy { private store = inject(Store); private router = inject(Router); private realtime = inject(RealtimeSessionFacade); @@ -115,9 +121,11 @@ export class RoomsSidePanelComponent { private voicePlayback = inject(VoicePlaybackService); private profileCard = inject(ProfileCardService); private directMessages = inject(DirectMessageService); + private readonly externalLinks = inject(ExternalLinkService); private readonly voiceActivity = inject(VoiceActivityService); private readonly voiceConnectivity = inject(VoiceConnectivityHealthService); private profileCardOpenTimer: ReturnType | null = null; + private readonly activityTimer = setInterval(() => this.activityNow.set(Date.now()), 1_000); readonly panelMode = input('channels'); readonly showVoiceControls = input(true); @@ -198,6 +206,26 @@ export class RoomsSidePanelComponent { volumeMenuDisplayName = signal(''); draggedVoiceUserId = signal(null); dragTargetVoiceChannelId = signal(null); + activityNow = signal(Date.now()); + + ngOnDestroy(): void { + clearInterval(this.activityTimer); + this.cancelQueuedProfileCardOpen(); + } + + gameActivityElapsed(user: User | null | undefined): string { + const activity = user?.gameActivity; + + return activity ? formatGameActivityElapsed(activity.startedAt, this.activityNow()) : ''; + } + + openGameStore(event: Event, activity: GameActivity): void { + event.stopPropagation(); + + if (activity.store?.url) { + this.externalLinks.open(activity.store.url); + } + } openProfileCard(event: Event, user: User, editable: boolean): void { event.stopPropagation(); diff --git a/toju-app/src/app/features/settings/settings-modal/settings-modal.component.html b/toju-app/src/app/features/settings/settings-modal/settings-modal.component.html index c7633e6..1adea0b 100644 --- a/toju-app/src/app/features/settings/settings-modal/settings-modal.component.html +++ b/toju-app/src/app/features/settings/settings-modal/settings-modal.component.html @@ -28,6 +28,7 @@ (keydown.space)="$event.stopPropagation()" role="dialog" aria-modal="true" + aria-labelledby="settings-modal-title" tabindex="-1" > @@ -36,7 +37,12 @@ class="flex w-56 flex-shrink-0 flex-col border-r border-border bg-card" >

-

Settings

+

+ Settings +

diff --git a/toju-app/src/app/shared-kernel/README.md b/toju-app/src/app/shared-kernel/README.md index 7bda573..38f4430 100644 --- a/toju-app/src/app/shared-kernel/README.md +++ b/toju-app/src/app/shared-kernel/README.md @@ -13,6 +13,7 @@ require coordination. | `message.models.ts` | `Message`, `Reaction`, `DELETED_MESSAGE_CONTENT` | | `moderation.models.ts` | `BanEntry` | | `voice-state.models.ts` | `VoiceState`, `ScreenShareState` | +| `game-activity.models.ts` | `GameActivity`, `MatchedGame`, game-match API response contract | | `chat-events.ts` | `ChatEventType`, `ChatEvent`, `ChatInventoryItem` | | `direct-message-contracts.ts` | `DirectMessage`, delivery status, P2P DM event payloads | | `media-preferences.ts` | `LatencyProfile`, `ScreenShareQuality`, quality presets | diff --git a/toju-app/src/app/shared-kernel/chat-events.ts b/toju-app/src/app/shared-kernel/chat-events.ts index 863b2f4..f249248 100644 --- a/toju-app/src/app/shared-kernel/chat-events.ts +++ b/toju-app/src/app/shared-kernel/chat-events.ts @@ -7,6 +7,7 @@ import type { Channel } from './room.models'; import type { VoiceState } from './voice-state.models'; +import type { GameActivity } from './game-activity.models'; import type { BanEntry } from './moderation.models'; import type { ChatAttachmentAnnouncement, ChatAttachmentMeta } from './attachment-contracts'; import type { @@ -66,6 +67,7 @@ export interface ChatEventBase { settings?: Partial; permissions?: Partial; voiceState?: Partial; + gameActivity?: GameActivity | null; isScreenSharing?: boolean; isCameraEnabled?: boolean; icon?: string; @@ -237,6 +239,11 @@ export interface CameraStateEvent extends ChatEventBase { isCameraEnabled: boolean; } +export interface GameActivityEvent extends ChatEventBase { + type: 'game-activity'; + gameActivity: GameActivity | null; +} + export interface VoiceStateRequestEvent extends ChatEventBase { type: 'voice-state-request'; } @@ -410,6 +417,7 @@ export type ChatEvent = | VoiceChannelMoveEvent | ScreenStateEvent | CameraStateEvent + | GameActivityEvent | VoiceStateRequestEvent | StateRequestEvent | ScreenShareRequestEvent diff --git a/toju-app/src/app/shared-kernel/game-activity.models.ts b/toju-app/src/app/shared-kernel/game-activity.models.ts new file mode 100644 index 0000000..9f315e2 --- /dev/null +++ b/toju-app/src/app/shared-kernel/game-activity.models.ts @@ -0,0 +1,28 @@ +export interface GameActivity { + id: string; + name: string; + iconUrl?: string; + store?: GameStoreLink; + startedAt: number; +} + +export interface GameStoreLink { + id?: string; + name: string; + slug?: string; + domain?: string; + url: string; +} + +export interface MatchedGame { + id: string; + name: string; + iconUrl?: string; + store?: GameStoreLink; + processName: string; +} + +export interface GameMatchResponse { + games: MatchedGame[]; + rateLimited?: boolean; +} diff --git a/toju-app/src/app/shared-kernel/index.ts b/toju-app/src/app/shared-kernel/index.ts index 1bc0c95..fd6bbc3 100644 --- a/toju-app/src/app/shared-kernel/index.ts +++ b/toju-app/src/app/shared-kernel/index.ts @@ -4,6 +4,7 @@ export * from './access-control.models'; export * from './message.models'; export * from './moderation.models'; export * from './voice-state.models'; +export * from './game-activity.models'; export * from './direct-message-contracts'; export * from './chat-events'; export * from './media-preferences'; diff --git a/toju-app/src/app/shared-kernel/user.models.ts b/toju-app/src/app/shared-kernel/user.models.ts index 9aefa1b..1b28899 100644 --- a/toju-app/src/app/shared-kernel/user.models.ts +++ b/toju-app/src/app/shared-kernel/user.models.ts @@ -3,6 +3,7 @@ import type { VoiceState, ScreenShareState } from './voice-state.models'; +import type { GameActivity } from './game-activity.models'; export type UserStatus = 'online' | 'away' | 'busy' | 'offline' | 'disconnected'; @@ -30,6 +31,7 @@ export interface User { voiceState?: VoiceState; screenShareState?: ScreenShareState; cameraState?: CameraState; + gameActivity?: GameActivity; } export interface RoomMember { diff --git a/toju-app/src/app/shared/components/profile-card/profile-card.component.html b/toju-app/src/app/shared/components/profile-card/profile-card.component.html index 810e342..5fa23a3 100644 --- a/toju-app/src/app/shared/components/profile-card/profile-card.component.html +++ b/toju-app/src/app/shared/components/profile-card/profile-card.component.html @@ -66,6 +66,26 @@ }

{{ profileUser.username }}

+ @if (profileUser.gameActivity; as activity) { +

+ + @if (activity.store?.url) { + + } @else { + Playing {{ activity.name }} + } + {{ gameActivityElapsed() }} +

+ }
@@ -92,11 +112,79 @@ }
+ + @if (profileUser.gameActivity; as activity) { +
+ @if (activity.iconUrl) { + + } @else { +
+ +
+ } +
+

Playing

+ @if (activity.store?.url) { + + } @else { +

{{ activity.name }}

+ } +

{{ gameActivityElapsed() }}

+
+
+ }
} @else {

{{ profileUser.displayName }}

{{ profileUser.username }}

+ @if (profileUser.gameActivity; as activity) { +
+ @if (activity.iconUrl) { + + } @else { +
+ +
+ } +
+

Playing

+ @if (activity.store?.url) { + + } @else { +

{{ activity.name }}

+ } +

{{ gameActivityElapsed() }}

+
+
+ } + @if (profileUser.description) {

{{ profileUser.description }}

} diff --git a/toju-app/src/app/shared/components/profile-card/profile-card.component.ts b/toju-app/src/app/shared/components/profile-card/profile-card.component.ts index e80e2cb..c12e1d5 100644 --- a/toju-app/src/app/shared/components/profile-card/profile-card.component.ts +++ b/toju-app/src/app/shared/components/profile-card/profile-card.component.ts @@ -3,15 +3,24 @@ import { computed, effect, inject, + OnDestroy, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { Store } from '@ngrx/store'; import { NgIcon, provideIcons } from '@ng-icons/core'; -import { lucideCheck, lucideChevronDown } from '@ng-icons/lucide'; +import { + lucideCheck, + lucideChevronDown, + lucideGamepad2 +} from '@ng-icons/lucide'; import { UserAvatarComponent } from '../user-avatar/user-avatar.component'; import { UserStatusService } from '../../../core/services/user-status.service'; -import { User, UserStatus } from '../../../shared-kernel'; +import { + GameActivity, + User, + UserStatus +} from '../../../shared-kernel'; import { EditableProfileAvatarSource, ProfileAvatarFacade, @@ -22,6 +31,8 @@ import { import { UsersActions } from '../../../store/users/users.actions'; import { selectUsersEntities } from '../../../store/users/users.selectors'; import { ThemeNodeDirective } from '../../../domains/theme'; +import { formatGameActivityElapsed } from '../../../domains/game-activity'; +import { ExternalLinkService } from '../../../core/platform/external-link.service'; @Component({ selector: 'app-profile-card', @@ -32,10 +43,10 @@ import { ThemeNodeDirective } from '../../../domains/theme'; UserAvatarComponent, ThemeNodeDirective ], - viewProviders: [provideIcons({ lucideCheck, lucideChevronDown })], + viewProviders: [provideIcons({ lucideCheck, lucideChevronDown, lucideGamepad2 })], templateUrl: './profile-card.component.html' }) -export class ProfileCardComponent { +export class ProfileCardComponent implements OnDestroy { readonly user = signal({ id: '', oderId: '', username: '', displayName: '', status: 'offline', role: 'member', joinedAt: 0 }); readonly displayedUser = computed(() => { const snapshot = this.user(); @@ -52,6 +63,7 @@ export class ProfileCardComponent { readonly editingField = signal<'displayName' | 'description' | null>(null); readonly displayNameDraft = signal(''); readonly descriptionDraft = signal(''); + readonly activityNow = signal(Date.now()); readonly statusOptions: { value: UserStatus | null; label: string; color: string }[] = [ { value: null, label: 'Online', color: 'bg-green-500' }, @@ -65,6 +77,8 @@ export class ProfileCardComponent { private readonly userStatus = inject(UserStatusService); private readonly profileAvatar = inject(ProfileAvatarFacade); private readonly profileAvatarEditor = inject(ProfileAvatarEditorService); + private readonly externalLinks = inject(ExternalLinkService); + private readonly activityTimer = setInterval(() => this.activityNow.set(Date.now()), 1_000); private readonly syncProfileDrafts = effect( () => { const user = this.displayedUser(); @@ -115,6 +129,24 @@ export class ProfileCardComponent { } } + gameActivityElapsed(): string { + const activity = this.displayedUser().gameActivity; + + return activity ? formatGameActivityElapsed(activity.startedAt, this.activityNow()) : ''; + } + + openGameStore(activity: GameActivity, event: Event): void { + event.stopPropagation(); + + if (activity.store?.url) { + this.externalLinks.open(activity.store.url); + } + } + + ngOnDestroy(): void { + clearInterval(this.activityTimer); + } + toggleStatusMenu(): void { this.showStatusMenu.update((isOpen) => !isOpen); } diff --git a/toju-app/src/app/store/users/users.actions.ts b/toju-app/src/app/store/users/users.actions.ts index f8d3dc0..59d22ab 100644 --- a/toju-app/src/app/store/users/users.actions.ts +++ b/toju-app/src/app/store/users/users.actions.ts @@ -12,7 +12,8 @@ import { BanEntry, VoiceState, ScreenShareState, - CameraState + CameraState, + GameActivity } from '../../shared-kernel'; export const UsersActions = createActionGroup({ @@ -65,6 +66,7 @@ export const UsersActions = createActionGroup({ 'Update Voice State': props<{ userId: string; voiceState: Partial }>(), 'Update Screen Share State': props<{ userId: string; screenShareState: Partial }>(), 'Update Camera State': props<{ userId: string; cameraState: Partial }>(), + 'Update Game Activity': props<{ userId: string; gameActivity: GameActivity | null }>(), 'Set Manual Status': props<{ status: UserStatus | null }>(), 'Update Remote User Status': props<{ userId: string; status: UserStatus }>(), diff --git a/toju-app/src/app/store/users/users.reducer.ts b/toju-app/src/app/store/users/users.reducer.ts index f8a72e1..27ffcda 100644 --- a/toju-app/src/app/store/users/users.reducer.ts +++ b/toju-app/src/app/store/users/users.reducer.ts @@ -242,7 +242,8 @@ function buildPresenceRemovalChanges( status: isOnline ? (user.status !== 'offline' ? user.status : 'online') : 'offline', voiceState: shouldClearLiveState ? buildDisconnectedVoiceState(user) : user.voiceState, screenShareState: shouldClearLiveState ? buildInactiveScreenShareState(user) : user.screenShareState, - cameraState: shouldClearLiveState ? buildInactiveCameraState(user) : user.cameraState + cameraState: shouldClearLiveState ? buildInactiveCameraState(user) : user.cameraState, + gameActivity: isOnline ? user.gameActivity : undefined }; } @@ -555,6 +556,23 @@ export const usersReducer = createReducer( state ); }), + on(UsersActions.updateGameActivity, (state, { userId, gameActivity }) => { + const existingUser = state.entities[userId]; + + if (!existingUser) { + return state; + } + + return usersAdapter.updateOne( + { + id: userId, + changes: { + gameActivity: gameActivity ?? undefined + } + }, + state + ); + }), on(UsersActions.syncUsers, (state, { users }) => usersAdapter.upsertMany(users, state) ),