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

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

View File

@@ -80,6 +80,11 @@ export class ServerSearchPage {
} }
async joinServerFromSearch(name: string) { 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();
} }
} }

View File

@@ -44,9 +44,11 @@ test.describe('Chat messaging features', () => {
await test.step('Opening first server once restores only its channels', async () => { await test.step('Opening first server once restores only its channels', async () => {
await openSavedRoomByName(scenario.client.page, alphaServerName); await openSavedRoomByName(scenario.client.page, alphaServerName);
await expect( await expect(
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${alphaChannelName}"]`) channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${alphaChannelName}"]`)
).toBeVisible({ timeout: 20_000 }); ).toBeVisible({ timeout: 20_000 });
await expect( await expect(
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${betaChannelName}"]`) channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${betaChannelName}"]`)
).toHaveCount(0); ).toHaveCount(0);
@@ -54,9 +56,11 @@ test.describe('Chat messaging features', () => {
await test.step('Opening second server once restores only its channels', async () => { await test.step('Opening second server once restores only its channels', async () => {
await openSavedRoomByName(scenario.client.page, betaServerName); await openSavedRoomByName(scenario.client.page, betaServerName);
await expect( await expect(
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${betaChannelName}"]`) channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${betaChannelName}"]`)
).toBeVisible({ timeout: 20_000 }); ).toBeVisible({ timeout: 20_000 });
await expect( await expect(
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${alphaChannelName}"]`) channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${alphaChannelName}"]`)
).toHaveCount(0); ).toHaveCount(0);
@@ -304,11 +308,8 @@ async function createChatScenario(createClient: () => Promise<Client>): Promise<
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 }); await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
const bobSearchPage = new ServerSearchPage(bob.page); const bobSearchPage = new ServerSearchPage(bob.page);
const serverCard = bob.page.locator('button', { hasText: serverName }).first();
await bobSearchPage.searchInput.fill(serverName); await bobSearchPage.joinServerFromSearch(serverName);
await expect(serverCard).toBeVisible({ timeout: 15_000 });
await serverCard.click();
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 }); await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
const aliceRoom = new ChatRoomPage(alice.page); const aliceRoom = new ChatRoomPage(alice.page);

View File

@@ -41,14 +41,16 @@ test.describe('Direct message flow', () => {
await expect(scenario.alice.page).toHaveURL(/\/search/, { timeout: 20_000 }); 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-server-search')).toBeVisible({ timeout: 20_000 });
await expect(scenario.alice.page.locator('app-user-search-list')).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 }); await expect(bobPeopleCard).toBeVisible({ timeout: 15_000 });
const friendButton = bobPeopleCard.locator(`[data-testid="friend-button-${scenario.bobUserId}"]`); 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(friendButton).toBeAttached({ timeout: 15_000 });
await expect(messageButton).toBeVisible({ timeout: 15_000 }); await expect(messageButton).toBeAttached({ timeout: 15_000 });
}); });
}); });
@@ -76,12 +78,7 @@ async function createDmScenario(createClient: () => Promise<Client>): Promise<Dm
const bobSearch = new ServerSearchPage(bob.page); const bobSearch = new ServerSearchPage(bob.page);
await bobSearch.searchInput.fill(serverName); await bobSearch.joinServerFromSearch(serverName);
await bob.page.locator('button', { hasText: serverName })
.first()
.click();
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 }); await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
await new ChatMessagesPage(bob.page).waitForReady(); await new ChatMessagesPage(bob.page).waitForReady();
const bobRoomCard = alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' }).first(); const bobRoomCard = alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' }).first();

View File

@@ -3,10 +3,7 @@ import {
type Locator, type Locator,
type Page type Page
} from '@playwright/test'; } from '@playwright/test';
import { import { test, type Client } from '../../fixtures/multi-client';
test,
type Client
} from '../../fixtures/multi-client';
import { RegisterPage } from '../../pages/register.page'; import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page'; import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatRoomPage } from '../../pages/chat-room.page'; import { ChatRoomPage } from '../../pages/chat-room.page';
@@ -109,14 +106,12 @@ async function createNotificationScenario(createClient: () => Promise<Client>):
await aliceSearch.createServer(serverName, { await aliceSearch.createServer(serverName, {
description: 'E2E notification coverage server' description: 'E2E notification coverage server'
}); });
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 }); await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
const bobSearch = new ServerSearchPage(bob.page); const bobSearch = new ServerSearchPage(bob.page);
const serverCard = bob.page.locator('button', { hasText: serverName }).first();
await bobSearch.searchInput.fill(serverName); await bobSearch.joinServerFromSearch(serverName);
await expect(serverCard).toBeVisible({ timeout: 15_000 });
await serverCard.click();
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 }); await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
const aliceRoom = new ChatRoomPage(alice.page); const aliceRoom = new ChatRoomPage(alice.page);
@@ -155,10 +150,6 @@ async function installDesktopNotificationSpy(page: Page): Promise<void> {
class MockNotification { class MockNotification {
static permission = 'granted'; static permission = 'granted';
static async requestPermission(): Promise<NotificationPermission> {
return 'granted';
}
onclick: (() => void) | null = null; onclick: (() => void) | null = null;
constructor(title: string, options?: NotificationOptions) { constructor(title: string, options?: NotificationOptions) {
@@ -168,6 +159,10 @@ async function installDesktopNotificationSpy(page: Page): Promise<void> {
}); });
} }
static async requestPermission(): Promise<NotificationPermission> {
return 'granted';
}
close(): void { close(): void {
return; return;
} }
@@ -256,7 +251,8 @@ function getUnreadBadge(container: Locator): Locator {
} }
function uniqueName(prefix: string): string { 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 { interface WindowWithDesktopNotifications extends Window {

View File

@@ -384,11 +384,8 @@ async function registerUser(client: PersistentClient): Promise<void> {
async function joinServerFromSearch(page: Page, serverName: string): Promise<void> { async function joinServerFromSearch(page: Page, serverName: string): Promise<void> {
const searchPage = new ServerSearchPage(page); const searchPage = new ServerSearchPage(page);
const serverCard = page.locator('button', { hasText: serverName }).first();
await searchPage.searchInput.fill(serverName); await searchPage.joinServerFromSearch(serverName);
await expect(serverCard).toBeVisible({ timeout: 15_000 });
await serverCard.click();
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 }); await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
} }

View File

@@ -56,12 +56,7 @@ async function setupServerWithBothUsers(
// Bob joins server // Bob joins server
const bobSearch = new ServerSearchPage(bob.page); const bobSearch = new ServerSearchPage(bob.page);
await bobSearch.searchInput.fill(SERVER_NAME); await bobSearch.joinServerFromSearch(SERVER_NAME);
const serverCard = bob.page.locator('button', { hasText: SERVER_NAME }).first();
await expect(serverCard).toBeVisible({ timeout: 10_000 });
await serverCard.click();
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 }); await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
} }

