diff --git a/e2e/fixtures/plugins/api-test-plugin/README.md b/e2e/fixtures/plugins/api-test-plugin/README.md new file mode 100644 index 0000000..a9eebab --- /dev/null +++ b/e2e/fixtures/plugins/api-test-plugin/README.md @@ -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. \ No newline at end of file diff --git a/e2e/fixtures/plugins/api-test-plugin/dist/main.js b/e2e/fixtures/plugins/api-test-plugin/dist/main.js new file mode 100644 index 0000000..b778b6a --- /dev/null +++ b/e2e/fixtures/plugins/api-test-plugin/dist/main.js @@ -0,0 +1,6 @@ +export default { + id: 'e2e.plugin-api', + activate(api) { + api?.logger?.info?.('E2E Plugin API Fixture activated'); + } +}; \ No newline at end of file diff --git a/e2e/fixtures/plugins/api-test-plugin/toju-plugin.json b/e2e/fixtures/plugins/api-test-plugin/toju-plugin.json new file mode 100644 index 0000000..2116a72 --- /dev/null +++ b/e2e/fixtures/plugins/api-test-plugin/toju-plugin.json @@ -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" +} \ No newline at end of file diff --git a/e2e/helpers/plugin-api-test-fixture.ts b/e2e/helpers/plugin-api-test-fixture.ts new file mode 100644 index 0000000..d95bda2 --- /dev/null +++ b/e2e/helpers/plugin-api-test-fixture.ts @@ -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 { + 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; +} diff --git a/e2e/tests/plugins/plugin-api-two-users.spec.ts b/e2e/tests/plugins/plugin-api-two-users.spec.ts new file mode 100644 index 0000000..32dc392 --- /dev/null +++ b/e2e/tests/plugins/plugin-api-two-users.spec.ts @@ -0,0 +1,179 @@ +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 and activate the plugin for Bob as the embed/soundboard receiver', async () => { + await installGrantAndActivatePlugin(scenario.bob.page); + 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('Install and activate the plugin for Alice as the API driver', async () => { + await installGrantAndActivatePlugin(scenario.alice.page); + 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('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): Promise { + 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 { + 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): Promise { + 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 }); + await page.getByPlaceholder('https://example.com/plugins.json').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' }).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 { + 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 }); +} diff --git a/e2e/tests/plugins/plugin-manager-ui.spec.ts b/e2e/tests/plugins/plugin-manager-ui.spec.ts new file mode 100644 index 0000000..5e57995 --- /dev/null +++ b/e2e/tests/plugins/plugin-manager-ui.spec.ts @@ -0,0 +1,88 @@ +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.getByPlaceholder('https://example.com/plugins.json').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' }).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: 'Plugins' })).toBeVisible(); + await expect(page.getByText('Development 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'); + }); + }); +}); diff --git a/e2e/tests/plugins/plugin-support-api.spec.ts b/e2e/tests/plugins/plugin-support-api.spec.ts new file mode 100644 index 0000000..1b3aa8b --- /dev/null +++ b/e2e/tests/plugins/plugin-support-api.spec.ts @@ -0,0 +1,405 @@ +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 PluginDataResponse { + record: { + key: string; + ownerId?: string; + pluginId: string; + schemaVersion: number; + scope: string; + value: unknown; + }; +} + +interface PluginDataListResponse { + records: PluginDataResponse['record'][]; +} + +interface PluginSnapshotResponse { + eventDefinitions: PluginEventDefinitionResponse['eventDefinition'][]; + requirements: PluginRequirementResponse['requirement'][]; + serverId: string; +} + +interface SocketMessage { + [key: string]: unknown; + type?: string; +} + +interface TestSocket { + close: () => Promise; + 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(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(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(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 stores, lists, and deletes server scoped data', async () => { + const stored = await expectJson(await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/data/settings`, { + data: { + actorUserId: OWNER_USER_ID, + schemaVersion: 1, + scope: 'server', + value: { + enabled: true, + pluginVersion: manifest.version + } + } + })); + + expect(stored.record).toEqual(expect.objectContaining({ + key: 'settings', + pluginId: TEST_PLUGIN_ID, + schemaVersion: 1, + scope: 'server', + value: { + enabled: true, + pluginVersion: manifest.version + } + })); + + const listed = await expectJson(await request.get(`${pluginsApi}/${TEST_PLUGIN_ID}/data`, { + params: { + key: 'settings', + scope: 'server', + userId: OWNER_USER_ID + } + })); + + expect(listed.records).toHaveLength(1); + expect(listed.records[0]?.value).toEqual({ + enabled: true, + pluginVersion: manifest.version + }); + + await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/data/settings`, { + data: { + actorUserId: OWNER_USER_ID, + scope: 'server' + } + })); + + const afterDelete = await expectJson(await request.get(`${pluginsApi}/${TEST_PLUGIN_ID}/data`, { + params: { + key: 'settings', + scope: 'server', + userId: OWNER_USER_ID + } + })); + + expect(afterDelete.records).toEqual([]); + }); + + 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(await request.get(pluginsApi)); + + expect(snapshot.eventDefinitions).toEqual([]); + expect(snapshot.requirements).toEqual([]); + }); + }); +}); + +async function createServer( + request: APIRequestContext, + baseUrl: string, + serverName: string +): Promise { + 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(response, 201); +} + +async function upsertEventDefinition( + request: APIRequestContext, + pluginsApi: string, + eventDefinition: ReturnType +): Promise { + return await expectJson(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(response: APIResponse, status = 200): Promise { + expect(response.status()).toBe(status); + + return await response.json() as T; +} + +async function openTestSocket(baseUrl: string): Promise { + 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((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((resolve) => { + socket.once('close', () => resolve()); + socket.close(); + }); + }, + messages, + send: (message: SocketMessage) => { + socket.send(JSON.stringify(message)); + } + }; +} + +async function waitForSocketMessage( + socket: Pick, + predicate: (message: SocketMessage) => boolean, + timeoutMs = 10_000 +): Promise { + 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); + }); +} diff --git a/electron/ipc/system.ts b/electron/ipc/system.ts index 4bad4f5..4f44ea3 100644 --- a/electron/ipc/system.ts +++ b/electron/ipc/system.ts @@ -49,6 +49,7 @@ import { readSavedTheme, writeSavedTheme } from '../theme-library'; +import { getLocalPluginsPath, listLocalPluginManifests } from '../plugin-library'; import { eraseUserData, exportUserData, @@ -349,6 +350,8 @@ export function setupSystemHandlers(): void { ipcMain.handle('import-user-data', async () => await importUserData()); ipcMain.handle('erase-user-data', async () => await eraseUserData()); ipcMain.handle('get-saved-themes-path', async () => await getSavedThemesPath()); + ipcMain.handle('get-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('read-saved-theme', async (_event, fileName: string) => await readSavedTheme(fileName)); ipcMain.handle('write-saved-theme', async (_event, fileName: string, text: string) => { diff --git a/electron/plugin-library.spec.ts b/electron/plugin-library.spec.ts new file mode 100644 index 0000000..d48ffff --- /dev/null +++ b/electron/plugin-library.spec.ts @@ -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(); + }); +}); diff --git a/electron/plugin-library.ts b/electron/plugin-library.ts new file mode 100644 index 0000000..ef43040 --- /dev/null +++ b/electron/plugin-library.ts @@ -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 { + const pluginsPath = resolvePluginsPath(); + + await fsp.mkdir(pluginsPath, { recursive: true }); + + return pluginsPath; +} + +async function realpathOrSelf(filePath: string): Promise { + 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, 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 { + 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 { + 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 { + 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 + : {}; + 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 { + return await ensurePluginsPath(); +} + +export async function listLocalPluginManifests(): Promise { + 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 + }; +} diff --git a/electron/preload.ts b/electron/preload.ts index 22a8ce0..3b96d27 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -109,6 +109,28 @@ export interface SavedThemeFileDescriptor { path: string; } +export interface LocalPluginManifestDescriptor { + discoveredAt: number; + entrypointPath?: string; + pluginRootUrl: string; + manifest: unknown; + manifestPath: string; + pluginRoot: string; + readmePath?: string; +} + +export interface LocalPluginDiscoveryError { + manifestPath?: string; + message: string; + pluginRoot?: string; +} + +export interface LocalPluginDiscoveryResult { + errors: LocalPluginDiscoveryError[]; + plugins: LocalPluginManifestDescriptor[]; + pluginsPath: string; +} + export interface ExportUserDataResult { cancelled: boolean; exported: boolean; @@ -181,6 +203,8 @@ export interface ElectronAPI { importUserData: () => Promise; eraseUserData: () => Promise; getSavedThemesPath: () => Promise; + getLocalPluginsPath: () => Promise; + listLocalPluginManifests: () => Promise; listSavedThemes: () => Promise; readSavedTheme: (fileName: string) => Promise; writeSavedTheme: (fileName: string, text: string) => Promise; @@ -294,6 +318,8 @@ const electronAPI: ElectronAPI = { importUserData: () => ipcRenderer.invoke('import-user-data'), eraseUserData: () => ipcRenderer.invoke('erase-user-data'), 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'), readSavedTheme: (fileName) => ipcRenderer.invoke('read-saved-theme', fileName), writeSavedTheme: (fileName, text) => ipcRenderer.invoke('write-saved-theme', fileName, text), diff --git a/server/README.md b/server/README.md index ac50250..3776fe0 100644 --- a/server/README.md +++ b/server/README.md @@ -20,6 +20,7 @@ Node/TypeScript signaling server for MetoYou / Toju. This package owns the publi - `SSL` can override the effective HTTP protocol, and `PORT` can override the effective port. - `DB_PATH` can override the SQLite database file location. - `data/variables.json` is normalized on startup and stores `klipyApiKey`, `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. - `RAWG_API_KEY` can override `rawgApiKey` for the `/api/games/match` now-playing metadata resolver. Successful matches include a preferred store link from RAWG store metadata, with Steam selected first when available. Negative game-match results are stored in the SQLite `game_match_misses` table so non-game process names do not repeatedly consume RAWG quota. - Packaged server builds store `metoyou.sqlite` in the OS app-data directory by default so upgrades do not overwrite runtime data. On first start, the server copies forward legacy packaged databases that still live beside the executable. - When HTTPS is enabled, certificates are read from the repository `.certs/` directory. diff --git a/server/data/metoyou.sqlite b/server/data/metoyou.sqlite index a9ce375..03cbbf6 100644 Binary files a/server/data/metoyou.sqlite and b/server/data/metoyou.sqlite differ diff --git a/server/src/config/variables.ts b/server/src/config/variables.ts index ff95193..401ebb5 100644 --- a/server/src/config/variables.ts +++ b/server/src/config/variables.ts @@ -10,6 +10,10 @@ export interface LinkPreviewConfig { maxCacheSizeMb: number; } +export interface OpenApiDocsConfig { + enabled: boolean; +} + export interface ServerVariablesConfig { klipyApiKey: string; rawgApiKey: string; @@ -18,6 +22,7 @@ export interface ServerVariablesConfig { serverProtocol: ServerHttpProtocol; serverHost: string; linkPreview: LinkPreviewConfig; + openApiDocs: OpenApiDocsConfig; } const DATA_DIR = resolveRuntimePath('data'); @@ -102,6 +107,14 @@ function normalizeLinkPreviewConfig(value: unknown): LinkPreviewConfig { return { enabled, cacheTtlMinutes: cacheTtl, maxCacheSizeMb: maxSize }; } +function normalizeOpenApiDocsConfig(value: unknown): OpenApiDocsConfig { + const raw = (value && typeof value === 'object' && !Array.isArray(value)) + ? value as Record + : {}; + + return { enabled: raw.enabled === true }; +} + function hasEnvironmentOverride(value: string | undefined): value is string { return typeof value === 'string' && value.trim().length > 0; } @@ -149,7 +162,8 @@ export function ensureVariablesConfig(): ServerVariablesConfig { serverPort: normalizeServerPort(remainingParsed.serverPort), serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol), 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'; @@ -164,7 +178,8 @@ export function ensureVariablesConfig(): ServerVariablesConfig { serverPort: normalized.serverPort, serverProtocol: normalized.serverProtocol, serverHost: normalized.serverHost, - linkPreview: normalized.linkPreview + linkPreview: normalized.linkPreview, + openApiDocs: normalized.openApiDocs }; } @@ -218,6 +233,31 @@ export function isHttpsServerEnabled(): boolean { 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 { return getVariablesConfig().linkPreview; } diff --git a/server/src/db/database.ts b/server/src/db/database.ts index 1babc42..526f210 100644 --- a/server/src/db/database.ts +++ b/server/src/db/database.ts @@ -15,7 +15,12 @@ import { ServerMembershipEntity, ServerInviteEntity, ServerBanEntity, - GameMatchMissEntity + GameMatchMissEntity, + ServerPluginRequirementEntity, + ServerPluginEventDefinitionEntity, + PluginDataEntity, + ServerPluginSettingsEntity, + PluginUserMetadataEntity } from '../entities'; import { serverMigrations } from '../migrations'; import { @@ -49,8 +54,18 @@ const DB_BACKUP = DB_FILE + '.bak'; const DATA_DIR = path.dirname(DB_FILE); // SQLite files start with this 16-byte header string. 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']); +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 saveQueue: Promise = Promise.resolve(); @@ -250,7 +265,12 @@ export async function initDatabase(): Promise { ServerMembershipEntity, ServerInviteEntity, ServerBanEntity, - GameMatchMissEntity + GameMatchMissEntity, + ServerPluginRequirementEntity, + ServerPluginEventDefinitionEntity, + PluginDataEntity, + ServerPluginSettingsEntity, + PluginUserMetadataEntity ], migrations: serverMigrations, synchronize: process.env.DB_SYNCHRONIZE === 'true', diff --git a/server/src/entities/PluginDataEntity.ts b/server/src/entities/PluginDataEntity.ts new file mode 100644 index 0000000..952fe9b --- /dev/null +++ b/server/src/entities/PluginDataEntity.ts @@ -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; +} diff --git a/server/src/entities/PluginUserMetadataEntity.ts b/server/src/entities/PluginUserMetadataEntity.ts new file mode 100644 index 0000000..751a1ba --- /dev/null +++ b/server/src/entities/PluginUserMetadataEntity.ts @@ -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; +} diff --git a/server/src/entities/ServerPluginEventDefinitionEntity.ts b/server/src/entities/ServerPluginEventDefinitionEntity.ts new file mode 100644 index 0000000..9b56506 --- /dev/null +++ b/server/src/entities/ServerPluginEventDefinitionEntity.ts @@ -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; +} diff --git a/server/src/entities/ServerPluginRequirementEntity.ts b/server/src/entities/ServerPluginRequirementEntity.ts new file mode 100644 index 0000000..de8bfb8 --- /dev/null +++ b/server/src/entities/ServerPluginRequirementEntity.ts @@ -0,0 +1,36 @@ +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 }) + configuredBy!: string | null; + + @Column('integer') + createdAt!: number; + + @Column('integer') + updatedAt!: number; +} diff --git a/server/src/entities/ServerPluginSettingsEntity.ts b/server/src/entities/ServerPluginSettingsEntity.ts new file mode 100644 index 0000000..4ebc6f9 --- /dev/null +++ b/server/src/entities/ServerPluginSettingsEntity.ts @@ -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; +} diff --git a/server/src/entities/index.ts b/server/src/entities/index.ts index b1d2a82..b857605 100644 --- a/server/src/entities/index.ts +++ b/server/src/entities/index.ts @@ -10,3 +10,10 @@ export { ServerMembershipEntity } from './ServerMembershipEntity'; export { ServerInviteEntity } from './ServerInviteEntity'; 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'; diff --git a/server/src/migrations/1000000000007-PluginSupport.ts b/server/src/migrations/1000000000007-PluginSupport.ts new file mode 100644 index 0000000..747e45a --- /dev/null +++ b/server/src/migrations/1000000000007-PluginSupport.ts @@ -0,0 +1,92 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class PluginSupport1000000000007 implements MigrationInterface { + name = 'PluginSupport1000000000007'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"`); + } +} diff --git a/server/src/migrations/index.ts b/server/src/migrations/index.ts index 564089b..617d28b 100644 --- a/server/src/migrations/index.ts +++ b/server/src/migrations/index.ts @@ -5,6 +5,7 @@ import { RepairLegacyVoiceChannels1000000000003 } from './1000000000003-RepairLe import { NormalizeServerArrays1000000000004 } from './1000000000004-NormalizeServerArrays'; import { ServerRoleAccessControl1000000000005 } from './1000000000005-ServerRoleAccessControl'; import { GameMatchMisses1000000000006 } from './1000000000006-GameMatchMisses'; +import { PluginSupport1000000000007 } from './1000000000007-PluginSupport'; export const serverMigrations = [ InitialSchema1000000000000, @@ -13,5 +14,6 @@ export const serverMigrations = [ RepairLegacyVoiceChannels1000000000003, NormalizeServerArrays1000000000004, ServerRoleAccessControl1000000000005, - GameMatchMisses1000000000006 + GameMatchMisses1000000000006, + PluginSupport1000000000007 ]; diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index 874efc1..8c6ba7c 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -6,6 +6,8 @@ import gamesRouter from './games'; import proxyRouter from './proxy'; import usersRouter from './users'; import serversRouter from './servers'; +import pluginSupportRouter from './plugin-support'; +import openApiDocsRouter from './openapi-docs'; import joinRequestsRouter from './join-requests'; import { invitesApiRouter, invitePageRouter } from './invites'; @@ -16,6 +18,8 @@ export function registerRoutes(app: Express): void { app.use('/api/games', gamesRouter); app.use('/api', proxyRouter); app.use('/api/users', usersRouter); + app.use('/api', openApiDocsRouter); + app.use('/api/servers', pluginSupportRouter); app.use('/api/servers', serversRouter); app.use('/api/invites', invitesApiRouter); app.use('/api/requests', joinRequestsRouter); diff --git a/server/src/routes/openapi-docs.ts b/server/src/routes/openapi-docs.ts new file mode 100644 index 0000000..68ed4be --- /dev/null +++ b/server/src/routes/openapi-docs.ts @@ -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 metadata, event definitions, and plugin data. ' + + '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: 'List plugin data records', + responses: { '200': { description: 'Plugin data records' }, '403': { description: 'Not a server member' } } + } + }, + '/servers/{serverId}/plugins/{pluginId}/data/{key}': { + put: { + summary: 'Write plugin data', + responses: { '200': { description: 'Plugin data saved' }, '403': { description: 'Not a server member' } } + }, + delete: { + summary: 'Delete plugin data', + responses: { '200': { description: 'Plugin data deleted' }, '403': { description: 'Not a server member' } } + } + }, + '/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(` + +MetoYou Plugin API Docs + +

