4 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
142 changed files with 13072 additions and 144 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

@@ -80,6 +80,11 @@ export class ServerSearchPage {
} }
async joinServerFromSearch(name: string) { async joinServerFromSearch(name: string) {
await this.page.locator('button', { hasText: name }).click(); await this.searchInput.fill(name);
const serverCard = this.page.locator('div[title]', { hasText: name }).first();
await expect(serverCard).toBeVisible({ timeout: 15_000 });
await serverCard.dblclick();
} }
} }

View File

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

View File

@@ -7,6 +7,7 @@ import {
import { RegisterPage } from '../../pages/register.page'; import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page'; import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatMessagesPage } from '../../pages/chat-messages.page'; import { ChatMessagesPage } from '../../pages/chat-messages.page';
import { disableLastViewedChatResume } from '../../helpers/seed-test-endpoint';
test.describe('Direct message flow', () => { test.describe('Direct message flow', () => {
test.describe.configure({ timeout: 180_000 }); test.describe.configure({ timeout: 180_000 });
@@ -37,18 +38,21 @@ test.describe('Direct message flow', () => {
test('shows friend and message actions on the search people list', async ({ createClient }) => { test('shows friend and message actions on the search people list', async ({ createClient }) => {
const scenario = await createDmScenario(createClient); const scenario = await createDmScenario(createClient);
await disableLastViewedChatResume(scenario.alice.page);
await scenario.alice.page.goto('/search', { waitUntil: 'domcontentloaded' }); await scenario.alice.page.goto('/search', { waitUntil: 'domcontentloaded' });
await expect(scenario.alice.page).toHaveURL(/\/search/, { timeout: 20_000 }); await expect(scenario.alice.page).toHaveURL(/\/search/, { timeout: 20_000 });
await expect(scenario.alice.page.locator('app-server-search')).toBeVisible({ timeout: 20_000 }); await expect(scenario.alice.page.locator('app-server-search')).toBeVisible({ timeout: 20_000 });
await expect(scenario.alice.page.locator('app-user-search-list')).toBeVisible({ timeout: 20_000 }); await expect(scenario.alice.page.locator('app-user-search-list')).toBeVisible({ timeout: 20_000 });
const bobPeopleCard = scenario.alice.page.locator(`app-user-search-list [data-testid="user-card-${scenario.bobUserId}"]`); const bobPeopleCard = scenario.alice.page
.locator('app-user-search-list [data-testid$="-' + scenario.bobUserId + '"]', { hasText: 'Bob' })
.first();
await expect(bobPeopleCard).toBeVisible({ timeout: 15_000 }); await expect(bobPeopleCard).toBeVisible({ timeout: 15_000 });
const friendButton = bobPeopleCard.locator(`[data-testid="friend-button-${scenario.bobUserId}"]`); const friendButton = bobPeopleCard.locator(`[data-testid="friend-button-${scenario.bobUserId}"]`);
const messageButton = bobPeopleCard.locator(`[data-testid="message-user-${scenario.bobUserId}"]`); const messageButton = bobPeopleCard.getByRole('button', { name: 'Message Bob' });
await expect(friendButton).toBeVisible({ timeout: 15_000 }); await expect(friendButton).toBeAttached({ timeout: 15_000 });
await expect(messageButton).toBeVisible({ timeout: 15_000 }); await expect(messageButton).toBeAttached({ timeout: 15_000 });
}); });
}); });
@@ -76,12 +80,7 @@ async function createDmScenario(createClient: () => Promise<Client>): Promise<Dm
const bobSearch = new ServerSearchPage(bob.page); const bobSearch = new ServerSearchPage(bob.page);
await bobSearch.searchInput.fill(serverName); await bobSearch.joinServerFromSearch(serverName);
await bob.page.locator('button', { hasText: serverName })
.first()
.click();
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 }); await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
await new ChatMessagesPage(bob.page).waitForReady(); await new ChatMessagesPage(bob.page).waitForReady();
const bobRoomCard = alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' }).first(); const bobRoomCard = alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' }).first();

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -572,10 +572,10 @@ async function joinRoomFromSearch(page: Page, roomName: string): Promise<void> {
await expect(searchInput).toBeVisible({ timeout: 20_000 }); await expect(searchInput).toBeVisible({ timeout: 20_000 });
await searchInput.fill(roomName); await searchInput.fill(roomName);
const roomCard = page.locator('button', { hasText: roomName }).first(); const roomCard = page.locator('div[title]', { hasText: roomName }).first();
await expect(roomCard).toBeVisible({ timeout: 20_000 }); await expect(roomCard).toBeVisible({ timeout: 20_000 });
await roomCard.click(); await roomCard.dblclick();
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 }); await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 }); await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
await waitForCurrentRoomName(page, roomName); await waitForCurrentRoomName(page, roomName);

View File

@@ -335,10 +335,10 @@ async function joinRoomFromSearch(page: Page, roomName: string): Promise<void> {
await expect(searchInput).toBeVisible({ timeout: 20_000 }); await expect(searchInput).toBeVisible({ timeout: 20_000 });
await searchInput.fill(roomName); await searchInput.fill(roomName);
const roomCard = page.locator('button', { hasText: roomName }).first(); const roomCard = page.locator('div[title]', { hasText: roomName }).first();
await expect(roomCard).toBeVisible({ timeout: 20_000 }); await expect(roomCard).toBeVisible({ timeout: 20_000 });
await roomCard.click(); await roomCard.dblclick();
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 }); await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 }); await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
await waitForCurrentRoomName(page, roomName); await waitForCurrentRoomName(page, roomName);

View File

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

View File

@@ -16,6 +16,7 @@ Electron main-process package for MetoYou / Toju. This directory owns desktop bo
| --- | --- | | --- | --- |
| `main.ts` | Electron app bootstrap and process entry point | | `main.ts` | Electron app bootstrap and process entry point |
| `preload.ts` | Typed renderer-facing preload bridge | | `preload.ts` | Typed renderer-facing preload bridge |
| `process-list.ts` | Linux/Windows process-name scan used by now-playing game detection |
| `app/` | App lifecycle and startup composition | | `app/` | App lifecycle and startup composition |
| `ipc/` | Renderer-invoked IPC handlers | | `ipc/` | Renderer-invoked IPC handlers |
| `cqrs/` | Local database command/query handlers and mappings | | `cqrs/` | Local database command/query handlers and mappings |
@@ -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

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