View File

@@ -117,22 +117,14 @@ test.describe('Connectivity warning', () => {
await test.step('Bob joins the server', async () => { await test.step('Bob joins the server', async () => {
const search = new ServerSearchPage(bob.page); const search = new ServerSearchPage(bob.page);
await search.searchInput.fill(serverName); await search.joinServerFromSearch(serverName);
const card = bob.page.locator('button', { hasText: serverName }).first();
await expect(card).toBeVisible({ timeout: 15_000 });
await card.click();
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 }); await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
}); });
await test.step('Charlie joins the server', async () => { await test.step('Charlie joins the server', async () => {
const search = new ServerSearchPage(charlie.page); const search = new ServerSearchPage(charlie.page);
await search.searchInput.fill(serverName); await search.joinServerFromSearch(serverName);
const card = charlie.page.locator('button', { hasText: serverName }).first();
await expect(card).toBeVisible({ timeout: 15_000 });
await card.click();
await expect(charlie.page).toHaveURL(/\/room\//, { timeout: 15_000 }); await expect(charlie.page).toHaveURL(/\/room\//, { timeout: 15_000 });
}); });

View File

@@ -11,8 +11,9 @@ test.describe('ICE server settings', () => {
await register.register(`user_${suffix}`, 'IceTestUser', 'TestPass123!'); await register.register(`user_${suffix}`, 'IceTestUser', 'TestPass123!');
await expect(page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 }); await expect(page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
await page.getByTitle('Settings').click(); 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 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 }) => { 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.reload({ waitUntil: 'domcontentloaded' });
await page.getByTitle('Settings').click(); 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 page.getByRole('button', { name: 'Network' }).click();
await expect(page.getByText('stun:persist-test.example.com:3478')).toBeVisible({ timeout: 10_000 }); await expect(page.getByText('stun:persist-test.example.com:3478')).toBeVisible({ timeout: 10_000 });
}); });

View File

@@ -109,11 +109,7 @@ test.describe('STUN/TURN fallback behaviour', () => {
await test.step('Bob joins Alice server', async () => { await test.step('Bob joins Alice server', async () => {
const search = new ServerSearchPage(bob.page); const search = new ServerSearchPage(bob.page);
await search.searchInput.fill(serverName); await search.joinServerFromSearch(serverName);
const serverCard = bob.page.locator('button', { hasText: serverName }).first();
await expect(serverCard).toBeVisible({ timeout: 15_000 });
await serverCard.click();
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 }); await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
}); });

View File

@@ -572,10 +572,10 @@ async function joinRoomFromSearch(page: Page, roomName: string): Promise<void> {
await expect(searchInput).toBeVisible({ timeout: 20_000 }); await expect(searchInput).toBeVisible({ timeout: 20_000 });
await searchInput.fill(roomName); 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 expect(roomCard).toBeVisible({ timeout: 20_000 });
await roomCard.click(); await roomCard.dblclick();
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 }); await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 }); await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
await waitForCurrentRoomName(page, roomName); await waitForCurrentRoomName(page, roomName);

View File

@@ -335,10 +335,10 @@ async function joinRoomFromSearch(page: Page, roomName: string): Promise<void> {
await expect(searchInput).toBeVisible({ timeout: 20_000 }); await expect(searchInput).toBeVisible({ timeout: 20_000 });
await searchInput.fill(roomName); 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 expect(roomCard).toBeVisible({ timeout: 20_000 });
await roomCard.click(); await roomCard.dblclick();
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 }); await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 }); await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
await waitForCurrentRoomName(page, roomName); await waitForCurrentRoomName(page, roomName);

View File

