feat: plugins v1

This commit is contained in:
2026-04-29 01:14:14 +02:00
parent ec3802ade6
commit 6920f93b41
86 changed files with 9036 additions and 14 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<Client>): Promise<PluginApiScenario> {
const suffix = uniqueName('plugin-api');
const serverName = `Plugin API Server ${suffix}`;
const alice = await createClient();
const bob = await createClient();
await registerUser(alice.page, `alice_${suffix}`, 'Alice');
await registerUser(bob.page, `bob_${suffix}`, 'Bob');
const aliceSearch = new ServerSearchPage(alice.page);
await aliceSearch.createServer(serverName, { description: 'Two-user plugin API E2E coverage' });
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 30_000 });
const aliceRoom = new ChatRoomPage(alice.page);
await aliceRoom.ensureVoiceChannelExists(VOICE_CHANNEL);
const bobSearch = new ServerSearchPage(bob.page);
await bobSearch.joinServerFromSearch(serverName);
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 30_000 });
const bobRoom = new ChatRoomPage(bob.page);
await aliceRoom.joinVoiceChannel(VOICE_CHANNEL);
await bobRoom.joinVoiceChannel(VOICE_CHANNEL);
await expect(aliceRoom.voiceControls).toBeVisible({ timeout: 30_000 });
await expect(bobRoom.voiceControls).toBeVisible({ timeout: 30_000 });
const aliceMessages = new ChatMessagesPage(alice.page);
const bobMessages = new ChatMessagesPage(bob.page);
await aliceMessages.waitForReady();
await bobMessages.waitForReady();
await expect(alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' })).toBeVisible({ timeout: 30_000 });
await expect(bob.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Alice' })).toBeVisible({ timeout: 30_000 });
return {
alice,
aliceRoom,
bob,
bobRoom,
aliceMessages,
bobMessages
};
}
async function registerUser(page: Page, username: string, displayName: string): Promise<void> {
const registerPage = new RegisterPage(page);
await registerPage.goto();
await registerPage.register(username, displayName, 'TestPass123!');
await expect(page).toHaveURL(/\/search/, { timeout: 30_000 });
}
async function installGrantAndActivatePlugin(page: Page): Promise<void> {
await page.getByRole('button', { name: 'Plugins' }).click();
await expect(page).toHaveURL(/\/plugin-store/, { timeout: 20_000 });
await expect(page.getByTestId('plugin-store-page')).toBeVisible({ timeout: 20_000 });
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<void> {
await page.keyboard.press('Escape');
await expect(page.getByTestId('plugin-manager')).toHaveCount(0);
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36)
.slice(2, 8)}`;
}
function soundboardComposerButton(page: Page) {
return page.locator('app-chat-message-composer')
.getByRole('button', { exact: true, name: SOUND_BOARD_LABEL });
}

View File

@@ -0,0 +1,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');
});
});
});

View File

@@ -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<void>;
messages: SocketMessage[];
send: (message: SocketMessage) => void;
}
test.describe('Plugin support API', () => {
test('covers plugin requirement, event, data, and websocket APIs with the fixture plugin', async ({ request, testServer }) => {
const manifest = await readPluginApiTestManifest();
const server = await createServer(request, testServer.url, `Plugin API ${Date.now()}`);
const relayEvent = getPluginApiTestEvent(manifest, TEST_PLUGIN_RELAY_EVENT);
const p2pEvent = getPluginApiTestEvent(manifest, TEST_PLUGIN_P2P_EVENT);
const pluginsApi = `${testServer.url}/api/servers/${encodeURIComponent(server.id)}/plugins`;
await test.step('Initial snapshot is empty', async () => {
const snapshot = await expectJson<PluginSnapshotResponse>(await request.get(pluginsApi));
expect(snapshot).toEqual(expect.objectContaining({
eventDefinitions: [],
requirements: [],
serverId: server.id
}));
});
await test.step('Requirement API enforces server management permission', async () => {
const response = await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, {
data: {
actorUserId: 'not-the-owner',
status: 'required'
}
});
const body = await expectJson<{ errorCode: string }>(response, 403);
expect(body.errorCode).toBe('NOT_AUTHORIZED');
});
await test.step('Requirement and event definition APIs persist the test plugin contract', async () => {
const requirement = await expectJson<PluginRequirementResponse>(await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, {
data: {
actorUserId: OWNER_USER_ID,
reason: manifest.description,
status: 'required',
versionRange: `^${manifest.version}`
}
}));
expect(requirement.requirement).toEqual(expect.objectContaining({
pluginId: TEST_PLUGIN_ID,
reason: manifest.description,
status: 'required',
versionRange: `^${manifest.version}`
}));
const relayDefinition = await upsertEventDefinition(request, pluginsApi, relayEvent);
const p2pDefinition = await upsertEventDefinition(request, pluginsApi, p2pEvent);
expect(relayDefinition.eventDefinition).toEqual(expect.objectContaining({
direction: 'serverRelay',
eventName: TEST_PLUGIN_RELAY_EVENT,
pluginId: TEST_PLUGIN_ID,
scope: 'server'
}));
expect(p2pDefinition.eventDefinition).toEqual(expect.objectContaining({
direction: 'p2pHint',
eventName: TEST_PLUGIN_P2P_EVENT,
pluginId: TEST_PLUGIN_ID,
scope: 'user'
}));
const snapshot = await expectJson<PluginSnapshotResponse>(await request.get(pluginsApi));
expect(snapshot.requirements.map((entry) => entry.pluginId)).toEqual([TEST_PLUGIN_ID]);
expect(snapshot.eventDefinitions.map((entry) => entry.eventName).sort()).toEqual([TEST_PLUGIN_P2P_EVENT, TEST_PLUGIN_RELAY_EVENT]);
});
await test.step('Plugin data API stores, lists, and deletes server scoped data', async () => {
const stored = await expectJson<PluginDataResponse>(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<PluginDataListResponse>(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<PluginDataListResponse>(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<PluginSnapshotResponse>(await request.get(pluginsApi));
expect(snapshot.eventDefinitions).toEqual([]);
expect(snapshot.requirements).toEqual([]);
});
});
});
async function createServer(
request: APIRequestContext,
baseUrl: string,
serverName: string
): Promise<CreatedServerResponse> {
const response = await request.post(`${baseUrl}/api/servers`, {
data: {
channels: [
{
id: 'general-text',
name: 'general',
position: 0,
type: 'text'
}
],
description: 'Server for plugin API E2E coverage',
id: `plugin-api-${Date.now()}`,
isPrivate: false,
name: serverName,
ownerId: OWNER_USER_ID,
ownerPublicKey: 'plugin-api-owner-public-key',
tags: ['plugins']
}
});
return await expectJson<CreatedServerResponse>(response, 201);
}
async function upsertEventDefinition(
request: APIRequestContext,
pluginsApi: string,
eventDefinition: ReturnType<typeof getPluginApiTestEvent>
): Promise<PluginEventDefinitionResponse> {
return await expectJson<PluginEventDefinitionResponse>(await request.put(
`${pluginsApi}/${TEST_PLUGIN_ID}/events/${encodeURIComponent(eventDefinition.eventName)}`,
{
data: {
actorUserId: OWNER_USER_ID,
direction: eventDefinition.direction,
maxPayloadBytes: eventDefinition.maxPayloadBytes,
schemaJson: '{"type":"object"}',
scope: eventDefinition.scope
}
}
));
}
async function expectJson<T>(response: APIResponse, status = 200): Promise<T> {
expect(response.status()).toBe(status);
return await response.json() as T;
}
async function openTestSocket(baseUrl: string): Promise<TestSocket> {
const socketUrl = baseUrl.replace(/^http/, 'ws');
const socket = new WebSocket(socketUrl);
const messages: SocketMessage[] = [];
socket.on('message', (data) => {
messages.push(JSON.parse(data.toString()) as SocketMessage);
});
await new Promise<void>((resolve, reject) => {
socket.once('open', () => resolve());
socket.once('error', reject);
});
await waitForSocketMessage({ messages, send: () => {}, close: async () => {} }, (message) => message.type === 'connected');
return {
close: async () => {
if (socket.readyState === WebSocket.CLOSED) {
return;
}
await new Promise<void>((resolve) => {
socket.once('close', () => resolve());
socket.close();
});
},
messages,
send: (message: SocketMessage) => {
socket.send(JSON.stringify(message));
}
};
}
async function waitForSocketMessage(
socket: Pick<TestSocket, 'messages'>,
predicate: (message: SocketMessage) => boolean,
timeoutMs = 10_000
): Promise<SocketMessage> {
const startedAt = Date.now();
return await new Promise((resolve, reject) => {
const interval = setInterval(() => {
const message = socket.messages.find(predicate);
if (message) {
clearInterval(interval);
resolve(message);
return;
}
if (Date.now() - startedAt > timeoutMs) {
clearInterval(interval);
reject(new Error('Timed out waiting for websocket message'));
}
}, 25);
});
}

View File

@@ -49,6 +49,7 @@ import {
readSavedTheme, readSavedTheme,
writeSavedTheme writeSavedTheme
} from '../theme-library'; } from '../theme-library';
import { getLocalPluginsPath, listLocalPluginManifests } from '../plugin-library';
import { import {
eraseUserData, eraseUserData,
exportUserData, exportUserData,
@@ -349,6 +350,8 @@ export function setupSystemHandlers(): void {
ipcMain.handle('import-user-data', async () => await importUserData()); ipcMain.handle('import-user-data', async () => await importUserData());
ipcMain.handle('erase-user-data', async () => await eraseUserData()); ipcMain.handle('erase-user-data', async () => await eraseUserData());
ipcMain.handle('get-saved-themes-path', async () => await getSavedThemesPath()); ipcMain.handle('get-saved-themes-path', async () => await getSavedThemesPath());
ipcMain.handle('get-local-plugins-path', async () => await getLocalPluginsPath());
ipcMain.handle('list-local-plugin-manifests', async () => await listLocalPluginManifests());
ipcMain.handle('list-saved-themes', async () => await listSavedThemes()); ipcMain.handle('list-saved-themes', async () => await listSavedThemes());
ipcMain.handle('read-saved-theme', async (_event, fileName: string) => await readSavedTheme(fileName)); ipcMain.handle('read-saved-theme', async (_event, fileName: string) => await readSavedTheme(fileName));
ipcMain.handle('write-saved-theme', async (_event, fileName: string, text: string) => { ipcMain.handle('write-saved-theme', async (_event, fileName: string, text: string) => {

View File

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

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

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

View File

@@ -109,6 +109,28 @@ export interface SavedThemeFileDescriptor {
path: string; path: string;
} }
export interface LocalPluginManifestDescriptor {
discoveredAt: number;
entrypointPath?: string;
pluginRootUrl: string;
manifest: unknown;
manifestPath: string;
pluginRoot: string;
readmePath?: string;
}
export interface LocalPluginDiscoveryError {
manifestPath?: string;
message: string;
pluginRoot?: string;
}
export interface LocalPluginDiscoveryResult {
errors: LocalPluginDiscoveryError[];
plugins: LocalPluginManifestDescriptor[];
pluginsPath: string;
}
export interface ExportUserDataResult { export interface ExportUserDataResult {
cancelled: boolean; cancelled: boolean;
exported: boolean; exported: boolean;
@@ -181,6 +203,8 @@ export interface ElectronAPI {
importUserData: () => Promise<ImportUserDataResult>; importUserData: () => Promise<ImportUserDataResult>;
eraseUserData: () => Promise<EraseUserDataResult>; eraseUserData: () => Promise<EraseUserDataResult>;
getSavedThemesPath: () => Promise<string>; getSavedThemesPath: () => Promise<string>;
getLocalPluginsPath: () => Promise<string>;
listLocalPluginManifests: () => Promise<LocalPluginDiscoveryResult>;
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>; listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
readSavedTheme: (fileName: string) => Promise<string>; readSavedTheme: (fileName: string) => Promise<string>;
writeSavedTheme: (fileName: string, text: string) => Promise<boolean>; writeSavedTheme: (fileName: string, text: string) => Promise<boolean>;
@@ -294,6 +318,8 @@ const electronAPI: ElectronAPI = {
importUserData: () => ipcRenderer.invoke('import-user-data'), importUserData: () => ipcRenderer.invoke('import-user-data'),
eraseUserData: () => ipcRenderer.invoke('erase-user-data'), eraseUserData: () => ipcRenderer.invoke('erase-user-data'),
getSavedThemesPath: () => ipcRenderer.invoke('get-saved-themes-path'), getSavedThemesPath: () => ipcRenderer.invoke('get-saved-themes-path'),
getLocalPluginsPath: () => ipcRenderer.invoke('get-local-plugins-path'),
listLocalPluginManifests: () => ipcRenderer.invoke('list-local-plugin-manifests'),
listSavedThemes: () => ipcRenderer.invoke('list-saved-themes'), listSavedThemes: () => ipcRenderer.invoke('list-saved-themes'),
readSavedTheme: (fileName) => ipcRenderer.invoke('read-saved-theme', fileName), readSavedTheme: (fileName) => ipcRenderer.invoke('read-saved-theme', fileName),
writeSavedTheme: (fileName, text) => ipcRenderer.invoke('write-saved-theme', fileName, text), writeSavedTheme: (fileName, text) => ipcRenderer.invoke('write-saved-theme', fileName, text),

View File

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

Binary file not shown.

View File

@@ -10,6 +10,10 @@ export interface LinkPreviewConfig {
maxCacheSizeMb: number; maxCacheSizeMb: number;
} }
export interface OpenApiDocsConfig {
enabled: boolean;
}
export interface ServerVariablesConfig { export interface ServerVariablesConfig {
klipyApiKey: string; klipyApiKey: string;
rawgApiKey: string; rawgApiKey: string;
@@ -18,6 +22,7 @@ export interface ServerVariablesConfig {
serverProtocol: ServerHttpProtocol; serverProtocol: ServerHttpProtocol;
serverHost: string; serverHost: string;
linkPreview: LinkPreviewConfig; linkPreview: LinkPreviewConfig;
openApiDocs: OpenApiDocsConfig;
} }
const DATA_DIR = resolveRuntimePath('data'); const DATA_DIR = resolveRuntimePath('data');
@@ -102,6 +107,14 @@ function normalizeLinkPreviewConfig(value: unknown): LinkPreviewConfig {
return { enabled, cacheTtlMinutes: cacheTtl, maxCacheSizeMb: maxSize }; return { enabled, cacheTtlMinutes: cacheTtl, maxCacheSizeMb: maxSize };
} }
function normalizeOpenApiDocsConfig(value: unknown): OpenApiDocsConfig {
const raw = (value && typeof value === 'object' && !Array.isArray(value))
? value as Record<string, unknown>
: {};
return { enabled: raw.enabled === true };
}
function hasEnvironmentOverride(value: string | undefined): value is string { function hasEnvironmentOverride(value: string | undefined): value is string {
return typeof value === 'string' && value.trim().length > 0; return typeof value === 'string' && value.trim().length > 0;
} }
@@ -149,7 +162,8 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
serverPort: normalizeServerPort(remainingParsed.serverPort), serverPort: normalizeServerPort(remainingParsed.serverPort),
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol), serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress), serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress),
linkPreview: normalizeLinkPreviewConfig(remainingParsed.linkPreview) linkPreview: normalizeLinkPreviewConfig(remainingParsed.linkPreview),
openApiDocs: normalizeOpenApiDocsConfig(remainingParsed.openApiDocs)
}; };
const nextContents = JSON.stringify(normalized, null, 2) + '\n'; const nextContents = JSON.stringify(normalized, null, 2) + '\n';
@@ -164,7 +178,8 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
serverPort: normalized.serverPort, serverPort: normalized.serverPort,
serverProtocol: normalized.serverProtocol, serverProtocol: normalized.serverProtocol,
serverHost: normalized.serverHost, serverHost: normalized.serverHost,
linkPreview: normalized.linkPreview linkPreview: normalized.linkPreview,
openApiDocs: normalized.openApiDocs
}; };
} }
@@ -218,6 +233,31 @@ export function isHttpsServerEnabled(): boolean {
return getServerProtocol() === 'https'; return getServerProtocol() === 'https';
} }
export function areOpenApiDocsEnabled(): boolean {
if (hasEnvironmentOverride(process.env.OPENAPI_DOCS_ENABLED)) {
return process.env.OPENAPI_DOCS_ENABLED.trim().toLowerCase() === 'true';
}
return getVariablesConfig().openApiDocs.enabled;
}
export function setOpenApiDocsEnabled(enabled: boolean): OpenApiDocsConfig {
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
const { parsed } = readRawVariables();
const next = {
...parsed,
openApiDocs: { enabled }
};
fs.writeFileSync(VARIABLES_FILE, JSON.stringify(next, null, 2) + '\n', 'utf8');
ensureVariablesConfig();
return { enabled };
}
export function getLinkPreviewConfig(): LinkPreviewConfig { export function getLinkPreviewConfig(): LinkPreviewConfig {
return getVariablesConfig().linkPreview; return getVariablesConfig().linkPreview;
} }

View File

@@ -15,7 +15,12 @@ import {
ServerMembershipEntity, ServerMembershipEntity,
ServerInviteEntity, ServerInviteEntity,
ServerBanEntity, ServerBanEntity,
GameMatchMissEntity GameMatchMissEntity,
ServerPluginRequirementEntity,
ServerPluginEventDefinitionEntity,
PluginDataEntity,
ServerPluginSettingsEntity,
PluginUserMetadataEntity
} from '../entities'; } from '../entities';
import { serverMigrations } from '../migrations'; import { serverMigrations } from '../migrations';
import { import {
@@ -49,8 +54,18 @@ const DB_BACKUP = DB_FILE + '.bak';
const DATA_DIR = path.dirname(DB_FILE); const DATA_DIR = path.dirname(DB_FILE);
// SQLite files start with this 16-byte header string. // SQLite files start with this 16-byte header string.
const SQLITE_MAGIC = 'SQLite format 3\0'; const SQLITE_MAGIC = 'SQLite format 3\0';
const SAVE_RETRY_DELAYS_MS = [25, 75, 150, 300, 600]; const SAVE_RETRY_DELAYS_MS = [
const RETRYABLE_SAVE_ERROR_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']); 25,
75,
150,
300,
600
];
const RETRYABLE_SAVE_ERROR_CODES = new Set([
'EPERM',
'EACCES',
'EBUSY'
]);
let applicationDataSource: DataSource | undefined; let applicationDataSource: DataSource | undefined;
let saveQueue: Promise<void> = Promise.resolve(); let saveQueue: Promise<void> = Promise.resolve();
@@ -250,7 +265,12 @@ export async function initDatabase(): Promise<void> {
ServerMembershipEntity, ServerMembershipEntity,
ServerInviteEntity, ServerInviteEntity,
ServerBanEntity, ServerBanEntity,
GameMatchMissEntity GameMatchMissEntity,
ServerPluginRequirementEntity,
ServerPluginEventDefinitionEntity,
PluginDataEntity,
ServerPluginSettingsEntity,
PluginUserMetadataEntity
], ],
migrations: serverMigrations, migrations: serverMigrations,
synchronize: process.env.DB_SYNCHRONIZE === 'true', synchronize: process.env.DB_SYNCHRONIZE === 'true',

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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;
}

View File

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

View File

@@ -10,3 +10,10 @@ export { ServerMembershipEntity } from './ServerMembershipEntity';
export { ServerInviteEntity } from './ServerInviteEntity'; export { ServerInviteEntity } from './ServerInviteEntity';
export { ServerBanEntity } from './ServerBanEntity'; export { ServerBanEntity } from './ServerBanEntity';
export { GameMatchMissEntity } from './GameMatchMissEntity'; export { GameMatchMissEntity } from './GameMatchMissEntity';
export { ServerPluginRequirementEntity } from './ServerPluginRequirementEntity';
export type { ServerPluginRequirementStatus } from './ServerPluginRequirementEntity';
export { ServerPluginEventDefinitionEntity } from './ServerPluginEventDefinitionEntity';
export type { ServerPluginEventDirection, ServerPluginEventScope } from './ServerPluginEventDefinitionEntity';
export { PluginDataEntity } from './PluginDataEntity';
export { ServerPluginSettingsEntity } from './ServerPluginSettingsEntity';
export { PluginUserMetadataEntity } from './PluginUserMetadataEntity';

View File

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

View File

@@ -5,6 +5,7 @@ import { RepairLegacyVoiceChannels1000000000003 } from './1000000000003-RepairLe
import { NormalizeServerArrays1000000000004 } from './1000000000004-NormalizeServerArrays'; import { NormalizeServerArrays1000000000004 } from './1000000000004-NormalizeServerArrays';
import { ServerRoleAccessControl1000000000005 } from './1000000000005-ServerRoleAccessControl'; import { ServerRoleAccessControl1000000000005 } from './1000000000005-ServerRoleAccessControl';
import { GameMatchMisses1000000000006 } from './1000000000006-GameMatchMisses'; import { GameMatchMisses1000000000006 } from './1000000000006-GameMatchMisses';
import { PluginSupport1000000000007 } from './1000000000007-PluginSupport';
export const serverMigrations = [ export const serverMigrations = [
InitialSchema1000000000000, InitialSchema1000000000000,
@@ -13,5 +14,6 @@ export const serverMigrations = [
RepairLegacyVoiceChannels1000000000003, RepairLegacyVoiceChannels1000000000003,
NormalizeServerArrays1000000000004, NormalizeServerArrays1000000000004,
ServerRoleAccessControl1000000000005, ServerRoleAccessControl1000000000005,
GameMatchMisses1000000000006 GameMatchMisses1000000000006,
PluginSupport1000000000007
]; ];

View File

@@ -6,6 +6,8 @@ import gamesRouter from './games';
import proxyRouter from './proxy'; import proxyRouter from './proxy';
import usersRouter from './users'; import usersRouter from './users';
import serversRouter from './servers'; import serversRouter from './servers';
import pluginSupportRouter from './plugin-support';
import openApiDocsRouter from './openapi-docs';
import joinRequestsRouter from './join-requests'; import joinRequestsRouter from './join-requests';
import { invitesApiRouter, invitePageRouter } from './invites'; import { invitesApiRouter, invitePageRouter } from './invites';
@@ -16,6 +18,8 @@ export function registerRoutes(app: Express): void {
app.use('/api/games', gamesRouter); app.use('/api/games', gamesRouter);
app.use('/api', proxyRouter); app.use('/api', proxyRouter);
app.use('/api/users', usersRouter); app.use('/api/users', usersRouter);
app.use('/api', openApiDocsRouter);
app.use('/api/servers', pluginSupportRouter);
app.use('/api/servers', serversRouter); app.use('/api/servers', serversRouter);
app.use('/api/invites', invitesApiRouter); app.use('/api/invites', invitesApiRouter);
app.use('/api/requests', joinRequestsRouter); app.use('/api/requests', joinRequestsRouter);

View File

@@ -0,0 +1,106 @@
import { Router } from 'express';
import { areOpenApiDocsEnabled, setOpenApiDocsEnabled } from '../config/variables';
const router = Router();
function createOpenApiDocument(baseUrl: string) {
return {
openapi: '3.1.0',
info: {
title: 'MetoYou Plugin Support API',
version: '1.0.0',
description: 'Official HTTP endpoints for plugin 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(`<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>MetoYou Plugin API Docs</title></head>
<body style="font-family:system-ui;margin:2rem;line-height:1.5">
<h1>MetoYou Plugin Support API</h1>
<p>Plugin support endpoints are available at <a href="/api/openapi.json">/api/openapi.json</a>.</p>
<p>The signal server stores metadata, data, and event definitions only. It never executes plugin code.</p>
</body>
</html>`);
});
export default router;

View File

@@ -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<void> {
const snapshot = await getPluginRequirementsSnapshot(serverId);
broadcastToServer(serverId, {
type: 'plugin_requirements_changed',
serverId,
snapshot
});
}
router.get('/:serverId/plugins', async (req, res) => {
try {
res.json(await getPluginRequirementsSnapshot(req.params.serverId));
} catch (error) {
sendPluginSupportError(error, res);
}
});
router.put('/:serverId/plugins/:pluginId/requirement', async (req, res) => {
const { serverId, pluginId } = req.params;
try {
const requirement = await upsertPluginRequirement({
actorUserId: readActorUserId(req.body.actorUserId),
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;

View File

@@ -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<ServerPluginRequirementStatus>([
'required',
'optional',
'recommended',
'blocked',
'incompatible'
]);
const VALID_EVENT_DIRECTIONS = new Set<ServerPluginEventDirection>([
'clientToServer',
'serverRelay',
'p2pHint'
]);
const VALID_EVENT_SCOPES = new Set<ServerPluginEventScope>([
'server',
'channel',
'user',
'plugin'
]);
const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9.-]{1,126}[a-z0-9]$/;
const EVENT_NAME_PATTERN = /^[a-z][a-z0-9.:-]{1,126}[a-z0-9]$/;
const DATA_KEY_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/;
const DATA_SCOPE_PATTERN = /^[a-zA-Z][a-zA-Z0-9._:-]{0,63}$/;
export interface PluginRequirementSummary {
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<void> {
const server = await assertServerExists(serverId);
if (!actorUserId || !resolveServerPermission(server, actorUserId, 'manageServer')) {
throw new PluginSupportError(403, 'NOT_AUTHORIZED', 'Not authorized');
}
}
export async function assertCanUsePluginData(serverId: string, actorUserId: string): Promise<void> {
const server = await assertServerExists(serverId);
if (!actorUserId) {
throw new PluginSupportError(400, 'MISSING_USER', 'Missing user id');
}
if (server.ownerId === actorUserId) {
return;
}
const membership = await findServerMembership(serverId, actorUserId);
if (!membership) {
throw new PluginSupportError(403, 'NOT_MEMBER', 'Only joined users can access plugin data');
}
}
export async function getPluginRequirementsSnapshot(serverId: string): Promise<PluginRequirementsSnapshot> {
await assertServerExists(serverId);
const requirementQuery = requirementRepository().find({ where: { serverId } });
const eventDefinitionQuery = eventDefinitionRepository().find({ where: { serverId } });
const [requirements, eventDefinitions] = await Promise.all([requirementQuery, eventDefinitionQuery]);
const requirementSummaries = requirements
.map(toRequirementSummary)
.sort((first, second) => first.pluginId.localeCompare(second.pluginId));
const eventDefinitionSummaries = eventDefinitions
.map(toEventDefinitionSummary)
.sort((first, second) => `${first.pluginId}:${first.eventName}`.localeCompare(`${second.pluginId}:${second.eventName}`));
const updatedAt = Math.max(
0,
...requirementSummaries.map((requirement) => requirement.updatedAt),
...eventDefinitionSummaries.map((definition) => definition.updatedAt)
);
return {
eventDefinitions: eventDefinitionSummaries,
requirements: requirementSummaries,
serverId,
updatedAt
};
}
export async function upsertPluginRequirement(options: {
actorUserId: string;
pluginId: string;
reason?: unknown;
serverId: string;
status: unknown;
versionRange?: unknown;
}): Promise<PluginRequirementSummary> {
await assertCanManagePluginSupport(options.serverId, options.actorUserId);
const pluginId = normalizePluginId(options.pluginId);
const status = options.status;
if (!VALID_REQUIREMENT_STATUSES.has(status as ServerPluginRequirementStatus)) {
throw new PluginSupportError(400, 'INVALID_REQUIREMENT_STATUS', 'Invalid plugin requirement status');
}
const repo = requirementRepository();
const now = Date.now();
const existing = await repo.findOne({ where: { serverId: options.serverId, pluginId } });
const entity = repo.create({
serverId: options.serverId,
pluginId,
status: status as ServerPluginRequirementStatus,
versionRange: normalizeOptionalString(options.versionRange, 128),
reason: normalizeOptionalString(options.reason, 512),
configuredBy: options.actorUserId,
createdAt: existing?.createdAt ?? now,
updatedAt: now
});
await repo.save(entity);
return toRequirementSummary(entity);
}
export async function deletePluginRequirement(options: {
actorUserId: string;
pluginId: string;
serverId: string;
}): Promise<void> {
await assertCanManagePluginSupport(options.serverId, options.actorUserId);
await requirementRepository().delete({ serverId: options.serverId, pluginId: normalizePluginId(options.pluginId) });
}
export async function upsertPluginEventDefinition(options: {
actorUserId: string;
direction: unknown;
eventName: string;
maxPayloadBytes?: unknown;
pluginId: string;
rateLimitJson?: unknown;
schemaJson?: unknown;
scope: unknown;
serverId: string;
}): Promise<PluginEventDefinitionSummary> {
await assertCanManagePluginSupport(options.serverId, options.actorUserId);
const pluginId = normalizePluginId(options.pluginId);
const eventName = normalizeEventName(options.eventName);
const { direction, scope } = options;
if (!VALID_EVENT_DIRECTIONS.has(direction as ServerPluginEventDirection)) {
throw new PluginSupportError(400, 'INVALID_EVENT_DIRECTION', 'Invalid plugin event direction');
}
if (!VALID_EVENT_SCOPES.has(scope as ServerPluginEventScope)) {
throw new PluginSupportError(400, 'INVALID_EVENT_SCOPE', 'Invalid plugin event scope');
}
const maxPayloadBytes = typeof options.maxPayloadBytes === 'number' && Number.isFinite(options.maxPayloadBytes)
? Math.max(1, Math.min(Math.floor(options.maxPayloadBytes), DEFAULT_PLUGIN_EVENT_MAX_PAYLOAD_BYTES))
: DEFAULT_PLUGIN_EVENT_MAX_PAYLOAD_BYTES;
const repo = eventDefinitionRepository();
const now = Date.now();
const existing = await repo.findOne({ where: { serverId: options.serverId, pluginId, eventName } });
const entity = repo.create({
serverId: options.serverId,
pluginId,
eventName,
direction: direction as ServerPluginEventDirection,
scope: scope as ServerPluginEventScope,
schemaJson: normalizeOptionalString(options.schemaJson, 10_000),
maxPayloadBytes,
rateLimitJson: normalizeOptionalString(options.rateLimitJson, 2_000),
createdAt: existing?.createdAt ?? now,
updatedAt: now
});
await repo.save(entity);
return toEventDefinitionSummary(entity);
}
export async function deletePluginEventDefinition(options: {
actorUserId: string;
eventName: string;
pluginId: string;
serverId: string;
}): Promise<void> {
await assertCanManagePluginSupport(options.serverId, options.actorUserId);
await eventDefinitionRepository().delete({
serverId: options.serverId,
pluginId: normalizePluginId(options.pluginId),
eventName: normalizeEventName(options.eventName)
});
}
export async function listPluginData(options: {
actorUserId: string;
key?: unknown;
ownerId?: unknown;
pluginId: string;
scope?: unknown;
serverId: string;
}): Promise<PluginDataRecord[]> {
await assertCanUsePluginData(options.serverId, options.actorUserId);
const pluginId = normalizePluginId(options.pluginId);
const scope = options.scope === undefined ? undefined : normalizeDataScope(options.scope);
const ownerId = options.ownerId === undefined ? undefined : normalizeOwnerId(options.ownerId);
const key = options.key === undefined ? undefined : normalizeDataKey(options.key);
const query = pluginDataRepository()
.createQueryBuilder('data')
.where('data.serverId = :serverId', { serverId: options.serverId })
.andWhere('data.pluginId = :pluginId', { pluginId });
if (scope !== undefined) {
query.andWhere('data.scope = :scope', { scope });
}
if (ownerId !== undefined) {
query.andWhere('data.ownerId = :ownerId', { ownerId });
}
if (key !== undefined) {
query.andWhere('data.key = :key', { key });
}
const records = await query
.orderBy('data.scope', 'ASC')
.addOrderBy('data.ownerId', 'ASC')
.addOrderBy('data.key', 'ASC')
.getMany();
return records.map(toPluginDataRecord);
}
export async function upsertPluginData(options: {
actorUserId: string;
key: string;
ownerId?: unknown;
pluginId: string;
schemaVersion?: unknown;
scope?: unknown;
serverId: string;
value: unknown;
}): Promise<PluginDataRecord> {
await assertCanUsePluginData(options.serverId, options.actorUserId);
const pluginId = normalizePluginId(options.pluginId);
const scope = normalizeDataScope(options.scope);
const ownerId = scope === 'user' ? normalizeOwnerId(options.ownerId ?? options.actorUserId) : normalizeOwnerId(options.ownerId);
if (scope === 'user' && ownerId !== options.actorUserId) {
await assertCanManagePluginSupport(options.serverId, options.actorUserId);
}
const key = normalizeDataKey(options.key);
const schemaVersion = typeof options.schemaVersion === 'number' && Number.isFinite(options.schemaVersion)
? Math.max(1, Math.floor(options.schemaVersion))
: 1;
const repo = pluginDataRepository();
const entity = repo.create({
serverId: options.serverId,
pluginId,
scope,
ownerId,
key,
valueJson: serializeJsonValue(options.value, 'INVALID_PLUGIN_DATA'),
schemaVersion,
updatedBy: options.actorUserId,
updatedAt: Date.now()
});
await repo.save(entity);
return toPluginDataRecord(entity);
}
export async function deletePluginData(options: {
actorUserId: string;
key: string;
ownerId?: unknown;
pluginId: string;
scope?: unknown;
serverId: string;
}): Promise<void> {
await assertCanUsePluginData(options.serverId, options.actorUserId);
const pluginId = normalizePluginId(options.pluginId);
const scope = normalizeDataScope(options.scope);
const ownerId = scope === 'user' ? normalizeOwnerId(options.ownerId ?? options.actorUserId) : normalizeOwnerId(options.ownerId);
if (scope === 'user' && ownerId !== options.actorUserId) {
await assertCanManagePluginSupport(options.serverId, options.actorUserId);
}
await pluginDataRepository().delete({
serverId: options.serverId,
pluginId,
scope,
ownerId,
key: normalizeDataKey(options.key)
});
}
export async function validatePluginEventEnvelope(envelope: PluginEventEnvelope): Promise<ServerPluginEventDefinitionEntity> {
const pluginId = normalizePluginId(envelope.pluginId);
const eventName = normalizeEventName(envelope.eventName);
const definition = await eventDefinitionRepository().findOne({
where: {
serverId: envelope.serverId,
pluginId,
eventName
}
});
if (!definition) {
throw new PluginSupportError(404, 'PLUGIN_EVENT_NOT_REGISTERED', 'Plugin event is not registered for this server');
}
if (definition.direction === 'p2pHint') {
throw new PluginSupportError(400, 'PLUGIN_EVENT_NOT_RELAYABLE', 'P2P plugin events must not be relayed by the signal server');
}
const payloadBytes = Buffer.byteLength(serializeJsonValue(envelope.payload, 'INVALID_PLUGIN_EVENT_PAYLOAD'), 'utf8');
if (payloadBytes > definition.maxPayloadBytes) {
throw new PluginSupportError(413, 'PLUGIN_EVENT_TOO_LARGE', 'Plugin event payload is too large');
}
return definition;
}

View File

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

View File

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

View File

@@ -96,12 +96,12 @@
"budgets": [ "budgets": [
{ {
"type": "initial", "type": "initial",
"maximumWarning": "2.2MB", "maximumWarning": "2.5MB",
"maximumError": "2.38MB" "maximumError": "2.6MB"
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",
"maximumWarning": "4kB", "maximumWarning": "7kB",
"maximumError": "8kB" "maximumError": "8kB"
} }
], ],

View File

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

View File

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

After

Width:  |  Height:  |  Size: 319 B

View File

@@ -0,0 +1,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();
}

View File

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

View File

@@ -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"
}
]
}

View File

@@ -48,5 +48,15 @@ export const routes: Routes = [
path: 'settings', path: 'settings',
loadComponent: () => loadComponent: () =>
import('./features/settings/settings.component').then((module) => module.SettingsComponent) import('./features/settings/settings.component').then((module) => module.SettingsComponent)
},
{
path: 'plugin-store',
loadComponent: () =>
import('./domains/plugins/feature/plugin-store/plugin-store.component').then((module) => module.PluginStoreComponent)
},
{
path: 'plugins/:pluginId/:pageId',
loadComponent: () =>
import('./domains/plugins/feature/plugin-page-host/plugin-page-host.component').then((module) => module.PluginPageHostComponent)
} }
]; ];

View File

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

View File

@@ -124,6 +124,28 @@ export interface SavedThemeFileDescriptor {
path: string; path: string;
} }
export interface LocalPluginManifestDescriptor {
discoveredAt: number;
entrypointPath?: string;
pluginRootUrl: string;
manifest: unknown;
manifestPath: string;
pluginRoot: string;
readmePath?: string;
}
export interface LocalPluginDiscoveryError {
manifestPath?: string;
message: string;
pluginRoot?: string;
}
export interface LocalPluginDiscoveryResult {
errors: LocalPluginDiscoveryError[];
plugins: LocalPluginManifestDescriptor[];
pluginsPath: string;
}
export interface ExportUserDataResult { export interface ExportUserDataResult {
cancelled: boolean; cancelled: boolean;
exported: boolean; exported: boolean;
@@ -189,6 +211,8 @@ export interface ElectronApi {
importUserData: () => Promise<ImportUserDataResult>; importUserData: () => Promise<ImportUserDataResult>;
eraseUserData: () => Promise<EraseUserDataResult>; eraseUserData: () => Promise<EraseUserDataResult>;
getSavedThemesPath: () => Promise<string>; getSavedThemesPath: () => Promise<string>;
getLocalPluginsPath: () => Promise<string>;
listLocalPluginManifests: () => Promise<LocalPluginDiscoveryResult>;
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>; listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
readSavedTheme: (fileName: string) => Promise<string>; readSavedTheme: (fileName: string) => Promise<string>;
writeSavedTheme: (fileName: string, text: string) => Promise<boolean>; writeSavedTheme: (fileName: string, text: string) => Promise<boolean>;

View File

@@ -2,6 +2,7 @@ import { Injectable, signal } from '@angular/core';
export type SettingsPage = export type SettingsPage =
| 'general' | 'general'
| 'plugins'
| 'theme' | 'theme'
| 'network' | 'network'
| 'notifications' | 'notifications'

View File

@@ -15,6 +15,7 @@ infrastructure adapters and UI.
| **direct-message** | One-to-one WebRTC messages, offline queueing, delivery state, and friends | `DirectMessageService`, `FriendService` | | **direct-message** | One-to-one WebRTC messages, offline queueing, delivery state, and friends | `DirectMessageService`, `FriendService` |
| **game-activity** | Local game detection, server metadata matching, P2P now-playing sync, and elapsed playtime formatting | `GameActivityService`, `formatGameActivityElapsed()` | | **game-activity** | Local game detection, server metadata matching, P2P now-playing sync, and elapsed playtime formatting | `GameActivityService`, `formatGameActivityElapsed()` |
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` | | **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
| **plugins** | Client-only plugin manifests, load ordering, registry state, and signal-server support metadata | `PluginHostService`, `PluginRegistryService` |
| **profile-avatar** | Profile picture upload, crop/zoom editing, processing, local persistence, and P2P avatar sync | `ProfileAvatarFacade` | | **profile-avatar** | Profile picture upload, crop/zoom editing, processing, local persistence, and P2P avatar sync | `ProfileAvatarFacade` |
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` | | **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
| **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` | | **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` |
@@ -32,6 +33,7 @@ The larger domains also keep longer design notes in their own folders:
- [chat/README.md](chat/README.md) - [chat/README.md](chat/README.md)
- [direct-message/README.md](direct-message/README.md) - [direct-message/README.md](direct-message/README.md)
- [notifications/README.md](notifications/README.md) - [notifications/README.md](notifications/README.md)
- [plugins/README.md](plugins/README.md)
- [profile-avatar/README.md](profile-avatar/README.md) - [profile-avatar/README.md](profile-avatar/README.md)
- [screen-share/README.md](screen-share/README.md) - [screen-share/README.md](screen-share/README.md)
- [server-directory/README.md](server-directory/README.md) - [server-directory/README.md](server-directory/README.md)

View File

@@ -141,6 +141,20 @@
(drop)="onDrop($event)" (drop)="onDrop($event)"
> >
<div class="absolute bottom-3 right-3 z-10 flex items-center gap-2 m-0.5"> <div class="absolute bottom-3 right-3 z-10 flex items-center gap-2 m-0.5">
@for (record of pluginComposerActions(); track record.id) {
<button
type="button"
(click)="runPluginComposerAction(record.contribution.run)"
class="inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-2xl border border-border/70 bg-secondary/55 px-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground shadow-sm backdrop-blur-md transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/35 hover:bg-secondary/90 hover:text-foreground"
[class.opacity-100]="inputHovered()"
[class.opacity-70]="!inputHovered()"
[attr.aria-label]="record.contribution.label"
[title]="record.contribution.label"
>
<span>{{ record.contribution.icon ?? record.contribution.label }}</span>
</button>
}
@if (klipyEnabled()) { @if (klipyEnabled()) {
<button <button
#klipyTrigger #klipyTrigger

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,17 @@
# Plugins Domain
Owns the client-only plugin runtime foundation: manifest validation, deterministic load ordering, registry state, local manifest discovery, capability grants, browser-imported client entrypoints, disposable UI extension registries, plugin logs, and typed access to signal-server plugin support metadata.
The signal server can store plugin metadata/data and relay registered plugin events, but it must never execute plugin code. Executable plugin loading belongs to the renderer/Electron boundary and should enter this domain through `PluginHostService`.
Desktop local plugins are discovered from the Electron app data `plugins` folder. Discovery reads `toju-plugin.json` or `plugin.json` from immediate child folders and resolves declared entrypoint/readme paths only when they stay inside that plugin folder.
The standalone plugin store is available from the title bar Plugins button, the title-bar Plugin Store menu item, the legacy Settings page button, and the Plugin Manager header. It owns source manifest management, search, readmes, install/update/uninstall actions, and links back to installed-plugin management.
The plugin manager UI is available from Settings -> Plugins and from the store page Manage Plugins button. It includes installed plugins, capability grant toggles, activate/reload/unload actions, runtime logs, extension-point counts, server requirements, generated settings, and docs.
The Store tab consumes user-managed HTTP(S) source manifests. A source manifest can expose a `plugins` array whose entries include `id`, `title`, `description`, `version`, `author`/`authors`, `image`/`imageUrl`, `github`/`githubUrl`, `install`/`installUrl`/`manifestUrl`, and `readme`/`readmeUrl`. Installing from the store fetches the linked plugin manifest, validates it, registers it with the client registry, and persists the manifest locally; it does not execute plugin code on the signal server.
Runtime activation is explicit. `PluginHostService.activateReadyPlugins()` imports browser-safe plugin entrypoints from URL-resolvable manifests, passes a frozen `TojuClientPluginApi`, runs `activate`, then runs `ready` after the load-order pass. `deactivate` runs during unload/reload, disposables are cleaned in reverse order, and UI contributions are removed by plugin id.
Plugins that need fully custom UI can call `api.ui.mountElement(id, { target, element, position })` with the `ui.dom` capability. The runtime tags mounted elements with plugin ownership metadata, replaces duplicate mounts for the same plugin/id pair, and removes remaining mounted elements when the plugin is unloaded.

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,255 @@
import { Injectable, inject } from '@angular/core';
import { environment } from '../../../../../environments/environment';
import type { TojuPluginManifest } from '../../../../shared-kernel';
import {
DEVELOPMENT_PLUGIN_ENTRYPOINT,
DEVELOPMENT_PLUGIN_MANIFEST,
DEVELOPMENT_PLUGIN_MODULE
} from '../../development/development-plugin';
import type {
TojuClientPluginModule,
TojuPluginActivationContext,
TojuPluginDisposable
} from '../../domain/models/plugin-api.models';
import type {
LocalPluginDiscoveryError,
LocalPluginRegistrationResult,
RegisteredPlugin
} from '../../domain/models/plugin-runtime.models';
import { LocalPluginDiscoveryService } from '../../infrastructure/local-plugin-discovery.service';
import { PluginCapabilityService } from './plugin-capability.service';
import { PluginClientApiService } from './plugin-client-api.service';
import { PluginLoggerService } from './plugin-logger.service';
import { PluginRegistryService } from './plugin-registry.service';
import { PluginUiRegistryService } from './plugin-ui-registry.service';
interface ActivePluginRuntime {
context: TojuPluginActivationContext;
module: TojuClientPluginModule;
}
@Injectable({ providedIn: 'root' })
export class PluginHostService {
private readonly apiFactory = inject(PluginClientApiService);
private readonly capabilities = inject(PluginCapabilityService);
private readonly localDiscovery = inject(LocalPluginDiscoveryService);
private readonly logger = inject(PluginLoggerService);
private readonly registry = inject(PluginRegistryService);
private readonly uiRegistry = inject(PluginUiRegistryService);
private readonly activePlugins = new Map<string, ActivePluginRuntime>();
constructor() {
this.registerDevelopmentPlugin();
}
registerLocalManifest(manifestValue: unknown, sourcePath?: string): RegisteredPlugin {
return this.registry.registerManifest(manifestValue, sourcePath);
}
async discoverLocalPlugins(): Promise<LocalPluginRegistrationResult> {
const discovery = await this.localDiscovery.discoverManifests();
const registered: RegisteredPlugin[] = [];
const errors: LocalPluginDiscoveryError[] = [...discovery.errors];
for (const descriptor of discovery.plugins) {
try {
registered.push(this.registerLocalManifest(descriptor.manifest, descriptor.pluginRootUrl ?? descriptor.pluginRoot));
} catch (error) {
errors.push({
manifestPath: descriptor.manifestPath,
message: error instanceof Error ? error.message : 'Plugin manifest validation failed',
pluginRoot: descriptor.pluginRoot
});
}
}
return {
discovery,
errors,
registered
};
}
getReadyManifests(): TojuPluginManifest[] {
return this.registry.loadOrder().ordered;
}
async activateReadyPlugins(): Promise<void> {
const activated: TojuPluginActivationContext[] = [];
for (const manifest of this.registry.loadOrder().ordered) {
const entry = this.registry.find(manifest.id);
if (!entry || !entry.enabled || this.activePlugins.has(manifest.id)) {
continue;
}
await this.activatePlugin(entry);
const active = this.activePlugins.get(manifest.id);
if (active) {
activated.push(active.context);
}
}
for (const context of activated) {
const active = this.activePlugins.get(context.pluginId);
if (!active?.module.ready) {
continue;
}
try {
await active.module.ready(context);
this.registry.setState(context.pluginId, 'ready');
} catch (error) {
this.failPlugin(context.pluginId, error);
}
}
}
async deactivatePlugin(pluginId: string): Promise<void> {
const active = this.activePlugins.get(pluginId);
if (!active) {
this.registry.setState(pluginId, 'unloaded');
this.uiRegistry.unregisterPlugin(pluginId);
return;
}
this.registry.setState(pluginId, 'unloading');
try {
await active.module.deactivate?.(active.context);
} catch (error) {
this.logger.warn(pluginId, 'Plugin deactivate failed', error);
}
for (const disposable of [...active.context.subscriptions].reverse()) {
safeDispose(disposable, pluginId, this.logger);
}
this.uiRegistry.unregisterPlugin(pluginId);
this.activePlugins.delete(pluginId);
this.registry.setState(pluginId, 'unloaded');
}
async deactivateAll(): Promise<void> {
const pluginIds = Array.from(this.activePlugins.keys()).reverse();
for (const pluginId of pluginIds) {
await this.deactivatePlugin(pluginId);
}
}
async reloadPlugin(pluginId: string): Promise<void> {
await this.deactivatePlugin(pluginId);
const entry = this.registry.find(pluginId);
if (entry?.enabled) {
await this.activatePlugin(entry);
}
}
markLoaded(pluginId: string): void {
this.registry.setState(pluginId, 'loaded');
}
markFailed(pluginId: string): void {
this.registry.setState(pluginId, 'failed');
}
private async activatePlugin(entry: RegisteredPlugin): Promise<void> {
const manifest = entry.manifest;
const missingCapabilities = this.capabilities.missing(manifest);
if (missingCapabilities.length > 0) {
this.registry.setFailed(manifest.id, `Missing capabilities: ${missingCapabilities.join(', ')}`);
this.logger.warn(manifest.id, 'Plugin blocked by missing capability grants', missingCapabilities);
return;
}
if (!manifest.entrypoint) {
this.registry.setState(manifest.id, 'ready');
return;
}
this.registry.setState(manifest.id, 'loading');
try {
const module = await this.loadPluginModule(manifest, entry.sourcePath);
const context: TojuPluginActivationContext = {
api: this.apiFactory.createApi(manifest),
manifest,
pluginId: manifest.id,
subscriptions: []
};
await module.activate?.(context);
this.activePlugins.set(manifest.id, { context, module });
this.registry.setState(manifest.id, 'loaded');
this.logger.info(manifest.id, 'Plugin activated');
} catch (error) {
this.failPlugin(manifest.id, error);
}
}
private failPlugin(pluginId: string, error: unknown): void {
const message = error instanceof Error ? error.message : 'Plugin activation failed';
this.registry.setFailed(pluginId, message);
this.logger.error(pluginId, message, error);
this.uiRegistry.unregisterPlugin(pluginId);
this.activePlugins.delete(pluginId);
}
private async loadPluginModule(manifest: TojuPluginManifest, sourcePath?: string): Promise<TojuClientPluginModule> {
if (manifest.entrypoint === DEVELOPMENT_PLUGIN_ENTRYPOINT) {
return DEVELOPMENT_PLUGIN_MODULE;
}
return await import(/* @vite-ignore */ this.resolveEntrypoint(manifest, sourcePath)) as TojuClientPluginModule;
}
private registerDevelopmentPlugin(): void {
if (environment.production) {
return;
}
try {
this.registry.registerManifest(DEVELOPMENT_PLUGIN_MANIFEST, DEVELOPMENT_PLUGIN_ENTRYPOINT);
} catch (error) {
this.logger.warn(DEVELOPMENT_PLUGIN_MANIFEST.id, 'Development plugin registration failed', error);
}
}
private resolveEntrypoint(manifest: TojuPluginManifest, sourcePath?: string): string {
if (!manifest.entrypoint) {
throw new Error('Plugin entrypoint is missing');
}
try {
return new URL(manifest.entrypoint).toString();
} catch {}
if (sourcePath?.startsWith('http://') || sourcePath?.startsWith('https://') || sourcePath?.startsWith('file://')) {
return new URL(manifest.entrypoint, sourcePath).toString();
}
if (manifest.entrypoint.startsWith('/')) {
return manifest.entrypoint;
}
throw new Error(`Plugin ${manifest.id} has no browser-importable entrypoint`);
}
}
function safeDispose(disposable: TojuPluginDisposable, pluginId: string, logger: PluginLoggerService): void {
try {
disposable.dispose();
} catch (error) {
logger.warn(pluginId, 'Plugin disposable failed', error);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,109 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { firstValueFrom } from 'rxjs';
import { getUserScopedStorageKey } from '../../../../core/storage/current-user-storage';
import { ServerDirectoryFacade } from '../../../server-directory';
import { selectCurrentRoomId } from '../../../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
const STORAGE_PREFIX_PLUGIN_LOCAL = 'metoyou_plugin_local';
interface PluginDataResponse {
record?: {
value: unknown;
};
records?: {
value: unknown;
}[];
}
@Injectable({ providedIn: 'root' })
export class PluginStorageService {
private readonly http = inject(HttpClient);
private readonly serverDirectory = inject(ServerDirectoryFacade);
private readonly store = inject(Store);
private readonly currentRoomId = this.store.selectSignal(selectCurrentRoomId);
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
getLocal(pluginId: string, key: string): unknown {
return this.read(`${STORAGE_PREFIX_PLUGIN_LOCAL}:${pluginId}:${key}`);
}
removeLocal(pluginId: string, key: string): void {
localStorage.removeItem(getUserScopedStorageKey(`${STORAGE_PREFIX_PLUGIN_LOCAL}:${pluginId}:${key}`));
}
setLocal(pluginId: string, key: string, value: unknown): void {
this.write(`${STORAGE_PREFIX_PLUGIN_LOCAL}:${pluginId}:${key}`, value);
}
async readServerData(pluginId: string, key: string): Promise<unknown> {
const response = await firstValueFrom(this.http.get<PluginDataResponse>(`${this.pluginsApi(pluginId)}/data`, {
params: new HttpParams()
.set('key', key)
.set('scope', 'server')
.set('userId', this.requireActorUserId())
}));
return response.records?.[0]?.value ?? null;
}
async removeServerData(pluginId: string, key: string): Promise<void> {
await firstValueFrom(this.http.delete(`${this.pluginsApi(pluginId)}/data/${encodeURIComponent(key)}`, {
body: {
actorUserId: this.requireActorUserId(),
scope: 'server'
}
}));
}
async writeServerData(pluginId: string, key: string, value: unknown): Promise<void> {
await firstValueFrom(this.http.put<PluginDataResponse>(`${this.pluginsApi(pluginId)}/data/${encodeURIComponent(key)}`, {
actorUserId: this.requireActorUserId(),
scope: 'server',
value
}));
}
private pluginsApi(pluginId: string): string {
const roomId = this.currentRoomId();
if (!roomId) {
throw new Error('No active server for plugin server data');
}
const apiBase = this.serverDirectory.getApiBaseUrl();
return `${apiBase}/servers/${encodeURIComponent(roomId)}/plugins/${encodeURIComponent(pluginId)}`;
}
private requireActorUserId(): string {
const user = this.currentUser();
const userId = user?.oderId || user?.id;
if (!userId) {
throw new Error('No current user for plugin server data');
}
return userId;
}
private read(key: string): unknown {
const raw = localStorage.getItem(getUserScopedStorageKey(key));
if (!raw) {
return null;
}
try {
return JSON.parse(raw) as unknown;
} catch {
return null;
}
}
private write(key: string, value: unknown): void {
localStorage.setItem(getUserScopedStorageKey(key), JSON.stringify(value));
}
}

View File

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

View File

@@ -0,0 +1,453 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Injectable,
computed,
inject,
signal
} from '@angular/core';
import { getUserScopedStorageKey } from '../../../../core/storage/current-user-storage';
import type { TojuPluginManifest } from '../../../../shared-kernel';
import { validateTojuPluginManifest } from '../../domain/logic/plugin-manifest-validation.logic';
import type {
InstalledStorePlugin,
PersistedPluginStoreState,
PluginStoreEntry,
PluginStoreInstallState,
PluginStoreReadme,
PluginStoreSourceResult
} from '../../domain/models/plugin-store.models';
import { PluginHostService } from './plugin-host.service';
import { PluginRegistryService } from './plugin-registry.service';
const STORE_SCHEMA_VERSION = 1;
const STORAGE_KEY_PLUGIN_STORE = 'metoyou_plugin_store';
const DEFAULT_STORE_STATE: PersistedPluginStoreState = {
installedPlugins: [],
sourceUrls: []
};
@Injectable({ providedIn: 'root' })
export class PluginStoreService {
private readonly host = inject(PluginHostService);
private readonly registry = inject(PluginRegistryService);
private readonly sourceUrlsSignal = signal<string[]>([]);
private readonly sourcesSignal = signal<PluginStoreSourceResult[]>([]);
private readonly installedPluginsSignal = signal<InstalledStorePlugin[]>([]);
private readonly loadingSignal = signal(false);
private refreshAbortController: AbortController | null = null;
private refreshVersion = 0;
readonly sourceUrls = this.sourceUrlsSignal.asReadonly();
readonly sources = this.sourcesSignal.asReadonly();
readonly installedPlugins = this.installedPluginsSignal.asReadonly();
readonly isLoading = this.loadingSignal.asReadonly();
readonly availablePlugins = computed(() => this.sources().flatMap((source) => source.plugins));
readonly installedById = computed(() => new Map(this.installedPlugins().map((plugin) => [plugin.manifest.id, plugin])));
constructor() {
const state = this.loadState();
this.sourceUrlsSignal.set(state.sourceUrls);
this.installedPluginsSignal.set(state.installedPlugins);
this.hydrateInstalledPlugins(state.installedPlugins);
}
async addSourceUrl(rawUrl: string): Promise<void> {
const sourceUrl = normalizeRemoteUrl(rawUrl, 'Plugin source URL');
if (this.sourceUrls().includes(sourceUrl)) {
throw new Error('Plugin source already exists');
}
this.sourceUrlsSignal.update((sourceUrls) => [...sourceUrls, sourceUrl]);
this.saveState();
await this.refreshSources();
}
async removeSourceUrl(sourceUrl: string): Promise<void> {
this.sourceUrlsSignal.update((sourceUrls) => sourceUrls.filter((candidate) => candidate !== sourceUrl));
this.sourcesSignal.update((sources) => sources.filter((source) => source.url !== sourceUrl));
this.saveState();
await this.refreshSources();
}
async refreshSources(): Promise<void> {
const currentRefresh = this.refreshVersion + 1;
const abortController = new AbortController();
this.refreshVersion = currentRefresh;
this.refreshAbortController?.abort();
this.refreshAbortController = abortController;
this.loadingSignal.set(true);
try {
const sources = await Promise.all(this.sourceUrls().map((sourceUrl) => this.loadSource(sourceUrl, abortController.signal)));
if (this.refreshVersion === currentRefresh) {
this.sourcesSignal.set(sources);
}
} finally {
if (this.refreshVersion === currentRefresh) {
this.refreshAbortController = null;
this.loadingSignal.set(false);
}
}
}
async installPlugin(plugin: PluginStoreEntry): Promise<InstalledStorePlugin> {
if (!plugin.installUrl) {
throw new Error('Plugin does not provide an install manifest URL');
}
const manifest = await this.fetchPluginManifest(plugin.installUrl);
const registered = this.host.registerLocalManifest(manifest, plugin.installUrl);
const now = Date.now();
const existing = this.installedById().get(registered.manifest.id);
const installedPlugin: InstalledStorePlugin = {
installedAt: existing?.installedAt ?? now,
installUrl: plugin.installUrl,
manifest: registered.manifest,
sourceUrl: plugin.sourceUrl,
updatedAt: now
};
this.installedPluginsSignal.update((installedPlugins) => {
const existingPlugins = installedPlugins.filter((candidate) => candidate.manifest.id !== registered.manifest.id);
return [...existingPlugins, installedPlugin].sort(sortInstalledPlugins);
});
this.saveState();
return installedPlugin;
}
uninstallPlugin(pluginId: string): void {
this.registry.unregister(pluginId);
this.installedPluginsSignal.update((installedPlugins) =>
installedPlugins.filter((installedPlugin) => installedPlugin.manifest.id !== pluginId)
);
this.saveState();
}
async loadReadme(plugin: PluginStoreEntry): Promise<PluginStoreReadme> {
if (!plugin.readmeUrl) {
throw new Error('Plugin does not provide a readme URL');
}
const response = await fetch(plugin.readmeUrl, { headers: { Accept: 'text/markdown,text/plain,*/*' } });
if (!response.ok) {
throw new Error(`Unable to load readme (${response.status})`);
}
return {
markdown: await response.text(),
pluginId: plugin.id,
title: plugin.title,
url: plugin.readmeUrl
};
}
getInstallState(plugin: PluginStoreEntry): PluginStoreInstallState {
const installed = this.installedById().get(plugin.id);
if (!installed) {
return 'notInstalled';
}
return compareVersions(plugin.version, installed.manifest.version) > 0
? 'updateAvailable'
: 'installed';
}
getActionLabel(plugin: PluginStoreEntry): 'Install' | 'Uninstall' | 'Update' {
const state = this.getInstallState(plugin);
if (state === 'updateAvailable') {
return 'Update';
}
return state === 'installed' ? 'Uninstall' : 'Install';
}
private async loadSource(sourceUrl: string, signal: AbortSignal): Promise<PluginStoreSourceResult> {
try {
const response = await fetch(sourceUrl, { headers: { Accept: 'application/json' }, signal });
if (!response.ok) {
throw new Error(`Source returned ${response.status}`);
}
const sourceValue = await response.json() as unknown;
return parsePluginSource(sourceUrl, sourceValue);
} catch (error) {
return {
error: error instanceof Error ? error.message : 'Unable to load plugin source',
plugins: [],
url: sourceUrl
};
}
}
private async fetchPluginManifest(manifestUrl: string): Promise<TojuPluginManifest> {
const response = await fetch(manifestUrl, { headers: { Accept: 'application/json' } });
if (!response.ok) {
throw new Error(`Install manifest returned ${response.status}`);
}
const manifestValue = await response.json() as unknown;
const validation = validateTojuPluginManifest(manifestValue);
if (!validation.manifest) {
throw new Error(validation.issues.map((issue) => `${issue.path}: ${issue.message}`).join('\n'));
}
return validation.manifest;
}
private hydrateInstalledPlugins(installedPlugins: InstalledStorePlugin[]): void {
const usableInstalledPlugins: InstalledStorePlugin[] = [];
for (const installedPlugin of installedPlugins) {
try {
this.host.registerLocalManifest(installedPlugin.manifest, installedPlugin.installUrl);
usableInstalledPlugins.push(installedPlugin);
} catch {
// Corrupt persisted manifests are ignored so the store can recover on next install.
}
}
if (usableInstalledPlugins.length !== installedPlugins.length) {
this.installedPluginsSignal.set(usableInstalledPlugins);
this.saveState();
}
}
private loadState(): PersistedPluginStoreState {
try {
const raw = localStorage.getItem(getUserScopedStorageKey(STORAGE_KEY_PLUGIN_STORE));
if (!raw) {
return { ...DEFAULT_STORE_STATE };
}
return normalizePersistedState(JSON.parse(raw) as unknown);
} catch {
return { ...DEFAULT_STORE_STATE };
}
}
private saveState(): void {
const state = {
installedPlugins: this.installedPlugins(),
schemaVersion: STORE_SCHEMA_VERSION,
sourceUrls: this.sourceUrls()
};
try {
localStorage.setItem(getUserScopedStorageKey(STORAGE_KEY_PLUGIN_STORE), JSON.stringify(state));
} catch {}
}
}
function parsePluginSource(sourceUrl: string, sourceValue: unknown): PluginStoreSourceResult {
const sourceRecord = isRecord(sourceValue) ? sourceValue : {};
const sourceTitle = readString(sourceRecord, 'title', 'name') ?? new URL(sourceUrl).hostname;
const rawPlugins = Array.isArray(sourceValue)
? sourceValue
: Array.isArray(sourceRecord['plugins'])
? sourceRecord['plugins']
: Array.isArray(sourceRecord['items'])
? sourceRecord['items']
: [];
const plugins = rawPlugins
.map((entry) => parsePluginEntry(sourceUrl, sourceTitle, entry))
.filter((entry): entry is PluginStoreEntry => !!entry)
.sort((left, right) => left.title.localeCompare(right.title));
return {
loadedAt: Date.now(),
plugins,
title: sourceTitle,
url: sourceUrl
};
}
function sortInstalledPlugins(left: InstalledStorePlugin, right: InstalledStorePlugin): number {
return left.manifest.title.localeCompare(right.manifest.title);
}
function parsePluginEntry(sourceUrl: string, sourceTitle: string, value: unknown): PluginStoreEntry | null {
if (!isRecord(value)) {
return null;
}
const id = readString(value, 'id', 'pluginId');
const version = readString(value, 'version') ?? '0.0.0';
if (!id) {
return null;
}
return {
author: readAuthor(value),
description: readString(value, 'description', 'summary') ?? '',
githubUrl: resolveOptionalUrl(sourceUrl, readGithubUrl(value)),
homepageUrl: resolveOptionalUrl(sourceUrl, readString(value, 'homepage', 'homepageUrl', 'website')),
id,
imageUrl: resolveOptionalUrl(sourceUrl, readString(value, 'image', 'imageUrl', 'icon', 'iconUrl', 'banner')),
installUrl: resolveOptionalUrl(sourceUrl, readString(value, 'install', 'installUrl', 'manifest', 'manifestUrl')),
readmeUrl: resolveOptionalUrl(sourceUrl, readString(value, 'readme', 'readmeUrl')),
sourceTitle,
sourceUrl,
title: readString(value, 'title', 'name') ?? id,
version
};
}
function normalizePersistedState(value: unknown): PersistedPluginStoreState {
if (!isRecord(value)) {
return { ...DEFAULT_STORE_STATE };
}
return {
installedPlugins: Array.isArray(value['installedPlugins'])
? value['installedPlugins'].filter(isInstalledStorePlugin)
: [],
sourceUrls: Array.isArray(value['sourceUrls'])
? value['sourceUrls']
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => normalizeOptionalRemoteUrl(entry))
.filter((entry): entry is string => !!entry)
: []
};
}
function isInstalledStorePlugin(value: unknown): value is InstalledStorePlugin {
if (!isRecord(value) || !isRecord(value['manifest'])) {
return false;
}
const validation = validateTojuPluginManifest(value['manifest']);
return !!validation.manifest;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function readString(record: Record<string, unknown>, ...keys: string[]): string | undefined {
for (const key of keys) {
const value = record[key];
if (typeof value === 'string' && value.trim()) {
return value.trim();
}
}
return undefined;
}
function readAuthor(record: Record<string, unknown>): string | undefined {
const author = readString(record, 'author');
if (author) {
return author;
}
const authors = record['authors'];
if (!Array.isArray(authors)) {
return undefined;
}
return authors
.map((entry) => isRecord(entry) ? readString(entry, 'name') : typeof entry === 'string' ? entry.trim() : '')
.filter(Boolean)
.join(', ') || undefined;
}
function readGithubUrl(record: Record<string, unknown>): string | undefined {
const directUrl = readString(record, 'github', 'githubUrl');
if (directUrl) {
return directUrl;
}
const repository = record['repository'];
return isRecord(repository) ? readString(repository, 'url') : typeof repository === 'string' ? repository.trim() : undefined;
}
function normalizeRemoteUrl(rawUrl: string, label: string): string {
const url = normalizeOptionalRemoteUrl(rawUrl);
if (!url) {
throw new Error(`${label} must be an http or https URL`);
}
return url;
}
function normalizeOptionalRemoteUrl(rawUrl: string): string | undefined {
try {
const url = new URL(rawUrl.trim());
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return undefined;
}
url.hash = '';
return url.toString();
} catch {
return undefined;
}
}
function resolveOptionalUrl(sourceUrl: string, rawUrl?: string): string | undefined {
if (!rawUrl) {
return undefined;
}
try {
const url = new URL(rawUrl, sourceUrl);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return undefined;
}
url.hash = '';
return url.toString();
} catch {
return undefined;
}
}
function compareVersions(leftVersion: string, rightVersion: string): number {
const leftParts = parseVersion(leftVersion);
const rightParts = parseVersion(rightVersion);
for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) {
const leftPart = leftParts[index] ?? 0;
const rightPart = rightParts[index] ?? 0;
if (leftPart !== rightPart) {
return leftPart - rightPart;
}
}
return leftVersion.localeCompare(rightVersion);
}
function parseVersion(version: string): number[] {
return version
.split(/[.+-]/)
.slice(0, 3)
.map((part) => Number.parseInt(part, 10))
.map((part) => Number.isFinite(part) ? part : 0);
}

View File

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

View File

@@ -0,0 +1,43 @@
import type { TojuPluginManifest } from '../../../shared-kernel';
import type { TojuClientPluginModule } from '../domain/models/plugin-api.models';
export const DEVELOPMENT_PLUGIN_ENTRYPOINT = 'toju:development-plugin';
export const DEVELOPMENT_PLUGIN_MANIFEST: TojuPluginManifest = {
apiVersion: '1.0.0',
capabilities: [],
compatibility: {
minimumTojuVersion: '1.0.0',
verifiedTojuVersion: '1.0.0'
},
description: 'Built-in development-only plugin for validating the local plugin runtime.',
entrypoint: DEVELOPMENT_PLUGIN_ENTRYPOINT,
homepage: 'https://localhost:4200',
id: 'metoyou.development-plugin',
kind: 'client',
readme: 'Only registered when the Angular app is running with environment.production=false.',
schemaVersion: 1,
settings: {
properties: {
enabled: {
default: true,
type: 'boolean'
}
},
type: 'object'
},
title: 'Development Plugin',
version: '0.0.0-dev'
};
export const DEVELOPMENT_PLUGIN_MODULE: TojuClientPluginModule = {
activate: (context) => {
context.api.logger.info('Development plugin activated');
},
deactivate: (context) => {
context.api.logger.info('Development plugin deactivated');
},
ready: (context) => {
context.api.logger.info('Development plugin ready');
}
};

View File

@@ -0,0 +1,82 @@
import type { TojuPluginManifest } from '../../../../shared-kernel';
import { resolvePluginLoadOrder } from './plugin-dependency-resolver.logic';
function manifest(id: string, overrides: Partial<TojuPluginManifest> = {}): TojuPluginManifest {
return {
apiVersion: '1.0.0',
compatibility: {
minimumTojuVersion: '1.0.0'
},
description: `${id} plugin`,
entrypoint: './main.js',
id,
kind: 'client',
schemaVersion: 1,
title: id,
version: '1.0.0',
...overrides
};
}
describe('plugin dependency resolver', () => {
it('orders required dependencies before dependants', () => {
const featurePlugin = manifest('feature.chat', { relationships: { requires: [{ id: 'library.base' }] } });
const result = resolvePluginLoadOrder([{ manifest: featurePlugin }, { manifest: manifest('library.base') }]);
expect(result.blocked).toEqual([]);
expect(result.ordered.map((entry) => entry.id)).toEqual(['library.base', 'feature.chat']);
});
it('uses priority then plugin id for otherwise independent plugins', () => {
const result = resolvePluginLoadOrder([
{ manifest: manifest('plugin.zed') },
{ manifest: manifest('plugin.bootstrap', { load: { priority: 'bootstrap' } }) },
{ manifest: manifest('plugin.alpha') }
]);
expect(result.ordered.map((entry) => entry.id)).toEqual([
'plugin.bootstrap',
'plugin.alpha',
'plugin.zed'
]);
});
it('blocks missing dependencies and leaves valid plugins loadable', () => {
const blockedPlugin = manifest('plugin.blocked', { relationships: { requires: [{ id: 'missing.library' }] } });
const result = resolvePluginLoadOrder([{ manifest: manifest('plugin.valid') }, { manifest: blockedPlugin }]);
expect(result.ordered.map((entry) => entry.id)).toEqual(['plugin.valid']);
expect(result.blocked).toContainEqual({
message: 'Missing required plugin missing.library',
pluginId: 'plugin.blocked',
reason: 'missingDependency'
});
});
it('detects duplicate ids and cycles', () => {
const result = resolvePluginLoadOrder([
{ manifest: manifest('plugin.duplicate') },
{ manifest: manifest('plugin.duplicate') },
{ manifest: manifest('plugin.a', { relationships: { after: ['plugin.b'] } }) },
{ manifest: manifest('plugin.b', { relationships: { after: ['plugin.a'] } }) }
]);
expect(result.blocked).toEqual(expect.arrayContaining([
{
message: 'Duplicate plugin id',
pluginId: 'plugin.duplicate',
reason: 'duplicate'
},
{
message: 'Plugin load order contains a cycle',
pluginId: 'plugin.a',
reason: 'cycle'
},
{
message: 'Plugin load order contains a cycle',
pluginId: 'plugin.b',
reason: 'cycle'
}
]));
});
});

View File

@@ -0,0 +1,251 @@
import type { TojuPluginManifest } from '../../../../shared-kernel';
import type {
PluginLoadBlocker,
PluginLoadCandidate,
PluginLoadOrderResult
} from '../models/plugin-runtime.models';
const PRIORITY_WEIGHT: Record<string, number> = {
bootstrap: 0,
high: 1,
default: 2,
low: 3
};
interface PluginLoadGraph {
edges: Map<string, Set<string>>;
inboundCounts: Map<string, number>;
}
function priorityWeight(manifest: TojuPluginManifest): number {
return PRIORITY_WEIGHT[manifest.load?.priority ?? 'default'] ?? PRIORITY_WEIGHT['default'];
}
function sortManifests(firstManifest: TojuPluginManifest, secondManifest: TojuPluginManifest): number {
const firstPriority = priorityWeight(firstManifest);
const secondPriority = priorityWeight(secondManifest);
if (firstPriority !== secondPriority) {
return firstPriority - secondPriority;
}
return firstManifest.id.localeCompare(secondManifest.id);
}
function addEdge(edges: Map<string, Set<string>>, fromPluginId: string, toPluginId: string): void {
const targets = edges.get(fromPluginId) ?? new Set<string>();
targets.add(toPluginId);
edges.set(fromPluginId, targets);
}
function addBlocker(blocked: PluginLoadBlocker[], pluginId: string, reason: PluginLoadBlocker['reason'], message: string): void {
blocked.push({ pluginId, reason, message });
}
function collectManifests(
candidates: readonly PluginLoadCandidate[],
blocked: PluginLoadBlocker[]
): Map<string, TojuPluginManifest> {
const manifestsById = new Map<string, TojuPluginManifest>();
for (const candidate of candidates) {
if (candidate.enabled === false) {
addBlocker(blocked, candidate.manifest.id, 'disabled', 'Plugin is disabled');
continue;
}
if (manifestsById.has(candidate.manifest.id)) {
addBlocker(blocked, candidate.manifest.id, 'duplicate', 'Duplicate plugin id');
continue;
}
manifestsById.set(candidate.manifest.id, candidate.manifest);
}
return manifestsById;
}
function createLoadGraph(manifestsById: Map<string, TojuPluginManifest>): PluginLoadGraph {
const graph: PluginLoadGraph = {
edges: new Map<string, Set<string>>(),
inboundCounts: new Map<string, number>()
};
for (const pluginId of manifestsById.keys()) {
graph.edges.set(pluginId, new Set<string>());
graph.inboundCounts.set(pluginId, 0);
}
return graph;
}
function addRequiredEdges(
manifest: TojuPluginManifest,
manifestsById: Map<string, TojuPluginManifest>,
edges: Map<string, Set<string>>,
blocked: PluginLoadBlocker[]
): void {
for (const required of manifest.relationships?.requires ?? []) {
if (!manifestsById.has(required.id)) {
addBlocker(blocked, manifest.id, 'missingDependency', `Missing required plugin ${required.id}`);
continue;
}
addEdge(edges, required.id, manifest.id);
}
}
function addOrderingEdges(
manifest: TojuPluginManifest,
manifestsById: Map<string, TojuPluginManifest>,
edges: Map<string, Set<string>>
): void {
for (const afterPluginId of manifest.relationships?.after ?? []) {
if (manifestsById.has(afterPluginId)) {
addEdge(edges, afterPluginId, manifest.id);
}
}
for (const beforePluginId of manifest.relationships?.before ?? []) {
if (manifestsById.has(beforePluginId)) {
addEdge(edges, manifest.id, beforePluginId);
}
}
}
function addConflictBlockers(
manifest: TojuPluginManifest,
manifestsById: Map<string, TojuPluginManifest>,
blocked: PluginLoadBlocker[]
): void {
for (const conflictPluginId of manifest.relationships?.conflicts ?? []) {
if (manifestsById.has(conflictPluginId)) {
addBlocker(blocked, manifest.id, 'conflict', `Conflicts with plugin ${conflictPluginId}`);
}
}
}
function applyRelationships(
manifestsById: Map<string, TojuPluginManifest>,
edges: Map<string, Set<string>>,
blocked: PluginLoadBlocker[]
): void {
for (const manifest of manifestsById.values()) {
addRequiredEdges(manifest, manifestsById, edges, blocked);
addOrderingEdges(manifest, manifestsById, edges);
addConflictBlockers(manifest, manifestsById, blocked);
}
}
function countInboundEdges(graph: PluginLoadGraph, blockedIds: Set<string>): void {
for (const [fromPluginId, targets] of graph.edges.entries()) {
if (blockedIds.has(fromPluginId)) {
continue;
}
for (const targetPluginId of targets) {
if (!blockedIds.has(targetPluginId)) {
graph.inboundCounts.set(targetPluginId, (graph.inboundCounts.get(targetPluginId) ?? 0) + 1);
}
}
}
}
function getInitialReadyManifests(
manifestsById: Map<string, TojuPluginManifest>,
inboundCounts: Map<string, number>,
blockedIds: Set<string>
): TojuPluginManifest[] {
return Array.from(manifestsById.values())
.filter((manifest) => !blockedIds.has(manifest.id) && (inboundCounts.get(manifest.id) ?? 0) === 0)
.sort(sortManifests);
}
function pushReadyManifest(
ready: TojuPluginManifest[],
manifestsById: Map<string, TojuPluginManifest>,
pluginId: string
): void {
const targetManifest = manifestsById.get(pluginId);
if (targetManifest) {
ready.push(targetManifest);
ready.sort(sortManifests);
}
}
function consumeReadyManifest(
manifest: TojuPluginManifest,
graph: PluginLoadGraph,
manifestsById: Map<string, TojuPluginManifest>,
ready: TojuPluginManifest[],
blockedIds: Set<string>
): void {
for (const targetPluginId of graph.edges.get(manifest.id) ?? []) {
if (blockedIds.has(targetPluginId)) {
continue;
}
const nextInboundCount = Math.max(0, (graph.inboundCounts.get(targetPluginId) ?? 0) - 1);
graph.inboundCounts.set(targetPluginId, nextInboundCount);
if (nextInboundCount === 0) {
pushReadyManifest(ready, manifestsById, targetPluginId);
}
}
}
function buildOrderedManifests(
graph: PluginLoadGraph,
manifestsById: Map<string, TojuPluginManifest>,
blockedIds: Set<string>
): TojuPluginManifest[] {
const ready = getInitialReadyManifests(manifestsById, graph.inboundCounts, blockedIds);
const ordered: TojuPluginManifest[] = [];
while (ready.length > 0) {
const nextManifest = ready.shift();
if (!nextManifest) {
break;
}
ordered.push(nextManifest);
consumeReadyManifest(nextManifest, graph, manifestsById, ready, blockedIds);
}
return ordered;
}
function addCycleBlockers(
manifestsById: Map<string, TojuPluginManifest>,
ordered: TojuPluginManifest[],
blockedIds: Set<string>,
blocked: PluginLoadBlocker[]
): void {
const orderedIds = new Set(ordered.map((manifest) => manifest.id));
for (const manifest of manifestsById.values()) {
if (!blockedIds.has(manifest.id) && !orderedIds.has(manifest.id)) {
addBlocker(blocked, manifest.id, 'cycle', 'Plugin load order contains a cycle');
}
}
}
export function resolvePluginLoadOrder(candidates: readonly PluginLoadCandidate[]): PluginLoadOrderResult {
const blocked: PluginLoadBlocker[] = [];
const manifestsById = collectManifests(candidates, blocked);
const graph = createLoadGraph(manifestsById);
applyRelationships(manifestsById, graph.edges, blocked);
const blockedIds = new Set(blocked.map((blocker) => blocker.pluginId));
countInboundEdges(graph, blockedIds);
const ordered = buildOrderedManifests(graph, manifestsById, blockedIds);
addCycleBlockers(manifestsById, ordered, blockedIds, blocked);
return { blocked, ordered };
}

View File

@@ -0,0 +1,86 @@
import type { TojuPluginManifest } from '../../../../shared-kernel';
import { isKnownPluginCapability, validateTojuPluginManifest } from './plugin-manifest-validation.logic';
function createManifest(overrides: Partial<TojuPluginManifest> = {}): TojuPluginManifest {
return {
apiVersion: '1.0.0',
compatibility: {
minimumTojuVersion: '1.0.0'
},
description: 'Adds test behavior.',
entrypoint: './main.js',
id: 'test.plugin',
kind: 'client',
schemaVersion: 1,
title: 'Test Plugin',
version: '1.2.3',
...overrides
};
}
describe('plugin manifest validation', () => {
it('accepts a valid client plugin manifest', () => {
const result = validateTojuPluginManifest(createManifest({
capabilities: ['messages.send', 'ui.settings'],
events: [
{
direction: 'serverRelay',
eventName: 'test:ping',
scope: 'server'
}
]
}));
expect(result.valid).toBe(true);
expect(result.manifest?.id).toBe('test.plugin');
expect(result.issues).toEqual([]);
});
it('rejects executable client manifests without an entrypoint', () => {
const manifest = createManifest({ entrypoint: undefined });
const result = validateTojuPluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.manifest).toBeUndefined();
expect(result.issues).toContainEqual({
message: 'client plugins require an entrypoint',
path: 'entrypoint',
severity: 'error'
});
});
it('allows library manifests without an entrypoint', () => {
const result = validateTojuPluginManifest(createManifest({
entrypoint: undefined,
kind: 'library'
}));
expect(result.valid).toBe(true);
});
it('rejects unknown capabilities and event dimensions', () => {
const result = validateTojuPluginManifest({
...createManifest(),
capabilities: ['messages.send', 'unknown.power'],
events: [
{
direction: 'serverMagic',
eventName: 'bad-event',
scope: 'cosmos'
}
]
});
expect(result.valid).toBe(false);
expect(result.issues.map((issue) => issue.path)).toEqual(expect.arrayContaining([
'capabilities.1',
'events.0.direction',
'events.0.scope'
]));
});
it('narrows known plugin capabilities', () => {
expect(isKnownPluginCapability('messages.send')).toBe(true);
expect(isKnownPluginCapability('messages.destroyEverything')).toBe(false);
});
});

View File

@@ -0,0 +1,204 @@
import {
PLUGIN_CAPABILITIES,
PLUGIN_EVENT_DIRECTIONS,
PLUGIN_EVENT_SCOPES,
type PluginCapabilityId,
type TojuPluginManifest
} from '../../../../shared-kernel';
import type { PluginManifestValidationResult, PluginValidationIssue } from '../models/plugin-runtime.models';
const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9.-]{1,126}[a-z0-9]$/;
const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
const capabilitySet = new Set<string>(PLUGIN_CAPABILITIES);
const eventDirectionSet = new Set<string>(PLUGIN_EVENT_DIRECTIONS);
const eventScopeSet = new Set<string>(PLUGIN_EVENT_SCOPES);
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function readString(record: Record<string, unknown>, key: string): string | null {
const value = record[key];
return typeof value === 'string' ? value.trim() : null;
}
function pushIssue(
issues: PluginValidationIssue[],
path: string,
message: string,
severity: PluginValidationIssue['severity'] = 'error'
): void {
issues.push({ path, message, severity });
}
function validateStringField(
issues: PluginValidationIssue[],
record: Record<string, unknown>,
key: string,
options?: { pattern?: RegExp; required?: boolean }
): void {
const value = readString(record, key);
if (!value) {
if (options?.required !== false) {
pushIssue(issues, key, `${key} is required`);
}
return;
}
if (options?.pattern && !options.pattern.test(value)) {
pushIssue(issues, key, `${key} has an invalid format`);
}
}
function validateStringArray(
issues: PluginValidationIssue[],
value: unknown,
path: string
): void {
if (value === undefined) {
return;
}
if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string' || !entry.trim())) {
pushIssue(issues, path, `${path} must be an array of non-empty strings`);
}
}
function validateRelationships(issues: PluginValidationIssue[], manifestRecord: Record<string, unknown>): void {
const relationships = manifestRecord['relationships'];
if (relationships === undefined) {
return;
}
if (!isRecord(relationships)) {
pushIssue(issues, 'relationships', 'relationships must be an object');
return;
}
validateStringArray(issues, relationships['after'], 'relationships.after');
validateStringArray(issues, relationships['before'], 'relationships.before');
validateStringArray(issues, relationships['conflicts'], 'relationships.conflicts');
for (const key of ['requires', 'optional'] as const) {
const entries = relationships[key];
if (entries === undefined) {
continue;
}
if (!Array.isArray(entries)) {
pushIssue(issues, `relationships.${key}`, `relationships.${key} must be an array`);
continue;
}
entries.forEach((entry, index) => {
if (!isRecord(entry) || typeof entry['id'] !== 'string' || !entry['id'].trim()) {
pushIssue(issues, `relationships.${key}.${index}`, 'dependency id is required');
}
});
}
}
function validateCapabilities(issues: PluginValidationIssue[], manifestRecord: Record<string, unknown>): void {
const capabilities = manifestRecord['capabilities'];
if (capabilities === undefined) {
return;
}
if (!Array.isArray(capabilities)) {
pushIssue(issues, 'capabilities', 'capabilities must be an array');
return;
}
capabilities.forEach((capability, index) => {
if (typeof capability !== 'string' || !capabilitySet.has(capability)) {
pushIssue(issues, `capabilities.${index}`, `Unknown capability ${String(capability)}`);
}
});
}
function validateEvents(issues: PluginValidationIssue[], manifestRecord: Record<string, unknown>): void {
const events = manifestRecord['events'];
if (events === undefined) {
return;
}
if (!Array.isArray(events)) {
pushIssue(issues, 'events', 'events must be an array');
return;
}
events.forEach((event, index) => {
if (!isRecord(event)) {
pushIssue(issues, `events.${index}`, 'event must be an object');
return;
}
if (typeof event['eventName'] !== 'string' || !event['eventName'].trim()) {
pushIssue(issues, `events.${index}.eventName`, 'eventName is required');
}
if (typeof event['direction'] !== 'string' || !eventDirectionSet.has(event['direction'])) {
pushIssue(issues, `events.${index}.direction`, 'direction is invalid');
}
if (typeof event['scope'] !== 'string' || !eventScopeSet.has(event['scope'])) {
pushIssue(issues, `events.${index}.scope`, 'scope is invalid');
}
});
}
export function validateTojuPluginManifest(value: unknown): PluginManifestValidationResult {
const issues: PluginValidationIssue[] = [];
if (!isRecord(value)) {
return {
issues: [{ path: '', message: 'Manifest must be an object', severity: 'error' }],
valid: false
};
}
validateStringField(issues, value, 'id', { pattern: PLUGIN_ID_PATTERN });
validateStringField(issues, value, 'title');
validateStringField(issues, value, 'description');
validateStringField(issues, value, 'version', { pattern: VERSION_PATTERN });
validateStringField(issues, value, 'apiVersion');
if (value['schemaVersion'] !== 1) {
pushIssue(issues, 'schemaVersion', 'schemaVersion must be 1');
}
if (value['kind'] !== 'client' && value['kind'] !== 'library') {
pushIssue(issues, 'kind', 'kind must be client or library');
}
if (!isRecord(value['compatibility'])) {
pushIssue(issues, 'compatibility', 'compatibility is required');
} else {
validateStringField(issues, value['compatibility'], 'minimumTojuVersion');
}
if (typeof value['entrypoint'] !== 'string' && value['kind'] === 'client') {
pushIssue(issues, 'entrypoint', 'client plugins require an entrypoint');
}
validateCapabilities(issues, value);
validateRelationships(issues, value);
validateEvents(issues, value);
return {
issues,
manifest: issues.some((issue) => issue.severity === 'error') ? undefined : value as unknown as TojuPluginManifest,
valid: !issues.some((issue) => issue.severity === 'error')
};
}
export function isKnownPluginCapability(value: string): value is PluginCapabilityId {
return capabilitySet.has(value);
}

View File

@@ -0,0 +1,226 @@
import type {
Channel,
Message,
PluginEventEnvelope,
PluginRequirementsSnapshot,
Room,
RoomMember,
RoomPermissions,
RoomRole,
RoomRoleAssignment,
TojuPluginManifest,
User
} from '../../../../shared-kernel';
export interface TojuPluginDisposable {
dispose: () => void;
}
export interface TojuPluginActivationContext {
api: TojuClientPluginApi;
manifest: TojuPluginManifest;
pluginId: string;
subscriptions: TojuPluginDisposable[];
}
export interface TojuClientPluginModule {
activate?: (context: TojuPluginActivationContext) => Promise<void> | void;
deactivate?: (context: TojuPluginActivationContext) => Promise<void> | void;
onPluginDataChanged?: (context: TojuPluginActivationContext, event: unknown) => Promise<void> | void;
onServerRequirementsChanged?: (context: TojuPluginActivationContext, snapshot: PluginRequirementsSnapshot) => Promise<void> | void;
ready?: (context: TojuPluginActivationContext) => Promise<void> | void;
}
export interface PluginApiProfileUpdate {
description?: string;
displayName: string;
}
export interface PluginApiAvatarUpdate {
avatarHash: string;
avatarMime: string;
avatarUrl: string;
}
export interface PluginApiChannelRequest {
id?: string;
name: string;
position?: number;
}
export interface PluginApiServerSettingsUpdate {
description?: string;
isPrivate?: boolean;
maxUsers?: number;
name?: string;
password?: string;
topic?: string;
}
export interface PluginApiPluginUserRequest {
avatarUrl?: string;
displayName: string;
id?: string;
}
export interface PluginApiMessageAsPluginUserRequest {
channelId?: string;
content: string;
pluginUserId: string;
}
export interface PluginApiAudioClipRequest {
volume?: number;
url: string;
}
export interface PluginApiCustomStreamRequest {
label?: string;
stream: MediaStream;
}
export interface PluginApiEventSubscription {
eventName: string;
handler: (event: PluginEventEnvelope) => void;
}
export interface PluginApiSettingsPageContribution {
label: string;
order?: number;
render: () => HTMLElement | string;
settingsKey?: string;
}
export interface PluginApiPageContribution {
label: string;
path: string;
render: () => HTMLElement | string;
}
export interface PluginApiPanelContribution {
label: string;
order?: number;
render: () => HTMLElement | string;
}
export interface PluginApiChannelSectionContribution {
label: string;
order?: number;
type?: 'audio' | 'custom' | 'video';
}
export interface PluginApiActionContribution {
icon?: string;
label: string;
run: () => Promise<void> | void;
}
export interface PluginApiEmbedRendererContribution {
embedType: string;
render: (payload: unknown) => HTMLElement | string;
}
export interface PluginApiDomMountRequest {
element: HTMLElement;
position?: InsertPosition;
target: Element | string;
}
export interface PluginApiUiContributionMap {
appPages: PluginApiPageContribution[];
channelSections: PluginApiChannelSectionContribution[];
composerActions: PluginApiActionContribution[];
embeds: PluginApiEmbedRendererContribution[];
profileActions: PluginApiActionContribution[];
settingsPages: PluginApiSettingsPageContribution[];
sidePanels: PluginApiPanelContribution[];
toolbarActions: PluginApiActionContribution[];
}
export interface TojuClientPluginApi {
readonly channels: {
addAudioChannel: (request: PluginApiChannelRequest) => void;
addVideoChannel: (request: PluginApiChannelRequest) => void;
list: () => Channel[];
remove: (channelId: string) => void;
rename: (channelId: string, name: string) => void;
select: (channelId: string) => void;
};
readonly events: {
publishP2p: (eventName: string, payload: unknown) => void;
publishServer: (eventName: string, payload: unknown) => void;
subscribeP2p: (subscription: PluginApiEventSubscription) => TojuPluginDisposable;
subscribeServer: (subscription: PluginApiEventSubscription) => TojuPluginDisposable;
};
readonly logger: {
debug: (message: string, data?: unknown) => void;
error: (message: string, data?: unknown) => void;
info: (message: string, data?: unknown) => void;
warn: (message: string, data?: unknown) => void;
};
readonly media: {
addCustomAudioStream: (request: PluginApiCustomStreamRequest) => Promise<void>;
addCustomVideoStream: (request: PluginApiCustomStreamRequest) => Promise<void>;
playAudioClip: (request: PluginApiAudioClipRequest) => Promise<void>;
setInputVolume: (volume: number) => void;
setOutputVolume: (volume: number) => void;
};
readonly messages: {
delete: (messageId: string) => void;
edit: (messageId: string, content: string) => void;
moderateDelete: (messageId: string) => void;
readCurrent: () => Message[];
send: (content: string, channelId?: string) => Message;
sendAsPluginUser: (request: PluginApiMessageAsPluginUserRequest) => void;
sync: (messages: Message[]) => void;
};
readonly p2p: {
broadcastData: (eventName: string, payload: unknown) => void;
connectedPeers: () => string[];
sendData: (peerId: string, eventName: string, payload: unknown) => void;
};
readonly profile: {
getCurrent: () => User | null;
update: (profile: PluginApiProfileUpdate) => void;
updateAvatar: (avatar: PluginApiAvatarUpdate) => void;
};
readonly roles: {
list: () => RoomRole[];
setAssignments: (assignments: RoomRoleAssignment[]) => void;
};
readonly server: {
getCurrent: () => Room | null;
registerPluginUser: (request: PluginApiPluginUserRequest) => string;
updatePermissions: (permissions: Partial<RoomPermissions>) => void;
updateSettings: (settings: PluginApiServerSettingsUpdate) => void;
};
readonly serverData: {
read: (key: string) => Promise<unknown>;
remove: (key: string) => Promise<void>;
write: (key: string, value: unknown) => Promise<void>;
};
readonly storage: {
get: (key: string) => unknown;
remove: (key: string) => void;
set: (key: string, value: unknown) => void;
};
readonly ui: {
registerAppPage: (id: string, contribution: PluginApiPageContribution) => TojuPluginDisposable;
registerChannelSection: (id: string, contribution: PluginApiChannelSectionContribution) => TojuPluginDisposable;
registerComposerAction: (id: string, contribution: PluginApiActionContribution) => TojuPluginDisposable;
registerEmbedRenderer: (id: string, contribution: PluginApiEmbedRendererContribution) => TojuPluginDisposable;
mountElement: (id: string, request: PluginApiDomMountRequest) => TojuPluginDisposable;
registerProfileAction: (id: string, contribution: PluginApiActionContribution) => TojuPluginDisposable;
registerSettingsPage: (id: string, contribution: PluginApiSettingsPageContribution) => TojuPluginDisposable;
registerSidePanel: (id: string, contribution: PluginApiPanelContribution) => TojuPluginDisposable;
registerToolbarAction: (id: string, contribution: PluginApiActionContribution) => TojuPluginDisposable;
};
readonly users: {
ban: (userId: string, reason?: string) => void;
getCurrent: () => User | null;
kick: (userId: string) => void;
list: () => User[];
readMembers: () => RoomMember[];
setRole: (userId: string, role: User['role']) => void;
};
}

View File

@@ -0,0 +1,81 @@
import type { TojuPluginManifest } from '../../../../shared-kernel';
export type PluginRuntimeState =
| 'discovered'
| 'validated'
| 'blocked'
| 'loading'
| 'ready'
| 'loaded'
| 'failed'
| 'unloading'
| 'unloaded'
| 'disabled';
export type PluginValidationSeverity = 'error' | 'warning';
export interface PluginValidationIssue {
message: string;
path: string;
severity: PluginValidationSeverity;
}
export interface PluginManifestValidationResult {
issues: PluginValidationIssue[];
manifest?: TojuPluginManifest;
valid: boolean;
}
export interface RegisteredPlugin {
enabled: boolean;
error?: string;
loadIndex?: number;
manifest: TojuPluginManifest;
sourcePath?: string;
state: PluginRuntimeState;
validationIssues: PluginValidationIssue[];
}
export interface PluginLoadCandidate {
enabled?: boolean;
manifest: TojuPluginManifest;
}
export interface PluginLoadBlocker {
message: string;
pluginId: string;
reason: 'conflict' | 'cycle' | 'disabled' | 'duplicate' | 'missingDependency' | 'validation';
}
export interface PluginLoadOrderResult {
blocked: PluginLoadBlocker[];
ordered: TojuPluginManifest[];
}
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 LocalPluginRegistrationResult {
discovery: LocalPluginDiscoveryResult;
errors: LocalPluginDiscoveryError[];
registered: RegisteredPlugin[];
}

View File

@@ -0,0 +1,46 @@
import type { TojuPluginManifest } from '../../../../shared-kernel';
export type PluginStoreInstallState = 'installed' | 'notInstalled' | 'updateAvailable';
export interface PluginStoreEntry {
author?: string;
description: string;
githubUrl?: string;
homepageUrl?: string;
id: string;
imageUrl?: string;
installUrl?: string;
readmeUrl?: string;
sourceTitle?: string;
sourceUrl: string;
title: string;
version: string;
}
export interface PluginStoreSourceResult {
error?: string;
loadedAt?: number;
plugins: PluginStoreEntry[];
title?: string;
url: string;
}
export interface InstalledStorePlugin {
installedAt: number;
installUrl?: string;
manifest: TojuPluginManifest;
sourceUrl?: string;
updatedAt: number;
}
export interface PluginStoreReadme {
pluginId: string;
title: string;
url: string;
markdown: string;
}
export interface PersistedPluginStoreState {
installedPlugins: InstalledStorePlugin[];
sourceUrls: string[];
}

View File

@@ -0,0 +1,449 @@
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
<section
class="flex h-full min-h-0 flex-col bg-background text-foreground"
data-testid="plugin-manager"
>
<header class="flex items-center justify-between border-b border-border px-4 py-3">
<div class="flex min-w-0 items-center gap-3">
<button
type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Back to settings"
(click)="close()"
>
<ng-icon
name="lucideArrowLeft"
size="18"
/>
</button>
<div class="min-w-0">
<h2 class="truncate text-base font-semibold">Plugins</h2>
<p class="truncate text-xs text-muted-foreground">Local runtime, store install, capabilities, logs, extension points.</p>
</div>
</div>
<button
type="button"
class="inline-flex h-8 items-center gap-2 rounded-md border border-border px-3 text-sm hover:bg-muted disabled:opacity-50"
[disabled]="busyAll()"
(click)="activateAll()"
>
<ng-icon
name="lucidePlay"
size="16"
/>
Activate ready plugins
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-2 rounded-md border border-border px-3 text-sm hover:bg-muted"
(click)="openStore()"
>
<ng-icon
name="lucideStore"
size="16"
/>
Open Plugin Store
</button>
</header>
<nav
class="flex gap-2 border-b border-border px-4 py-2"
aria-label="Plugin manager sections"
>
<button
type="button"
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm"
[class.bg-muted]="activeTab() === 'installed'"
(click)="setTab('installed')"
>
<ng-icon
name="lucidePackage"
size="16"
/>
Installed
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm"
[class.bg-muted]="activeTab() === 'extensions'"
(click)="setTab('extensions')"
>
<ng-icon
name="lucideSettings"
size="16"
/>
Extension points
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm"
[class.bg-muted]="activeTab() === 'requirements'"
(click)="setTab('requirements')"
>
<ng-icon
name="lucideShield"
size="16"
/>
Requirements
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm"
[class.bg-muted]="activeTab() === 'settings'"
(click)="setTab('settings')"
>
<ng-icon
name="lucideSettings"
size="16"
/>
Settings
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm"
[class.bg-muted]="activeTab() === 'docs'"
(click)="setTab('docs')"
>
<ng-icon
name="lucidePackage"
size="16"
/>
Docs
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm"
[class.bg-muted]="activeTab() === 'logs'"
(click)="setTab('logs')"
>
<ng-icon
name="lucideBug"
size="16"
/>
Logs
</button>
</nav>
<div class="min-h-0 flex-1 overflow-auto p-4">
@switch (activeTab()) {
@case ('extensions') {
<div class="space-y-4">
<div
class="grid gap-3 md:grid-cols-2 xl:grid-cols-4"
data-testid="plugin-extension-counts"
>
@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
) {
<article class="rounded-lg border border-border bg-card p-3">
<p class="text-sm text-muted-foreground">{{ item.label }}</p>
<p class="mt-2 text-2xl font-semibold">{{ item.value }}</p>
</article>
}
</div>
<section
class="rounded-lg border border-border bg-card p-4"
data-testid="plugin-conflict-diagnostics"
>
<h3 class="text-sm font-semibold">Conflict diagnostics</h3>
@if (uiConflicts().length === 0) {
<p class="mt-2 text-sm text-muted-foreground">
No duplicate route, action, embed, channel, panel, or settings contribution ids detected.
</p>
} @else {
<div class="mt-3 space-y-2">
@for (conflict of uiConflicts(); track conflict.kind + conflict.contributionId) {
<div class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm">
<span class="font-medium">{{ conflict.kind }} / {{ conflict.contributionId }}</span>
<span class="text-muted-foreground"> conflicts in {{ conflict.pluginIds.join(', ') }}</span>
</div>
}
</div>
}
</section>
</div>
}
@case ('requirements') {
<div
class="space-y-3"
data-testid="plugin-server-requirements"
>
@if (requirementComparisons().length === 0) {
<p class="rounded-lg border border-border bg-card p-4 text-sm text-muted-foreground">
No server plugin requirements for the current room.
</p>
} @else {
@for (comparison of requirementComparisons(); track comparison.pluginId) {
<article class="rounded-lg border border-border bg-card p-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<h3 class="text-sm font-semibold">{{ comparison.installed?.title ?? comparison.pluginId }}</h3>
<p class="mt-1 text-xs text-muted-foreground">{{ comparison.pluginId }}</p>
</div>
<span class="rounded bg-muted px-2 py-1 text-xs text-muted-foreground">{{ comparison.status }}</span>
</div>
@if (comparison.requirement) {
<p class="mt-3 text-sm text-muted-foreground">Server status: {{ comparison.requirement.status }}</p>
@if (comparison.requirement.versionRange) {
<p class="mt-1 text-sm text-muted-foreground">Version range: {{ comparison.requirement.versionRange }}</p>
}
@if (comparison.requirement.reason) {
<p class="mt-1 text-sm text-muted-foreground">{{ comparison.requirement.reason }}</p>
}
}
</article>
}
}
</div>
}
@case ('settings') {
<div
class="grid gap-4 xl:grid-cols-[260px_minmax(0,1fr)]"
data-testid="plugin-generated-settings"
>
<div class="space-y-2">
@for (entry of entries(); track trackEntry($index, entry)) {
<button
type="button"
class="w-full rounded-md border border-border px-3 py-2 text-left text-sm hover:bg-muted"
[class.bg-muted]="isSelected(entry)"
(click)="selectPlugin(entry.manifest.id)"
>
{{ entry.manifest.title }}
</button>
}
</div>
<section class="rounded-lg border border-border bg-card p-4">
@if (selectedPlugin(); as plugin) {
<h3 class="text-sm font-semibold">{{ plugin.manifest.title }} settings</h3>
@if (selectedSettingsPages().length > 0) {
<div class="mt-4 space-y-3">
@for (page of selectedSettingsPages(); track page.id) {
<article class="rounded-md border border-border bg-background/40 p-3">
<h4 class="mb-2 text-sm font-medium">{{ page.contribution.label }}</h4>
<app-plugin-render-host [render]="page.contribution.render"></app-plugin-render-host>
</article>
}
</div>
}
@if (selectedSettingsSchema()) {
<pre class="mt-3 max-h-[420px] overflow-auto rounded-md bg-muted p-3 text-xs">{{ selectedSettingsSchema() | json }}</pre>
} @else {
<p class="mt-2 text-sm text-muted-foreground">This plugin does not declare a settings schema.</p>
}
}
</section>
</div>
}
@case ('docs') {
<div
class="grid gap-4 xl:grid-cols-[260px_minmax(0,1fr)]"
data-testid="plugin-installed-docs"
>
<div class="space-y-2">
@for (entry of entries(); track trackEntry($index, entry)) {
<button
type="button"
class="w-full rounded-md border border-border px-3 py-2 text-left text-sm hover:bg-muted"
[class.bg-muted]="isSelected(entry)"
(click)="selectPlugin(entry.manifest.id)"
>
{{ entry.manifest.title }}
</button>
}
</div>
<section class="rounded-lg border border-border bg-card p-4">
@if (selectedPlugin(); as plugin) {
<h3 class="text-sm font-semibold">{{ plugin.manifest.title }}</h3>
<p class="mt-2 text-sm text-muted-foreground">{{ plugin.manifest.description }}</p>
<div class="mt-4 flex flex-wrap gap-2">
@for (doc of selectedDocs(); track doc.label) {
<a
class="rounded-md border border-border px-3 py-1.5 text-sm hover:bg-muted"
[href]="doc.url"
target="_blank"
rel="noreferrer"
>{{ doc.label }}</a
>
}
</div>
<pre class="mt-4 max-h-[420px] overflow-auto rounded-md bg-muted p-3 text-xs">{{ plugin.manifest | json }}</pre>
}
</section>
</div>
}
@case ('logs') {
<div class="space-y-3">
@if (!selectedPlugin()) {
<p class="text-sm text-muted-foreground">No plugins installed.</p>
} @else {
<div class="flex flex-wrap gap-2">
@for (entry of entries(); track trackEntry($index, entry)) {
<button
type="button"
class="rounded-md border border-border px-3 py-1 text-sm hover:bg-muted"
[class.bg-muted]="isSelected(entry)"
(click)="selectPlugin(entry.manifest.id)"
>
{{ entry.manifest.title }}
</button>
}
</div>
<div class="rounded-lg border border-border bg-card">
@if (selectedLogs().length === 0) {
<p class="p-4 text-sm text-muted-foreground">No logs for selected plugin.</p>
} @else {
@for (log of selectedLogs(); track log.timestamp) {
<div class="border-b border-border px-4 py-3 last:border-b-0">
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<span class="uppercase">{{ log.level }}</span>
<span>{{ log.timestamp | date: 'short' }}</span>
</div>
<p class="mt-1 text-sm">{{ log.message }}</p>
</div>
}
}
</div>
}
</div>
}
@default {
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_360px]">
<div class="space-y-3">
@if (entries().length === 0) {
<div
class="rounded-lg border border-dashed border-border p-8 text-center"
data-testid="plugin-empty-state"
>
<ng-icon
class="mx-auto text-muted-foreground"
name="lucidePackage"
size="28"
/>
<p class="mt-3 text-sm font-medium">No plugins installed.</p>
<p class="mt-1 text-sm text-muted-foreground">Use Store tab or local plugin folder discovery.</p>
</div>
} @else {
@for (entry of entries(); track trackEntry($index, entry)) {
<article
class="rounded-lg border border-border bg-card p-4"
[class.ring-2]="isSelected(entry)"
[class.ring-primary]="isSelected(entry)"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h3 class="truncate text-sm font-semibold">{{ entry.manifest.title }}</h3>
<span class="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground">{{ entry.state }}</span>
<span class="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground">v{{ entry.manifest.version }}</span>
</div>
<p class="mt-1 text-sm text-muted-foreground">{{ entry.manifest.description }}</p>
<p class="mt-2 text-xs text-muted-foreground">{{ entry.manifest.id }}</p>
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted"
(click)="selectPlugin(entry.manifest.id)"
>
Select
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted"
(click)="setEnabled(entry, !entry.enabled)"
>
<ng-icon
[name]="entry.enabled ? 'lucideX' : 'lucideCheck'"
size="14"
/>
{{ entry.enabled ? 'Disable' : 'Enable' }}
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted disabled:opacity-50"
[disabled]="busyPluginId() === entry.manifest.id"
(click)="reload(entry)"
>
<ng-icon
name="lucideRefreshCw"
size="14"
/>
Reload
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted disabled:opacity-50"
[disabled]="busyPluginId() === entry.manifest.id"
(click)="unload(entry)"
>
<ng-icon
name="lucideX"
size="14"
/>
Unload
</button>
</div>
</div>
@if (entry.error) {
<p class="mt-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">{{ entry.error }}</p>
}
</article>
}
}
</div>
<aside class="rounded-lg border border-border bg-card p-4">
@if (selectedPlugin(); as plugin) {
<div class="flex items-center gap-2">
<ng-icon
name="lucideShield"
size="18"
/>
<h3 class="text-sm font-semibold">Capabilities</h3>
</div>
@if ((plugin.manifest.capabilities?.length ?? 0) === 0) {
<p class="mt-3 text-sm text-muted-foreground">Plugin requests no capabilities.</p>
} @else {
<button
type="button"
class="mt-3 h-8 rounded-md border border-border px-3 text-sm hover:bg-muted"
(click)="grantAll(plugin)"
>
Grant all requested
</button>
<div class="mt-3 space-y-2">
@for (capability of plugin.manifest.capabilities; track trackCapability($index, capability)) {
<label class="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm">
<input
type="checkbox"
class="h-4 w-4"
[checked]="capabilities.has(plugin.manifest.id, capability)"
(change)="toggleCapability(plugin, capability)"
/>
<span>{{ capability }}</span>
</label>
}
</div>
}
@if (missingCapabilities().length > 0) {
<p class="mt-3 text-xs text-muted-foreground">Missing: {{ missingCapabilities().join(', ') }}</p>
}
}
</aside>
</div>
}
}
</div>
</section>

View File

@@ -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<void>();
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<PluginManagerTab>('installed');
readonly busyPluginId = signal<string | null>(null);
readonly busyAll = signal(false);
readonly selectedPluginId = signal<string | null>(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<void> {
this.busyAll.set(true);
try {
await this.host.activateReadyPlugins();
} finally {
this.busyAll.set(false);
}
}
async reload(entry: RegisteredPlugin): Promise<void> {
this.busyPluginId.set(entry.manifest.id);
try {
await this.host.reloadPlugin(entry.manifest.id);
} finally {
this.busyPluginId.set(null);
}
}
async unload(entry: RegisteredPlugin): Promise<void> {
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;
}
}

View File

@@ -0,0 +1,17 @@
<main class="min-h-screen bg-background p-6 text-foreground">
<a routerLink="/search" class="text-sm text-muted-foreground hover:text-foreground">Back</a>
@if (page(); as pageRecord) {
<section class="mx-auto mt-6 max-w-5xl">
<p class="text-xs uppercase tracking-[0.18em] text-muted-foreground">{{ pageRecord.pluginId }}</p>
<h1 class="mt-1 text-2xl font-semibold">{{ pageRecord.contribution.label }}</h1>
<div class="mt-6 rounded-lg border border-border bg-card p-4">
<app-plugin-render-host [render]="pageRecord.contribution.render" />
</div>
</section>
} @else {
<section class="mx-auto mt-6 max-w-2xl rounded-lg border border-border bg-card p-8 text-center">
<h1 class="text-xl font-semibold">Plugin page unavailable</h1>
<p class="mt-2 text-sm text-muted-foreground">The plugin page is not registered or the plugin is not loaded.</p>
</section>
}
</main>

View File

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

View File

@@ -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: '<div #host></div>'
})
export class PluginRenderHostComponent {
readonly render = input.required<PluginRenderable>();
private readonly host = viewChild.required<ElementRef<HTMLElement>>('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';
}
}
}

View File

@@ -0,0 +1,314 @@
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity, @angular-eslint/template/prefer-ngsrc -->
<main
class="plugin-store"
data-testid="plugin-store-page"
>
<header class="plugin-store__topbar">
<div class="plugin-store__title-row">
<button
type="button"
(click)="goBack()"
class="plugin-store__icon-button"
title="Back to app"
>
<ng-icon name="lucideArrowLeft" />
</button>
<div class="plugin-store__brand-icon">
<ng-icon name="lucideStore" />
</div>
<div class="plugin-store__title-copy">
<h1>Plugin Store</h1>
<p>{{ installedCount() }} installed · {{ totalSourcePlugins() }} available · {{ sourceCount() }} sources</p>
</div>
</div>
<div class="plugin-store__top-actions">
<button
type="button"
(click)="openManager()"
class="plugin-store__secondary-button"
>
<ng-icon name="lucideSettings" />
Manage Plugins
</button>
<button
type="button"
(click)="refreshSources()"
[disabled]="store.isLoading()"
class="plugin-store__secondary-button"
>
<ng-icon
name="lucideRefreshCw"
[class.is-spinning]="store.isLoading()"
/>
Refresh
</button>
</div>
</header>
<section class="plugin-store__source-strip">
<div class="plugin-store__source-form">
<label class="plugin-store__input-shell plugin-store__source-input">
<input
type="url"
[(ngModel)]="newSourceUrl"
(keyup.enter)="addSourceUrl()"
placeholder="https://example.com/plugins.json"
aria-label="Plugin source manifest URL"
/>
</label>
<button
type="button"
(click)="addSourceUrl()"
[disabled]="!newSourceUrl.trim() || store.isLoading()"
class="plugin-store__primary-button"
>
<ng-icon name="lucidePlus" />
Add Source
</button>
</div>
@if (sourceError()) {
<p class="plugin-store__error-text">{{ sourceError() }}</p>
}
</section>
<div class="plugin-store__layout">
<aside
class="plugin-store__rail"
aria-label="Plugin sources"
>
<section class="plugin-store__panel">
<div class="plugin-store__panel-header">
<h2>Sources</h2>
<span>{{ sourceCount() }}</span>
</div>
<button
type="button"
class="plugin-store__source-filter"
[class.is-active]="selectedSourceUrl() === null"
(click)="selectSource(null)"
>
<span>All sources</span>
<strong>{{ totalSourcePlugins() }}</strong>
</button>
@for (source of store.sources(); track source.url) {
<div
class="plugin-store__source-row"
[class.has-error]="!!source.error"
>
<button
type="button"
class="plugin-store__source-filter"
[class.is-active]="selectedSourceUrl() === source.url"
(click)="selectSource(source.url)"
>
<span>{{ source.title || source.url }}</span>
<strong>{{ source.plugins.length }}</strong>
</button>
<button
type="button"
(click)="removeSourceUrl(source.url)"
class="plugin-store__icon-button plugin-store__icon-button--danger"
title="Remove source"
>
<ng-icon name="lucideTrash2" />
</button>
</div>
@if (source.error) {
<p class="plugin-store__source-error">{{ source.error }}</p>
}
}
@for (sourceUrl of pendingSourceUrls(); track sourceUrl) {
<div class="plugin-store__source-row">
<button
type="button"
class="plugin-store__source-filter"
[class.is-active]="selectedSourceUrl() === sourceUrl"
(click)="selectSource(sourceUrl)"
>
<span>{{ sourceUrl }}</span>
<strong>0</strong>
</button>
<button
type="button"
(click)="removeSourceUrl(sourceUrl)"
class="plugin-store__icon-button plugin-store__icon-button--danger"
title="Remove source"
>
<ng-icon name="lucideTrash2" />
</button>
</div>
}
</section>
<section class="plugin-store__panel">
<div class="plugin-store__panel-header">
<h2>Filters</h2>
</div>
<button
type="button"
class="plugin-store__toggle-button"
[class.is-active]="showInstalledOnly()"
(click)="toggleInstalledOnly()"
>
<span>Installed only</span>
<strong>{{ installedCount() }}</strong>
</button>
</section>
</aside>
<section
class="plugin-store__catalog"
aria-label="Available plugins"
>
<div class="plugin-store__toolbar">
<label class="plugin-store__input-shell plugin-store__search">
<ng-icon name="lucideSearch" />
<input
type="search"
[ngModel]="searchTerm()"
(ngModelChange)="searchTerm.set($event)"
placeholder="Search plugins, authors, ids"
aria-label="Search plugins"
/>
</label>
<div class="plugin-store__count">{{ filteredPlugins().length }} shown</div>
</div>
@if (actionError()) {
<p class="plugin-store__error-banner">{{ actionError() }}</p>
}
@if (readmeError()) {
<p class="plugin-store__error-banner">{{ readmeError() }}</p>
}
@if (filteredPlugins().length > 0) {
<div class="plugin-store__grid">
@for (plugin of filteredPlugins(); track trackPlugin($index, plugin)) {
<article class="plugin-card">
<div class="plugin-card__media">
@if (plugin.imageUrl) {
<img
[src]="plugin.imageUrl"
[alt]="plugin.title"
(error)="hideBrokenImage($event)"
/>
} @else {
<ng-icon name="lucidePackage" />
}
</div>
<div class="plugin-card__body">
<div class="plugin-card__header">
<div>
<h2>{{ plugin.title }}</h2>
<p>{{ plugin.author || 'Unknown author' }} · v{{ plugin.version }}</p>
</div>
@if (store.getInstallState(plugin) === 'updateAvailable') {
<span class="plugin-card__badge">Update</span>
} @else if (store.getInstallState(plugin) === 'installed') {
<span class="plugin-card__badge plugin-card__badge--installed">Installed</span>
}
</div>
<p class="plugin-card__description">{{ plugin.description }}</p>
<div class="plugin-card__meta">
<span>{{ plugin.id }}</span>
<span>{{ plugin.sourceTitle || plugin.sourceUrl }}</span>
</div>
<div class="plugin-card__actions">
<button
type="button"
(click)="runPrimaryAction(plugin)"
[disabled]="isPrimaryActionDisabled(plugin)"
class="plugin-store__primary-button plugin-card__primary-action"
[class.plugin-card__primary-action--danger]="store.getActionLabel(plugin) === 'Uninstall'"
>
<ng-icon
[name]="primaryActionIcon(plugin)"
[class.is-spinning]="isPluginBusy(plugin)"
/>
{{ store.getActionLabel(plugin) }}
</button>
@if (plugin.readmeUrl) {
<button
type="button"
(click)="loadReadme(plugin)"
class="plugin-store__text-button"
title="Load readme"
>
{{ isReadmeLoading(plugin) ? 'Loading' : 'Readme' }}
</button>
}
@if (plugin.githubUrl) {
<button
type="button"
(click)="openExternal(plugin.githubUrl)"
class="plugin-store__icon-button"
title="Open GitHub"
>
<ng-icon name="lucideExternalLink" />
</button>
}
</div>
</div>
</article>
}
</div>
} @else {
<section class="plugin-store__empty">
<ng-icon name="lucidePackage" />
<h2>No plugins found</h2>
<p>{{ sourceCount() ? 'Adjust filters or add another source manifest.' : 'Add a plugin source manifest URL to populate the catalog.' }}</p>
</section>
}
</section>
@if (readme()) {
<aside
class="plugin-store__readme"
aria-label="Plugin readme"
>
<div class="plugin-store__readme-header">
<div>
<p>Readme</p>
<h2>{{ readme()!.title }}</h2>
@if (selectedReadmePlugin(); as plugin) {
<span>{{ plugin.author || 'Unknown author' }} · v{{ plugin.version }}</span>
}
</div>
<button
type="button"
(click)="closeReadme()"
class="plugin-store__icon-button"
title="Close readme"
>
<ng-icon name="lucideX" />
</button>
</div>
<pre>{{ readme()!.markdown }}</pre>
<button
type="button"
(click)="openExternal(readme()!.url)"
class="plugin-store__secondary-button plugin-store__readme-link"
>
<ng-icon name="lucideExternalLink" />
Open source readme
</button>
</aside>
}
</div>
</main>

View File

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

View File

@@ -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<string | null>(null);
readonly showInstalledOnly = signal(false);
readonly sourceError = signal<string | null>(null);
readonly actionError = signal<string | null>(null);
readonly actionBusyPluginId = signal<string | null>(null);
readonly readme = signal<PluginStoreReadme | null>(null);
readonly readmeError = signal<string | null>(null);
readonly readmeLoadingPluginId = signal<string | null>(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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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';
}
}

View File

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

View File

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

View File

@@ -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<string | null> {
const api = this.electronBridge.getApi();
return api ? await api.getLocalPluginsPath() : null;
}
async discoverManifests(): Promise<LocalPluginDiscoveryResult> {
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
};
}
}

View File

@@ -250,6 +250,41 @@
} }
</div> </div>
</section> </section>
@if (pluginChannelSections().length > 0 || pluginSidePanels().length > 0) {
<section class="border-t border-border px-2 py-3" data-testid="plugin-room-side-panel">
<div class="mb-2 px-1">
<h4 class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">Plugins</h4>
</div>
@if (pluginChannelSections().length > 0) {
<div class="space-y-1">
@for (record of pluginChannelSections(); track record.id) {
<button
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-foreground/70 transition-colors hover:bg-secondary/60 hover:text-foreground"
[title]="record.pluginId">
<ng-icon
[name]="record.contribution.type === 'video' ? 'lucideVideo' : 'lucideHash'"
class="h-4 w-4 text-muted-foreground"
/>
<span class="min-w-0 flex-1 truncate">{{ record.contribution.label }}</span>
</button>
}
</div>
}
@if (pluginSidePanels().length > 0) {
<div class="mt-3 space-y-2">
@for (record of pluginSidePanels(); track record.id) {
<article class="rounded-md border border-border bg-background/40 p-2">
<p class="mb-2 truncate text-xs font-medium text-muted-foreground">{{ record.contribution.label }}</p>
<app-plugin-render-host [render]="record.contribution.render" />
</article>
}
</div>
}
</section>
}
</div> </div>
} }

View File

@@ -51,6 +51,8 @@ import { VoicePlaybackService } from '../../../domains/voice-connection';
import { formatGameActivityElapsed } from '../../../domains/game-activity'; import { formatGameActivityElapsed } from '../../../domains/game-activity';
import { ExternalLinkService } from '../../../core/platform/external-link.service'; import { ExternalLinkService } from '../../../core/platform/external-link.service';
import { VoiceControlsComponent } from '../../../domains/voice-session/feature/voice-controls/voice-controls.component'; import { VoiceControlsComponent } from '../../../domains/voice-session/feature/voice-controls/voice-controls.component';
import { 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 { isChannelNameTaken, normalizeChannelName } from '../../../store/rooms/room-channels.rules';
import { import {
canManageMember, canManageMember,
@@ -89,6 +91,7 @@ type PanelMode = 'channels' | 'users';
UserVolumeMenuComponent, UserVolumeMenuComponent,
UserAvatarComponent, UserAvatarComponent,
ConfirmDialogComponent, ConfirmDialogComponent,
PluginRenderHostComponent,
ThemeNodeDirective ThemeNodeDirective
], ],
viewProviders: [ viewProviders: [
@@ -124,6 +127,7 @@ export class RoomsSidePanelComponent implements OnDestroy {
private readonly externalLinks = inject(ExternalLinkService); private readonly externalLinks = inject(ExternalLinkService);
private readonly voiceActivity = inject(VoiceActivityService); private readonly voiceActivity = inject(VoiceActivityService);
private readonly voiceConnectivity = inject(VoiceConnectivityHealthService); private readonly voiceConnectivity = inject(VoiceConnectivityHealthService);
private readonly pluginUi = inject(PluginUiRegistryService);
private profileCardOpenTimer: ReturnType<typeof setTimeout> | null = null; private profileCardOpenTimer: ReturnType<typeof setTimeout> | null = null;
private readonly activityTimer = setInterval(() => this.activityNow.set(Date.now()), 1_000); 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); activeChannelId = this.store.selectSignal(selectActiveChannelId);
textChannels = this.store.selectSignal(selectTextChannels); textChannels = this.store.selectSignal(selectTextChannels);
voiceChannels = this.store.selectSignal(selectVoiceChannels); voiceChannels = this.store.selectSignal(selectVoiceChannels);
pluginChannelSections = this.pluginUi.channelSectionRecords;
pluginSidePanels = this.pluginUi.sidePanelRecords;
localUserHasDesync = this.voiceConnectivity.localUserHasDesync; localUserHasDesync = this.voiceConnectivity.localUserHasDesync;
roomMembers = computed(() => this.currentRoom()?.members ?? []); roomMembers = computed(() => this.currentRoom()?.members ?? []);
roomMemberIdentifiers = computed(() => { roomMemberIdentifiers = computed(() => {

View File

@@ -135,6 +135,9 @@
@case ('general') { @case ('general') {
General General
} }
@case ('plugins') {
Plugins
}
@case ('network') { @case ('network') {
Network Network
} }
@@ -193,6 +196,9 @@
@case ('general') { @case ('general') {
<app-general-settings /> <app-general-settings />
} }
@case ('plugins') {
<app-plugin-manager (closed)="navigate('general')" />
}
@case ('network') { @case ('network') {
<app-network-settings /> <app-network-settings />
} }

View File

@@ -21,6 +21,7 @@ import {
lucideGlobe, lucideGlobe,
lucideAudioLines, lucideAudioLines,
lucidePalette, lucidePalette,
lucidePackage,
lucideSettings, lucideSettings,
lucideUsers, lucideUsers,
lucideBan, lucideBan,
@@ -33,6 +34,7 @@ import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.
import { selectCurrentUser } from '../../../store/users/users.selectors'; import { selectCurrentUser } from '../../../store/users/users.selectors';
import { Room, UserRole } from '../../../shared-kernel'; import { Room, UserRole } from '../../../shared-kernel';
import { NotificationsSettingsComponent } from '../../../domains/notifications'; import { NotificationsSettingsComponent } from '../../../domains/notifications';
import { PluginManagerComponent } from '../../../domains/plugins/feature/plugin-manager/plugin-manager.component';
import { resolveLegacyRole, resolveRoomPermission } from '../../../domains/access-control'; import { resolveLegacyRole, resolveRoomPermission } from '../../../domains/access-control';
import { GeneralSettingsComponent } from './general-settings/general-settings.component'; import { GeneralSettingsComponent } from './general-settings/general-settings.component';
@@ -62,6 +64,7 @@ import {
GeneralSettingsComponent, GeneralSettingsComponent,
NetworkSettingsComponent, NetworkSettingsComponent,
NotificationsSettingsComponent, NotificationsSettingsComponent,
PluginManagerComponent,
VoiceSettingsComponent, VoiceSettingsComponent,
UpdatesSettingsComponent, UpdatesSettingsComponent,
DataSettingsComponent, DataSettingsComponent,
@@ -81,6 +84,7 @@ import {
lucideGlobe, lucideGlobe,
lucideAudioLines, lucideAudioLines,
lucidePalette, lucidePalette,
lucidePackage,
lucideSettings, lucideSettings,
lucideUsers, lucideUsers,
lucideBan, lucideBan,
@@ -117,6 +121,7 @@ export class SettingsModalComponent {
readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [ readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [
{ id: 'general', label: 'General', icon: 'lucideSettings' }, { id: 'general', label: 'General', icon: 'lucideSettings' },
{ id: 'plugins', label: 'Plugins', icon: 'lucidePackage' },
{ id: 'theme', label: 'Theme Studio', icon: 'lucidePalette' }, { id: 'theme', label: 'Theme Studio', icon: 'lucidePalette' },
{ id: 'network', label: 'Network', icon: 'lucideGlobe' }, { id: 'network', label: 'Network', icon: 'lucideGlobe' },
{ id: 'notifications', label: 'Notifications', icon: 'lucideBell' }, { id: 'notifications', label: 'Notifications', icon: 'lucideBell' },

View File

@@ -18,6 +18,18 @@
<h1 class="text-2xl font-bold text-foreground">Settings</h1> <h1 class="text-2xl font-bold text-foreground">Settings</h1>
</div> </div>
<button
type="button"
(click)="openPluginStore()"
class="mb-6 flex items-center gap-2 rounded-lg bg-secondary px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-secondary/80"
>
<ng-icon
name="lucidePackage"
class="h-4 w-4"
/>
Plugin Store
</button>
<!-- Server Endpoints Section --> <!-- Server Endpoints Section -->
<div class="bg-card border border-border rounded-lg p-6 mb-6"> <div class="bg-card border border-border rounded-lg p-6 mb-6">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">

View File

@@ -20,7 +20,8 @@ import {
lucideRefreshCw, lucideRefreshCw,
lucideGlobe, lucideGlobe,
lucideArrowLeft, lucideArrowLeft,
lucideAudioLines lucideAudioLines,
lucidePackage
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { ServerDirectoryFacade } from '../../domains/server-directory'; import { ServerDirectoryFacade } from '../../domains/server-directory';
@@ -47,7 +48,8 @@ import { STORAGE_KEY_CONNECTION_SETTINGS, STORAGE_KEY_VOICE_SETTINGS } from '../
lucideRefreshCw, lucideRefreshCw,
lucideGlobe, lucideGlobe,
lucideArrowLeft, lucideArrowLeft,
lucideAudioLines lucideAudioLines,
lucidePackage
}) })
], ],
templateUrl: './settings.component.html' templateUrl: './settings.component.html'
@@ -173,6 +175,12 @@ export class SettingsComponent implements OnInit {
this.router.navigate(['/']); 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. */ /** Load voice settings (noise reduction) from localStorage. */
loadVoiceSettings(): void { loadVoiceSettings(): void {
const settings = localStorage.getItem(STORAGE_KEY_VOICE_SETTINGS); const settings = localStorage.getItem(STORAGE_KEY_VOICE_SETTINGS);

View File

@@ -85,6 +85,20 @@
Login Login
</button> </button>
<button
type="button"
class="flex h-8 items-center gap-1.5 rounded-md px-2 text-sm text-foreground transition-colors hover:bg-secondary"
[class.hidden]="!isAuthed()"
(click)="openPluginStore()"
title="Plugin Store"
>
<ng-icon
name="lucidePackage"
class="h-4 w-4 text-muted-foreground"
/>
Plugins
</button>
<div class="relative"> <div class="relative">
<button <button
type="button" type="button"
@@ -131,6 +145,14 @@
{{ inviteStatus() }} {{ inviteStatus() }}
</div> </div>
<div class="mx-2 my-1 h-px bg-border"></div> <div class="mx-2 my-1 h-px bg-border"></div>
<button
type="button"
(click)="openPluginStore()"
class="w-full rounded-md px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-secondary"
>
Plugin Store
</button>
<div class="mx-2 my-1 h-px bg-border"></div>
<button <button
type="button" type="button"
(click)="logout()" (click)="logout()"

View File

@@ -16,6 +16,7 @@ import {
lucideChevronLeft, lucideChevronLeft,
lucideHash, lucideHash,
lucideMenu, lucideMenu,
lucidePackage,
lucideRefreshCw lucideRefreshCw
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { NavigationEnd, Router } from '@angular/router'; import { NavigationEnd, Router } from '@angular/router';
@@ -59,6 +60,7 @@ import { ThemeNodeDirective } from '../../../domains/theme';
lucideChevronLeft, lucideChevronLeft,
lucideHash, lucideHash,
lucideMenu, lucideMenu,
lucidePackage,
lucideRefreshCw }) lucideRefreshCw })
], ],
templateUrl: './title-bar.component.html' templateUrl: './title-bar.component.html'
@@ -179,6 +181,13 @@ export class TitleBarComponent {
this.router.navigate(['/login']); this.router.navigate(['/login']);
} }
openPluginStore(): void {
const returnUrl = this.router.url.startsWith('/plugin-store') ? '/search' : this.router.url;
this._showMenu.set(false);
void this.router.navigate(['/plugin-store'], { queryParams: { returnUrl } });
}
/** Open the unified leave-server confirmation dialog. */ /** Open the unified leave-server confirmation dialog. */
private openLeaveConfirm() { private openLeaveConfirm() {
this._showMenu.set(false); this._showMenu.set(false);

View File

@@ -10,5 +10,6 @@ export * from './chat-events';
export * from './media-preferences'; export * from './media-preferences';
export * from './signaling-contracts'; export * from './signaling-contracts';
export * from './attachment-contracts'; export * from './attachment-contracts';
export * from './plugin-system.contracts';
export * from './p2p-transfer.constants'; export * from './p2p-transfer.constants';
export * from './p2p-transfer.utils'; export * from './p2p-transfer.utils';

View File

@@ -0,0 +1,193 @@
export const PLUGIN_REQUIREMENT_STATUSES = [
'required',
'optional',
'recommended',
'blocked',
'incompatible'
] as const;
export type PluginRequirementStatus = typeof PLUGIN_REQUIREMENT_STATUSES[number];
export const PLUGIN_EVENT_DIRECTIONS = [
'clientToServer',
'serverRelay',
'p2pHint'
] as const;
export type PluginEventDirection = typeof PLUGIN_EVENT_DIRECTIONS[number];
export const PLUGIN_EVENT_SCOPES = [
'server',
'channel',
'user',
'plugin'
] as const;
export type PluginEventScope = typeof PLUGIN_EVENT_SCOPES[number];
export const PLUGIN_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'
] as const;
export type PluginCapabilityId = typeof PLUGIN_CAPABILITIES[number];
export interface PluginRequirementSummary {
pluginId: string;
reason?: string;
status: PluginRequirementStatus;
updatedAt: number;
versionRange?: string;
}
export interface PluginEventDefinitionSummary {
direction: PluginEventDirection;
eventName: string;
maxPayloadBytes: number;
pluginId: string;
scope: PluginEventScope;
schemaJson?: string;
updatedAt: number;
}
export interface PluginRequirementsSnapshot {
eventDefinitions: PluginEventDefinitionSummary[];
requirements: PluginRequirementSummary[];
serverId: string;
updatedAt: number;
}
export interface PluginEventEnvelope<TPayload = unknown> {
emittedAt?: number;
eventId?: string;
eventName: string;
payload: TPayload;
pluginId: string;
serverId: string;
sourcePluginUserId?: string;
sourceUserId?: string;
type: 'plugin_event';
}
export interface PluginRequirementsMessage {
snapshot: PluginRequirementsSnapshot;
serverId: string;
type: 'plugin_requirements';
}
export interface PluginRequirementsChangedMessage {
snapshot: PluginRequirementsSnapshot;
serverId: string;
type: 'plugin_requirements_changed';
}
export interface PluginDataChangedMessage {
key: string;
ownerId?: string;
pluginId: string;
scope: string;
serverId: string;
type: 'plugin_data_changed';
updatedAt: number;
}
export interface PluginErrorMessage {
code: string;
eventId?: string;
eventName?: string;
message: string;
pluginId?: string;
serverId?: string;
type: 'plugin_error';
}
export interface TojuPluginManifest {
apiVersion: string;
authors?: {
email?: string;
name: string;
url?: string;
}[];
bugs?: string;
capabilities?: PluginCapabilityId[];
changelog?: string;
compatibility: {
maximumTojuVersion?: string;
minimumTojuVersion: string;
verifiedTojuVersion?: string;
};
data?: {
key: string;
schema?: string;
scope: string;
storage: 'local' | 'serverData';
}[];
description: string;
entrypoint?: string;
events?: {
direction: PluginEventDirection;
eventName: string;
maxPayloadBytes?: number;
schema?: string;
scope: PluginEventScope;
}[];
homepage?: string;
id: string;
kind: 'client' | 'library';
license?: string;
load?: {
priority?: 'bootstrap' | 'high' | 'default' | 'low';
};
pluginUser?: {
avatar?: string;
displayName: string;
label?: string;
};
readme?: string;
relationships?: {
after?: string[];
before?: string[];
conflicts?: string[];
optional?: { id: string; versionRange?: string }[];
requires?: { id: string; versionRange?: string }[];
};
schemaVersion: 1;
settings?: Record<string, unknown>;
title: string;
ui?: Record<string, unknown>;
version: string;
}