8 Commits

Author SHA1 Message Date
Myx
eabbc08896 feat: plugins v1.5 2026-04-29 01:14:30 +02:00
Myx
6920f93b41 feat: plugins v1 2026-04-29 01:14:14 +02:00
Myx
ec3802ade6 test: fix broken dm test
All checks were successful
Queue Release Build / prepare (push) Successful in 23s
Deploy Web Apps / deploy (push) Successful in 6m5s
Queue Release Build / build-windows (push) Successful in 17m1s
Queue Release Build / build-linux (push) Successful in 29m15s
Queue Release Build / finalize (push) Successful in 38s
2026-04-27 22:48:45 +02:00
Myx
66c6f34cd3 feat: Add game activity status (Experimental)
All checks were successful
Queue Release Build / prepare (push) Successful in 21s
Deploy Web Apps / deploy (push) Successful in 5m14s
Queue Release Build / build-windows (push) Successful in 16m18s
Queue Release Build / build-linux (push) Successful in 29m20s
Queue Release Build / finalize (push) Successful in 36s
2026-04-27 11:02:34 +02:00
Myx
3858beb28e feat: Data management 2026-04-27 03:29:41 +02:00
Myx
1b91eacb5b feat: Theme studio v2 2026-04-27 03:02:13 +02:00
Myx
11c2588e45 feat: Add pm 2026-04-27 01:02:39 +02:00
Myx
bc2fa7de22 fix: multiple bug fixes
isolated users, db backup, weird disconnect issues for long voice sessions,
2026-04-26 22:54:13 +02:00
261 changed files with 22130 additions and 1243 deletions

View File

@@ -0,0 +1,3 @@
# E2E Plugin API Fixture
This plugin is intentionally tiny. Tests use its manifest to exercise plugin discovery, server support metadata, server data, and plugin event relay APIs without executing plugin code.

View File

@@ -0,0 +1,6 @@
export default {
id: 'e2e.plugin-api',
activate(api) {
api?.logger?.info?.('E2E Plugin API Fixture activated');
}
};

View File

@@ -0,0 +1,49 @@
{
"apiVersion": "1.0.0",
"capabilities": [
"storage.serverData.read",
"storage.serverData.write",
"events.server.publish",
"events.server.subscribe",
"events.p2p.publish",
"events.p2p.subscribe"
],
"compatibility": {
"minimumTojuVersion": "1.0.0",
"verifiedTojuVersion": "1.0.0"
},
"data": [
{
"key": "settings",
"scope": "server",
"storage": "serverData"
},
{
"key": "presence",
"scope": "user",
"storage": "serverData"
}
],
"description": "Fixture plugin used by automated tests for plugin support APIs.",
"entrypoint": "./dist/main.js",
"events": [
{
"direction": "serverRelay",
"eventName": "e2e:relay",
"maxPayloadBytes": 2048,
"scope": "server"
},
{
"direction": "p2pHint",
"eventName": "e2e:p2p",
"maxPayloadBytes": 512,
"scope": "user"
}
],
"id": "e2e.plugin-api",
"kind": "client",
"readme": "./README.md",
"schemaVersion": 1,
"title": "E2E Plugin API Fixture",
"version": "1.0.0"
}

View File

@@ -0,0 +1,42 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
export const TEST_PLUGIN_FIXTURE_DIR = join(__dirname, '..', 'fixtures', 'plugins', 'api-test-plugin');
export const TEST_PLUGIN_ID = 'e2e.plugin-api';
export const TEST_PLUGIN_RELAY_EVENT = 'e2e:relay';
export const TEST_PLUGIN_P2P_EVENT = 'e2e:p2p';
export interface PluginApiTestManifestEvent {
direction: 'clientToServer' | 'serverRelay' | 'p2pHint';
eventName: string;
maxPayloadBytes?: number;
scope: 'server' | 'channel' | 'user' | 'plugin';
}
export interface PluginApiTestManifest {
description: string;
events: PluginApiTestManifestEvent[];
id: string;
title: string;
version: string;
}
export async function readPluginApiTestManifest(): Promise<PluginApiTestManifest> {
const manifestPath = join(TEST_PLUGIN_FIXTURE_DIR, 'toju-plugin.json');
const manifestText = await readFile(manifestPath, 'utf8');
return JSON.parse(manifestText) as PluginApiTestManifest;
}
export function getPluginApiTestEvent(
manifest: PluginApiTestManifest,
eventName: string
): PluginApiTestManifestEvent {
const eventDefinition = manifest.events.find((event) => event.eventName === eventName);
if (!eventDefinition) {
throw new Error(`Expected fixture plugin to define ${eventName}`);
}
return eventDefinition;
}

View File