@@ -96,14 +96,7 @@ test.describe('Full user journey: register -> server -> voice chat', () => {
await test.step('Bob finds and joins the server', async () => { await test.step('Bob finds and joins the server', async () => {
const searchPage = new ServerSearchPage(bob.page); const searchPage = new ServerSearchPage(bob.page);
// Search for the server await searchPage.joinServerFromSearch(SERVER_NAME);
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();
// Bob should be in the room now // Bob should be in the room now
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 }); await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });

View File

@@ -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 | | `main.ts` | Electron app bootstrap and process entry point |
| `preload.ts` | Typed renderer-facing preload bridge | | `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 | | `app/` | App lifecycle and startup composition |
| `ipc/` | Renderer-invoked IPC handlers | | `ipc/` | Renderer-invoked IPC handlers |
| `cqrs/` | Local database command/query handlers and mappings | | `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. - 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. - Treat `dist/electron/` and `dist-electron/` as generated output.
- See [AGENTS.md](AGENTS.md) for package-level editing rules. - See [AGENTS.md](AGENTS.md) for package-level editing rules.

View File

@@ -55,6 +55,7 @@ import {
importUserData, importUserData,
openCurrentDataFolder openCurrentDataFolder
} from '../data-management'; } from '../data-management';
import { listRunningProcessNames } from '../process-list';
const DEFAULT_MIME_TYPE = 'application/octet-stream'; const DEFAULT_MIME_TYPE = 'application/octet-stream';
const FILE_CLIPBOARD_FORMATS = [ 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 () => { ipcMain.handle('prepare-linux-screen-share-audio-routing', async () => {
return await prepareLinuxScreenShareAudioRouting(); return await prepareLinuxScreenShareAudioRouting();
}); });

View File

@@ -167,6 +167,7 @@ export interface ElectronAPI {
openExternal: (url: string) => Promise<boolean>; openExternal: (url: string) => Promise<boolean>;
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>; getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
getRunningProcessNames: () => Promise<string[]>;
prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>; prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>; activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>; deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>;
@@ -252,6 +253,7 @@ const electronAPI: ElectronAPI = {
openExternal: (url) => ipcRenderer.invoke('open-external', url), openExternal: (url) => ipcRenderer.invoke('open-external', url),
getSources: () => ipcRenderer.invoke('get-sources'), getSources: () => ipcRenderer.invoke('get-sources'),
getRunningProcessNames: () => ipcRenderer.invoke('get-running-process-names'),
prepareLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('prepare-linux-screen-share-audio-routing'), prepareLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('prepare-linux-screen-share-audio-routing'),
activateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('activate-linux-screen-share-audio-routing'), activateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('activate-linux-screen-share-audio-routing'),
deactivateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('deactivate-linux-screen-share-audio-routing'), deactivateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('deactivate-linux-screen-share-audio-routing'),

85
electron/process-list.ts Normal file
View File

@@ -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<string[]> {
if (process.platform === 'win32') {
return normalizeProcessNames(await listWindowsProcessNames());
}
if (process.platform === 'linux') {
return normalizeProcessNames(await listLinuxProcessNames());
}
return [];
}
async function listLinuxProcessNames(): Promise<string[]> {
const { stdout } = await execFileAsync('ps', ['-eo', 'comm='], {
maxBuffer: 1024 * 1024,
timeout: 5_000
});
return stdout.split('\n');
}
async function listWindowsProcessNames(): Promise<string[]> {
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<string>();
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;
}

View File

@@ -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. - The server loads the repository-root `.env` file on startup.
- `SSL` can override the effective HTTP protocol, and `PORT` can override the effective port. - `SSL` can override the effective HTTP protocol, and `PORT` can override the effective port.
- `DB_PATH` can override the SQLite database file location. - `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. - 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. - 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 ## Notes
- `dist/` and `../dist-server/` are generated output. - `dist/` and `../dist-server/` are generated output.
- See [AGENTS.md](AGENTS.md) for package-specific editing guidance. - See [AGENTS.md](AGENTS.md) for package-specific editing guidance.

View File

@@ -12,6 +12,7 @@ export interface LinkPreviewConfig {
export interface ServerVariablesConfig { export interface ServerVariablesConfig {
klipyApiKey: string; klipyApiKey: string;
rawgApiKey: string;
releaseManifestUrl: string; releaseManifestUrl: string;
serverPort: number; serverPort: number;
serverProtocol: ServerHttpProtocol; serverProtocol: ServerHttpProtocol;
@@ -31,6 +32,10 @@ function normalizeKlipyApiKey(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''; return typeof value === 'string' ? value.trim() : '';
} }
function normalizeRawgApiKey(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeReleaseManifestUrl(value: unknown): string { function normalizeReleaseManifestUrl(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''; return typeof value === 'string' ? value.trim() : '';
} }
@@ -139,6 +144,7 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
const normalized = { const normalized = {
...remainingParsed, ...remainingParsed,
klipyApiKey: normalizeKlipyApiKey(remainingParsed.klipyApiKey), klipyApiKey: normalizeKlipyApiKey(remainingParsed.klipyApiKey),
rawgApiKey: normalizeRawgApiKey(remainingParsed.rawgApiKey),
releaseManifestUrl: normalizeReleaseManifestUrl(remainingParsed.releaseManifestUrl), releaseManifestUrl: normalizeReleaseManifestUrl(remainingParsed.releaseManifestUrl),
serverPort: normalizeServerPort(remainingParsed.serverPort), serverPort: normalizeServerPort(remainingParsed.serverPort),
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol), serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
@@ -153,6 +159,7 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
return { return {
klipyApiKey: normalized.klipyApiKey, klipyApiKey: normalized.klipyApiKey,
rawgApiKey: normalized.rawgApiKey,
releaseManifestUrl: normalized.releaseManifestUrl, releaseManifestUrl: normalized.releaseManifestUrl,
serverPort: normalized.serverPort, serverPort: normalized.serverPort,
serverProtocol: normalized.serverProtocol, serverProtocol: normalized.serverProtocol,
@@ -169,6 +176,14 @@ export function getKlipyApiKey(): string {
return getVariablesConfig().klipyApiKey; 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 { export function hasKlipyApiKey(): boolean {
return getKlipyApiKey().length > 0; return getKlipyApiKey().length > 0;
} }

View File

@@ -14,7 +14,8 @@ import {
JoinRequestEntity, JoinRequestEntity,
ServerMembershipEntity, ServerMembershipEntity,
ServerInviteEntity, ServerInviteEntity,
ServerBanEntity ServerBanEntity,
GameMatchMissEntity
} from '../entities'; } from '../entities';
import { serverMigrations } from '../migrations'; import { serverMigrations } from '../migrations';
import { import {
@@ -202,7 +203,8 @@ export async function initDatabase(): Promise<void> {
JoinRequestEntity, JoinRequestEntity,
ServerMembershipEntity, ServerMembershipEntity,
ServerInviteEntity, ServerInviteEntity,
ServerBanEntity ServerBanEntity,
GameMatchMissEntity
], ],
migrations: serverMigrations, migrations: serverMigrations,
synchronize: process.env.DB_SYNCHRONIZE === 'true', synchronize: process.env.DB_SYNCHRONIZE === 'true',

View File

@@ -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;
}

View File

@@ -9,3 +9,4 @@ export { JoinRequestEntity } from './JoinRequestEntity';
export { ServerMembershipEntity } from './ServerMembershipEntity'; export { ServerMembershipEntity } from './ServerMembershipEntity';
export { ServerInviteEntity } from './ServerInviteEntity'; export { ServerInviteEntity } from './ServerInviteEntity';
export { ServerBanEntity } from './ServerBanEntity'; export { ServerBanEntity } from './ServerBanEntity';
export { GameMatchMissEntity } from './GameMatchMissEntity';

View File

@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class GameMatchMisses1000000000006 implements MigrationInterface {
name = 'GameMatchMisses1000000000006';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "game_match_misses"`);
}
}

