Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 66c6f34cd3 |
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -26,6 +26,24 @@ let dbBackupPath = '';
|
|||||||
|
|
||||||
// SQLite files start with this 16-byte header string.
|
// SQLite files start with this 16-byte header string.
|
||||||
const SQLITE_MAGIC = 'SQLite format 3\0';
|
const SQLITE_MAGIC = 'SQLite format 3\0';
|
||||||
|
const SAVE_RETRY_DELAYS_MS = [25, 75, 150, 300, 600];
|
||||||
|
const RETRYABLE_SAVE_ERROR_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
|
||||||
|
|
||||||
|
let saveQueue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
function wait(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRetryableSaveError(error: unknown): boolean {
|
||||||
|
if (!error || typeof error !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
|
|
||||||
|
return typeof code === 'string' && RETRYABLE_SAVE_ERROR_CODES.has(code);
|
||||||
|
}
|
||||||
|
|
||||||
export function getDataSource(): DataSource | undefined {
|
export function getDataSource(): DataSource | undefined {
|
||||||
return applicationDataSource;
|
return applicationDataSource;
|
||||||
@@ -87,18 +105,47 @@ function safeguardDbFile(): Uint8Array | undefined {
|
|||||||
* then rename it over the real file. rename() is atomic on the same
|
* then rename it over the real file. rename() is atomic on the same
|
||||||
* filesystem, so a crash mid-write can never leave a half-written DB.
|
* filesystem, so a crash mid-write can never leave a half-written DB.
|
||||||
*/
|
*/
|
||||||
async function atomicSave(data: Uint8Array): Promise<void> {
|
async function replaceDatabaseFile(tmpPath: string): Promise<void> {
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
try {
|
||||||
|
await fsp.rename(tmpPath, dbFilePath);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
const delay = SAVE_RETRY_DELAYS_MS[attempt];
|
||||||
|
|
||||||
|
if (!isRetryableSaveError(error) || delay === undefined) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await wait(delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeDatabaseSnapshot(snapshot: Buffer): Promise<void> {
|
||||||
const tmpPath = dbFilePath + '.tmp-' + randomBytes(6).toString('hex');
|
const tmpPath = dbFilePath + '.tmp-' + randomBytes(6).toString('hex');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fsp.writeFile(tmpPath, Buffer.from(data));
|
await fsp.writeFile(tmpPath, snapshot);
|
||||||
await fsp.rename(tmpPath, dbFilePath);
|
await replaceDatabaseFile(tmpPath);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await fsp.unlink(tmpPath).catch(() => {});
|
await fsp.unlink(tmpPath).catch(() => {});
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function atomicSave(data: Uint8Array): Promise<void> {
|
||||||
|
const snapshot = Buffer.from(data);
|
||||||
|
const saveTask = saveQueue.then(
|
||||||
|
() => writeDatabaseSnapshot(snapshot),
|
||||||
|
() => writeDatabaseSnapshot(snapshot)
|
||||||
|
);
|
||||||
|
|
||||||
|
saveQueue = saveTask.catch(() => {});
|
||||||
|
|
||||||
|
return saveTask;
|
||||||
|
}
|
||||||
|
|
||||||
export async function initializeDatabase(): Promise<void> {
|
export async function initializeDatabase(): Promise<void> {
|
||||||
const userDataPath = app.getPath('userData');
|
const userDataPath = app.getPath('userData');
|
||||||
const dbDir = path.join(userDataPath, 'metoyou');
|
const dbDir = path.join(userDataPath, 'metoyou');
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
85
electron/process-list.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -48,8 +49,25 @@ const DB_BACKUP = DB_FILE + '.bak';
|
|||||||
const DATA_DIR = path.dirname(DB_FILE);
|
const DATA_DIR = path.dirname(DB_FILE);
|
||||||
// SQLite files start with this 16-byte header string.
|
// SQLite files start with this 16-byte header string.
|
||||||
const SQLITE_MAGIC = 'SQLite format 3\0';
|
const SQLITE_MAGIC = 'SQLite format 3\0';
|
||||||
|
const SAVE_RETRY_DELAYS_MS = [25, 75, 150, 300, 600];
|
||||||
|
const RETRYABLE_SAVE_ERROR_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
|
||||||
|
|
||||||
let applicationDataSource: DataSource | undefined;
|
let applicationDataSource: DataSource | undefined;
|
||||||
|
let saveQueue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
function wait(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRetryableSaveError(error: unknown): boolean {
|
||||||
|
if (!error || typeof error !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
|
|
||||||
|
return typeof code === 'string' && RETRYABLE_SAVE_ERROR_CODES.has(code);
|
||||||
|
}
|
||||||
|
|
||||||
function restoreFromBackup(reason: string): Uint8Array | undefined {
|
function restoreFromBackup(reason: string): Uint8Array | undefined {
|
||||||
if (!fs.existsSync(DB_BACKUP)) {
|
if (!fs.existsSync(DB_BACKUP)) {
|
||||||
@@ -159,18 +177,47 @@ function resolveSqlJsConfig(): { locateFile: (file: string) => string } {
|
|||||||
* then rename it over the real file. rename() is atomic on the same
|
* then rename it over the real file. rename() is atomic on the same
|
||||||
* filesystem, so a crash mid-write can never leave a half-written DB.
|
* filesystem, so a crash mid-write can never leave a half-written DB.
|
||||||
*/
|
*/
|
||||||
async function atomicSave(data: Uint8Array): Promise<void> {
|
async function replaceDatabaseFile(tmpPath: string): Promise<void> {
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
try {
|
||||||
|
await fsp.rename(tmpPath, DB_FILE);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
const delay = SAVE_RETRY_DELAYS_MS[attempt];
|
||||||
|
|
||||||
|
if (!isRetryableSaveError(error) || delay === undefined) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await wait(delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeDatabaseSnapshot(snapshot: Buffer): Promise<void> {
|
||||||
const tmpPath = DB_FILE + '.tmp-' + randomBytes(6).toString('hex');
|
const tmpPath = DB_FILE + '.tmp-' + randomBytes(6).toString('hex');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fsp.writeFile(tmpPath, Buffer.from(data));
|
await fsp.writeFile(tmpPath, snapshot);
|
||||||
await fsp.rename(tmpPath, DB_FILE);
|
await replaceDatabaseFile(tmpPath);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await fsp.unlink(tmpPath).catch(() => {});
|
await fsp.unlink(tmpPath).catch(() => {});
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function atomicSave(data: Uint8Array): Promise<void> {
|
||||||
|
const snapshot = Buffer.from(data);
|
||||||
|
const saveTask = saveQueue.then(
|
||||||
|
() => writeDatabaseSnapshot(snapshot),
|
||||||
|
() => writeDatabaseSnapshot(snapshot)
|
||||||
|
);
|
||||||
|
|
||||||
|
saveQueue = saveTask.catch(() => {});
|
||||||
|
|
||||||
|
return saveTask;
|
||||||
|
}
|
||||||
|
|
||||||
export function getDataSource(): DataSource {
|
export function getDataSource(): DataSource {
|
||||||
if (!applicationDataSource?.isInitialized) {
|
if (!applicationDataSource?.isInitialized) {
|
||||||
throw new Error('DataSource not initialised');
|
throw new Error('DataSource not initialised');
|
||||||
@@ -202,7 +249,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',
|
||||||
|
|||||||
22
server/src/entities/GameMatchMissEntity.ts
Normal file
22
server/src/entities/GameMatchMissEntity.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
24
server/src/migrations/1000000000006-GameMatchMisses.ts
Normal file
24
server/src/migrations/1000000000006-GameMatchMisses.ts
Normal 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"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
];
|
];
|
||||||
|
|||||||
17
server/src/routes/games.ts
Normal file
17
server/src/routes/games.ts
Normal 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;
|
||||||
@@ -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);
|
||||||
|
|||||||
591
server/src/services/game-matching.service.ts
Normal file
591
server/src/services/game-matching.service.ts
Normal 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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
} from './broadcast';
|
} from './broadcast';
|
||||||
import { handleWebSocketMessage } from './handler';
|
import { handleWebSocketMessage } from './handler';
|
||||||
|
|
||||||
|
type IncomingWebSocketMessage = Parameters<typeof handleWebSocketMessage>[1];
|
||||||
|
|
||||||
/** How often to ping all connected clients (ms). */
|
/** How often to ping all connected clients (ms). */
|
||||||
const PING_INTERVAL_MS = 30_000;
|
const PING_INTERVAL_MS = 30_000;
|
||||||
/** Maximum time a client can go without a pong before we consider it dead (ms). */
|
/** Maximum time a client can go without a pong before we consider it dead (ms). */
|
||||||
@@ -89,12 +91,20 @@ export function setupWebSocket(server: Server<typeof IncomingMessage, typeof Ser
|
|||||||
});
|
});
|
||||||
|
|
||||||
ws.on('message', async (data) => {
|
ws.on('message', async (data) => {
|
||||||
try {
|
let message: IncomingWebSocketMessage;
|
||||||
const message = JSON.parse(data.toString());
|
|
||||||
|
|
||||||
await handleWebSocketMessage(connectionId, message);
|
try {
|
||||||
|
message = JSON.parse(data.toString()) as IncomingWebSocketMessage;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Invalid WebSocket message:', err);
|
console.error('Invalid WebSocket message:', err);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handleWebSocketMessage(connectionId, message);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('WebSocket message handler failed:', err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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` |
|
||||||
|
|||||||
@@ -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()
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,582 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NgZone,
|
||||||
|
OnDestroy,
|
||||||
|
inject
|
||||||
|
} from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Store } from '@ngrx/store';
|
||||||
|
import { Subscription, firstValueFrom } from 'rxjs';
|
||||||
|
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
|
||||||
|
import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||||
|
import { ServerDirectoryFacade } from '../../server-directory';
|
||||||
|
import { UsersActions } from '../../../store/users/users.actions';
|
||||||
|
import { selectAllUsers, selectCurrentUser } from '../../../store/users/users.selectors';
|
||||||
|
import type {
|
||||||
|
ChatEvent,
|
||||||
|
GameActivity,
|
||||||
|
GameStoreLink,
|
||||||
|
GameMatchResponse,
|
||||||
|
MatchedGame,
|
||||||
|
User
|
||||||
|
} from '../../../shared-kernel';
|
||||||
|
|
||||||
|
const DEFAULT_SCAN_INTERVAL_MS = 10_000;
|
||||||
|
const MIN_SCAN_INTERVAL_MS = 5_000;
|
||||||
|
const MAX_SCAN_INTERVAL_MS = 60_000;
|
||||||
|
const MAX_PROCESS_NAMES_PER_REQUEST = 256;
|
||||||
|
const MAX_CANDIDATE_PROCESSES_PER_REQUEST = 12;
|
||||||
|
const POSITIVE_CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
const NEGATIVE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const MAX_LOCAL_CACHE_ENTRIES = 128;
|
||||||
|
const SCAN_INTERVAL_STORAGE_KEY = 'metoyou_game_scan_interval_ms';
|
||||||
|
const GAME_MATCH_CACHE_STORAGE_KEY = 'metoyou_game_match_cache_v1';
|
||||||
|
|
||||||
|
interface CachedGameMatch {
|
||||||
|
expiresAt: number;
|
||||||
|
game: MatchedGame | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CandidateProcess {
|
||||||
|
processName: string;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IGNORED_PROCESS_NAMES = new Set([
|
||||||
|
'agent',
|
||||||
|
'bash',
|
||||||
|
'baloorunner',
|
||||||
|
'chrome',
|
||||||
|
'code',
|
||||||
|
'conhost',
|
||||||
|
'cursor',
|
||||||
|
'csrss',
|
||||||
|
'dbus daemon',
|
||||||
|
'discord',
|
||||||
|
'dwm',
|
||||||
|
'electron',
|
||||||
|
'explorer',
|
||||||
|
'firefox',
|
||||||
|
'gameoverlayui',
|
||||||
|
'gamemoded',
|
||||||
|
'gamescopereaper',
|
||||||
|
'gnome shell',
|
||||||
|
'metoyou',
|
||||||
|
'node',
|
||||||
|
'npm',
|
||||||
|
'powershell',
|
||||||
|
'pulseaudio',
|
||||||
|
'steam',
|
||||||
|
'steamwebhelper',
|
||||||
|
'systemd',
|
||||||
|
'taskhostw',
|
||||||
|
'wininit',
|
||||||
|
'winlogon',
|
||||||
|
'xorg'
|
||||||
|
]);
|
||||||
|
const IGNORED_PROCESS_PATTERNS = [
|
||||||
|
new RegExp('(^|\\s)(agent|browser|daemon|desktop|helper|indexer|launcher|monitor|renderer|runner)(\\s|$)'),
|
||||||
|
new RegExp('(^|\\s)(service|settings|shell|tray|updater|utility|watcher|worker)(\\s|$)'),
|
||||||
|
new RegExp('(^|\\s)(audio|bluetooth|clipboard|crash|dbus|file|gpu|input|network|notification)(\\s|$)'),
|
||||||
|
new RegExp('(^|\\s)(portal|proxy|screen|session|sync|system|tracker|web|window)(\\s|$)'),
|
||||||
|
/^(appimage|at spi|baloo|dconf|gvfs|ibus|kde|kworker)/,
|
||||||
|
/^(pipewire|plasmashell|pulseaudio|xdg|xwayland|zeitgeist)/,
|
||||||
|
/(helper|service|daemon|runner|tracker|portal|updater|worker)$/
|
||||||
|
];
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class GameActivityService implements OnDestroy {
|
||||||
|
private readonly electron = inject(ElectronBridgeService);
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly ngZone = inject(NgZone);
|
||||||
|
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||||
|
private readonly store = inject(Store);
|
||||||
|
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||||
|
|
||||||
|
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
|
private readonly allUsers = this.store.selectSignal(selectAllUsers);
|
||||||
|
private readonly subscriptions = new Subscription();
|
||||||
|
|
||||||
|
private scanTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private lastProcessHash = '';
|
||||||
|
private currentActivity: GameActivity | null = null;
|
||||||
|
private scanInFlight = false;
|
||||||
|
private started = false;
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (this.started) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.started = true;
|
||||||
|
|
||||||
|
this.subscriptions.add(
|
||||||
|
this.webrtc.onMessageReceived.subscribe((event) => this.handlePeerEvent(event))
|
||||||
|
);
|
||||||
|
|
||||||
|
this.subscriptions.add(
|
||||||
|
this.webrtc.onPeerConnected.subscribe((peerId) => this.sendCurrentActivityToPeer(peerId))
|
||||||
|
);
|
||||||
|
|
||||||
|
const api = this.electron.getApi();
|
||||||
|
|
||||||
|
if (!api?.getRunningProcessNames) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ngZone.runOutsideAngular(() => {
|
||||||
|
this.scanTimer = setInterval(() => {
|
||||||
|
void this.scanRunningProcesses();
|
||||||
|
}, this.getScanIntervalMs());
|
||||||
|
});
|
||||||
|
|
||||||
|
void this.scanRunningProcesses();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private stop(): void {
|
||||||
|
if (this.scanTimer) {
|
||||||
|
clearInterval(this.scanTimer);
|
||||||
|
this.scanTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.subscriptions.unsubscribe();
|
||||||
|
this.started = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async scanRunningProcesses(): Promise<void> {
|
||||||
|
if (this.scanInFlight || !this.currentUser()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = this.electron.getApi();
|
||||||
|
|
||||||
|
if (!api?.getRunningProcessNames) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scanInFlight = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const processNames = (await api.getRunningProcessNames()).slice(0, MAX_PROCESS_NAMES_PER_REQUEST);
|
||||||
|
const processHash = this.buildProcessHash(processNames);
|
||||||
|
|
||||||
|
if (processHash === this.lastProcessHash) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastProcessHash = processHash;
|
||||||
|
|
||||||
|
const matchedGame = await this.matchRunningGame(processNames);
|
||||||
|
|
||||||
|
this.ngZone.run(() => this.applyMatchedGame(matchedGame));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[GameActivity] Failed to scan running processes', error);
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
this.scanInFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async matchRunningGame(processes: string[]): Promise<MatchedGame | null> {
|
||||||
|
const candidates = this.selectCandidateProcesses(processes);
|
||||||
|
const cachedGame = this.findCachedGame(candidates);
|
||||||
|
|
||||||
|
if (cachedGame !== undefined) {
|
||||||
|
return cachedGame;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unknownCandidates = candidates
|
||||||
|
.filter((candidate) => !this.hasFreshCacheEntry(candidate.processName))
|
||||||
|
.slice(0, MAX_CANDIDATE_PROCESSES_PER_REQUEST);
|
||||||
|
|
||||||
|
if (unknownCandidates.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiBase = this.serverDirectory.getApiBaseUrl();
|
||||||
|
const currentUser = this.currentUser();
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.http.post<GameMatchResponse>(`${apiBase}/games/match`, {
|
||||||
|
processes: unknownCandidates.map((candidate) => candidate.processName),
|
||||||
|
userId: currentUser?.id ?? currentUser?.oderId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
this.storeMatchResponse(unknownCandidates, response);
|
||||||
|
|
||||||
|
return response.games[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectCandidateProcesses(processes: string[]): CandidateProcess[] {
|
||||||
|
const candidates = new Map<string, CandidateProcess>();
|
||||||
|
|
||||||
|
for (const processName of processes.slice(0, MAX_PROCESS_NAMES_PER_REQUEST)) {
|
||||||
|
const normalized = this.normalizeProcessName(processName);
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = this.normalizeCacheKey(normalized);
|
||||||
|
const existing = candidates.get(cacheKey);
|
||||||
|
const candidate = {
|
||||||
|
processName,
|
||||||
|
score: this.scoreCandidateProcess(processName, normalized)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!existing || candidate.score > existing.score) {
|
||||||
|
candidates.set(cacheKey, candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(candidates.values())
|
||||||
|
.sort((left, right) => right.score - left.score || left.processName.localeCompare(right.processName));
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeProcessName(value: string): string {
|
||||||
|
const normalized = value.trim()
|
||||||
|
.replace(/\.exe$/i, '')
|
||||||
|
.replace(/[_-]+/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
const cacheKey = this.normalizeCacheKey(normalized);
|
||||||
|
|
||||||
|
if (normalized.length < 4 || normalized.length > 96 || this.shouldIgnoreProcessName(cacheKey)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldIgnoreProcessName(cacheKey: string): boolean {
|
||||||
|
return IGNORED_PROCESS_NAMES.has(cacheKey)
|
||||||
|
|| IGNORED_PROCESS_PATTERNS.some((pattern) => pattern.test(cacheKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
private scoreCandidateProcess(rawValue: string, normalized: string): number {
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
if (/\.exe$/i.test(rawValue.trim())) {
|
||||||
|
score += 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/[A-Z]/.test(normalized) && /[a-z]/.test(normalized)) {
|
||||||
|
score += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/\d/.test(normalized)) {
|
||||||
|
score += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.length >= 5 && normalized.length <= 32) {
|
||||||
|
score += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.includes(' ')) {
|
||||||
|
score -= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
private findCachedGame(candidates: CandidateProcess[]): MatchedGame | null | undefined {
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasCachedMissForEveryCandidate = true;
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const cached = this.getCachedMatch(candidate.processName);
|
||||||
|
|
||||||
|
if (cached === undefined) {
|
||||||
|
hasCachedMissForEveryCandidate = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasCachedMissForEveryCandidate ? null : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private storeMatchResponse(candidates: CandidateProcess[], response: GameMatchResponse): void {
|
||||||
|
for (const game of response.games) {
|
||||||
|
this.setCachedMatch(game.processName, game, POSITIVE_CACHE_TTL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.rateLimited) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchedProcessKeys = new Set(response.games.map((game) => this.normalizeCacheKey(game.processName)));
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (!matchedProcessKeys.has(this.normalizeCacheKey(candidate.processName))) {
|
||||||
|
this.setCachedMatch(candidate.processName, null, NEGATIVE_CACHE_TTL_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasFreshCacheEntry(processName: string): boolean {
|
||||||
|
return this.getCachedMatch(processName) !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCachedMatch(processName: string): MatchedGame | null | undefined {
|
||||||
|
const cache = this.readMatchCache();
|
||||||
|
const cacheKey = this.normalizeCacheKey(processName);
|
||||||
|
const cached = cache[cacheKey];
|
||||||
|
|
||||||
|
if (!cached) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cached.expiresAt <= Date.now()) {
|
||||||
|
this.writeMatchCache(Object.fromEntries(
|
||||||
|
Object.entries(cache).filter(([key]) => key !== cacheKey)
|
||||||
|
));
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cached.game;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setCachedMatch(processName: string, game: MatchedGame | null, ttlMs: number): void {
|
||||||
|
const cache = this.readMatchCache();
|
||||||
|
|
||||||
|
cache[this.normalizeCacheKey(processName)] = {
|
||||||
|
expiresAt: Date.now() + ttlMs,
|
||||||
|
game
|
||||||
|
};
|
||||||
|
|
||||||
|
this.writeMatchCache(cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readMatchCache(): Record<string, CachedGameMatch> {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(localStorage.getItem(GAME_MATCH_CACHE_STORAGE_KEY) ?? '{}') as unknown;
|
||||||
|
|
||||||
|
return this.normalizeMatchCache(parsed);
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeMatchCache(value: unknown): Record<string, CachedGameMatch> {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache: Record<string, CachedGameMatch> = {};
|
||||||
|
|
||||||
|
for (const [key, entry] of Object.entries(value)) {
|
||||||
|
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = entry as Partial<CachedGameMatch>;
|
||||||
|
|
||||||
|
if (typeof cached.expiresAt === 'number') {
|
||||||
|
cache[key] = {
|
||||||
|
expiresAt: cached.expiresAt,
|
||||||
|
game: this.normalizeCachedGame(cached.game)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeCachedGame(value: unknown): MatchedGame | null {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const game = value as Partial<MatchedGame>;
|
||||||
|
|
||||||
|
if (typeof game.id !== 'string' || typeof game.name !== 'string' || typeof game.processName !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: game.id,
|
||||||
|
name: game.name,
|
||||||
|
iconUrl: typeof game.iconUrl === 'string' ? game.iconUrl : undefined,
|
||||||
|
store: this.normalizeGameStore(game.store),
|
||||||
|
processName: game.processName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeGameStore(value: unknown): GameStoreLink | undefined {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = value as Partial<GameStoreLink>;
|
||||||
|
|
||||||
|
if (typeof store.name !== 'string' || typeof store.url !== 'string' || !this.isExternalUrl(store.url)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: typeof store.id === 'string' ? store.id : undefined,
|
||||||
|
name: store.name,
|
||||||
|
slug: typeof store.slug === 'string' ? store.slug : undefined,
|
||||||
|
domain: typeof store.domain === 'string' ? store.domain : undefined,
|
||||||
|
url: store.url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeMatchCache(cache: Record<string, CachedGameMatch>): void {
|
||||||
|
const entries = Object.entries(cache)
|
||||||
|
.filter(([, entry]) => entry.expiresAt > Date.now())
|
||||||
|
.sort((left, right) => right[1].expiresAt - left[1].expiresAt)
|
||||||
|
.slice(0, MAX_LOCAL_CACHE_ENTRIES);
|
||||||
|
|
||||||
|
localStorage.setItem(GAME_MATCH_CACHE_STORAGE_KEY, JSON.stringify(Object.fromEntries(entries)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeCacheKey(value: string): string {
|
||||||
|
return value.trim()
|
||||||
|
.replace(/\.exe$/i, '')
|
||||||
|
.replace(/[_-]+/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyMatchedGame(game: MatchedGame | null): void {
|
||||||
|
if (!game) {
|
||||||
|
this.setCurrentActivity(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = this.currentActivity;
|
||||||
|
const activity: GameActivity = {
|
||||||
|
id: game.id,
|
||||||
|
name: game.name,
|
||||||
|
iconUrl: game.iconUrl,
|
||||||
|
store: game.store,
|
||||||
|
startedAt: previous?.id === game.id ? previous.startedAt : Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
this.setCurrentActivity(activity);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setCurrentActivity(activity: GameActivity | null): void {
|
||||||
|
if (this.isSameActivity(this.currentActivity, activity)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentActivity = activity;
|
||||||
|
|
||||||
|
const user = this.currentUser();
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
this.store.dispatch(UsersActions.updateGameActivity({
|
||||||
|
userId: user.id,
|
||||||
|
gameActivity: activity
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.webrtc.broadcastMessage({
|
||||||
|
type: 'game-activity',
|
||||||
|
oderId: user?.oderId || user?.id,
|
||||||
|
displayName: user?.displayName || 'User',
|
||||||
|
gameActivity: activity
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handlePeerEvent(event: ChatEvent): void {
|
||||||
|
if (event.type !== 'game-activity') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const peerIdentifier = event.fromPeerId ?? event.oderId;
|
||||||
|
|
||||||
|
if (!peerIdentifier) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = this.currentUser();
|
||||||
|
|
||||||
|
if (peerIdentifier === currentUser?.id || peerIdentifier === currentUser?.oderId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = this.findUser(peerIdentifier);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.store.dispatch(UsersActions.updateGameActivity({
|
||||||
|
userId: user.id,
|
||||||
|
gameActivity: this.normalizeIncomingActivity(event.gameActivity)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendCurrentActivityToPeer(peerId: string): void {
|
||||||
|
const user = this.currentUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.webrtc.sendToPeer(peerId, {
|
||||||
|
type: 'game-activity',
|
||||||
|
oderId: user.oderId || user.id,
|
||||||
|
displayName: user.displayName || 'User',
|
||||||
|
gameActivity: this.currentActivity
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private findUser(identifier: string): User | null {
|
||||||
|
return this.allUsers().find((user) => user.id === identifier || user.oderId === identifier) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeIncomingActivity(value: GameActivity | null | undefined): GameActivity | null {
|
||||||
|
if (!value || typeof value.id !== 'string' || typeof value.name !== 'string' || typeof value.startedAt !== 'number') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: value.id,
|
||||||
|
name: value.name,
|
||||||
|
iconUrl: typeof value.iconUrl === 'string' ? value.iconUrl : undefined,
|
||||||
|
store: this.normalizeGameStore(value.store),
|
||||||
|
startedAt: value.startedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSameActivity(previous: GameActivity | null, next: GameActivity | null): boolean {
|
||||||
|
return previous?.id === next?.id
|
||||||
|
&& previous?.name === next?.name
|
||||||
|
&& previous?.iconUrl === next?.iconUrl
|
||||||
|
&& previous?.store?.url === next?.store?.url
|
||||||
|
&& previous?.startedAt === next?.startedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isExternalUrl(value: string): boolean {
|
||||||
|
return value.startsWith('http://') || value.startsWith('https://');
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildProcessHash(processNames: string[]): string {
|
||||||
|
return processNames.map((name) => name.trim().toLowerCase())
|
||||||
|
.sort()
|
||||||
|
.join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
private getScanIntervalMs(): number {
|
||||||
|
const storedValue = Number.parseInt(localStorage.getItem(SCAN_INTERVAL_STORAGE_KEY) ?? '', 10);
|
||||||
|
const interval = Number.isFinite(storedValue) ? storedValue : DEFAULT_SCAN_INTERVAL_MS;
|
||||||
|
|
||||||
|
return Math.min(Math.max(interval, MIN_SCAN_INTERVAL_MS), MAX_SCAN_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(':');
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import type { GameActivity } from '../../../shared-kernel';
|
||||||
|
|
||||||
|
export type CurrentGameActivity = GameActivity | null;
|
||||||
3
toju-app/src/app/domains/game-activity/index.ts
Normal file
3
toju-app/src/app/domains/game-activity/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './application/game-activity.service';
|
||||||
|
export * from './domain/game-activity.models';
|
||||||
|
export * from './domain/game-activity-time';
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
28
toju-app/src/app/shared-kernel/game-activity.models.ts
Normal file
28
toju-app/src/app/shared-kernel/game-activity.models.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }>(),
|
||||||
|
|||||||
@@ -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)
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user