MetoYou Plugin Support API

+

Plugin support endpoints are available at /api/openapi.json.

+

The signal server stores metadata, data, and event definitions only. It never executes plugin code.

+ +`); +}); + +export default router; diff --git a/server/src/routes/plugin-support.ts b/server/src/routes/plugin-support.ts new file mode 100644 index 0000000..97424dc --- /dev/null +++ b/server/src/routes/plugin-support.ts @@ -0,0 +1,208 @@ +import { Response, Router } from 'express'; +import { + deletePluginData, + deletePluginEventDefinition, + deletePluginRequirement, + getPluginRequirementsSnapshot, + listPluginData, + PluginSupportError, + upsertPluginData, + 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 { + 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), + pluginId, + reason: req.body.reason, + serverId, + 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', async (req, res) => { + const { serverId, pluginId } = req.params; + + try { + const records = await listPluginData({ + actorUserId: readActorUserId(req.query.userId), + key: req.query.key, + ownerId: req.query.ownerId, + pluginId, + scope: req.query.scope, + serverId + }); + + res.json({ records }); + } catch (error) { + sendPluginSupportError(error, res); + } +}); + +router.put('/:serverId/plugins/:pluginId/data/:key', async (req, res) => { + const { serverId, pluginId, key } = req.params; + + try { + const record = await upsertPluginData({ + actorUserId: readActorUserId(req.body.actorUserId), + key, + ownerId: req.body.ownerId, + pluginId, + schemaVersion: req.body.schemaVersion, + scope: req.body.scope, + serverId, + value: req.body.value + }); + + broadcastToServer(serverId, { + type: 'plugin_data_changed', + serverId, + pluginId: record.pluginId, + scope: record.scope, + ownerId: record.ownerId, + key: record.key, + updatedAt: record.updatedAt + }); + + res.json({ record }); + } catch (error) { + sendPluginSupportError(error, res); + } +}); + +router.delete('/:serverId/plugins/:pluginId/data/:key', async (req, res) => { + const { serverId, pluginId, key } = req.params; + const scope = req.body.scope ?? req.query.scope; + const ownerId = req.body.ownerId ?? req.query.ownerId; + + try { + await deletePluginData({ + actorUserId: readActorUserId(req.body.actorUserId), + key, + ownerId, + pluginId, + scope, + serverId + }); + + broadcastToServer(serverId, { + type: 'plugin_data_changed', + serverId, + pluginId, + scope: typeof scope === 'string' ? scope : 'server', + ownerId: typeof ownerId === 'string' && ownerId.trim() ? ownerId.trim() : undefined, + key, + updatedAt: Date.now() + }); + + res.json({ ok: true }); + } catch (error) { + sendPluginSupportError(error, res); + } +}); + +export default router; diff --git a/server/src/services/plugin-support.service.ts b/server/src/services/plugin-support.service.ts new file mode 100644 index 0000000..53501c1 --- /dev/null +++ b/server/src/services/plugin-support.service.ts @@ -0,0 +1,523 @@ +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([ + 'required', + 'optional', + 'recommended', + 'blocked', + 'incompatible' +]); +const VALID_EVENT_DIRECTIONS = new Set([ + 'clientToServer', + 'serverRelay', + 'p2pHint' +]); +const VALID_EVENT_SCOPES = new Set([ + '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 { + pluginId: string; + reason?: 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 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 { + pluginId: entity.pluginId, + reason: entity.reason ?? 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 { + 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 { + 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 { + 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; + pluginId: string; + reason?: unknown; + serverId: string; + status: unknown; + versionRange?: unknown; +}): Promise { + 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), + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/server/src/websocket/handler-plugin.spec.ts b/server/src/websocket/handler-plugin.spec.ts new file mode 100644 index 0000000..247081d --- /dev/null +++ b/server/src/websocket/handler-plugin.spec.ts @@ -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 { + 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[] { + return (user.ws as unknown as SentMessageStore).sentMessages.map((messageText) => JSON.parse(messageText) as Record); +} + +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' + })); + }); +}); diff --git a/server/src/websocket/handler.ts b/server/src/websocket/handler.ts index 1d6299e..51ab38b 100644 --- a/server/src/websocket/handler.ts +++ b/server/src/websocket/handler.ts @@ -8,6 +8,11 @@ import { isOderIdConnectedToServer } from './broadcast'; import { authorizeWebSocketJoin } from '../services/server-access.service'; +import { + getPluginRequirementsSnapshot, + PluginSupportError, + validatePluginEventEnvelope +} from '../services/plugin-support.service'; interface WsMessage { [key: string]: unknown; @@ -50,6 +55,29 @@ function readMessageId(value: unknown): string | undefined { 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. */ function sendServerUsers(user: ConnectedUser, serverId: string): void { 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 })); } +async function sendPluginRequirements(user: ConnectedUser, serverId: string): Promise { + 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 { const newOderId = readMessageId(message['oderId']) ?? connectionId; const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined; @@ -137,6 +179,7 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect ); sendServerUsers(user, sid); + await sendPluginRequirements(user, sid); if (isNewIdentityMembership) { broadcastToServer(sid, { @@ -151,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 { const viewSid = readMessageId(message['serverId']); if (!viewSid) return; + if (!user.serverIds.has(viewSid)) { + return; + } + user.viewedServerId = viewSid; connectedUsers.set(connectionId, user); console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) viewing server ${viewSid}`); sendServerUsers(user, viewSid); + await sendPluginRequirements(user, viewSid); } function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId: string): void { @@ -268,6 +316,52 @@ function handleStatusUpdate(user: ConnectedUser, message: WsMessage, connectionI } } +async function handlePluginEvent(user: ConnectedUser, message: WsMessage): Promise { + 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 { const user = connectedUsers.get(connectionId); @@ -290,7 +384,7 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe break; case 'view_server': - handleViewServer(user, message, connectionId); + await handleViewServer(user, message, connectionId); break; case 'leave_server': @@ -315,6 +409,10 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe handleStatusUpdate(user, message, connectionId); break; + case 'plugin_event': + await handlePluginEvent(user, message); + break; + default: console.log('Unknown message type:', message.type); } diff --git a/toju-app/angular.json b/toju-app/angular.json index 652f152..26aa127 100644 --- a/toju-app/angular.json +++ b/toju-app/angular.json @@ -96,12 +96,12 @@ "budgets": [ { "type": "initial", - "maximumWarning": "2.2MB", - "maximumError": "2.38MB" + "maximumWarning": "2.5MB", + "maximumError": "2.6MB" }, { "type": "anyComponentStyle", - "maximumWarning": "4kB", + "maximumWarning": "7kB", "maximumError": "8kB" } ], diff --git a/toju-app/public/plugins/e2e-all-api/README.md b/toju-app/public/plugins/e2e-all-api/README.md new file mode 100644 index 0000000..58a8f81 --- /dev/null +++ b/toju-app/public/plugins/e2e-all-api/README.md @@ -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. diff --git a/toju-app/public/plugins/e2e-all-api/icon.svg b/toju-app/public/plugins/e2e-all-api/icon.svg new file mode 100644 index 0000000..3306fe4 --- /dev/null +++ b/toju-app/public/plugins/e2e-all-api/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/toju-app/public/plugins/e2e-all-api/main.js b/toju-app/public/plugins/e2e-all-api/main.js new file mode 100644 index 0000000..68919f3 --- /dev/null +++ b/toju-app/public/plugins/e2e-all-api/main.js @@ -0,0 +1,273 @@ +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.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()); + + 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.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(); +} diff --git a/toju-app/public/plugins/e2e-all-api/toju.plugin.json b/toju-app/public/plugins/e2e-all-api/toju.plugin.json new file mode 100644 index 0000000..fc58135 --- /dev/null +++ b/toju-app/public/plugins/e2e-all-api/toju.plugin.json @@ -0,0 +1,99 @@ +{ + "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", + "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" + } +} diff --git a/toju-app/public/plugins/e2e-plugin-source.json b/toju-app/public/plugins/e2e-plugin-source.json new file mode 100644 index 0000000..ad41cfe --- /dev/null +++ b/toju-app/public/plugins/e2e-plugin-source.json @@ -0,0 +1,17 @@ +{ + "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", + "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" + } + ] +} diff --git a/toju-app/src/app/app.routes.ts b/toju-app/src/app/app.routes.ts index 3c54a41..9d5ac29 100644 --- a/toju-app/src/app/app.routes.ts +++ b/toju-app/src/app/app.routes.ts @@ -48,5 +48,15 @@ export const routes: Routes = [ path: 'settings', loadComponent: () => 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) } ]; diff --git a/toju-app/src/app/core/models/index.ts b/toju-app/src/app/core/models/index.ts index 8f58525..663739e 100644 --- a/toju-app/src/app/core/models/index.ts +++ b/toju-app/src/app/core/models/index.ts @@ -49,4 +49,20 @@ export type { ChatAttachmentMeta } 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'; diff --git a/toju-app/src/app/core/platform/electron/electron-api.models.ts b/toju-app/src/app/core/platform/electron/electron-api.models.ts index 61cc2cc..5e9fbdb 100644 --- a/toju-app/src/app/core/platform/electron/electron-api.models.ts +++ b/toju-app/src/app/core/platform/electron/electron-api.models.ts @@ -124,6 +124,28 @@ export interface SavedThemeFileDescriptor { path: string; } +export interface LocalPluginManifestDescriptor { + discoveredAt: number; + entrypointPath?: string; + pluginRootUrl: string; + manifest: unknown; + manifestPath: string; + pluginRoot: string; + readmePath?: string; +} + +export interface LocalPluginDiscoveryError { + manifestPath?: string; + message: string; + pluginRoot?: string; +} + +export interface LocalPluginDiscoveryResult { + errors: LocalPluginDiscoveryError[]; + plugins: LocalPluginManifestDescriptor[]; + pluginsPath: string; +} + export interface ExportUserDataResult { cancelled: boolean; exported: boolean; @@ -189,6 +211,8 @@ export interface ElectronApi { importUserData: () => Promise; eraseUserData: () => Promise; getSavedThemesPath: () => Promise; + getLocalPluginsPath: () => Promise; + listLocalPluginManifests: () => Promise; listSavedThemes: () => Promise; readSavedTheme: (fileName: string) => Promise; writeSavedTheme: (fileName: string, text: string) => Promise; diff --git a/toju-app/src/app/core/services/settings-modal.service.ts b/toju-app/src/app/core/services/settings-modal.service.ts index d9b8563..6137ce8 100644 --- a/toju-app/src/app/core/services/settings-modal.service.ts +++ b/toju-app/src/app/core/services/settings-modal.service.ts @@ -2,6 +2,7 @@ import { Injectable, signal } from '@angular/core'; export type SettingsPage = | 'general' + | 'plugins' | 'theme' | 'network' | 'notifications' diff --git a/toju-app/src/app/domains/README.md b/toju-app/src/app/domains/README.md index b2e3abf..d43ae82 100644 --- a/toju-app/src/app/domains/README.md +++ b/toju-app/src/app/domains/README.md @@ -15,6 +15,7 @@ infrastructure adapters and UI. | **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` | +| **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` | | **screen-share** | Source picker, quality presets | `ScreenShareFacade` | | **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` | @@ -32,6 +33,7 @@ The larger domains also keep longer design notes in their own folders: - [chat/README.md](chat/README.md) - [direct-message/README.md](direct-message/README.md) - [notifications/README.md](notifications/README.md) +- [plugins/README.md](plugins/README.md) - [profile-avatar/README.md](profile-avatar/README.md) - [screen-share/README.md](screen-share/README.md) - [server-directory/README.md](server-directory/README.md) diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.html index 81e0655..ad35e28 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.html +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.html @@ -141,6 +141,20 @@ (drop)="onDrop($event)" >
+ @for (record of pluginComposerActions(); track record.id) { + + } + @if (klipyEnabled()) { +
+

Plugins

+

Local runtime, store install, capabilities, logs, extension points.

+
+
+ + + + + + +
+ @switch (activeTab()) { + @case ('extensions') { +
+
+ @for ( + item of [ + { label: 'Settings pages', value: extensionCounts().settingsPages }, + { label: 'App pages', value: extensionCounts().appPages }, + { label: 'Side panels', value: extensionCounts().sidePanels }, + { label: 'Channel sections', value: extensionCounts().channelSections }, + { label: 'Composer actions', value: extensionCounts().composerActions }, + { label: 'Profile actions', value: extensionCounts().profileActions }, + { label: 'Toolbar actions', value: extensionCounts().toolbarActions }, + { label: 'Embed renderers', value: extensionCounts().embeds } + ]; + track item.label + ) { +
+

{{ item.label }}

+

{{ item.value }}

+
+ } +
+ +
+

Conflict diagnostics

+ @if (uiConflicts().length === 0) { +

+ No duplicate route, action, embed, channel, panel, or settings contribution ids detected. +

+ } @else { +
+ @for (conflict of uiConflicts(); track conflict.kind + conflict.contributionId) { +
+ {{ conflict.kind }} / {{ conflict.contributionId }} + conflicts in {{ conflict.pluginIds.join(', ') }} +
+ } +
+ } +
+
+ } + @case ('requirements') { +
+ @if (requirementComparisons().length === 0) { +

+ No server plugin requirements for the current room. +

+ } @else { + @for (comparison of requirementComparisons(); track comparison.pluginId) { +
+
+
+

{{ comparison.installed?.title ?? comparison.pluginId }}

+

{{ comparison.pluginId }}

+
+ {{ comparison.status }} +
+ @if (comparison.requirement) { +

Server status: {{ comparison.requirement.status }}

+ @if (comparison.requirement.versionRange) { +

Version range: {{ comparison.requirement.versionRange }}

+ } + @if (comparison.requirement.reason) { +

{{ comparison.requirement.reason }}

+ } + } +
+ } + } +
+ } + @case ('settings') { +
+
+ @for (entry of entries(); track trackEntry($index, entry)) { + + } +
+
+ @if (selectedPlugin(); as plugin) { +

{{ plugin.manifest.title }} settings

+ @if (selectedSettingsPages().length > 0) { +
+ @for (page of selectedSettingsPages(); track page.id) { +
+

{{ page.contribution.label }}

+ +
+ } +
+ } + @if (selectedSettingsSchema()) { +
{{ selectedSettingsSchema() | json }}
+ } @else { +

This plugin does not declare a settings schema.

+ } + } +
+
+ } + @case ('docs') { +
+
+ @for (entry of entries(); track trackEntry($index, entry)) { + + } +
+
+ @if (selectedPlugin(); as plugin) { +

{{ plugin.manifest.title }}

+

{{ plugin.manifest.description }}

+
+ @for (doc of selectedDocs(); track doc.label) { + {{ doc.label }} + } +
+
{{ plugin.manifest | json }}
+ } +
+
+ } + @case ('logs') { +
+ @if (!selectedPlugin()) { +

No plugins installed.

+ } @else { +
+ @for (entry of entries(); track trackEntry($index, entry)) { + + } +
+
+ @if (selectedLogs().length === 0) { +

No logs for selected plugin.

+ } @else { + @for (log of selectedLogs(); track log.timestamp) { +
+
+ {{ log.level }} + {{ log.timestamp | date: 'short' }} +
+

{{ log.message }}

+
+ } + } +
+ } +
+ } + @default { +
+
+ @if (entries().length === 0) { +
+ +

No plugins installed.

+

Use Store tab or local plugin folder discovery.

+
+ } @else { + @for (entry of entries(); track trackEntry($index, entry)) { +
+
+
+
+

{{ entry.manifest.title }}

+ {{ entry.state }} + v{{ entry.manifest.version }} +
+

{{ entry.manifest.description }}

+

{{ entry.manifest.id }}

+
+
+ + + + +
+
+ @if (entry.error) { +

{{ entry.error }}

+ } +
+ } + } +
+ + +
+ } + } +
+ diff --git a/toju-app/src/app/domains/plugins/feature/plugin-manager/plugin-manager.component.ts b/toju-app/src/app/domains/plugins/feature/plugin-manager/plugin-manager.component.ts new file mode 100644 index 0000000..92f06e6 --- /dev/null +++ b/toju-app/src/app/domains/plugins/feature/plugin-manager/plugin-manager.component.ts @@ -0,0 +1,208 @@ +import { CommonModule } from '@angular/common'; +import { + Component, + EventEmitter, + Output, + computed, + inject, + signal +} from '@angular/core'; +import { Router } from '@angular/router'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { + lucideArrowLeft, + lucideBug, + lucideCheck, + lucidePackage, + lucidePlay, + lucideRefreshCw, + lucideSettings, + lucideShield, + lucideStore, + lucideX +} from '@ng-icons/lucide'; +import type { PluginCapabilityId } from '../../../../shared-kernel'; +import { PluginCapabilityService } from '../../application/services/plugin-capability.service'; +import { PluginHostService } from '../../application/services/plugin-host.service'; +import { PluginLoggerService } from '../../application/services/plugin-logger.service'; +import { PluginRegistryService } from '../../application/services/plugin-registry.service'; +import { PluginRequirementStateService } from '../../application/services/plugin-requirement-state.service'; +import { PluginUiRegistryService } from '../../application/services/plugin-ui-registry.service'; +import type { RegisteredPlugin } from '../../domain/models/plugin-runtime.models'; +import { PluginRenderHostComponent } from '../plugin-render-host/plugin-render-host.component'; + +type PluginManagerTab = 'docs' | 'extensions' | 'installed' | 'logs' | 'requirements' | 'settings'; + +@Component({ + selector: 'app-plugin-manager', + standalone: true, + imports: [ + CommonModule, + NgIcon, + PluginRenderHostComponent + ], + templateUrl: './plugin-manager.component.html', + viewProviders: [ + provideIcons({ + lucideArrowLeft, + lucideBug, + lucideCheck, + lucidePackage, + lucidePlay, + lucideRefreshCw, + lucideSettings, + lucideShield, + lucideStore, + lucideX + }) + ] +}) +export class PluginManagerComponent { + @Output() readonly closed = new EventEmitter(); + + readonly capabilities = inject(PluginCapabilityService); + readonly host = inject(PluginHostService); + readonly logger = inject(PluginLoggerService); + readonly registry = inject(PluginRegistryService); + readonly requirementState = inject(PluginRequirementStateService); + readonly router = inject(Router); + readonly uiRegistry = inject(PluginUiRegistryService); + readonly activeTab = signal('installed'); + readonly busyPluginId = signal(null); + readonly busyAll = signal(false); + readonly selectedPluginId = signal(null); + readonly entries = this.registry.entries; + readonly selectedPlugin = computed(() => { + const selectedPluginId = this.selectedPluginId(); + + return this.entries().find((entry) => entry.manifest.id === selectedPluginId) ?? this.entries()[0] ?? null; + }); + readonly missingCapabilities = computed(() => { + const selectedPlugin = this.selectedPlugin(); + + return selectedPlugin ? this.capabilities.missing(selectedPlugin.manifest) : []; + }); + readonly selectedLogs = computed(() => { + const selectedPlugin = this.selectedPlugin(); + + return selectedPlugin ? this.logger.entries().filter((entry) => entry.pluginId === selectedPlugin.manifest.id) + .slice(-20) : []; + }); + readonly extensionCounts = computed(() => ({ + appPages: this.uiRegistry.appPages().length, + channelSections: this.uiRegistry.channelSections().length, + composerActions: this.uiRegistry.composerActions().length, + embeds: this.uiRegistry.embeds().length, + profileActions: this.uiRegistry.profileActions().length, + settingsPages: this.uiRegistry.settingsPages().length, + sidePanels: this.uiRegistry.sidePanels().length, + toolbarActions: this.uiRegistry.toolbarActions().length + })); + readonly requirementComparisons = this.requirementState.comparisons; + readonly uiConflicts = this.uiRegistry.conflicts; + readonly selectedRequirement = computed(() => { + const selectedPlugin = this.selectedPlugin(); + + return selectedPlugin ? this.requirementState.comparisonFor(selectedPlugin.manifest.id) : null; + }); + readonly selectedSettingsSchema = computed(() => this.selectedPlugin()?.manifest.settings ?? null); + readonly selectedSettingsPages = computed(() => { + const selectedPlugin = this.selectedPlugin(); + + return selectedPlugin + ? this.uiRegistry.settingsPageRecords().filter((record) => record.pluginId === selectedPlugin.manifest.id) + : []; + }); + readonly selectedDocs = computed(() => { + const manifest = this.selectedPlugin()?.manifest; + + if (!manifest) { + return []; + } + + return [ + { label: 'Readme', url: manifest.readme }, + { label: 'Homepage', url: manifest.homepage }, + { label: 'Changelog', url: manifest.changelog }, + { label: 'Support', url: manifest.bugs } + ].filter((item): item is { label: string; url: string } => typeof item.url === 'string' && item.url.length > 0); + }); + + setTab(tab: PluginManagerTab): void { + this.activeTab.set(tab); + } + + openStore(): void { + const returnUrl = this.router.url.startsWith('/plugin-store') ? '/search' : this.router.url; + + this.closed.emit(); + void this.router.navigate(['/plugin-store'], { queryParams: { returnUrl } }); + } + + selectPlugin(pluginId: string): void { + this.selectedPluginId.set(pluginId); + } + + grantAll(entry: RegisteredPlugin): void { + this.capabilities.grantAll(entry.manifest); + } + + toggleCapability(entry: RegisteredPlugin, capability: PluginCapabilityId): void { + if (this.capabilities.has(entry.manifest.id, capability)) { + this.capabilities.revoke(entry.manifest.id, capability); + return; + } + + this.capabilities.grant(entry.manifest.id, capability); + } + + async activateAll(): Promise { + this.busyAll.set(true); + + try { + await this.host.activateReadyPlugins(); + } finally { + this.busyAll.set(false); + } + } + + async reload(entry: RegisteredPlugin): Promise { + this.busyPluginId.set(entry.manifest.id); + + try { + await this.host.reloadPlugin(entry.manifest.id); + } finally { + this.busyPluginId.set(null); + } + } + + async unload(entry: RegisteredPlugin): Promise { + this.busyPluginId.set(entry.manifest.id); + + try { + await this.host.deactivatePlugin(entry.manifest.id); + } finally { + this.busyPluginId.set(null); + } + } + + setEnabled(entry: RegisteredPlugin, enabled: boolean): void { + this.registry.setEnabled(entry.manifest.id, enabled); + } + + isSelected(entry: RegisteredPlugin): boolean { + return this.selectedPlugin()?.manifest.id === entry.manifest.id; + } + + close(): void { + this.closed.emit(); + } + + trackEntry(index: number, entry: RegisteredPlugin): string { + return entry.manifest.id; + } + + trackCapability(index: number, capability: PluginCapabilityId): string { + return capability; + } +} diff --git a/toju-app/src/app/domains/plugins/feature/plugin-page-host/plugin-page-host.component.html b/toju-app/src/app/domains/plugins/feature/plugin-page-host/plugin-page-host.component.html new file mode 100644 index 0000000..13b6933 --- /dev/null +++ b/toju-app/src/app/domains/plugins/feature/plugin-page-host/plugin-page-host.component.html @@ -0,0 +1,17 @@ +
+ Back + @if (page(); as pageRecord) { +
+

{{ pageRecord.pluginId }}

+

{{ pageRecord.contribution.label }}

+
+ +
+
+ } @else { +
+

Plugin page unavailable

+

The plugin page is not registered or the plugin is not loaded.

+
+ } +
\ No newline at end of file diff --git a/toju-app/src/app/domains/plugins/feature/plugin-page-host/plugin-page-host.component.ts b/toju-app/src/app/domains/plugins/feature/plugin-page-host/plugin-page-host.component.ts new file mode 100644 index 0000000..29ffff4 --- /dev/null +++ b/toju-app/src/app/domains/plugins/feature/plugin-page-host/plugin-page-host.component.ts @@ -0,0 +1,42 @@ +import { toSignal } from '@angular/core/rxjs-interop'; +import { CommonModule } from '@angular/common'; +import { + Component, + computed, + inject +} from '@angular/core'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { map } from 'rxjs/operators'; +import { PluginUiRegistryService } from '../../application/services/plugin-ui-registry.service'; +import { PluginRenderHostComponent } from '../plugin-render-host/plugin-render-host.component'; + +@Component({ + selector: 'app-plugin-page-host', + standalone: true, + imports: [ + CommonModule, + RouterLink, + PluginRenderHostComponent + ], + templateUrl: './plugin-page-host.component.html' +}) +export class PluginPageHostComponent { + readonly page = computed(() => { + const params = this.params(); + + if (!params?.pluginId || !params.pageId) { + return null; + } + + return this.uiRegistry.appPageRecords().find((record) => + record.pluginId === params.pluginId && record.contributionKey === params.pageId + ) ?? null; + }); + + private readonly route = inject(ActivatedRoute); + private readonly uiRegistry = inject(PluginUiRegistryService); + private readonly params = toSignal(this.route.paramMap.pipe(map((params) => ({ + pageId: params.get('pageId'), + pluginId: params.get('pluginId') + })))); +} diff --git a/toju-app/src/app/domains/plugins/feature/plugin-render-host/plugin-render-host.component.ts b/toju-app/src/app/domains/plugins/feature/plugin-render-host/plugin-render-host.component.ts new file mode 100644 index 0000000..0d90b05 --- /dev/null +++ b/toju-app/src/app/domains/plugins/feature/plugin-render-host/plugin-render-host.component.ts @@ -0,0 +1,44 @@ +import { + Component, + ElementRef, + effect, + input, + viewChild +} from '@angular/core'; + +export type PluginRenderable = () => HTMLElement | string; + +@Component({ + selector: 'app-plugin-render-host', + standalone: true, + template: '
' +}) +export class PluginRenderHostComponent { + readonly render = input.required(); + private readonly host = viewChild.required>('host'); + + constructor() { + effect(() => { + this.renderContribution(this.render()); + }); + } + + private renderContribution(render: PluginRenderable): void { + const hostElement = this.host().nativeElement; + + hostElement.replaceChildren(); + + try { + const rendered = render(); + + if (typeof rendered === 'string') { + hostElement.textContent = rendered; + return; + } + + hostElement.appendChild(rendered); + } catch (error) { + hostElement.textContent = error instanceof Error ? error.message : 'Plugin contribution failed to render'; + } + } +} diff --git a/toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.html b/toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.html new file mode 100644 index 0000000..2f9d238 --- /dev/null +++ b/toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.html @@ -0,0 +1,314 @@ + +
+
+
+ + +
+ +
+ +
+

Plugin Store

+

{{ installedCount() }} installed · {{ totalSourcePlugins() }} available · {{ sourceCount() }} sources

+
+
+ +
+ + +
+
+ +
+
+ + +
+ + @if (sourceError()) { +

{{ sourceError() }}

+ } +
+ +
+ + +
+
+ + +
{{ filteredPlugins().length }} shown
+
+ + @if (actionError()) { +

{{ actionError() }}

+ } + + @if (readmeError()) { +

{{ readmeError() }}

+ } + + @if (filteredPlugins().length > 0) { +
+ @for (plugin of filteredPlugins(); track trackPlugin($index, plugin)) { +
+
+ @if (plugin.imageUrl) { + + } @else { + + } +
+ +
+
+
+

{{ plugin.title }}

+

{{ plugin.author || 'Unknown author' }} · v{{ plugin.version }}

+
+ + @if (store.getInstallState(plugin) === 'updateAvailable') { + Update + } @else if (store.getInstallState(plugin) === 'installed') { + Installed + } +
+ +

{{ plugin.description }}

+ +
+ {{ plugin.id }} + {{ plugin.sourceTitle || plugin.sourceUrl }} +
+ +
+ + + @if (plugin.readmeUrl) { + + } + + @if (plugin.githubUrl) { + + } +
+
+
+ } +
+ } @else { +
+ +

No plugins found

+

{{ sourceCount() ? 'Adjust filters or add another source manifest.' : 'Add a plugin source manifest URL to populate the catalog.' }}

+
+ } +
+ + @if (readme()) { + + } +
+
diff --git a/toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.scss b/toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.scss new file mode 100644 index 0000000..575dc83 --- /dev/null +++ b/toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.scss @@ -0,0 +1,490 @@ +:host { + display: block; + min-height: 100%; +} + +.plugin-store { + min-height: calc(100vh - 2.5rem); + padding: 1rem clamp(0.75rem, 1.6vw, 1.5rem); + color: hsl(var(--foreground)); + background: hsl(var(--background)); +} + +.plugin-store__topbar, +.plugin-store__title-row, +.plugin-store__top-actions, +.plugin-store__source-form, +.plugin-store__toolbar, +.plugin-store__source-row, +.plugin-store__source-filter, +.plugin-store__toggle-button, +.plugin-card__actions, +.plugin-card__meta, +.plugin-store__panel-header { + display: flex; + align-items: center; +} + +.plugin-store__topbar { + justify-content: space-between; + gap: 1rem; + padding-bottom: 0.875rem; + border-bottom: 1px solid hsl(var(--border)); +} + +.plugin-store__title-row, +.plugin-store__top-actions, +.plugin-store__source-form, +.plugin-store__toolbar, +.plugin-card__actions, +.plugin-card__meta { + gap: 0.625rem; +} + +.plugin-store__title-copy, +.plugin-store__title-copy h1, +.plugin-store__title-copy p, +.plugin-store__source-filter span, +.plugin-store__count, +.plugin-card__header h2, +.plugin-card__header p, +.plugin-card__meta span, +.plugin-store__readme-header h2, +.plugin-store__readme-header span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.plugin-store__title-copy h1 { + margin: 0; + font-size: 1.35rem; + line-height: 1.8rem; +} + +.plugin-store__title-copy p, +.plugin-store__count, +.plugin-card__header p, +.plugin-card__description, +.plugin-card__meta, +.plugin-store__source-error, +.plugin-store__error-text, +.plugin-store__readme-header span { + margin: 0; + color: hsl(var(--muted-foreground)); + font-size: 0.8125rem; +} + +.plugin-store__brand-icon, +.plugin-store__icon-button { + display: grid; + place-items: center; + flex: 0 0 auto; + border-radius: 0.5rem; +} + +.plugin-store__brand-icon { + width: 2.25rem; + height: 2.25rem; + color: hsl(var(--primary)); + background: hsl(var(--primary) / 0.1); +} + +.plugin-store__icon-button { + width: 2rem; + height: 2rem; + border: 1px solid hsl(var(--border)); + color: hsl(var(--muted-foreground)); + background: transparent; +} + +.plugin-store__icon-button:hover, +.plugin-store__secondary-button:hover, +.plugin-store__text-button:hover, +.plugin-store__source-filter:hover, +.plugin-store__toggle-button:hover { + color: hsl(var(--foreground)); + background: hsl(var(--secondary)); +} + +.plugin-store__icon-button--danger:hover { + color: hsl(var(--destructive)); + background: hsl(var(--destructive) / 0.1); +} + +.plugin-store__primary-button, +.plugin-store__secondary-button, +.plugin-store__text-button { + display: inline-flex; + min-height: 2rem; + align-items: center; + justify-content: center; + gap: 0.45rem; + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; + padding: 0.4rem 0.7rem; + font-size: 0.8125rem; + font-weight: 600; + color: hsl(var(--foreground)); + background: hsl(var(--card)); +} + +.plugin-store__primary-button { + border-color: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); + background: hsl(var(--primary)); +} + +button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +ng-icon { + width: 1rem; + height: 1rem; +} + +.plugin-store__brand-icon ng-icon, +.plugin-store__empty ng-icon, +.plugin-card__media ng-icon { + width: 1.4rem; + height: 1.4rem; +} + +.plugin-store__source-strip { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.75rem; + align-items: center; + padding: 0.75rem 0; +} + +.plugin-store__source-form { + min-width: 0; + align-items: stretch; +} + +.plugin-store__input-shell { + position: relative; + display: flex; + min-width: 0; + flex: 1 1 auto; +} + +.plugin-store__input-shell ng-icon { + position: absolute; + left: 0.7rem; + color: hsl(var(--muted-foreground)); +} + +.plugin-store__input-shell input { + width: 100%; + min-height: 2.2rem; + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; + padding: 0.45rem 0.7rem; + color: hsl(var(--foreground)); + background: hsl(var(--secondary)); +} + +.plugin-store__search input { + padding-left: 2rem; +} + +.plugin-store__layout { + display: grid; + grid-template-columns: minmax(13rem, 17rem) minmax(0, 1fr) minmax(18rem, 24rem); + gap: 0.875rem; + align-items: start; +} + +.plugin-store__rail { + display: grid; + gap: 0.75rem; + position: sticky; + top: 0.75rem; +} + +.plugin-store__panel, +.plugin-store__catalog, +.plugin-store__readme, +.plugin-card, +.plugin-store__empty { + min-width: 0; + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; + background: hsl(var(--card)); +} + +.plugin-store__panel, +.plugin-store__catalog, +.plugin-store__readme { + display: grid; + gap: 0.75rem; + padding: 0.75rem; +} + +.plugin-store__panel { + gap: 0.375rem; + padding: 0.625rem; +} + +.plugin-store__panel-header { + justify-content: space-between; + gap: 0.5rem; +} + +.plugin-store__panel-header h2, +.plugin-store__readme-header h2, +.plugin-card__header h2 { + margin: 0; +} + +.plugin-store__panel-header h2 { + font-size: 0.8rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.plugin-store__panel-header span, +.plugin-store__source-filter strong, +.plugin-store__toggle-button strong, +.plugin-card__meta span { + border-radius: 999px; + padding: 0.12rem 0.45rem; + color: hsl(var(--muted-foreground)); + background: hsl(var(--secondary)); + font-size: 0.72rem; +} + +.plugin-store__source-row { + gap: 0.375rem; +} + +.plugin-store__source-filter, +.plugin-store__toggle-button { + min-width: 0; + flex: 1 1 auto; + justify-content: space-between; + gap: 0.5rem; + border: 0; + border-radius: 0.45rem; + padding: 0.45rem 0.55rem; + color: hsl(var(--muted-foreground)); + background: transparent; + text-align: left; +} + +.plugin-store__source-filter.is-active, +.plugin-store__toggle-button.is-active { + color: hsl(var(--foreground)); + background: hsl(var(--secondary)); +} + +.plugin-store__source-error, +.plugin-store__error-text, +.plugin-store__error-banner { + color: hsl(var(--destructive)); +} + +.plugin-store__error-banner { + margin: 0; + border: 1px solid hsl(var(--destructive) / 0.3); + border-radius: 0.5rem; + padding: 0.55rem 0.7rem; + background: hsl(var(--destructive) / 0.1); + font-size: 0.8125rem; +} + +.plugin-store__toolbar { + justify-content: space-between; +} + +.plugin-store__search { + max-width: 30rem; +} + +.plugin-store__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(21rem, 1fr)); + gap: 0.75rem; +} + +.plugin-card { + display: grid; + grid-template-columns: 5.5rem minmax(0, 1fr); + overflow: hidden; +} + +.plugin-card__media { + display: grid; + min-height: 100%; + place-items: center; + color: hsl(var(--muted-foreground)); + background: hsl(var(--secondary)); +} + +.plugin-card__media img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.plugin-card__body { + display: grid; + gap: 0.55rem; + padding: 0.7rem; +} + +.plugin-card__header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.5rem; +} + +.plugin-card__header h2, +.plugin-store__readme-header h2 { + font-size: 1rem; +} + +.plugin-card__badge { + border-radius: 999px; + padding: 0.18rem 0.45rem; + color: hsl(var(--primary)); + background: hsl(var(--primary) / 0.1); + font-size: 0.72rem; + font-weight: 700; +} + +.plugin-card__badge--installed { + color: rgb(5 150 105); + background: rgb(5 150 105 / 0.1); +} + +.plugin-card__description { + display: -webkit-box; + min-height: 2.45rem; + overflow: hidden; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.plugin-card__meta, +.plugin-card__actions { + flex-wrap: wrap; +} + +.plugin-card__primary-action--danger { + border-color: hsl(var(--destructive) / 0.35); + color: hsl(var(--destructive)); + background: hsl(var(--destructive) / 0.1); +} + +.plugin-store__readme { + position: sticky; + top: 0.75rem; + max-height: calc(100vh - 6rem); +} + +.plugin-store__readme-header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.75rem; +} + +.plugin-store__readme-header p { + margin: 0 0 0.25rem; + color: hsl(var(--primary)); + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.plugin-store__readme pre { + max-height: calc(100vh - 14rem); + overflow: auto; + margin: 0; + border-radius: 0.5rem; + padding: 0.75rem; + white-space: pre-wrap; + background: hsl(var(--secondary) / 0.5); +} + +.plugin-store__empty { + display: grid; + min-height: 14rem; + place-items: center; + gap: 0.35rem; + padding: 1.5rem; + text-align: center; +} + +.plugin-store__empty h2, +.plugin-store__empty p { + margin: 0; +} + +.is-spinning { + animation: plugin-store-spin 0.9s linear infinite; +} + +@keyframes plugin-store-spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 1180px) { + .plugin-store__layout { + grid-template-columns: minmax(12rem, 16rem) minmax(0, 1fr); + } + + .plugin-store__readme { + grid-column: 1 / -1; + position: static; + max-height: none; + } +} + +@media (max-width: 820px) { + .plugin-store__topbar, + .plugin-store__source-strip, + .plugin-store__toolbar, + .plugin-store__layout { + grid-template-columns: 1fr; + } + + .plugin-store__topbar, + .plugin-store__source-form, + .plugin-store__toolbar { + align-items: stretch; + flex-direction: column; + } + + .plugin-store__top-actions, + .plugin-card__actions { + flex-wrap: wrap; + } + + .plugin-store__rail { + position: static; + } + + .plugin-store__search { + max-width: none; + } +} + +@media (max-width: 560px) { + .plugin-card { + grid-template-columns: 1fr; + } + + .plugin-card__media { + min-height: 4.5rem; + } +} diff --git a/toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.ts b/toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.ts new file mode 100644 index 0000000..3b7cb00 --- /dev/null +++ b/toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.ts @@ -0,0 +1,310 @@ +import { CommonModule } from '@angular/common'; +import { + Component, + DestroyRef, + OnInit, + computed, + inject, + signal +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { + lucideArrowLeft, + lucideExternalLink, + lucidePlus, + lucidePackage, + lucideRefreshCw, + lucideSearch, + lucideSettings, + lucideStore, + lucideTrash2, + lucideX +} from '@ng-icons/lucide'; +import { ExternalLinkService } from '../../../../core/platform'; +import { SettingsModalService } from '../../../../core/services/settings-modal.service'; +import { PluginStoreService } from '../../application/services/plugin-store.service'; +import type { PluginStoreEntry, PluginStoreReadme } from '../../domain/models/plugin-store.models'; + +@Component({ + selector: 'app-plugin-store', + standalone: true, + imports: [ + CommonModule, + FormsModule, + NgIcon + ], + viewProviders: [ + provideIcons({ + lucideArrowLeft, + lucideExternalLink, + lucidePlus, + lucidePackage, + lucideRefreshCw, + lucideSearch, + lucideSettings, + lucideStore, + lucideTrash2, + lucideX + }) + ], + styleUrl: './plugin-store.component.scss', + templateUrl: './plugin-store.component.html' +}) +export class PluginStoreComponent implements OnInit { + readonly store = inject(PluginStoreService); + readonly sourceErrors = computed(() => this.store.sources().filter((source) => !!source.error)); + readonly installedIds = computed(() => new Set(this.store.installedPlugins().map((plugin) => plugin.manifest.id))); + readonly filteredPlugins = computed(() => { + const searchTerm = this.searchTerm().trim() + .toLowerCase(); + const sourceFilter = this.selectedSourceUrl(); + const showInstalled = this.showInstalledOnly(); + const installedIds = this.installedIds(); + const plugins = this.store.availablePlugins() + .filter((plugin) => !sourceFilter || plugin.sourceUrl === sourceFilter) + .filter((plugin) => !showInstalled || installedIds.has(plugin.id)); + + if (!searchTerm) { + return plugins; + } + + return plugins.filter((plugin) => this.matchesSearch(plugin, searchTerm)); + }); + readonly installedCount = computed(() => this.store.installedPlugins().length); + readonly totalSourcePlugins = computed(() => this.store.availablePlugins().length); + readonly sourceCount = computed(() => this.store.sourceUrls().length); + readonly pendingSourceUrls = computed(() => { + const loadedUrls = new Set(this.store.sources().map((source) => source.url)); + + return this.store.sourceUrls().filter((sourceUrl) => !loadedUrls.has(sourceUrl)); + }); + readonly selectedReadmePlugin = computed(() => { + const readme = this.readme(); + + return readme ? this.store.availablePlugins().find((plugin) => plugin.id === readme.pluginId) ?? null : null; + }); + + newSourceUrl = ''; + readonly searchTerm = signal(''); + readonly selectedSourceUrl = signal(null); + readonly showInstalledOnly = signal(false); + readonly sourceError = signal(null); + readonly actionError = signal(null); + readonly actionBusyPluginId = signal(null); + readonly readme = signal(null); + readonly readmeError = signal(null); + readonly readmeLoadingPluginId = signal(null); + + private destroyed = false; + private readonly destroyRef = inject(DestroyRef); + private readonly externalLinks = inject(ExternalLinkService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly settingsModal = inject(SettingsModalService); + + constructor() { + this.destroyRef.onDestroy(() => { + this.destroyed = true; + }); + } + + ngOnInit(): void { + if (this.store.sourceUrls().length > 0 && this.store.sources().length === 0) { + void this.refreshSources(); + } + } + + async addSourceUrl(): Promise { + const sourceUrl = this.newSourceUrl.trim(); + + if (!sourceUrl) { + return; + } + + this.sourceError.set(null); + + try { + await this.store.addSourceUrl(sourceUrl); + + if (this.destroyed) { + return; + } + + this.newSourceUrl = ''; + } catch (error) { + if (this.destroyed) { + return; + } + + this.sourceError.set(error instanceof Error ? error.message : 'Unable to add plugin source'); + } + } + + async removeSourceUrl(sourceUrl: string): Promise { + this.sourceError.set(null); + + try { + await this.store.removeSourceUrl(sourceUrl); + + if (this.selectedSourceUrl() === sourceUrl) { + this.selectedSourceUrl.set(null); + } + } catch (error) { + if (this.destroyed) { + return; + } + + this.sourceError.set(error instanceof Error ? error.message : 'Unable to remove plugin source'); + } + } + + async refreshSources(): Promise { + this.sourceError.set(null); + + try { + await this.store.refreshSources(); + } catch (error) { + if (this.destroyed) { + return; + } + + this.sourceError.set(error instanceof Error ? error.message : 'Unable to refresh plugin sources'); + } + } + + async runPrimaryAction(plugin: PluginStoreEntry): Promise { + const action = this.store.getActionLabel(plugin); + + this.actionError.set(null); + this.actionBusyPluginId.set(plugin.id); + + try { + if (action === 'Uninstall') { + this.store.uninstallPlugin(plugin.id); + } else { + await this.store.installPlugin(plugin); + } + } catch (error) { + if (this.destroyed) { + return; + } + + this.actionError.set(error instanceof Error ? error.message : 'Unable to update plugin installation'); + } finally { + if (!this.destroyed) { + this.actionBusyPluginId.set(null); + } + } + } + + async loadReadme(plugin: PluginStoreEntry): Promise { + this.readmeError.set(null); + this.readmeLoadingPluginId.set(plugin.id); + + try { + const readme = await this.store.loadReadme(plugin); + + if (this.destroyed) { + return; + } + + this.readme.set(readme); + } catch (error) { + if (this.destroyed) { + return; + } + + this.readmeError.set(error instanceof Error ? error.message : 'Unable to load readme'); + } finally { + if (!this.destroyed) { + this.readmeLoadingPluginId.set(null); + } + } + } + + closeReadme(): void { + this.readme.set(null); + this.readmeError.set(null); + } + + goBack(): void { + void this.router.navigateByUrl(this.getReturnUrl()); + } + + async openManager(): Promise { + await this.router.navigateByUrl(this.getReturnUrl()); + this.settingsModal.open('plugins'); + } + + selectSource(sourceUrl: string | null): void { + this.selectedSourceUrl.set(sourceUrl); + } + + toggleInstalledOnly(): void { + this.showInstalledOnly.update((value) => !value); + } + + openExternal(url?: string): void { + if (url) { + this.externalLinks.open(url); + } + } + + isPluginBusy(plugin: PluginStoreEntry): boolean { + return this.actionBusyPluginId() === plugin.id; + } + + isReadmeLoading(plugin: PluginStoreEntry): boolean { + return this.readmeLoadingPluginId() === plugin.id; + } + + isPrimaryActionDisabled(plugin: PluginStoreEntry): boolean { + return this.isPluginBusy(plugin) + || (!plugin.installUrl && this.store.getInstallState(plugin) !== 'installed'); + } + + primaryActionIcon(plugin: PluginStoreEntry): string { + const action = this.store.getActionLabel(plugin); + + if (action === 'Uninstall') { + return 'lucideTrash2'; + } + + return 'lucidePlus'; + } + + trackPlugin(index: number, plugin: PluginStoreEntry): string { + return `${plugin.sourceUrl}:${plugin.id}`; + } + + hideBrokenImage(event: Event): void { + const image = event.target as HTMLImageElement | null; + + if (image) { + image.hidden = true; + } + } + + private matchesSearch(plugin: PluginStoreEntry, searchTerm: string): boolean { + return [ + plugin.author, + plugin.description, + plugin.id, + plugin.sourceTitle, + plugin.title, + plugin.version + ].some((value) => value?.toLowerCase().includes(searchTerm)); + } + + private getReturnUrl(): string { + const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl'); + + if (returnUrl?.startsWith('/') && !returnUrl.startsWith('//') && !returnUrl.startsWith('/plugin-store')) { + return returnUrl; + } + + return '/search'; + } +} diff --git a/toju-app/src/app/domains/plugins/index.ts b/toju-app/src/app/domains/plugins/index.ts new file mode 100644 index 0000000..102b374 --- /dev/null +++ b/toju-app/src/app/domains/plugins/index.ts @@ -0,0 +1,16 @@ +export * from './application/services/plugin-capability.service'; +export * from './application/services/plugin-client-api.service'; +export * from './application/services/plugin-host.service'; +export * from './application/services/plugin-logger.service'; +export * from './application/services/plugin-registry.service'; +export * from './application/services/plugin-requirement.service'; +export * from './application/services/plugin-requirement-state.service'; +export * from './application/services/plugin-storage.service'; +export * from './application/services/plugin-store.service'; +export * from './application/services/plugin-ui-registry.service'; +export * from './domain/logic/plugin-dependency-resolver.logic'; +export * from './domain/logic/plugin-manifest-validation.logic'; +export * from './domain/models/plugin-api.models'; +export * from './domain/models/plugin-runtime.models'; +export * from './domain/models/plugin-store.models'; +export * from './infrastructure/local-plugin-discovery.service'; diff --git a/toju-app/src/app/domains/plugins/infrastructure/local-plugin-discovery.service.spec.ts b/toju-app/src/app/domains/plugins/infrastructure/local-plugin-discovery.service.spec.ts new file mode 100644 index 0000000..30232a1 --- /dev/null +++ b/toju-app/src/app/domains/plugins/infrastructure/local-plugin-discovery.service.spec.ts @@ -0,0 +1,114 @@ +import { Injector } from '@angular/core'; +import type { ElectronApi } from '../../../core/platform/electron/electron-api.models'; +import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service'; +import type { TojuPluginManifest } from '../../../shared-kernel'; +import { LocalPluginDiscoveryService } from './local-plugin-discovery.service'; + +const TEST_PLUGIN_MANIFEST = createTestPluginManifest(); + +describe('LocalPluginDiscoveryService', () => { + let electronApi: ElectronApi | null; + + beforeEach(() => { + electronApi = null; + }); + + it('returns a safe empty result outside Electron', async () => { + const service = createDiscoveryService(() => electronApi); + + expect(service.isAvailable).toBe(false); + await expect(service.getPluginsPath()).resolves.toBeNull(); + await expect(service.discoverManifests()).resolves.toEqual({ + errors: [], + plugins: [], + pluginsPath: '' + }); + }); + + it('maps Electron discovery results into plugin runtime models', async () => { + electronApi = { + getLocalPluginsPath: vi.fn(async () => '/plugins'), + listLocalPluginManifests: vi.fn(async () => ({ + 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' + })) + } as Partial as ElectronApi; + + const service = createDiscoveryService(() => electronApi); + + expect(service.isAvailable).toBe(true); + await expect(service.getPluginsPath()).resolves.toBe('/plugins'); + await expect(service.discoverManifests()).resolves.toEqual({ + 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' + }); + }); +}); + +function createDiscoveryService(readElectronApi: () => ElectronApi | null): LocalPluginDiscoveryService { + const injector = Injector.create({ + providers: [ + LocalPluginDiscoveryService, + { + provide: ElectronBridgeService, + useValue: { + get isAvailable(): boolean { + return readElectronApi() !== null; + }, + getApi: vi.fn(() => readElectronApi()) + } + } + ] + }); + + return injector.get(LocalPluginDiscoveryService); +} + +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' + }; +} diff --git a/toju-app/src/app/domains/plugins/infrastructure/local-plugin-discovery.service.ts b/toju-app/src/app/domains/plugins/infrastructure/local-plugin-discovery.service.ts new file mode 100644 index 0000000..aae98ed --- /dev/null +++ b/toju-app/src/app/domains/plugins/infrastructure/local-plugin-discovery.service.ts @@ -0,0 +1,46 @@ +import { Injectable, inject } from '@angular/core'; +import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service'; +import type { LocalPluginDiscoveryResult, LocalPluginManifestDescriptor } from '../domain/models/plugin-runtime.models'; + +@Injectable({ providedIn: 'root' }) +export class LocalPluginDiscoveryService { + private readonly electronBridge = inject(ElectronBridgeService); + + get isAvailable(): boolean { + return this.electronBridge.isAvailable; + } + + async getPluginsPath(): Promise { + const api = this.electronBridge.getApi(); + + return api ? await api.getLocalPluginsPath() : null; + } + + async discoverManifests(): Promise { + const api = this.electronBridge.getApi(); + + if (!api) { + return { + errors: [], + plugins: [], + pluginsPath: '' + }; + } + + const result = await api.listLocalPluginManifests(); + + return { + errors: result.errors, + plugins: result.plugins.map((plugin): LocalPluginManifestDescriptor => ({ + discoveredAt: plugin.discoveredAt, + entrypointPath: plugin.entrypointPath, + pluginRootUrl: plugin.pluginRootUrl, + manifest: plugin.manifest, + manifestPath: plugin.manifestPath, + pluginRoot: plugin.pluginRoot, + readmePath: plugin.readmePath + })), + pluginsPath: result.pluginsPath + }; + } +} diff --git a/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html b/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html index 4e952e7..80e9027 100644 --- a/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html +++ b/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html @@ -250,6 +250,41 @@ } + + @if (pluginChannelSections().length > 0 || pluginSidePanels().length > 0) { +
+
+

Plugins

+
+ + @if (pluginChannelSections().length > 0) { +
+ @for (record of pluginChannelSections(); track record.id) { + + } +
+ } + + @if (pluginSidePanels().length > 0) { +
+ @for (record of pluginSidePanels(); track record.id) { +
+

{{ record.contribution.label }}

+ +
+ } +
+ } +
+ } } diff --git a/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts b/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts index 0466fd6..14d4bf7 100644 --- a/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts +++ b/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts @@ -51,6 +51,8 @@ import { VoicePlaybackService } from '../../../domains/voice-connection'; import { formatGameActivityElapsed } from '../../../domains/game-activity'; import { ExternalLinkService } from '../../../core/platform/external-link.service'; import { VoiceControlsComponent } from '../../../domains/voice-session/feature/voice-controls/voice-controls.component'; +import { PluginRenderHostComponent } from '../../../domains/plugins/feature/plugin-render-host/plugin-render-host.component'; +import { PluginUiRegistryService } from '../../../domains/plugins'; import { isChannelNameTaken, normalizeChannelName } from '../../../store/rooms/room-channels.rules'; import { canManageMember, @@ -89,6 +91,7 @@ type PanelMode = 'channels' | 'users'; UserVolumeMenuComponent, UserAvatarComponent, ConfirmDialogComponent, + PluginRenderHostComponent, ThemeNodeDirective ], viewProviders: [ @@ -124,6 +127,7 @@ export class RoomsSidePanelComponent implements OnDestroy { private readonly externalLinks = inject(ExternalLinkService); private readonly voiceActivity = inject(VoiceActivityService); private readonly voiceConnectivity = inject(VoiceConnectivityHealthService); + private readonly pluginUi = inject(PluginUiRegistryService); private profileCardOpenTimer: ReturnType | null = null; private readonly activityTimer = setInterval(() => this.activityNow.set(Date.now()), 1_000); @@ -137,6 +141,8 @@ export class RoomsSidePanelComponent implements OnDestroy { activeChannelId = this.store.selectSignal(selectActiveChannelId); textChannels = this.store.selectSignal(selectTextChannels); voiceChannels = this.store.selectSignal(selectVoiceChannels); + pluginChannelSections = this.pluginUi.channelSectionRecords; + pluginSidePanels = this.pluginUi.sidePanelRecords; localUserHasDesync = this.voiceConnectivity.localUserHasDesync; roomMembers = computed(() => this.currentRoom()?.members ?? []); roomMemberIdentifiers = computed(() => { diff --git a/toju-app/src/app/features/settings/settings-modal/settings-modal.component.html b/toju-app/src/app/features/settings/settings-modal/settings-modal.component.html index 1adea0b..06bc181 100644 --- a/toju-app/src/app/features/settings/settings-modal/settings-modal.component.html +++ b/toju-app/src/app/features/settings/settings-modal/settings-modal.component.html @@ -135,6 +135,9 @@ @case ('general') { General } + @case ('plugins') { + Plugins + } @case ('network') { Network } @@ -193,6 +196,9 @@ @case ('general') { } + @case ('plugins') { + + } @case ('network') { } diff --git a/toju-app/src/app/features/settings/settings-modal/settings-modal.component.ts b/toju-app/src/app/features/settings/settings-modal/settings-modal.component.ts index eb9ccb9..d4d42ab 100644 --- a/toju-app/src/app/features/settings/settings-modal/settings-modal.component.ts +++ b/toju-app/src/app/features/settings/settings-modal/settings-modal.component.ts @@ -21,6 +21,7 @@ import { lucideGlobe, lucideAudioLines, lucidePalette, + lucidePackage, lucideSettings, lucideUsers, lucideBan, @@ -33,6 +34,7 @@ import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms. import { selectCurrentUser } from '../../../store/users/users.selectors'; import { Room, UserRole } from '../../../shared-kernel'; import { NotificationsSettingsComponent } from '../../../domains/notifications'; +import { PluginManagerComponent } from '../../../domains/plugins/feature/plugin-manager/plugin-manager.component'; import { resolveLegacyRole, resolveRoomPermission } from '../../../domains/access-control'; import { GeneralSettingsComponent } from './general-settings/general-settings.component'; @@ -62,6 +64,7 @@ import { GeneralSettingsComponent, NetworkSettingsComponent, NotificationsSettingsComponent, + PluginManagerComponent, VoiceSettingsComponent, UpdatesSettingsComponent, DataSettingsComponent, @@ -81,6 +84,7 @@ import { lucideGlobe, lucideAudioLines, lucidePalette, + lucidePackage, lucideSettings, lucideUsers, lucideBan, @@ -117,6 +121,7 @@ export class SettingsModalComponent { readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [ { id: 'general', label: 'General', icon: 'lucideSettings' }, + { id: 'plugins', label: 'Plugins', icon: 'lucidePackage' }, { id: 'theme', label: 'Theme Studio', icon: 'lucidePalette' }, { id: 'network', label: 'Network', icon: 'lucideGlobe' }, { id: 'notifications', label: 'Notifications', icon: 'lucideBell' }, diff --git a/toju-app/src/app/features/settings/settings.component.html b/toju-app/src/app/features/settings/settings.component.html index 2680fc7..a5b8b66 100644 --- a/toju-app/src/app/features/settings/settings.component.html +++ b/toju-app/src/app/features/settings/settings.component.html @@ -18,6 +18,18 @@

Settings

+ +
diff --git a/toju-app/src/app/features/settings/settings.component.ts b/toju-app/src/app/features/settings/settings.component.ts index 7b90a0b..6643c4c 100644 --- a/toju-app/src/app/features/settings/settings.component.ts +++ b/toju-app/src/app/features/settings/settings.component.ts @@ -20,7 +20,8 @@ import { lucideRefreshCw, lucideGlobe, lucideArrowLeft, - lucideAudioLines + lucideAudioLines, + lucidePackage } from '@ng-icons/lucide'; import { ServerDirectoryFacade } from '../../domains/server-directory'; @@ -47,7 +48,8 @@ import { STORAGE_KEY_CONNECTION_SETTINGS, STORAGE_KEY_VOICE_SETTINGS } from '../ lucideRefreshCw, lucideGlobe, lucideArrowLeft, - lucideAudioLines + lucideAudioLines, + lucidePackage }) ], templateUrl: './settings.component.html' @@ -173,6 +175,12 @@ export class SettingsComponent implements OnInit { this.router.navigate(['/']); } + openPluginStore(): void { + const returnUrl = this.router.url.startsWith('/plugin-store') ? '/search' : this.router.url; + + void this.router.navigate(['/plugin-store'], { queryParams: { returnUrl } }); + } + /** Load voice settings (noise reduction) from localStorage. */ loadVoiceSettings(): void { const settings = localStorage.getItem(STORAGE_KEY_VOICE_SETTINGS); diff --git a/toju-app/src/app/features/shell/title-bar/title-bar.component.html b/toju-app/src/app/features/shell/title-bar/title-bar.component.html index 38764ec..b8ee3ea 100644 --- a/toju-app/src/app/features/shell/title-bar/title-bar.component.html +++ b/toju-app/src/app/features/shell/title-bar/title-bar.component.html @@ -85,6 +85,20 @@ Login + +
+