View File

@@ -4,6 +4,7 @@ import { ServerChannels1000000000002 } from './1000000000002-ServerChannels';
import { RepairLegacyVoiceChannels1000000000003 } from './1000000000003-RepairLegacyVoiceChannels'; import { RepairLegacyVoiceChannels1000000000003 } from './1000000000003-RepairLegacyVoiceChannels';
import { NormalizeServerArrays1000000000004 } from './1000000000004-NormalizeServerArrays'; import { NormalizeServerArrays1000000000004 } from './1000000000004-NormalizeServerArrays';
import { ServerRoleAccessControl1000000000005 } from './1000000000005-ServerRoleAccessControl'; import { ServerRoleAccessControl1000000000005 } from './1000000000005-ServerRoleAccessControl';
import { GameMatchMisses1000000000006 } from './1000000000006-GameMatchMisses';
export const serverMigrations = [ export const serverMigrations = [
InitialSchema1000000000000, InitialSchema1000000000000,
@@ -11,5 +12,6 @@ export const serverMigrations = [
ServerChannels1000000000002, ServerChannels1000000000002,
RepairLegacyVoiceChannels1000000000003, RepairLegacyVoiceChannels1000000000003,
NormalizeServerArrays1000000000004, NormalizeServerArrays1000000000004,
ServerRoleAccessControl1000000000005 ServerRoleAccessControl1000000000005,
GameMatchMisses1000000000006
]; ];

View File

@@ -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;

View File

@@ -2,6 +2,7 @@ import { Express } from 'express';
import healthRouter from './health'; import healthRouter from './health';
import klipyRouter from './klipy'; import klipyRouter from './klipy';
import linkMetadataRouter from './link-metadata'; import linkMetadataRouter from './link-metadata';
import gamesRouter from './games';
import proxyRouter from './proxy'; import proxyRouter from './proxy';
import usersRouter from './users'; import usersRouter from './users';
import serversRouter from './servers'; import serversRouter from './servers';
@@ -12,6 +13,7 @@ export function registerRoutes(app: Express): void {
app.use('/api', healthRouter); app.use('/api', healthRouter);
app.use('/api', klipyRouter); app.use('/api', klipyRouter);
app.use('/api', linkMetadataRouter); app.use('/api', linkMetadataRouter);
app.use('/api/games', gamesRouter);
app.use('/api', proxyRouter); app.use('/api', proxyRouter);
app.use('/api/users', usersRouter); app.use('/api/users', usersRouter);
app.use('/api/servers', serversRouter); app.use('/api/servers', serversRouter);

View File

@@ -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<MatchedGame, 'processName'> | 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, (query: string) => 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<string, string>([
['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<string, number>([
['steam', 0],
['gog', 10],
['epic-games', 20],
['itch', 30],
['xbox-store', 80],
['playstation-store', 90]
]);
const cache = new Map<string, CacheEntry>();
const rawgLookupBudgets = new Map<string, RawgLookupBudget>();
export async function matchRunningGames(
processNames: unknown,
requester: unknown = 'anonymous'
): Promise<GameMatchResult> {
const candidates = normalizeProcessList(processNames).slice(0, MAX_CANDIDATE_PROCESSES);
const matches: MatchedGame[] = [];
const seenGameIds = new Set<string>();
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<string, CandidateProcess>();
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<MatchedGame, 'processName'> | 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<MatchedGame, 'processName'> | null): void {
cache.set(cacheKey, {
expiresAt: Date.now() + CACHE_TTL_MS,
game
});
}
async function loadPersistedMissKeys(processNames: string[]): Promise<Set<string>> {
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<void> {
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<Omit<MatchedGame, 'processName'> | 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<Pick<RawgGameResult, 'id' | 'name'>> & 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<string>,
processName: string,
game: Omit<MatchedGame, 'processName'> | null
): void {
if (!game || seenGameIds.has(game.id)) {
return;
}
seenGameIds.add(game.id);
matches.push({
...game,
processName
});
}

View File

@@ -71,25 +71,6 @@ function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: s
const previousDescription = user.description; const previousDescription = user.description;
const previousProfileUpdatedAt = user.profileUpdatedAt; 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.oderId = newOderId;
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName)); user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));

View File