@@ -58,6 +58,10 @@ function buildSeededEndpointStorageState(
function applySeededEndpointStorageState(storageState: SeededEndpointStorageState): void { function applySeededEndpointStorageState(storageState: SeededEndpointStorageState): void {
try { try {
const storage = window.localStorage; const storage = window.localStorage;
const currentUserId = storage.getItem('metoyou_currentUserId')?.trim() || null;
const generalSettings = JSON.stringify({
reopenLastViewedChat: false
});
storage.setItem(storageState.key, JSON.stringify(storageState.endpoints)); storage.setItem(storageState.key, JSON.stringify(storageState.endpoints));
storage.setItem(storageState.removedKey, JSON.stringify([ storage.setItem(storageState.removedKey, JSON.stringify([
@@ -65,11 +69,56 @@ function applySeededEndpointStorageState(storageState: SeededEndpointStorageStat
'toju-primary', 'toju-primary',
'toju-sweden' 'toju-sweden'
])); ]));
storage.setItem('metoyou_general_settings', generalSettings);
if (currentUserId) {
storage.setItem(`metoyou_general_settings__${encodeURIComponent(currentUserId)}`, generalSettings);
}
const keysToRemove: string[] = [];
for (let index = 0; index < storage.length; index += 1) {
const key = storage.key(index);
if (key === 'metoyou_lastViewedChat' || key?.startsWith('metoyou_lastViewedChat__')) {
keysToRemove.push(key);
}
}
for (const key of keysToRemove) {
storage.removeItem(key);
}
} catch { } catch {
// about:blank and some Playwright UI pages deny localStorage access. // about:blank and some Playwright UI pages deny localStorage access.
} }
} }
export async function disableLastViewedChatResume(page: Page): Promise<void> {
await page.evaluate(() => {
const currentUserId = localStorage.getItem('metoyou_currentUserId')?.trim() || null;
const generalSettings = JSON.stringify({ reopenLastViewedChat: false });
const keysToRemove: string[] = [];
localStorage.setItem('metoyou_general_settings', generalSettings);
if (currentUserId) {
localStorage.setItem(`metoyou_general_settings__${encodeURIComponent(currentUserId)}`, generalSettings);
}
for (let index = 0; index < localStorage.length; index += 1) {
const key = localStorage.key(index);
if (key === 'metoyou_lastViewedChat' || key?.startsWith('metoyou_lastViewedChat__')) {
keysToRemove.push(key);
}
}
for (const key of keysToRemove) {
localStorage.removeItem(key);
}
});
}
export async function installTestServerEndpoint( export async function installTestServerEndpoint(
context: BrowserContext, context: BrowserContext,
port: number = Number(process.env.TEST_SERVER_PORT) || 3099 port: number = Number(process.env.TEST_SERVER_PORT) || 3099

View File

@@ -1,6 +1,7 @@
import { type Page, type Locator } from '@playwright/test'; import { type Page, type Locator } from '@playwright/test';
export class LoginPage { export class LoginPage {
readonly form: Locator;
readonly usernameInput: Locator; readonly usernameInput: Locator;
readonly passwordInput: Locator; readonly passwordInput: Locator;
readonly serverSelect: Locator; readonly serverSelect: Locator;
@@ -9,12 +10,13 @@ export class LoginPage {
readonly registerLink: Locator; readonly registerLink: Locator;
constructor(private page: Page) { constructor(private page: Page) {
this.form = page.locator('#login-username').locator('xpath=ancestor::div[contains(@class, "space-y-3")]').first();
this.usernameInput = page.locator('#login-username'); this.usernameInput = page.locator('#login-username');
this.passwordInput = page.locator('#login-password'); this.passwordInput = page.locator('#login-password');
this.serverSelect = page.locator('#login-server'); this.serverSelect = page.locator('#login-server');
this.submitButton = page.getByRole('button', { name: 'Login' }); this.submitButton = this.form.getByRole('button', { name: 'Login' });
this.errorText = page.locator('.text-destructive'); this.errorText = page.locator('.text-destructive');
this.registerLink = page.getByRole('button', { name: 'Register' }); this.registerLink = this.form.getByRole('button', { name: 'Register' });
} }
async goto() { async goto() {

View File

@@ -43,8 +43,11 @@ export class RegisterPage {
async register(username: string, displayName: string, password: string) { async register(username: string, displayName: string, password: string) {
await this.usernameInput.fill(username); await this.usernameInput.fill(username);
await expect(this.usernameInput).toHaveValue(username);
await this.displayNameInput.fill(displayName); await this.displayNameInput.fill(displayName);
await expect(this.displayNameInput).toHaveValue(displayName);
await this.passwordInput.fill(password); await this.passwordInput.fill(password);
await expect(this.passwordInput).toHaveValue(password);
await this.submitButton.click(); await this.submitButton.click();
} }
} }

View File

@@ -22,7 +22,7 @@ export class ServerSearchPage {
readonly dialogCancelButton: Locator; readonly dialogCancelButton: Locator;
constructor(private page: Page) { constructor(private page: Page) {
this.searchInput = page.getByPlaceholder('Search servers...'); this.searchInput = page.getByPlaceholder('Search servers and users...');
this.railCreateServerButton = page.locator('button[title="Create Server"]'); this.railCreateServerButton = page.locator('button[title="Create Server"]');
this.searchCreateServerButton = page.getByRole('button', { name: 'Create New Server' }); this.searchCreateServerButton = page.getByRole('button', { name: 'Create New Server' });
this.createServerButton = this.searchCreateServerButton; this.createServerButton = this.searchCreateServerButton;
@@ -80,6 +80,11 @@ export class ServerSearchPage {
} }
async joinServerFromSearch(name: string) { async joinServerFromSearch(name: string) {
await this.page.locator('button', { hasText: name }).click(); await this.searchInput.fill(name);
const serverCard = this.page.locator('div[title]', { hasText: name }).first();
await expect(serverCard).toBeVisible({ timeout: 15_000 });
await serverCard.dblclick();
} }
} }

View File

@@ -0,0 +1,269 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
chromium,
type BrowserContext,
type Page
} from '@playwright/test';
import { test, expect } from '../../fixtures/multi-client';
import { installTestServerEndpoint } from '../../helpers/seed-test-endpoint';
import { ChatMessagesPage } from '../../pages/chat-messages.page';
import { LoginPage } from '../../pages/login.page';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
interface TestUser {
username: string;
displayName: string;
password: string;
}
interface PersistentClient {
context: BrowserContext;
page: Page;
userDataDir: string;
}
const CLIENT_LAUNCH_ARGS = [
'--use-fake-device-for-media-stream',
'--use-fake-ui-for-media-stream'
];
test.describe('User session data isolation', () => {
test.describe.configure({ timeout: 240_000 });
test('preserves a user saved rooms and local history across app restarts', async ({ testServer }) => {
const suffix = uniqueName('persist');
const userDataDir = await mkdtemp(join(tmpdir(), 'metoyou-auth-persist-'));
const alice: TestUser = {
username: `alice_${suffix}`,
displayName: 'Alice',
password: 'TestPass123!'
};
const aliceServerName = `Alice Session Server ${suffix}`;
const aliceMessage = `Alice persisted message ${suffix}`;
let client: PersistentClient | null = null;
try {
client = await launchPersistentClient(userDataDir, testServer.port);
await test.step('Alice registers and creates local chat history', async () => {
await registerUser(client.page, alice);
await createServerAndSendMessage(client.page, aliceServerName, aliceMessage);
});
await test.step('Alice sees the same saved room and message after a full restart', async () => {
await restartPersistentClient(client, testServer.port);
await openApp(client.page);
await expect(client.page).not.toHaveURL(/\/login/, { timeout: 15_000 });
await expectSavedRoomAndHistory(client.page, aliceServerName, aliceMessage);
});
} finally {
await closePersistentClient(client);
await rm(userDataDir, { recursive: true, force: true });
}
});
test('gives a new user a blank slate and restores only that user local data after account switches', async ({ testServer }) => {
const suffix = uniqueName('isolation');
const userDataDir = await mkdtemp(join(tmpdir(), 'metoyou-auth-isolation-'));
const alice: TestUser = {
username: `alice_${suffix}`,
displayName: 'Alice',
password: 'TestPass123!'
};
const bob: TestUser = {
username: `bob_${suffix}`,
displayName: 'Bob',
password: 'TestPass123!'
};
const aliceServerName = `Alice Private Server ${suffix}`;
const bobServerName = `Bob Private Server ${suffix}`;
const aliceMessage = `Alice history ${suffix}`;
const bobMessage = `Bob history ${suffix}`;
let client: PersistentClient | null = null;
try {
client = await launchPersistentClient(userDataDir, testServer.port);
await test.step('Alice creates persisted local data and verifies it survives a restart', async () => {
await registerUser(client.page, alice);
await createServerAndSendMessage(client.page, aliceServerName, aliceMessage);
await restartPersistentClient(client, testServer.port);
await openApp(client.page);
await expectSavedRoomAndHistory(client.page, aliceServerName, aliceMessage);
});
await test.step('Bob starts from a blank slate in the same browser profile', async () => {
await logoutUser(client.page);
await registerUser(client.page, bob);
await expectBlankSlate(client.page, [aliceServerName]);
});
await test.step('Bob gets only his own saved room and history after a restart', async () => {
await createServerAndSendMessage(client.page, bobServerName, bobMessage);
await restartPersistentClient(client, testServer.port);
await openApp(client.page);
await expectSavedRoomAndHistory(client.page, bobServerName, bobMessage);
await expectSavedRoomHidden(client.page, aliceServerName);
});
await test.step('When Alice logs back in she sees only Alice local data, not Bob data', async () => {
await logoutUser(client.page);
await restartPersistentClient(client, testServer.port);
await loginUser(client.page, alice);
await expectSavedRoomVisible(client.page, aliceServerName);
await expectSavedRoomHidden(client.page, bobServerName);
await expectSavedRoomAndHistory(client.page, aliceServerName, aliceMessage);
});
} finally {
await closePersistentClient(client);
await rm(userDataDir, { recursive: true, force: true });
}
});
});
async function launchPersistentClient(userDataDir: string, testServerPort: number): Promise<PersistentClient> {
const context = await chromium.launchPersistentContext(userDataDir, {
args: CLIENT_LAUNCH_ARGS,
baseURL: 'http://localhost:4200',
permissions: ['microphone', 'camera']
});
await installTestServerEndpoint(context, testServerPort);
const page = context.pages()[0] ?? await context.newPage();
return {
context,
page,
userDataDir
};
}
async function restartPersistentClient(client: PersistentClient, testServerPort: number): Promise<void> {
await client.context.close();
const restartedClient = await launchPersistentClient(client.userDataDir, testServerPort);
client.context = restartedClient.context;
client.page = restartedClient.page;
}
async function closePersistentClient(client: PersistentClient | null): Promise<void> {
if (!client) {
return;
}
await client.context.close().catch(() => {});
}
async function openApp(page: Page): Promise<void> {
await retryTransientNavigation(() => page.goto('/', { waitUntil: 'domcontentloaded' }));
}
async function registerUser(page: Page, user: TestUser): Promise<void> {
const registerPage = new RegisterPage(page);
await retryTransientNavigation(() => registerPage.goto());
await registerPage.register(user.username, user.displayName, user.password);
await expect(page).toHaveURL(/\/search/, { timeout: 15_000 });
}
async function loginUser(page: Page, user: TestUser): Promise<void> {
const loginPage = new LoginPage(page);
await retryTransientNavigation(() => loginPage.goto());
await loginPage.login(user.username, user.password);
await expect(page).toHaveURL(/\/(search|room)(\/|$)/, { timeout: 15_000 });
}
async function logoutUser(page: Page): Promise<void> {
const menuButton = page.getByRole('button', { name: 'Menu' });
const logoutButton = page.getByRole('button', { name: 'Logout' });
const loginPage = new LoginPage(page);
await expect(menuButton).toBeVisible({ timeout: 10_000 });
await menuButton.click();
await expect(logoutButton).toBeVisible({ timeout: 10_000 });
await logoutButton.click();
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
await expect(loginPage.usernameInput).toBeVisible({ timeout: 10_000 });
}
async function createServerAndSendMessage(page: Page, serverName: string, messageText: string): Promise<void> {
const searchPage = new ServerSearchPage(page);
const messagesPage = new ChatMessagesPage(page);
await searchPage.createServer(serverName, {
description: `User session isolation coverage for ${serverName}`
});
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
await messagesPage.sendMessage(messageText);
await expect(messagesPage.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 });
}
async function expectSavedRoomAndHistory(page: Page, roomName: string, messageText: string): Promise<void> {
const roomButton = getSavedRoomButton(page, roomName);
const messagesPage = new ChatMessagesPage(page);
await expect(roomButton).toBeVisible({ timeout: 20_000 });
await roomButton.click();
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
await expect(messagesPage.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 });
}
async function expectBlankSlate(page: Page, hiddenRoomNames: string[]): Promise<void> {
const searchPage = new ServerSearchPage(page);
await expect(page).toHaveURL(/\/search/, { timeout: 15_000 });
await expect(searchPage.createServerButton).toBeVisible({ timeout: 15_000 });
for (const roomName of hiddenRoomNames) {
await expectSavedRoomHidden(page, roomName);
}
}
async function expectSavedRoomVisible(page: Page, roomName: string): Promise<void> {
await expect(getSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 });
}
async function expectSavedRoomHidden(page: Page, roomName: string): Promise<void> {
await expect(getSavedRoomButton(page, roomName)).toHaveCount(0);
}
function getSavedRoomButton(page: Page, roomName: string) {
return page.locator(`button[title="${roomName}"]`).first();
}
async function retryTransientNavigation<T>(navigate: () => Promise<T>, attempts = 4): Promise<T> {
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
return await navigate();
} catch (error) {
lastError = error;
const message = error instanceof Error ? error.message : String(error);
const isTransientNavigationError = message.includes('ERR_EMPTY_RESPONSE') || message.includes('ERR_CONNECTION_RESET');
if (!isTransientNavigationError || attempt === attempts) {
throw error;
}
}
}
throw lastError instanceof Error
? lastError
: new Error(`Navigation failed after ${attempts} attempts`);
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}

View File

@@ -18,6 +18,85 @@ const DELETED_MESSAGE_CONTENT = '[Message deleted]';
test.describe('Chat messaging features', () => { test.describe('Chat messaging features', () => {
test.describe.configure({ timeout: 180_000 }); test.describe.configure({ timeout: 180_000 });
test('shows per-server channel lists on first saved-server click', async ({ createClient }) => {
const scenario = await createSingleClientChatScenario(createClient);
const alphaServerName = `Alpha Server ${uniqueName('rail')}`;
const betaServerName = `Beta Server ${uniqueName('rail')}`;
const alphaChannelName = uniqueName('alpha-updates');
const betaChannelName = uniqueName('beta-plans');
const channelsPanel = scenario.room.channelsSidePanel;
await test.step('Create first saved server with a unique text channel', async () => {
await createServerAndOpenRoom(scenario.search, scenario.client.page, alphaServerName, 'Rail switch alpha server');
await scenario.room.ensureTextChannelExists(alphaChannelName);
await expect(
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${alphaChannelName}"]`)
).toBeVisible({ timeout: 20_000 });
});
await test.step('Create second saved server with a different text channel', async () => {
await createServerAndOpenRoom(scenario.search, scenario.client.page, betaServerName, 'Rail switch beta server');
await scenario.room.ensureTextChannelExists(betaChannelName);
await expect(
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${betaChannelName}"]`)
).toBeVisible({ timeout: 20_000 });
});
await test.step('Opening first server once restores only its channels', async () => {
await openSavedRoomByName(scenario.client.page, alphaServerName);
await expect(
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${alphaChannelName}"]`)
).toBeVisible({ timeout: 20_000 });
await expect(
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${betaChannelName}"]`)
).toHaveCount(0);
});
await test.step('Opening second server once restores only its channels', async () => {
await openSavedRoomByName(scenario.client.page, betaServerName);
await expect(
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${betaChannelName}"]`)
).toBeVisible({ timeout: 20_000 });
await expect(
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${alphaChannelName}"]`)
).toHaveCount(0);
});
});
test('shows local room history on first saved-server click', async ({ createClient }) => {
const scenario = await createSingleClientChatScenario(createClient);
const alphaServerName = `History Alpha ${uniqueName('rail')}`;
const betaServerName = `History Beta ${uniqueName('rail')}`;
const alphaMessage = `Alpha history message ${uniqueName('msg')}`;
const betaMessage = `Beta history message ${uniqueName('msg')}`;
await test.step('Create first server and send a local message', async () => {
await createServerAndOpenRoom(scenario.search, scenario.client.page, alphaServerName, 'Rail history alpha server');
await scenario.messages.sendMessage(alphaMessage);
await expect(scenario.messages.getMessageItemByText(alphaMessage)).toBeVisible({ timeout: 20_000 });
});
await test.step('Create second server and send a different local message', async () => {
await createServerAndOpenRoom(scenario.search, scenario.client.page, betaServerName, 'Rail history beta server');
await scenario.messages.sendMessage(betaMessage);
await expect(scenario.messages.getMessageItemByText(betaMessage)).toBeVisible({ timeout: 20_000 });
});
await test.step('Opening first server once restores its history immediately', async () => {
await openSavedRoomByName(scenario.client.page, alphaServerName);
await expect(scenario.messages.getMessageItemByText(alphaMessage)).toBeVisible({ timeout: 20_000 });
});
await test.step('Opening second server once restores its history immediately', async () => {
await openSavedRoomByName(scenario.client.page, betaServerName);
await expect(scenario.messages.getMessageItemByText(betaMessage)).toBeVisible({ timeout: 20_000 });
});
});
test('syncs messages in a newly created text channel', async ({ createClient }) => { test('syncs messages in a newly created text channel', async ({ createClient }) => {
const scenario = await createChatScenario(createClient); const scenario = await createChatScenario(createClient);
const channelName = uniqueName('updates'); const channelName = uniqueName('updates');
@@ -143,6 +222,43 @@ interface ChatScenario {
bobMessages: ChatMessagesPage; bobMessages: ChatMessagesPage;
} }
interface SingleClientChatScenario {
client: Client;
messages: ChatMessagesPage;
room: ChatRoomPage;
search: ServerSearchPage;
}
async function createSingleClientChatScenario(createClient: () => Promise<Client>): Promise<SingleClientChatScenario> {
const suffix = uniqueName('solo');
const client = await createClient();
const credentials = {
username: `solo_${suffix}`,
displayName: 'Solo',
password: 'TestPass123!'
};
await installChatFeatureMocks(client.page);
const registerPage = new RegisterPage(client.page);
await registerPage.goto();
await registerPage.register(
credentials.username,
credentials.displayName,
credentials.password
);
await expect(client.page).toHaveURL(/\/search/, { timeout: 15_000 });
return {
client,
messages: new ChatMessagesPage(client.page),
room: new ChatRoomPage(client.page),
search: new ServerSearchPage(client.page)
};
}
async function createChatScenario(createClient: () => Promise<Client>): Promise<ChatScenario> { async function createChatScenario(createClient: () => Promise<Client>): Promise<ChatScenario> {
const suffix = uniqueName('chat'); const suffix = uniqueName('chat');
const serverName = `Chat Server ${suffix}`; const serverName = `Chat Server ${suffix}`;
@@ -192,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);
@@ -217,6 +330,52 @@ async function createChatScenario(createClient: () => Promise<Client>): Promise<
}; };
} }
async function createServerAndOpenRoom(
searchPage: ServerSearchPage,
page: Page,
serverName: string,
description: string
): Promise<void> {
await searchPage.createServer(serverName, { description });
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
await waitForCurrentRoomName(page, serverName);
}
async function openSavedRoomByName(page: Page, roomName: string): Promise<void> {
const roomButton = page.locator(`button[title="${roomName}"]`);
await expect(roomButton).toBeVisible({ timeout: 20_000 });
await roomButton.click();
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
await waitForCurrentRoomName(page, roomName);
}
async function waitForCurrentRoomName(page: Page, roomName: string, timeout = 20_000): Promise<void> {
await page.waitForFunction(
(expectedRoomName) => {
interface RoomShape { name?: string }
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return false;
}
const component = debugApi.getComponent(host);
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
return currentRoom?.name === expectedRoomName;
},
roomName,
{ timeout }
);
}
async function installChatFeatureMocks(page: Page): Promise<void> { async function installChatFeatureMocks(page: Page): Promise<void> {
await page.route('**/api/klipy/config', async (route) => { await page.route('**/api/klipy/config', async (route) => {
await route.fulfill({ await route.fulfill({

View File

@@ -0,0 +1,116 @@
import { type Page } from '@playwright/test';
import {
test,
expect,
type Client
} from '../../fixtures/multi-client';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatMessagesPage } from '../../pages/chat-messages.page';
import { disableLastViewedChatResume } from '../../helpers/seed-test-endpoint';
test.describe('Direct message flow', () => {
test.describe.configure({ timeout: 180_000 });
test('opens a DM from a user card and queues messages while offline', async ({ createClient }) => {
const scenario = await createDmScenario(createClient);
const offlineMessage = `Offline DM ${uniqueName('msg')}`;
await test.step('Alice opens Bob from the room user list', async () => {
const bobUserCard = scenario.alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' }).first();
await expect(bobUserCard).toBeVisible({ timeout: 20_000 });
await bobUserCard.getByRole('button', { name: 'Message Bob' }).click();
await expect(scenario.alice.page).toHaveURL(/\/dm\//, { timeout: 15_000 });
await expect(scenario.alice.page.getByRole('heading', { name: 'Bob' })).toBeVisible({ timeout: 10_000 });
});
await test.step('Offline send persists locally as queued', async () => {
await scenario.alice.page.evaluate(() => window.simulateOffline?.());
await scenario.alice.page.getByTestId('dm-input').fill(offlineMessage);
await scenario.alice.page.getByTestId('dm-input').press('Enter');
await expect(scenario.alice.page.locator('app-dm-chat').getByText(offlineMessage)).toBeVisible({ timeout: 10_000 });
await expect(scenario.alice.page.getByTestId('message-status').last()).toContainText('QUEUED');
});
});
test('shows friend and message actions on the search people list', async ({ createClient }) => {
const scenario = await createDmScenario(createClient);
await disableLastViewedChatResume(scenario.alice.page);
await scenario.alice.page.goto('/search', { waitUntil: 'domcontentloaded' });
await expect(scenario.alice.page).toHaveURL(/\/search/, { timeout: 20_000 });
await expect(scenario.alice.page.locator('app-server-search')).toBeVisible({ timeout: 20_000 });
await expect(scenario.alice.page.locator('app-user-search-list')).toBeVisible({ timeout: 20_000 });
const bobPeopleCard = scenario.alice.page
.locator('app-user-search-list [data-testid$="-' + scenario.bobUserId + '"]', { hasText: 'Bob' })
.first();
await expect(bobPeopleCard).toBeVisible({ timeout: 15_000 });
const friendButton = bobPeopleCard.locator(`[data-testid="friend-button-${scenario.bobUserId}"]`);
const messageButton = bobPeopleCard.getByRole('button', { name: 'Message Bob' });
await expect(friendButton).toBeAttached({ timeout: 15_000 });
await expect(messageButton).toBeAttached({ timeout: 15_000 });
});
});
interface DmScenario {
alice: Client;
bob: Client;
bobUserId: string;
aliceSearch: ServerSearchPage;
}
async function createDmScenario(createClient: () => Promise<Client>): Promise<DmScenario> {
const suffix = uniqueName('dm');
const serverName = `DM Server ${suffix}`;
const alice = await createClient();
const bob = await createClient();
await registerUser(alice.page, `alice_${suffix}`, 'Alice');
await registerUser(bob.page, `bob_${suffix}`, 'Bob');
const aliceSearch = new ServerSearchPage(alice.page);
await aliceSearch.createServer(serverName, { description: 'E2E direct message discovery server' });
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
await new ChatMessagesPage(alice.page).waitForReady();
const bobSearch = new ServerSearchPage(bob.page);
await bobSearch.joinServerFromSearch(serverName);
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
await new ChatMessagesPage(bob.page).waitForReady();
const bobRoomCard = alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' }).first();
await expect(bobRoomCard).toBeVisible({ timeout: 20_000 });
const bobUserCardTestId = await bobRoomCard.getAttribute('data-testid');
const bobUserId = bobUserCardTestId?.replace('room-user-card-', '');
if (!bobUserId) {
throw new Error('Expected Bob room user card to expose a stable test id.');
}
return {
alice,
bob,
bobUserId,
aliceSearch
};
}
async function registerUser(page: Page, username: string, displayName: string): Promise<void> {
const registerPage = new RegisterPage(page);
await registerPage.goto();
await registerPage.register(username, displayName, 'TestPass123!');
await expect(page).toHaveURL(/\/search/, { timeout: 15_000 });
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36)
.slice(2, 8)}`;
}

View File

@@ -0,0 +1,260 @@
import {
expect,
type Locator,
type Page
} from '@playwright/test';
import { test, type Client } from '../../fixtures/multi-client';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatRoomPage } from '../../pages/chat-room.page';
import { ChatMessagesPage } from '../../pages/chat-messages.page';
interface DesktopNotificationRecord {
title: string;
body: string;
}
interface NotificationScenario {
alice: Client;
bob: Client;
aliceRoom: ChatRoomPage;
bobRoom: ChatRoomPage;
bobMessages: ChatMessagesPage;
serverName: string;
channelName: string;
}
test.describe('Chat notifications', () => {
test.describe.configure({ timeout: 180_000 });
test('shows desktop notifications and unread badges for inactive channels', async ({ createClient }) => {
const scenario = await createNotificationScenario(createClient);
const message = `Background notification ${uniqueName('msg')}`;
await test.step('Bob sends a message to Alice\'s inactive channel', async () => {
await clearDesktopNotifications(scenario.alice.page);
await scenario.bobRoom.joinTextChannel(scenario.channelName);
await scenario.bobMessages.sendMessage(message);
});
await test.step('Alice receives a desktop notification with the channel preview', async () => {
const notification = await waitForDesktopNotification(scenario.alice.page);
expect(notification).toEqual({
title: `${scenario.serverName} · #${scenario.channelName}`,
body: `Bob: ${message}`
});
});
await test.step('Alice sees unread badges for the room and the inactive channel', async () => {
await expect(getUnreadBadge(getSavedRoomButton(scenario.alice.page, scenario.serverName))).toHaveText('1', { timeout: 20_000 });
await expect(getUnreadBadge(getTextChannelButton(scenario.alice.page, scenario.channelName))).toHaveText('1', { timeout: 20_000 });
});
});
test('keeps unread badges visible when a muted channel suppresses desktop popups', async ({ createClient }) => {
const scenario = await createNotificationScenario(createClient);
const message = `Muted notification ${uniqueName('msg')}`;
await test.step('Alice mutes the inactive text channel', async () => {
await muteTextChannel(scenario.alice.page, scenario.channelName);
await clearDesktopNotifications(scenario.alice.page);
});
await test.step('Bob sends a message into the muted channel', async () => {
await scenario.bobRoom.joinTextChannel(scenario.channelName);
await scenario.bobMessages.sendMessage(message);
});
await test.step('Alice still sees unread badges for the room and channel', async () => {
await expect(getUnreadBadge(getSavedRoomButton(scenario.alice.page, scenario.serverName))).toHaveText('1', { timeout: 20_000 });
await expect(getUnreadBadge(getTextChannelButton(scenario.alice.page, scenario.channelName))).toHaveText('1', { timeout: 20_000 });
});
await test.step('Alice does not get a muted desktop popup', async () => {
const notificationAppeared = await waitForAnyDesktopNotification(scenario.alice.page, 1_500);
expect(notificationAppeared).toBe(false);
});
});
});
async function createNotificationScenario(createClient: () => Promise<Client>): Promise<NotificationScenario> {
const suffix = uniqueName('notify');
const serverName = `Notifications Server ${suffix}`;
const channelName = uniqueName('updates');
const aliceCredentials = {
username: `alice_${suffix}`,
displayName: 'Alice',
password: 'TestPass123!'
};
const bobCredentials = {
username: `bob_${suffix}`,
displayName: 'Bob',
password: 'TestPass123!'
};
const alice = await createClient();
const bob = await createClient();
await installDesktopNotificationSpy(alice.page);
await registerUser(alice.page, aliceCredentials.username, aliceCredentials.displayName, aliceCredentials.password);
await registerUser(bob.page, bobCredentials.username, bobCredentials.displayName, bobCredentials.password);
const aliceSearch = new ServerSearchPage(alice.page);
await aliceSearch.createServer(serverName, {
description: 'E2E notification coverage server'
});
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
const bobSearch = new ServerSearchPage(bob.page);
await bobSearch.joinServerFromSearch(serverName);
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
const aliceRoom = new ChatRoomPage(alice.page);
const bobRoom = new ChatRoomPage(bob.page);
const aliceMessages = new ChatMessagesPage(alice.page);
const bobMessages = new ChatMessagesPage(bob.page);
await aliceMessages.waitForReady();
await bobMessages.waitForReady();
await aliceRoom.ensureTextChannelExists(channelName);
await expect(getTextChannelButton(alice.page, channelName)).toBeVisible({ timeout: 20_000 });
return {
alice,
bob,
aliceRoom,
bobRoom,
bobMessages,
serverName,
channelName
};
}
async function registerUser(page: Page, username: string, displayName: string, password: string): Promise<void> {
const registerPage = new RegisterPage(page);
await registerPage.goto();
await registerPage.register(username, displayName, password);
await expect(page).toHaveURL(/\/search/, { timeout: 15_000 });
}
async function installDesktopNotificationSpy(page: Page): Promise<void> {
await page.addInitScript(() => {
const notifications: DesktopNotificationRecord[] = [];
class MockNotification {
static permission = 'granted';
onclick: (() => void) | null = null;
constructor(title: string, options?: NotificationOptions) {
notifications.push({
title,
body: options?.body ?? ''
});
}
static async requestPermission(): Promise<NotificationPermission> {
return 'granted';
}
close(): void {
return;
}
}
Object.defineProperty(window, '__desktopNotifications', {
value: notifications,
configurable: true
});
Object.defineProperty(window, 'Notification', {
value: MockNotification,
configurable: true,
writable: true
});
});
}
async function clearDesktopNotifications(page: Page): Promise<void> {
await page.evaluate(() => {
(window as WindowWithDesktopNotifications).__desktopNotifications.length = 0;
});
}
async function waitForDesktopNotification(page: Page): Promise<DesktopNotificationRecord> {
await expect.poll(
async () => (await readDesktopNotifications(page)).length,
{
timeout: 20_000,
message: 'Expected a desktop notification to be emitted'
}
).toBeGreaterThan(0);
const notifications = await readDesktopNotifications(page);
return notifications[notifications.length - 1];
}
async function waitForAnyDesktopNotification(page: Page, timeout: number): Promise<boolean> {
try {
await page.waitForFunction(
() => (window as WindowWithDesktopNotifications).__desktopNotifications.length > 0,
undefined,
{ timeout }
);
return true;
} catch (error) {
if (error instanceof Error && error.name === 'TimeoutError') {
return false;
}
throw error;
}
}
async function readDesktopNotifications(page: Page): Promise<DesktopNotificationRecord[]> {
return page.evaluate(() => {
return [...(window as WindowWithDesktopNotifications).__desktopNotifications];
});
}
async function muteTextChannel(page: Page, channelName: string): Promise<void> {
const channelButton = getTextChannelButton(page, channelName);
const contextMenu = page.locator('app-context-menu');
await expect(channelButton).toBeVisible({ timeout: 20_000 });
await channelButton.click({ button: 'right' });
await expect(contextMenu.getByRole('button', { name: 'Mute Notifications' })).toBeVisible({ timeout: 10_000 });
await contextMenu.getByRole('button', { name: 'Mute Notifications' }).click();
await expect(contextMenu).toHaveCount(0);
}
function getSavedRoomButton(page: Page, roomName: string): Locator {
return page.locator(`button[title="${roomName}"]`).first();
}
function getTextChannelButton(page: Page, channelName: string): Locator {
return page.locator('app-rooms-side-panel').first()
.locator(`button[data-channel-type="text"][data-channel-name="${channelName}"]`)
.first();
}
function getUnreadBadge(container: Locator): Locator {
return container.locator('span.rounded-full').first();
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36)
.slice(2, 8)}`;
}
interface WindowWithDesktopNotifications extends Window {
__desktopNotifications: DesktopNotificationRecord[];
}

View File

@@ -69,6 +69,7 @@ const NETSCAPE_LOOP_EXTENSION = Buffer.from([
]); ]);
const CLIENT_LAUNCH_ARGS = ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream']; const CLIENT_LAUNCH_ARGS = ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'];
const VOICE_CHANNEL = 'General'; const VOICE_CHANNEL = 'General';
const AVATAR_SYNC_TIMEOUT_MS = 45_000;
test.describe('Profile avatar sync', () => { test.describe('Profile avatar sync', () => {
test.describe.configure({ timeout: 240_000 }); test.describe.configure({ timeout: 240_000 });
@@ -384,11 +385,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 });
} }
@@ -601,7 +599,7 @@ async function expectSidebarAvatar(page: Page, displayName: string, expectedData
return image.getAttribute('src'); return image.getAttribute('src');
}, { }, {
timeout: 20_000, timeout: AVATAR_SYNC_TIMEOUT_MS,
message: `${displayName} avatar src should update` message: `${displayName} avatar src should update`
}).toBe(expectedDataUrl); }).toBe(expectedDataUrl);
@@ -618,7 +616,7 @@ async function expectSidebarAvatar(page: Page, displayName: string, expectedData
return img.complete && img.naturalWidth > 0 && img.naturalHeight > 0; return img.complete && img.naturalWidth > 0 && img.naturalHeight > 0;
}); });
}, { }, {
timeout: 20_000, timeout: AVATAR_SYNC_TIMEOUT_MS,
message: `${displayName} avatar image should load` message: `${displayName} avatar image should load`
}).toBe(true); }).toBe(true);
} }
@@ -638,7 +636,7 @@ async function expectChatMessageAvatar(page: Page, messageText: string, expected
return image.getAttribute('src'); return image.getAttribute('src');
}, { }, {
timeout: 20_000, timeout: AVATAR_SYNC_TIMEOUT_MS,
message: `Chat message avatar for "${messageText}" should update` message: `Chat message avatar for "${messageText}" should update`
}).toBe(expectedDataUrl); }).toBe(expectedDataUrl);
} }
@@ -665,7 +663,7 @@ async function expectVoiceControlsAvatar(page: Page, expectedDataUrl: string): P
return image.getAttribute('src'); return image.getAttribute('src');
}, { }, {
timeout: 20_000, timeout: AVATAR_SYNC_TIMEOUT_MS,
message: 'Voice controls avatar should update' message: 'Voice controls avatar should update'
}).toBe(expectedDataUrl); }).toBe(expectedDataUrl);
} }

View File

@@ -0,0 +1,185 @@
import { type Page } from '@playwright/test';
import {
expect,
test,
type Client
} from '../../fixtures/multi-client';
import { ChatMessagesPage } from '../../pages/chat-messages.page';
import { ChatRoomPage } from '../../pages/chat-room.page';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
const PLUGIN_SOURCE_URL = 'http://localhost:4200/plugins/e2e-plugin-source.json';
const PLUGIN_TITLE = 'E2E All API Plugin';
const EDITED_MESSAGE = 'Plugin API edited message';
const ORIGINAL_MESSAGE = 'Plugin API original message';
const DELETED_MESSAGE = 'Plugin API deleted message';
const DELETED_MESSAGE_CONTENT = '[Message deleted]';
const PLUGIN_BOT_MESSAGE = 'Plugin bot message from all-api fixture';
const CUSTOM_EMBED_TEXT = 'E2E custom embed: Plugin API custom embed';
const SOUND_BOARD_TEXT = 'E2E soundboard ready';
const SOUND_BOARD_LABEL = 'E2E Soundboard';
const SOUND_BOARD_PLAYED_MESSAGE = 'E2E soundboard played Airhorn to voice channel';
const VOICE_CHANNEL = 'Plugin Voice';
test.describe('Plugin API multi-user runtime', () => {
test.describe.configure({ timeout: 180_000 });
test('runs chat, embed, soundboard, and profile APIs between two users', async ({ createClient }) => {
const scenario = await createPluginApiScenario(createClient);
await test.step('Install the server plugin as Alice', async () => {
await installGrantAndActivatePlugin(scenario.alice.page, true);
await closeSettingsModal(scenario.alice.page);
await expect(soundboardComposerButton(scenario.alice.page)).toBeVisible({ timeout: 20_000 });
await expect(scenario.alice.page.getByText(SOUND_BOARD_TEXT, { exact: true })).toBeVisible({ timeout: 20_000 });
await expect(scenario.alice.page.getByTestId('e2e-plugin-owned-dom')).toHaveAttribute('data-plugin-owner', 'e2e.all-api-plugin');
});
await test.step('Activate the server plugin for Bob as the embed/soundboard receiver', async () => {
await installGrantAndActivatePlugin(scenario.bob.page, false);
await closeSettingsModal(scenario.bob.page);
await expect(soundboardComposerButton(scenario.bob.page)).toBeVisible({ timeout: 20_000 });
await expect(scenario.bob.page.getByText(SOUND_BOARD_TEXT, { exact: true })).toBeVisible({ timeout: 20_000 });
await expect(scenario.bob.page.getByTestId('e2e-plugin-owned-dom')).toHaveAttribute('data-plugin-owner', 'e2e.all-api-plugin');
});
await test.step('Alice opens the plugin soundboard modal and plays a sound to voice', async () => {
await soundboardComposerButton(scenario.alice.page).click();
await expect(scenario.alice.page.getByRole('dialog', { name: SOUND_BOARD_LABEL })).toBeVisible({ timeout: 20_000 });
await expect(scenario.alice.page.getByTestId('e2e-soundboard-modal')).toHaveAttribute('data-plugin-owner', 'e2e.all-api-plugin');
await scenario.alice.page.getByRole('button', { name: 'Play airhorn to voice' }).click();
await expect(scenario.alice.page.getByTestId('e2e-soundboard-status')).toHaveText(SOUND_BOARD_PLAYED_MESSAGE, { timeout: 20_000 });
});
await test.step('Bob receives messages sent and edited by Alice through the plugin API', async () => {
await expect(scenario.bobMessages.getMessageItemByText(EDITED_MESSAGE)).toBeVisible({ timeout: 30_000 });
await expect(scenario.bobMessages.getMessageItemByText(ORIGINAL_MESSAGE)).toHaveCount(0);
await expect(scenario.bob.page.getByText('(edited)')).toBeVisible({ timeout: 20_000 });
});
await test.step('Bob sees plugin API deletion state and plugin-user messages', async () => {
await expect(scenario.bobMessages.getMessageItemByText(DELETED_MESSAGE_CONTENT)).toBeVisible({ timeout: 30_000 });
await expect(scenario.bobMessages.getMessageItemByText(DELETED_MESSAGE)).toHaveCount(0);
await expect(scenario.bobMessages.getMessageItemByText(PLUGIN_BOT_MESSAGE)).toBeVisible({ timeout: 30_000 });
await expect(scenario.bobMessages.getMessageItemByText(SOUND_BOARD_PLAYED_MESSAGE)).toBeVisible({ timeout: 30_000 });
});
await test.step('Bob renders Alice custom embed through the plugin embed API', async () => {
await expect(scenario.bob.page.getByTestId('plugin-message-embeds')).toContainText(CUSTOM_EMBED_TEXT, { timeout: 30_000 });
});
await test.step('Bob sees Alice profile name changed by the plugin API', async () => {
await expect(scenario.bobMessages.getMessageItemByText(EDITED_MESSAGE)).toContainText('Alice Plugin Renamed', { timeout: 30_000 });
});
});
});
interface PluginApiScenario {
alice: Client;
aliceRoom: ChatRoomPage;
bob: Client;
bobRoom: ChatRoomPage;
aliceMessages: ChatMessagesPage;
bobMessages: ChatMessagesPage;
}
async function createPluginApiScenario(createClient: () => Promise<Client>): Promise<PluginApiScenario> {
const suffix = uniqueName('plugin-api');
const serverName = `Plugin API Server ${suffix}`;
const alice = await createClient();
const bob = await createClient();
await registerUser(alice.page, `alice_${suffix}`, 'Alice');
await registerUser(bob.page, `bob_${suffix}`, 'Bob');
const aliceSearch = new ServerSearchPage(alice.page);
await aliceSearch.createServer(serverName, { description: 'Two-user plugin API E2E coverage' });
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 30_000 });
const aliceRoom = new ChatRoomPage(alice.page);
await aliceRoom.ensureVoiceChannelExists(VOICE_CHANNEL);
const bobSearch = new ServerSearchPage(bob.page);
await bobSearch.joinServerFromSearch(serverName);
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 30_000 });
const bobRoom = new ChatRoomPage(bob.page);
await aliceRoom.joinVoiceChannel(VOICE_CHANNEL);
await bobRoom.joinVoiceChannel(VOICE_CHANNEL);
await expect(aliceRoom.voiceControls).toBeVisible({ timeout: 30_000 });
await expect(bobRoom.voiceControls).toBeVisible({ timeout: 30_000 });
const aliceMessages = new ChatMessagesPage(alice.page);
const bobMessages = new ChatMessagesPage(bob.page);
await aliceMessages.waitForReady();
await bobMessages.waitForReady();
await expect(alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' })).toBeVisible({ timeout: 30_000 });
await expect(bob.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Alice' })).toBeVisible({ timeout: 30_000 });
return {
alice,
aliceRoom,
bob,
bobRoom,
aliceMessages,
bobMessages
};
}
async function registerUser(page: Page, username: string, displayName: string): Promise<void> {
const registerPage = new RegisterPage(page);
await registerPage.goto();
await registerPage.register(username, displayName, 'TestPass123!');
await expect(page).toHaveURL(/\/search/, { timeout: 30_000 });
}
async function installGrantAndActivatePlugin(page: Page, installFromStore: boolean): Promise<void> {
await page.getByRole('button', { name: 'Plugins' }).click();
await expect(page).toHaveURL(/\/plugin-store/, { timeout: 20_000 });
await expect(page.getByTestId('plugin-store-page')).toBeVisible({ timeout: 20_000 });
if (installFromStore) {
await page.getByLabel('Plugin source manifest URL').fill(PLUGIN_SOURCE_URL);
await page.getByRole('button', { name: 'Add Source' }).click();
await expect(page.getByRole('heading', { name: PLUGIN_TITLE })).toBeVisible({ timeout: 20_000 });
await page.getByRole('button', { exact: true, name: /^(Install|Install to Server)$/ }).click();
await expect(page.getByRole('dialog', { name: PLUGIN_TITLE })).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Install and Activate' }).click();
await expect(page.locator('article', { hasText: PLUGIN_TITLE }).getByText('Installed')).toBeVisible({ timeout: 20_000 });
}
await page.getByRole('button', { name: 'Manage Plugins' }).click();
await expect(page.getByTestId('plugin-manager')).toBeVisible({ timeout: 20_000 });
await expect(page.locator('article', { hasText: PLUGIN_TITLE })).toBeVisible({ timeout: 20_000 });
await page.locator('article', { hasText: PLUGIN_TITLE })
.getByRole('button', { name: 'Select' })
.click();
await page.getByRole('button', { name: 'Grant all requested' }).click();
await page.getByRole('button', { name: 'Activate ready plugins' }).click();
await expect(page.locator('article', { hasText: PLUGIN_TITLE }).getByText('ready', { exact: true })).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: 'Logs' }).click();
await expect(page.getByText('all-api plugin completed')).toBeVisible({ timeout: 30_000 });
}
async function closeSettingsModal(page: Page): Promise<void> {
await page.keyboard.press('Escape');
await expect(page.getByTestId('plugin-manager')).toHaveCount(0);
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36)
.slice(2, 8)}`;
}
function soundboardComposerButton(page: Page) {
return page.locator('app-chat-message-composer')
.getByRole('button', { exact: true, name: SOUND_BOARD_LABEL });
}

View File

@@ -0,0 +1,93 @@
import { expect, test } from '../../fixtures/multi-client';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
test.describe('Plugin manager UI', () => {
test.describe.configure({ timeout: 180_000 });
test('installs, grants, activates, and logs an all-API test plugin', async ({ createClient }) => {
const client = await createClient();
const { page } = client;
const suffix = Date.now();
const register = new RegisterPage(page);
const search = new ServerSearchPage(page);
await test.step('Register user and create server context', async () => {
await register.goto();
await register.register(`plugin_${suffix}`, 'Plugin Tester', 'TestPass123!');
await expect(page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
await search.createServer(`Plugin API Server ${suffix}`, {
description: 'Plugin manager UI E2E coverage'
});
await expect(page).toHaveURL(/\/room\//, { timeout: 30_000 });
});
await test.step('Open visible Plugins button', async () => {
await page.getByRole('button', { name: 'Plugins' }).click();
await expect(page).toHaveURL(/\/plugin-store/, { timeout: 10_000 });
await expect(page.getByTestId('plugin-store-page')).toBeVisible({ timeout: 10_000 });
});
await test.step('Install fixture plugin from source manifest', async () => {
await page.getByLabel('Plugin source manifest URL').fill('http://localhost:4200/plugins/e2e-plugin-source.json');
await page.getByRole('button', { name: 'Add Source' }).click();
await expect(page.getByRole('heading', { name: 'E2E All API Plugin' })).toBeVisible({ timeout: 15_000 });
await page.getByRole('button', { name: 'Readme' }).click();
await expect(page.getByText('Fixture plugin for Playwright coverage.')).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { exact: true, name: /^(Install|Install to Server)$/ }).click();
const installDialog = page.getByRole('dialog', { name: 'E2E All API Plugin' });
await expect(installDialog).toBeVisible({ timeout: 10_000 });
await expect(installDialog.getByText('Install to server', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Install and Activate' }).click();
await expect(page.locator('article', { hasText: 'E2E All API Plugin' }).getByText('Installed')).toBeVisible({ timeout: 10_000 });
});
await test.step('Open plugin manager from the store page', async () => {
await page.getByRole('button', { name: 'Manage Plugins' }).click();
await expect(page.getByTestId('plugin-manager')).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId('plugin-manager').getByRole('heading', { name: 'Server plugins' })).toBeVisible();
await expect(page.getByText('E2E All API Plugin')).toBeVisible();
});
await test.step('Grant capabilities and activate runtime', async () => {
const manager = page.getByTestId('plugin-manager');
const pluginCard = manager.locator('article', { hasText: 'E2E All API Plugin' });
await manager.getByRole('button', { name: 'Installed' }).click();
await expect(pluginCard).toBeVisible({ timeout: 10_000 });
await pluginCard.getByRole('button', { name: 'Select' }).click();
await page.getByRole('button', { name: 'Grant all requested' }).click();
await page.getByRole('button', { name: 'Activate ready plugins' }).click();
await expect(page.locator('article', { hasText: 'E2E All API Plugin' }).getByText('ready', { exact: true })).toBeVisible({ timeout: 20_000 });
});
await test.step('Verify plugin exercised APIs through logs and extension points', async () => {
const manager = page.getByTestId('plugin-manager');
await manager.getByRole('button', { name: 'Logs' }).click();
await expect(page.getByText('all-api plugin completed')).toBeVisible({ timeout: 20_000 });
await expect(page.getByText('all-api plugin ready')).toBeVisible({ timeout: 10_000 });
await manager.getByRole('button', { name: 'Extension points' }).click();
await expect(page.getByTestId('plugin-extension-counts')).toContainText('Settings pages');
await expect(page.getByTestId('plugin-extension-counts')).toContainText('Embed renderers');
await expect(page.getByTestId('plugin-extension-counts')).toContainText('1');
await expect(page.getByTestId('plugin-conflict-diagnostics')).toContainText(
'No duplicate route, action, embed, channel, panel, or settings contribution ids detected.'
);
await manager.getByRole('button', { exact: true, name: 'Requirements' }).click();
await expect(page.getByTestId('plugin-server-requirements')).toContainText('E2E All API Plugin');
await expect(page.getByTestId('plugin-server-requirements')).toContainText('enabled');
await manager.getByRole('button', { exact: true, name: 'Settings' }).click();
await expect(page.getByTestId('plugin-generated-settings')).toContainText('E2E settings contribution');
await expect(page.getByTestId('plugin-generated-settings')).toContainText('"enabled"');
await manager.getByRole('button', { exact: true, name: 'Docs' }).click();
await expect(page.getByTestId('plugin-installed-docs')).toContainText('Calls every public Toju plugin API surface');
});
});
});

View File

@@ -0,0 +1,369 @@
import type { APIRequestContext, APIResponse } from '@playwright/test';
import WebSocket from 'ws';
import { expect, test } from '../../fixtures/multi-client';
import {
getPluginApiTestEvent,
readPluginApiTestManifest,
TEST_PLUGIN_ID,
TEST_PLUGIN_P2P_EVENT,
TEST_PLUGIN_RELAY_EVENT
} from '../../helpers/plugin-api-test-fixture';
const OWNER_USER_ID = 'plugin-api-owner';
interface CreatedServerResponse {
id: string;
}
interface PluginRequirementResponse {
requirement: {
pluginId: string;
reason?: string;
status: string;
versionRange?: string;
};
}
interface PluginEventDefinitionResponse {
eventDefinition: {
direction: string;
eventName: string;
maxPayloadBytes: number;
pluginId: string;
scope: string;
};
}
interface PluginSnapshotResponse {
eventDefinitions: PluginEventDefinitionResponse['eventDefinition'][];
requirements: PluginRequirementResponse['requirement'][];
serverId: string;
}
interface SocketMessage {
[key: string]: unknown;
type?: string;
}
interface TestSocket {
close: () => Promise<void>;
messages: SocketMessage[];
send: (message: SocketMessage) => void;
}
test.describe('Plugin support API', () => {
test('covers plugin requirement, event, data, and websocket APIs with the fixture plugin', async ({ request, testServer }) => {
const manifest = await readPluginApiTestManifest();
const server = await createServer(request, testServer.url, `Plugin API ${Date.now()}`);
const relayEvent = getPluginApiTestEvent(manifest, TEST_PLUGIN_RELAY_EVENT);
const p2pEvent = getPluginApiTestEvent(manifest, TEST_PLUGIN_P2P_EVENT);
const pluginsApi = `${testServer.url}/api/servers/${encodeURIComponent(server.id)}/plugins`;
await test.step('Initial snapshot is empty', async () => {
const snapshot = await expectJson<PluginSnapshotResponse>(await request.get(pluginsApi));
expect(snapshot).toEqual(expect.objectContaining({
eventDefinitions: [],
requirements: [],
serverId: server.id
}));
});
await test.step('Requirement API enforces server management permission', async () => {
const response = await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, {
data: {
actorUserId: 'not-the-owner',
status: 'required'
}
});
const body = await expectJson<{ errorCode: string }>(response, 403);
expect(body.errorCode).toBe('NOT_AUTHORIZED');
});
await test.step('Requirement and event definition APIs persist the test plugin contract', async () => {
const requirement = await expectJson<PluginRequirementResponse>(await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, {
data: {
actorUserId: OWNER_USER_ID,
reason: manifest.description,
status: 'required',
versionRange: `^${manifest.version}`
}
}));
expect(requirement.requirement).toEqual(expect.objectContaining({
pluginId: TEST_PLUGIN_ID,
reason: manifest.description,
status: 'required',
versionRange: `^${manifest.version}`
}));
const relayDefinition = await upsertEventDefinition(request, pluginsApi, relayEvent);
const p2pDefinition = await upsertEventDefinition(request, pluginsApi, p2pEvent);
expect(relayDefinition.eventDefinition).toEqual(expect.objectContaining({
direction: 'serverRelay',
eventName: TEST_PLUGIN_RELAY_EVENT,
pluginId: TEST_PLUGIN_ID,
scope: 'server'
}));
expect(p2pDefinition.eventDefinition).toEqual(expect.objectContaining({
direction: 'p2pHint',
eventName: TEST_PLUGIN_P2P_EVENT,
pluginId: TEST_PLUGIN_ID,
scope: 'user'
}));
const snapshot = await expectJson<PluginSnapshotResponse>(await request.get(pluginsApi));
expect(snapshot.requirements.map((entry) => entry.pluginId)).toEqual([TEST_PLUGIN_ID]);
expect(snapshot.eventDefinitions.map((entry) => entry.eventName).sort()).toEqual([TEST_PLUGIN_P2P_EVENT, TEST_PLUGIN_RELAY_EVENT]);
});
await test.step('Plugin data API refuses arbitrary server persistence', async () => {
const stored = await expectJson<{ errorCode: string }>(await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/data/settings`, {
data: {
actorUserId: OWNER_USER_ID,
schemaVersion: 1,
scope: 'server',
value: {
enabled: true,
pluginVersion: manifest.version
}
}
}), 410);
expect(stored.errorCode).toBe('PLUGIN_DATA_DISABLED');
const listed = await expectJson<{ errorCode: string }>(await request.get(`${pluginsApi}/${TEST_PLUGIN_ID}/data`, {
params: {
key: 'settings',
scope: 'server',
userId: OWNER_USER_ID
}
}), 410);
expect(listed.errorCode).toBe('PLUGIN_DATA_DISABLED');
const afterDelete = await expectJson<{ errorCode: string }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/data/settings`, {
data: {
actorUserId: OWNER_USER_ID,
scope: 'server'
}
}), 410);
expect(afterDelete.errorCode).toBe('PLUGIN_DATA_DISABLED');
});
await test.step('WebSocket plugin API sends snapshots, relays server events, and rejects p2p relays', async () => {
const alice = await openTestSocket(testServer.url);
const bob = await openTestSocket(testServer.url);
try {
alice.send({ type: 'identify', oderId: OWNER_USER_ID, displayName: 'Plugin Owner' });
bob.send({ type: 'identify', oderId: 'plugin-api-peer', displayName: 'Plugin Peer' });
alice.send({ type: 'join_server', serverId: server.id });
bob.send({ type: 'join_server', serverId: server.id });
const aliceSnapshot = await waitForSocketMessage(alice, (message) => message.type === 'plugin_requirements');
const bobSnapshot = await waitForSocketMessage(bob, (message) => message.type === 'plugin_requirements');
const bobEventNames = (bobSnapshot['snapshot'] as PluginSnapshotResponse).eventDefinitions
.map((entry) => entry.eventName)
.sort();
expect((aliceSnapshot['snapshot'] as PluginSnapshotResponse).requirements[0]?.pluginId).toBe(TEST_PLUGIN_ID);
expect(bobEventNames).toEqual([TEST_PLUGIN_P2P_EVENT, TEST_PLUGIN_RELAY_EVENT]);
alice.send({
type: 'plugin_event',
eventId: 'relay-event-1',
eventName: TEST_PLUGIN_RELAY_EVENT,
payload: { message: 'hello from fixture plugin' },
pluginId: TEST_PLUGIN_ID,
serverId: server.id,
sourcePluginUserId: 'fixture-plugin-user'
});
const relayedEvent = await waitForSocketMessage(bob, (message) => message.type === 'plugin_event');
expect(relayedEvent).toEqual(expect.objectContaining({
eventId: 'relay-event-1',
eventName: TEST_PLUGIN_RELAY_EVENT,
pluginId: TEST_PLUGIN_ID,
serverId: server.id,
sourcePluginUserId: 'fixture-plugin-user',
sourceUserId: OWNER_USER_ID
}));
expect(relayedEvent['payload']).toEqual({ message: 'hello from fixture plugin' });
expect(typeof relayedEvent['emittedAt']).toBe('number');
alice.send({
type: 'plugin_event',
eventId: 'p2p-event-1',
eventName: TEST_PLUGIN_P2P_EVENT,
payload: { hint: true },
pluginId: TEST_PLUGIN_ID,
serverId: server.id
});
const p2pError = await waitForSocketMessage(
alice,
(message) => message.type === 'plugin_error' && message['eventId'] === 'p2p-event-1'
);
expect(p2pError['code']).toBe('PLUGIN_EVENT_NOT_RELAYABLE');
alice.send({
type: 'plugin_event',
eventId: 'missing-event-1',
eventName: 'e2e:missing',
payload: {},
pluginId: TEST_PLUGIN_ID,
serverId: server.id
});
const missingError = await waitForSocketMessage(
alice,
(message) => message.type === 'plugin_error' && message['eventId'] === 'missing-event-1'
);
expect(missingError['code']).toBe('PLUGIN_EVENT_NOT_REGISTERED');
} finally {
await Promise.all([alice.close(), bob.close()]);
}
});
await test.step('Delete APIs remove event definitions and requirements', async () => {
await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/events/${TEST_PLUGIN_RELAY_EVENT}`, {
data: { actorUserId: OWNER_USER_ID }
}));
await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/events/${TEST_PLUGIN_P2P_EVENT}`, {
data: { actorUserId: OWNER_USER_ID }
}));
await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, {
data: { actorUserId: OWNER_USER_ID }
}));
const snapshot = await expectJson<PluginSnapshotResponse>(await request.get(pluginsApi));
expect(snapshot.eventDefinitions).toEqual([]);
expect(snapshot.requirements).toEqual([]);
});
});
});
async function createServer(
request: APIRequestContext,
baseUrl: string,
serverName: string
): Promise<CreatedServerResponse> {
const response = await request.post(`${baseUrl}/api/servers`, {
data: {
channels: [
{
id: 'general-text',
name: 'general',
position: 0,
type: 'text'
}
],
description: 'Server for plugin API E2E coverage',
id: `plugin-api-${Date.now()}`,
isPrivate: false,
name: serverName,
ownerId: OWNER_USER_ID,
ownerPublicKey: 'plugin-api-owner-public-key',
tags: ['plugins']
}
});
return await expectJson<CreatedServerResponse>(response, 201);
}
async function upsertEventDefinition(
request: APIRequestContext,
pluginsApi: string,
eventDefinition: ReturnType<typeof getPluginApiTestEvent>
): Promise<PluginEventDefinitionResponse> {
return await expectJson<PluginEventDefinitionResponse>(await request.put(
`${pluginsApi}/${TEST_PLUGIN_ID}/events/${encodeURIComponent(eventDefinition.eventName)}`,
{
data: {
actorUserId: OWNER_USER_ID,
direction: eventDefinition.direction,
maxPayloadBytes: eventDefinition.maxPayloadBytes,
schemaJson: '{"type":"object"}',
scope: eventDefinition.scope
}
}
));
}
async function expectJson<T>(response: APIResponse, status = 200): Promise<T> {
expect(response.status()).toBe(status);
return await response.json() as T;
}
async function openTestSocket(baseUrl: string): Promise<TestSocket> {
const socketUrl = baseUrl.replace(/^http/, 'ws');
const socket = new WebSocket(socketUrl);
const messages: SocketMessage[] = [];
socket.on('message', (data) => {
messages.push(JSON.parse(data.toString()) as SocketMessage);
});
await new Promise<void>((resolve, reject) => {
socket.once('open', () => resolve());
socket.once('error', reject);
});
await waitForSocketMessage({ messages, send: () => {}, close: async () => {} }, (message) => message.type === 'connected');
return {
close: async () => {
if (socket.readyState === WebSocket.CLOSED) {
return;
}
await new Promise<void>((resolve) => {
socket.once('close', () => resolve());
socket.close();
});
},
messages,
send: (message: SocketMessage) => {
socket.send(JSON.stringify(message));
}
};
}
async function waitForSocketMessage(
socket: Pick<TestSocket, 'messages'>,
predicate: (message: SocketMessage) => boolean,
timeoutMs = 10_000
): Promise<SocketMessage> {
const startedAt = Date.now();
return await new Promise((resolve, reject) => {
const interval = setInterval(() => {
const message = socket.messages.find(predicate);
if (message) {
clearInterval(interval);
resolve(message);
return;
}
if (Date.now() - startedAt > timeoutMs) {
clearInterval(interval);
reject(new Error('Timed out waiting for websocket message'));
}
}, 25);
});
}

View File

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

View File