@@ -8,11 +8,12 @@ import {
GetMessageByIdQuery, GetMessageByIdQuery,
GetReactionsForMessageQuery, GetReactionsForMessageQuery,
GetUserQuery, GetUserQuery,
GetCurrentUserIdQuery,
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';
@@ -28,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),
@@ -43,5 +46,7 @@ export const buildQueryHandlers = (dataSource: DataSource): Record<QueryTypeKey,
[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;
@@ -34,7 +37,9 @@ export const QueryType = {
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];
@@ -172,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> } }
@@ -188,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 =
@@ -207,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 } }
@@ -223,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
@@ -238,4 +261,6 @@ export type Query =
| GetBansForRoomQuery | GetBansForRoomQuery
| IsUserBannedQuery | IsUserBannedQuery
| GetAttachmentsForMessageQuery | GetAttachmentsForMessageQuery
| GetAllAttachmentsQuery; | GetAllAttachmentsQuery
| GetPluginDataQuery
| GetMetaQuery;

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,12 +49,14 @@ import {
readSavedTheme, readSavedTheme,
writeSavedTheme writeSavedTheme
} from '../theme-library'; } from '../theme-library';
import { getLocalPluginsPath, listLocalPluginManifests } from '../plugin-library';
import { import {
eraseUserData, eraseUserData,
exportUserData, exportUserData,
importUserData, importUserData,
openCurrentDataFolder openCurrentDataFolder
} from '../data-management'; } from '../data-management';
import { listRunningProcessNames } from '../process-list';
const DEFAULT_MIME_TYPE = 'application/octet-stream'; const DEFAULT_MIME_TYPE = 'application/octet-stream';
const FILE_CLIPBOARD_FORMATS = [ const FILE_CLIPBOARD_FORMATS = [
@@ -320,6 +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();
}); });
@@ -346,6 +350,8 @@ export function setupSystemHandlers(): void {
ipcMain.handle('import-user-data', async () => await importUserData()); ipcMain.handle('import-user-data', async () => await importUserData());
ipcMain.handle('erase-user-data', async () => await eraseUserData()); 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,28 @@ 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 { export interface ExportUserDataResult {
cancelled: boolean; cancelled: boolean;
exported: boolean; exported: boolean;
@@ -167,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>;
@@ -180,6 +203,8 @@ export interface ElectronAPI {
importUserData: () => Promise<ImportUserDataResult>; importUserData: () => Promise<ImportUserDataResult>;
eraseUserData: () => Promise<EraseUserDataResult>; 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>;
@@ -252,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'),
@@ -292,6 +318,8 @@ const electronAPI: ElectronAPI = {
importUserData: () => ipcRenderer.invoke('import-user-data'), importUserData: () => ipcRenderer.invoke('import-user-data'),
eraseUserData: () => ipcRenderer.invoke('erase-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;
}

View File

@@ -19,7 +19,9 @@ Node/TypeScript signaling server for MetoYou / Toju. This package owns the publi
- The server loads the repository-root `.env` file on startup. - The server loads the repository-root `.env` file on startup.
- `SSL` can override the effective HTTP protocol, and `PORT` can override the effective port. - `SSL` can override the effective HTTP protocol, and `PORT` can override the effective port.
- `DB_PATH` can override the SQLite database file location. - `DB_PATH` can override the SQLite database file location.
- `data/variables.json` is normalized on startup and stores `klipyApiKey`, `releaseManifestUrl`, `serverPort`, `serverProtocol`, `serverHost`, and `linkPreview`. - `data/variables.json` is normalized on startup and stores `klipyApiKey`, `rawgApiKey`, `releaseManifestUrl`, `serverPort`, `serverProtocol`, `serverHost`, and `linkPreview`.
- `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. - 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.

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,7 +14,13 @@ 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 { import {
@@ -48,8 +54,35 @@ const DB_BACKUP = DB_FILE + '.bak';
const DATA_DIR = path.dirname(DB_FILE); const DATA_DIR = path.dirname(DB_FILE);
// SQLite files start with this 16-byte header string. // SQLite files start with this 16-byte header string.
const SQLITE_MAGIC = 'SQLite format 3\0'; const SQLITE_MAGIC = 'SQLite format 3\0';
const SAVE_RETRY_DELAYS_MS = [
25,
75,
150,
300,
600
];
const RETRYABLE_SAVE_ERROR_CODES = new Set([
'EPERM',
'EACCES',
'EBUSY'
]);
let applicationDataSource: DataSource | undefined; let applicationDataSource: DataSource | undefined;
let saveQueue: Promise<void> = Promise.resolve();
function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isRetryableSaveError(error: unknown): boolean {
if (!error || typeof error !== 'object') {
return false;
}
const code = (error as NodeJS.ErrnoException).code;
return typeof code === 'string' && RETRYABLE_SAVE_ERROR_CODES.has(code);
}
function restoreFromBackup(reason: string): Uint8Array | undefined { function restoreFromBackup(reason: string): Uint8Array | undefined {
if (!fs.existsSync(DB_BACKUP)) { if (!fs.existsSync(DB_BACKUP)) {
@@ -159,18 +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');
@@ -202,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

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

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

@@ -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,6 +316,52 @@ 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);
@@ -309,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':
@@ -334,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

@@ -96,13 +96,13 @@
"budgets": [ "budgets": [
{ {
"type": "initial", "type": "initial",
"maximumWarning": "2.2MB", "maximumWarning": "10mb",
"maximumError": "2.38MB" "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

@@ -48,5 +48,15 @@ export const routes: Routes = [
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';
@@ -95,6 +96,7 @@ export class App implements OnInit, OnDestroy {
readonly externalLinks = inject(ExternalLinkService); readonly externalLinks = inject(ExternalLinkService);
readonly electronBridge = inject(ElectronBridgeService); readonly electronBridge = inject(ElectronBridgeService);
readonly userStatus = inject(UserStatusService); readonly userStatus = inject(UserStatusService);
readonly gameActivity = inject(GameActivityService);
readonly dismissedDesktopUpdateNoticeKey = signal<string | null>(null); readonly dismissedDesktopUpdateNoticeKey = signal<string | null>(null);
readonly themeStudioFullscreenComponent = signal<Type<unknown> | null>(null); readonly themeStudioFullscreenComponent = signal<Type<unknown> | null>(null);
readonly themeStudioControlsPosition = signal<{ x: number; y: number } | null>(null); readonly themeStudioControlsPosition = signal<{ x: number; y: number } | null>(null);
@@ -246,6 +248,7 @@ export class App implements OnInit, OnDestroy {
await this.setupDesktopDeepLinks(); await this.setupDesktopDeepLinks();
this.userStatus.start(); this.userStatus.start();
this.gameActivity.start();
const currentUrl = this.getCurrentRouteUrl(); const currentUrl = this.getCurrentRouteUrl();
if (!currentUserId) { if (!currentUserId) {

View File

@@ -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,28 @@ 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 { export interface ExportUserDataResult {
cancelled: boolean; cancelled: boolean;
exported: boolean; exported: boolean;
@@ -175,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>;
@@ -188,6 +211,8 @@ export interface ElectronApi {
importUserData: () => Promise<ImportUserDataResult>; importUserData: () => Promise<ImportUserDataResult>;
eraseUserData: () => Promise<EraseUserDataResult>; 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,6 +2,7 @@ import { Injectable, signal } from '@angular/core';
export type SettingsPage = export type SettingsPage =
| 'general' | 'general'
| 'plugins'
| 'theme' | 'theme'
| 'network' | 'network'
| 'notifications' | 'notifications'
@@ -10,6 +11,7 @@ export type SettingsPage =
| 'data' | 'data'
| 'debugging' | 'debugging'
| 'server' | 'server'
| 'serverPlugins'
| 'members' | 'members'
| 'bans' | 'bans'
| 'permissions'; | 'permissions';

View File

@@ -13,7 +13,9 @@ infrastructure adapters and UI.
| **authentication** | Login / register HTTP orchestration, user-bar UI | `AuthenticationService` | | **authentication** | Login / register HTTP orchestration, user-bar UI | `AuthenticationService` |
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` | | **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` |
| **direct-message** | One-to-one WebRTC messages, offline queueing, delivery state, and friends | `DirectMessageService`, `FriendService` | | **direct-message** | One-to-one WebRTC messages, offline queueing, delivery state, and friends | `DirectMessageService`, `FriendService` |
| **game-activity** | Local game detection, server metadata matching, P2P now-playing sync, and elapsed playtime formatting | `GameActivityService`, `formatGameActivityElapsed()` |
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` | | **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
| **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` |
@@ -31,6 +33,7 @@ The larger domains also keep longer design notes in their own folders:
- [chat/README.md](chat/README.md) - [chat/README.md](chat/README.md)
- [direct-message/README.md](direct-message/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

@@ -141,6 +141,20 @@
(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">
@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()) { @if (klipyEnabled()) {
<button <button
#klipyTrigger #klipyTrigger

View File

@@ -23,6 +23,7 @@ import type { ClipboardFilePayload } from '../../../../../../core/platform/elect
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
import { KlipyGif, KlipyService } from '../../../../application/services/klipy.service'; import { KlipyGif, KlipyService } from '../../../../application/services/klipy.service';
import { Message } from '../../../../../../shared-kernel'; import { Message } from '../../../../../../shared-kernel';
import { PluginUiRegistryService } from '../../../../../plugins';
import { ThemeNodeDirective } from '../../../../../theme'; import { ThemeNodeDirective } from '../../../../../theme';
import type { RoomSignalSourceInput } from '../../../../../server-directory'; import type { RoomSignalSourceInput } from '../../../../../server-directory';
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive'; import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive';
@@ -82,8 +83,10 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
private readonly klipy = inject(KlipyService); private readonly klipy = inject(KlipyService);
private readonly markdown = inject(ChatMarkdownService); private readonly markdown = inject(ChatMarkdownService);
private readonly electronBridge = inject(ElectronBridgeService); private readonly electronBridge = inject(ElectronBridgeService);
private readonly pluginUi = inject(PluginUiRegistryService);
readonly pendingKlipyGif = signal<KlipyGif | null>(null); readonly pendingKlipyGif = signal<KlipyGif | null>(null);
readonly pluginComposerActions = this.pluginUi.composerActionRecords;
readonly toolbarVisible = signal(false); readonly toolbarVisible = signal(false);
readonly dragActive = signal(false); readonly dragActive = signal(false);
readonly inputHovered = signal(false); readonly inputHovered = signal(false);
@@ -219,6 +222,11 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
this.klipyGifPickerToggleRequested.emit(); this.klipyGifPickerToggleRequested.emit();
} }
runPluginComposerAction(action: () => Promise<void> | void): void {
void Promise.resolve()
.then(() => action());
}
getKlipyTriggerRect(): DOMRect | null { getKlipyTriggerRect(): DOMRect | null {
return this.klipyTrigger?.nativeElement.getBoundingClientRect() ?? null; return this.klipyTrigger?.nativeElement.getBoundingClientRect() ?? null;
} }

View File

@@ -115,6 +115,20 @@
} }
} }
@if (pluginEmbeds().length > 0) {
<div class="mt-2 space-y-2" data-testid="plugin-message-embeds">
@for (embed of pluginEmbeds(); track embed.id) {
<article class="rounded-md border border-border bg-secondary/30 p-3">
<div class="mb-2 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span>{{ embed.contribution.embedType }}</span>
<span>{{ embed.pluginId }}</span>
</div>
<app-plugin-render-host [render]="embed.render" />
</article>
}
</div>
}
@if (attachmentsList.length > 0) { @if (attachmentsList.length > 0) {
<div class="mt-2 space-y-2"> <div class="mt-2 space-y-2">
@for (att of attachmentsList; track att.id) { @for (att of attachmentsList; track att.id) {

View File

@@ -38,6 +38,8 @@ import {
User User
} from '../../../../../../shared-kernel'; } from '../../../../../../shared-kernel';
import { ThemeNodeDirective } from '../../../../../theme'; import { ThemeNodeDirective } from '../../../../../theme';
import { PluginRenderHostComponent } from '../../../../../plugins/feature/plugin-render-host/plugin-render-host.component';
import { PluginUiRegistryService } from '../../../../../plugins';
import { import {
ChatAudioPlayerComponent, ChatAudioPlayerComponent,
@@ -98,6 +100,7 @@ interface ChatMessageAttachmentViewModel extends Attachment {
ChatMessageMarkdownComponent, ChatMessageMarkdownComponent,
ChatLinkEmbedComponent, ChatLinkEmbedComponent,
UserAvatarComponent, UserAvatarComponent,
PluginRenderHostComponent,
ThemeNodeDirective ThemeNodeDirective
], ],
viewProviders: [ viewProviders: [
@@ -124,6 +127,7 @@ export class ChatMessageItemComponent {
private readonly attachmentsSvc = inject(AttachmentFacade); private readonly attachmentsSvc = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService); private readonly klipy = inject(KlipyService);
private readonly pluginUi = inject(PluginUiRegistryService);
private readonly profileCard = inject(ProfileCardService); private readonly profileCard = inject(ProfileCardService);
private readonly attachmentVersion = signal(this.attachmentsSvc.updated()); private readonly attachmentVersion = signal(this.attachmentsSvc.updated());
@@ -146,6 +150,7 @@ export class ChatMessageItemComponent {
readonly commonEmojis = COMMON_EMOJIS; readonly commonEmojis = COMMON_EMOJIS;
readonly deletedMessageContent = DELETED_MESSAGE_CONTENT; readonly deletedMessageContent = DELETED_MESSAGE_CONTENT;
readonly pluginEmbeds = computed(() => this.findPluginEmbeds(this.message().content));
readonly isEditing = signal(false); readonly isEditing = signal(false);
readonly showEmojiPicker = signal(false); readonly showEmojiPicker = signal(false);
readonly senderUser = computed<User>(() => { readonly senderUser = computed<User>(() => {
@@ -191,6 +196,28 @@ export class ChatMessageItemComponent {
}); });
}); });
private findPluginEmbeds(content: string) {
const match = /^toju:embed:([a-zA-Z0-9._:-]+):([\s\S]*)$/.exec(content.trim());
if (!match) {
return [];
}
const [
,
embedType,
payloadText
] = match;
const payload = parseEmbedPayload(payloadText);
return this.pluginUi.embedRecords()
.filter((record) => record.contribution.embedType === embedType)
.map((record) => ({
...record,
render: () => record.contribution.render(payload)
}));
}
startEdit(): void { startEdit(): void {
this.editContent = this.message().content; this.editContent = this.message().content;
this.isEditing.set(true); this.isEditing.set(true);
@@ -507,3 +534,15 @@ export class ChatMessageItemComponent {
return this.attachmentsSvc.getForMessage(this.message().id).find((attachment) => attachment.id === attachmentId); return this.attachmentsSvc.getForMessage(this.message().id).find((attachment) => attachment.id === attachmentId);
} }
} }
function parseEmbedPayload(payloadText: string | undefined): unknown {
if (!payloadText?.trim()) {
return null;
}
try {
return JSON.parse(payloadText) as unknown;
} catch {
return payloadText;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,25 @@
# Plugins Domain
Owns the client-only plugin runtime foundation: manifest validation, deterministic load ordering, registry state, local manifest discovery, capability grants, browser-imported client entrypoints, disposable UI extension registries, plugin logs, and typed access to signal-server plugin support metadata.
The signal server stores plugin install metadata and event definitions, but it must never execute plugin code or store arbitrary plugin data. Executable plugin loading belongs to the renderer/Electron boundary and should enter this domain through `PluginHostService`.
Desktop local plugins are discovered from the Electron app data `plugins` folder. Discovery reads `toju-plugin.json` or `plugin.json` from immediate child folders and resolves declared entrypoint/readme paths only when they stay inside that plugin folder.
The standalone plugin store is available from the title bar Plugins button, the title-bar Plugin Store menu item, the legacy Settings page button, and the Plugin Manager header. It owns source manifest management, search, readmes, install/update/uninstall actions, and links back to installed-plugin management. Manifest `kind` describes runtime shape (`client` or `library`), while top-level manifest `scope` describes installation scope: omit it or use `scope: "client"` for global client plugins, and use `scope: "server"` for chat-server plugins. Server-scoped store entries are presented as Install to Server, Update Server, or Remove from Server.
The plugin manager UI is split between Settings -> Client plugins for global client plugins and Settings -> Server -> Server plugins for chat-server plugins. The two pages filter by manifest `scope` and include installed plugins, capability grant toggles, per-plugin activate/reload/unload actions, runtime logs, extension-point counts, server requirements, generated settings, and docs.
The Store tab consumes user-managed HTTP(S), `file://`, or absolute local-path source manifests. Local-path sources and entrypoints are read through the Electron desktop file bridge. A source manifest can expose a `plugins` array whose entries include `id`, `title`, `description`, `version`, `scope`, `author`/`authors`, `image`/`imageUrl`, `github`/`githubUrl`, `install`/`installUrl`/`manifestUrl`, and `readme`/`readmeUrl`. Installing a `scope: "server"` plugin fetches the linked plugin manifest, validates it, registers it with the client registry, and persists the basic install metadata as a server plugin requirement. Required server plugins are installed on each member client when that chat server opens; optional server plugins stay listed as server requirements but are not auto-installed. Installing a `scope: "client"` plugin persists it locally for the current desktop/browser client.
The server-side plugin support API is metadata-only. The signal server can keep plugin id, requirement status, version range, install/source URLs, and the validated manifest snapshot needed for member clients to install required plugins. Plugin `serverData` API calls are handled as local per-user/per-server client state; HTTP plugin data persistence on the signal server returns `PLUGIN_DATA_DISABLED`.
Plugin data that belongs to the current client uses the Electron database when the desktop bridge is available. The plugin runtime writes `api.clientData.*` and `api.serverData.*` records to Electron's dedicated `plugin_data` table, with renderer localStorage as the browser fallback. The legacy synchronous `api.storage.*` surface remains local and mirrors writes to the same Electron table when possible; plugins that need guaranteed database reads should use the async `api.clientData.*` methods.
Plugins can communicate over a plugin-only message bus through `api.messageBus`. It sends `plugin-message-bus` data-channel events that are ignored by the normal chat message reducers/effects, can target a peer or broadcast to connected users, and can include a bounded latest-message snapshot filtered by channel, timestamp, and deletion state.
Desktop plugin preferences that belong to the local user, including capability grants, disabled plugin ids, and previously activated plugin ids, are persisted through Electron's local database meta table with renderer localStorage as the browser fallback.
Runtime activation is explicit. `PluginHostService.activateReadyPlugins()` imports browser-safe plugin entrypoints from URL-resolvable manifests, passes a frozen `TojuClientPluginApi`, runs `activate`, then runs `ready` after the load-order pass. Successfully activated plugin ids are remembered locally, and store-installed plugins are reactivated for the active server when their persisted manifests load again. `deactivate` runs during unload/reload, disposables are cleaned in reverse order, and UI contributions are removed by plugin id.
Plugins that need fully custom UI can call `api.ui.mountElement(id, { target, element, position })` with the `ui.dom` capability. The runtime tags mounted elements with plugin ownership metadata, replaces duplicate mounts for the same plugin/id pair, and removes remaining mounted elements when the plugin is unloaded.

View File

@@ -0,0 +1,123 @@
import {
Injectable,
computed,
inject,
signal
} from '@angular/core';
import { getUserScopedStorageKey } from '../../../../core/storage/current-user-storage';
import type { PluginCapabilityId, TojuPluginManifest } from '../../../../shared-kernel';
import { PluginDesktopStateService } from './plugin-desktop-state.service';
const STORAGE_KEY_PLUGIN_CAPABILITIES = 'metoyou_plugin_capability_grants';
export class PluginCapabilityError extends Error {
constructor(pluginId: string, capability: PluginCapabilityId) {
super(`Plugin ${pluginId} needs capability ${capability}`);
this.name = 'PluginCapabilityError';
}
}
@Injectable({ providedIn: 'root' })
export class PluginCapabilityService {
readonly grants = computed(() => this.grantsSignal());
private readonly desktopState = inject(PluginDesktopStateService);
private readonly grantsSignal = signal<Record<string, PluginCapabilityId[]>>(this.loadGrants());
constructor() {
void this.loadDesktopGrants();
}
grant(pluginId: string, capability: PluginCapabilityId): void {
this.grantsSignal.update((grants) => ({
...grants,
[pluginId]: Array.from(new Set([...(grants[pluginId] ?? []), capability])).sort()
}));
void this.saveGrants();
}
grantAll(manifest: TojuPluginManifest): void {
this.grantsSignal.update((grants) => ({
...grants,
[manifest.id]: [...(manifest.capabilities ?? [])].sort()
}));
void this.saveGrants();
}
revoke(pluginId: string, capability: PluginCapabilityId): void {
this.grantsSignal.update((grants) => ({
...grants,
[pluginId]: (grants[pluginId] ?? []).filter((entry) => entry !== capability)
}));
void this.saveGrants();
}
revokeAll(pluginId: string): void {
this.grantsSignal.update((grants) => {
const { [pluginId]: _removed, ...next } = grants;
return next;
});
void this.saveGrants();
}
has(pluginId: string, capability: PluginCapabilityId): boolean {
return this.grants()[pluginId]?.includes(capability) ?? false;
}
assert(pluginId: string, capability: PluginCapabilityId): void {
if (!this.has(pluginId, capability)) {
throw new PluginCapabilityError(pluginId, capability);
}
}
missing(manifest: TojuPluginManifest): PluginCapabilityId[] {
return (manifest.capabilities ?? []).filter((capability) => !this.has(manifest.id, capability));
}
private loadGrants(): Record<string, PluginCapabilityId[]> {
try {
const raw = localStorage.getItem(getUserScopedStorageKey(STORAGE_KEY_PLUGIN_CAPABILITIES));
if (!raw) {
return {};
}
const parsed = JSON.parse(raw) as unknown;
return isGrantRecord(parsed) ? parsed : {};
} catch {
return {};
}
}
private async loadDesktopGrants(): Promise<void> {
const grants = await this.desktopState.readJson<Record<string, PluginCapabilityId[]>>(STORAGE_KEY_PLUGIN_CAPABILITIES, this.grantsSignal());
if (isGrantRecord(grants)) {
this.grantsSignal.set(grants);
}
}
private async saveGrants(): Promise<void> {
try {
localStorage.setItem(
getUserScopedStorageKey(STORAGE_KEY_PLUGIN_CAPABILITIES),
JSON.stringify(this.grantsSignal())
);
} catch {}
await this.desktopState.writeJson(STORAGE_KEY_PLUGIN_CAPABILITIES, this.grantsSignal());
}
}
function isGrantRecord(value: unknown): value is Record<string, PluginCapabilityId[]> {
return !!value
&& typeof value === 'object'
&& !Array.isArray(value)
&& Object.values(value).every((entry) => Array.isArray(entry) && entry.every((item) => typeof item === 'string'));
}

View File

@@ -0,0 +1,596 @@
import { Injectable, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { Subscription } from 'rxjs';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { VoiceConnectionFacade } from '../../../voice-connection/application/facades/voice-connection.facade';
import type {
Channel,
ChatEvent,
Message,
PluginCapabilityId,
PluginEventEnvelope,
TojuPluginManifest,
User
} from '../../../../shared-kernel';
import { MessagesActions } from '../../../../store/messages/messages.actions';
import { selectCurrentRoomMessages } from '../../../../store/messages/messages.selectors';
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import {
selectActiveChannelId,
selectCurrentRoom,
selectCurrentRoomChannels,
selectCurrentRoomId
} from '../../../../store/rooms/rooms.selectors';
import { UsersActions } from '../../../../store/users/users.actions';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
import type {
PluginApiAvatarUpdate,
PluginApiChannelRequest,
PluginApiCustomStreamRequest,
PluginApiMessageAsPluginUserRequest,
PluginApiServerSettingsUpdate,
TojuClientPluginApi
} from '../../domain/models/plugin-api.models';
import { PluginCapabilityService } from './plugin-capability.service';
import { PluginLoggerService } from './plugin-logger.service';
import { PluginMessageBusService } from './plugin-message-bus.service';
import { PluginStorageService } from './plugin-storage.service';
import { PluginUiRegistryService } from './plugin-ui-registry.service';
@Injectable({ providedIn: 'root' })
export class PluginClientApiService {
private readonly capabilities = inject(PluginCapabilityService);
private readonly logger = inject(PluginLoggerService);
private readonly messageBus = inject(PluginMessageBusService);
private readonly realtime = inject(RealtimeSessionFacade);
private readonly store = inject(Store);
private readonly storage = inject(PluginStorageService);
private readonly uiRegistry = inject(PluginUiRegistryService);
private readonly voice = inject(VoiceConnectionFacade);
private readonly currentMessages = this.store.selectSignal(selectCurrentRoomMessages);
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
private readonly currentRoomChannels = this.store.selectSignal(selectCurrentRoomChannels);
private readonly currentRoomId = this.store.selectSignal(selectCurrentRoomId);
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
private readonly users = this.store.selectSignal(selectAllUsers);
createApi(manifest: TojuPluginManifest): TojuClientPluginApi {
const pluginId = manifest.id;
const requireCapability = (capability: PluginCapabilityId): void => this.capabilities.assert(pluginId, capability);
const assertEvent = (eventName: string): void => this.assertDeclaredEvent(manifest, eventName);
return deepFreeze<TojuClientPluginApi>({
channels: {
addAudioChannel: (request) => {
requireCapability('channels.manage');
this.store.dispatch(RoomsActions.addChannel({ channel: createChannel(request, 'voice') }));
},
addVideoChannel: (request) => {
requireCapability('channels.manage');
this.uiRegistry.registerChannelSection(pluginId, request.id ?? request.name, {
label: request.name,
order: request.position,
type: 'video'
});
},
list: () => {
requireCapability('channels.read');
return this.currentRoomChannels();
},
remove: (channelId) => {
requireCapability('channels.manage');
this.store.dispatch(RoomsActions.removeChannel({ channelId }));
},
rename: (channelId, name) => {
requireCapability('channels.manage');
this.store.dispatch(RoomsActions.renameChannel({ channelId, name }));
},
select: (channelId) => {
requireCapability('channels.read');
this.store.dispatch(RoomsActions.selectChannel({ channelId }));
}
},
events: {
publishP2p: (eventName, payload) => {
requireCapability('events.p2p.publish');
assertEvent(eventName);
this.broadcastPluginEvent(pluginId, eventName, payload, 'p2p');
},
publishServer: (eventName, payload) => {
requireCapability('events.server.publish');
assertEvent(eventName);
this.publishServerPluginEvent(pluginId, eventName, payload);
},
subscribeP2p: (subscription) => {
requireCapability('events.p2p.subscribe');
assertEvent(subscription.eventName);
return this.rememberSubscription(pluginId, subscription.eventName);
},
subscribeServer: (subscription) => {
requireCapability('events.server.subscribe');
assertEvent(subscription.eventName);
return this.subscribeServerPluginEvent(pluginId, subscription.eventName, subscription.handler);
}
},
logger: {
debug: (message, data) => this.logger.debug(pluginId, message, data),
error: (message, data) => this.logger.error(pluginId, message, data),
info: (message, data) => this.logger.info(pluginId, message, data),
warn: (message, data) => this.logger.warn(pluginId, message, data)
},
clientData: {
read: async (key) => {
requireCapability('storage.local');
return await this.storage.readClientData(pluginId, key);
},
remove: async (key) => {
requireCapability('storage.local');
await this.storage.removeClientData(pluginId, key);
},
write: async (key, value) => {
requireCapability('storage.local');
await this.storage.writeClientData(pluginId, key, value);
}
},
media: {
addCustomAudioStream: async (request) => {
requireCapability('media.addAudioStream');
await this.voice.setLocalStream(request.stream);
},
addCustomVideoStream: async (_request: PluginApiCustomStreamRequest) => {
requireCapability('media.addVideoStream');
this.logger.info(pluginId, 'Video stream contribution registered');
},
playAudioClip: async (request) => {
requireCapability('media.playAudio');
await playAudioClip(request.url, request.volume);
},
setInputVolume: (volume) => {
requireCapability('audio.volume');
this.voice.setInputVolume(volume);
},
setOutputVolume: (volume) => {
requireCapability('audio.volume');
this.voice.setOutputVolume(volume);
}
},
messages: {
delete: (messageId) => {
requireCapability('messages.deleteOwn');
this.deletePluginMessage(messageId);
},
edit: (messageId, content) => {
requireCapability('messages.editOwn');
this.editPluginMessage(messageId, content);
},
moderateDelete: (messageId) => {
requireCapability('messages.moderate');
this.store.dispatch(MessagesActions.adminDeleteMessage({ messageId }));
},
readCurrent: () => {
requireCapability('messages.read');
return this.currentMessages();
},
send: (content, channelId) => {
requireCapability('messages.send');
return this.sendPluginMessage(content, channelId);
},
sendAsPluginUser: (request) => {
requireCapability('messages.send');
this.receivePluginUserMessage(pluginId, request);
},
sync: (messages) => {
requireCapability('messages.sync');
this.store.dispatch(MessagesActions.syncMessages({ messages }));
}
},
messageBus: {
publish: (request) => {
requireCapability('events.p2p.publish');
if (request.includeLatestMessages) {
requireCapability('messages.read');
}
return this.messageBus.publish(pluginId, request);
},
sendLatestMessages: (request = {}) => {
requireCapability('events.p2p.publish');
requireCapability('messages.read');
return this.messageBus.sendLatestMessages(pluginId, request);
},
subscribe: (subscription) => {
requireCapability('events.p2p.subscribe');
if (subscription.replayLatest) {
requireCapability('messages.read');
}
return this.messageBus.subscribe(pluginId, subscription);
}
},
p2p: {
broadcastData: (eventName, payload) => {
requireCapability('p2p.data');
this.broadcastPluginEvent(pluginId, eventName, payload, 'p2p');
},
connectedPeers: () => {
requireCapability('p2p.data');
return this.voice.getConnectedPeers();
},
sendData: (peerId, eventName, payload) => {
requireCapability('p2p.data');
this.broadcastPluginEvent(pluginId, eventName, { payload, peerId }, 'p2p');
}
},
profile: {
getCurrent: () => {
requireCapability('profile.read');
return this.currentUser() ?? null;
},
update: (profile) => {
requireCapability('profile.write');
this.store.dispatch(UsersActions.updateCurrentUserProfile({
profile: {
...profile,
profileUpdatedAt: Date.now()
}
}));
},
updateAvatar: (avatar: PluginApiAvatarUpdate) => {
requireCapability('profile.write');
this.store.dispatch(UsersActions.updateCurrentUserAvatar({
avatar: {
...avatar,
avatarUpdatedAt: Date.now()
}
}));
}
},
roles: {
list: () => {
requireCapability('roles.read');
return this.currentRoom()?.roles ?? [];
},
setAssignments: (assignments) => {
requireCapability('roles.manage');
this.updateRoomAccessControl({ roleAssignments: assignments });
}
},
server: {
getCurrent: () => {
requireCapability('server.read');
return this.currentRoom();
},
registerPluginUser: (request) => {
requireCapability('users.manage');
const userId = request.id ?? `${pluginId}:${slug(request.displayName)}`;
this.store.dispatch(UsersActions.userJoined({
user: {
avatarUrl: request.avatarUrl,
displayName: request.displayName,
id: userId,
isOnline: true,
joinedAt: Date.now(),
oderId: userId,
role: 'member',
status: 'online',
username: userId
}
}));
return userId;
},
updatePermissions: (permissions) => {
requireCapability('server.manage');
this.store.dispatch(RoomsActions.updateRoomPermissions({ roomId: this.requireRoomId(), permissions }));
},
updateSettings: (settings: PluginApiServerSettingsUpdate) => {
requireCapability('server.manage');
this.store.dispatch(RoomsActions.updateRoomSettings({
roomId: this.requireRoomId(),
settings: {
description: settings.description,
hasPassword: !!settings.password,
isPrivate: settings.isPrivate ?? this.currentRoom()?.isPrivate ?? false,
maxUsers: settings.maxUsers,
name: settings.name ?? this.currentRoom()?.name ?? 'Server',
password: settings.password,
rules: [],
topic: settings.topic
}
}));
}
},
serverData: {
read: async (key) => {
requireCapability('storage.serverData.read');
return await this.storage.readServerData(pluginId, key);
},
remove: async (key) => {
requireCapability('storage.serverData.write');
await this.storage.removeServerData(pluginId, key);
},
write: async (key, value) => {
requireCapability('storage.serverData.write');
await this.storage.writeServerData(pluginId, key, value);
}
},
storage: {
get: (key) => {
requireCapability('storage.local');
return this.storage.getLocal(pluginId, key);
},
remove: (key) => {
requireCapability('storage.local');
this.storage.removeLocal(pluginId, key);
},
set: (key, value) => {
requireCapability('storage.local');
this.storage.setLocal(pluginId, key, value);
}
},
ui: {
registerAppPage: (id, contribution) => {
requireCapability('ui.pages');
return this.uiRegistry.registerAppPage(pluginId, id, contribution);
},
registerChannelSection: (id, contribution) => {
requireCapability('ui.channelsSection');
return this.uiRegistry.registerChannelSection(pluginId, id, contribution);
},
registerComposerAction: (id, contribution) => {
requireCapability('ui.pages');
return this.uiRegistry.registerComposerAction(pluginId, id, contribution);
},
registerEmbedRenderer: (id, contribution) => {
requireCapability('ui.embeds');
return this.uiRegistry.registerEmbedRenderer(pluginId, id, contribution);
},
mountElement: (id, request) => {
requireCapability('ui.dom');
return this.uiRegistry.mountElement(pluginId, id, request);
},
registerProfileAction: (id, contribution) => {
requireCapability('ui.pages');
return this.uiRegistry.registerProfileAction(pluginId, id, contribution);
},
registerSettingsPage: (id, contribution) => {
requireCapability('ui.settings');
return this.uiRegistry.registerSettingsPage(pluginId, id, contribution);
},
registerSidePanel: (id, contribution) => {
requireCapability('ui.sidePanel');
return this.uiRegistry.registerSidePanel(pluginId, id, contribution);
},
registerToolbarAction: (id, contribution) => {
requireCapability('ui.pages');
return this.uiRegistry.registerToolbarAction(pluginId, id, contribution);
}
},
users: {
ban: (userId, reason) => {
requireCapability('users.manage');
this.store.dispatch(UsersActions.banUser({ reason, userId }));
},
getCurrent: () => {
requireCapability('users.read');
return this.currentUser() ?? null;
},
kick: (userId) => {
requireCapability('users.manage');
this.store.dispatch(UsersActions.kickUser({ userId }));
},
list: () => {
requireCapability('users.read');
return this.users();
},
readMembers: () => {
requireCapability('users.read');
return this.currentRoom()?.members ?? [];
},
setRole: (userId, role: User['role']) => {
requireCapability('roles.manage');
this.store.dispatch(UsersActions.updateUserRole({ role, userId }));
}
}
});
}
private assertDeclaredEvent(manifest: TojuPluginManifest, eventName: string): void {
const declared = manifest.events?.some((event) => event.eventName === eventName) ?? false;
if (!declared) {
throw new Error(`Plugin ${manifest.id} did not declare event ${eventName}`);
}
}
private broadcastPluginEvent(pluginId: string, eventName: string, payload: unknown, target: 'p2p' | 'server'): void {
const roomId = this.currentRoomId() ?? 'local';
const event: PluginEventEnvelope = {
emittedAt: Date.now(),
eventId: createId(),
eventName,
payload,
pluginId,
serverId: roomId,
type: 'plugin_event'
};
this.voice.broadcastMessage({
data: JSON.stringify({ event, target }),
roomId,
timestamp: Date.now(),
type: 'plugin-event'
} as unknown as ChatEvent);
}
private publishServerPluginEvent(pluginId: string, eventName: string, payload: unknown): void {
this.realtime.sendRawMessage({
type: 'plugin_event',
eventId: createId(),
eventName,
payload,
pluginId,
serverId: this.requireRoomId()
});
}
private subscribeServerPluginEvent(
pluginId: string,
eventName: string,
handler: (event: PluginEventEnvelope) => void
) {
const subscription = new Subscription();
subscription.add(this.realtime.onSignalingMessage.subscribe((message) => {
const record = message as Record<string, unknown>;
if (record['type'] !== 'plugin_event' || record['pluginId'] !== pluginId || record['eventName'] !== eventName) {
return;
}
handler(message as PluginEventEnvelope);
}));
this.logger.info(pluginId, `Subscribed to server event ${eventName}`);
return {
dispose: () => {
subscription.unsubscribe();
this.logger.info(pluginId, `Unsubscribed from server event ${eventName}`);
}
};
}
private receivePluginUserMessage(pluginId: string, request: PluginApiMessageAsPluginUserRequest): void {
const roomId = this.requireRoomId();
const message: Message = {
channelId: request.channelId ?? this.activeChannelId() ?? undefined,
content: request.content,
id: createId(),
isDeleted: false,
reactions: [],
roomId,
senderId: request.pluginUserId,
senderName: request.pluginUserId,
timestamp: Date.now()
};
this.logger.info(pluginId, 'Plugin user message emitted', { messageId: message.id });
this.store.dispatch(MessagesActions.receiveMessage({ message }));
this.voice.broadcastMessage({ type: 'chat-message', message } as unknown as ChatEvent);
}
private deletePluginMessage(messageId: string): void {
this.store.dispatch(MessagesActions.deleteMessageSuccess({ messageId }));
this.voice.broadcastMessage({
deletedAt: Date.now(),
messageId,
type: 'message-deleted'
} as unknown as ChatEvent);
}
private editPluginMessage(messageId: string, content: string): void {
const editedAt = Date.now();
this.store.dispatch(MessagesActions.editMessageSuccess({
content,
editedAt,
messageId
}));
this.voice.broadcastMessage({
content,
editedAt,
messageId,
type: 'message-edited'
} as unknown as ChatEvent);
}
private sendPluginMessage(content: string, channelId?: string): Message {
const currentUser = this.currentUser();
const roomId = this.requireRoomId();
const message: Message = {
channelId: channelId ?? this.activeChannelId() ?? 'general',
content,
id: createId(),
isDeleted: false,
reactions: [],
roomId,
senderId: currentUser?.id ?? 'plugin',
senderName: currentUser?.displayName || currentUser?.username || 'Plugin',
timestamp: Date.now()
};
this.store.dispatch(MessagesActions.sendMessageSuccess({ message }));
this.voice.broadcastMessage({ type: 'chat-message', message } as unknown as ChatEvent);
return message;
}
private rememberSubscription(pluginId: string, eventName: string) {
this.logger.info(pluginId, `Subscribed to ${eventName}`);
return {
dispose: () => this.logger.info(pluginId, `Unsubscribed from ${eventName}`)
};
}
private requireRoomId(): string {
const roomId = this.currentRoomId();
if (!roomId) {
throw new Error('No active server');
}
return roomId;
}
private updateRoomAccessControl(changes: Parameters<typeof RoomsActions.updateRoomAccessControl>[0]['changes']): void {
this.store.dispatch(RoomsActions.updateRoomAccessControl({
changes,
roomId: this.requireRoomId()
}));
}
}
function createChannel(request: PluginApiChannelRequest, type: Channel['type']): Channel {
return {
id: request.id ?? slug(request.name),
name: request.name,
position: request.position ?? Date.now(),
type
};
}
function createId(): string {
return globalThis.crypto?.randomUUID?.() ?? `plugin-${Date.now()}-${Math.random().toString(36)
.slice(2)}`;
}
function deepFreeze<TValue extends object>(value: TValue): TValue {
for (const propertyValue of Object.values(value)) {
if (propertyValue && typeof propertyValue === 'object') {
deepFreeze(propertyValue as Record<string, unknown>);
}
}
return Object.freeze(value);
}
async function playAudioClip(url: string, volume = 1): Promise<void> {
const audio = new Audio(url);
audio.volume = Math.max(0, Math.min(1, volume));
await audio.play();
}
function slug(value: string): string {
return value.trim().toLowerCase()
.replace(/[^a-z0-9.-]+/g, '-')
.replace(/(^-+|-+$)/g, '') || createId();
}

View File

@@ -0,0 +1,54 @@
import { Injectable, inject } from '@angular/core';
import { getUserScopedStorageKey } from '../../../../core/storage/current-user-storage';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
@Injectable({ providedIn: 'root' })
export class PluginDesktopStateService {
private readonly electronBridge = inject(ElectronBridgeService);
async readJson<TValue>(key: string, fallback: TValue): Promise<TValue> {
const raw = await this.readRaw(key);
if (!raw) {
return fallback;
}
try {
return JSON.parse(raw) as TValue;
} catch {
return fallback;
}
}
async writeJson(key: string, value: unknown): Promise<void> {
await this.writeRaw(key, JSON.stringify(value));
}
private async readRaw(key: string): Promise<string | null> {
const api = this.electronBridge.getApi();
if (api) {
return await api.query<string | null>({
type: 'get-meta',
payload: { key }
});
}
return localStorage.getItem(getUserScopedStorageKey(key));
}
private async writeRaw(key: string, value: string): Promise<void> {
const api = this.electronBridge.getApi();
if (api) {
await api.command({
type: 'save-meta',
payload: { key, value }
});
return;
}
localStorage.setItem(getUserScopedStorageKey(key), value);
}
}

View File

@@ -0,0 +1,169 @@
import { Injector } from '@angular/core';
import type { TojuPluginManifest } from '../../../../shared-kernel';
import { DEVELOPMENT_PLUGIN_MANIFEST } from '../../development/development-plugin';
import type { LocalPluginDiscoveryResult } from '../../domain/models/plugin-runtime.models';
import { LocalPluginDiscoveryService } from '../../infrastructure/local-plugin-discovery.service';
import { PluginCapabilityService } from './plugin-capability.service';
import { PluginClientApiService } from './plugin-client-api.service';
import { PluginDesktopStateService } from './plugin-desktop-state.service';
import { PluginHostService } from './plugin-host.service';
import { PluginLoggerService } from './plugin-logger.service';
import { PluginRegistryService } from './plugin-registry.service';
import { PluginUiRegistryService } from './plugin-ui-registry.service';
const TEST_PLUGIN_MANIFEST = createTestPluginManifest();
describe('PluginHostService', () => {
let discoveryResult: LocalPluginDiscoveryResult;
beforeEach(() => {
discoveryResult = {
errors: [],
plugins: [],
pluginsPath: '/plugins'
};
});
it('registers discovered test plugin manifests', async () => {
discoveryResult = {
errors: [],
plugins: [
{
discoveredAt: 1,
entrypointPath: '/plugins/api-test-plugin/dist/main.js',
manifest: TEST_PLUGIN_MANIFEST,
manifestPath: '/plugins/api-test-plugin/toju-plugin.json',
pluginRoot: '/plugins/api-test-plugin',
readmePath: '/plugins/api-test-plugin/README.md'
}
],
pluginsPath: '/plugins'
};
const host = createHostService(() => discoveryResult);
const result = await host.discoverLocalPlugins();
expect(result.errors).toEqual([]);
expect(result.registered.map((plugin) => plugin.manifest.id)).toEqual([TEST_PLUGIN_MANIFEST.id]);
const readyManifestIds = host.getReadyManifests().map((manifest) => manifest.id);
expect(readyManifestIds.sort()).toEqual([DEVELOPMENT_PLUGIN_MANIFEST.id, TEST_PLUGIN_MANIFEST.id].sort());
});
it('registers the built-in development plugin in development builds', () => {
const host = createHostService(() => discoveryResult);
expect(host.getReadyManifests().map((manifest) => manifest.id)).toEqual([DEVELOPMENT_PLUGIN_MANIFEST.id]);
});
it('keeps discovery and validation failures visible to callers', async () => {
discoveryResult = {
errors: [
{
manifestPath: '/plugins/broken/plugin.json',
message: 'Unexpected end of JSON input',
pluginRoot: '/plugins/broken'
}
],
plugins: [
{
discoveredAt: 1,
manifest: {
...TEST_PLUGIN_MANIFEST,
entrypoint: undefined
},
manifestPath: '/plugins/invalid/toju-plugin.json',
pluginRoot: '/plugins/invalid'
}
],
pluginsPath: '/plugins'
};
const host = createHostService(() => discoveryResult);
const result = await host.discoverLocalPlugins();
expect(result.registered).toEqual([]);
expect(result.errors.map((error) => error.pluginRoot)).toEqual(['/plugins/broken', '/plugins/invalid']);
expect(result.errors[1]?.message).toContain('client plugins require an entrypoint');
});
});
function createHostService(readDiscoveryResult: () => LocalPluginDiscoveryResult): PluginHostService {
const injector = Injector.create({
providers: [
PluginHostService,
PluginRegistryService,
{
provide: PluginCapabilityService,
useValue: {
missing: vi.fn(() => [])
}
},
{
provide: PluginDesktopStateService,
useValue: {
readJson: vi.fn(async (_key: string, fallback: unknown) => fallback),
writeJson: vi.fn(async () => undefined)
}
},
{
provide: PluginClientApiService,
useValue: {
createApi: vi.fn(() => ({}))
}
},
{
provide: PluginLoggerService,
useValue: {
error: vi.fn(),
info: vi.fn(),
warn: vi.fn()
}
},
{
provide: PluginUiRegistryService,
useValue: {
unregisterPlugin: vi.fn()
}
},
{
provide: LocalPluginDiscoveryService,
useValue: {
discoverManifests: vi.fn(async () => readDiscoveryResult())
}
}
]
});
return injector.get(PluginHostService);
}
function createTestPluginManifest(): TojuPluginManifest {
return {
apiVersion: '1.0.0',
capabilities: [
'storage.serverData.read',
'storage.serverData.write',
'events.server.publish'
],
compatibility: {
minimumTojuVersion: '1.0.0'
},
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'
}
],
id: 'e2e.plugin-api',
kind: 'client',
schemaVersion: 1,
title: 'E2E Plugin API Fixture',
version: '1.0.0'
};
}

View File

@@ -0,0 +1,423 @@
import { Injectable, inject } from '@angular/core';
import { environment } from '../../../../../environments/environment';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import type { TojuPluginManifest } from '../../../../shared-kernel';
import {
DEVELOPMENT_PLUGIN_ENTRYPOINT,
DEVELOPMENT_PLUGIN_MANIFEST,
DEVELOPMENT_PLUGIN_MODULE
} from '../../development/development-plugin';
import type {
TojuClientPluginModule,
TojuPluginActivationContext,
TojuPluginDisposable
} from '../../domain/models/plugin-api.models';
import type {
LocalPluginDiscoveryError,
LocalPluginRegistrationResult,
RegisteredPlugin
} from '../../domain/models/plugin-runtime.models';
import { LocalPluginDiscoveryService } from '../../infrastructure/local-plugin-discovery.service';
import { PluginCapabilityService } from './plugin-capability.service';
import { PluginDesktopStateService } from './plugin-desktop-state.service';
import { PluginClientApiService } from './plugin-client-api.service';
import { PluginLoggerService } from './plugin-logger.service';
import { PluginRegistryService } from './plugin-registry.service';
import { PluginUiRegistryService } from './plugin-ui-registry.service';
interface ActivePluginRuntime {
context: TojuPluginActivationContext;
moduleObjectUrl?: string;
module: TojuClientPluginModule;
}
const STORAGE_KEY_PLUGIN_ACTIVATION = 'metoyou_plugin_activation_state';
@Injectable({ providedIn: 'root' })
export class PluginHostService {
private readonly apiFactory = inject(PluginClientApiService);
private readonly capabilities = inject(PluginCapabilityService);
private readonly desktopState = inject(PluginDesktopStateService);
private readonly electronBridge = inject(ElectronBridgeService, { optional: true });
private readonly localDiscovery = inject(LocalPluginDiscoveryService);
private readonly logger = inject(PluginLoggerService);
private readonly registry = inject(PluginRegistryService);
private readonly uiRegistry = inject(PluginUiRegistryService);
private readonly activePlugins = new Map<string, ActivePluginRuntime>();
private readonly activationStateReady: Promise<void>;
private activatedPluginIds = new Set<string>();
constructor() {
this.registerDevelopmentPlugin();
this.activationStateReady = this.loadActivationState();
}
registerLocalManifest(manifestValue: unknown, sourcePath?: string): RegisteredPlugin {
return this.registry.registerManifest(manifestValue, sourcePath);
}
async discoverLocalPlugins(): Promise<LocalPluginRegistrationResult> {
const discovery = await this.localDiscovery.discoverManifests();
const registered: RegisteredPlugin[] = [];
const errors: LocalPluginDiscoveryError[] = [...discovery.errors];
for (const descriptor of discovery.plugins) {
try {
registered.push(this.registerLocalManifest(descriptor.manifest, descriptor.pluginRootUrl ?? descriptor.pluginRoot));
} catch (error) {
errors.push({
manifestPath: descriptor.manifestPath,
message: error instanceof Error ? error.message : 'Plugin manifest validation failed',
pluginRoot: descriptor.pluginRoot
});
}
}
return {
discovery,
errors,
registered
};
}
getReadyManifests(): TojuPluginManifest[] {
return this.registry.loadOrder().ordered;
}
async activateReadyPlugins(): Promise<void> {
await this.activationStateReady;
const activated: TojuPluginActivationContext[] = [];
for (const manifest of this.registry.loadOrder().ordered) {
const entry = this.registry.find(manifest.id);
if (!entry || !entry.enabled || this.activePlugins.has(manifest.id)) {
continue;
}
await this.activatePlugin(entry);
const active = this.activePlugins.get(manifest.id);
if (active) {
activated.push(active.context);
this.activatedPluginIds.add(active.context.pluginId);
}
}
await this.saveActivationState();
await this.runReadyHooks(activated);
}
async activatePluginById(pluginId: string): Promise<void> {
await this.activationStateReady;
if (this.activePlugins.has(pluginId)) {
this.activatedPluginIds.add(pluginId);
await this.saveActivationState();
return;
}
const entry = this.registry.find(pluginId);
if (!entry?.enabled) {
return;
}
await this.activatePlugin(entry);
const active = this.activePlugins.get(pluginId);
if (!active) {
return;
}
this.activatedPluginIds.add(pluginId);
await this.saveActivationState();
await this.runReadyHooks([active.context]);
}
async rememberActivation(pluginId: string): Promise<void> {
await this.activationStateReady;
this.activatedPluginIds.add(pluginId);
await this.saveActivationState();
}
async activatePersistedPlugins(): Promise<void> {
await this.activationStateReady;
const activated: TojuPluginActivationContext[] = [];
for (const manifest of this.registry.loadOrder().ordered) {
if (!this.activatedPluginIds.has(manifest.id) || this.activePlugins.has(manifest.id)) {
continue;
}
const entry = this.registry.find(manifest.id);
if (!entry?.enabled) {
continue;
}
await this.activatePlugin(entry);
const active = this.activePlugins.get(manifest.id);
if (active) {
activated.push(active.context);
}
}
await this.runReadyHooks(activated);
}
isPluginActive(pluginId: string): boolean {
return this.activePlugins.has(pluginId);
}
async deactivatePlugin(pluginId: string, options: { forgetActivation?: boolean } = {}): Promise<void> {
await this.activationStateReady;
const active = this.activePlugins.get(pluginId);
if (!active) {
if (options.forgetActivation) {
this.activatedPluginIds.delete(pluginId);
await this.saveActivationState();
}
this.registry.setState(pluginId, 'unloaded');
this.uiRegistry.unregisterPlugin(pluginId);
return;
}
this.registry.setState(pluginId, 'unloading');
try {
await active.module.deactivate?.(active.context);
} catch (error) {
this.logger.warn(pluginId, 'Plugin deactivate failed', error);
}
for (const disposable of [...active.context.subscriptions].reverse()) {
safeDispose(disposable, pluginId, this.logger);
}
this.uiRegistry.unregisterPlugin(pluginId);
this.activePlugins.delete(pluginId);
this.revokeModuleObjectUrl(pluginId);
if (options.forgetActivation) {
this.activatedPluginIds.delete(pluginId);
await this.saveActivationState();
}
this.registry.setState(pluginId, 'unloaded');
}
async deactivateAll(): Promise<void> {
const pluginIds = Array.from(this.activePlugins.keys()).reverse();
for (const pluginId of pluginIds) {
await this.deactivatePlugin(pluginId);
}
}
async reloadPlugin(pluginId: string): Promise<void> {
await this.deactivatePlugin(pluginId);
const entry = this.registry.find(pluginId);
if (entry?.enabled) {
await this.activatePlugin(entry);
if (this.activePlugins.has(pluginId)) {
this.activatedPluginIds.add(pluginId);
await this.saveActivationState();
}
}
}
markLoaded(pluginId: string): void {
this.registry.setState(pluginId, 'loaded');
}
markFailed(pluginId: string): void {
this.registry.setState(pluginId, 'failed');
}
private async runReadyHooks(contexts: TojuPluginActivationContext[]): Promise<void> {
for (const context of contexts) {
const active = this.activePlugins.get(context.pluginId);
if (!active?.module.ready) {
continue;
}
try {
await active.module.ready(context);
this.registry.setState(context.pluginId, 'ready');
} catch (error) {
this.failPlugin(context.pluginId, error);
}
}
}
private async activatePlugin(entry: RegisteredPlugin): Promise<void> {
const manifest = entry.manifest;
const missingCapabilities = this.capabilities.missing(manifest);
if (missingCapabilities.length > 0) {
this.registry.setFailed(manifest.id, `Missing capabilities: ${missingCapabilities.join(', ')}`);
this.logger.warn(manifest.id, 'Plugin blocked by missing capability grants', missingCapabilities);
return;
}
if (!manifest.entrypoint) {
this.registry.setState(manifest.id, 'ready');
return;
}
this.registry.setState(manifest.id, 'loading');
try {
const { module, moduleObjectUrl } = await this.loadPluginModule(manifest, entry.sourcePath);
const context: TojuPluginActivationContext = {
api: this.apiFactory.createApi(manifest),
manifest,
pluginId: manifest.id,
subscriptions: []
};
await module.activate?.(context);
this.activePlugins.set(manifest.id, { context, module, moduleObjectUrl });
this.registry.setState(manifest.id, 'loaded');
this.logger.info(manifest.id, 'Plugin activated');
} catch (error) {
this.failPlugin(manifest.id, error);
}
}
private failPlugin(pluginId: string, error: unknown): void {
const message = error instanceof Error ? error.message : 'Plugin activation failed';
this.registry.setFailed(pluginId, message);
this.logger.error(pluginId, message, error);
this.uiRegistry.unregisterPlugin(pluginId);
this.activePlugins.delete(pluginId);
this.revokeModuleObjectUrl(pluginId);
}
private async loadPluginModule(
manifest: TojuPluginManifest,
sourcePath?: string
): Promise<{ module: TojuClientPluginModule; moduleObjectUrl?: string }> {
if (manifest.entrypoint === DEVELOPMENT_PLUGIN_ENTRYPOINT) {
return { module: DEVELOPMENT_PLUGIN_MODULE };
}
const entrypointUrl = this.resolveEntrypoint(manifest, sourcePath);
if (entrypointUrl.startsWith('file://')) {
const moduleObjectUrl = await this.createLocalModuleObjectUrl(entrypointUrl);
const module = await import(/* @vite-ignore */ moduleObjectUrl) as TojuClientPluginModule;
return { module, moduleObjectUrl };
}
return {
module: await import(/* @vite-ignore */ entrypointUrl) as TojuClientPluginModule
};
}
private async createLocalModuleObjectUrl(entrypointUrl: string): Promise<string> {
const api = this.electronBridge?.getApi();
if (!api) {
throw new Error('Local plugin entrypoints require the desktop app');
}
const base64Data = await api.readFile(fileUrlToPath(entrypointUrl));
const bytes = Uint8Array.from(atob(base64Data), (character) => character.charCodeAt(0));
const source = new TextDecoder().decode(bytes);
return URL.createObjectURL(new Blob([source], { type: 'text/javascript' }));
}
private revokeModuleObjectUrl(pluginId: string): void {
const moduleObjectUrl = this.activePlugins.get(pluginId)?.moduleObjectUrl;
if (moduleObjectUrl) {
URL.revokeObjectURL(moduleObjectUrl);
}
}
private registerDevelopmentPlugin(): void {
if (environment.production) {
return;
}
try {
this.registry.registerManifest(DEVELOPMENT_PLUGIN_MANIFEST, DEVELOPMENT_PLUGIN_ENTRYPOINT);
} catch (error) {
this.logger.warn(DEVELOPMENT_PLUGIN_MANIFEST.id, 'Development plugin registration failed', error);
}
}
private async loadActivationState(): Promise<void> {
const state = await this.desktopState.readJson<{ activatedPluginIds?: string[] }>(STORAGE_KEY_PLUGIN_ACTIVATION, {});
this.activatedPluginIds = new Set(
Array.isArray(state.activatedPluginIds)
? state.activatedPluginIds.filter((pluginId): pluginId is string => typeof pluginId === 'string')
: []
);
}
private async saveActivationState(): Promise<void> {
await this.desktopState.writeJson(STORAGE_KEY_PLUGIN_ACTIVATION, {
activatedPluginIds: Array.from(this.activatedPluginIds).sort()
});
}
private resolveEntrypoint(manifest: TojuPluginManifest, sourcePath?: string): string {
if (!manifest.entrypoint) {
throw new Error('Plugin entrypoint is missing');
}
try {
return new URL(manifest.entrypoint).toString();
} catch {}
if (sourcePath?.startsWith('http://') || sourcePath?.startsWith('https://') || sourcePath?.startsWith('file://')) {
return new URL(manifest.entrypoint, sourcePath).toString();
}
if (manifest.entrypoint.startsWith('/')) {
return manifest.entrypoint;
}
throw new Error(`Plugin ${manifest.id} has no browser-importable entrypoint`);
}
}
function fileUrlToPath(fileUrl: string): string {
const url = new URL(fileUrl);
const decodedPath = decodeURIComponent(url.pathname);
if (/^\/[A-Za-z]:\//.test(decodedPath)) {
return decodedPath.slice(1).replace(/\//g, '\\');
}
return decodedPath;
}
function safeDispose(disposable: TojuPluginDisposable, pluginId: string, logger: PluginLoggerService): void {
try {
disposable.dispose();
} catch (error) {
logger.warn(pluginId, 'Plugin disposable failed', error);
}
}

View File

@@ -0,0 +1,64 @@
import {
Injectable,
Signal,
computed,
signal
} from '@angular/core';
export type PluginLogLevel = 'debug' | 'error' | 'info' | 'warn';
export interface PluginLogEntry {
data?: unknown;
level: PluginLogLevel;
message: string;
pluginId: string;
timestamp: number;
}
@Injectable({ providedIn: 'root' })
export class PluginLoggerService {
readonly entries: Signal<PluginLogEntry[]>;
private readonly entriesSignal = signal<PluginLogEntry[]>([]);
constructor() {
this.entries = this.entriesSignal.asReadonly();
}
entriesFor(pluginId: string): Signal<PluginLogEntry[]> {
return computed(() => this.entries().filter((entry) => entry.pluginId === pluginId));
}
debug(pluginId: string, message: string, data?: unknown): void {
this.add(pluginId, 'debug', message, data);
}
error(pluginId: string, message: string, data?: unknown): void {
this.add(pluginId, 'error', message, data);
}
info(pluginId: string, message: string, data?: unknown): void {
this.add(pluginId, 'info', message, data);
}
warn(pluginId: string, message: string, data?: unknown): void {
this.add(pluginId, 'warn', message, data);
}
clear(pluginId?: string): void {
this.entriesSignal.update((entries) => pluginId ? entries.filter((entry) => entry.pluginId !== pluginId) : []);
}
private add(pluginId: string, level: PluginLogLevel, message: string, data?: unknown): void {
this.entriesSignal.update((entries) => [
...entries,
{
data,
level,
message,
pluginId,
timestamp: Date.now()
}
].slice(-500));
}
}

View File

@@ -0,0 +1,236 @@
import { Injectable, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { Subscription } from 'rxjs';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import type {
ChatEvent,
Message,
User
} from '../../../../shared-kernel';
import { selectCurrentRoomMessages } from '../../../../store/messages/messages.selectors';
import { selectCurrentRoomId } from '../../../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
import type {
PluginApiMessageBusEnvelope,
PluginApiMessageBusLatestRequest,
PluginApiMessageBusPublishRequest,
PluginApiMessageBusSubscription,
TojuPluginDisposable
} from '../../domain/models/plugin-api.models';
const DEFAULT_LATEST_MESSAGE_LIMIT = 50;
const MAX_LATEST_MESSAGE_LIMIT = 250;
const LATEST_MESSAGES_TOPIC = 'latest-messages';
@Injectable({ providedIn: 'root' })
export class PluginMessageBusService {
private readonly realtime = inject(RealtimeSessionFacade);
private readonly store = inject(Store);
private readonly currentMessages = this.store.selectSignal(selectCurrentRoomMessages);
private readonly currentRoomId = this.store.selectSignal(selectCurrentRoomId);
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
private readonly localSubscriptions = new Map<string, Set<PluginApiMessageBusSubscription>>();
publish(pluginId: string, request: PluginApiMessageBusPublishRequest): PluginApiMessageBusEnvelope {
const envelope = this.createEnvelope(pluginId, request.topic, request.payload, request, request.includeLatestMessages === true);
this.sendEnvelope(envelope, request.targetPeerId);
if (request.includeSelf) {
this.dispatchLocal(envelope);
}
return envelope;
}
sendLatestMessages(pluginId: string, request: PluginApiMessageBusLatestRequest = {}): PluginApiMessageBusEnvelope {
const envelope = this.createEnvelope(pluginId, request.topic ?? LATEST_MESSAGES_TOPIC, undefined, request, true);
this.sendEnvelope(envelope, request.targetPeerId);
return envelope;
}
subscribe(pluginId: string, subscription: PluginApiMessageBusSubscription): TojuPluginDisposable {
const pluginSubscriptions = this.localSubscriptions.get(pluginId) ?? new Set<PluginApiMessageBusSubscription>();
const realtimeSubscription = new Subscription();
pluginSubscriptions.add(subscription);
this.localSubscriptions.set(pluginId, pluginSubscriptions);
realtimeSubscription.add(this.realtime.onMessageReceived.subscribe((event) => {
const envelope = readPluginMessageBusEnvelope(event);
if (!envelope || envelope.pluginId !== pluginId || !matchesSubscription(envelope, subscription)) {
return;
}
subscription.handler(envelope);
}));
if (subscription.replayLatest) {
subscription.handler(this.createEnvelope(
pluginId,
subscription.topic ?? LATEST_MESSAGES_TOPIC,
undefined,
{
channelId: subscription.channelId,
limit: subscription.latestMessageLimit
},
true
));
}
return {
dispose: () => {
realtimeSubscription.unsubscribe();
pluginSubscriptions.delete(subscription);
if (pluginSubscriptions.size === 0) {
this.localSubscriptions.delete(pluginId);
}
}
};
}
private createEnvelope(
pluginId: string,
topic: string,
payload: unknown,
request: PluginApiMessageBusLatestRequest,
includeMessages: boolean
): PluginApiMessageBusEnvelope {
const currentUser = this.currentUser();
const envelope: PluginApiMessageBusEnvelope = {
eventId: createId(),
pluginId,
roomId: this.requireRoomId(),
sentAt: Date.now(),
sourceUserId: readUserId(currentUser),
topic
};
if (request.channelId) {
envelope.channelId = request.channelId;
}
if (payload !== undefined) {
envelope.payload = payload;
}
if (includeMessages) {
envelope.messages = this.latestMessages(request);
}
return envelope;
}
private latestMessages(request: PluginApiMessageBusLatestRequest): Message[] {
const limit = clampLimit(request.limit);
const sinceTimestamp = typeof request.sinceTimestamp === 'number' ? request.sinceTimestamp : null;
return this.currentMessages()
.filter((message) => !request.channelId || message.channelId === request.channelId)
.filter((message) => request.includeDeleted || !message.isDeleted)
.filter((message) => sinceTimestamp === null || message.timestamp > sinceTimestamp)
.slice(-limit);
}
private sendEnvelope(envelope: PluginApiMessageBusEnvelope, targetPeerId?: string): void {
const event: ChatEvent = {
pluginMessage: envelope,
roomId: envelope.roomId,
timestamp: envelope.sentAt,
type: 'plugin-message-bus'
};
if (targetPeerId) {
this.realtime.sendToPeer(targetPeerId, event);
return;
}
this.realtime.broadcastMessage(event);
}
private dispatchLocal(envelope: PluginApiMessageBusEnvelope): void {
for (const subscription of this.localSubscriptions.get(envelope.pluginId) ?? []) {
if (matchesSubscription(envelope, subscription)) {
subscription.handler(envelope);
}
}
}
private requireRoomId(): string {
const roomId = this.currentRoomId();
if (!roomId) {
throw new Error('No active server');
}
return roomId;
}
}
function readPluginMessageBusEnvelope(event: ChatEvent): PluginApiMessageBusEnvelope | null {
if (event.type !== 'plugin-message-bus' || !isRecord(event.pluginMessage)) {
return null;
}
const envelope = event.pluginMessage;
if (typeof envelope['eventId'] !== 'string'
|| typeof envelope['pluginId'] !== 'string'
|| typeof envelope['roomId'] !== 'string'
|| typeof envelope['sentAt'] !== 'number'
|| typeof envelope['topic'] !== 'string') {
return null;
}
return {
channelId: typeof envelope['channelId'] === 'string' ? envelope['channelId'] : undefined,
eventId: envelope['eventId'],
messages: Array.isArray(envelope['messages']) ? envelope['messages'].filter(isMessage) : undefined,
payload: envelope['payload'],
pluginId: envelope['pluginId'],
roomId: envelope['roomId'],
sentAt: envelope['sentAt'],
sourcePeerId: event.fromPeerId,
sourceUserId: typeof envelope['sourceUserId'] === 'string' ? envelope['sourceUserId'] : undefined,
topic: envelope['topic']
};
}
function matchesSubscription(envelope: PluginApiMessageBusEnvelope, subscription: PluginApiMessageBusSubscription): boolean {
return (!subscription.topic || subscription.topic === envelope.topic)
&& (!subscription.channelId || subscription.channelId === envelope.channelId);
}
function clampLimit(limit: number | undefined): number {
if (typeof limit !== 'number' || !Number.isFinite(limit)) {
return DEFAULT_LATEST_MESSAGE_LIMIT;
}
return Math.max(1, Math.min(MAX_LATEST_MESSAGE_LIMIT, Math.floor(limit)));
}
function readUserId(user: User | null): string | undefined {
return user?.oderId || user?.id || undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
function isMessage(value: unknown): value is Message {
return isRecord(value)
&& typeof value['id'] === 'string'
&& typeof value['roomId'] === 'string'
&& typeof value['senderId'] === 'string'
&& typeof value['content'] === 'string'
&& typeof value['timestamp'] === 'number';
}
function createId(): string {
return globalThis.crypto?.randomUUID?.() ?? `plugin-bus-${Date.now()}-${Math.random().toString(36)
.slice(2)}`;
}

View File

@@ -0,0 +1,155 @@
import {
Injectable,
type Signal,
computed,
inject,
signal
} from '@angular/core';
import {
RegisteredPlugin,
type PluginLoadOrderResult,
type PluginRuntimeState
} from '../../domain/models/plugin-runtime.models';
import { resolvePluginLoadOrder } from '../../domain/logic/plugin-dependency-resolver.logic';
import { validateTojuPluginManifest } from '../../domain/logic/plugin-manifest-validation.logic';
import { PluginDesktopStateService } from './plugin-desktop-state.service';
const STORAGE_KEY_PLUGIN_REGISTRY_STATE = 'metoyou_plugin_registry_state';
@Injectable({ providedIn: 'root' })
export class PluginRegistryService {
readonly entries: Signal<RegisteredPlugin[]>;
readonly enabledEntries: Signal<RegisteredPlugin[]>;
readonly loadOrder: Signal<PluginLoadOrderResult>;
private readonly desktopState = inject(PluginDesktopStateService);
private readonly entriesSignal = signal<RegisteredPlugin[]>([]);
private disabledPluginIds = new Set<string>();
constructor() {
this.entries = this.entriesSignal.asReadonly();
this.enabledEntries = computed(() => this.entries().filter((entry) => entry.enabled));
this.loadOrder = computed<PluginLoadOrderResult>(() =>
resolvePluginLoadOrder(this.entries().map((entry) => ({ enabled: entry.enabled, manifest: entry.manifest })))
);
void this.loadRegistryState();
}
clear(): void {
this.entriesSignal.set([]);
}
registerManifest(manifestValue: unknown, sourcePath?: string): RegisteredPlugin {
const validation = validateTojuPluginManifest(manifestValue);
if (!validation.manifest) {
throw new Error(validation.issues.map((issue) => `${issue.path}: ${issue.message}`).join('\n'));
}
const existingIndex = this.entries().findIndex((entry) => entry.manifest.id === validation.manifest?.id);
const entry: RegisteredPlugin = {
enabled: !this.disabledPluginIds.has(validation.manifest.id),
manifest: validation.manifest,
sourcePath,
state: validation.valid ? 'validated' : 'blocked',
validationIssues: validation.issues
};
if (existingIndex >= 0) {
this.entriesSignal.update((entries) => entries.map((candidate, index) => index === existingIndex ? entry : candidate));
} else {
this.entriesSignal.update((entries) => [...entries, entry]);
}
this.syncLoadState();
return entry;
}
setEnabled(pluginId: string, enabled: boolean): void {
if (enabled) {
this.disabledPluginIds.delete(pluginId);
} else {
this.disabledPluginIds.add(pluginId);
}
this.entriesSignal.update((entries) => entries.map((entry) => entry.manifest.id === pluginId
? {
...entry,
enabled,
state: enabled ? entry.state === 'disabled' ? 'validated' : entry.state : 'disabled'
}
: entry));
this.syncLoadState();
void this.saveRegistryState();
}
unregister(pluginId: string): void {
this.entriesSignal.update((entries) => entries.filter((entry) => entry.manifest.id !== pluginId));
this.syncLoadState();
}
setState(pluginId: string, state: PluginRuntimeState): void {
this.entriesSignal.update((entries) => entries.map((entry) => entry.manifest.id === pluginId
? { ...entry, error: undefined, state }
: entry));
}
setFailed(pluginId: string, error: string): void {
this.entriesSignal.update((entries) => entries.map((entry) => entry.manifest.id === pluginId
? { ...entry, error, state: 'failed' }
: entry));
}
find(pluginId: string): RegisteredPlugin | undefined {
return this.entries().find((entry) => entry.manifest.id === pluginId);
}
private syncLoadState(): void {
const loadOrder = this.loadOrder();
const blockedIds = new Set(loadOrder.blocked.map((blocker) => blocker.pluginId));
const loadIndexes = new Map(loadOrder.ordered.map((manifest, index) => [manifest.id, index]));
this.entriesSignal.update((entries) => entries.map((entry) => {
const loadIndex = loadIndexes.get(entry.manifest.id);
if (!entry.enabled) {
return { ...entry, loadIndex: undefined, state: 'disabled' };
}
if (blockedIds.has(entry.manifest.id)) {
return { ...entry, loadIndex: undefined, state: 'blocked' };
}
return {
...entry,
loadIndex,
state: loadIndex === undefined ? entry.state : 'ready'
};
}));
}
private async loadRegistryState(): Promise<void> {
const state = await this.desktopState.readJson<{ disabledPluginIds?: string[] }>(STORAGE_KEY_PLUGIN_REGISTRY_STATE, {});
this.disabledPluginIds = new Set(
Array.isArray(state.disabledPluginIds)
? state.disabledPluginIds.filter((pluginId): pluginId is string => typeof pluginId === 'string')
: []
);
this.entriesSignal.update((entries) => entries.map((entry) => ({
...entry,
enabled: !this.disabledPluginIds.has(entry.manifest.id)
})));
this.syncLoadState();
}
private async saveRegistryState(): Promise<void> {
await this.desktopState.writeJson(STORAGE_KEY_PLUGIN_REGISTRY_STATE, {
disabledPluginIds: Array.from(this.disabledPluginIds).sort()
});
}
}

View File

@@ -0,0 +1,202 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
DestroyRef,
Injectable,
computed,
effect,
inject,
signal
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store';
import type {
PluginRequirementSummary,
PluginRequirementsSnapshot,
TojuPluginManifest
} from '../../../../shared-kernel';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { selectCurrentRoomId } from '../../../../store/rooms/rooms.selectors';
import { ServerDirectoryFacade } from '../../../server-directory';
import { PluginRegistryService } from './plugin-registry.service';
import { PluginRequirementService } from './plugin-requirement.service';
export type PluginRequirementComparisonStatus =
| 'blockedByServer'
| 'disabled'
| 'enabled'
| 'incompatible'
| 'missing'
| 'notRequired';
export interface PluginRequirementComparison {
installed?: TojuPluginManifest;
pluginId: string;
requirement?: PluginRequirementSummary;
status: PluginRequirementComparisonStatus;
}
@Injectable({ providedIn: 'root' })
export class PluginRequirementStateService {
private readonly destroyRef = inject(DestroyRef);
private readonly pluginRequirements = inject(PluginRequirementService);
private readonly realtime = inject(RealtimeSessionFacade);
private readonly registry = inject(PluginRegistryService);
private readonly serverDirectory = inject(ServerDirectoryFacade);
private readonly store = inject(Store);
private readonly currentRoomId = this.store.selectSignal(selectCurrentRoomId);
private readonly snapshotsSignal = signal<Record<string, PluginRequirementsSnapshot>>({});
private readonly refreshErrorsSignal = signal<Record<string, string>>({});
readonly currentSnapshot = computed(() => {
const roomId = this.currentRoomId();
return roomId ? this.snapshotsSignal()[roomId] ?? null : null;
});
readonly refreshErrors = this.refreshErrorsSignal.asReadonly();
readonly comparisons = computed<PluginRequirementComparison[]>(() => {
const snapshot = this.currentSnapshot();
const installedEntries = this.registry.entries();
const installedById = new Map(installedEntries.map((entry) => [entry.manifest.id, entry]));
const requirementIds = new Set(snapshot?.requirements.map((requirement) => requirement.pluginId) ?? []);
const comparisons: PluginRequirementComparison[] = [];
for (const requirement of snapshot?.requirements ?? []) {
const entry = installedById.get(requirement.pluginId);
comparisons.push({
installed: entry?.manifest,
pluginId: requirement.pluginId,
requirement,
status: this.resolveStatus(requirement, entry)
});
}
for (const entry of installedEntries) {
if (!requirementIds.has(entry.manifest.id)) {
comparisons.push({
installed: entry.manifest,
pluginId: entry.manifest.id,
status: entry.enabled ? 'enabled' : 'disabled'
});
}
}
return comparisons.sort((left, right) => left.pluginId.localeCompare(right.pluginId));
});
constructor() {
this.realtime.onSignalingMessage
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((message) => {
if ((message.type === 'plugin_requirements' || message.type === 'plugin_requirements_changed') && isSnapshotMessage(message)) {
this.setSnapshot(message.serverId, message.snapshot);
}
});
effect(() => {
const roomId = this.currentRoomId();
if (roomId) {
void this.refreshCurrent();
}
});
}
async refreshCurrent(): Promise<void> {
const roomId = this.currentRoomId();
if (!roomId) {
return;
}
try {
const apiBaseUrl = this.serverDirectory.getApiBaseUrl();
const snapshot = await new Promise<PluginRequirementsSnapshot>((resolve, reject) => {
this.pluginRequirements.getSnapshot(apiBaseUrl, roomId).subscribe({
error: reject,
next: resolve
});
});
this.setSnapshot(roomId, snapshot);
this.refreshErrorsSignal.update((errors) => {
const { [roomId]: _removed, ...next } = errors;
return next;
});
} catch (error) {
this.refreshErrorsSignal.update((errors) => ({
...errors,
[roomId]: error instanceof Error ? error.message : 'Unable to refresh plugin requirements'
}));
}
}
comparisonFor(pluginId: string): PluginRequirementComparison | null {
return this.comparisons().find((comparison) => comparison.pluginId === pluginId) ?? null;
}
private setSnapshot(serverId: string, snapshot: PluginRequirementsSnapshot): void {
this.snapshotsSignal.update((snapshots) => ({
...snapshots,
[serverId]: snapshot
}));
}
private resolveStatus(
requirement: PluginRequirementSummary,
entry: { enabled: boolean; manifest: TojuPluginManifest } | undefined
): PluginRequirementComparisonStatus {
if (requirement.status === 'blocked') {
return 'blockedByServer';
}
if (requirement.status === 'incompatible') {
return 'incompatible';
}
if (!entry) {
return 'missing';
}
if (!entry.enabled) {
return 'disabled';
}
if (requirement.versionRange && !isVersionCompatible(entry.manifest.version, requirement.versionRange)) {
return 'incompatible';
}
return 'enabled';
}
}
function isSnapshotMessage(message: unknown): message is { serverId: string; snapshot: PluginRequirementsSnapshot } {
const record = message as Record<string, unknown>;
return typeof record['serverId'] === 'string'
&& !!record['snapshot']
&& typeof record['snapshot'] === 'object';
}
function isVersionCompatible(version: string, versionRange: string): boolean {
const normalizedRange = versionRange.trim();
if (!normalizedRange || normalizedRange === '*') {
return true;
}
if (normalizedRange.startsWith('^')) {
return version.split('.')[0] === normalizedRange.slice(1).split('.')[0];
}
if (normalizedRange.startsWith('~')) {
const [major, minor] = version.split('.');
const [rangeMajor, rangeMinor] = normalizedRange.slice(1).split('.');
return major === rangeMajor && minor === rangeMinor;
}
return version === normalizedRange;
}

View File

@@ -0,0 +1,77 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import type {
PluginEventDefinitionSummary,
PluginRequirementStatus,
PluginRequirementSummary,
PluginRequirementsSnapshot,
TojuPluginManifest
} from '../../../../shared-kernel';
export interface UpsertPluginRequirementRequest {
actorUserId: string;
installUrl?: string;
manifest?: TojuPluginManifest;
reason?: string;
sourceUrl?: string;
status: PluginRequirementStatus;
versionRange?: string;
}
export interface UpsertPluginEventDefinitionRequest {
actorUserId: string;
direction: 'clientToServer' | 'serverRelay' | 'p2pHint';
maxPayloadBytes?: number;
rateLimitJson?: string;
schemaJson?: string;
scope: 'server' | 'channel' | 'user' | 'plugin';
}
@Injectable({ providedIn: 'root' })
export class PluginRequirementService {
private readonly http = inject(HttpClient);
getSnapshot(apiBaseUrl: string, serverId: string): Observable<PluginRequirementsSnapshot> {
return this.http.get<PluginRequirementsSnapshot>(`${this.apiBase(apiBaseUrl)}/servers/${encodeURIComponent(serverId)}/plugins`);
}
upsertRequirement(
apiBaseUrl: string,
serverId: string,
pluginId: string,
request: UpsertPluginRequirementRequest
): Observable<{ requirement: PluginRequirementSummary }> {
return this.http.put<{ requirement: PluginRequirementSummary }>(
`${this.apiBase(apiBaseUrl)}/servers/${encodeURIComponent(serverId)}/plugins/${encodeURIComponent(pluginId)}/requirement`,
request
);
}
deleteRequirement(apiBaseUrl: string, serverId: string, pluginId: string, actorUserId: string): Observable<{ ok: boolean }> {
return this.http.delete<{ ok: boolean }>(
`${this.apiBase(apiBaseUrl)}/servers/${encodeURIComponent(serverId)}/plugins/${encodeURIComponent(pluginId)}/requirement`,
{ body: { actorUserId } }
);
}
upsertEventDefinition(
apiBaseUrl: string,
serverId: string,
pluginId: string,
eventName: string,
request: UpsertPluginEventDefinitionRequest
): Observable<{ eventDefinition: PluginEventDefinitionSummary }> {
const eventUrl = `${this.apiBase(apiBaseUrl)}/servers/${encodeURIComponent(serverId)}`
+ `/plugins/${encodeURIComponent(pluginId)}/events/${encodeURIComponent(eventName)}`;
return this.http.put<{ eventDefinition: PluginEventDefinitionSummary }>(
eventUrl,
request
);
}
private apiBase(apiBaseUrl: string): string {
return apiBaseUrl.replace(/\/$/, '');
}
}

View File

@@ -0,0 +1,150 @@
import { Injectable, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { getUserScopedStorageKey } from '../../../../core/storage/current-user-storage';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { selectCurrentRoomId } from '../../../../store/rooms/rooms.selectors';
const STORAGE_PREFIX_PLUGIN_LOCAL = 'metoyou_plugin_local';
const STORAGE_PREFIX_PLUGIN_SERVER_LOCAL = 'metoyou_plugin_server_local';
type PluginDataScope = 'local' | 'server';
@Injectable({ providedIn: 'root' })
export class PluginStorageService {
private readonly electronBridge = inject(ElectronBridgeService);
private readonly store = inject(Store);
private readonly currentRoomId = this.store.selectSignal(selectCurrentRoomId);
getLocal(pluginId: string, key: string): unknown {
return this.read(`${STORAGE_PREFIX_PLUGIN_LOCAL}:${pluginId}:${key}`);
}
removeLocal(pluginId: string, key: string): void {
localStorage.removeItem(getUserScopedStorageKey(`${STORAGE_PREFIX_PLUGIN_LOCAL}:${pluginId}:${key}`));
void this.deleteFromClientDatabase(pluginId, 'local', key);
}
setLocal(pluginId: string, key: string, value: unknown): void {
this.write(`${STORAGE_PREFIX_PLUGIN_LOCAL}:${pluginId}:${key}`, value);
void this.writeToClientDatabase(pluginId, 'local', key, value);
}
async readClientData(pluginId: string, key: string): Promise<unknown> {
return await this.readScopedData(pluginId, 'local', key);
}
async removeClientData(pluginId: string, key: string): Promise<void> {
localStorage.removeItem(getUserScopedStorageKey(`${STORAGE_PREFIX_PLUGIN_LOCAL}:${pluginId}:${key}`));
await this.deleteFromClientDatabase(pluginId, 'local', key);
}
async writeClientData(pluginId: string, key: string, value: unknown): Promise<void> {
this.write(`${STORAGE_PREFIX_PLUGIN_LOCAL}:${pluginId}:${key}`, value);
await this.writeToClientDatabase(pluginId, 'local', key, value);
}
async readServerData(pluginId: string, key: string): Promise<unknown> {
return await this.readScopedData(pluginId, 'server', key, this.requireRoomId());
}
async removeServerData(pluginId: string, key: string): Promise<void> {
localStorage.removeItem(getUserScopedStorageKey(this.serverLocalKey(pluginId, key)));
await this.deleteFromClientDatabase(pluginId, 'server', key, this.requireRoomId());
}
async writeServerData(pluginId: string, key: string, value: unknown): Promise<void> {
this.write(this.serverLocalKey(pluginId, key), value);
await this.writeToClientDatabase(pluginId, 'server', key, value, this.requireRoomId());
}
private async readScopedData(pluginId: string, scope: PluginDataScope, key: string, serverId?: string): Promise<unknown> {
const api = this.electronBridge.getApi();
if (api) {
return await api.query({
type: 'get-plugin-data',
payload: {
key,
pluginId,
scope,
serverId
}
});
}
return this.read(scope === 'server'
? this.serverLocalKey(pluginId, key)
: `${STORAGE_PREFIX_PLUGIN_LOCAL}:${pluginId}:${key}`);
}
private async writeToClientDatabase(pluginId: string, scope: PluginDataScope, key: string, value: unknown, serverId?: string): Promise<void> {
const api = this.electronBridge.getApi();
if (!api) {
return;
}
await api.command({
type: 'save-plugin-data',
payload: {
key,
pluginId,
scope,
serverId,
value
}
});
}
private async deleteFromClientDatabase(pluginId: string, scope: PluginDataScope, key: string, serverId?: string): Promise<void> {
const api = this.electronBridge.getApi();
if (!api) {
return;
}
await api.command({
type: 'delete-plugin-data',
payload: {
key,
pluginId,
scope,
serverId
}
});
}
private serverLocalKey(pluginId: string, key: string): string {
const roomId = this.requireRoomId();
return `${STORAGE_PREFIX_PLUGIN_SERVER_LOCAL}:${roomId}:${pluginId}:${key}`;
}
private requireRoomId(): string {
const roomId = this.currentRoomId();
if (!roomId) {
throw new Error('No active server for plugin server data');
}
return roomId;
}
private read(key: string): unknown {
const raw = localStorage.getItem(getUserScopedStorageKey(key));
if (!raw) {
return null;
}
try {
return JSON.parse(raw) as unknown;
} catch {
return null;
}
}
private write(key: string, value: unknown): void {
localStorage.setItem(getUserScopedStorageKey(key), JSON.stringify(value));
}
}

View File

@@ -0,0 +1,261 @@
import { Injector } from '@angular/core';
import type { TojuPluginManifest } from '../../../../shared-kernel';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { PluginStoreService } from './plugin-store.service';
import { PluginHostService } from './plugin-host.service';
import { PluginDesktopStateService } from './plugin-desktop-state.service';
import { PluginRequirementService } from './plugin-requirement.service';
import { PluginRegistryService } from './plugin-registry.service';
import type { PluginStoreEntry } from '../../domain/models/plugin-store.models';
describe('PluginStoreService', () => {
let fetchMock: ReturnType<typeof vi.fn>;
let registerLocalManifest: ReturnType<typeof vi.fn>;
let unregister: ReturnType<typeof vi.fn>;
let storage: Storage;
beforeEach(() => {
storage = createMemoryStorage();
vi.stubGlobal('localStorage', storage);
fetchMock = vi.fn();
registerLocalManifest = vi.fn((manifest: TojuPluginManifest, sourcePath?: string) => ({
enabled: true,
manifest,
sourcePath,
state: 'validated',
validationIssues: []
}));
unregister = vi.fn();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
storage.clear();
vi.unstubAllGlobals();
});
it('loads plugin entries from source manifests and resolves relative links', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({
plugins: [
{
author: 'Ada Example',
description: 'Adds better channel tools.',
github: 'https://github.com/example/better-channels',
id: 'example.better-channels',
image: './images/better.png',
install: './better/toju-plugin.json',
readme: './better/README.md',
title: 'Better Channels',
version: '1.2.0'
}
],
title: 'Example Plugins'
}));
const service = createService(registerLocalManifest, unregister);
await service.addSourceUrl('https://plugins.example.test/index.json#latest');
expect(service.sourceUrls()).toEqual(['https://plugins.example.test/index.json']);
expect(service.sources()[0]?.title).toBe('Example Plugins');
expect(service.availablePlugins()).toEqual([
expect.objectContaining({
author: 'Ada Example',
githubUrl: 'https://github.com/example/better-channels',
id: 'example.better-channels',
imageUrl: 'https://plugins.example.test/images/better.png',
installUrl: 'https://plugins.example.test/better/toju-plugin.json',
readmeUrl: 'https://plugins.example.test/better/README.md',
sourceTitle: 'Example Plugins',
title: 'Better Channels',
version: '1.2.0'
})
]);
});
it('accepts local source manifest paths and resolves relative file links', async () => {
const localSourceManifest = {
plugins: [
{
description: 'Local plugin source.',
id: 'example.local-plugin',
image: './icon.svg',
install: './toju-plugin.json',
readme: './README.md',
title: 'Local Plugin',
version: '1.0.0'
}
],
title: 'Local Plugins'
};
const readFile = vi.fn(async () => toBase64(JSON.stringify(localSourceManifest)));
const service = createService(registerLocalManifest, unregister, { readFile });
await service.addSourceUrl('/home/ludde/Desktop/TestPlugin/plugin-source.json');
expect(fetchMock).not.toHaveBeenCalled();
expect(readFile).toHaveBeenCalledWith('/home/ludde/Desktop/TestPlugin/plugin-source.json');
expect(service.sourceUrls()).toEqual(['file:///home/ludde/Desktop/TestPlugin/plugin-source.json']);
expect(service.availablePlugins()).toEqual([
expect.objectContaining({
id: 'example.local-plugin',
imageUrl: 'file:///home/ludde/Desktop/TestPlugin/icon.svg',
installUrl: 'file:///home/ludde/Desktop/TestPlugin/toju-plugin.json',
readmeUrl: 'file:///home/ludde/Desktop/TestPlugin/README.md',
sourceTitle: 'Local Plugins'
})
]);
});
it('installs, detects updates, and uninstalls store plugins', async () => {
const manifest = createManifest({ version: '1.0.0' });
const plugin = createStoreEntry({ version: '1.0.0' });
fetchMock.mockResolvedValueOnce(jsonResponse(manifest));
const service = createService(registerLocalManifest, unregister);
await service.installPlugin(plugin);
expect(registerLocalManifest).toHaveBeenCalledWith(manifest, plugin.installUrl);
expect(service.installedPlugins()[0]?.manifest.id).toBe(plugin.id);
expect(service.getActionLabel(plugin)).toBe('Uninstall');
expect(service.getActionLabel(createStoreEntry({ version: '1.1.0' }))).toBe('Update');
await service.uninstallPlugin(plugin.id);
expect(unregister).toHaveBeenCalledWith(plugin.id);
expect(service.installedPlugins()).toEqual([]);
});
it('loads plugin readmes as markdown text', async () => {
const plugin = createStoreEntry({ readmeUrl: 'https://plugins.example.test/better/README.md' });
fetchMock.mockResolvedValueOnce(textResponse('# Better Channels'));
const service = createService(registerLocalManifest, unregister);
const readme = await service.loadReadme(plugin);
expect(readme).toEqual({
markdown: '# Better Channels',
pluginId: plugin.id,
title: plugin.title,
url: plugin.readmeUrl
});
});
});
function createService(
registerLocalManifest: ReturnType<typeof vi.fn>,
unregister: ReturnType<typeof vi.fn>,
electronApi: { readFile: (filePath: string) => Promise<string> } | null = null
): PluginStoreService {
const injector = Injector.create({
providers: [
PluginStoreService,
{
provide: ElectronBridgeService,
useValue: {
getApi: vi.fn(() => electronApi)
}
},
{
provide: PluginHostService,
useValue: {
activatePersistedPlugins: vi.fn(async () => {}),
deactivatePlugin: vi.fn(async () => {}),
registerLocalManifest
}
},
{
provide: PluginDesktopStateService,
useValue: {
readJson: vi.fn(async (_key: string, fallback: unknown) => fallback),
writeJson: vi.fn(async () => undefined)
}
},
{
provide: PluginRegistryService,
useValue: { unregister }
},
{
provide: PluginRequirementService,
useValue: {}
}
]
});
return injector.get(PluginStoreService);
}
function toBase64(value: string): string {
return Buffer.from(value, 'utf8').toString('base64');
}
function createManifest(overrides: Partial<TojuPluginManifest> = {}): TojuPluginManifest {
return {
apiVersion: '1.0.0',
compatibility: {
minimumTojuVersion: '1.0.0'
},
description: 'Adds better channel tools.',
entrypoint: './dist/main.js',
id: 'example.better-channels',
kind: 'client',
schemaVersion: 1,
title: 'Better Channels',
version: '1.0.0',
...overrides
};
}
function createStoreEntry(overrides: Partial<PluginStoreEntry> = {}): PluginStoreEntry {
return {
author: 'Ada Example',
description: 'Adds better channel tools.',
githubUrl: 'https://github.com/example/better-channels',
id: 'example.better-channels',
imageUrl: 'https://plugins.example.test/images/better.png',
installUrl: 'https://plugins.example.test/better/toju-plugin.json',
readmeUrl: 'https://plugins.example.test/better/README.md',
sourceTitle: 'Example Plugins',
sourceUrl: 'https://plugins.example.test/index.json',
title: 'Better Channels',
version: '1.0.0',
...overrides
};
}
function jsonResponse(value: unknown): Response {
return {
json: vi.fn(async () => value),
ok: true,
status: 200,
text: vi.fn(async () => JSON.stringify(value))
} as unknown as Response;
}
function textResponse(value: string): Response {
return {
json: vi.fn(async () => JSON.parse(value) as unknown),
ok: true,
status: 200,
text: vi.fn(async () => value)
} as unknown as Response;
}
function createMemoryStorage(): Storage {
const values = new Map<string, string>();
return {
get length(): number {
return values.size;
},
clear: vi.fn(() => values.clear()),
getItem: vi.fn((key: string) => values.get(key) ?? null),
key: vi.fn((index: number) => Array.from(values.keys())[index] ?? null),
removeItem: vi.fn((key: string) => values.delete(key)),
setItem: vi.fn((key: string, value: string) => values.set(key, value))
};
}

View File

@@ -0,0 +1,849 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
DestroyRef,
Injectable,
computed,
effect,
inject,
signal
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store';
import { firstValueFrom } from 'rxjs';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { getUserScopedStorageKey } from '../../../../core/storage/current-user-storage';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import type {
PluginRequirementSummary,
TojuPluginInstallScope,
TojuPluginManifest
} from '../../../../shared-kernel';
import { selectCurrentRoomId, selectCurrentRoomName } from '../../../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { ServerDirectoryFacade } from '../../../server-directory';
import { getPluginInstallScope } from '../../domain/logic/plugin-install-scope.logic';
import { validateTojuPluginManifest } from '../../domain/logic/plugin-manifest-validation.logic';
import type {
InstalledStorePlugin,
PersistedPluginStoreState,
PluginStoreEntry,
PluginStoreInstallState,
PluginStoreActionLabel,
PluginStoreReadme,
PluginStoreSourceResult
} from '../../domain/models/plugin-store.models';
import { PluginHostService } from './plugin-host.service';
import { PluginDesktopStateService } from './plugin-desktop-state.service';
import { PluginRequirementService } from './plugin-requirement.service';
import { PluginRegistryService } from './plugin-registry.service';
const STORE_SCHEMA_VERSION = 1;
const STORAGE_KEY_PLUGIN_STORE = 'metoyou_plugin_store';
const DEFAULT_STORE_STATE: PersistedPluginStoreState = {
installedPlugins: [],
sourceUrls: []
};
export interface PluginStoreInstallOptions {
activate?: boolean;
manifest?: TojuPluginManifest;
optional?: boolean;
serverId?: string;
}
@Injectable({ providedIn: 'root' })
export class PluginStoreService {
private readonly electronBridge = inject(ElectronBridgeService);
private readonly desktopState = inject(PluginDesktopStateService);
private readonly destroyRef = inject(DestroyRef);
private readonly host = inject(PluginHostService);
private readonly pluginRequirements = inject(PluginRequirementService);
private readonly realtime = inject(RealtimeSessionFacade, { optional: true });
private readonly registry = inject(PluginRegistryService);
private readonly serverDirectory = inject(ServerDirectoryFacade, { optional: true });
private readonly store = inject(Store, { optional: true });
private readonly currentRoomId = this.store?.selectSignal(selectCurrentRoomId) ?? null;
private readonly currentRoomName = this.store?.selectSignal(selectCurrentRoomName) ?? null;
private readonly currentUser = this.store?.selectSignal(selectCurrentUser) ?? null;
private readonly sourceUrlsSignal = signal<string[]>([]);
private readonly sourcesSignal = signal<PluginStoreSourceResult[]>([]);
private readonly clientInstalledPluginsSignal = signal<InstalledStorePlugin[]>([]);
private readonly serverInstalledPluginsSignal = signal<InstalledStorePlugin[]>([]);
private readonly loadingSignal = signal(false);
private refreshAbortController: AbortController | null = null;
private refreshVersion = 0;
private installedLoadVersion = 0;
private stateMutated = false;
readonly sourceUrls = this.sourceUrlsSignal.asReadonly();
readonly sources = this.sourcesSignal.asReadonly();
readonly installedPlugins = computed(() => {
const installedPlugins = this.clientInstalledPluginsSignal().concat(this.serverInstalledPluginsSignal());
return installedPlugins.sort(sortInstalledPlugins);
});
readonly isLoading = this.loadingSignal.asReadonly();
readonly availablePlugins = computed(() => this.sources().flatMap((source) => source.plugins));
readonly hasActiveServerInstallScope = computed(() => !!this.currentRoomId?.());
readonly installedById = computed(() => new Map(this.installedPlugins().map((plugin) => [plugin.manifest.id, plugin])));
readonly installScopeLabel = computed(() => this.currentRoomName?.() || 'this device');
constructor() {
const state = this.loadState();
this.sourceUrlsSignal.set(state.sourceUrls);
void this.applyInstalledPlugins(state.installedPlugins, 'client');
if (this.currentRoomId && this.currentUser && this.serverDirectory) {
effect(() => {
const roomId = this.currentRoomId?.() ?? null;
const actorUserId = this.currentActorUserId();
void this.loadInstalledPluginsForScope(roomId, actorUserId);
});
this.realtime?.onSignalingMessage
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((message) => {
if (isPluginRequirementsChangedMessage(message) && message.serverId === this.currentRoomId?.()) {
void this.loadInstalledPluginsForScope(message.serverId, this.currentActorUserId());
}
});
}
void this.loadDesktopState();
}
async addSourceUrl(rawUrl: string): Promise<void> {
const sourceUrl = normalizeSourceUrl(rawUrl, 'Plugin source URL');
if (this.sourceUrls().includes(sourceUrl)) {
throw new Error('Plugin source already exists');
}
this.sourceUrlsSignal.update((sourceUrls) => [...sourceUrls, sourceUrl]);
this.saveState();
await this.refreshSources();
}
async removeSourceUrl(sourceUrl: string): Promise<void> {
this.sourceUrlsSignal.update((sourceUrls) => sourceUrls.filter((candidate) => candidate !== sourceUrl));
this.sourcesSignal.update((sources) => sources.filter((source) => source.url !== sourceUrl));
this.saveState();
await this.refreshSources();
}
async refreshSources(): Promise<void> {
const currentRefresh = this.refreshVersion + 1;
const abortController = new AbortController();
this.refreshVersion = currentRefresh;
this.refreshAbortController?.abort();
this.refreshAbortController = abortController;
this.loadingSignal.set(true);
try {
const sources = await Promise.all(this.sourceUrls().map((sourceUrl) => this.loadSource(sourceUrl, abortController.signal)));
if (this.refreshVersion === currentRefresh) {
this.sourcesSignal.set(sources);
}
} finally {
if (this.refreshVersion === currentRefresh) {
this.refreshAbortController = null;
this.loadingSignal.set(false);
}
}
}
async installPlugin(plugin: PluginStoreEntry, options: PluginStoreInstallOptions = {}): Promise<InstalledStorePlugin> {
if (!plugin.installUrl) {
throw new Error('Plugin does not provide an install manifest URL');
}
const manifest = options.manifest ?? await this.fetchPluginManifest(plugin.installUrl);
const installScope = getPluginInstallScope(manifest);
const targetServerId = installScope === 'server' ? (options.serverId ?? this.currentRoomId?.() ?? null) : null;
if (installScope === 'server' && !targetServerId) {
throw new Error('Open a chat server before installing server-scoped plugins');
}
const now = Date.now();
const currentScopePlugins = installScope === 'server'
? await this.installedPluginsForServer(targetServerId)
: this.installedPluginsForScope(installScope);
const existing = currentScopePlugins.find((candidate) => candidate.manifest.id === manifest.id);
const installedPlugin: InstalledStorePlugin = {
installedAt: existing?.installedAt ?? now,
installUrl: plugin.installUrl,
manifest,
sourceUrl: plugin.sourceUrl,
updatedAt: now
};
const nextInstalledPlugins = currentScopePlugins
.filter((candidate) => candidate.manifest.id !== manifest.id)
.concat(installedPlugin)
.sort(sortInstalledPlugins);
if (installScope === 'server') {
await this.saveServerPluginRequirement(installedPlugin, targetServerId, options.optional === true ? 'optional' : 'required');
} else {
await this.persistInstalledPlugins(nextInstalledPlugins, installScope);
}
if (installScope === 'client' || targetServerId === this.currentRoomId?.()) {
this.host.registerLocalManifest(manifest, plugin.installUrl);
if (installScope === 'client' || options.optional !== true) {
this.setInstalledPluginsForScope(installScope, nextInstalledPlugins);
}
if (options.activate) {
await this.host.activatePluginById(manifest.id);
}
} else if (options.activate) {
await this.host.rememberActivation(manifest.id);
}
return installedPlugin;
}
async loadInstallManifest(plugin: PluginStoreEntry): Promise<TojuPluginManifest> {
if (!plugin.installUrl) {
throw new Error('Plugin does not provide an install manifest URL');
}
return await this.fetchPluginManifest(plugin.installUrl);
}
async uninstallPlugin(pluginId: string, scope?: TojuPluginInstallScope): Promise<void> {
const installScope = scope ?? this.findInstalledPluginScope(pluginId) ?? 'client';
const nextInstalledPlugins = this.installedPluginsForScope(installScope).filter((installedPlugin) => installedPlugin.manifest.id !== pluginId);
if (installScope === 'server') {
await this.deleteServerPluginRequirement(pluginId);
} else {
await this.persistInstalledPlugins(nextInstalledPlugins, installScope);
}
await this.host.deactivatePlugin(pluginId, { forgetActivation: true });
this.registry.unregister(pluginId);
this.setInstalledPluginsForScope(installScope, nextInstalledPlugins);
}
async loadReadme(plugin: PluginStoreEntry): Promise<PluginStoreReadme> {
if (!plugin.readmeUrl) {
throw new Error('Plugin does not provide a readme URL');
}
return {
markdown: await this.fetchText(plugin.readmeUrl, 'text/markdown,text/plain,*/*'),
pluginId: plugin.id,
title: plugin.title,
url: plugin.readmeUrl
};
}
getInstallState(plugin: PluginStoreEntry): PluginStoreInstallState {
const installed = this.installedPluginForScope(plugin.id, getStoreEntryInstallScope(plugin));
if (!installed) {
return 'notInstalled';
}
return compareVersions(plugin.version, installed.manifest.version) > 0
? 'updateAvailable'
: 'installed';
}
getActionLabel(plugin: PluginStoreEntry): PluginStoreActionLabel {
const state = this.getInstallState(plugin);
const serverScoped = getStoreEntryInstallScope(plugin) === 'server';
if (state === 'updateAvailable') {
return serverScoped ? 'Update Server' : 'Update';
}
if (state === 'installed') {
return serverScoped ? 'Remove from Server' : 'Uninstall';
}
return serverScoped ? 'Install to Server' : 'Install';
}
canInstallPlugin(plugin: PluginStoreEntry): boolean {
return getStoreEntryInstallScope(plugin) !== 'server' || this.hasActiveServerInstallScope();
}
private async loadSource(sourceUrl: string, signal: AbortSignal): Promise<PluginStoreSourceResult> {
try {
const sourceValue = await this.fetchJson(sourceUrl, signal);
return parsePluginSource(sourceUrl, sourceValue);
} catch (error) {
return {
error: error instanceof Error ? error.message : 'Unable to load plugin source',
plugins: [],
url: sourceUrl
};
}
}
private async fetchPluginManifest(manifestUrl: string): Promise<TojuPluginManifest> {
const manifestValue = await this.fetchJson(manifestUrl);
const validation = validateTojuPluginManifest(manifestValue);
if (!validation.manifest) {
throw new Error(validation.issues.map((issue) => `${issue.path}: ${issue.message}`).join('\n'));
}
return validation.manifest;
}
private async fetchJson(url: string, signal?: AbortSignal): Promise<unknown> {
return JSON.parse(await this.fetchText(url, 'application/json', signal)) as unknown;
}
private async fetchText(url: string, accept: string, signal?: AbortSignal): Promise<string> {
if (url.startsWith('file://')) {
return await this.readLocalFileUrl(url);
}
const response = await fetch(url, { headers: { Accept: accept }, signal });
if (!response.ok) {
throw new Error(`Request returned ${response.status}`);
}
return await response.text();
}
private async readLocalFileUrl(fileUrl: string): Promise<string> {
const api = this.electronBridge.getApi();
if (!api) {
throw new Error('Local plugin source paths require the desktop app');
}
const base64Data = await api.readFile(fileUrlToPath(fileUrl));
const bytes = Uint8Array.from(atob(base64Data), (character) => character.charCodeAt(0));
return new TextDecoder().decode(bytes);
}
private async applyInstalledPlugins(installedPlugins: InstalledStorePlugin[], scope: TojuPluginInstallScope): Promise<void> {
const usableInstalledPlugins: InstalledStorePlugin[] = [];
const scopedInstalledPlugins = installedPlugins.filter((installedPlugin) => getPluginInstallScope(installedPlugin.manifest) === scope);
const nextIds = new Set(scopedInstalledPlugins.map((installedPlugin) => installedPlugin.manifest.id));
for (const previousPlugin of this.installedPluginsForScope(scope)) {
if (!nextIds.has(previousPlugin.manifest.id)) {
await this.host.deactivatePlugin(previousPlugin.manifest.id);
this.registry.unregister(previousPlugin.manifest.id);
}
}
for (const installedPlugin of scopedInstalledPlugins) {
try {
this.host.registerLocalManifest(installedPlugin.manifest, installedPlugin.installUrl);
usableInstalledPlugins.push(installedPlugin);
} catch {
// Corrupt persisted manifests are ignored so the store can recover on next install.
}
}
this.setInstalledPluginsForScope(scope, usableInstalledPlugins);
await this.host.activatePersistedPlugins();
if (usableInstalledPlugins.length !== scopedInstalledPlugins.length) {
if (scope === 'client') {
await this.persistInstalledPlugins(usableInstalledPlugins, scope);
}
}
}
private async loadInstalledPluginsForScope(roomId: string | null, actorUserId: string | null): Promise<void> {
const currentLoad = this.installedLoadVersion + 1;
this.installedLoadVersion = currentLoad;
await Promise.resolve();
if (!roomId || !actorUserId || !this.serverDirectory) {
if (this.installedLoadVersion === currentLoad) {
await this.applyInstalledPlugins([], 'server');
}
return;
}
try {
const installedPlugins = await this.readServerInstalledPlugins(roomId);
if (this.installedLoadVersion === currentLoad && this.currentRoomId?.() === roomId) {
await this.applyInstalledPlugins(installedPlugins, 'server');
}
} catch {
if (this.installedLoadVersion === currentLoad && this.currentRoomId?.() === roomId) {
await this.applyInstalledPlugins([], 'server');
}
}
}
private async persistInstalledPlugins(
installedPlugins: InstalledStorePlugin[],
scope: TojuPluginInstallScope,
serverId?: string | null
): Promise<void> {
const roomId = serverId ?? this.currentRoomId?.() ?? null;
const actorUserId = this.currentActorUserId();
if (scope === 'server') {
if (!roomId || !actorUserId || !this.serverDirectory) {
throw new Error('Open a chat server before saving server-scoped plugins');
}
await Promise.all(installedPlugins.map((installedPlugin) => this.saveServerPluginRequirement(installedPlugin, roomId, 'required')));
return;
}
this.clientInstalledPluginsSignal.set(installedPlugins);
this.saveState();
}
private async readServerInstalledPlugins(roomId: string): Promise<InstalledStorePlugin[]> {
if (!this.serverDirectory) {
return [];
}
const snapshot = await firstValueFrom(this.pluginRequirements.getSnapshot(this.serverDirectory.getApiBaseUrl(), roomId));
return snapshot.requirements
.map((requirement) => installedPluginFromRequirement(requirement))
.filter((installedPlugin): installedPlugin is InstalledStorePlugin => !!installedPlugin)
.sort(sortInstalledPlugins);
}
private async saveServerPluginRequirement(
installedPlugin: InstalledStorePlugin,
roomId: string | null,
status: 'optional' | 'required'
): Promise<void> {
const actorUserId = this.currentActorUserId();
if (!roomId || !actorUserId || !this.serverDirectory) {
throw new Error('Open a chat server before saving server-scoped plugins');
}
await firstValueFrom(this.pluginRequirements.upsertRequirement(
this.serverDirectory.getApiBaseUrl(),
roomId,
installedPlugin.manifest.id,
{
actorUserId,
installUrl: installedPlugin.installUrl,
manifest: installedPlugin.manifest,
reason: installedPlugin.manifest.description,
sourceUrl: installedPlugin.sourceUrl,
status,
versionRange: `^${installedPlugin.manifest.version}`
}
));
}
private async deleteServerPluginRequirement(pluginId: string): Promise<void> {
const roomId = this.currentRoomId?.() ?? null;
const actorUserId = this.currentActorUserId();
if (!roomId || !actorUserId || !this.serverDirectory) {
throw new Error('Open a chat server before removing server-scoped plugins');
}
await firstValueFrom(this.pluginRequirements.deleteRequirement(this.serverDirectory.getApiBaseUrl(), roomId, pluginId, actorUserId));
}
private currentActorUserId(): string | null {
const user = this.currentUser?.() ?? null;
return user?.oderId || user?.id || null;
}
private async installedPluginsForServer(serverId: string | null): Promise<InstalledStorePlugin[]> {
if (!serverId) {
return [];
}
if (serverId === this.currentRoomId?.()) {
return this.serverInstalledPluginsSignal();
}
const actorUserId = this.currentActorUserId();
if (!actorUserId || !this.serverDirectory) {
throw new Error('Unable to read server plugins without an active user and server directory');
}
try {
return await this.readServerInstalledPlugins(serverId);
} catch {
return [];
}
}
private loadState(): PersistedPluginStoreState {
try {
const raw = localStorage.getItem(getUserScopedStorageKey(STORAGE_KEY_PLUGIN_STORE));
if (!raw) {
return { ...DEFAULT_STORE_STATE };
}
return normalizePersistedState(JSON.parse(raw) as unknown);
} catch {
return { ...DEFAULT_STORE_STATE };
}
}
private saveState(): void {
this.stateMutated = true;
const state = {
installedPlugins: this.clientInstalledPluginsSignal(),
schemaVersion: STORE_SCHEMA_VERSION,
sourceUrls: this.sourceUrls()
};
try {
localStorage.setItem(getUserScopedStorageKey(STORAGE_KEY_PLUGIN_STORE), JSON.stringify(state));
} catch {}
void this.desktopState.writeJson(STORAGE_KEY_PLUGIN_STORE, state);
}
private async loadDesktopState(): Promise<void> {
const state = await this.desktopState.readJson<PersistedPluginStoreState>(STORAGE_KEY_PLUGIN_STORE, this.loadState());
if (this.stateMutated) {
return;
}
const normalized = normalizePersistedState(state);
const sourceUrlsChanged = JSON.stringify(normalized.sourceUrls) !== JSON.stringify(this.sourceUrls());
if (sourceUrlsChanged) {
this.sourceUrlsSignal.set(normalized.sourceUrls);
void this.refreshSources();
}
await this.applyInstalledPlugins(normalized.installedPlugins, 'client');
}
private installedPluginsForScope(scope: TojuPluginInstallScope): InstalledStorePlugin[] {
return scope === 'server' ? this.serverInstalledPluginsSignal() : this.clientInstalledPluginsSignal();
}
private setInstalledPluginsForScope(scope: TojuPluginInstallScope, installedPlugins: InstalledStorePlugin[]): void {
if (scope === 'server') {
this.serverInstalledPluginsSignal.set(installedPlugins);
return;
}
this.clientInstalledPluginsSignal.set(installedPlugins);
}
private installedPluginForScope(pluginId: string, scope: TojuPluginInstallScope): InstalledStorePlugin | undefined {
return this.installedPluginsForScope(scope).find((installedPlugin) => installedPlugin.manifest.id === pluginId);
}
private findInstalledPluginScope(pluginId: string): TojuPluginInstallScope | null {
if (this.serverInstalledPluginsSignal().some((installedPlugin) => installedPlugin.manifest.id === pluginId)) {
return 'server';
}
if (this.clientInstalledPluginsSignal().some((installedPlugin) => installedPlugin.manifest.id === pluginId)) {
return 'client';
}
return null;
}
}
function isPluginRequirementsChangedMessage(message: unknown): message is { serverId: string; type: string } {
if (!isRecord(message)) {
return false;
}
return (message['type'] === 'plugin_requirements' || message['type'] === 'plugin_requirements_changed')
&& typeof message['serverId'] === 'string';
}
function installedPluginFromRequirement(requirement: PluginRequirementSummary): InstalledStorePlugin | null {
if (requirement.status === 'optional' || requirement.status === 'blocked' || requirement.status === 'incompatible') {
return null;
}
const manifest = requirement.manifest;
if (!manifest || !isInstalledStorePlugin({ manifest })) {
return null;
}
return {
installedAt: requirement.updatedAt,
installUrl: requirement.installUrl,
manifest,
sourceUrl: requirement.sourceUrl,
updatedAt: requirement.updatedAt
};
}
function parsePluginSource(sourceUrl: string, sourceValue: unknown): PluginStoreSourceResult {
const sourceRecord = isRecord(sourceValue) ? sourceValue : {};
const sourceTitle = readString(sourceRecord, 'title', 'name') ?? new URL(sourceUrl).hostname;
const rawPlugins = Array.isArray(sourceValue)
? sourceValue
: Array.isArray(sourceRecord['plugins'])
? sourceRecord['plugins']
: Array.isArray(sourceRecord['items'])
? sourceRecord['items']
: [];
const plugins = rawPlugins
.map((entry) => parsePluginEntry(sourceUrl, sourceTitle, entry))
.filter((entry): entry is PluginStoreEntry => !!entry)
.sort((left, right) => left.title.localeCompare(right.title));
return {
loadedAt: Date.now(),
plugins,
title: sourceTitle,
url: sourceUrl
};
}
function sortInstalledPlugins(left: InstalledStorePlugin, right: InstalledStorePlugin): number {
return left.manifest.title.localeCompare(right.manifest.title);
}
function parsePluginEntry(sourceUrl: string, sourceTitle: string, value: unknown): PluginStoreEntry | null {
if (!isRecord(value)) {
return null;
}
const id = readString(value, 'id', 'pluginId');
const version = readString(value, 'version') ?? '0.0.0';
if (!id) {
return null;
}
return {
author: readAuthor(value),
description: readString(value, 'description', 'summary') ?? '',
githubUrl: resolveOptionalUrl(sourceUrl, readGithubUrl(value)),
homepageUrl: resolveOptionalUrl(sourceUrl, readString(value, 'homepage', 'homepageUrl', 'website')),
id,
imageUrl: resolveOptionalUrl(sourceUrl, readString(value, 'image', 'imageUrl', 'icon', 'iconUrl', 'banner')),
installUrl: resolveOptionalUrl(sourceUrl, readString(value, 'install', 'installUrl', 'manifest', 'manifestUrl')),
readmeUrl: resolveOptionalUrl(sourceUrl, readString(value, 'readme', 'readmeUrl')),
scope: readPluginInstallScope(value),
sourceTitle,
sourceUrl,
title: readString(value, 'title', 'name') ?? id,
version
};
}
function getStoreEntryInstallScope(plugin: PluginStoreEntry): TojuPluginInstallScope {
return plugin.scope === 'server' ? 'server' : 'client';
}
function readPluginInstallScope(record: Record<string, unknown>): TojuPluginInstallScope | undefined {
const scope = readString(record, 'scope', 'installScope', 'pluginScope');
return scope === 'server' || scope === 'client' ? scope : undefined;
}
function normalizePersistedState(value: unknown): PersistedPluginStoreState {
if (!isRecord(value)) {
return { ...DEFAULT_STORE_STATE };
}
return {
installedPlugins: Array.isArray(value['installedPlugins'])
? value['installedPlugins'].filter(isInstalledStorePlugin)
: [],
sourceUrls: Array.isArray(value['sourceUrls'])
? value['sourceUrls']
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => normalizeOptionalSourceUrl(entry))
.filter((entry): entry is string => !!entry)
: []
};
}
function isInstalledStorePlugin(value: unknown): value is InstalledStorePlugin {
if (!isRecord(value) || !isRecord(value['manifest'])) {
return false;
}
const validation = validateTojuPluginManifest(value['manifest']);
return !!validation.manifest;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function readString(record: Record<string, unknown>, ...keys: string[]): string | undefined {
for (const key of keys) {
const value = record[key];
if (typeof value === 'string' && value.trim()) {
return value.trim();
}
}
return undefined;
}
function readAuthor(record: Record<string, unknown>): string | undefined {
const author = readString(record, 'author');
if (author) {
return author;
}
const authors = record['authors'];
if (!Array.isArray(authors)) {
return undefined;
}
return authors
.map((entry) => isRecord(entry) ? readString(entry, 'name') : typeof entry === 'string' ? entry.trim() : '')
.filter(Boolean)
.join(', ') || undefined;
}
function readGithubUrl(record: Record<string, unknown>): string | undefined {
const directUrl = readString(record, 'github', 'githubUrl');
if (directUrl) {
return directUrl;
}
const repository = record['repository'];
return isRecord(repository) ? readString(repository, 'url') : typeof repository === 'string' ? repository.trim() : undefined;
}
function normalizeSourceUrl(rawUrl: string, label: string): string {
const url = normalizeOptionalSourceUrl(rawUrl);
if (!url) {
throw new Error(`${label} must be an http, https, file URL, or absolute local path`);
}
return url;
}
function normalizeOptionalSourceUrl(rawUrl: string): string | undefined {
const trimmedUrl = rawUrl.trim();
if (!trimmedUrl) {
return undefined;
}
try {
const url = new URL(trimmedUrl);
if (!isAllowedPluginSourceProtocol(url.protocol)) {
return undefined;
}
url.hash = '';
return url.toString();
} catch {
return localPathToFileUrl(trimmedUrl);
}
}
function resolveOptionalUrl(sourceUrl: string, rawUrl?: string): string | undefined {
if (!rawUrl) {
return undefined;
}
try {
const url = new URL(rawUrl, sourceUrl);
if (!isAllowedPluginSourceProtocol(url.protocol)) {
return undefined;
}
url.hash = '';
return url.toString();
} catch {
return undefined;
}
}
function isAllowedPluginSourceProtocol(protocol: string): boolean {
return protocol === 'http:' || protocol === 'https:' || protocol === 'file:';
}
function localPathToFileUrl(filePath: string): string | undefined {
if (!isAbsoluteLocalPath(filePath)) {
return undefined;
}
const normalizedPath = filePath.replace(/\\/g, '/');
const pathWithLeadingSlash = /^[A-Za-z]:\//.test(normalizedPath)
? `/${normalizedPath}`
: normalizedPath;
return `file://${pathWithLeadingSlash.split('/')
.map(encodeURIComponent)
.join('/')}`;
}
function fileUrlToPath(fileUrl: string): string {
const url = new URL(fileUrl);
const decodedPath = decodeURIComponent(url.pathname);
if (/^\/[A-Za-z]:\//.test(decodedPath)) {
return decodedPath.slice(1).replace(/\//g, '\\');
}
return decodedPath;
}
function isAbsoluteLocalPath(filePath: string): boolean {
return filePath.startsWith('/') || /^[A-Za-z]:[\\/]/.test(filePath);
}
function compareVersions(leftVersion: string, rightVersion: string): number {
const leftParts = parseVersion(leftVersion);
const rightParts = parseVersion(rightVersion);
for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) {
const leftPart = leftParts[index] ?? 0;
const rightPart = rightParts[index] ?? 0;
if (leftPart !== rightPart) {
return leftPart - rightPart;
}
}
return leftVersion.localeCompare(rightVersion);
}
function parseVersion(version: string): number[] {
return version
.split(/[.+-]/)
.slice(0, 3)
.map((part) => Number.parseInt(part, 10))
.map((part) => Number.isFinite(part) ? part : 0);
}

View File

@@ -0,0 +1,237 @@
import {
Injectable,
Signal,
computed,
signal
} from '@angular/core';
import type {
PluginApiActionContribution,
PluginApiChannelSectionContribution,
PluginApiDomMountRequest,
PluginApiEmbedRendererContribution,
PluginApiPageContribution,
PluginApiPanelContribution,
PluginApiSettingsPageContribution,
PluginApiUiContributionMap,
TojuPluginDisposable
} from '../../domain/models/plugin-api.models';
type ContributionKind = keyof PluginApiUiContributionMap;
export interface PluginUiContributionRecord<TContribution> {
contribution: TContribution;
contributionKey: string;
id: string;
pluginId: string;
}
export interface PluginUiConflictDiagnostic {
contributionId: string;
kind: ContributionKind;
pluginIds: string[];
}
interface PluginDomMountRecord {
element: HTMLElement;
id: string;
pluginId: string;
}
@Injectable({ providedIn: 'root' })
export class PluginUiRegistryService {
readonly appPages = this.createContributionSignal('appPages');
readonly appPageRecords = this.createContributionRecordSignal('appPages');
readonly channelSections = this.createContributionSignal('channelSections');
readonly channelSectionRecords = this.createContributionRecordSignal('channelSections');
readonly composerActions = this.createContributionSignal('composerActions');
readonly composerActionRecords = this.createContributionRecordSignal('composerActions');
readonly embeds = this.createContributionSignal('embeds');
readonly embedRecords = this.createContributionRecordSignal('embeds');
readonly profileActions = this.createContributionSignal('profileActions');
readonly profileActionRecords = this.createContributionRecordSignal('profileActions');
readonly settingsPages = this.createContributionSignal('settingsPages');
readonly settingsPageRecords = this.createContributionRecordSignal('settingsPages');
readonly sidePanels = this.createContributionSignal('sidePanels');
readonly sidePanelRecords = this.createContributionRecordSignal('sidePanels');
readonly toolbarActions = this.createContributionSignal('toolbarActions');
readonly toolbarActionRecords = this.createContributionRecordSignal('toolbarActions');
readonly conflicts = computed(() => this.collectConflicts());
private readonly domMounts = new Map<string, PluginDomMountRecord>();
private readonly contributionsSignal = signal<{
appPages: PluginUiContributionRecord<PluginApiPageContribution>[];
channelSections: PluginUiContributionRecord<PluginApiChannelSectionContribution>[];
composerActions: PluginUiContributionRecord<PluginApiActionContribution>[];
embeds: PluginUiContributionRecord<PluginApiEmbedRendererContribution>[];
profileActions: PluginUiContributionRecord<PluginApiActionContribution>[];
settingsPages: PluginUiContributionRecord<PluginApiSettingsPageContribution>[];
sidePanels: PluginUiContributionRecord<PluginApiPanelContribution>[];
toolbarActions: PluginUiContributionRecord<PluginApiActionContribution>[];
}>({
appPages: [],
channelSections: [],
composerActions: [],
embeds: [],
profileActions: [],
settingsPages: [],
sidePanels: [],
toolbarActions: []
});
registerAppPage(pluginId: string, id: string, contribution: PluginApiPageContribution): TojuPluginDisposable {
return this.register('appPages', pluginId, id, contribution);
}
registerChannelSection(pluginId: string, id: string, contribution: PluginApiChannelSectionContribution): TojuPluginDisposable {
return this.register('channelSections', pluginId, id, contribution);
}
registerComposerAction(pluginId: string, id: string, contribution: PluginApiActionContribution): TojuPluginDisposable {
return this.register('composerActions', pluginId, id, contribution);
}
registerEmbedRenderer(pluginId: string, id: string, contribution: PluginApiEmbedRendererContribution): TojuPluginDisposable {
return this.register('embeds', pluginId, id, contribution);
}
mountElement(pluginId: string, id: string, request: PluginApiDomMountRequest): TojuPluginDisposable {
const mountId = `${pluginId}:${id}`;
const target = this.resolveMountTarget(request.target);
if (!target) {
throw new Error(`Plugin mount target not found: ${typeof request.target === 'string' ? request.target : request.target.tagName}`);
}
this.unmountElement(mountId);
request.element.dataset['pluginOwner'] = pluginId;
request.element.dataset['pluginMountId'] = mountId;
target.insertAdjacentElement(request.position ?? 'beforeend', request.element);
this.domMounts.set(mountId, { element: request.element, id: mountId, pluginId });
return {
dispose: () => this.unmountElement(mountId)
};
}
registerProfileAction(pluginId: string, id: string, contribution: PluginApiActionContribution): TojuPluginDisposable {
return this.register('profileActions', pluginId, id, contribution);
}
registerSettingsPage(pluginId: string, id: string, contribution: PluginApiSettingsPageContribution): TojuPluginDisposable {
return this.register('settingsPages', pluginId, id, contribution);
}
registerSidePanel(pluginId: string, id: string, contribution: PluginApiPanelContribution): TojuPluginDisposable {
return this.register('sidePanels', pluginId, id, contribution);
}
registerToolbarAction(pluginId: string, id: string, contribution: PluginApiActionContribution): TojuPluginDisposable {
return this.register('toolbarActions', pluginId, id, contribution);
}
unregisterPlugin(pluginId: string): void {
for (const mount of this.domMounts.values()) {
if (mount.pluginId === pluginId) {
this.unmountElement(mount.id);
}
}
this.contributionsSignal.update((current) => ({
appPages: current.appPages.filter((entry) => entry.pluginId !== pluginId),
channelSections: current.channelSections.filter((entry) => entry.pluginId !== pluginId),
composerActions: current.composerActions.filter((entry) => entry.pluginId !== pluginId),
embeds: current.embeds.filter((entry) => entry.pluginId !== pluginId),
profileActions: current.profileActions.filter((entry) => entry.pluginId !== pluginId),
settingsPages: current.settingsPages.filter((entry) => entry.pluginId !== pluginId),
sidePanels: current.sidePanels.filter((entry) => entry.pluginId !== pluginId),
toolbarActions: current.toolbarActions.filter((entry) => entry.pluginId !== pluginId)
}));
}
private register<TKind extends ContributionKind>(
kind: TKind,
pluginId: string,
id: string,
contribution: PluginApiUiContributionMap[TKind][number]
): TojuPluginDisposable {
const contributionId = `${pluginId}:${id}`;
this.contributionsSignal.update((current) => ({
...current,
[kind]: [
...current[kind].filter((entry) => entry.id !== contributionId),
{
contribution,
contributionKey: id,
id: contributionId,
pluginId
}
]
}));
return {
dispose: () => this.unregister(kind, contributionId)
};
}
private unregister(kind: ContributionKind, contributionId: string): void {
this.contributionsSignal.update((current) => ({
...current,
[kind]: current[kind].filter((entry) => entry.id !== contributionId)
}));
}
private resolveMountTarget(target: Element | string): Element | null {
return typeof target === 'string'
? document.querySelector(target)
: target;
}
private unmountElement(mountId: string): void {
const mount = this.domMounts.get(mountId);
if (!mount) {
return;
}
mount.element.remove();
this.domMounts.delete(mountId);
}
private createContributionSignal<TKind extends ContributionKind>(kind: TKind): Signal<PluginApiUiContributionMap[TKind]> {
return computed(() => this.contributionsSignal()[kind].map((entry) => entry.contribution) as PluginApiUiContributionMap[TKind]);
}
private createContributionRecordSignal<TKind extends ContributionKind>(
kind: TKind
): Signal<PluginUiContributionRecord<PluginApiUiContributionMap[TKind][number]>[]> {
return computed(() => this.contributionsSignal()[kind] as PluginUiContributionRecord<PluginApiUiContributionMap[TKind][number]>[]);
}
private collectConflicts(): PluginUiConflictDiagnostic[] {
const conflicts: PluginUiConflictDiagnostic[] = [];
for (const kind of Object.keys(this.contributionsSignal()) as ContributionKind[]) {
const byKey = new Map<string, Set<string>>();
for (const entry of this.contributionsSignal()[kind]) {
const pluginIds = byKey.get(entry.contributionKey) ?? new Set<string>();
pluginIds.add(entry.pluginId);
byKey.set(entry.contributionKey, pluginIds);
}
for (const [contributionId, pluginIds] of byKey.entries()) {
if (pluginIds.size > 1) {
conflicts.push({
contributionId,
kind,
pluginIds: Array.from(pluginIds).sort()
});
}
}
}
return conflicts;
}
}

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