@@ -34,6 +34,7 @@ import { ExternalLinkService } from './core/platform';
import { SettingsModalService } from './core/services/settings-modal.service'; import { SettingsModalService } from './core/services/settings-modal.service';
import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service';
import { UserStatusService } from './core/services/user-status.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 { ServersRailComponent } from './features/servers/servers-rail/servers-rail.component';
import { TitleBarComponent } from './features/shell/title-bar/title-bar.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'; 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 externalLinks = inject(ExternalLinkService);
readonly electronBridge = inject(ElectronBridgeService); readonly electronBridge = inject(ElectronBridgeService);
readonly userStatus = inject(UserStatusService); readonly userStatus = inject(UserStatusService);
readonly gameActivity = inject(GameActivityService);
readonly dismissedDesktopUpdateNoticeKey = signal<string | null>(null); readonly dismissedDesktopUpdateNoticeKey = signal<string | null>(null);
readonly themeStudioFullscreenComponent = signal<Type<unknown> | null>(null); readonly themeStudioFullscreenComponent = signal<Type<unknown> | null>(null);
readonly themeStudioControlsPosition = signal<{ x: number; y: number } | null>(null); readonly themeStudioControlsPosition = signal<{ x: number; y: number } | null>(null);
@@ -246,6 +248,7 @@ export class App implements OnInit, OnDestroy {
await this.setupDesktopDeepLinks(); await this.setupDesktopDeepLinks();
this.userStatus.start(); this.userStatus.start();
this.gameActivity.start();
const currentUrl = this.getCurrentRouteUrl(); const currentUrl = this.getCurrentRouteUrl();
if (!currentUserId) { if (!currentUserId) {

View File

@@ -175,6 +175,7 @@ export interface ElectronApi {
closeWindow: () => void; closeWindow: () => void;
openExternal: (url: string) => Promise<boolean>; openExternal: (url: string) => Promise<boolean>;
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>; getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
getRunningProcessNames: () => Promise<string[]>;
prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>; prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>; activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>; deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>;

View File

@@ -13,6 +13,7 @@ infrastructure adapters and UI.
| **authentication** | Login / register HTTP orchestration, user-bar UI | `AuthenticationService` | | **authentication** | Login / register HTTP orchestration, user-bar UI | `AuthenticationService` |
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` | | **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` | | **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` | | **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` | | **profile-avatar** | Profile picture upload, crop/zoom editing, processing, local persistence, and P2P avatar sync | `ProfileAvatarFacade` |
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` | | **screen-share** | Source picker, quality presets | `ScreenShareFacade` |

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -157,6 +157,14 @@ describe('ServerEndpointStateService', () => {
expect(service.servers().find((candidate) => candidate.id === endpoint.id)?.isActive).toBe(true); 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', () => { it('persists turning a configured default endpoint off and back on', () => {
const defaultServer = getConfiguredDefaultServer('toju-primary'); const defaultServer = getConfiguredDefaultServer('toju-primary');
const service = createService(); const service = createService();

View File

@@ -117,8 +117,9 @@ export class ServerEndpointStateService {
findServerByUrl(url: string): ServerEndpoint | undefined { findServerByUrl(url: string): ServerEndpoint | undefined {
const sanitisedUrl = this.sanitiseUrl(url); 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 { resolveCanonicalEndpoint(endpoint: ServerEndpoint | null | undefined): ServerEndpoint | null {
@@ -447,4 +448,28 @@ export class ServerEndpointStateService {
return false; 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;
}
}
} }

View File

@@ -8,7 +8,11 @@ import {
of, of,
throwError throwError
} from 'rxjs'; } from 'rxjs';
import { catchError, map, scan } from 'rxjs/operators'; import {
catchError,
map,
scan
} from 'rxjs/operators';
import { import {
ChannelPermissionOverride, ChannelPermissionOverride,
type Channel, type Channel,
@@ -32,6 +36,19 @@ import type {
} from '../../domain/models/server-directory.model'; } from '../../domain/models/server-directory.model';
import type { RoomSignalSourceInput } from '../../domain/logic/room-signal-source.logic'; 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' }) @Injectable({ providedIn: 'root' })
export class ServerDirectoryApiService { export class ServerDirectoryApiService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
@@ -90,6 +107,10 @@ export class ServerDirectoryApiService {
return this.http.get<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`).pipe( return this.http.get<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`).pipe(
map((server) => this.normalizeServerInfo(server, this.resolveEndpoint(selector))), map((server) => this.normalizeServerInfo(server, this.resolveEndpoint(selector))),
catchError((error) => { catchError((error) => {
if (isServerNotFoundError(error)) {
return of(null);
}
console.error('Failed to get server:', error); console.error('Failed to get server:', error);
return of(null); return of(null);
}) })

View File

@@ -277,6 +277,29 @@
/> />
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="text-sm text-foreground truncate">{{ currentUser()?.displayName }}</p> <p class="text-sm text-foreground truncate">{{ currentUser()?.displayName }}</p>
@if (currentUser()?.gameActivity; as activity) {
<p class="mt-0.5 flex items-center gap-1 truncate text-[10px] text-muted-foreground">
<ng-icon
name="lucideGamepad2"
class="h-2.5 w-2.5 shrink-0"
/>
@if (activity.store?.url) {
<button
type="button"
class="truncate text-left hover:text-foreground hover:underline"
(click)="openGameStore($event, activity)"
(dblclick)="$event.stopPropagation()"
(keydown.enter)="$event.stopPropagation()"
(keydown.space)="$event.stopPropagation()"
>
Playing {{ activity.name }}
</button>
} @else {
<span class="truncate">Playing {{ activity.name }}</span>
}
<span class="shrink-0">{{ gameActivityElapsed(currentUser()) }}</span>
</p>
}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@if (currentUser()?.voiceState?.isConnected) { @if (currentUser()?.voiceState?.isConnected) {
<p class="text-[10px] text-muted-foreground flex items-center gap-1"> <p class="text-[10px] text-muted-foreground flex items-center gap-1">
@@ -340,6 +363,29 @@
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded font-medium">Mod</span> <span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded font-medium">Mod</span>
} }
</div> </div>
@if (user.gameActivity; as activity) {
<p class="mt-0.5 flex items-center gap-1 truncate text-[10px] text-muted-foreground">
<ng-icon
name="lucideGamepad2"
class="h-2.5 w-2.5 shrink-0"
/>
@if (activity.store?.url) {
<button
type="button"
class="truncate text-left hover:text-foreground hover:underline"
(click)="openGameStore($event, activity)"
(dblclick)="$event.stopPropagation()"
(keydown.enter)="$event.stopPropagation()"
(keydown.space)="$event.stopPropagation()"
>
Playing {{ activity.name }}
</button>
} @else {
<span class="truncate">Playing {{ activity.name }}</span>
}
<span class="shrink-0">{{ gameActivityElapsed(user) }}</span>
</p>
}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@if (user.voiceState?.isConnected) { @if (user.voiceState?.isConnected) {
<p class="text-[10px] text-muted-foreground flex items-center gap-1"> <p class="text-[10px] text-muted-foreground flex items-center gap-1">

View File

@@ -4,6 +4,7 @@ import {
inject, inject,
computed, computed,
input, input,
OnDestroy,
signal signal
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
@@ -22,7 +23,8 @@ import {
lucideHash, lucideHash,
lucideUsers, lucideUsers,
lucidePlus, lucidePlus,
lucideVolumeX lucideVolumeX,
lucideGamepad2
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { selectOnlineUsers, selectCurrentUser } from '../../../store/users/users.selectors'; import { selectOnlineUsers, selectCurrentUser } from '../../../store/users/users.selectors';
import { import {
@@ -46,6 +48,8 @@ import {
import { VoiceSessionFacade, VoiceWorkspaceService } from '../../../domains/voice-session'; import { VoiceSessionFacade, VoiceWorkspaceService } from '../../../domains/voice-session';
import { DirectMessageService } from '../../../domains/direct-message'; import { DirectMessageService } from '../../../domains/direct-message';
import { VoicePlaybackService } from '../../../domains/voice-connection'; 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 { VoiceControlsComponent } from '../../../domains/voice-session/feature/voice-controls/voice-controls.component';
import { isChannelNameTaken, normalizeChannelName } from '../../../store/rooms/room-channels.rules'; import { isChannelNameTaken, normalizeChannelName } from '../../../store/rooms/room-channels.rules';
import { import {
@@ -64,6 +68,7 @@ import {
import { import {
Channel, Channel,
ChatEvent, ChatEvent,
GameActivity,
RoomMember, RoomMember,
Room, Room,
User User
@@ -98,12 +103,13 @@ type PanelMode = 'channels' | 'users';
lucideHash, lucideHash,
lucideUsers, lucideUsers,
lucidePlus, lucidePlus,
lucideVolumeX lucideVolumeX,
lucideGamepad2
}) })
], ],
templateUrl: './rooms-side-panel.component.html' templateUrl: './rooms-side-panel.component.html'
}) })
export class RoomsSidePanelComponent { export class RoomsSidePanelComponent implements OnDestroy {
private store = inject(Store); private store = inject(Store);
private router = inject(Router); private router = inject(Router);
private realtime = inject(RealtimeSessionFacade); private realtime = inject(RealtimeSessionFacade);
@@ -115,9 +121,11 @@ export class RoomsSidePanelComponent {
private voicePlayback = inject(VoicePlaybackService); private voicePlayback = inject(VoicePlaybackService);
private profileCard = inject(ProfileCardService); private profileCard = inject(ProfileCardService);
private directMessages = inject(DirectMessageService); private directMessages = inject(DirectMessageService);
private readonly externalLinks = inject(ExternalLinkService);
private readonly voiceActivity = inject(VoiceActivityService); private readonly voiceActivity = inject(VoiceActivityService);
private readonly voiceConnectivity = inject(VoiceConnectivityHealthService); private readonly voiceConnectivity = inject(VoiceConnectivityHealthService);
private profileCardOpenTimer: ReturnType<typeof setTimeout> | null = null; private profileCardOpenTimer: ReturnType<typeof setTimeout> | null = null;
private readonly activityTimer = setInterval(() => this.activityNow.set(Date.now()), 1_000);
readonly panelMode = input<PanelMode>('channels'); readonly panelMode = input<PanelMode>('channels');
readonly showVoiceControls = input(true); readonly showVoiceControls = input(true);
@@ -198,6 +206,26 @@ export class RoomsSidePanelComponent {
volumeMenuDisplayName = signal(''); volumeMenuDisplayName = signal('');
draggedVoiceUserId = signal<string | null>(null); draggedVoiceUserId = signal<string | null>(null);
dragTargetVoiceChannelId = signal<string | null>(null); dragTargetVoiceChannelId = signal<string | null>(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 { openProfileCard(event: Event, user: User, editable: boolean): void {
event.stopPropagation(); event.stopPropagation();

View File

@@ -28,6 +28,7 @@
(keydown.space)="$event.stopPropagation()" (keydown.space)="$event.stopPropagation()"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="settings-modal-title"
tabindex="-1" tabindex="-1"
> >
<!-- Side Navigation --> <!-- Side Navigation -->
@@ -36,7 +37,12 @@
class="flex w-56 flex-shrink-0 flex-col border-r border-border bg-card" class="flex w-56 flex-shrink-0 flex-col border-r border-border bg-card"
> >
<div class="border-b border-border px-3 py-3"> <div class="border-b border-border px-3 py-3">
<h2 class="text-lg font-semibold text-foreground">Settings</h2> <h2
id="settings-modal-title"
class="text-lg font-semibold text-foreground"
>
Settings
</h2>
</div> </div>
<div class="flex-1 overflow-y-auto py-2"> <div class="flex-1 overflow-y-auto py-2">

View File

@@ -13,6 +13,7 @@ require coordination.
| `message.models.ts` | `Message`, `Reaction`, `DELETED_MESSAGE_CONTENT` | | `message.models.ts` | `Message`, `Reaction`, `DELETED_MESSAGE_CONTENT` |
| `moderation.models.ts` | `BanEntry` | | `moderation.models.ts` | `BanEntry` |
| `voice-state.models.ts` | `VoiceState`, `ScreenShareState` | | `voice-state.models.ts` | `VoiceState`, `ScreenShareState` |
| `game-activity.models.ts` | `GameActivity`, `MatchedGame`, game-match API response contract |
| `chat-events.ts` | `ChatEventType`, `ChatEvent`, `ChatInventoryItem` | | `chat-events.ts` | `ChatEventType`, `ChatEvent`, `ChatInventoryItem` |
| `direct-message-contracts.ts` | `DirectMessage`, delivery status, P2P DM event payloads | | `direct-message-contracts.ts` | `DirectMessage`, delivery status, P2P DM event payloads |
| `media-preferences.ts` | `LatencyProfile`, `ScreenShareQuality`, quality presets | | `media-preferences.ts` | `LatencyProfile`, `ScreenShareQuality`, quality presets |

View File

@@ -7,6 +7,7 @@ import type {
Channel Channel
} from './room.models'; } from './room.models';
import type { VoiceState } from './voice-state.models'; import type { VoiceState } from './voice-state.models';
import type { GameActivity } from './game-activity.models';
import type { BanEntry } from './moderation.models'; import type { BanEntry } from './moderation.models';
import type { ChatAttachmentAnnouncement, ChatAttachmentMeta } from './attachment-contracts'; import type { ChatAttachmentAnnouncement, ChatAttachmentMeta } from './attachment-contracts';
import type { import type {
@@ -66,6 +67,7 @@ export interface ChatEventBase {
settings?: Partial<RoomSettings>; settings?: Partial<RoomSettings>;
permissions?: Partial<RoomPermissions>; permissions?: Partial<RoomPermissions>;
voiceState?: Partial<VoiceState>; voiceState?: Partial<VoiceState>;
gameActivity?: GameActivity | null;
isScreenSharing?: boolean; isScreenSharing?: boolean;
isCameraEnabled?: boolean; isCameraEnabled?: boolean;
icon?: string; icon?: string;
@@ -237,6 +239,11 @@ export interface CameraStateEvent extends ChatEventBase {
isCameraEnabled: boolean; isCameraEnabled: boolean;
} }
export interface GameActivityEvent extends ChatEventBase {
type: 'game-activity';
gameActivity: GameActivity | null;
}
export interface VoiceStateRequestEvent extends ChatEventBase { export interface VoiceStateRequestEvent extends ChatEventBase {
type: 'voice-state-request'; type: 'voice-state-request';
} }
@@ -410,6 +417,7 @@ export type ChatEvent =
| VoiceChannelMoveEvent | VoiceChannelMoveEvent
| ScreenStateEvent | ScreenStateEvent
| CameraStateEvent | CameraStateEvent
| GameActivityEvent
| VoiceStateRequestEvent | VoiceStateRequestEvent
| StateRequestEvent | StateRequestEvent
| ScreenShareRequestEvent | ScreenShareRequestEvent

View File

@@ -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;
}

View File

@@ -4,6 +4,7 @@ export * from './access-control.models';
export * from './message.models'; export * from './message.models';
export * from './moderation.models'; export * from './moderation.models';
export * from './voice-state.models'; export * from './voice-state.models';
export * from './game-activity.models';
export * from './direct-message-contracts'; export * from './direct-message-contracts';
export * from './chat-events'; export * from './chat-events';
export * from './media-preferences'; export * from './media-preferences';

View File

@@ -3,6 +3,7 @@ import type {
VoiceState, VoiceState,
ScreenShareState ScreenShareState
} from './voice-state.models'; } from './voice-state.models';
import type { GameActivity } from './game-activity.models';
export type UserStatus = 'online' | 'away' | 'busy' | 'offline' | 'disconnected'; export type UserStatus = 'online' | 'away' | 'busy' | 'offline' | 'disconnected';
@@ -30,6 +31,7 @@ export interface User {
voiceState?: VoiceState; voiceState?: VoiceState;
screenShareState?: ScreenShareState; screenShareState?: ScreenShareState;
cameraState?: CameraState; cameraState?: CameraState;
gameActivity?: GameActivity;
} }
export interface RoomMember { export interface RoomMember {

View File

@@ -66,6 +66,26 @@
} }
<p class="truncate text-sm text-muted-foreground">{{ profileUser.username }}</p> <p class="truncate text-sm text-muted-foreground">{{ profileUser.username }}</p>
@if (profileUser.gameActivity; as activity) {
<p class="mt-1 flex items-center gap-1 truncate text-xs text-muted-foreground">
<ng-icon
name="lucideGamepad2"
class="h-3 w-3 shrink-0"
/>
@if (activity.store?.url) {
<button
type="button"
class="truncate text-left hover:text-foreground hover:underline"
(click)="openGameStore(activity, $event)"
>
Playing {{ activity.name }}
</button>
} @else {
<span class="truncate">Playing {{ activity.name }}</span>
}
<span class="shrink-0">{{ gameActivityElapsed() }}</span>
</p>
}
</div> </div>
<div> <div>
@@ -92,11 +112,79 @@
</button> </button>
} }
</div> </div>
@if (profileUser.gameActivity; as activity) {
<div class="flex items-center gap-2 rounded-md border border-border bg-background/40 px-2.5 py-2">
@if (activity.iconUrl) {
<img
class="h-9 w-9 rounded-md object-cover"
[src]="activity.iconUrl"
[alt]="activity.name"
/>
} @else {
<div class="grid h-9 w-9 place-items-center rounded-md bg-secondary text-muted-foreground">
<ng-icon
name="lucideGamepad2"
class="h-4 w-4"
/>
</div>
}
<div class="min-w-0 flex-1">
<p class="truncate text-xs text-muted-foreground">Playing</p>
@if (activity.store?.url) {
<button
type="button"
class="block max-w-full truncate text-left text-sm font-medium text-foreground hover:underline"
(click)="openGameStore(activity, $event)"
>
{{ activity.name }}
</button>
} @else {
<p class="truncate text-sm font-medium text-foreground">{{ activity.name }}</p>
}
<p class="text-xs text-muted-foreground">{{ gameActivityElapsed() }}</p>
</div>
</div>
}
</div> </div>
} @else { } @else {
<p class="truncate text-base font-semibold text-foreground">{{ profileUser.displayName }}</p> <p class="truncate text-base font-semibold text-foreground">{{ profileUser.displayName }}</p>
<p class="truncate text-sm text-muted-foreground">{{ profileUser.username }}</p> <p class="truncate text-sm text-muted-foreground">{{ profileUser.username }}</p>
@if (profileUser.gameActivity; as activity) {
<div class="mt-3 flex items-center gap-2 rounded-md border border-border bg-background/40 px-2.5 py-2">
@if (activity.iconUrl) {
<img
class="h-9 w-9 rounded-md object-cover"
[src]="activity.iconUrl"
[alt]="activity.name"
/>
} @else {
<div class="grid h-9 w-9 place-items-center rounded-md bg-secondary text-muted-foreground">
<ng-icon
name="lucideGamepad2"
class="h-4 w-4"
/>
</div>
}
<div class="min-w-0 flex-1">
<p class="truncate text-xs text-muted-foreground">Playing</p>
@if (activity.store?.url) {
<button
type="button"
class="block max-w-full truncate text-left text-sm font-medium text-foreground hover:underline"
(click)="openGameStore(activity, $event)"
>
{{ activity.name }}
</button>
} @else {
<p class="truncate text-sm font-medium text-foreground">{{ activity.name }}</p>
}
<p class="text-xs text-muted-foreground">{{ gameActivityElapsed() }}</p>
</div>
</div>
}
@if (profileUser.description) { @if (profileUser.description) {
<p class="mt-2 whitespace-pre-line text-sm leading-5 text-muted-foreground">{{ profileUser.description }}</p> <p class="mt-2 whitespace-pre-line text-sm leading-5 text-muted-foreground">{{ profileUser.description }}</p>
} }

View File

@@ -3,15 +3,24 @@ import {
computed, computed,
effect, effect,
inject, inject,
OnDestroy,
signal signal
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core'; 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 { UserAvatarComponent } from '../user-avatar/user-avatar.component';
import { UserStatusService } from '../../../core/services/user-status.service'; import { UserStatusService } from '../../../core/services/user-status.service';
import { User, UserStatus } from '../../../shared-kernel'; import {
GameActivity,
User,
UserStatus
} from '../../../shared-kernel';
import { import {
EditableProfileAvatarSource, EditableProfileAvatarSource,
ProfileAvatarFacade, ProfileAvatarFacade,
@@ -22,6 +31,8 @@ import {
import { UsersActions } from '../../../store/users/users.actions'; import { UsersActions } from '../../../store/users/users.actions';
import { selectUsersEntities } from '../../../store/users/users.selectors'; import { selectUsersEntities } from '../../../store/users/users.selectors';
import { ThemeNodeDirective } from '../../../domains/theme'; import { ThemeNodeDirective } from '../../../domains/theme';
import { formatGameActivityElapsed } from '../../../domains/game-activity';
import { ExternalLinkService } from '../../../core/platform/external-link.service';
@Component({ @Component({
selector: 'app-profile-card', selector: 'app-profile-card',
@@ -32,10 +43,10 @@ import { ThemeNodeDirective } from '../../../domains/theme';
UserAvatarComponent, UserAvatarComponent,
ThemeNodeDirective ThemeNodeDirective
], ],
viewProviders: [provideIcons({ lucideCheck, lucideChevronDown })], viewProviders: [provideIcons({ lucideCheck, lucideChevronDown, lucideGamepad2 })],
templateUrl: './profile-card.component.html' templateUrl: './profile-card.component.html'
}) })
export class ProfileCardComponent { export class ProfileCardComponent implements OnDestroy {
readonly user = signal<User>({ id: '', oderId: '', username: '', displayName: '', status: 'offline', role: 'member', joinedAt: 0 }); readonly user = signal<User>({ id: '', oderId: '', username: '', displayName: '', status: 'offline', role: 'member', joinedAt: 0 });
readonly displayedUser = computed(() => { readonly displayedUser = computed(() => {
const snapshot = this.user(); const snapshot = this.user();
@@ -52,6 +63,7 @@ export class ProfileCardComponent {
readonly editingField = signal<'displayName' | 'description' | null>(null); readonly editingField = signal<'displayName' | 'description' | null>(null);
readonly displayNameDraft = signal(''); readonly displayNameDraft = signal('');
readonly descriptionDraft = signal(''); readonly descriptionDraft = signal('');
readonly activityNow = signal(Date.now());
readonly statusOptions: { value: UserStatus | null; label: string; color: string }[] = [ readonly statusOptions: { value: UserStatus | null; label: string; color: string }[] = [
{ value: null, label: 'Online', color: 'bg-green-500' }, { value: null, label: 'Online', color: 'bg-green-500' },
@@ -65,6 +77,8 @@ export class ProfileCardComponent {
private readonly userStatus = inject(UserStatusService); private readonly userStatus = inject(UserStatusService);
private readonly profileAvatar = inject(ProfileAvatarFacade); private readonly profileAvatar = inject(ProfileAvatarFacade);
private readonly profileAvatarEditor = inject(ProfileAvatarEditorService); 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( private readonly syncProfileDrafts = effect(
() => { () => {
const user = this.displayedUser(); 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 { toggleStatusMenu(): void {
this.showStatusMenu.update((isOpen) => !isOpen); this.showStatusMenu.update((isOpen) => !isOpen);
} }

View File

@@ -12,7 +12,8 @@ import {
BanEntry, BanEntry,
VoiceState, VoiceState,
ScreenShareState, ScreenShareState,
CameraState CameraState,
GameActivity
} from '../../shared-kernel'; } from '../../shared-kernel';
export const UsersActions = createActionGroup({ export const UsersActions = createActionGroup({
@@ -65,6 +66,7 @@ export const UsersActions = createActionGroup({
'Update Voice State': props<{ userId: string; voiceState: Partial<VoiceState> }>(), 'Update Voice State': props<{ userId: string; voiceState: Partial<VoiceState> }>(),
'Update Screen Share State': props<{ userId: string; screenShareState: Partial<ScreenShareState> }>(), 'Update Screen Share State': props<{ userId: string; screenShareState: Partial<ScreenShareState> }>(),
'Update Camera State': props<{ userId: string; cameraState: Partial<CameraState> }>(), 'Update Camera State': props<{ userId: string; cameraState: Partial<CameraState> }>(),
'Update Game Activity': props<{ userId: string; gameActivity: GameActivity | null }>(),
'Set Manual Status': props<{ status: UserStatus | null }>(), 'Set Manual Status': props<{ status: UserStatus | null }>(),
'Update Remote User Status': props<{ userId: string; status: UserStatus }>(), 'Update Remote User Status': props<{ userId: string; status: UserStatus }>(),

View File

@@ -242,7 +242,8 @@ function buildPresenceRemovalChanges(
status: isOnline ? (user.status !== 'offline' ? user.status : 'online') : 'offline', status: isOnline ? (user.status !== 'offline' ? user.status : 'online') : 'offline',
voiceState: shouldClearLiveState ? buildDisconnectedVoiceState(user) : user.voiceState, voiceState: shouldClearLiveState ? buildDisconnectedVoiceState(user) : user.voiceState,
screenShareState: shouldClearLiveState ? buildInactiveScreenShareState(user) : user.screenShareState, 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 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 }) => on(UsersActions.syncUsers, (state, { users }) =>
usersAdapter.upsertMany(users, state) usersAdapter.upsertMany(users, state)
), ),