@@ -88,7 +88,7 @@ test.describe('Connectivity warning', () => {
await register.goto(); await register.goto();
await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!'); await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!');
await expect(alice.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 }); await expect(alice.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
}); });
await test.step('Register Bob', async () => { await test.step('Register Bob', async () => {
@@ -96,7 +96,7 @@ test.describe('Connectivity warning', () => {
await register.goto(); await register.goto();
await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!'); await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!');
await expect(bob.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 }); await expect(bob.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
}); });
await test.step('Register Charlie', async () => { await test.step('Register Charlie', async () => {
@@ -104,7 +104,7 @@ test.describe('Connectivity warning', () => {
await register.goto(); await register.goto();
await register.register(`charlie_${suffix}`, 'Charlie', 'TestPass123!'); await register.register(`charlie_${suffix}`, 'Charlie', 'TestPass123!');
await expect(charlie.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 }); await expect(charlie.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
}); });
// ── Create server and have everyone join ── // ── Create server and have everyone join ──
@@ -117,22 +117,14 @@ test.describe('Connectivity warning', () => {
await test.step('Bob joins the server', async () => { await test.step('Bob joins the server', async () => {
const search = new ServerSearchPage(bob.page); const search = new ServerSearchPage(bob.page);
await search.searchInput.fill(serverName); await search.joinServerFromSearch(serverName);
const card = bob.page.locator('button', { hasText: serverName }).first();
await expect(card).toBeVisible({ timeout: 15_000 });
await card.click();
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 }); await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
}); });
await test.step('Charlie joins the server', async () => { await test.step('Charlie joins the server', async () => {
const search = new ServerSearchPage(charlie.page); const search = new ServerSearchPage(charlie.page);
await search.searchInput.fill(serverName); await search.joinServerFromSearch(serverName);
const card = charlie.page.locator('button', { hasText: serverName }).first();
await expect(card).toBeVisible({ timeout: 15_000 });
await card.click();
await expect(charlie.page).toHaveURL(/\/room\//, { timeout: 15_000 }); await expect(charlie.page).toHaveURL(/\/room\//, { timeout: 15_000 });
}); });

View File

@@ -9,10 +9,11 @@ test.describe('ICE server settings', () => {
await register.goto(); await register.goto();
await register.register(`user_${suffix}`, 'IceTestUser', 'TestPass123!'); await register.register(`user_${suffix}`, 'IceTestUser', 'TestPass123!');
await expect(page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 }); await expect(page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
await page.getByTitle('Settings').click(); await page.getByTitle('Settings').click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10_000 }); await expect(page.getByRole('button', { name: 'Network' })).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Network' }).click(); await page.getByRole('button', { name: 'Network' }).click();
await expect(page.getByTestId('ice-server-settings')).toBeVisible({ timeout: 10_000 });
} }
test('allows adding, removing, and reordering ICE servers', async ({ createClient }) => { test('allows adding, removing, and reordering ICE servers', async ({ createClient }) => {
@@ -101,7 +102,7 @@ test.describe('ICE server settings', () => {
await page.reload({ waitUntil: 'domcontentloaded' }); await page.reload({ waitUntil: 'domcontentloaded' });
await page.getByTitle('Settings').click(); await page.getByTitle('Settings').click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10_000 }); await expect(page.getByRole('button', { name: 'Network' })).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Network' }).click(); await page.getByRole('button', { name: 'Network' }).click();
await expect(page.getByText('stun:persist-test.example.com:3478')).toBeVisible({ timeout: 10_000 }); await expect(page.getByText('stun:persist-test.example.com:3478')).toBeVisible({ timeout: 10_000 });
}); });

View File

@@ -89,7 +89,7 @@ test.describe('STUN/TURN fallback behaviour', () => {
await register.goto(); await register.goto();
await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!'); await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!');
await expect(alice.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 }); await expect(alice.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
}); });
await test.step('Register Bob', async () => { await test.step('Register Bob', async () => {
@@ -97,7 +97,7 @@ test.describe('STUN/TURN fallback behaviour', () => {
await register.goto(); await register.goto();
await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!'); await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!');
await expect(bob.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 }); await expect(bob.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
}); });
await test.step('Alice creates a server', async () => { await test.step('Alice creates a server', async () => {
@@ -109,11 +109,7 @@ test.describe('STUN/TURN fallback behaviour', () => {
await test.step('Bob joins Alice server', async () => { await test.step('Bob joins Alice server', async () => {
const search = new ServerSearchPage(bob.page); const search = new ServerSearchPage(bob.page);
await search.searchInput.fill(serverName); await search.joinServerFromSearch(serverName);
const serverCard = bob.page.locator('button', { hasText: serverName }).first();
await expect(serverCard).toBeVisible({ timeout: 15_000 });
await serverCard.click();
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 }); await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
}); });

View File

@@ -556,7 +556,7 @@ async function installDeterministicVoiceSettings(page: Page): Promise<void> {
} }
async function openSearchView(page: Page): Promise<void> { async function openSearchView(page: Page): Promise<void> {
const searchInput = page.getByPlaceholder('Search servers...'); const searchInput = page.getByPlaceholder('Search servers and users...');
if (await searchInput.isVisible().catch(() => false)) { if (await searchInput.isVisible().catch(() => false)) {
return; return;
@@ -567,15 +567,15 @@ async function openSearchView(page: Page): Promise<void> {
} }
async function joinRoomFromSearch(page: Page, roomName: string): Promise<void> { async function joinRoomFromSearch(page: Page, roomName: string): Promise<void> {
const searchInput = page.getByPlaceholder('Search servers...'); const searchInput = page.getByPlaceholder('Search servers and users...');
await expect(searchInput).toBeVisible({ timeout: 20_000 }); await expect(searchInput).toBeVisible({ timeout: 20_000 });
await searchInput.fill(roomName); await searchInput.fill(roomName);
const roomCard = page.locator('button', { hasText: roomName }).first(); const roomCard = page.locator('div[title]', { hasText: roomName }).first();
await expect(roomCard).toBeVisible({ timeout: 20_000 }); await expect(roomCard).toBeVisible({ timeout: 20_000 });
await roomCard.click(); await roomCard.dblclick();
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 }); await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 }); await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
await waitForCurrentRoomName(page, roomName); await waitForCurrentRoomName(page, roomName);

View File

@@ -319,7 +319,7 @@ async function installDeterministicVoiceSettings(page: Page): Promise<void> {
} }
async function openSearchView(page: Page): Promise<void> { async function openSearchView(page: Page): Promise<void> {
const searchInput = page.getByPlaceholder('Search servers...'); const searchInput = page.getByPlaceholder('Search servers and users...');
if (await searchInput.isVisible().catch(() => false)) { if (await searchInput.isVisible().catch(() => false)) {
return; return;
@@ -330,15 +330,15 @@ async function openSearchView(page: Page): Promise<void> {
} }
async function joinRoomFromSearch(page: Page, roomName: string): Promise<void> { async function joinRoomFromSearch(page: Page, roomName: string): Promise<void> {
const searchInput = page.getByPlaceholder('Search servers...'); const searchInput = page.getByPlaceholder('Search servers and users...');
await expect(searchInput).toBeVisible({ timeout: 20_000 }); await expect(searchInput).toBeVisible({ timeout: 20_000 });
await searchInput.fill(roomName); await searchInput.fill(roomName);
const roomCard = page.locator('button', { hasText: roomName }).first(); const roomCard = page.locator('div[title]', { hasText: roomName }).first();
await expect(roomCard).toBeVisible({ timeout: 20_000 }); await expect(roomCard).toBeVisible({ timeout: 20_000 });
await roomCard.click(); await roomCard.dblclick();
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 }); await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 }); await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
await waitForCurrentRoomName(page, roomName); await waitForCurrentRoomName(page, roomName);

View File

@@ -96,14 +96,7 @@ test.describe('Full user journey: register -> server -> voice chat', () => {
await test.step('Bob finds and joins the server', async () => { await test.step('Bob finds and joins the server', async () => {
const searchPage = new ServerSearchPage(bob.page); const searchPage = new ServerSearchPage(bob.page);
// Search for the server await searchPage.joinServerFromSearch(SERVER_NAME);
await searchPage.searchInput.fill(SERVER_NAME);
// Wait for search results and click the server
const serverCard = bob.page.locator('button', { hasText: SERVER_NAME }).first();
await expect(serverCard).toBeVisible({ timeout: 10_000 });
await serverCard.click();
// Bob should be in the room now // Bob should be in the room now
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 }); await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });

View File

@@ -16,6 +16,7 @@ Electron main-process package for MetoYou / Toju. This directory owns desktop bo
| --- | --- | | --- | --- |
| `main.ts` | Electron app bootstrap and process entry point | | `main.ts` | Electron app bootstrap and process entry point |
| `preload.ts` | Typed renderer-facing preload bridge | | `preload.ts` | Typed renderer-facing preload bridge |
| `process-list.ts` | Linux/Windows process-name scan used by now-playing game detection |
| `app/` | App lifecycle and startup composition | | `app/` | App lifecycle and startup composition |
| `ipc/` | Renderer-invoked IPC handlers | | `ipc/` | Renderer-invoked IPC handlers |
| `cqrs/` | Local database command/query handlers and mappings | | `cqrs/` | Local database command/query handlers and mappings |
@@ -27,5 +28,6 @@ Electron main-process package for MetoYou / Toju. This directory owns desktop bo
## Notes ## Notes
- 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.
- Plugin client data is stored in the local Electron SQLite database in the dedicated `plugin_data` table. Renderer plugins reach it through CQRS commands/queries exposed by the preload bridge; the signal server must not be used for arbitrary plugin data persistence.
- Treat `dist/electron/` and `dist-electron/` as generated output. - Treat `dist/electron/` and `dist-electron/` as generated output.
- See [AGENTS.md](AGENTS.md) for package-level editing rules. - See [AGENTS.md](AGENTS.md) for package-level editing rules.

View File

@@ -5,6 +5,7 @@ import { createWindow, getMainWindow } from '../window/create-window';
const CUSTOM_PROTOCOL = 'toju'; const CUSTOM_PROTOCOL = 'toju';
const DEEP_LINK_PREFIX = `${CUSTOM_PROTOCOL}://`; const DEEP_LINK_PREFIX = `${CUSTOM_PROTOCOL}://`;
const DEV_SINGLE_INSTANCE_EXIT_CODE_ENV = 'METOYOU_SINGLE_INSTANCE_EXIT_CODE'; const DEV_SINGLE_INSTANCE_EXIT_CODE_ENV = 'METOYOU_SINGLE_INSTANCE_EXIT_CODE';
const DEV_RELOAD_EXISTING_ARG = '--metoyou-dev-reload-existing';
let pendingDeepLink: string | null = null; let pendingDeepLink: string | null = null;
@@ -95,6 +96,12 @@ export function initializeDeepLinkHandling(): boolean {
} }
app.on('second-instance', (_event, argv) => { app.on('second-instance', (_event, argv) => {
if (resolveDevSingleInstanceExitCode() != null && argv.includes(DEV_RELOAD_EXISTING_ARG)) {
app.relaunch();
app.exit(0);
return;
}
focusMainWindow(); focusMainWindow();
const deepLink = extractDeepLink(argv); const deepLink = extractDeepLink(argv);

View File

@@ -11,7 +11,8 @@ import {
ReactionEntity, ReactionEntity,
BanEntity, BanEntity,
AttachmentEntity, AttachmentEntity,
MetaEntity MetaEntity,
PluginDataEntity
} from '../../../entities'; } from '../../../entities';
export async function handleClearAllData(dataSource: DataSource): Promise<void> { export async function handleClearAllData(dataSource: DataSource): Promise<void> {
@@ -27,4 +28,5 @@ export async function handleClearAllData(dataSource: DataSource): Promise<void>
await dataSource.getRepository(BanEntity).clear(); await dataSource.getRepository(BanEntity).clear();
await dataSource.getRepository(AttachmentEntity).clear(); await dataSource.getRepository(AttachmentEntity).clear();
await dataSource.getRepository(MetaEntity).clear(); await dataSource.getRepository(MetaEntity).clear();
await dataSource.getRepository(PluginDataEntity).clear();
} }

View File

@@ -0,0 +1,14 @@
import { DataSource } from 'typeorm';
import { PluginDataEntity } from '../../../entities';
import { DeletePluginDataCommand } from '../../types';
export async function handleDeletePluginData(command: DeletePluginDataCommand, dataSource: DataSource): Promise<void> {
const { payload } = command;
await dataSource.getRepository(PluginDataEntity).delete({
key: payload.key,
pluginId: payload.pluginId,
scope: payload.scope,
serverId: payload.serverId ?? ''
});
}

View File

@@ -0,0 +1,10 @@
import { DataSource } from 'typeorm';
import { MetaEntity } from '../../../entities';
import { SaveMetaCommand } from '../../types';
export async function handleSaveMeta(command: SaveMetaCommand, dataSource: DataSource): Promise<void> {
await dataSource.getRepository(MetaEntity).save({
key: command.payload.key,
value: command.payload.value
});
}

View File

@@ -0,0 +1,16 @@
import { DataSource } from 'typeorm';
import { PluginDataEntity } from '../../../entities';
import { SavePluginDataCommand } from '../../types';
export async function handleSavePluginData(command: SavePluginDataCommand, dataSource: DataSource): Promise<void> {
const { payload } = command;
await dataSource.getRepository(PluginDataEntity).save({
key: payload.key,
pluginId: payload.pluginId,
scope: payload.scope,
serverId: payload.serverId ?? '',
updatedAt: Date.now(),
valueJson: JSON.stringify(payload.value ?? null)
});
}

View File

@@ -18,7 +18,10 @@ import {
SaveBanCommand, SaveBanCommand,
RemoveBanCommand, RemoveBanCommand,
SaveAttachmentCommand, SaveAttachmentCommand,
DeleteAttachmentsForMessageCommand DeleteAttachmentsForMessageCommand,
SavePluginDataCommand,
DeletePluginDataCommand,
SaveMetaCommand
} from '../types'; } from '../types';
import { handleSaveMessage } from './handlers/saveMessage'; import { handleSaveMessage } from './handlers/saveMessage';
import { handleDeleteMessage } from './handlers/deleteMessage'; import { handleDeleteMessage } from './handlers/deleteMessage';
@@ -36,6 +39,9 @@ import { handleSaveBan } from './handlers/saveBan';
import { handleRemoveBan } from './handlers/removeBan'; import { handleRemoveBan } from './handlers/removeBan';
import { handleSaveAttachment } from './handlers/saveAttachment'; import { handleSaveAttachment } from './handlers/saveAttachment';
import { handleDeleteAttachmentsForMessage } from './handlers/deleteAttachmentsForMessage'; import { handleDeleteAttachmentsForMessage } from './handlers/deleteAttachmentsForMessage';
import { handleSavePluginData } from './handlers/savePluginData';
import { handleDeletePluginData } from './handlers/deletePluginData';
import { handleSaveMeta } from './handlers/saveMeta';
import { handleClearAllData } from './handlers/clearAllData'; import { handleClearAllData } from './handlers/clearAllData';
export const buildCommandHandlers = (dataSource: DataSource): Record<CommandTypeKey, (command: Command) => Promise<unknown>> => ({ export const buildCommandHandlers = (dataSource: DataSource): Record<CommandTypeKey, (command: Command) => Promise<unknown>> => ({
@@ -55,5 +61,8 @@ export const buildCommandHandlers = (dataSource: DataSource): Record<CommandType
[CommandType.RemoveBan]: (cmd) => handleRemoveBan(cmd as RemoveBanCommand, dataSource), [CommandType.RemoveBan]: (cmd) => handleRemoveBan(cmd as RemoveBanCommand, dataSource),
[CommandType.SaveAttachment]: (cmd) => handleSaveAttachment(cmd as SaveAttachmentCommand, dataSource), [CommandType.SaveAttachment]: (cmd) => handleSaveAttachment(cmd as SaveAttachmentCommand, dataSource),
[CommandType.DeleteAttachmentsForMessage]: (cmd) => handleDeleteAttachmentsForMessage(cmd as DeleteAttachmentsForMessageCommand, dataSource), [CommandType.DeleteAttachmentsForMessage]: (cmd) => handleDeleteAttachmentsForMessage(cmd as DeleteAttachmentsForMessageCommand, dataSource),
[CommandType.SavePluginData]: (cmd) => handleSavePluginData(cmd as SavePluginDataCommand, dataSource),
[CommandType.DeletePluginData]: (cmd) => handleDeletePluginData(cmd as DeletePluginDataCommand, dataSource),
[CommandType.SaveMeta]: (cmd) => handleSaveMeta(cmd as SaveMetaCommand, dataSource),
[CommandType.ClearAllData]: () => handleClearAllData(dataSource) [CommandType.ClearAllData]: () => handleClearAllData(dataSource)
}); });

View File

@@ -0,0 +1,9 @@
import { DataSource } from 'typeorm';
import { MetaEntity } from '../../../entities';
export async function handleGetCurrentUserId(dataSource: DataSource): Promise<string | null> {
const metaRepo = dataSource.getRepository(MetaEntity);
const metaRow = await metaRepo.findOne({ where: { key: 'currentUserId' } });
return metaRow?.value?.trim() || null;
}

View File

@@ -0,0 +1,11 @@
import { DataSource } from 'typeorm';
import { MetaEntity } from '../../../entities';
import { GetMetaQuery } from '../../types';
export async function handleGetMeta(query: GetMetaQuery, dataSource: DataSource): Promise<string | null> {
const meta = await dataSource.getRepository(MetaEntity).findOne({
where: { key: query.payload.key }
});
return meta?.value ?? null;
}

View File

@@ -0,0 +1,25 @@
import { DataSource } from 'typeorm';
import { PluginDataEntity } from '../../../entities';
import { GetPluginDataQuery } from '../../types';
export async function handleGetPluginData(query: GetPluginDataQuery, dataSource: DataSource): Promise<unknown> {
const { payload } = query;
const record = await dataSource.getRepository(PluginDataEntity).findOne({
where: {
key: payload.key,
pluginId: payload.pluginId,
scope: payload.scope,
serverId: payload.serverId ?? ''
}
});
if (!record) {
return null;
}
try {
return JSON.parse(record.valueJson) as unknown;
} catch {
return null;
}
}

View File

@@ -11,7 +11,9 @@ import {
GetRoomQuery, GetRoomQuery,
GetBansForRoomQuery, GetBansForRoomQuery,
IsUserBannedQuery, IsUserBannedQuery,
GetAttachmentsForMessageQuery GetAttachmentsForMessageQuery,
GetPluginDataQuery,
GetMetaQuery
} from '../types'; } from '../types';
import { handleGetMessages } from './handlers/getMessages'; import { handleGetMessages } from './handlers/getMessages';
import { handleGetMessagesSince } from './handlers/getMessagesSince'; import { handleGetMessagesSince } from './handlers/getMessagesSince';
@@ -19,6 +21,7 @@ import { handleGetMessageById } from './handlers/getMessageById';
import { handleGetReactionsForMessage } from './handlers/getReactionsForMessage'; import { handleGetReactionsForMessage } from './handlers/getReactionsForMessage';
import { handleGetUser } from './handlers/getUser'; import { handleGetUser } from './handlers/getUser';
import { handleGetCurrentUser } from './handlers/getCurrentUser'; import { handleGetCurrentUser } from './handlers/getCurrentUser';
import { handleGetCurrentUserId } from './handlers/getCurrentUserId';
import { handleGetUsersByRoom } from './handlers/getUsersByRoom'; import { handleGetUsersByRoom } from './handlers/getUsersByRoom';
import { handleGetRoom } from './handlers/getRoom'; import { handleGetRoom } from './handlers/getRoom';
import { handleGetAllRooms } from './handlers/getAllRooms'; import { handleGetAllRooms } from './handlers/getAllRooms';
@@ -26,6 +29,8 @@ import { handleGetBansForRoom } from './handlers/getBansForRoom';
import { handleIsUserBanned } from './handlers/isUserBanned'; import { handleIsUserBanned } from './handlers/isUserBanned';
import { handleGetAttachmentsForMessage } from './handlers/getAttachmentsForMessage'; import { handleGetAttachmentsForMessage } from './handlers/getAttachmentsForMessage';
import { handleGetAllAttachments } from './handlers/getAllAttachments'; import { handleGetAllAttachments } from './handlers/getAllAttachments';
import { handleGetPluginData } from './handlers/getPluginData';
import { handleGetMeta } from './handlers/getMeta';
export const buildQueryHandlers = (dataSource: DataSource): Record<QueryTypeKey, (query: Query) => Promise<unknown>> => ({ export const buildQueryHandlers = (dataSource: DataSource): Record<QueryTypeKey, (query: Query) => Promise<unknown>> => ({
[QueryType.GetMessages]: (query) => handleGetMessages(query as GetMessagesQuery, dataSource), [QueryType.GetMessages]: (query) => handleGetMessages(query as GetMessagesQuery, dataSource),
@@ -34,11 +39,14 @@ export const buildQueryHandlers = (dataSource: DataSource): Record<QueryTypeKey,
[QueryType.GetReactionsForMessage]: (query) => handleGetReactionsForMessage(query as GetReactionsForMessageQuery, dataSource), [QueryType.GetReactionsForMessage]: (query) => handleGetReactionsForMessage(query as GetReactionsForMessageQuery, dataSource),
[QueryType.GetUser]: (query) => handleGetUser(query as GetUserQuery, dataSource), [QueryType.GetUser]: (query) => handleGetUser(query as GetUserQuery, dataSource),
[QueryType.GetCurrentUser]: () => handleGetCurrentUser(dataSource), [QueryType.GetCurrentUser]: () => handleGetCurrentUser(dataSource),
[QueryType.GetCurrentUserId]: () => handleGetCurrentUserId(dataSource),
[QueryType.GetUsersByRoom]: () => handleGetUsersByRoom(dataSource), [QueryType.GetUsersByRoom]: () => handleGetUsersByRoom(dataSource),
[QueryType.GetRoom]: (query) => handleGetRoom(query as GetRoomQuery, dataSource), [QueryType.GetRoom]: (query) => handleGetRoom(query as GetRoomQuery, dataSource),
[QueryType.GetAllRooms]: () => handleGetAllRooms(dataSource), [QueryType.GetAllRooms]: () => handleGetAllRooms(dataSource),
[QueryType.GetBansForRoom]: (query) => handleGetBansForRoom(query as GetBansForRoomQuery, dataSource), [QueryType.GetBansForRoom]: (query) => handleGetBansForRoom(query as GetBansForRoomQuery, dataSource),
[QueryType.IsUserBanned]: (query) => handleIsUserBanned(query as IsUserBannedQuery, dataSource), [QueryType.IsUserBanned]: (query) => handleIsUserBanned(query as IsUserBannedQuery, dataSource),
[QueryType.GetAttachmentsForMessage]: (query) => handleGetAttachmentsForMessage(query as GetAttachmentsForMessageQuery, dataSource), [QueryType.GetAttachmentsForMessage]: (query) => handleGetAttachmentsForMessage(query as GetAttachmentsForMessageQuery, dataSource),
[QueryType.GetAllAttachments]: () => handleGetAllAttachments(dataSource) [QueryType.GetAllAttachments]: () => handleGetAllAttachments(dataSource),
[QueryType.GetPluginData]: (query) => handleGetPluginData(query as GetPluginDataQuery, dataSource),
[QueryType.GetMeta]: (query) => handleGetMeta(query as GetMetaQuery, dataSource)
}); });

View File

@@ -15,6 +15,9 @@ export const CommandType = {
RemoveBan: 'remove-ban', RemoveBan: 'remove-ban',
SaveAttachment: 'save-attachment', SaveAttachment: 'save-attachment',
DeleteAttachmentsForMessage: 'delete-attachments-for-message', DeleteAttachmentsForMessage: 'delete-attachments-for-message',
SavePluginData: 'save-plugin-data',
DeletePluginData: 'delete-plugin-data',
SaveMeta: 'save-meta',
ClearAllData: 'clear-all-data' ClearAllData: 'clear-all-data'
} as const; } as const;
@@ -27,13 +30,16 @@ export const QueryType = {
GetReactionsForMessage: 'get-reactions-for-message', GetReactionsForMessage: 'get-reactions-for-message',
GetUser: 'get-user', GetUser: 'get-user',
GetCurrentUser: 'get-current-user', GetCurrentUser: 'get-current-user',
GetCurrentUserId: 'get-current-user-id',
GetUsersByRoom: 'get-users-by-room', GetUsersByRoom: 'get-users-by-room',
GetRoom: 'get-room', GetRoom: 'get-room',
GetAllRooms: 'get-all-rooms', GetAllRooms: 'get-all-rooms',
GetBansForRoom: 'get-bans-for-room', GetBansForRoom: 'get-bans-for-room',
IsUserBanned: 'is-user-banned', IsUserBanned: 'is-user-banned',
GetAttachmentsForMessage: 'get-attachments-for-message', GetAttachmentsForMessage: 'get-attachments-for-message',
GetAllAttachments: 'get-all-attachments' GetAllAttachments: 'get-all-attachments',
GetPluginData: 'get-plugin-data',
GetMeta: 'get-meta'
} as const; } as const;
export type QueryTypeKey = typeof QueryType[keyof typeof QueryType]; export type QueryTypeKey = typeof QueryType[keyof typeof QueryType];
@@ -171,6 +177,16 @@ export interface AttachmentPayload {
savedPath?: string; savedPath?: string;
} }
export type PluginDataScopePayload = 'local' | 'server';
export interface PluginDataPayload {
key: string;
pluginId: string;
scope: PluginDataScopePayload;
serverId?: string;
value: unknown;
}
export interface SaveMessageCommand { type: typeof CommandType.SaveMessage; payload: { message: MessagePayload } } export interface SaveMessageCommand { type: typeof CommandType.SaveMessage; payload: { message: MessagePayload } }
export interface DeleteMessageCommand { type: typeof CommandType.DeleteMessage; payload: { messageId: string } } export interface DeleteMessageCommand { type: typeof CommandType.DeleteMessage; payload: { messageId: string } }
export interface UpdateMessageCommand { type: typeof CommandType.UpdateMessage; payload: { messageId: string; updates: Partial<MessagePayload> } } export interface UpdateMessageCommand { type: typeof CommandType.UpdateMessage; payload: { messageId: string; updates: Partial<MessagePayload> } }
@@ -187,6 +203,9 @@ export interface SaveBanCommand { type: typeof CommandType.SaveBan; payload: { b
export interface RemoveBanCommand { type: typeof CommandType.RemoveBan; payload: { oderId: string } } export interface RemoveBanCommand { type: typeof CommandType.RemoveBan; payload: { oderId: string } }
export interface SaveAttachmentCommand { type: typeof CommandType.SaveAttachment; payload: { attachment: AttachmentPayload } } export interface SaveAttachmentCommand { type: typeof CommandType.SaveAttachment; payload: { attachment: AttachmentPayload } }
export interface DeleteAttachmentsForMessageCommand { type: typeof CommandType.DeleteAttachmentsForMessage; payload: { messageId: string } } export interface DeleteAttachmentsForMessageCommand { type: typeof CommandType.DeleteAttachmentsForMessage; payload: { messageId: string } }
export interface SavePluginDataCommand { type: typeof CommandType.SavePluginData; payload: PluginDataPayload }
export interface DeletePluginDataCommand { type: typeof CommandType.DeletePluginData; payload: Omit<PluginDataPayload, 'value'> }
export interface SaveMetaCommand { type: typeof CommandType.SaveMeta; payload: { key: string; value: string | null } }
export interface ClearAllDataCommand { type: typeof CommandType.ClearAllData; payload: Record<string, never> } export interface ClearAllDataCommand { type: typeof CommandType.ClearAllData; payload: Record<string, never> }
export type Command = export type Command =
@@ -206,6 +225,9 @@ export type Command =
| RemoveBanCommand | RemoveBanCommand
| SaveAttachmentCommand | SaveAttachmentCommand
| DeleteAttachmentsForMessageCommand | DeleteAttachmentsForMessageCommand
| SavePluginDataCommand
| DeletePluginDataCommand
| SaveMetaCommand
| ClearAllDataCommand; | ClearAllDataCommand;
export interface GetMessagesQuery { type: typeof QueryType.GetMessages; payload: { roomId: string; limit?: number; offset?: number } } export interface GetMessagesQuery { type: typeof QueryType.GetMessages; payload: { roomId: string; limit?: number; offset?: number } }
@@ -214,6 +236,7 @@ export interface GetMessageByIdQuery { type: typeof QueryType.GetMessageById; pa
export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } } export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } }
export interface GetUserQuery { type: typeof QueryType.GetUser; payload: { userId: string } } export interface GetUserQuery { type: typeof QueryType.GetUser; payload: { userId: string } }
export interface GetCurrentUserQuery { type: typeof QueryType.GetCurrentUser; payload: Record<string, never> } export interface GetCurrentUserQuery { type: typeof QueryType.GetCurrentUser; payload: Record<string, never> }
export interface GetCurrentUserIdQuery { type: typeof QueryType.GetCurrentUserId; payload: Record<string, never> }
export interface GetUsersByRoomQuery { type: typeof QueryType.GetUsersByRoom; payload: { roomId: string } } export interface GetUsersByRoomQuery { type: typeof QueryType.GetUsersByRoom; payload: { roomId: string } }
export interface GetRoomQuery { type: typeof QueryType.GetRoom; payload: { roomId: string } } export interface GetRoomQuery { type: typeof QueryType.GetRoom; payload: { roomId: string } }
export interface GetAllRoomsQuery { type: typeof QueryType.GetAllRooms; payload: Record<string, never> } export interface GetAllRoomsQuery { type: typeof QueryType.GetAllRooms; payload: Record<string, never> }
@@ -221,6 +244,8 @@ export interface GetBansForRoomQuery { type: typeof QueryType.GetBansForRoom; pa
export interface IsUserBannedQuery { type: typeof QueryType.IsUserBanned; payload: { userId: string; roomId: string } } export interface IsUserBannedQuery { type: typeof QueryType.IsUserBanned; payload: { userId: string; roomId: string } }
export interface GetAttachmentsForMessageQuery { type: typeof QueryType.GetAttachmentsForMessage; payload: { messageId: string } } export interface GetAttachmentsForMessageQuery { type: typeof QueryType.GetAttachmentsForMessage; payload: { messageId: string } }
export interface GetAllAttachmentsQuery { type: typeof QueryType.GetAllAttachments; payload: Record<string, never> } export interface GetAllAttachmentsQuery { type: typeof QueryType.GetAllAttachments; payload: Record<string, never> }
export interface GetPluginDataQuery { type: typeof QueryType.GetPluginData; payload: Omit<PluginDataPayload, 'value'> }
export interface GetMetaQuery { type: typeof QueryType.GetMeta; payload: { key: string } }
export type Query = export type Query =
| GetMessagesQuery | GetMessagesQuery
@@ -229,10 +254,13 @@ export type Query =
| GetReactionsForMessageQuery | GetReactionsForMessageQuery
| GetUserQuery | GetUserQuery
| GetCurrentUserQuery | GetCurrentUserQuery
| GetCurrentUserIdQuery
| GetUsersByRoomQuery | GetUsersByRoomQuery
| GetRoomQuery | GetRoomQuery
| GetAllRoomsQuery | GetAllRoomsQuery
| GetBansForRoomQuery | GetBansForRoomQuery
| IsUserBannedQuery | IsUserBannedQuery
| GetAttachmentsForMessageQuery | GetAttachmentsForMessageQuery
| GetAllAttachmentsQuery; | GetAllAttachmentsQuery
| GetPluginDataQuery
| GetMetaQuery;

229
electron/data-archive.ts Normal file
View File

@@ -0,0 +1,229 @@
import * as fsp from 'fs/promises';
import * as path from 'path';
export interface ZipArchiveEntry {
data: Buffer;
path: string;
}
interface CentralDirectoryEntry {
compressedSize: number;
crc: number;
data: Buffer;
localHeaderOffset: number;
name: Buffer;
uncompressedSize: number;
}
const ZIP_LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
const ZIP_CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50;
const ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50;
const ZIP_UTF8_FLAG = 0x0800;
const ZIP_STORE_METHOD = 0;
const ZIP_VERSION = 20;
const MAX_UINT32 = 0xffffffff;
const crcTable = buildCrcTable();
export function createZipArchive(entries: ZipArchiveEntry[]): Buffer {
const localParts: Buffer[] = [];
const centralEntries: CentralDirectoryEntry[] = [];
let offset = 0;
for (const entry of entries) {
const normalizedPath = normalizeZipPath(entry.path);
const name = Buffer.from(normalizedPath, 'utf8');
const data = entry.data;
if (name.length > 0xffff || data.length > MAX_UINT32 || offset > MAX_UINT32) {
throw new Error('Data archive is too large for the portable ZIP format.');
}
const crc = crc32(data);
const localHeader = Buffer.alloc(30);
localHeader.writeUInt32LE(ZIP_LOCAL_FILE_HEADER_SIGNATURE, 0);
localHeader.writeUInt16LE(ZIP_VERSION, 4);
localHeader.writeUInt16LE(ZIP_UTF8_FLAG, 6);
localHeader.writeUInt16LE(ZIP_STORE_METHOD, 8);
localHeader.writeUInt16LE(0, 10);
localHeader.writeUInt16LE(0, 12);
localHeader.writeUInt32LE(crc, 14);
localHeader.writeUInt32LE(data.length, 18);
localHeader.writeUInt32LE(data.length, 22);
localHeader.writeUInt16LE(name.length, 26);
localHeader.writeUInt16LE(0, 28);
localParts.push(localHeader, name, data);
centralEntries.push({
compressedSize: data.length,
crc,
data,
localHeaderOffset: offset,
name,
uncompressedSize: data.length
});
offset += localHeader.length + name.length + data.length;
}
const centralDirectoryOffset = offset;
const centralParts = centralEntries.map((entry) => {
const header = Buffer.alloc(46);
header.writeUInt32LE(ZIP_CENTRAL_DIRECTORY_SIGNATURE, 0);
header.writeUInt16LE(ZIP_VERSION, 4);
header.writeUInt16LE(ZIP_VERSION, 6);
header.writeUInt16LE(ZIP_UTF8_FLAG, 8);
header.writeUInt16LE(ZIP_STORE_METHOD, 10);
header.writeUInt16LE(0, 12);
header.writeUInt16LE(0, 14);
header.writeUInt32LE(entry.crc, 16);
header.writeUInt32LE(entry.compressedSize, 20);
header.writeUInt32LE(entry.uncompressedSize, 24);
header.writeUInt16LE(entry.name.length, 28);
header.writeUInt16LE(0, 30);
header.writeUInt16LE(0, 32);
header.writeUInt16LE(0, 34);
header.writeUInt16LE(0, 36);
header.writeUInt32LE(0, 38);
header.writeUInt32LE(entry.localHeaderOffset, 42);
offset += header.length + entry.name.length;
return Buffer.concat([header, entry.name]);
});
const centralDirectorySize = offset - centralDirectoryOffset;
if (centralEntries.length > 0xffff || centralDirectoryOffset > MAX_UINT32 || centralDirectorySize > MAX_UINT32) {
throw new Error('Data archive is too large for the portable ZIP format.');
}
const end = Buffer.alloc(22);
end.writeUInt32LE(ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE, 0);
end.writeUInt16LE(0, 4);
end.writeUInt16LE(0, 6);
end.writeUInt16LE(centralEntries.length, 8);
end.writeUInt16LE(centralEntries.length, 10);
end.writeUInt32LE(centralDirectorySize, 12);
end.writeUInt32LE(centralDirectoryOffset, 16);
end.writeUInt16LE(0, 20);
return Buffer.concat([...localParts, ...centralParts, end]);
}
export function readZipArchive(data: Buffer): ZipArchiveEntry[] {
const endOffset = findEndOfCentralDirectory(data);
if (endOffset < 0) {
throw new Error('The selected file is not a supported data archive.');
}
const entryCount = data.readUInt16LE(endOffset + 10);
const centralDirectoryOffset = data.readUInt32LE(endOffset + 16);
const entries: ZipArchiveEntry[] = [];
let offset = centralDirectoryOffset;
for (let index = 0; index < entryCount; index += 1) {
if (data.readUInt32LE(offset) !== ZIP_CENTRAL_DIRECTORY_SIGNATURE) {
throw new Error('The data archive directory is invalid.');
}
const method = data.readUInt16LE(offset + 10);
const compressedSize = data.readUInt32LE(offset + 20);
const uncompressedSize = data.readUInt32LE(offset + 24);
const nameLength = data.readUInt16LE(offset + 28);
const extraLength = data.readUInt16LE(offset + 30);
const commentLength = data.readUInt16LE(offset + 32);
const localHeaderOffset = data.readUInt32LE(offset + 42);
const entryPath = normalizeZipPath(data.subarray(offset + 46, offset + 46 + nameLength).toString('utf8'));
if (method !== ZIP_STORE_METHOD || compressedSize !== uncompressedSize) {
throw new Error('Compressed data archives are not supported by this build.');
}
if (data.readUInt32LE(localHeaderOffset) !== ZIP_LOCAL_FILE_HEADER_SIGNATURE) {
throw new Error('The data archive contains an invalid file entry.');
}
const localNameLength = data.readUInt16LE(localHeaderOffset + 26);
const localExtraLength = data.readUInt16LE(localHeaderOffset + 28);
const dataOffset = localHeaderOffset + 30 + localNameLength + localExtraLength;
entries.push({
data: Buffer.from(data.subarray(dataOffset, dataOffset + compressedSize)),
path: entryPath
});
offset += 46 + nameLength + extraLength + commentLength;
}
return entries;
}
export async function extractZipEntries(entries: ZipArchiveEntry[], destinationPath: string): Promise<void> {
const destinationRoot = path.resolve(destinationPath);
for (const entry of entries) {
const targetPath = path.resolve(destinationRoot, entry.path);
if (!targetPath.startsWith(destinationRoot + path.sep) && targetPath !== destinationRoot) {
throw new Error('The data archive contains an unsafe path.');
}
await fsp.mkdir(path.dirname(targetPath), { recursive: true });
await fsp.writeFile(targetPath, entry.data);
}
}
function findEndOfCentralDirectory(data: Buffer): number {
const minimumOffset = Math.max(0, data.length - 0xffff - 22);
for (let offset = data.length - 22; offset >= minimumOffset; offset -= 1) {
if (data.readUInt32LE(offset) === ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE) {
return offset;
}
}
return -1;
}
function normalizeZipPath(value: string): string {
const normalized = value.replace(/\\/g, '/').replace(/^\/+/, '');
if (!normalized || normalized.split('/').some((part) => part === '..' || part === '')) {
throw new Error('The data archive contains an unsafe path.');
}
return normalized;
}
function buildCrcTable(): number[] {
const table: number[] = [];
for (let index = 0; index < 256; index += 1) {
let value = index;
for (let bit = 0; bit < 8; bit += 1) {
value = (value & 1) !== 0
? 0xedb88320 ^ (value >>> 1)
: value >>> 1;
}
table[index] = value >>> 0;
}
return table;
}
function crc32(data: Buffer): number {
let crc = 0xffffffff;
for (const byte of data) {
crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}

257
electron/data-management.ts Normal file
View File

@@ -0,0 +1,257 @@
import {
app,
dialog,
shell
} from 'electron';
import * as fsp from 'fs/promises';
import * as path from 'path';
import { destroyDatabase, initializeDatabase } from './db/database';
import {
createZipArchive,
extractZipEntries,
readZipArchive,
type ZipArchiveEntry
} from './data-archive';
export interface ExportUserDataResult {
cancelled: boolean;
exported: boolean;
filePath?: string;
}
export interface ImportUserDataResult {
backupPath?: string;
cancelled: boolean;
imported: boolean;
restartRequired: boolean;
}
export interface EraseUserDataResult {
erased: boolean;
restartRequired: boolean;
}
const ARCHIVE_MANIFEST_PATH = 'metoyou-data-manifest.json';
const ARCHIVE_DATA_PREFIX = 'data/';
const BACKUP_DIRECTORY_NAME = 'metoyou-data-backups';
export async function openCurrentDataFolder(): Promise<boolean> {
const error = await shell.openPath(app.getPath('userData'));
return error.length === 0;
}
export async function exportUserData(): Promise<ExportUserDataResult> {
const dataPath = app.getPath('userData');
const defaultFileName = `metoyou-data-${new Date().toISOString().slice(0, 10)}.dat`;
const { canceled, filePath } = await dialog.showSaveDialog({
defaultPath: path.join(app.getPath('documents'), defaultFileName),
filters: [
{ extensions: ['dat'], name: 'MetoYou data archive' }
],
title: 'Export MetoYou data'
});
if (canceled || !filePath) {
return { cancelled: true, exported: false };
}
const entries: ZipArchiveEntry[] = [
{
data: Buffer.from(JSON.stringify({
appVersion: app.getVersion(),
exportedAt: new Date().toISOString(),
format: 'metoyou-user-data',
version: 1
}, null, 2)),
path: ARCHIVE_MANIFEST_PATH
}
];
for (const file of await collectDataFiles(dataPath)) {
const relativePath = toArchivePath(path.relative(dataPath, file));
entries.push({
data: await fsp.readFile(file),
path: `${ARCHIVE_DATA_PREFIX}${relativePath}`
});
}
await fsp.writeFile(ensureDatExtension(filePath), createZipArchive(entries));
return {
cancelled: false,
exported: true,
filePath: ensureDatExtension(filePath)
};
}
export async function importUserData(): Promise<ImportUserDataResult> {
const { canceled, filePaths } = await dialog.showOpenDialog({
filters: [
{ extensions: ['dat', 'zip'], name: 'MetoYou data archive' }
],
properties: ['openFile'],
title: 'Import MetoYou data'
});
if (canceled || filePaths.length === 0) {
return {
cancelled: true,
imported: false,
restartRequired: false
};
}
const archiveEntries = readZipArchive(await fsp.readFile(filePaths[0]));
validateArchiveManifest(archiveEntries);
const importRoot = path.join(app.getPath('temp'), `metoyou-import-${Date.now()}`);
const importDataPath = path.join(importRoot, 'data');
try {
await extractZipEntries(
archiveEntries
.filter((entry) => entry.path.startsWith(ARCHIVE_DATA_PREFIX))
.map((entry) => ({
data: entry.data,
path: entry.path.slice(ARCHIVE_DATA_PREFIX.length)
})),
importDataPath
);
await destroyDatabase();
const backupPath = await moveCurrentDataAside();
await copyDirectory(importDataPath, app.getPath('userData'));
await initializeDatabase();
return {
backupPath,
cancelled: false,
imported: true,
restartRequired: true
};
} catch (error) {
await initializeDatabase().catch(() => {});
throw error;
} finally {
await fsp.rm(importRoot, { force: true, recursive: true }).catch(() => {});
}
}
export async function eraseUserData(): Promise<EraseUserDataResult> {
const dataPath = app.getPath('userData');
await destroyDatabase();
for (const entry of await fsp.readdir(dataPath, { withFileTypes: true }).catch(() => [])) {
await fsp.rm(path.join(dataPath, entry.name), { force: true, recursive: true });
}
await fsp.mkdir(dataPath, { recursive: true });
await initializeDatabase();
return {
erased: true,
restartRequired: true
};
}
async function collectDataFiles(directoryPath: string): Promise<string[]> {
const files: string[] = [];
const entries = await fsp.readdir(directoryPath, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
if (entry.name === BACKUP_DIRECTORY_NAME) {
continue;
}
const entryPath = path.join(directoryPath, entry.name);
if (entry.isDirectory()) {
files.push(...await collectDataFiles(entryPath));
} else if (entry.isFile()) {
files.push(entryPath);
}
}
return files;
}
async function moveCurrentDataAside(): Promise<string | undefined> {
const dataPath = app.getPath('userData');
const backupRoot = path.join(dataPath, BACKUP_DIRECTORY_NAME);
const backupPath = path.join(backupRoot, `before-import-${new Date().toISOString().replace(/[:.]/g, '-')}`);
const entries = await fsp.readdir(dataPath, { withFileTypes: true }).catch(() => []);
await fsp.mkdir(backupPath, { recursive: true });
let movedAny = false;
for (const entry of entries) {
if (entry.name === BACKUP_DIRECTORY_NAME) {
continue;
}
const sourcePath = path.join(dataPath, entry.name);
const targetPath = path.join(backupPath, entry.name);
await fsp.mkdir(path.dirname(targetPath), { recursive: true });
await fsp.rename(sourcePath, targetPath).catch(async () => {
await copyPath(sourcePath, targetPath);
await fsp.rm(sourcePath, { force: true, recursive: true });
});
movedAny = true;
}
return movedAny ? backupPath : undefined;
}
async function copyDirectory(sourcePath: string, targetPath: string): Promise<void> {
await fsp.mkdir(targetPath, { recursive: true });
for (const entry of await fsp.readdir(sourcePath, { withFileTypes: true }).catch(() => [])) {
await copyPath(path.join(sourcePath, entry.name), path.join(targetPath, entry.name));
}
}
async function copyPath(sourcePath: string, targetPath: string): Promise<void> {
const stats = await fsp.stat(sourcePath);
if (stats.isDirectory()) {
await copyDirectory(sourcePath, targetPath);
return;
}
if (stats.isFile()) {
await fsp.mkdir(path.dirname(targetPath), { recursive: true });
await fsp.copyFile(sourcePath, targetPath);
}
}
function validateArchiveManifest(entries: ZipArchiveEntry[]): void {
const manifest = entries.find((entry) => entry.path === ARCHIVE_MANIFEST_PATH);
if (!manifest) {
throw new Error('The selected file is missing a MetoYou data manifest.');
}
const parsed = JSON.parse(manifest.data.toString('utf8')) as { format?: string; version?: number };
if (parsed.format !== 'metoyou-user-data' || parsed.version !== 1) {
throw new Error('The selected file uses an unsupported data archive format.');
}
}
function ensureDatExtension(filePath: string): string {
return path.extname(filePath).toLowerCase() === '.dat'
? filePath
: `${filePath}.dat`;
}
function toArchivePath(filePath: string): string {
return filePath.split(path.sep).join('/');
}

View File

@@ -25,7 +25,8 @@ import {
ReactionEntity, ReactionEntity,
BanEntity, BanEntity,
AttachmentEntity, AttachmentEntity,
MetaEntity MetaEntity,
PluginDataEntity
} from './entities'; } from './entities';
const projectRootDatabaseFilePath = path.join(__dirname, '..', '..', settings.databaseName); const projectRootDatabaseFilePath = path.join(__dirname, '..', '..', settings.databaseName);
@@ -51,7 +52,8 @@ export const AppDataSource = new DataSource({
ReactionEntity, ReactionEntity,
BanEntity, BanEntity,
AttachmentEntity, AttachmentEntity,
MetaEntity MetaEntity,
PluginDataEntity
], ],
migrations: [path.join(__dirname, 'migrations', '*.{ts,js}')], migrations: [path.join(__dirname, 'migrations', '*.{ts,js}')],
synchronize: false, synchronize: false,

View File

@@ -16,7 +16,8 @@ import {
ReactionEntity, ReactionEntity,
BanEntity, BanEntity,
AttachmentEntity, AttachmentEntity,
MetaEntity MetaEntity,
PluginDataEntity
} from '../entities'; } from '../entities';
import { settings } from '../settings'; import { settings } from '../settings';
@@ -26,6 +27,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 +106,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');
@@ -124,7 +172,8 @@ export async function initializeDatabase(): Promise<void> {
ReactionEntity, ReactionEntity,
BanEntity, BanEntity,
AttachmentEntity, AttachmentEntity,
MetaEntity MetaEntity,
PluginDataEntity
], ],
migrations: [path.join(__dirname, '..', 'migrations', '*.js'), path.join(__dirname, '..', 'migrations', '*.ts')], migrations: [path.join(__dirname, '..', 'migrations', '*.js'), path.join(__dirname, '..', 'migrations', '*.ts')],
synchronize: false, synchronize: false,

View File

@@ -0,0 +1,26 @@
import {
Column,
Entity,
PrimaryColumn
} from 'typeorm';
@Entity('plugin_data')
export class PluginDataEntity {
@PrimaryColumn('text')
pluginId!: string;
@PrimaryColumn('text')
scope!: string;
@PrimaryColumn('text')
serverId!: string;
@PrimaryColumn('text')
key!: string;
@Column('text')
valueJson!: string;
@Column('integer')
updatedAt!: number;
}

View File

@@ -10,3 +10,4 @@ export { ReactionEntity } from './ReactionEntity';
export { BanEntity } from './BanEntity'; export { BanEntity } from './BanEntity';
export { AttachmentEntity } from './AttachmentEntity'; export { AttachmentEntity } from './AttachmentEntity';
export { MetaEntity } from './MetaEntity'; export { MetaEntity } from './MetaEntity';
export { PluginDataEntity } from './PluginDataEntity';

View File

@@ -49,6 +49,14 @@ import {
readSavedTheme, readSavedTheme,
writeSavedTheme writeSavedTheme
} from '../theme-library'; } from '../theme-library';
import { getLocalPluginsPath, listLocalPluginManifests } from '../plugin-library';
import {
eraseUserData,
exportUserData,
importUserData,
openCurrentDataFolder
} 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 = [
@@ -314,6 +322,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();
}); });
@@ -335,7 +345,13 @@ export function setupSystemHandlers(): void {
}); });
ipcMain.handle('get-app-data-path', () => app.getPath('userData')); ipcMain.handle('get-app-data-path', () => app.getPath('userData'));
ipcMain.handle('open-current-data-folder', async () => await openCurrentDataFolder());
ipcMain.handle('export-user-data', async () => await exportUserData());
ipcMain.handle('import-user-data', async () => await importUserData());
ipcMain.handle('erase-user-data', async () => await eraseUserData());
ipcMain.handle('get-saved-themes-path', async () => await getSavedThemesPath()); ipcMain.handle('get-saved-themes-path', async () => await getSavedThemesPath());
ipcMain.handle('get-local-plugins-path', async () => await getLocalPluginsPath());
ipcMain.handle('list-local-plugin-manifests', async () => await listLocalPluginManifests());
ipcMain.handle('list-saved-themes', async () => await listSavedThemes()); ipcMain.handle('list-saved-themes', async () => await listSavedThemes());
ipcMain.handle('read-saved-theme', async (_event, fileName: string) => await readSavedTheme(fileName)); ipcMain.handle('read-saved-theme', async (_event, fileName: string) => await readSavedTheme(fileName));
ipcMain.handle('write-saved-theme', async (_event, fileName: string, text: string) => { ipcMain.handle('write-saved-theme', async (_event, fileName: string, text: string) => {

View File

@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPluginData1000000000008 implements MigrationInterface {
name = 'AddPluginData1000000000008';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "plugin_data" (
"pluginId" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"serverId" TEXT NOT NULL DEFAULT '',
"key" TEXT NOT NULL,
"valueJson" TEXT NOT NULL,
"updatedAt" INTEGER NOT NULL,
PRIMARY KEY ("pluginId", "scope", "serverId", "key")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_plugin_data_plugin_scope" ON "plugin_data" ("pluginId", "scope")`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS "idx_plugin_data_plugin_scope"`);
await queryRunner.query(`DROP TABLE IF EXISTS "plugin_data"`);
}
}

View File

@@ -0,0 +1,126 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import {
cp,
mkdtemp,
mkdir,
rm,
writeFile
} from 'fs/promises';
import { join } from 'path';
import { tmpdir } from 'os';
import { TEST_PLUGIN_FIXTURE_DIR, TEST_PLUGIN_ID } from '../e2e/helpers/plugin-api-test-fixture';
const { mockGetPath } = vi.hoisted(() => ({
mockGetPath: vi.fn()
}));
vi.mock('electron', () => ({
app: {
getPath: mockGetPath
}
}));
import { getLocalPluginsPath, listLocalPluginManifests } from './plugin-library';
describe('plugin-library', () => {
let userDataPath: string;
beforeEach(async () => {
userDataPath = await mkdtemp(join(tmpdir(), 'metoyou-plugin-library-'));
mockGetPath.mockReturnValue(userDataPath);
});
afterEach(async () => {
await rm(userDataPath, { recursive: true, force: true });
mockGetPath.mockReset();
});
it('creates and reports the local plugins folder', async () => {
const pluginsPath = await getLocalPluginsPath();
const result = await listLocalPluginManifests();
expect(pluginsPath).toBe(join(userDataPath, 'plugins'));
expect(result).toEqual({
errors: [],
plugins: [],
pluginsPath
});
});
it('discovers immediate child plugin manifests and safe relative files', async () => {
const pluginRoot = join(userDataPath, 'plugins', 'api-test-plugin');
await cp(TEST_PLUGIN_FIXTURE_DIR, pluginRoot, { recursive: true });
const result = await listLocalPluginManifests();
expect(result.errors).toEqual([]);
expect(result.plugins).toHaveLength(1);
expect(result.plugins[0]).toEqual(expect.objectContaining({
entrypointPath: join(pluginRoot, 'dist', 'main.js'),
manifestPath: join(pluginRoot, 'toju-plugin.json'),
pluginRoot,
readmePath: join(pluginRoot, 'README.md')
}));
expect(result.plugins[0]?.manifest).toEqual(expect.objectContaining({ id: TEST_PLUGIN_ID }));
});
it('reports invalid JSON and keeps scanning other plugins', async () => {
const invalidRoot = join(userDataPath, 'plugins', 'invalid-plugin');
const validRoot = join(userDataPath, 'plugins', 'valid-plugin');
await mkdir(invalidRoot, { recursive: true });
await mkdir(validRoot, { recursive: true });
await writeFile(join(invalidRoot, 'plugin.json'), '{', 'utf8');
await writeFile(join(validRoot, 'plugin.json'), JSON.stringify({
apiVersion: '1.0.0',
compatibility: { minimumTojuVersion: '1.0.0' },
description: 'Valid plugin',
entrypoint: './main.js',
id: 'valid.plugin',
kind: 'client',
schemaVersion: 1,
title: 'Valid Plugin',
version: '1.0.0'
}), 'utf8');
const result = await listLocalPluginManifests();
expect(result.plugins.map((plugin) => plugin.pluginRoot)).toEqual([validRoot]);
expect(result.errors).toHaveLength(1);
expect(result.errors[0]).toEqual(expect.objectContaining({
manifestPath: join(invalidRoot, 'plugin.json'),
pluginRoot: invalidRoot
}));
});
it('does not resolve entrypoints outside the plugin folder', async () => {
const pluginRoot = join(userDataPath, 'plugins', 'unsafe-plugin');
await mkdir(pluginRoot, { recursive: true });
await writeFile(join(userDataPath, 'plugins', 'outside.js'), 'export default {};', 'utf8');
await writeFile(join(pluginRoot, 'plugin.json'), JSON.stringify({
apiVersion: '1.0.0',
compatibility: { minimumTojuVersion: '1.0.0' },
description: 'Unsafe plugin',
entrypoint: '../outside.js',
id: 'unsafe.plugin',
kind: 'client',
schemaVersion: 1,
title: 'Unsafe Plugin',
version: '1.0.0'
}), 'utf8');
const result = await listLocalPluginManifests();
expect(result.plugins[0]?.entrypointPath).toBeUndefined();
});
});

165
electron/plugin-library.ts Normal file
View File

@@ -0,0 +1,165 @@
import { app } from 'electron';
import * as fsp from 'fs/promises';
import * as path from 'path';
import { pathToFileURL } from 'url';
const PLUGINS_FOLDER_NAME = 'plugins';
const MANIFEST_FILE_NAMES = ['toju-plugin.json', 'plugin.json'] as const;
export interface LocalPluginManifestDescriptor {
discoveredAt: number;
entrypointPath?: string;
pluginRootUrl: string;
manifest: unknown;
manifestPath: string;
pluginRoot: string;
readmePath?: string;
}
export interface LocalPluginDiscoveryError {
manifestPath?: string;
message: string;
pluginRoot?: string;
}
export interface LocalPluginDiscoveryResult {
errors: LocalPluginDiscoveryError[];
plugins: LocalPluginManifestDescriptor[];
pluginsPath: string;
}
function resolvePluginsPath(): string {
return path.join(app.getPath('userData'), PLUGINS_FOLDER_NAME);
}
async function ensurePluginsPath(): Promise<string> {
const pluginsPath = resolvePluginsPath();
await fsp.mkdir(pluginsPath, { recursive: true });
return pluginsPath;
}
async function realpathOrSelf(filePath: string): Promise<string> {
try {
return await fsp.realpath(filePath);
} catch {
return filePath;
}
}
function isPathInside(parentPath: string, candidatePath: string): boolean {
const relativePath = path.relative(parentPath, candidatePath);
return !!relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
}
function readManifestPath(manifestRecord: Record<string, unknown>, key: string): string | undefined {
const value = manifestRecord[key];
return typeof value === 'string' && value.trim()
? value.trim()
: undefined;
}
async function resolveManifestRelativeFile(pluginRoot: string, relativeFilePath: string | undefined): Promise<string | undefined> {
if (!relativeFilePath || path.isAbsolute(relativeFilePath)) {
return undefined;
}
const normalizedPath = path.normalize(relativeFilePath);
if (normalizedPath.startsWith('..')) {
return undefined;
}
const candidatePath = path.join(pluginRoot, normalizedPath);
const [realPluginRoot, realCandidatePath] = await Promise.all([realpathOrSelf(pluginRoot), realpathOrSelf(candidatePath)]);
if (!isPathInside(realPluginRoot, realCandidatePath)) {
return undefined;
}
try {
const stats = await fsp.stat(realCandidatePath);
return stats.isFile() ? realCandidatePath : undefined;
} catch {
return undefined;
}
}
async function findManifestPath(pluginRoot: string): Promise<string | undefined> {
for (const fileName of MANIFEST_FILE_NAMES) {
const manifestPath = path.join(pluginRoot, fileName);
try {
const stats = await fsp.stat(manifestPath);
if (stats.isFile()) {
return manifestPath;
}
} catch {
// Missing manifest candidates are expected while scanning folders.
}
}
return undefined;
}
async function readPluginManifest(pluginRoot: string, manifestPath: string): Promise<LocalPluginManifestDescriptor> {
const text = await fsp.readFile(manifestPath, 'utf8');
const manifest = JSON.parse(text) as unknown;
const manifestRecord = manifest && typeof manifest === 'object' && !Array.isArray(manifest)
? manifest as Record<string, unknown>
: {};
const entrypointPromise = resolveManifestRelativeFile(pluginRoot, readManifestPath(manifestRecord, 'entrypoint'));
const readmePromise = resolveManifestRelativeFile(pluginRoot, readManifestPath(manifestRecord, 'readme'));
const [entrypointPath, readmePath] = await Promise.all([entrypointPromise, readmePromise]);
return {
discoveredAt: Date.now(),
entrypointPath,
pluginRootUrl: pathToFileURL(pluginRoot + path.sep).toString(),
manifest,
manifestPath,
pluginRoot,
readmePath
};
}
export async function getLocalPluginsPath(): Promise<string> {
return await ensurePluginsPath();
}
export async function listLocalPluginManifests(): Promise<LocalPluginDiscoveryResult> {
const pluginsPath = await ensurePluginsPath();
const entries = await fsp.readdir(pluginsPath, { withFileTypes: true });
const plugins: LocalPluginManifestDescriptor[] = [];
const errors: LocalPluginDiscoveryError[] = [];
for (const entry of entries.filter((candidate) => candidate.isDirectory())) {
const pluginRoot = path.join(pluginsPath, entry.name);
const manifestPath = await findManifestPath(pluginRoot);
if (!manifestPath) {
continue;
}
try {
plugins.push(await readPluginManifest(pluginRoot, manifestPath));
} catch (error) {
errors.push({
manifestPath,
message: error instanceof Error ? error.message : 'Unable to read plugin manifest',
pluginRoot
});
}
}
return {
errors,
plugins: plugins.sort((left, right) => left.pluginRoot.localeCompare(right.pluginRoot)),
pluginsPath
};
}

View File

@@ -109,6 +109,46 @@ export interface SavedThemeFileDescriptor {
path: string; path: string;
} }
export interface LocalPluginManifestDescriptor {
discoveredAt: number;
entrypointPath?: string;
pluginRootUrl: string;
manifest: unknown;
manifestPath: string;
pluginRoot: string;
readmePath?: string;
}
export interface LocalPluginDiscoveryError {
manifestPath?: string;
message: string;
pluginRoot?: string;
}
export interface LocalPluginDiscoveryResult {
errors: LocalPluginDiscoveryError[];
plugins: LocalPluginManifestDescriptor[];
pluginsPath: string;
}
export interface ExportUserDataResult {
cancelled: boolean;
exported: boolean;
filePath?: string;
}
export interface ImportUserDataResult {
backupPath?: string;
cancelled: boolean;
imported: boolean;
restartRequired: boolean;
}
export interface EraseUserDataResult {
erased: boolean;
restartRequired: boolean;
}
function readLinuxDisplayServer(): string { function readLinuxDisplayServer(): string {
if (process.platform !== 'linux') { if (process.platform !== 'linux') {
return 'N/A'; return 'N/A';
@@ -149,6 +189,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>;
@@ -157,7 +198,13 @@ export interface ElectronAPI {
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void; onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void; onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
getAppDataPath: () => Promise<string>; getAppDataPath: () => Promise<string>;
openCurrentDataFolder: () => Promise<boolean>;
exportUserData: () => Promise<ExportUserDataResult>;
importUserData: () => Promise<ImportUserDataResult>;
eraseUserData: () => Promise<EraseUserDataResult>;
getSavedThemesPath: () => Promise<string>; getSavedThemesPath: () => Promise<string>;
getLocalPluginsPath: () => Promise<string>;
listLocalPluginManifests: () => Promise<LocalPluginDiscoveryResult>;
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>; listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
readSavedTheme: (fileName: string) => Promise<string>; readSavedTheme: (fileName: string) => Promise<string>;
writeSavedTheme: (fileName: string, text: string) => Promise<boolean>; writeSavedTheme: (fileName: string, text: string) => Promise<boolean>;
@@ -230,6 +277,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'),
@@ -265,7 +313,13 @@ const electronAPI: ElectronAPI = {
}; };
}, },
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'), getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
openCurrentDataFolder: () => ipcRenderer.invoke('open-current-data-folder'),
exportUserData: () => ipcRenderer.invoke('export-user-data'),
importUserData: () => ipcRenderer.invoke('import-user-data'),
eraseUserData: () => ipcRenderer.invoke('erase-user-data'),
getSavedThemesPath: () => ipcRenderer.invoke('get-saved-themes-path'), getSavedThemesPath: () => ipcRenderer.invoke('get-saved-themes-path'),
getLocalPluginsPath: () => ipcRenderer.invoke('get-local-plugins-path'),
listLocalPluginManifests: () => ipcRenderer.invoke('list-local-plugin-manifests'),
listSavedThemes: () => ipcRenderer.invoke('list-saved-themes'), listSavedThemes: () => ipcRenderer.invoke('list-saved-themes'),
readSavedTheme: (fileName) => ipcRenderer.invoke('read-saved-theme', fileName), readSavedTheme: (fileName) => ipcRenderer.invoke('read-saved-theme', fileName),
writeSavedTheme: (fileName, text) => ipcRenderer.invoke('write-saved-theme', fileName, text), writeSavedTheme: (fileName, text) => ipcRenderer.invoke('write-saved-theme', fileName, text),

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

@@ -0,0 +1,85 @@
import { execFile } from 'child_process';
import * as path from 'path';
import { promisify } from 'util';
const execFileAsync = promisify(execFile);
const MAX_PROCESS_NAMES = 512;
export async function listRunningProcessNames(): Promise<string[]> {
if (process.platform === 'win32') {
return normalizeProcessNames(await listWindowsProcessNames());
}
if (process.platform === 'linux') {
return normalizeProcessNames(await listLinuxProcessNames());
}
return [];
}
async function listLinuxProcessNames(): Promise<string[]> {
const { stdout } = await execFileAsync('ps', ['-eo', 'comm='], {
maxBuffer: 1024 * 1024,
timeout: 5_000
});
return stdout.split('\n');
}
async function listWindowsProcessNames(): Promise<string[]> {
const { stdout } = await execFileAsync('tasklist', [
'/FO',
'CSV',
'/NH'
], {
maxBuffer: 1024 * 1024,
timeout: 5_000,
windowsHide: true
});
return stdout
.split(/\r?\n/)
.map((line) => parseCsvFirstColumn(line));
}
function parseCsvFirstColumn(line: string): string {
const trimmed = line.trim();
if (!trimmed) {
return '';
}
if (!trimmed.startsWith('"')) {
return trimmed.split(',')[0] ?? '';
}
const endQuoteIndex = trimmed.indexOf('"', 1);
return endQuoteIndex > 1 ? trimmed.slice(1, endQuoteIndex) : '';
}
function normalizeProcessNames(names: string[]): string[] {
const normalized = new Set<string>();
for (const rawName of names) {
const name = normalizeProcessName(rawName);
if (name) {
normalized.add(name);
}
}
return Array.from(normalized)
.sort()
.slice(0, MAX_PROCESS_NAMES);
}
function normalizeProcessName(rawName: string): string {
const baseName = path.basename(rawName.trim()).trim();
if (!baseName || baseName.length > 96) {
return '';
}
return baseName;
}

26
package-lock.json generated
View File

@@ -15,8 +15,10 @@
"@angular/platform-browser": "^21.0.0", "@angular/platform-browser": "^21.0.0",
"@angular/router": "^21.0.0", "@angular/router": "^21.0.0",
"@codemirror/commands": "^6.10.3", "@codemirror/commands": "^6.10.3",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-json": "^6.0.2", "@codemirror/lang-json": "^6.0.2",
"@codemirror/language": "^6.12.3", "@codemirror/language": "^6.12.3",
"@codemirror/lint": "^6.9.5",
"@codemirror/state": "^6.6.0", "@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.41.0", "@codemirror/view": "^6.41.0",
@@ -2731,6 +2733,19 @@
"@lezer/common": "^1.1.0" "@lezer/common": "^1.1.0"
} }
}, },
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-json": { "node_modules/@codemirror/lang-json": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
@@ -5791,6 +5806,17 @@
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==", "integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@lezer/css": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.3.tgz",
"integrity": "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": { "node_modules/@lezer/highlight": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",

View File

@@ -65,8 +65,10 @@
"@angular/platform-browser": "^21.0.0", "@angular/platform-browser": "^21.0.0",
"@angular/router": "^21.0.0", "@angular/router": "^21.0.0",
"@codemirror/commands": "^6.10.3", "@codemirror/commands": "^6.10.3",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-json": "^6.0.2", "@codemirror/lang-json": "^6.0.2",
"@codemirror/language": "^6.12.3", "@codemirror/language": "^6.12.3",
"@codemirror/lint": "^6.9.5",
"@codemirror/state": "^6.6.0", "@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.41.0", "@codemirror/view": "^6.41.0",

View File

@@ -18,7 +18,11 @@ 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.
- `data/variables.json` is normalized on startup and stores `klipyApiKey`, `releaseManifestUrl`, `serverPort`, `serverProtocol`, `serverHost`, and `linkPreview`. - `DB_PATH` can override the SQLite database file location.
- `data/variables.json` is normalized on startup and stores `klipyApiKey`, `rawgApiKey`, `releaseManifestUrl`, `serverPort`, `serverProtocol`, `serverHost`, and `linkPreview`.
- `openApiDocs.enabled` in `data/variables.json`, or `OPENAPI_DOCS_ENABLED=true`, exposes the plugin support OpenAPI document at `/api/openapi.json` and a small docs page at `/api/docs`. It is disabled by default. Plugin support is metadata-only: the server stores install requirements and event definitions, but arbitrary plugin data persistence is disabled.
- `RAWG_API_KEY` can override `rawgApiKey` for the `/api/games/match` now-playing metadata resolver. Successful matches include a preferred store link from RAWG store metadata, with Steam selected first when available. Negative game-match results are stored in the SQLite `game_match_misses` table so non-game process names do not repeatedly consume RAWG quota.
- Packaged server builds store `metoyou.sqlite` in the OS app-data directory by default so upgrades do not overwrite runtime data. On first start, the server copies forward legacy packaged databases that still live beside the executable.
- When HTTPS is enabled, certificates are read from the repository `.certs/` directory. - When HTTPS is enabled, certificates are read from the repository `.certs/` directory.
## Structure ## Structure
@@ -37,4 +41,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.

Binary file not shown.

View File

@@ -10,13 +10,19 @@ export interface LinkPreviewConfig {
maxCacheSizeMb: number; maxCacheSizeMb: number;
} }
export interface OpenApiDocsConfig {
enabled: boolean;
}
export interface ServerVariablesConfig { export interface ServerVariablesConfig {
klipyApiKey: string; klipyApiKey: string;
rawgApiKey: string;
releaseManifestUrl: string; releaseManifestUrl: string;
serverPort: number; serverPort: number;
serverProtocol: ServerHttpProtocol; serverProtocol: ServerHttpProtocol;
serverHost: string; serverHost: string;
linkPreview: LinkPreviewConfig; linkPreview: LinkPreviewConfig;
openApiDocs: OpenApiDocsConfig;
} }
const DATA_DIR = resolveRuntimePath('data'); const DATA_DIR = resolveRuntimePath('data');
@@ -31,6 +37,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() : '';
} }
@@ -97,6 +107,14 @@ function normalizeLinkPreviewConfig(value: unknown): LinkPreviewConfig {
return { enabled, cacheTtlMinutes: cacheTtl, maxCacheSizeMb: maxSize }; return { enabled, cacheTtlMinutes: cacheTtl, maxCacheSizeMb: maxSize };
} }
function normalizeOpenApiDocsConfig(value: unknown): OpenApiDocsConfig {
const raw = (value && typeof value === 'object' && !Array.isArray(value))
? value as Record<string, unknown>
: {};
return { enabled: raw.enabled === true };
}
function hasEnvironmentOverride(value: string | undefined): value is string { function hasEnvironmentOverride(value: string | undefined): value is string {
return typeof value === 'string' && value.trim().length > 0; return typeof value === 'string' && value.trim().length > 0;
} }
@@ -139,11 +157,13 @@ 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),
serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress), serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress),
linkPreview: normalizeLinkPreviewConfig(remainingParsed.linkPreview) linkPreview: normalizeLinkPreviewConfig(remainingParsed.linkPreview),
openApiDocs: normalizeOpenApiDocsConfig(remainingParsed.openApiDocs)
}; };
const nextContents = JSON.stringify(normalized, null, 2) + '\n'; const nextContents = JSON.stringify(normalized, null, 2) + '\n';
@@ -153,11 +173,13 @@ 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,
serverHost: normalized.serverHost, serverHost: normalized.serverHost,
linkPreview: normalized.linkPreview linkPreview: normalized.linkPreview,
openApiDocs: normalized.openApiDocs
}; };
} }
@@ -169,6 +191,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;
} }
@@ -203,6 +233,31 @@ export function isHttpsServerEnabled(): boolean {
return getServerProtocol() === 'https'; return getServerProtocol() === 'https';
} }
export function areOpenApiDocsEnabled(): boolean {
if (hasEnvironmentOverride(process.env.OPENAPI_DOCS_ENABLED)) {
return process.env.OPENAPI_DOCS_ENABLED.trim().toLowerCase() === 'true';
}
return getVariablesConfig().openApiDocs.enabled;
}
export function setOpenApiDocsEnabled(enabled: boolean): OpenApiDocsConfig {
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
const { parsed } = readRawVariables();
const next = {
...parsed,
openApiDocs: { enabled }
};
fs.writeFileSync(VARIABLES_FILE, JSON.stringify(next, null, 2) + '\n', 'utf8');
ensureVariablesConfig();
return { enabled };
}
export function getLinkPreviewConfig(): LinkPreviewConfig { export function getLinkPreviewConfig(): LinkPreviewConfig {
return getVariablesConfig().linkPreview; return getVariablesConfig().linkPreview;
} }

View File

@@ -14,10 +14,30 @@ import {
JoinRequestEntity, JoinRequestEntity,
ServerMembershipEntity, ServerMembershipEntity,
ServerInviteEntity, ServerInviteEntity,
ServerBanEntity ServerBanEntity,
GameMatchMissEntity,
ServerPluginRequirementEntity,
ServerPluginEventDefinitionEntity,
PluginDataEntity,
ServerPluginSettingsEntity,
PluginUserMetadataEntity
} from '../entities'; } from '../entities';
import { serverMigrations } from '../migrations'; import { serverMigrations } from '../migrations';
import { findExistingPath, resolveRuntimePath } from '../runtime-paths'; import {
findExistingPath,
isPackagedRuntime,
resolvePersistentDataPath,
resolveRuntimePath
} from '../runtime-paths';
const LEGACY_PACKAGED_DB_FILE = path.join(resolveRuntimePath('data'), 'metoyou.sqlite');
const LEGACY_PACKAGED_DB_BACKUP = LEGACY_PACKAGED_DB_FILE + '.bak';
function resolveDefaultDbFile(): string {
return isPackagedRuntime()
? resolvePersistentDataPath('metoyou.sqlite')
: LEGACY_PACKAGED_DB_FILE;
}
function resolveDbFile(): string { function resolveDbFile(): string {
const envPath = process.env.DB_PATH; const envPath = process.env.DB_PATH;
@@ -26,7 +46,7 @@ function resolveDbFile(): string {
return path.resolve(envPath); return path.resolve(envPath);
} }
return path.join(resolveRuntimePath('data'), 'metoyou.sqlite'); return resolveDefaultDbFile();
} }
const DB_FILE = resolveDbFile(); const DB_FILE = resolveDbFile();
@@ -34,8 +54,84 @@ 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 {
if (!fs.existsSync(DB_BACKUP)) {
console.error(`[DB] ${reason}. No backup available - starting with a fresh database`);
return undefined;
}
const backup = new Uint8Array(fs.readFileSync(DB_BACKUP));
if (!isValidSqlite(backup)) {
console.error(`[DB] ${reason}. Backup is also invalid - starting with a fresh database`);
return undefined;
}
fs.copyFileSync(DB_BACKUP, DB_FILE);
console.warn('[DB] Restored database from backup', DB_BACKUP);
return backup;
}
async function migrateLegacyPackagedDatabase(): Promise<void> {
if (process.env.DB_PATH || !isPackagedRuntime() || path.resolve(DB_FILE) === path.resolve(LEGACY_PACKAGED_DB_FILE)) {
return;
}
let migrated = false;
if (!fs.existsSync(DB_FILE)) {
if (fs.existsSync(LEGACY_PACKAGED_DB_FILE)) {
await fsp.copyFile(LEGACY_PACKAGED_DB_FILE, DB_FILE);
migrated = true;
} else if (fs.existsSync(LEGACY_PACKAGED_DB_BACKUP)) {
await fsp.copyFile(LEGACY_PACKAGED_DB_BACKUP, DB_FILE);
migrated = true;
}
}
if (!fs.existsSync(DB_BACKUP) && fs.existsSync(LEGACY_PACKAGED_DB_BACKUP)) {
await fsp.copyFile(LEGACY_PACKAGED_DB_BACKUP, DB_BACKUP);
migrated = true;
}
if (migrated) {
console.log('[DB] Migrated packaged database files to:', DATA_DIR);
console.log('[DB] Legacy packaged database location was:', LEGACY_PACKAGED_DB_FILE);
}
}
/** /**
* Returns true when `data` looks like a valid SQLite file * Returns true when `data` looks like a valid SQLite file
@@ -56,8 +152,11 @@ function isValidSqlite(data: Uint8Array): boolean {
* restore the backup before the server loads the database. * restore the backup before the server loads the database.
*/ */
function safeguardDbFile(): Uint8Array | undefined { function safeguardDbFile(): Uint8Array | undefined {
if (!fs.existsSync(DB_FILE)) if (!fs.existsSync(DB_FILE)) {
return undefined; console.warn(`[DB] ${DB_FILE} is missing - checking backup`);
return restoreFromBackup('Database file missing');
}
const data = new Uint8Array(fs.readFileSync(DB_FILE)); const data = new Uint8Array(fs.readFileSync(DB_FILE));
@@ -72,22 +171,7 @@ function safeguardDbFile(): Uint8Array | undefined {
// The main file is corrupt or empty. // The main file is corrupt or empty.
console.warn(`[DB] ${DB_FILE} appears corrupt (${data.length} bytes) - checking backup`); console.warn(`[DB] ${DB_FILE} appears corrupt (${data.length} bytes) - checking backup`);
if (fs.existsSync(DB_BACKUP)) { return restoreFromBackup(`Database file is invalid (${data.length} bytes)`);
const backup = new Uint8Array(fs.readFileSync(DB_BACKUP));
if (isValidSqlite(backup)) {
fs.copyFileSync(DB_BACKUP, DB_FILE);
console.warn('[DB] Restored database from backup', DB_BACKUP);
return backup;
}
console.error('[DB] Backup is also invalid - starting with a fresh database');
} else {
console.error('[DB] No backup available - starting with a fresh database');
}
return undefined;
} }
function resolveSqlJsConfig(): { locateFile: (file: string) => string } { function resolveSqlJsConfig(): { locateFile: (file: string) => string } {
@@ -108,18 +192,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');
@@ -132,6 +245,8 @@ export async function initDatabase(): Promise<void> {
if (!fs.existsSync(DATA_DIR)) if (!fs.existsSync(DATA_DIR))
fs.mkdirSync(DATA_DIR, { recursive: true }); fs.mkdirSync(DATA_DIR, { recursive: true });
await migrateLegacyPackagedDatabase();
const database = safeguardDbFile(); const database = safeguardDbFile();
try { try {
@@ -149,7 +264,13 @@ export async function initDatabase(): Promise<void> {
JoinRequestEntity, JoinRequestEntity,
ServerMembershipEntity, ServerMembershipEntity,
ServerInviteEntity, ServerInviteEntity,
ServerBanEntity ServerBanEntity,
GameMatchMissEntity,
ServerPluginRequirementEntity,
ServerPluginEventDefinitionEntity,
PluginDataEntity,
ServerPluginSettingsEntity,
PluginUserMetadataEntity
], ],
migrations: serverMigrations, migrations: serverMigrations,
synchronize: process.env.DB_SYNCHRONIZE === 'true', synchronize: process.env.DB_SYNCHRONIZE === 'true',

View File

@@ -0,0 +1,22 @@
import {
Column,
Entity,
Index,
PrimaryColumn
} from 'typeorm';
@Entity('game_match_misses')
export class GameMatchMissEntity {
@PrimaryColumn('text')
processKey!: string;
@Column('text')
processName!: string;
@Column('integer')
missedAt!: number;
@Index()
@Column('integer')
expiresAt!: number;
}

View File

@@ -0,0 +1,35 @@
import {
Column,
Entity,
PrimaryColumn
} from 'typeorm';
@Entity('plugin_data')
export class PluginDataEntity {
@PrimaryColumn('text')
serverId!: string;
@PrimaryColumn('text')
pluginId!: string;
@PrimaryColumn('text')
scope!: string;
@PrimaryColumn('text')
ownerId!: string;
@PrimaryColumn('text')
key!: string;
@Column('text')
valueJson!: string;
@Column('integer', { default: 1 })
schemaVersion!: number;
@Column('text', { nullable: true })
updatedBy!: string | null;
@Column('integer')
updatedAt!: number;
}

View File

@@ -0,0 +1,38 @@
import {
Column,
Entity,
PrimaryColumn
} from 'typeorm';
@Entity('plugin_user_metadata')
export class PluginUserMetadataEntity {
@PrimaryColumn('text')
serverId!: string;
@PrimaryColumn('text')
pluginId!: string;
@PrimaryColumn('text')
pluginUserId!: string;
@Column('text')
displayName!: string;
@Column('text', { nullable: true })
avatarHash!: string | null;
@Column('text', { nullable: true })
avatarMime!: string | null;
@Column('integer', { nullable: true })
avatarUpdatedAt!: number | null;
@Column('text')
roleIdsJson!: string;
@Column('integer')
createdAt!: number;
@Column('integer')
updatedAt!: number;
}

View File

@@ -0,0 +1,41 @@
import {
Column,
Entity,
PrimaryColumn
} from 'typeorm';
export type ServerPluginEventDirection = 'clientToServer' | 'serverRelay' | 'p2pHint';
export type ServerPluginEventScope = 'server' | 'channel' | 'user' | 'plugin';
@Entity('server_plugin_event_definitions')
export class ServerPluginEventDefinitionEntity {
@PrimaryColumn('text')
serverId!: string;
@PrimaryColumn('text')
pluginId!: string;
@PrimaryColumn('text')
eventName!: string;
@Column('text')
direction!: ServerPluginEventDirection;
@Column('text')
scope!: ServerPluginEventScope;
@Column('text', { nullable: true })
schemaJson!: string | null;
@Column('integer')
maxPayloadBytes!: number;
@Column('text', { nullable: true })
rateLimitJson!: string | null;
@Column('integer')
createdAt!: number;
@Column('integer')
updatedAt!: number;
}

View File

@@ -0,0 +1,45 @@
import {
Column,
Entity,
Index,
PrimaryColumn
} from 'typeorm';
export type ServerPluginRequirementStatus = 'required' | 'optional' | 'recommended' | 'blocked' | 'incompatible';
@Entity('server_plugin_requirements')
export class ServerPluginRequirementEntity {
@PrimaryColumn('text')
serverId!: string;
@PrimaryColumn('text')
pluginId!: string;
@Index()
@Column('text')
status!: ServerPluginRequirementStatus;
@Column('text', { nullable: true })
versionRange!: string | null;
@Column('text', { nullable: true })
reason!: string | null;
@Column('text', { nullable: true })
installUrl!: string | null;
@Column('text', { nullable: true })
sourceUrl!: string | null;
@Column('text', { nullable: true })
manifestJson!: string | null;
@Column('text', { nullable: true })
configuredBy!: string | null;
@Column('integer')
createdAt!: number;
@Column('integer')
updatedAt!: number;
}

View File

@@ -0,0 +1,26 @@
import {
Column,
Entity,
PrimaryColumn
} from 'typeorm';
@Entity('server_plugin_settings')
export class ServerPluginSettingsEntity {
@PrimaryColumn('text')
serverId!: string;
@PrimaryColumn('text')
pluginId!: string;
@Column('text')
settingsJson!: string;
@Column('integer', { default: 1 })
schemaVersion!: number;
@Column('text', { nullable: true })
updatedBy!: string | null;
@Column('integer')
updatedAt!: number;
}

View File

@@ -9,3 +9,11 @@ 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';
export { ServerPluginRequirementEntity } from './ServerPluginRequirementEntity';
export type { ServerPluginRequirementStatus } from './ServerPluginRequirementEntity';
export { ServerPluginEventDefinitionEntity } from './ServerPluginEventDefinitionEntity';
export type { ServerPluginEventDirection, ServerPluginEventScope } from './ServerPluginEventDefinitionEntity';
export { PluginDataEntity } from './PluginDataEntity';
export { ServerPluginSettingsEntity } from './ServerPluginSettingsEntity';
export { PluginUserMetadataEntity } from './PluginUserMetadataEntity';

View File

@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class GameMatchMisses1000000000006 implements MigrationInterface {
name = 'GameMatchMisses1000000000006';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "game_match_misses" (
"processKey" TEXT PRIMARY KEY NOT NULL,
"processName" TEXT NOT NULL,
"missedAt" INTEGER NOT NULL,
"expiresAt" INTEGER NOT NULL
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_game_match_misses_expiresAt"
ON "game_match_misses" ("expiresAt")
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "game_match_misses"`);
}
}

View File

@@ -0,0 +1,92 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PluginSupport1000000000007 implements MigrationInterface {
name = 'PluginSupport1000000000007';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "server_plugin_requirements" (
"serverId" TEXT NOT NULL,
"pluginId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"versionRange" TEXT,
"reason" TEXT,
"configuredBy" TEXT,
"createdAt" INTEGER NOT NULL,
"updatedAt" INTEGER NOT NULL,
PRIMARY KEY ("serverId", "pluginId")
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_server_plugin_requirements_status"
ON "server_plugin_requirements" ("status")
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "server_plugin_event_definitions" (
"serverId" TEXT NOT NULL,
"pluginId" TEXT NOT NULL,
"eventName" TEXT NOT NULL,
"direction" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"schemaJson" TEXT,
"maxPayloadBytes" INTEGER NOT NULL,
"rateLimitJson" TEXT,
"createdAt" INTEGER NOT NULL,
"updatedAt" INTEGER NOT NULL,
PRIMARY KEY ("serverId", "pluginId", "eventName")
)
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "plugin_data" (
"serverId" TEXT NOT NULL,
"pluginId" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"ownerId" TEXT NOT NULL,
"key" TEXT NOT NULL,
"valueJson" TEXT NOT NULL,
"schemaVersion" INTEGER NOT NULL DEFAULT 1,
"updatedBy" TEXT,
"updatedAt" INTEGER NOT NULL,
PRIMARY KEY ("serverId", "pluginId", "scope", "ownerId", "key")
)
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "server_plugin_settings" (
"serverId" TEXT NOT NULL,
"pluginId" TEXT NOT NULL,
"settingsJson" TEXT NOT NULL,
"schemaVersion" INTEGER NOT NULL DEFAULT 1,
"updatedBy" TEXT,
"updatedAt" INTEGER NOT NULL,
PRIMARY KEY ("serverId", "pluginId")
)
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "plugin_user_metadata" (
"serverId" TEXT NOT NULL,
"pluginId" TEXT NOT NULL,
"pluginUserId" TEXT NOT NULL,
"displayName" TEXT NOT NULL,
"avatarHash" TEXT,
"avatarMime" TEXT,
"avatarUpdatedAt" INTEGER,
"roleIdsJson" TEXT NOT NULL,
"createdAt" INTEGER NOT NULL,
"updatedAt" INTEGER NOT NULL,
PRIMARY KEY ("serverId", "pluginId", "pluginUserId")
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "plugin_user_metadata"`);
await queryRunner.query(`DROP TABLE IF EXISTS "server_plugin_settings"`);
await queryRunner.query(`DROP TABLE IF EXISTS "plugin_data"`);
await queryRunner.query(`DROP TABLE IF EXISTS "server_plugin_event_definitions"`);
await queryRunner.query(`DROP TABLE IF EXISTS "server_plugin_requirements"`);
}
}

View File

@@ -0,0 +1,30 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class ServerPluginInstallMetadata1000000000008 implements MigrationInterface {
name = 'ServerPluginInstallMetadata1000000000008';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "server_plugin_requirements" ADD COLUMN "installUrl" TEXT`);
await queryRunner.query(`ALTER TABLE "server_plugin_requirements" ADD COLUMN "sourceUrl" TEXT`);
await queryRunner.query(`ALTER TABLE "server_plugin_requirements" ADD COLUMN "manifestJson" TEXT`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "temporary_server_plugin_requirements" (
"serverId" TEXT NOT NULL,
"pluginId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"versionRange" TEXT,
"reason" TEXT,
"configuredBy" TEXT,
"createdAt" INTEGER NOT NULL,
"updatedAt" INTEGER NOT NULL,
PRIMARY KEY ("serverId", "pluginId")
)`);
await queryRunner.query(`INSERT INTO "temporary_server_plugin_requirements" ("serverId", "pluginId", "status", "versionRange", "reason", "configuredBy", "createdAt", "updatedAt")
SELECT "serverId", "pluginId", "status", "versionRange", "reason", "configuredBy", "createdAt", "updatedAt" FROM "server_plugin_requirements"`);
await queryRunner.query(`DROP TABLE "server_plugin_requirements"`);
await queryRunner.query(`ALTER TABLE "temporary_server_plugin_requirements" RENAME TO "server_plugin_requirements"`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_plugin_requirements_status" ON "server_plugin_requirements" ("status")`);
}
}

View File

@@ -4,6 +4,9 @@ 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';
import { PluginSupport1000000000007 } from './1000000000007-PluginSupport';
import { ServerPluginInstallMetadata1000000000008 } from './1000000000008-ServerPluginInstallMetadata';
export const serverMigrations = [ export const serverMigrations = [
InitialSchema1000000000000, InitialSchema1000000000000,
@@ -11,5 +14,8 @@ export const serverMigrations = [
ServerChannels1000000000002, ServerChannels1000000000002,
RepairLegacyVoiceChannels1000000000003, RepairLegacyVoiceChannels1000000000003,
NormalizeServerArrays1000000000004, NormalizeServerArrays1000000000004,
ServerRoleAccessControl1000000000005 ServerRoleAccessControl1000000000005,
GameMatchMisses1000000000006,
PluginSupport1000000000007,
ServerPluginInstallMetadata1000000000008
]; ];

View File

@@ -0,0 +1,17 @@
import { Router } from 'express';
import { matchRunningGames } from '../services/game-matching.service';
const router = Router();
router.post('/match', async (req, res) => {
try {
const result = await matchRunningGames(req.body?.processes, req.body?.userId ?? req.ip);
res.json(result);
} catch (error) {
console.error('[Games] Failed to match running games', error);
res.status(500).json({ error: 'Failed to match running games' });
}
});
export default router;

View File

@@ -2,9 +2,12 @@ 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';
import pluginSupportRouter from './plugin-support';
import openApiDocsRouter from './openapi-docs';
import joinRequestsRouter from './join-requests'; import joinRequestsRouter from './join-requests';
import { invitesApiRouter, invitePageRouter } from './invites'; import { invitesApiRouter, invitePageRouter } from './invites';
@@ -12,8 +15,11 @@ 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', openApiDocsRouter);
app.use('/api/servers', pluginSupportRouter);
app.use('/api/servers', serversRouter); app.use('/api/servers', serversRouter);
app.use('/api/invites', invitesApiRouter); app.use('/api/invites', invitesApiRouter);
app.use('/api/requests', joinRequestsRouter); app.use('/api/requests', joinRequestsRouter);

View File

@@ -0,0 +1,106 @@
import { Router } from 'express';
import { areOpenApiDocsEnabled, setOpenApiDocsEnabled } from '../config/variables';
const router = Router();
function createOpenApiDocument(baseUrl: string) {
return {
openapi: '3.1.0',
info: {
title: 'MetoYou Plugin Support API',
version: '1.0.0',
description: 'Official HTTP endpoints for plugin install metadata and event definitions. '
+ 'Plugin code is never executed by the signal server.'
},
servers: [{ url: `${baseUrl}/api` }],
paths: {
'/servers/{serverId}/plugins': {
get: {
summary: 'Read plugin requirement snapshot',
parameters: [{ name: 'serverId', in: 'path', required: true, schema: { type: 'string' } }],
responses: { '200': { description: 'Plugin requirements and event definitions' } }
}
},
'/servers/{serverId}/plugins/{pluginId}/requirement': {
put: {
summary: 'Create or update a server plugin requirement',
responses: { '200': { description: 'Requirement saved' }, '403': { description: 'Not authorized' } }
},
delete: {
summary: 'Delete a server plugin requirement',
responses: { '200': { description: 'Requirement deleted' }, '403': { description: 'Not authorized' } }
}
},
'/servers/{serverId}/plugins/{pluginId}/events/{eventName}': {
put: {
summary: 'Create or update a plugin event definition',
responses: { '200': { description: 'Event definition saved' }, '403': { description: 'Not authorized' } }
},
delete: {
summary: 'Delete a plugin event definition',
responses: { '200': { description: 'Event definition deleted' }, '403': { description: 'Not authorized' } }
}
},
'/servers/{serverId}/plugins/{pluginId}/data': {
get: {
summary: 'Plugin data persistence disabled',
responses: { '410': { description: 'Plugin data persistence is disabled on the signal server' } }
}
},
'/servers/{serverId}/plugins/{pluginId}/data/{key}': {
put: {
summary: 'Plugin data persistence disabled',
responses: { '410': { description: 'Plugin data persistence is disabled on the signal server' } }
},
delete: {
summary: 'Plugin data persistence disabled',
responses: { '410': { description: 'Plugin data persistence is disabled on the signal server' } }
}
},
'/openapi/settings': {
get: { summary: 'Read OpenAPI docs setting', responses: { '200': { description: 'Setting value' } } },
put: { summary: 'Toggle OpenAPI docs exposure', responses: { '200': { description: 'Setting value' } } }
}
}
};
}
function docsDisabledResponse() {
return { error: 'OpenAPI docs are disabled', errorCode: 'OPENAPI_DOCS_DISABLED' };
}
router.get('/openapi/settings', (_req, res) => {
res.json({ enabled: areOpenApiDocsEnabled() });
});
router.put('/openapi/settings', (req, res) => {
res.json(setOpenApiDocsEnabled(req.body?.enabled === true));
});
router.get('/openapi.json', (req, res) => {
if (!areOpenApiDocsEnabled()) {
res.status(404).json(docsDisabledResponse());
return;
}
res.json(createOpenApiDocument(`${req.protocol}://${req.get('host') ?? 'localhost'}`));
});
router.get('/docs', (_req, res) => {
if (!areOpenApiDocsEnabled()) {
res.status(404).json(docsDisabledResponse());
return;
}
res.type('html').send(`<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>MetoYou Plugin API Docs</title></head>
<body style="font-family:system-ui;margin:2rem;line-height:1.5">
<h1>MetoYou Plugin Support API</h1>
<p>Plugin support endpoints are available at <a href="/api/openapi.json">/api/openapi.json</a>.</p>
<p>The signal server stores plugin install metadata and event definitions only. It never executes plugin code or stores arbitrary plugin data.</p>
</body>
</html>`);
});
export default router;

View File

@@ -0,0 +1,148 @@
import { Response, Router } from 'express';
import {
deletePluginEventDefinition,
deletePluginRequirement,
getPluginRequirementsSnapshot,
PluginSupportError,
upsertPluginEventDefinition,
upsertPluginRequirement
} from '../services/plugin-support.service';
import { broadcastToServer } from '../websocket/broadcast';
const router = Router();
function sendPluginSupportError(error: unknown, res: Response): void {
if (error instanceof PluginSupportError) {
res.status(error.status).json({ error: error.message, errorCode: error.code });
return;
}
console.error('Unhandled plugin support error:', error);
res.status(500).json({ error: 'Internal server error', errorCode: 'INTERNAL_ERROR' });
}
function readActorUserId(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
async function broadcastRequirementsSnapshot(serverId: string): Promise<void> {
const snapshot = await getPluginRequirementsSnapshot(serverId);
broadcastToServer(serverId, {
type: 'plugin_requirements_changed',
serverId,
snapshot
});
}
router.get('/:serverId/plugins', async (req, res) => {
try {
res.json(await getPluginRequirementsSnapshot(req.params.serverId));
} catch (error) {
sendPluginSupportError(error, res);
}
});
router.put('/:serverId/plugins/:pluginId/requirement', async (req, res) => {
const { serverId, pluginId } = req.params;
try {
const requirement = await upsertPluginRequirement({
actorUserId: readActorUserId(req.body.actorUserId),
installUrl: req.body.installUrl,
manifest: req.body.manifest,
pluginId,
reason: req.body.reason,
serverId,
sourceUrl: req.body.sourceUrl,
status: req.body.status,
versionRange: req.body.versionRange
});
await broadcastRequirementsSnapshot(serverId);
res.json({ requirement });
} catch (error) {
sendPluginSupportError(error, res);
}
});
router.delete('/:serverId/plugins/:pluginId/requirement', async (req, res) => {
const { serverId, pluginId } = req.params;
try {
await deletePluginRequirement({
actorUserId: readActorUserId(req.body.actorUserId),
pluginId,
serverId
});
await broadcastRequirementsSnapshot(serverId);
res.json({ ok: true });
} catch (error) {
sendPluginSupportError(error, res);
}
});
router.put('/:serverId/plugins/:pluginId/events/:eventName', async (req, res) => {
const { serverId, pluginId, eventName } = req.params;
try {
const eventDefinition = await upsertPluginEventDefinition({
actorUserId: readActorUserId(req.body.actorUserId),
direction: req.body.direction,
eventName,
maxPayloadBytes: req.body.maxPayloadBytes,
pluginId,
rateLimitJson: req.body.rateLimitJson,
schemaJson: req.body.schemaJson,
scope: req.body.scope,
serverId
});
await broadcastRequirementsSnapshot(serverId);
res.json({ eventDefinition });
} catch (error) {
sendPluginSupportError(error, res);
}
});
router.delete('/:serverId/plugins/:pluginId/events/:eventName', async (req, res) => {
const { serverId, pluginId, eventName } = req.params;
try {
await deletePluginEventDefinition({
actorUserId: readActorUserId(req.body.actorUserId),
eventName,
pluginId,
serverId
});
await broadcastRequirementsSnapshot(serverId);
res.json({ ok: true });
} catch (error) {
sendPluginSupportError(error, res);
}
});
router.get('/:serverId/plugins/:pluginId/data', (_req, res) => {
res.status(410).json({
error: 'Plugin data persistence is disabled on the signal server',
errorCode: 'PLUGIN_DATA_DISABLED'
});
});
router.put('/:serverId/plugins/:pluginId/data/:key', (_req, res) => {
res.status(410).json({
error: 'Plugin data persistence is disabled on the signal server',
errorCode: 'PLUGIN_DATA_DISABLED'
});
});
router.delete('/:serverId/plugins/:pluginId/data/:key', (_req, res) => {
res.status(410).json({
error: 'Plugin data persistence is disabled on the signal server',
errorCode: 'PLUGIN_DATA_DISABLED'
});
});
export default router;

View File

@@ -19,7 +19,8 @@ import {
ServerAccessError, ServerAccessError,
kickServerUser, kickServerUser,
ensureServerMembership, ensureServerMembership,
unbanServerUser unbanServerUser,
countServerMemberships
} from '../services/server-access.service'; } from '../services/server-access.service';
import { import {
buildAppInviteUrl, buildAppInviteUrl,
@@ -78,6 +79,7 @@ function normalizeServerChannels(value: unknown): ServerChannelPayload[] {
async function enrichServer(server: ServerPayload, sourceUrl?: string) { async function enrichServer(server: ServerPayload, sourceUrl?: string) {
const owner = await getUserById(server.ownerId); const owner = await getUserById(server.ownerId);
const userCount = await countServerMemberships(server.id);
const { passwordHash, ...publicServer } = server; const { passwordHash, ...publicServer } = server;
return { return {
@@ -85,7 +87,8 @@ async function enrichServer(server: ServerPayload, sourceUrl?: string) {
hasPassword: server.hasPassword ?? !!passwordHash, hasPassword: server.hasPassword ?? !!passwordHash,
ownerName: owner?.displayName, ownerName: owner?.displayName,
sourceUrl, sourceUrl,
userCount: server.currentUsers currentUsers: userCount,
userCount
}; };
} }

View File

@@ -1,6 +1,9 @@
import fs from 'fs'; import fs from 'fs';
import os from 'os';
import path from 'path'; import path from 'path';
const PACKAGED_DATA_DIRECTORY_NAME = 'MetoYou Server';
type PackagedProcess = NodeJS.Process & { pkg?: unknown }; type PackagedProcess = NodeJS.Process & { pkg?: unknown };
function uniquePaths(paths: string[]): string[] { function uniquePaths(paths: string[]): string[] {
@@ -21,6 +24,33 @@ export function resolveRuntimePath(...segments: string[]): string {
return path.join(getRuntimeBaseDir(), ...segments); return path.join(getRuntimeBaseDir(), ...segments);
} }
function resolvePackagedDataDirectory(): string {
const homeDirectory = os.homedir();
switch (process.platform) {
case 'win32':
return path.join(
process.env.APPDATA || path.join(homeDirectory, 'AppData', 'Roaming'),
PACKAGED_DATA_DIRECTORY_NAME
);
case 'darwin':
return path.join(homeDirectory, 'Library', 'Application Support', PACKAGED_DATA_DIRECTORY_NAME);
default:
return path.join(
process.env.XDG_DATA_HOME || path.join(homeDirectory, '.local', 'share'),
PACKAGED_DATA_DIRECTORY_NAME
);
}
}
export function resolvePersistentDataPath(...segments: string[]): string {
if (!isPackagedRuntime()) {
return resolveRuntimePath(...segments);
}
return path.join(resolvePackagedDataDirectory(), ...segments);
}
export function resolveProjectRootPath(...segments: string[]): string { export function resolveProjectRootPath(...segments: string[]): string {
return path.resolve(__dirname, '..', '..', ...segments); return path.resolve(__dirname, '..', '..', ...segments);
} }

View File

@@ -0,0 +1,591 @@
import { getRawgApiKey } from '../config/variables';
import { getDataSource } from '../db/database';
import { GameMatchMissEntity } from '../entities';
export interface MatchedGame {
id: string;
name: string;
iconUrl?: string;
store?: GameStoreLink;
processName: string;
}
export interface GameStoreLink {
id?: string;
name: string;
slug?: string;
domain?: string;
url: string;
}
interface CacheEntry {
expiresAt: number;
game: Omit<MatchedGame, 'processName'> | null;
}
interface RawgSearchResponse {
results?: RawgGameResult[];
}
interface RawgGameResult {
id?: number;
name?: string;
background_image?: string | null;
slug?: string;
stores?: RawgStoreEntry[] | null;
}
interface RawgStoreEntry {
url?: string | null;
store?: RawgStore | null;
}
interface RawgStore {
id?: number;
name?: string;
slug?: string;
domain?: string | null;
}
interface CandidateProcess {
processName: string;
score: number;
}
interface GameMatchResult {
games: MatchedGame[];
rateLimited?: boolean;
}
interface RawgLookupBudget {
used: number;
windowStartedAt: number;
}
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const PERSISTED_MISS_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const RAWG_LOOKUP_WINDOW_MS = 60 * 60 * 1000;
const RAWG_SEARCH_TIMEOUT_MS = 4_000;
const MAX_INCOMING_PROCESSES = 256;
const MAX_CANDIDATE_PROCESSES = 24;
const MAX_UNCACHED_LOOKUPS_PER_REQUEST = 4;
const MAX_RAWG_LOOKUPS_PER_USER_PER_WINDOW = 8;
const RAWG_SEARCH_URL = 'https://api.rawg.io/api/games';
const MIN_SEARCH_QUERY_LENGTH = 4;
const IGNORED_PROCESS_NAMES = new Set([
'agent',
'bash',
'baloorunner',
'chrome',
'code',
'conhost',
'cursor',
'csrss',
'dbus-daemon',
'discord',
'dwm',
'electron',
'explorer',
'firefox',
'gameoverlayui',
'gamemoded',
'gamescopereaper',
'gnome-shell',
'init',
'kernel_task',
'metoyou',
'nvidia-settings',
'node',
'npm',
'obs',
'powershell',
'pulseaudio',
'services',
'steam',
'steamwebhelper',
'system',
'systemd',
'taskhostw',
'wininit',
'winlogon',
'xorg'
]);
const IGNORED_PROCESS_PATTERNS = [
new RegExp('(^|\\s)(agent|browser|daemon|desktop|helper|indexer|launcher|monitor|renderer|runner)(\\s|$)'),
new RegExp('(^|\\s)(service|settings|shell|tray|updater|utility|watcher|worker)(\\s|$)'),
new RegExp('(^|\\s)(audio|bluetooth|clipboard|crash|dbus|file|gpu|input|network|notification)(\\s|$)'),
new RegExp('(^|\\s)(portal|proxy|screen|session|sync|system|tracker|web|window)(\\s|$)'),
/^(appimage|at-spi|baloo|dconf|gvfs|ibus|kde|kworker)/,
/^(pipewire|plasmashell|pulseaudio|xdg|xwayland|zeitgeist)/,
/(helper|service|daemon|runner|tracker|portal|updater|worker)$/
];
const STORE_SEARCH_URL_BUILDERS: Record<string, (query: string) => string> = {
steam: (query) => `https://store.steampowered.com/search/?term=${query}`,
'epic-games': (query) => `https://store.epicgames.com/en-US/browse?q=${query}`,
gog: (query) => `https://www.gog.com/en/games?query=${query}`,
itch: (query) => `https://itch.io/search?q=${query}`,
'xbox-store': (query) => `https://www.xbox.com/search?q=${query}`,
'playstation-store': (query) => `https://store.playstation.com/search/${query}`,
nintendo: (query) => `https://www.nintendo.com/search/#q=${query}`,
'apple-appstore': (query) => `https://apps.apple.com/us/search?term=${query}`,
'google-play': (query) => `https://play.google.com/store/search?q=${query}&c=apps`
};
const STORE_SEARCH_ALIASES = new Map<string, string>([
['steam', 'steam'],
['store.steampowered.com', 'steam'],
['epic-games', 'epic-games'],
['store.epicgames.com', 'epic-games'],
['gog', 'gog'],
['www.gog.com', 'gog'],
['gog.com', 'gog'],
['itch', 'itch'],
['itch.io', 'itch'],
['xbox-store', 'xbox-store'],
['www.xbox.com', 'xbox-store'],
['xbox.com', 'xbox-store'],
['playstation-store', 'playstation-store'],
['store.playstation.com', 'playstation-store'],
['nintendo', 'nintendo'],
['www.nintendo.com', 'nintendo'],
['nintendo.com', 'nintendo'],
['apple-appstore', 'apple-appstore'],
['apps.apple.com', 'apple-appstore'],
['google-play', 'google-play'],
['play.google.com', 'google-play']
]);
const STORE_PRIORITY = new Map<string, number>([
['steam', 0],
['gog', 10],
['epic-games', 20],
['itch', 30],
['xbox-store', 80],
['playstation-store', 90]
]);
const cache = new Map<string, CacheEntry>();
const rawgLookupBudgets = new Map<string, RawgLookupBudget>();
export async function matchRunningGames(
processNames: unknown,
requester: unknown = 'anonymous'
): Promise<GameMatchResult> {
const candidates = normalizeProcessList(processNames).slice(0, MAX_CANDIDATE_PROCESSES);
const matches: MatchedGame[] = [];
const seenGameIds = new Set<string>();
const requesterKey = normalizeRequesterKey(requester);
const persistedMisses = await loadPersistedMissKeys(candidates.map((candidate) => candidate.processName));
let uncachedLookups = 0;
let rateLimited = false;
for (const { processName } of candidates) {
const cacheKey = normalizeCacheKey(processName);
const cached = getCachedGame(cacheKey);
if (cached !== undefined) {
appendMatch(matches, seenGameIds, processName, cached);
continue;
}
if (persistedMisses.has(cacheKey)) {
setCachedGame(cacheKey, null);
continue;
}
if (uncachedLookups >= MAX_UNCACHED_LOOKUPS_PER_REQUEST) {
rateLimited = true;
continue;
}
if (!tryConsumeRawgLookup(requesterKey)) {
rateLimited = true;
continue;
}
uncachedLookups += 1;
const game = await resolveRawgGame(processName);
setCachedGame(cacheKey, game);
if (!game) {
await rememberPersistedMiss(cacheKey, processName);
}
appendMatch(matches, seenGameIds, processName, game);
}
return {
games: matches,
rateLimited: rateLimited || undefined
};
}
function normalizeProcessList(value: unknown): CandidateProcess[] {
if (!Array.isArray(value)) {
return [];
}
const processes = new Map<string, CandidateProcess>();
for (const entry of value.slice(0, MAX_INCOMING_PROCESSES)) {
const processName = normalizeProcessName(entry);
if (processName) {
const cacheKey = normalizeCacheKey(processName);
if (!processes.has(cacheKey)) {
processes.set(cacheKey, {
processName,
score: scoreCandidateProcess(String(entry), processName)
});
}
}
}
return Array.from(processes.values())
.sort((left, right) => right.score - left.score || left.processName.localeCompare(right.processName));
}
function normalizeProcessName(value: unknown): string {
if (typeof value !== 'string') {
return '';
}
const normalized = value
.trim()
.replace(/\.exe$/i, '')
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const cacheKey = normalizeCacheKey(normalized);
if (normalized.length < 3 || normalized.length > 96 || shouldIgnoreProcessName(cacheKey)) {
return '';
}
return normalized;
}
function shouldIgnoreProcessName(cacheKey: string): boolean {
return IGNORED_PROCESS_NAMES.has(cacheKey)
|| IGNORED_PROCESS_PATTERNS.some((pattern) => pattern.test(cacheKey));
}
function normalizeRequesterKey(value: unknown): string {
if (typeof value !== 'string') {
return 'anonymous';
}
const normalized = value.trim().toLowerCase();
return normalized || 'anonymous';
}
function tryConsumeRawgLookup(requesterKey: string): boolean {
const now = Date.now();
const existing = rawgLookupBudgets.get(requesterKey);
if (!existing || existing.windowStartedAt + RAWG_LOOKUP_WINDOW_MS <= now) {
rawgLookupBudgets.set(requesterKey, {
used: 1,
windowStartedAt: now
});
return true;
}
if (existing.used >= MAX_RAWG_LOOKUPS_PER_USER_PER_WINDOW) {
return false;
}
existing.used += 1;
return true;
}
function scoreCandidateProcess(rawValue: string, processName: string): number {
let score = 0;
if (/\.exe$/i.test(rawValue.trim())) {
score += 12;
}
if (/[A-Z]/.test(processName) && /[a-z]/.test(processName)) {
score += 4;
}
if (/\d/.test(processName)) {
score += 1;
}
if (processName.length >= 5 && processName.length <= 32) {
score += 2;
}
if (processName.includes(' ')) {
score -= 2;
}
return score;
}
function normalizeCacheKey(value: string): string {
return value.trim()
.toLowerCase()
.replace(/\s+/g, ' ');
}
function getCachedGame(cacheKey: string): Omit<MatchedGame, 'processName'> | null | undefined {
const cached = cache.get(cacheKey);
if (!cached) {
return undefined;
}
if (cached.expiresAt <= Date.now()) {
cache.delete(cacheKey);
return undefined;
}
return cached.game;
}
function setCachedGame(cacheKey: string, game: Omit<MatchedGame, 'processName'> | null): void {
cache.set(cacheKey, {
expiresAt: Date.now() + CACHE_TTL_MS,
game
});
}
async function loadPersistedMissKeys(processNames: string[]): Promise<Set<string>> {
const cacheKeys = Array.from(new Set(processNames.map((name) => normalizeCacheKey(name))));
if (cacheKeys.length === 0) {
return new Set();
}
try {
const repository = getDataSource().getRepository(GameMatchMissEntity);
const now = Date.now();
await repository.createQueryBuilder()
.delete()
.where('expiresAt <= :now', { now })
.execute();
const rows = await repository.createQueryBuilder('miss')
.select('miss.processKey')
.where('miss.processKey IN (:...cacheKeys)', { cacheKeys })
.andWhere('miss.expiresAt > :now', { now })
.getMany();
return new Set(rows.map((row) => row.processKey));
} catch {
return new Set();
}
}
async function rememberPersistedMiss(cacheKey: string, processName: string): Promise<void> {
try {
const now = Date.now();
await getDataSource().getRepository(GameMatchMissEntity)
.save({
processKey: cacheKey,
processName,
missedAt: now,
expiresAt: now + PERSISTED_MISS_TTL_MS
});
} catch {
return;
}
}
async function resolveRawgGame(processName: string): Promise<Omit<MatchedGame, 'processName'> | null> {
const apiKey = getRawgApiKey();
if (!apiKey) {
return null;
}
const query = buildSearchQuery(processName);
if (!query) {
return null;
}
const url = new URL(RAWG_SEARCH_URL);
url.searchParams.set('key', apiKey);
url.searchParams.set('search', query);
url.searchParams.set('search_precise', 'true');
url.searchParams.set('exclude_additions', 'true');
url.searchParams.set('page_size', '1');
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), RAWG_SEARCH_TIMEOUT_MS);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) {
return null;
}
const body = await response.json() as RawgSearchResponse;
const result = body.results?.[0];
if (!isAcceptableRawgMatch(query, result)) {
return null;
}
return {
id: String(result.id),
name: result.name.trim(),
iconUrl: result.background_image || undefined,
store: selectPreferredStore(result, result.name.trim())
};
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}
function selectPreferredStore(result: RawgGameResult, gameName: string): GameStoreLink | undefined {
const stores = Array.isArray(result.stores) ? result.stores : [];
const usableStores = stores
.map((entry) => buildStoreLink(entry, gameName))
.filter((store): store is GameStoreLink => !!store);
return usableStores.sort((left, right) => getStorePriority(left) - getStorePriority(right))[0];
}
function getStorePriority(store: GameStoreLink): number {
const storeKey = STORE_SEARCH_ALIASES.get(store.slug ?? '')
?? STORE_SEARCH_ALIASES.get(store.domain ?? '')
?? store.name.trim().toLowerCase();
return STORE_PRIORITY.get(storeKey) ?? 50;
}
function buildStoreLink(entry: RawgStoreEntry, gameName: string): GameStoreLink | undefined {
const store = entry.store;
if (!store || typeof store.name !== 'string' || !store.name.trim()) {
return undefined;
}
const slug = typeof store.slug === 'string' && store.slug.trim()
? store.slug.trim().toLowerCase()
: undefined;
const domain = typeof store.domain === 'string' && store.domain.trim()
? store.domain.trim()
.replace(/^https?:\/\//i, '')
.replace(/\/$/, '')
: undefined;
const url = normalizeExternalUrl(entry.url) ?? buildStoreSearchUrl(slug, domain, gameName);
if (!url) {
return undefined;
}
return {
id: typeof store.id === 'number' ? String(store.id) : undefined,
name: store.name.trim(),
slug,
domain,
url
};
}
function normalizeExternalUrl(value: unknown): string | undefined {
if (typeof value !== 'string' || !value.trim()) {
return undefined;
}
const trimmed = value.trim();
return trimmed.startsWith('http://') || trimmed.startsWith('https://')
? trimmed
: undefined;
}
function buildStoreSearchUrl(slug: string | undefined, domain: string | undefined, gameName: string): string | undefined {
const query = encodeURIComponent(gameName);
const storeKey = STORE_SEARCH_ALIASES.get(slug ?? '') ?? STORE_SEARCH_ALIASES.get(domain ?? '');
const buildUrl = storeKey ? STORE_SEARCH_URL_BUILDERS[storeKey] : undefined;
return buildUrl?.(query) ?? (domain ? `https://${domain}` : undefined);
}
function buildSearchQuery(processName: string): string {
const query = processName
.replace(/\.exe$/i, '')
.replace(/\b(x64|x86|win64|win32|linux|shipping|client|launcher|game)\b/gi, ' ')
.replace(/\s+/g, ' ')
.trim();
return query.length >= MIN_SEARCH_QUERY_LENGTH ? query : '';
}
function isAcceptableRawgMatch(
query: string,
result: RawgGameResult | undefined
): result is Required<Pick<RawgGameResult, 'id' | 'name'>> & RawgGameResult {
if (!result || typeof result.id !== 'number' || typeof result.name !== 'string' || !result.name.trim()) {
return false;
}
const queryKey = normalizeComparableText(query);
const nameKey = normalizeComparableText(result.name);
const slugKey = normalizeComparableText(result.slug ?? '');
const queryTokens = tokenizeComparableText(queryKey);
const nameTokens = tokenizeComparableText(nameKey);
const slugTokens = tokenizeComparableText(slugKey);
if (queryKey.length < MIN_SEARCH_QUERY_LENGTH || queryTokens.length === 0) {
return false;
}
if (queryKey === nameKey || queryKey === slugKey) {
return true;
}
if (queryTokens.length === 1) {
const [queryToken] = queryTokens;
return queryToken.length >= 5
&& (nameTokens.includes(queryToken) || slugTokens.includes(queryToken));
}
return queryTokens.every((token) => nameTokens.includes(token) || slugTokens.includes(token));
}
function normalizeComparableText(value: string): string {
return value.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
function tokenizeComparableText(value: string): string[] {
return value.split(' ')
.filter((token) => token.length >= 2);
}
function appendMatch(
matches: MatchedGame[],
seenGameIds: Set<string>,
processName: string,
game: Omit<MatchedGame, 'processName'> | null
): void {
if (!game || seenGameIds.has(game.id)) {
return;
}
seenGameIds.add(game.id);
matches.push({
...game,
processName
});
}

View File

@@ -0,0 +1,539 @@
import { getServerById } from '../cqrs';
import { getDataSource } from '../db/database';
import {
PluginDataEntity,
ServerPluginEventDefinitionEntity,
ServerPluginEventDirection,
ServerPluginEventScope,
ServerPluginRequirementEntity,
ServerPluginRequirementStatus
} from '../entities';
import { findServerMembership } from './server-access.service';
import { resolveServerPermission } from './server-permissions.service';
export const DEFAULT_PLUGIN_EVENT_MAX_PAYLOAD_BYTES = 64 * 1024;
const VALID_REQUIREMENT_STATUSES = new Set<ServerPluginRequirementStatus>([
'required',
'optional',
'recommended',
'blocked',
'incompatible'
]);
const VALID_EVENT_DIRECTIONS = new Set<ServerPluginEventDirection>([
'clientToServer',
'serverRelay',
'p2pHint'
]);
const VALID_EVENT_SCOPES = new Set<ServerPluginEventScope>([
'server',
'channel',
'user',
'plugin'
]);
const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9.-]{1,126}[a-z0-9]$/;
const EVENT_NAME_PATTERN = /^[a-z][a-z0-9.:-]{1,126}[a-z0-9]$/;
const DATA_KEY_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/;
const DATA_SCOPE_PATTERN = /^[a-zA-Z][a-zA-Z0-9._:-]{0,63}$/;
export interface PluginRequirementSummary {
installUrl?: string;
manifest?: unknown;
pluginId: string;
reason?: string;
sourceUrl?: string;
status: ServerPluginRequirementStatus;
updatedAt: number;
versionRange?: string;
}
export interface PluginEventDefinitionSummary {
direction: ServerPluginEventDirection;
eventName: string;
maxPayloadBytes: number;
pluginId: string;
scope: ServerPluginEventScope;
schemaJson?: string;
updatedAt: number;
}
export interface PluginRequirementsSnapshot {
eventDefinitions: PluginEventDefinitionSummary[];
requirements: PluginRequirementSummary[];
serverId: string;
updatedAt: number;
}
export interface PluginDataRecord {
key: string;
ownerId?: string;
pluginId: string;
schemaVersion: number;
scope: string;
serverId: string;
updatedAt: number;
updatedBy?: string;
value: unknown;
}
export interface PluginEventEnvelope {
eventId?: string;
eventName: string;
payload: unknown;
pluginId: string;
serverId: string;
sourcePluginUserId?: string;
type: 'plugin_event';
}
export class PluginSupportError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string
) {
super(message);
this.name = 'PluginSupportError';
}
}
function requirementRepository() {
return getDataSource().getRepository(ServerPluginRequirementEntity);
}
function eventDefinitionRepository() {
return getDataSource().getRepository(ServerPluginEventDefinitionEntity);
}
function pluginDataRepository() {
return getDataSource().getRepository(PluginDataEntity);
}
function normalizeOptionalString(value: unknown, maxLength: number): string | null {
if (typeof value !== 'string') {
return null;
}
const normalized = value.trim();
return normalized ? normalized.slice(0, maxLength) : null;
}
function assertPattern(value: string, pattern: RegExp, code: string, label: string): void {
if (!pattern.test(value)) {
throw new PluginSupportError(400, code, `Invalid ${label}`);
}
}
function normalizePluginId(pluginId: unknown): string {
const normalized = normalizeOptionalString(pluginId, 128);
if (!normalized) {
throw new PluginSupportError(400, 'MISSING_PLUGIN_ID', 'Missing plugin id');
}
assertPattern(normalized, PLUGIN_ID_PATTERN, 'INVALID_PLUGIN_ID', 'plugin id');
return normalized;
}
function normalizeEventName(eventName: unknown): string {
const normalized = normalizeOptionalString(eventName, 128);
if (!normalized) {
throw new PluginSupportError(400, 'MISSING_EVENT_NAME', 'Missing event name');
}
assertPattern(normalized, EVENT_NAME_PATTERN, 'INVALID_EVENT_NAME', 'event name');
return normalized;
}
function normalizeDataKey(key: unknown): string {
const normalized = normalizeOptionalString(key, 128);
if (!normalized) {
throw new PluginSupportError(400, 'MISSING_DATA_KEY', 'Missing data key');
}
assertPattern(normalized, DATA_KEY_PATTERN, 'INVALID_DATA_KEY', 'data key');
return normalized;
}
function normalizeDataScope(scope: unknown): string {
const normalized = normalizeOptionalString(scope, 64) ?? 'server';
assertPattern(normalized, DATA_SCOPE_PATTERN, 'INVALID_DATA_SCOPE', 'data scope');
return normalized;
}
function normalizeOwnerId(ownerId: unknown): string {
return normalizeOptionalString(ownerId, 128) ?? '';
}
function parseJsonValue(valueJson: string): unknown {
try {
return JSON.parse(valueJson) as unknown;
} catch {
return null;
}
}
function parseOptionalJsonValue(valueJson: string | null): unknown {
return valueJson ? parseJsonValue(valueJson) : undefined;
}
function serializeJsonValue(value: unknown, code: string): string {
try {
return JSON.stringify(value ?? null);
} catch {
throw new PluginSupportError(400, code, 'Value must be JSON serializable');
}
}
function toRequirementSummary(entity: ServerPluginRequirementEntity): PluginRequirementSummary {
return {
installUrl: entity.installUrl ?? undefined,
manifest: parseOptionalJsonValue(entity.manifestJson),
pluginId: entity.pluginId,
reason: entity.reason ?? undefined,
sourceUrl: entity.sourceUrl ?? undefined,
status: entity.status,
updatedAt: entity.updatedAt,
versionRange: entity.versionRange ?? undefined
};
}
function toEventDefinitionSummary(entity: ServerPluginEventDefinitionEntity): PluginEventDefinitionSummary {
return {
direction: entity.direction,
eventName: entity.eventName,
maxPayloadBytes: entity.maxPayloadBytes,
pluginId: entity.pluginId,
scope: entity.scope,
schemaJson: entity.schemaJson ?? undefined,
updatedAt: entity.updatedAt
};
}
function toPluginDataRecord(entity: PluginDataEntity): PluginDataRecord {
return {
key: entity.key,
ownerId: entity.ownerId || undefined,
pluginId: entity.pluginId,
schemaVersion: entity.schemaVersion,
scope: entity.scope,
serverId: entity.serverId,
updatedAt: entity.updatedAt,
updatedBy: entity.updatedBy ?? undefined,
value: parseJsonValue(entity.valueJson)
};
}
async function assertServerExists(serverId: string) {
const server = await getServerById(serverId);
if (!server) {
throw new PluginSupportError(404, 'SERVER_NOT_FOUND', 'Server not found');
}
return server;
}
export async function assertCanManagePluginSupport(serverId: string, actorUserId: string): Promise<void> {
const server = await assertServerExists(serverId);
if (!actorUserId || !resolveServerPermission(server, actorUserId, 'manageServer')) {
throw new PluginSupportError(403, 'NOT_AUTHORIZED', 'Not authorized');
}
}
export async function assertCanUsePluginData(serverId: string, actorUserId: string): Promise<void> {
const server = await assertServerExists(serverId);
if (!actorUserId) {
throw new PluginSupportError(400, 'MISSING_USER', 'Missing user id');
}
if (server.ownerId === actorUserId) {
return;
}
const membership = await findServerMembership(serverId, actorUserId);
if (!membership) {
throw new PluginSupportError(403, 'NOT_MEMBER', 'Only joined users can access plugin data');
}
}
export async function getPluginRequirementsSnapshot(serverId: string): Promise<PluginRequirementsSnapshot> {
await assertServerExists(serverId);
const requirementQuery = requirementRepository().find({ where: { serverId } });
const eventDefinitionQuery = eventDefinitionRepository().find({ where: { serverId } });
const [requirements, eventDefinitions] = await Promise.all([requirementQuery, eventDefinitionQuery]);
const requirementSummaries = requirements
.map(toRequirementSummary)
.sort((first, second) => first.pluginId.localeCompare(second.pluginId));
const eventDefinitionSummaries = eventDefinitions
.map(toEventDefinitionSummary)
.sort((first, second) => `${first.pluginId}:${first.eventName}`.localeCompare(`${second.pluginId}:${second.eventName}`));
const updatedAt = Math.max(
0,
...requirementSummaries.map((requirement) => requirement.updatedAt),
...eventDefinitionSummaries.map((definition) => definition.updatedAt)
);
return {
eventDefinitions: eventDefinitionSummaries,
requirements: requirementSummaries,
serverId,
updatedAt
};
}
export async function upsertPluginRequirement(options: {
actorUserId: string;
installUrl?: unknown;
manifest?: unknown;
pluginId: string;
reason?: unknown;
serverId: string;
sourceUrl?: unknown;
status: unknown;
versionRange?: unknown;
}): Promise<PluginRequirementSummary> {
await assertCanManagePluginSupport(options.serverId, options.actorUserId);
const pluginId = normalizePluginId(options.pluginId);
const status = options.status;
if (!VALID_REQUIREMENT_STATUSES.has(status as ServerPluginRequirementStatus)) {
throw new PluginSupportError(400, 'INVALID_REQUIREMENT_STATUS', 'Invalid plugin requirement status');
}
const repo = requirementRepository();
const now = Date.now();
const existing = await repo.findOne({ where: { serverId: options.serverId, pluginId } });
const entity = repo.create({
serverId: options.serverId,
pluginId,
status: status as ServerPluginRequirementStatus,
versionRange: normalizeOptionalString(options.versionRange, 128),
reason: normalizeOptionalString(options.reason, 512),
installUrl: normalizeOptionalString(options.installUrl, 2_000),
sourceUrl: normalizeOptionalString(options.sourceUrl, 2_000),
manifestJson: options.manifest === undefined ? null : serializeJsonValue(options.manifest, 'INVALID_PLUGIN_MANIFEST_METADATA'),
configuredBy: options.actorUserId,
createdAt: existing?.createdAt ?? now,
updatedAt: now
});
await repo.save(entity);
return toRequirementSummary(entity);
}
export async function deletePluginRequirement(options: {
actorUserId: string;
pluginId: string;
serverId: string;
}): Promise<void> {
await assertCanManagePluginSupport(options.serverId, options.actorUserId);
await requirementRepository().delete({ serverId: options.serverId, pluginId: normalizePluginId(options.pluginId) });
}
export async function upsertPluginEventDefinition(options: {
actorUserId: string;
direction: unknown;
eventName: string;
maxPayloadBytes?: unknown;
pluginId: string;
rateLimitJson?: unknown;
schemaJson?: unknown;
scope: unknown;
serverId: string;
}): Promise<PluginEventDefinitionSummary> {
await assertCanManagePluginSupport(options.serverId, options.actorUserId);
const pluginId = normalizePluginId(options.pluginId);
const eventName = normalizeEventName(options.eventName);
const { direction, scope } = options;
if (!VALID_EVENT_DIRECTIONS.has(direction as ServerPluginEventDirection)) {
throw new PluginSupportError(400, 'INVALID_EVENT_DIRECTION', 'Invalid plugin event direction');
}
if (!VALID_EVENT_SCOPES.has(scope as ServerPluginEventScope)) {
throw new PluginSupportError(400, 'INVALID_EVENT_SCOPE', 'Invalid plugin event scope');
}
const maxPayloadBytes = typeof options.maxPayloadBytes === 'number' && Number.isFinite(options.maxPayloadBytes)
? Math.max(1, Math.min(Math.floor(options.maxPayloadBytes), DEFAULT_PLUGIN_EVENT_MAX_PAYLOAD_BYTES))
: DEFAULT_PLUGIN_EVENT_MAX_PAYLOAD_BYTES;
const repo = eventDefinitionRepository();
const now = Date.now();
const existing = await repo.findOne({ where: { serverId: options.serverId, pluginId, eventName } });
const entity = repo.create({
serverId: options.serverId,
pluginId,
eventName,
direction: direction as ServerPluginEventDirection,
scope: scope as ServerPluginEventScope,
schemaJson: normalizeOptionalString(options.schemaJson, 10_000),
maxPayloadBytes,
rateLimitJson: normalizeOptionalString(options.rateLimitJson, 2_000),
createdAt: existing?.createdAt ?? now,
updatedAt: now
});
await repo.save(entity);
return toEventDefinitionSummary(entity);
}
export async function deletePluginEventDefinition(options: {
actorUserId: string;
eventName: string;
pluginId: string;
serverId: string;
}): Promise<void> {
await assertCanManagePluginSupport(options.serverId, options.actorUserId);
await eventDefinitionRepository().delete({
serverId: options.serverId,
pluginId: normalizePluginId(options.pluginId),
eventName: normalizeEventName(options.eventName)
});
}
export async function listPluginData(options: {
actorUserId: string;
key?: unknown;
ownerId?: unknown;
pluginId: string;
scope?: unknown;
serverId: string;
}): Promise<PluginDataRecord[]> {
await assertCanUsePluginData(options.serverId, options.actorUserId);
const pluginId = normalizePluginId(options.pluginId);
const scope = options.scope === undefined ? undefined : normalizeDataScope(options.scope);
const ownerId = options.ownerId === undefined ? undefined : normalizeOwnerId(options.ownerId);
const key = options.key === undefined ? undefined : normalizeDataKey(options.key);
const query = pluginDataRepository()
.createQueryBuilder('data')
.where('data.serverId = :serverId', { serverId: options.serverId })
.andWhere('data.pluginId = :pluginId', { pluginId });
if (scope !== undefined) {
query.andWhere('data.scope = :scope', { scope });
}
if (ownerId !== undefined) {
query.andWhere('data.ownerId = :ownerId', { ownerId });
}
if (key !== undefined) {
query.andWhere('data.key = :key', { key });
}
const records = await query
.orderBy('data.scope', 'ASC')
.addOrderBy('data.ownerId', 'ASC')
.addOrderBy('data.key', 'ASC')
.getMany();
return records.map(toPluginDataRecord);
}
export async function upsertPluginData(options: {
actorUserId: string;
key: string;
ownerId?: unknown;
pluginId: string;
schemaVersion?: unknown;
scope?: unknown;
serverId: string;
value: unknown;
}): Promise<PluginDataRecord> {
await assertCanUsePluginData(options.serverId, options.actorUserId);
const pluginId = normalizePluginId(options.pluginId);
const scope = normalizeDataScope(options.scope);
const ownerId = scope === 'user' ? normalizeOwnerId(options.ownerId ?? options.actorUserId) : normalizeOwnerId(options.ownerId);
if (scope === 'user' && ownerId !== options.actorUserId) {
await assertCanManagePluginSupport(options.serverId, options.actorUserId);
}
const key = normalizeDataKey(options.key);
const schemaVersion = typeof options.schemaVersion === 'number' && Number.isFinite(options.schemaVersion)
? Math.max(1, Math.floor(options.schemaVersion))
: 1;
const repo = pluginDataRepository();
const entity = repo.create({
serverId: options.serverId,
pluginId,
scope,
ownerId,
key,
valueJson: serializeJsonValue(options.value, 'INVALID_PLUGIN_DATA'),
schemaVersion,
updatedBy: options.actorUserId,
updatedAt: Date.now()
});
await repo.save(entity);
return toPluginDataRecord(entity);
}
export async function deletePluginData(options: {
actorUserId: string;
key: string;
ownerId?: unknown;
pluginId: string;
scope?: unknown;
serverId: string;
}): Promise<void> {
await assertCanUsePluginData(options.serverId, options.actorUserId);
const pluginId = normalizePluginId(options.pluginId);
const scope = normalizeDataScope(options.scope);
const ownerId = scope === 'user' ? normalizeOwnerId(options.ownerId ?? options.actorUserId) : normalizeOwnerId(options.ownerId);
if (scope === 'user' && ownerId !== options.actorUserId) {
await assertCanManagePluginSupport(options.serverId, options.actorUserId);
}
await pluginDataRepository().delete({
serverId: options.serverId,
pluginId,
scope,
ownerId,
key: normalizeDataKey(options.key)
});
}
export async function validatePluginEventEnvelope(envelope: PluginEventEnvelope): Promise<ServerPluginEventDefinitionEntity> {
const pluginId = normalizePluginId(envelope.pluginId);
const eventName = normalizeEventName(envelope.eventName);
const definition = await eventDefinitionRepository().findOne({
where: {
serverId: envelope.serverId,
pluginId,
eventName
}
});
if (!definition) {
throw new PluginSupportError(404, 'PLUGIN_EVENT_NOT_REGISTERED', 'Plugin event is not registered for this server');
}
if (definition.direction === 'p2pHint') {
throw new PluginSupportError(400, 'PLUGIN_EVENT_NOT_RELAYABLE', 'P2P plugin events must not be relayed by the signal server');
}
const payloadBytes = Buffer.byteLength(serializeJsonValue(envelope.payload, 'INVALID_PLUGIN_EVENT_PAYLOAD'), 'utf8');
if (payloadBytes > definition.maxPayloadBytes) {
throw new PluginSupportError(413, 'PLUGIN_EVENT_TOO_LARGE', 'Plugin event payload is too large');
}
return definition;
}

View File

@@ -130,6 +130,10 @@ export async function findServerMembership(serverId: string, userId: string): Pr
return await getMembershipRepository().findOne({ where: { serverId, userId } }); return await getMembershipRepository().findOne({ where: { serverId, userId } });
} }
export async function countServerMemberships(serverId: string): Promise<number> {
return await getMembershipRepository().count({ where: { serverId } });
}
export async function ensureServerMembership(serverId: string, userId: string): Promise<ServerMembershipEntity> { export async function ensureServerMembership(serverId: string, userId: string): Promise<ServerMembershipEntity> {
const repo = getMembershipRepository(); const repo = getMembershipRepository();
const now = Date.now(); const now = Date.now();

View File

@@ -0,0 +1,221 @@
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import { WebSocket } from 'ws';
import { ConnectedUser } from './types';
import { connectedUsers } from './state';
const pluginSupportMocks = vi.hoisted(() => {
class MockPluginSupportError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string
) {
super(message);
this.name = 'PluginSupportError';
}
}
return {
getPluginRequirementsSnapshot: vi.fn(),
PluginSupportError: MockPluginSupportError,
validatePluginEventEnvelope: vi.fn()
};
});
vi.mock('../services/server-access.service', () => ({
authorizeWebSocketJoin: vi.fn(async () => ({ allowed: true as const }))
}));
vi.mock('../services/plugin-support.service', () => pluginSupportMocks);
import { handleWebSocketMessage } from './handler';
interface SentMessageStore {
sentMessages: string[];
}
function createMockWs(): WebSocket & SentMessageStore {
const sentMessages: string[] = [];
const socket = {
readyState: WebSocket.OPEN,
send: (data: string) => {
sentMessages.push(data);
},
close: () => {},
sentMessages
} as unknown as WebSocket & SentMessageStore;
return socket;
}
function createConnectedUser(
connectionId: string,
oderId: string,
overrides: Partial<ConnectedUser> = {}
): ConnectedUser {
const user: ConnectedUser = {
displayName: `User ${oderId}`,
lastPong: Date.now(),
oderId,
serverIds: new Set(),
ws: createMockWs(),
...overrides
};
connectedUsers.set(connectionId, user);
return user;
}
function readSentMessages(user: ConnectedUser): Record<string, unknown>[] {
return (user.ws as unknown as SentMessageStore).sentMessages.map((messageText) => JSON.parse(messageText) as Record<string, unknown>);
}
describe('server websocket handler - plugin support', () => {
beforeEach(() => {
connectedUsers.clear();
pluginSupportMocks.getPluginRequirementsSnapshot.mockReset();
pluginSupportMocks.validatePluginEventEnvelope.mockReset();
pluginSupportMocks.getPluginRequirementsSnapshot.mockResolvedValue({
eventDefinitions: [],
requirements: [],
serverId: 'server-1',
updatedAt: 0
});
pluginSupportMocks.validatePluginEventEnvelope.mockResolvedValue({ direction: 'serverRelay' });
});
it('sends plugin requirement snapshots after joining a server', async () => {
const alice = createConnectedUser('conn-1', 'alice');
pluginSupportMocks.getPluginRequirementsSnapshot.mockResolvedValue({
eventDefinitions: [
{
direction: 'serverRelay',
eventName: 'e2e:relay',
maxPayloadBytes: 2048,
pluginId: 'e2e.plugin-api',
scope: 'server',
updatedAt: 2
}
],
requirements: [
{
pluginId: 'e2e.plugin-api',
status: 'required',
updatedAt: 1
}
],
serverId: 'server-1',
updatedAt: 2
});
await handleWebSocketMessage('conn-1', { type: 'join_server', serverId: 'server-1' });
const messages = readSentMessages(alice);
const pluginRequirements = messages.find((message) => message['type'] === 'plugin_requirements');
expect(pluginRequirements?.['serverId']).toBe('server-1');
expect(pluginRequirements?.['snapshot']).toEqual(expect.objectContaining({ updatedAt: 2 }));
});
it('validates and relays plugin events to other joined users', async () => {
const alice = createConnectedUser('conn-1', 'alice', { viewedServerId: 'server-1' });
const bob = createConnectedUser('conn-2', 'bob', { viewedServerId: 'server-1' });
alice.serverIds.add('server-1');
bob.serverIds.add('server-1');
await handleWebSocketMessage('conn-1', {
type: 'plugin_event',
eventId: 'event-1',
eventName: 'e2e:relay',
payload: { ok: true },
pluginId: 'e2e.plugin-api',
serverId: 'server-1',
sourcePluginUserId: 'fixture-user'
});
expect(pluginSupportMocks.validatePluginEventEnvelope).toHaveBeenCalledWith({
type: 'plugin_event',
eventId: 'event-1',
eventName: 'e2e:relay',
payload: { ok: true },
pluginId: 'e2e.plugin-api',
serverId: 'server-1',
sourcePluginUserId: 'fixture-user'
});
const bobMessages = readSentMessages(bob);
const relayedEvent = bobMessages.find((message) => message['type'] === 'plugin_event');
expect(relayedEvent).toEqual(expect.objectContaining({
eventId: 'event-1',
eventName: 'e2e:relay',
pluginId: 'e2e.plugin-api',
serverId: 'server-1',
sourcePluginUserId: 'fixture-user',
sourceUserId: 'alice'
}));
expect(typeof relayedEvent?.['emittedAt']).toBe('number');
});
it('returns plugin errors for invalid plugin event messages', async () => {
const alice = createConnectedUser('conn-1', 'alice');
await handleWebSocketMessage('conn-1', {
type: 'plugin_event',
eventName: 'e2e:relay',
pluginId: 'e2e.plugin-api',
serverId: 'server-1'
});
const pluginError = readSentMessages(alice).find((message) => message['type'] === 'plugin_error');
expect(pluginError).toEqual(expect.objectContaining({
code: 'INVALID_PLUGIN_EVENT',
eventName: 'e2e:relay',
pluginId: 'e2e.plugin-api',
serverId: 'server-1'
}));
expect(pluginSupportMocks.validatePluginEventEnvelope).not.toHaveBeenCalled();
});
it('forwards plugin support validation errors to the sending user', async () => {
const alice = createConnectedUser('conn-1', 'alice', { viewedServerId: 'server-1' });
alice.serverIds.add('server-1');
pluginSupportMocks.validatePluginEventEnvelope.mockRejectedValue(new pluginSupportMocks.PluginSupportError(
400,
'PLUGIN_EVENT_NOT_RELAYABLE',
'P2P plugin events must not be relayed by the signal server'
));
await handleWebSocketMessage('conn-1', {
type: 'plugin_event',
eventId: 'event-p2p',
eventName: 'e2e:p2p',
payload: { hint: true },
pluginId: 'e2e.plugin-api',
serverId: 'server-1'
});
const pluginError = readSentMessages(alice).find((message) => message['type'] === 'plugin_error');
expect(pluginError).toEqual(expect.objectContaining({
code: 'PLUGIN_EVENT_NOT_RELAYABLE',
eventId: 'event-p2p',
eventName: 'e2e:p2p',
pluginId: 'e2e.plugin-api',
serverId: 'server-1'
}));
});
});

View File

@@ -67,6 +67,14 @@ describe('server websocket handler - status_update', () => {
connectedUsers.clear(); connectedUsers.clear();
}); });
it('treats signaling keepalive messages as connection liveness', async () => {
createConnectedUser('conn-1', 'user-1', { lastPong: 1 });
await handleWebSocketMessage('conn-1', { type: 'keepalive' });
expect(connectedUsers.get('conn-1')?.lastPong).toBeGreaterThan(1);
});
it('updates user status on valid status_update message', async () => { it('updates user status on valid status_update message', async () => {
const user = createConnectedUser('conn-1', 'user-1'); const user = createConnectedUser('conn-1', 'user-1');

View File

@@ -8,6 +8,11 @@ import {
isOderIdConnectedToServer isOderIdConnectedToServer
} from './broadcast'; } from './broadcast';
import { authorizeWebSocketJoin } from '../services/server-access.service'; import { authorizeWebSocketJoin } from '../services/server-access.service';
import {
getPluginRequirementsSnapshot,
PluginSupportError,
validatePluginEventEnvelope
} from '../services/plugin-support.service';
interface WsMessage { interface WsMessage {
[key: string]: unknown; [key: string]: unknown;
@@ -50,6 +55,29 @@ function readMessageId(value: unknown): string | undefined {
return normalized; return normalized;
} }
function sendPluginError(user: ConnectedUser, error: unknown, message: WsMessage): void {
if (error instanceof PluginSupportError) {
user.ws.send(JSON.stringify({
type: 'plugin_error',
serverId: typeof message['serverId'] === 'string' ? message['serverId'] : undefined,
pluginId: typeof message['pluginId'] === 'string' ? message['pluginId'] : undefined,
eventName: typeof message['eventName'] === 'string' ? message['eventName'] : undefined,
eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined,
code: error.code,
message: error.message
}));
return;
}
console.error('Unhandled plugin websocket error:', error);
user.ws.send(JSON.stringify({
type: 'plugin_error',
code: 'INTERNAL_ERROR',
message: 'Internal server error'
}));
}
/** Sends the current user list for a given server to a single connected user. */ /** Sends the current user list for a given server to a single connected user. */
function sendServerUsers(user: ConnectedUser, serverId: string): void { function sendServerUsers(user: ConnectedUser, serverId: string): void {
const users = getUniqueUsersInServer(serverId, user.oderId) const users = getUniqueUsersInServer(serverId, user.oderId)
@@ -64,6 +92,20 @@ function sendServerUsers(user: ConnectedUser, serverId: string): void {
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users })); user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
} }
async function sendPluginRequirements(user: ConnectedUser, serverId: string): Promise<void> {
try {
const snapshot = await getPluginRequirementsSnapshot(serverId);
user.ws.send(JSON.stringify({
type: 'plugin_requirements',
serverId,
snapshot
}));
} catch (error) {
sendPluginError(user, error, { type: 'plugin_requirements', serverId });
}
}
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void { function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
const newOderId = readMessageId(message['oderId']) ?? connectionId; const newOderId = readMessageId(message['oderId']) ?? connectionId;
const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined; const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined;
@@ -71,25 +113,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));
@@ -156,6 +179,7 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
); );
sendServerUsers(user, sid); sendServerUsers(user, sid);
await sendPluginRequirements(user, sid);
if (isNewIdentityMembership) { if (isNewIdentityMembership) {
broadcastToServer(sid, { broadcastToServer(sid, {
@@ -170,17 +194,22 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
} }
} }
function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId: string): void { async function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
const viewSid = readMessageId(message['serverId']); const viewSid = readMessageId(message['serverId']);
if (!viewSid) if (!viewSid)
return; return;
if (!user.serverIds.has(viewSid)) {
return;
}
user.viewedServerId = viewSid; user.viewedServerId = viewSid;
connectedUsers.set(connectionId, user); connectedUsers.set(connectionId, user);
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) viewing server ${viewSid}`); console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) viewing server ${viewSid}`);
sendServerUsers(user, viewSid); sendServerUsers(user, viewSid);
await sendPluginRequirements(user, viewSid);
} }
function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId: string): void { function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
@@ -287,13 +316,65 @@ function handleStatusUpdate(user: ConnectedUser, message: WsMessage, connectionI
} }
} }
async function handlePluginEvent(user: ConnectedUser, message: WsMessage): Promise<void> {
const serverId = readMessageId(message['serverId']) ?? user.viewedServerId;
const pluginId = readMessageId(message['pluginId']);
const eventName = readMessageId(message['eventName']);
if (!serverId || !pluginId || !eventName || !user.serverIds.has(serverId)) {
user.ws.send(JSON.stringify({
type: 'plugin_error',
serverId,
pluginId,
eventName,
eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined,
code: 'INVALID_PLUGIN_EVENT',
message: 'Plugin event is missing required fields or server membership'
}));
return;
}
try {
await validatePluginEventEnvelope({
type: 'plugin_event',
serverId,
pluginId,
eventName,
eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined,
payload: message['payload'],
sourcePluginUserId: typeof message['sourcePluginUserId'] === 'string' ? message['sourcePluginUserId'] : undefined
});
broadcastToServer(serverId, {
type: 'plugin_event',
serverId,
pluginId,
eventName,
eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined,
payload: message['payload'],
sourcePluginUserId: typeof message['sourcePluginUserId'] === 'string' ? message['sourcePluginUserId'] : undefined,
sourceUserId: user.oderId,
emittedAt: Date.now()
}, user.oderId);
} catch (error) {
sendPluginError(user, error, message);
}
}
export async function handleWebSocketMessage(connectionId: string, message: WsMessage): Promise<void> { export async function handleWebSocketMessage(connectionId: string, message: WsMessage): Promise<void> {
const user = connectedUsers.get(connectionId); const user = connectedUsers.get(connectionId);
if (!user) if (!user)
return; return;
user.lastPong = Date.now();
connectedUsers.set(connectionId, user);
switch (message.type) { switch (message.type) {
case 'keepalive':
break;
case 'identify': case 'identify':
handleIdentify(user, message, connectionId); handleIdentify(user, message, connectionId);
break; break;
@@ -303,7 +384,7 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
break; break;
case 'view_server': case 'view_server':
handleViewServer(user, message, connectionId); await handleViewServer(user, message, connectionId);
break; break;
case 'leave_server': case 'leave_server':
@@ -328,6 +409,10 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
handleStatusUpdate(user, message, connectionId); handleStatusUpdate(user, message, connectionId);
break; break;
case 'plugin_event':
await handlePluginEvent(user, message);
break;
default: default:
console.log('Unknown message type:', message.type); console.log('Unknown message type:', message.type);
} }

View File

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

View File

@@ -17,6 +17,6 @@ export interface ConnectedUser {
connectionScope?: string; connectionScope?: string;
/** User availability status (online, away, busy, offline). */ /** User availability status (online, away, busy, offline). */
status?: 'online' | 'away' | 'busy' | 'offline'; status?: 'online' | 'away' | 'busy' | 'offline';
/** Timestamp of the last pong received (used to detect dead connections). */ /** Timestamp of the last pong or client message received (used to detect dead connections). */
lastPong: number; lastPong: number;
} }

View File

@@ -96,13 +96,13 @@
"budgets": [ "budgets": [
{ {
"type": "initial", "type": "initial",
"maximumWarning": "2.2MB", "maximumWarning": "10mb",
"maximumError": "2.35MB" "maximumError": "20mb"
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",
"maximumWarning": "4kB", "maximumWarning": "10mb",
"maximumError": "8kB" "maximumError": "20mb"
} }
], ],
"outputHashing": "all" "outputHashing": "all"

View File

@@ -0,0 +1,3 @@
# E2E All API Plugin
Fixture plugin for Playwright coverage. It calls every public Toju plugin API surface, registers UI contributions, writes storage, publishes events, creates plugin user data, and logs completion.

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="E2E plugin icon">
<rect width="64" height="64" rx="12" fill="#111827" />
<path d="M18 22h28v20H18z" fill="#38bdf8" />
<path d="M24 16h16v6H24zM24 42h16v6H24z" fill="#a7f3d0" />
<path d="M25 30h14v4H25z" fill="#111827" />
</svg>

After

Width:  |  Height:  |  Size: 319 B

View File

@@ -0,0 +1,293 @@
const tinyWave = 'data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAESsAACJWAAACABAAZGF0YQAAAAA=';
const originalMessage = 'Plugin API original message';
const editedMessage = 'Plugin API edited message';
const deletedMessage = 'Plugin API deleted message';
const embedMessage = 'toju:embed:e2e.coverage:{"title":"Plugin API custom embed","body":"Rendered by plugin API"}';
const soundboardPlayedMessage = 'E2E soundboard played Airhorn to voice channel';
export async function activate(context) {
const api = context.api;
const currentUser = api.profile.getCurrent();
const shouldMutateChat = !currentUser?.displayName?.includes('Bob');
const pluginUserId = api.server.registerPluginUser({
displayName: 'E2E Plugin Bot',
id: 'e2e-plugin-bot'
});
context.subscriptions.push(api.ui.registerSettingsPage('coverage', {
label: 'E2E Coverage',
render: () => 'E2E settings contribution'
}));
context.subscriptions.push(api.ui.registerAppPage('coverage', {
label: 'E2E Page',
path: '/plugins/e2e/coverage',
render: () => 'E2E page contribution'
}));
context.subscriptions.push(api.ui.registerSidePanel('coverage', {
label: 'E2E Soundboard',
render: () => 'E2E soundboard ready'
}));
context.subscriptions.push(api.ui.registerChannelSection('coverage', {
label: 'E2E Soundboard',
type: 'custom'
}));
context.subscriptions.push(api.ui.registerComposerAction('coverage', {
icon: 'SFX',
label: 'E2E Soundboard',
run: () => openSoundboardModal(api, pluginUserId)
}));
context.subscriptions.push(api.ui.registerProfileAction('coverage', {
label: 'E2E Profile',
run: () => api.logger.info('profile action ran')
}));
context.subscriptions.push(api.ui.registerToolbarAction('coverage', {
label: 'E2E Toolbar',
run: () => api.logger.info('toolbar action ran')
}));
context.subscriptions.push(api.ui.registerEmbedRenderer('coverage', {
embedType: 'e2e.coverage',
render: (payload) => `E2E custom embed: ${payload?.title ?? 'missing title'}`
}));
const injectedBadge = document.createElement('div');
injectedBadge.dataset.testid = 'e2e-plugin-owned-dom';
injectedBadge.textContent = 'E2E plugin-owned DOM injected into chat';
injectedBadge.style.position = 'absolute';
injectedBadge.style.left = '1rem';
injectedBadge.style.bottom = '5.5rem';
injectedBadge.style.zIndex = '20';
injectedBadge.style.border = '1px solid hsl(var(--border))';
injectedBadge.style.borderRadius = '0.5rem';
injectedBadge.style.padding = '0.35rem 0.5rem';
injectedBadge.style.background = 'hsl(var(--card))';
injectedBadge.style.color = 'hsl(var(--foreground))';
injectedBadge.style.fontSize = '0.75rem';
context.subscriptions.push(api.ui.mountElement('chat-owned-badge', {
element: injectedBadge,
target: 'app-chat-messages'
}));
context.subscriptions.push(api.events.subscribeServer({ eventName: 'e2e:server', handler: () => {} }));
context.subscriptions.push(api.events.subscribeP2p({ eventName: 'e2e:p2p', handler: () => {} }));
api.storage.set('coverage', { ok: true });
api.storage.get('coverage');
await api.clientData.write('coverage', { ok: true });
await api.clientData.read('coverage');
await api.serverData.write('coverage', { ok: true });
await api.serverData.read('coverage');
api.profile.update({
description: 'Updated by E2E plugin',
displayName: `${currentUser?.displayName || 'E2E Plugin User'} Plugin Renamed`
});
api.profile.updateAvatar({
avatarHash: 'e2e-plugin-avatar',
avatarMime: 'image/svg+xml',
avatarUrl: '/plugins/e2e-all-api/icon.svg'
});
api.users.getCurrent();
api.users.list();
api.users.readMembers();
api.users.setRole(pluginUserId, 'member');
api.users.kick(pluginUserId);
api.users.ban(pluginUserId, 'E2E coverage');
api.roles.list();
api.roles.setAssignments([]);
api.channels.list();
api.channels.addAudioChannel({ id: 'e2e-audio', name: 'E2E Audio', position: 90 });
api.channels.addVideoChannel({ id: 'e2e-video', name: 'E2E Video', position: 91 });
api.channels.select('general');
api.channels.rename('e2e-audio', 'E2E Audio Renamed');
api.server.getCurrent();
api.server.updatePermissions({ allowVoice: true });
api.server.updateSettings({
name: api.server.getCurrent()?.name,
topic: 'Updated by E2E plugin'
});
api.messages.readCurrent();
if (shouldMutateChat) {
const sentMessage = api.messages.send(originalMessage);
api.messages.edit(sentMessage.id, editedMessage);
const removableMessage = api.messages.send(deletedMessage);
api.messages.delete(removableMessage.id);
api.messages.send(embedMessage);
}
api.messages.sendAsPluginUser({
content: 'Plugin bot message from all-api fixture',
pluginUserId
});
api.messages.moderateDelete('missing-message-id');
api.messages.sync(api.messages.readCurrent());
context.subscriptions.push(api.messageBus.subscribe({
handler: () => {},
latestMessageLimit: 5,
replayLatest: true,
topic: 'e2e:latest'
}));
api.messageBus.publish({
includeLatestMessages: true,
includeSelf: true,
latestMessageLimit: 5,
payload: { ok: true },
topic: 'e2e:latest'
});
api.messageBus.sendLatestMessages({
limit: 5,
topic: 'e2e:latest'
});
api.p2p.connectedPeers();
api.p2p.broadcastData('e2e:p2p', { ok: true });
api.p2p.sendData('missing-peer', 'e2e:p2p', { ok: true });
api.events.publishServer('e2e:server', { ok: true });
api.events.publishP2p('e2e:p2p', { ok: true });
api.media.setOutputVolume(0.8);
api.media.setInputVolume(0.8);
await api.media.playAudioClip({ url: tinyWave, volume: 0 }).catch((error) => api.logger.warn('audio clip rejected', String(error)));
await api.media.addCustomVideoStream({ label: 'e2e-video', stream: new MediaStream() });
const audioContext = new AudioContext();
const destination = audioContext.createMediaStreamDestination();
await api.media.addCustomAudioStream({ label: 'e2e-audio', stream: destination.stream }).catch((error) => api.logger.warn('audio stream rejected', String(error)));
await audioContext.close();
api.storage.remove('coverage');
await api.clientData.remove('coverage');
await api.serverData.remove('coverage');
api.logger.info('all-api plugin completed');
}
export function ready(context) {
context.api.logger.info('all-api plugin ready');
}
export function deactivate(context) {
context.api.logger.info('all-api plugin deactivated');
}
function openSoundboardModal(api, pluginUserId) {
document.querySelector('[data-testid="e2e-soundboard-modal"]')?.remove();
const overlay = document.createElement('div');
overlay.dataset.testid = 'e2e-soundboard-modal';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-label', 'E2E Soundboard');
overlay.style.position = 'fixed';
overlay.style.inset = '0';
overlay.style.zIndex = '9999';
overlay.style.display = 'grid';
overlay.style.placeItems = 'center';
overlay.style.background = 'rgb(0 0 0 / 0.45)';
const panel = document.createElement('section');
panel.style.width = 'min(24rem, calc(100vw - 2rem))';
panel.style.border = '1px solid hsl(var(--border))';
panel.style.borderRadius = '0.5rem';
panel.style.padding = '1rem';
panel.style.color = 'hsl(var(--foreground))';
panel.style.background = 'hsl(var(--card))';
panel.style.boxShadow = '0 1.25rem 3rem rgb(0 0 0 / 0.25)';
const title = document.createElement('h2');
title.textContent = 'E2E Soundboard';
title.style.margin = '0 0 0.75rem';
title.style.fontSize = '1rem';
const status = document.createElement('p');
status.dataset.testid = 'e2e-soundboard-status';
status.textContent = 'Ready to play to voice channel';
status.style.margin = '0 0 1rem';
status.style.color = 'hsl(var(--muted-foreground))';
status.style.fontSize = '0.875rem';
const actions = document.createElement('div');
actions.style.display = 'flex';
actions.style.gap = '0.5rem';
actions.style.justifyContent = 'flex-end';
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.textContent = 'Close';
closeButton.style.border = '1px solid hsl(var(--border))';
closeButton.style.borderRadius = '0.375rem';
closeButton.style.padding = '0.5rem 0.75rem';
closeButton.style.background = 'transparent';
closeButton.style.color = 'hsl(var(--foreground))';
closeButton.addEventListener('click', () => overlay.remove());
const playButton = document.createElement('button');
playButton.type = 'button';
playButton.textContent = 'Play airhorn to voice';
playButton.style.border = '0';
playButton.style.borderRadius = '0.375rem';
playButton.style.padding = '0.5rem 0.75rem';
playButton.style.background = 'hsl(var(--primary))';
playButton.style.color = 'hsl(var(--primary-foreground))';
playButton.addEventListener('click', async () => {
playButton.disabled = true;
status.textContent = 'Playing Airhorn to voice channel';
try {
await playSoundboardClipToVoice(api);
api.p2p.broadcastData('e2e:p2p', { sound: 'airhorn', source: 'soundboard' });
api.events.publishP2p('e2e:p2p', { sound: 'airhorn', source: 'soundboard' });
api.messages.sendAsPluginUser({ content: soundboardPlayedMessage, pluginUserId });
api.logger.info('soundboard played to voice channel');
status.textContent = soundboardPlayedMessage;
} catch (error) {
status.textContent = error instanceof Error ? error.message : 'Soundboard playback failed';
api.logger.warn('soundboard playback failed', String(error));
} finally {
playButton.disabled = false;
}
});
actions.append(closeButton, playButton);
panel.append(title, status, actions);
overlay.append(panel);
api.ui.mountElement('soundboard-modal', {
element: overlay,
target: 'body'
});
}
async function playSoundboardClipToVoice(api) {
const audioContext = new AudioContext();
const oscillator = audioContext.createOscillator();
const gain = audioContext.createGain();
const destination = audioContext.createMediaStreamDestination();
oscillator.type = 'square';
oscillator.frequency.value = 330;
gain.gain.value = 0.08;
oscillator.connect(gain);
gain.connect(destination);
oscillator.start();
await api.media.addCustomAudioStream({ label: 'e2e-soundboard-airhorn', stream: destination.stream });
await api.media.playAudioClip({ url: tinyWave, volume: 0 }).catch((error) => api.logger.warn('soundboard preview rejected', String(error)));
await new Promise((resolve) => setTimeout(resolve, 150));
oscillator.stop();
await audioContext.close();
}

View File

@@ -0,0 +1,100 @@
{
"schemaVersion": 1,
"id": "e2e.all-api-plugin",
"title": "E2E All API Plugin",
"description": "Calls every public Toju plugin API surface for user-facing Playwright coverage.",
"version": "1.0.0",
"kind": "client",
"scope": "server",
"apiVersion": "1.0.0",
"compatibility": {
"minimumTojuVersion": "1.0.0",
"verifiedTojuVersion": "1.0.0"
},
"entrypoint": "./main.js",
"authors": [
{
"name": "MetoYou Tests",
"url": "https://git.azaaxin.com/myxelium/Toju"
}
],
"homepage": "https://git.azaaxin.com/myxelium/Toju",
"readme": "./README.md",
"capabilities": [
"profile.read",
"profile.write",
"users.read",
"users.manage",
"roles.read",
"roles.manage",
"messages.read",
"messages.send",
"messages.editOwn",
"messages.deleteOwn",
"messages.moderate",
"messages.sync",
"channels.read",
"channels.manage",
"server.read",
"server.manage",
"p2p.data",
"p2p.media",
"media.playAudio",
"media.addAudioStream",
"media.addVideoStream",
"audio.volume",
"audio.effects",
"ui.settings",
"ui.pages",
"ui.sidePanel",
"ui.channelsSection",
"ui.embeds",
"ui.dom",
"storage.local",
"storage.serverData.read",
"storage.serverData.write",
"events.server.publish",
"events.server.subscribe",
"events.p2p.publish",
"events.p2p.subscribe"
],
"events": [
{
"eventName": "e2e:server",
"direction": "serverRelay",
"scope": "server",
"maxPayloadBytes": 2048
},
{
"eventName": "e2e:p2p",
"direction": "p2pHint",
"scope": "user",
"maxPayloadBytes": 2048
}
],
"data": [
{
"key": "coverage",
"scope": "server",
"storage": "serverData"
}
],
"settings": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"default": true
}
}
},
"ui": {
"settingsPages": ["coverage"],
"sidePanels": ["coverage"],
"channelSections": ["coverage"]
},
"pluginUser": {
"displayName": "E2E Plugin Bot",
"label": "All API fixture"
}
}

View File

@@ -0,0 +1,18 @@
{
"title": "MetoYou E2E Plugin Source",
"plugins": [
{
"id": "e2e.all-api-plugin",
"title": "E2E All API Plugin",
"description": "Test plugin that calls every public Toju plugin API surface.",
"version": "1.0.0",
"scope": "server",
"author": "MetoYou Tests",
"image": "./e2e-all-api/icon.svg",
"github": "https://git.azaaxin.com/myxelium/Toju",
"homepage": "https://git.azaaxin.com/myxelium/Toju",
"install": "./e2e-all-api/toju.plugin.json",
"readme": "./e2e-all-api/README.md"
}
]
}

View File

@@ -145,7 +145,7 @@
</button> </button>
</div> </div>
</div> </div>
} @if (!isThemeStudioFullscreen()) { } @if (!isThemeStudioFullscreen() && !isDirectMessageRoute()) {
<app-floating-voice-controls /> <app-floating-voice-controls />
} }
<app-settings-modal /> <app-settings-modal />

View File

@@ -34,9 +34,29 @@ export const routes: Routes = [
loadComponent: () => loadComponent: () =>
import('./features/room/chat-room/chat-room.component').then((module) => module.ChatRoomComponent) import('./features/room/chat-room/chat-room.component').then((module) => module.ChatRoomComponent)
}, },
{
path: 'dm',
loadComponent: () =>
import('./domains/direct-message/feature/dm-workspace/dm-workspace.component').then((module) => module.DmWorkspaceComponent)
},
{
path: 'dm/:conversationId',
loadComponent: () =>
import('./domains/direct-message/feature/dm-workspace/dm-workspace.component').then((module) => module.DmWorkspaceComponent)
},
{ {
path: 'settings', path: 'settings',
loadComponent: () => loadComponent: () =>
import('./features/settings/settings.component').then((module) => module.SettingsComponent) import('./features/settings/settings.component').then((module) => module.SettingsComponent)
},
{
path: 'plugin-store',
loadComponent: () =>
import('./domains/plugins/feature/plugin-store/plugin-store.component').then((module) => module.PluginStoreComponent)
},
{
path: 'plugins/:pluginId/:pageId',
loadComponent: () =>
import('./domains/plugins/feature/plugin-page-host/plugin-page-host.component').then((module) => module.PluginPageHostComponent)
} }
]; ];

View File

@@ -34,6 +34,7 @@ import { ExternalLinkService } from './core/platform';
import { SettingsModalService } from './core/services/settings-modal.service'; import { SettingsModalService } from './core/services/settings-modal.service';
import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service';
import { UserStatusService } from './core/services/user-status.service'; import { UserStatusService } from './core/services/user-status.service';
import { GameActivityService } from './domains/game-activity';
import { ServersRailComponent } from './features/servers/servers-rail/servers-rail.component'; import { ServersRailComponent } from './features/servers/servers-rail/servers-rail.component';
import { TitleBarComponent } from './features/shell/title-bar/title-bar.component'; import { TitleBarComponent } from './features/shell/title-bar/title-bar.component';
import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component'; import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component';
@@ -44,7 +45,8 @@ import { NativeContextMenuComponent } from './features/shell/native-context-menu
import { UsersActions } from './store/users/users.actions'; import { UsersActions } from './store/users/users.actions';
import { RoomsActions } from './store/rooms/rooms.actions'; import { RoomsActions } from './store/rooms/rooms.actions';
import { selectCurrentRoom } from './store/rooms/rooms.selectors'; import { selectCurrentRoom } from './store/rooms/rooms.selectors';
import { ROOM_URL_PATTERN, STORAGE_KEY_CURRENT_USER_ID } from './core/constants'; import { ROOM_URL_PATTERN } from './core/constants';
import { clearStoredCurrentUserId, getStoredCurrentUserId } from './core/storage/current-user-storage';
import { import {
ThemeNodeDirective, ThemeNodeDirective,
ThemePickerOverlayComponent, ThemePickerOverlayComponent,
@@ -94,10 +96,12 @@ 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);
readonly isDraggingThemeStudioControls = signal(false); readonly isDraggingThemeStudioControls = signal(false);
readonly currentRouteUrl = signal(this.getCurrentRouteUrl());
readonly appShellLayoutStyles = computed(() => this.theme.getLayoutContainerStyles('appShell')); readonly appShellLayoutStyles = computed(() => this.theme.getLayoutContainerStyles('appShell'));
readonly serversRailLayoutStyles = computed(() => this.theme.getLayoutItemStyles('serversRail')); readonly serversRailLayoutStyles = computed(() => this.theme.getLayoutItemStyles('serversRail'));
@@ -111,6 +115,7 @@ export class App implements OnInit, OnDestroy {
return this.settingsModal.activePage() === 'theme' return this.settingsModal.activePage() === 'theme'
&& this.settingsModal.themeStudioMinimized(); && this.settingsModal.themeStudioMinimized();
}); });
readonly isDirectMessageRoute = computed(() => this.getRoutePath(this.currentRouteUrl()).startsWith('/dm'));
readonly desktopUpdateNoticeKey = computed(() => { readonly desktopUpdateNoticeKey = computed(() => {
const updateState = this.desktopUpdateState(); const updateState = this.desktopUpdateState();
@@ -219,8 +224,19 @@ export class App implements OnInit, OnDestroy {
void this.desktopUpdates.initialize(); void this.desktopUpdates.initialize();
let currentUserId = getStoredCurrentUserId();
await this.databaseService.initialize(); await this.databaseService.initialize();
if (currentUserId) {
const persistedUserId = await this.databaseService.getCurrentUserId();
if (persistedUserId !== currentUserId) {
clearStoredCurrentUserId();
currentUserId = null;
}
}
try { try {
const apiBase = this.servers.getApiBaseUrl(); const apiBase = this.servers.getApiBaseUrl();
@@ -231,31 +247,29 @@ export class App implements OnInit, OnDestroy {
await this.setupDesktopDeepLinks(); await this.setupDesktopDeepLinks();
this.store.dispatch(UsersActions.loadCurrentUser());
this.userStatus.start(); this.userStatus.start();
this.gameActivity.start();
this.store.dispatch(RoomsActions.loadRooms()); const currentUrl = this.getCurrentRouteUrl();
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
if (!currentUserId) { if (!currentUserId) {
if (!this.isPublicRoute(this.router.url)) { if (!this.isPublicRoute(currentUrl)) {
this.router.navigate(['/login'], { this.router.navigate(['/login'], {
queryParams: { queryParams: {
returnUrl: this.router.url returnUrl: currentUrl
} }
}).catch(() => {}); }).catch(() => {});
} }
} else { } else {
const current = this.router.url; this.store.dispatch(UsersActions.loadCurrentUser());
this.store.dispatch(RoomsActions.loadRooms());
const generalSettings = loadGeneralSettingsFromStorage(); const generalSettings = loadGeneralSettingsFromStorage();
const lastViewedChat = loadLastViewedChatFromStorage(currentUserId); const lastViewedChat = loadLastViewedChatFromStorage(currentUserId);
if ( if (
generalSettings.reopenLastViewedChat generalSettings.reopenLastViewedChat
&& lastViewedChat && lastViewedChat
&& (current === '/' || current === '/search') && (currentUrl === '/' || currentUrl === '/search')
) { ) {
this.router.navigate(['/room', lastViewedChat.roomId], { replaceUrl: true }).catch(() => {}); this.router.navigate(['/room', lastViewedChat.roomId], { replaceUrl: true }).catch(() => {});
} }
@@ -264,6 +278,8 @@ export class App implements OnInit, OnDestroy {
this.router.events.subscribe((evt) => { this.router.events.subscribe((evt) => {
if (evt instanceof NavigationEnd) { if (evt instanceof NavigationEnd) {
const url = evt.urlAfterRedirects || evt.url; const url = evt.urlAfterRedirects || evt.url;
this.currentRouteUrl.set(url);
const roomMatch = url.match(ROOM_URL_PATTERN); const roomMatch = url.match(ROOM_URL_PATTERN);
const currentRoomId = roomMatch ? roomMatch[1] : null; const currentRoomId = roomMatch ? roomMatch[1] : null;
@@ -388,9 +404,31 @@ export class App implements OnInit, OnDestroy {
} }
private isPublicRoute(url: string): boolean { private isPublicRoute(url: string): boolean {
return url === '/login' || const path = this.getRoutePath(url);
url === '/register' ||
url.startsWith('/invite/'); return path === '/login' ||
path === '/register' ||
path.startsWith('/invite/');
}
private getCurrentRouteUrl(): string {
if (typeof window === 'undefined') {
return this.router.url;
}
const currentUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`;
return currentUrl || this.router.url;
}
private getRoutePath(url: string): string {
if (!url) {
return '/';
}
const [path] = url.split(/[?#]/, 1);
return path || '/';
} }
private parseDesktopInviteUrl(url: string): { inviteId: string; sourceUrl: string } | null { private parseDesktopInviteUrl(url: string): { inviteId: string; sourceUrl: string } | null {

View File

@@ -49,4 +49,20 @@ export type {
ChatAttachmentMeta ChatAttachmentMeta
} from '../../shared-kernel'; } from '../../shared-kernel';
export type {
PluginCapabilityId,
PluginDataChangedMessage,
PluginErrorMessage,
PluginEventDefinitionSummary,
PluginEventDirection,
PluginEventEnvelope,
PluginEventScope,
PluginRequirementStatus,
PluginRequirementSummary,
PluginRequirementsChangedMessage,
PluginRequirementsMessage,
PluginRequirementsSnapshot,
TojuPluginManifest
} from '../../shared-kernel';
export type { ServerInfo } from '../../domains/server-directory'; export type { ServerInfo } from '../../domains/server-directory';

View File

@@ -124,6 +124,46 @@ export interface SavedThemeFileDescriptor {
path: string; path: string;
} }
export interface LocalPluginManifestDescriptor {
discoveredAt: number;
entrypointPath?: string;
pluginRootUrl: string;
manifest: unknown;
manifestPath: string;
pluginRoot: string;
readmePath?: string;
}
export interface LocalPluginDiscoveryError {
manifestPath?: string;
message: string;
pluginRoot?: string;
}
export interface LocalPluginDiscoveryResult {
errors: LocalPluginDiscoveryError[];
plugins: LocalPluginManifestDescriptor[];
pluginsPath: string;
}
export interface ExportUserDataResult {
cancelled: boolean;
exported: boolean;
filePath?: string;
}
export interface ImportUserDataResult {
backupPath?: string;
cancelled: boolean;
imported: boolean;
restartRequired: boolean;
}
export interface EraseUserDataResult {
erased: boolean;
restartRequired: boolean;
}
export interface ElectronCommand { export interface ElectronCommand {
type: string; type: string;
payload: unknown; payload: unknown;
@@ -157,6 +197,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>;
@@ -165,7 +206,13 @@ export interface ElectronApi {
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void; onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void; onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
getAppDataPath: () => Promise<string>; getAppDataPath: () => Promise<string>;
openCurrentDataFolder: () => Promise<boolean>;
exportUserData: () => Promise<ExportUserDataResult>;
importUserData: () => Promise<ImportUserDataResult>;
eraseUserData: () => Promise<EraseUserDataResult>;
getSavedThemesPath: () => Promise<string>; getSavedThemesPath: () => Promise<string>;
getLocalPluginsPath: () => Promise<string>;
listLocalPluginManifests: () => Promise<LocalPluginDiscoveryResult>;
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>; listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
readSavedTheme: (fileName: string) => Promise<string>; readSavedTheme: (fileName: string) => Promise<string>;
writeSavedTheme: (fileName: string, text: string) => Promise<boolean>; writeSavedTheme: (fileName: string, text: string) => Promise<boolean>;

View File

@@ -2,13 +2,16 @@ import { Injectable, signal } from '@angular/core';
export type SettingsPage = export type SettingsPage =
| 'general' | 'general'
| 'plugins'
| 'theme' | 'theme'
| 'network' | 'network'
| 'notifications' | 'notifications'
| 'voice' | 'voice'
| 'updates' | 'updates'
| 'data'
| 'debugging' | 'debugging'
| 'server' | 'server'
| 'serverPlugins'
| 'members' | 'members'
| 'bans' | 'bans'
| 'permissions'; | 'permissions';

View File

@@ -0,0 +1,59 @@
import { STORAGE_KEY_CURRENT_USER_ID } from '../constants';
const METOYOU_STORAGE_PREFIX = 'metoyou_';
function normaliseStorageUserId(userId?: string | null): string | null {
const trimmedUserId = userId?.trim();
return trimmedUserId || null;
}
export function getStoredCurrentUserId(): string | null {
try {
const raw = normaliseStorageUserId(localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID));
return raw || null;
} catch {
return null;
}
}
export function getUserScopedStorageKey(baseKey: string, userId?: string | null): string {
const scopedUserId = userId === undefined
? getStoredCurrentUserId()
: normaliseStorageUserId(userId);
return scopedUserId
? `${baseKey}__${encodeURIComponent(scopedUserId)}`
: baseKey;
}
export function setStoredCurrentUserId(userId: string): void {
try {
localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, userId);
} catch {}
}
export function clearStoredCurrentUserId(): void {
try {
localStorage.removeItem(STORAGE_KEY_CURRENT_USER_ID);
} catch {}
}
export function clearStoredLocalAppData(): void {
try {
const keysToRemove: string[] = [];
for (let index = 0; index < localStorage.length; index += 1) {
const key = localStorage.key(index);
if (key?.startsWith(METOYOU_STORAGE_PREFIX)) {
keysToRemove.push(key);
}
}
for (const key of keysToRemove) {
localStorage.removeItem(key);
}
} catch {}
}

View File

@@ -12,7 +12,10 @@ infrastructure adapters and UI.
| **access-control** | Role, permission, ban matching, moderation, and room access rules | `normalizeRoomAccessControl()`, `resolveRoomPermission()`, `hasRoomBanForUser()` | | **access-control** | Role, permission, ban matching, moderation, and room access rules | `normalizeRoomAccessControl()`, `resolveRoomPermission()`, `hasRoomBanForUser()` |
| **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` |
| **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` |
| **plugins** | Client-only plugin manifests, load ordering, registry state, and signal-server support metadata | `PluginHostService`, `PluginRegistryService` |
| **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` |
| **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` | | **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` |
@@ -28,7 +31,9 @@ The larger domains also keep longer design notes in their own folders:
- [access-control/README.md](access-control/README.md) - [access-control/README.md](access-control/README.md)
- [authentication/README.md](authentication/README.md) - [authentication/README.md](authentication/README.md)
- [chat/README.md](chat/README.md) - [chat/README.md](chat/README.md)
- [direct-message/README.md](direct-message/README.md)
- [notifications/README.md](notifications/README.md) - [notifications/README.md](notifications/README.md)
- [plugins/README.md](plugins/README.md)
- [profile-avatar/README.md](profile-avatar/README.md) - [profile-avatar/README.md](profile-avatar/README.md)
- [screen-share/README.md](screen-share/README.md) - [screen-share/README.md](screen-share/README.md)
- [server-directory/README.md](server-directory/README.md) - [server-directory/README.md](server-directory/README.md)

View File

@@ -24,7 +24,7 @@ authentication/
## Service overview ## Service overview
`AuthenticationService` resolves the API base URL from `ServerDirectoryFacade`, then makes POST requests for login and registration. It does not hold session state itself; after a successful login the calling component stores `currentUserId` in localStorage and dispatches `UsersActions.setCurrentUser` into the NgRx store. `AuthenticationService` resolves the API base URL from `ServerDirectoryFacade`, then makes POST requests for login and registration. It does not hold session state itself; after a successful login the calling component dispatches `UsersActions.authenticateUser`, and the users effects prepare the local persistence boundary before exposing the new user in the NgRx store.
```mermaid ```mermaid
graph TD graph TD
@@ -58,6 +58,7 @@ sequenceDiagram
participant SD as ServerDirectoryFacade participant SD as ServerDirectoryFacade
participant API as Server API participant API as Server API
participant Store as NgRx Store participant Store as NgRx Store
participant Effects as UsersEffects
User->>Login: Submit credentials User->>Login: Submit credentials
Login->>Auth: login(username, password) Login->>Auth: login(username, password)
@@ -66,13 +67,15 @@ sequenceDiagram
Auth->>API: POST /api/auth/login Auth->>API: POST /api/auth/login
API-->>Auth: { userId, displayName } API-->>Auth: { userId, displayName }
Auth-->>Login: success Auth-->>Login: success
Login->>Store: UsersActions.setCurrentUser Login->>Store: UsersActions.authenticateUser
Login->>Login: localStorage.setItem(currentUserId) Store->>Effects: prepare persisted user scope
Effects->>Store: reset stale room/user/message state
Effects->>Store: UsersActions.setCurrentUser
``` ```
## Registration flow ## Registration flow
Registration follows the same pattern but posts to `/api/auth/register` with an additional `displayName` field. On success the user is treated as logged in and the same store dispatch happens. Registration follows the same pattern but posts to `/api/auth/register` with an additional `displayName` field. On success the user is treated as logged in and the same authenticated-user transition runs, switching the browser persistence layer to that user's local scope before the app reloads rooms and user state.
## User bar ## User bar

View File

@@ -15,7 +15,6 @@ import { AuthenticationService } from '../../application/services/authentication
import { ServerDirectoryFacade } from '../../../server-directory'; import { ServerDirectoryFacade } from '../../../server-directory';
import { UsersActions } from '../../../../store/users/users.actions'; import { UsersActions } from '../../../../store/users/users.actions';
import { User } from '../../../../shared-kernel'; import { User } from '../../../../shared-kernel';
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../../core/constants';
@Component({ @Component({
selector: 'app-login', selector: 'app-login',
@@ -70,9 +69,7 @@ export class LoginComponent {
joinedAt: Date.now() joinedAt: Date.now()
}; };
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {} this.store.dispatch(UsersActions.authenticateUser({ user }));
this.store.dispatch(UsersActions.setCurrentUser({ user }));
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim(); const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
if (returnUrl?.startsWith('/')) { if (returnUrl?.startsWith('/')) {

View File

@@ -15,7 +15,6 @@ import { AuthenticationService } from '../../application/services/authentication
import { ServerDirectoryFacade } from '../../../server-directory'; import { ServerDirectoryFacade } from '../../../server-directory';
import { UsersActions } from '../../../../store/users/users.actions'; import { UsersActions } from '../../../../store/users/users.actions';
import { User } from '../../../../shared-kernel'; import { User } from '../../../../shared-kernel';
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../../core/constants';
@Component({ @Component({
selector: 'app-register', selector: 'app-register',
@@ -72,9 +71,7 @@ export class RegisterComponent {
joinedAt: Date.now() joinedAt: Date.now()
}; };
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {} this.store.dispatch(UsersActions.authenticateUser({ user }));
this.store.dispatch(UsersActions.setCurrentUser({ user }));
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim(); const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
if (returnUrl?.startsWith('/')) { if (returnUrl?.startsWith('/')) {

View File

@@ -1,8 +1,5 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Injectable, Injectable,
computed,
effect,
inject, inject,
signal signal
} from '@angular/core'; } from '@angular/core';
@@ -13,7 +10,11 @@ import {
throwError throwError
} from 'rxjs'; } from 'rxjs';
import { catchError, map } from 'rxjs/operators'; import { catchError, map } from 'rxjs/operators';
import { ServerDirectoryFacade } from '../../../server-directory'; import {
ServerDirectoryFacade,
type RoomSignalSourceInput,
type ServerSourceSelector
} from '../../../server-directory';
export interface KlipyGif { export interface KlipyGif {
id: string; id: string;
@@ -37,51 +38,47 @@ export interface KlipyGifSearchResponse {
const DEFAULT_PAGE_SIZE = 24; const DEFAULT_PAGE_SIZE = 24;
const KLIPY_CUSTOMER_ID_STORAGE_KEY = 'metoyou_klipy_customer_id'; const KLIPY_CUSTOMER_ID_STORAGE_KEY = 'metoyou_klipy_customer_id';
const DEFAULT_AVAILABILITY_KEY = 'default';
interface KlipyAvailabilityState {
enabled: boolean;
loading: boolean;
}
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class KlipyService { export class KlipyService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly serverDirectory = inject(ServerDirectoryFacade); private readonly serverDirectory = inject(ServerDirectoryFacade);
private readonly availabilityState = signal({ private readonly availabilityByKey = signal<Record<string, KlipyAvailabilityState>>({});
enabled: false,
loading: true
});
private lastAvailabilityKey = '';
readonly isEnabled = computed(() => this.availabilityState().enabled); isEnabled(source?: RoomSignalSourceInput | null): boolean {
readonly isLoading = computed(() => this.availabilityState().loading); return this.getAvailabilityState(source).enabled;
constructor() {
effect(() => {
const activeServer = this.serverDirectory.activeServer();
const apiBaseUrl = this.serverDirectory.getApiBaseUrl();
const nextKey = `${activeServer?.id ?? 'default'}:${apiBaseUrl}`;
if (nextKey === this.lastAvailabilityKey)
return;
this.lastAvailabilityKey = nextKey;
void this.refreshAvailability();
});
} }
async refreshAvailability(): Promise<void> { isLoading(source?: RoomSignalSourceInput | null): boolean {
this.availabilityState.set({ enabled: false, return this.getAvailabilityState(source).loading;
}
async refreshAvailability(source?: RoomSignalSourceInput | null): Promise<void> {
const selector = this.getSourceSelector(source);
const key = this.getAvailabilityKey(selector);
this.setAvailabilityState(key, { enabled: false,
loading: true }); loading: true });
try { try {
const response = await firstValueFrom( const response = await firstValueFrom(
this.http.get<KlipyAvailabilityResponse>( this.http.get<KlipyAvailabilityResponse>(
`${this.serverDirectory.getApiBaseUrl()}/klipy/config` `${this.serverDirectory.getApiBaseUrl(selector)}/klipy/config`
) )
); );
this.availabilityState.set({ this.setAvailabilityState(key, {
enabled: response.enabled === true, enabled: response.enabled === true,
loading: false loading: false
}); });
} catch { } catch {
this.availabilityState.set({ enabled: false, this.setAvailabilityState(key, { enabled: false,
loading: false }); loading: false });
} }
} }
@@ -89,8 +86,11 @@ export class KlipyService {
searchGifs( searchGifs(
query: string, query: string,
page = 1, page = 1,
perPage = DEFAULT_PAGE_SIZE perPage = DEFAULT_PAGE_SIZE,
source?: RoomSignalSourceInput | null
): Observable<KlipyGifSearchResponse> { ): Observable<KlipyGifSearchResponse> {
const selector = this.getSourceSelector(source);
let params = new HttpParams() let params = new HttpParams()
.set('page', String(Math.max(1, Math.floor(page)))) .set('page', String(Math.max(1, Math.floor(page))))
.set('per_page', String(Math.max(1, Math.floor(perPage)))) .set('per_page', String(Math.max(1, Math.floor(perPage))))
@@ -109,7 +109,7 @@ export class KlipyService {
} }
return this.http return this.http
.get<KlipyGifSearchResponse>(`${this.serverDirectory.getApiBaseUrl()}/klipy/gifs`, { params }) .get<KlipyGifSearchResponse>(`${this.serverDirectory.getApiBaseUrl(selector)}/klipy/gifs`, { params })
.pipe( .pipe(
map((response) => ({ map((response) => ({
enabled: response.enabled !== false, enabled: response.enabled !== false,
@@ -138,7 +138,7 @@ export class KlipyService {
return this.normalizeMediaUrl(url); return this.normalizeMediaUrl(url);
} }
buildImageProxyUrl(url: string): string { buildImageProxyUrl(url: string, source?: RoomSignalSourceInput | null): string {
const trimmed = this.normalizeMediaUrl(url); const trimmed = this.normalizeMediaUrl(url);
if (!trimmed) if (!trimmed)
@@ -147,7 +147,36 @@ export class KlipyService {
if (!/^https?:\/\//i.test(trimmed)) if (!/^https?:\/\//i.test(trimmed))
return trimmed; return trimmed;
return `${this.serverDirectory.getApiBaseUrl()}/image-proxy?url=${encodeURIComponent(trimmed)}`; return `${this.serverDirectory.getApiBaseUrl(this.getSourceSelector(source))}/image-proxy?url=${encodeURIComponent(trimmed)}`;
}
private getAvailabilityState(source?: RoomSignalSourceInput | null): KlipyAvailabilityState {
return this.availabilityByKey()[this.getAvailabilityKey(this.getSourceSelector(source))]
?? { enabled: false,
loading: true };
}
private setAvailabilityState(key: string, state: KlipyAvailabilityState): void {
this.availabilityByKey.update((availabilityByKey) => ({
...availabilityByKey,
[key]: state
}));
}
private getSourceSelector(source?: RoomSignalSourceInput | null): ServerSourceSelector | undefined {
return this.serverDirectory.buildRoomSignalSelector(source ?? undefined);
}
private getAvailabilityKey(selector?: ServerSourceSelector): string {
if (selector?.sourceId) {
return `id:${selector.sourceId}`;
}
if (selector?.sourceUrl) {
return `url:${selector.sourceUrl}`;
}
return DEFAULT_AVAILABILITY_KEY;
} }
private getPreferredLocale(): string | null { private getPreferredLocale(): string | null {

View File

@@ -8,6 +8,7 @@ import {
signal signal
} from '@angular/core'; } from '@angular/core';
import { KlipyService } from '../application/services/klipy.service'; import { KlipyService } from '../application/services/klipy.service';
import type { RoomSignalSourceInput } from '../../server-directory';
@Directive({ @Directive({
selector: 'img[appChatImageProxyFallback]', selector: 'img[appChatImageProxyFallback]',
@@ -15,6 +16,7 @@ import { KlipyService } from '../application/services/klipy.service';
}) })
export class ChatImageProxyFallbackDirective { export class ChatImageProxyFallbackDirective {
readonly sourceUrl = input('', { alias: 'appChatImageProxyFallback' }); readonly sourceUrl = input('', { alias: 'appChatImageProxyFallback' });
readonly signalSource = input<RoomSignalSourceInput | null>(null);
private readonly klipy = inject(KlipyService); private readonly klipy = inject(KlipyService);
private readonly renderedSource = signal(''); private readonly renderedSource = signal('');
@@ -38,7 +40,7 @@ export class ChatImageProxyFallbackDirective {
return; return;
} }
const proxyUrl = this.klipy.buildImageProxyUrl(this.sourceUrl()); const proxyUrl = this.klipy.buildImageProxyUrl(this.sourceUrl(), this.signalSource());
if (!proxyUrl || proxyUrl === this.renderedSource()) { if (!proxyUrl || proxyUrl === this.renderedSource()) {
return; return;

View File

@@ -1,4 +1,7 @@
<div class="chat-layout relative h-full"> <div
appThemeNode="chatSurface"
class="chat-layout relative h-full"
>
<app-chat-message-list <app-chat-message-list
[allMessages]="allMessages()" [allMessages]="allMessages()"
[channelMessages]="channelMessages()" [channelMessages]="channelMessages()"
@@ -19,10 +22,15 @@
(embedRemoved)="handleEmbedRemoved($event)" (embedRemoved)="handleEmbedRemoved($event)"
/> />
<div class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10"> <div
appThemeNode="chatComposerBar"
class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10"
>
<app-chat-message-composer <app-chat-message-composer
[replyTo]="replyTo()" [replyTo]="replyTo()"
[showKlipyGifPicker]="showKlipyGifPicker()" [showKlipyGifPicker]="showKlipyGifPicker()"
[klipyEnabled]="klipyEnabled()"
[klipySignalSource]="currentRoom()"
(messageSubmitted)="handleMessageSubmitted($event)" (messageSubmitted)="handleMessageSubmitted($event)"
(typingStarted)="handleTypingStarted()" (typingStarted)="handleTypingStarted()"
(replyCleared)="clearReply()" (replyCleared)="clearReply()"
@@ -45,11 +53,13 @@
<div class="pointer-events-none fixed inset-0 z-[90]"> <div class="pointer-events-none fixed inset-0 z-[90]">
<div <div
appThemeNode="chatGifPickerSurface"
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]" class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]"
[style.bottom.px]="composerBottomPadding() + 8" [style.bottom.px]="composerBottomPadding() + 8"
[style.right.px]="klipyGifPickerAnchorRight()" [style.right.px]="klipyGifPickerAnchorRight()"
> >
<app-klipy-gif-picker <app-klipy-gif-picker
[signalSource]="currentRoom()"
(gifSelected)="handleKlipyGifSelected($event)" (gifSelected)="handleKlipyGifSelected($event)"
(closed)="closeKlipyGifPicker()" (closed)="closeKlipyGifPicker()"
/> />

View File

@@ -4,6 +4,7 @@ import {
HostListener, HostListener,
ViewChild, ViewChild,
computed, computed,
effect,
inject, inject,
signal signal
} from '@angular/core'; } from '@angular/core';
@@ -11,7 +12,7 @@ import { Store } from '@ngrx/store';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { RealtimeSessionFacade } from '../../../../core/realtime'; import { RealtimeSessionFacade } from '../../../../core/realtime';
import { Attachment, AttachmentFacade } from '../../../attachment'; import { Attachment, AttachmentFacade } from '../../../attachment';
import { KlipyGif } from '../../application/services/klipy.service'; import { KlipyGif, KlipyService } from '../../application/services/klipy.service';
import { MessagesActions } from '../../../../store/messages/messages.actions'; import { MessagesActions } from '../../../../store/messages/messages.actions';
import { import {
selectAllMessages, selectAllMessages,
@@ -21,6 +22,7 @@ import {
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../../store/users/users.selectors'; import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../../store/users/users.selectors';
import { selectActiveChannelId, selectCurrentRoom } from '../../../../store/rooms/rooms.selectors'; import { selectActiveChannelId, selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
import { Message } from '../../../../shared-kernel'; import { Message } from '../../../../shared-kernel';
import { ThemeNodeDirective } from '../../../theme';
import { ChatMessageComposerComponent } from './components/message-composer/chat-message-composer.component'; import { ChatMessageComposerComponent } from './components/message-composer/chat-message-composer.component';
import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.component'; import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.component';
import { ChatMessageListComponent } from './components/message-list/chat-message-list.component'; import { ChatMessageListComponent } from './components/message-list/chat-message-list.component';
@@ -42,7 +44,8 @@ import {
ChatMessageComposerComponent, ChatMessageComposerComponent,
KlipyGifPickerComponent, KlipyGifPickerComponent,
ChatMessageListComponent, ChatMessageListComponent,
ChatMessageOverlaysComponent ChatMessageOverlaysComponent,
ThemeNodeDirective
], ],
templateUrl: './chat-messages.component.html', templateUrl: './chat-messages.component.html',
styleUrl: './chat-messages.component.scss' styleUrl: './chat-messages.component.scss'
@@ -54,10 +57,11 @@ export class ChatMessagesComponent {
private readonly store = inject(Store); private readonly store = inject(Store);
private readonly webrtc = inject(RealtimeSessionFacade); private readonly webrtc = inject(RealtimeSessionFacade);
private readonly attachmentsSvc = inject(AttachmentFacade); private readonly attachmentsSvc = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService);
readonly allMessages = this.store.selectSignal(selectAllMessages); readonly allMessages = this.store.selectSignal(selectAllMessages);
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId); private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom); readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
readonly loading = this.store.selectSignal(selectMessagesLoading); readonly loading = this.store.selectSignal(selectMessagesLoading);
readonly syncing = this.store.selectSignal(selectMessagesSyncing); readonly syncing = this.store.selectSignal(selectMessagesSyncing);
@@ -68,16 +72,11 @@ export class ChatMessagesComponent {
const channelId = this.activeChannelId(); const channelId = this.activeChannelId();
const roomId = this.currentRoom()?.id; const roomId = this.currentRoom()?.id;
return this.allMessages().filter( return this.allMessages().filter((message) => message.roomId === roomId && (message.channelId || 'general') === channelId);
(message) =>
message.roomId === roomId &&
(message.channelId || 'general') === channelId
);
}); });
readonly conversationKey = computed( readonly conversationKey = computed(() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`);
() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}` readonly klipyEnabled = computed(() => this.klipy.isEnabled(this.currentRoom()));
);
readonly composerBottomPadding = signal(140); readonly composerBottomPadding = signal(140);
readonly klipyGifPickerAnchorRight = signal(16); readonly klipyGifPickerAnchorRight = signal(16);
readonly replyTo = signal<Message | null>(null); readonly replyTo = signal<Message | null>(null);
@@ -85,6 +84,12 @@ export class ChatMessagesComponent {
readonly lightboxAttachment = signal<Attachment | null>(null); readonly lightboxAttachment = signal<Attachment | null>(null);
readonly imageContextMenu = signal<ChatMessageImageContextMenuEvent | null>(null); readonly imageContextMenu = signal<ChatMessageImageContextMenuEvent | null>(null);
constructor() {
effect(() => {
void this.klipy.refreshAvailability(this.currentRoom());
});
}
@HostListener('window:resize') @HostListener('window:resize')
onWindowResize(): void { onWindowResize(): void {
if (this.showKlipyGifPicker()) { if (this.showKlipyGifPicker()) {
@@ -167,9 +172,7 @@ export class ChatMessagesComponent {
if (!message || !currentUserId) if (!message || !currentUserId)
return; return;
const hasReacted = message.reactions.some( const hasReacted = message.reactions.some((reaction) => reaction.emoji === event.emoji && reaction.userId === currentUserId);
(reaction) => reaction.emoji === event.emoji && reaction.userId === currentUserId
);
if (hasReacted) { if (hasReacted) {
this.store.dispatch( this.store.dispatch(
@@ -234,9 +237,7 @@ export class ChatMessagesComponent {
const minRight = 16; const minRight = 16;
const maxRight = Math.max(minRight, viewportWidth - popupWidth - 16); const maxRight = Math.max(minRight, viewportWidth - popupWidth - 16);
this.klipyGifPickerAnchorRight.set( this.klipyGifPickerAnchorRight.set(Math.min(Math.max(Math.round(preferredRight), minRight), maxRight));
Math.min(Math.max(Math.round(preferredRight), minRight), maxRight)
);
} }
private getKlipyGifPickerWidth(viewportWidth: number): number { private getKlipyGifPickerWidth(viewportWidth: number): number {
@@ -281,10 +282,7 @@ export class ChatMessagesComponent {
if (blob) { if (blob) {
try { try {
const result = await electronApi.saveFileAs( const result = await electronApi.saveFileAs(attachment.filename, await this.blobToBase64(blob));
attachment.filename,
await this.blobToBase64(blob)
);
if (result.saved || result.cancelled) if (result.saved || result.cancelled)
return; return;
@@ -407,12 +405,7 @@ export class ChatMessagesComponent {
const message = [...this.channelMessages()] const message = [...this.channelMessages()]
.reverse() .reverse()
.find( .find((entry) => entry.senderId === currentUserId && entry.content === content && !entry.isDeleted);
(entry) =>
entry.senderId === currentUserId &&
entry.content === content &&
!entry.isDeleted
);
if (!message) { if (!message) {
setTimeout(() => this.attachFilesToLastOwnMessage(content, pendingFiles), 150); setTimeout(() => this.attachFilesToLastOwnMessage(content, pendingFiles), 150);

View File

@@ -1,7 +1,13 @@
<!-- eslint-disable @angular-eslint/template/button-has-type --> <!-- eslint-disable @angular-eslint/template/button-has-type -->
<div #composerRoot> <div
#composerRoot
appThemeNode="chatComposerBar"
>
@if (replyTo()) { @if (replyTo()) {
<div class="pointer-events-auto flex items-center gap-2 bg-secondary/50 px-4 py-2"> <div
appThemeNode="chatComposerReplyBar"
class="pointer-events-auto flex items-center gap-2 bg-secondary/50 px-4 py-2"
>
<ng-icon <ng-icon
name="lucideReply" name="lucideReply"
class="h-4 w-4 text-muted-foreground" class="h-4 w-4 text-muted-foreground"
@@ -31,6 +37,7 @@
(mouseleave)="onToolbarMouseLeave()" (mouseleave)="onToolbarMouseLeave()"
> >
<div <div
appThemeNode="chatComposerToolbar"
class="mx-4 -mb-2 flex flex-wrap items-center justify-start gap-2 rounded-lg border border-border bg-card/70 px-2 py-1 shadow-sm backdrop-blur" class="mx-4 -mb-2 flex flex-wrap items-center justify-start gap-2 rounded-lg border border-border bg-card/70 px-2 py-1 shadow-sm backdrop-blur"
> >
<button <button
@@ -124,6 +131,7 @@
<div class="border-border p-4"> <div class="border-border p-4">
<div <div
appThemeNode="chatComposerInput"
class="chat-input-wrapper relative" class="chat-input-wrapper relative"
(mouseenter)="inputHovered.set(true)" (mouseenter)="inputHovered.set(true)"
(mouseleave)="inputHovered.set(false)" (mouseleave)="inputHovered.set(false)"
@@ -133,7 +141,21 @@
(drop)="onDrop($event)" (drop)="onDrop($event)"
> >
<div class="absolute bottom-3 right-3 z-10 flex items-center gap-2 m-0.5"> <div class="absolute bottom-3 right-3 z-10 flex items-center gap-2 m-0.5">
@if (klipy.isEnabled()) { @for (record of pluginComposerActions(); track record.id) {
<button
type="button"
(click)="runPluginComposerAction(record.contribution.run)"
class="inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-2xl border border-border/70 bg-secondary/55 px-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground shadow-sm backdrop-blur-md transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/35 hover:bg-secondary/90 hover:text-foreground"
[class.opacity-100]="inputHovered()"
[class.opacity-70]="!inputHovered()"
[attr.aria-label]="record.contribution.label"
[title]="record.contribution.label"
>
<span>{{ record.contribution.icon ?? record.contribution.label }}</span>
</button>
}
@if (klipyEnabled()) {
<button <button
#klipyTrigger #klipyTrigger
type="button" type="button"
@@ -156,6 +178,7 @@
} }
<button <button
appThemeNode="chatComposerSendButton"
type="button" type="button"
(click)="sendMessage()" (click)="sendMessage()"
[disabled]="!messageContent.trim() && pendingFiles.length === 0 && !pendingKlipyGif()" [disabled]="!messageContent.trim() && pendingFiles.length === 0 && !pendingKlipyGif()"
@@ -172,6 +195,7 @@
<textarea <textarea
#messageInputRef #messageInputRef
[attr.data-testid]="textareaTestId()"
rows="1" rows="1"
[(ngModel)]="messageContent" [(ngModel)]="messageContent"
(focus)="onInputFocus()" (focus)="onInputFocus()"
@@ -189,8 +213,8 @@
[class.border-primary]="dragActive()" [class.border-primary]="dragActive()"
[class.chat-textarea-expanded]="textareaExpanded()" [class.chat-textarea-expanded]="textareaExpanded()"
[class.ctrl-resize]="ctrlHeld()" [class.ctrl-resize]="ctrlHeld()"
[class.pr-16]="!klipy.isEnabled()" [class.pr-16]="!klipyEnabled()"
[class.pr-40]="klipy.isEnabled()" [class.pr-40]="klipyEnabled()"
></textarea> ></textarea>
@if (dragActive()) { @if (dragActive()) {
@@ -207,6 +231,7 @@
<div class="relative h-12 w-12 overflow-hidden rounded-lg bg-secondary/80"> <div class="relative h-12 w-12 overflow-hidden rounded-lg bg-secondary/80">
<img <img
[appChatImageProxyFallback]="pendingKlipyGif()!.previewUrl || pendingKlipyGif()!.url" [appChatImageProxyFallback]="pendingKlipyGif()!.previewUrl || pendingKlipyGif()!.url"
[signalSource]="klipySignalSource()"
[alt]="pendingKlipyGif()!.title || 'KLIPY GIF'" [alt]="pendingKlipyGif()!.title || 'KLIPY GIF'"
class="h-full w-full object-cover" class="h-full w-full object-cover"
loading="lazy" loading="lazy"

Some files were not shown because too many files have changed in this diff Show More