feat: plugins v1
This commit is contained in:
3
e2e/fixtures/plugins/api-test-plugin/README.md
Normal file
3
e2e/fixtures/plugins/api-test-plugin/README.md
Normal 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.
|
||||
6
e2e/fixtures/plugins/api-test-plugin/dist/main.js
vendored
Normal file
6
e2e/fixtures/plugins/api-test-plugin/dist/main.js
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
id: 'e2e.plugin-api',
|
||||
activate(api) {
|
||||
api?.logger?.info?.('E2E Plugin API Fixture activated');
|
||||
}
|
||||
};
|
||||
49
e2e/fixtures/plugins/api-test-plugin/toju-plugin.json
Normal file
49
e2e/fixtures/plugins/api-test-plugin/toju-plugin.json
Normal 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"
|
||||
}
|
||||
42
e2e/helpers/plugin-api-test-fixture.ts
Normal file
42
e2e/helpers/plugin-api-test-fixture.ts
Normal 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;
|
||||
}
|
||||
179
e2e/tests/plugins/plugin-api-two-users.spec.ts
Normal file
179
e2e/tests/plugins/plugin-api-two-users.spec.ts
Normal 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 });
|
||||
}
|
||||
88
e2e/tests/plugins/plugin-manager-ui.spec.ts
Normal file
88
e2e/tests/plugins/plugin-manager-ui.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
405
e2e/tests/plugins/plugin-support-api.spec.ts
Normal file
405
e2e/tests/plugins/plugin-support-api.spec.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
readSavedTheme,
|
||||
writeSavedTheme
|
||||
} from '../theme-library';
|
||||
import { getLocalPluginsPath, listLocalPluginManifests } from '../plugin-library';
|
||||
import {
|
||||
eraseUserData,
|
||||
exportUserData,
|
||||
@@ -349,6 +350,8 @@ export function setupSystemHandlers(): void {
|
||||
ipcMain.handle('import-user-data', async () => await importUserData());
|
||||
ipcMain.handle('erase-user-data', async () => await eraseUserData());
|
||||
ipcMain.handle('get-saved-themes-path', async () => await getSavedThemesPath());
|
||||
ipcMain.handle('get-local-plugins-path', async () => await getLocalPluginsPath());
|
||||
ipcMain.handle('list-local-plugin-manifests', async () => await listLocalPluginManifests());
|
||||
ipcMain.handle('list-saved-themes', async () => await listSavedThemes());
|
||||
ipcMain.handle('read-saved-theme', async (_event, fileName: string) => await readSavedTheme(fileName));
|
||||
ipcMain.handle('write-saved-theme', async (_event, fileName: string, text: string) => {
|
||||
|
||||
126
electron/plugin-library.spec.ts
Normal file
126
electron/plugin-library.spec.ts
Normal 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
165
electron/plugin-library.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -109,6 +109,28 @@ export interface SavedThemeFileDescriptor {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface LocalPluginManifestDescriptor {
|
||||
discoveredAt: number;
|
||||
entrypointPath?: string;
|
||||
pluginRootUrl: string;
|
||||
manifest: unknown;
|
||||
manifestPath: string;
|
||||
pluginRoot: string;
|
||||
readmePath?: string;
|
||||
}
|
||||
|
||||
export interface LocalPluginDiscoveryError {
|
||||
manifestPath?: string;
|
||||
message: string;
|
||||
pluginRoot?: string;
|
||||
}
|
||||
|
||||
export interface LocalPluginDiscoveryResult {
|
||||
errors: LocalPluginDiscoveryError[];
|
||||
plugins: LocalPluginManifestDescriptor[];
|
||||
pluginsPath: string;
|
||||
}
|
||||
|
||||
export interface ExportUserDataResult {
|
||||
cancelled: boolean;
|
||||
exported: boolean;
|
||||
@@ -181,6 +203,8 @@ export interface ElectronAPI {
|
||||
importUserData: () => Promise<ImportUserDataResult>;
|
||||
eraseUserData: () => Promise<EraseUserDataResult>;
|
||||
getSavedThemesPath: () => Promise<string>;
|
||||
getLocalPluginsPath: () => Promise<string>;
|
||||
listLocalPluginManifests: () => Promise<LocalPluginDiscoveryResult>;
|
||||
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
|
||||
readSavedTheme: (fileName: string) => Promise<string>;
|
||||
writeSavedTheme: (fileName: string, text: string) => Promise<boolean>;
|
||||
@@ -294,6 +318,8 @@ const electronAPI: ElectronAPI = {
|
||||
importUserData: () => ipcRenderer.invoke('import-user-data'),
|
||||
eraseUserData: () => ipcRenderer.invoke('erase-user-data'),
|
||||
getSavedThemesPath: () => ipcRenderer.invoke('get-saved-themes-path'),
|
||||
getLocalPluginsPath: () => ipcRenderer.invoke('get-local-plugins-path'),
|
||||
listLocalPluginManifests: () => ipcRenderer.invoke('list-local-plugin-manifests'),
|
||||
listSavedThemes: () => ipcRenderer.invoke('list-saved-themes'),
|
||||
readSavedTheme: (fileName) => ipcRenderer.invoke('read-saved-theme', fileName),
|
||||
writeSavedTheme: (fileName, text) => ipcRenderer.invoke('write-saved-theme', fileName, text),
|
||||
|
||||
@@ -20,6 +20,7 @@ Node/TypeScript signaling server for MetoYou / Toju. This package owns the publi
|
||||
- `SSL` can override the effective HTTP protocol, and `PORT` can override the effective port.
|
||||
- `DB_PATH` can override the SQLite database file location.
|
||||
- `data/variables.json` is normalized on startup and stores `klipyApiKey`, `rawgApiKey`, `releaseManifestUrl`, `serverPort`, `serverProtocol`, `serverHost`, and `linkPreview`.
|
||||
- `openApiDocs.enabled` in `data/variables.json`, or `OPENAPI_DOCS_ENABLED=true`, exposes the plugin support OpenAPI document at `/api/openapi.json` and a small docs page at `/api/docs`. It is disabled by default.
|
||||
- `RAWG_API_KEY` can override `rawgApiKey` for the `/api/games/match` now-playing metadata resolver. Successful matches include a preferred store link from RAWG store metadata, with Steam selected first when available. Negative game-match results are stored in the SQLite `game_match_misses` table so non-game process names do not repeatedly consume RAWG quota.
|
||||
- Packaged server builds store `metoyou.sqlite` in the OS app-data directory by default so upgrades do not overwrite runtime data. On first start, the server copies forward legacy packaged databases that still live beside the executable.
|
||||
- When HTTPS is enabled, certificates are read from the repository `.certs/` directory.
|
||||
|
||||
Binary file not shown.
@@ -10,6 +10,10 @@ export interface LinkPreviewConfig {
|
||||
maxCacheSizeMb: number;
|
||||
}
|
||||
|
||||
export interface OpenApiDocsConfig {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface ServerVariablesConfig {
|
||||
klipyApiKey: string;
|
||||
rawgApiKey: string;
|
||||
@@ -18,6 +22,7 @@ export interface ServerVariablesConfig {
|
||||
serverProtocol: ServerHttpProtocol;
|
||||
serverHost: string;
|
||||
linkPreview: LinkPreviewConfig;
|
||||
openApiDocs: OpenApiDocsConfig;
|
||||
}
|
||||
|
||||
const DATA_DIR = resolveRuntimePath('data');
|
||||
@@ -102,6 +107,14 @@ function normalizeLinkPreviewConfig(value: unknown): LinkPreviewConfig {
|
||||
return { enabled, cacheTtlMinutes: cacheTtl, maxCacheSizeMb: maxSize };
|
||||
}
|
||||
|
||||
function normalizeOpenApiDocsConfig(value: unknown): OpenApiDocsConfig {
|
||||
const raw = (value && typeof value === 'object' && !Array.isArray(value))
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
|
||||
return { enabled: raw.enabled === true };
|
||||
}
|
||||
|
||||
function hasEnvironmentOverride(value: string | undefined): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
@@ -149,7 +162,8 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
||||
serverPort: normalizeServerPort(remainingParsed.serverPort),
|
||||
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
|
||||
serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress),
|
||||
linkPreview: normalizeLinkPreviewConfig(remainingParsed.linkPreview)
|
||||
linkPreview: normalizeLinkPreviewConfig(remainingParsed.linkPreview),
|
||||
openApiDocs: normalizeOpenApiDocsConfig(remainingParsed.openApiDocs)
|
||||
};
|
||||
const nextContents = JSON.stringify(normalized, null, 2) + '\n';
|
||||
|
||||
@@ -164,7 +178,8 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
||||
serverPort: normalized.serverPort,
|
||||
serverProtocol: normalized.serverProtocol,
|
||||
serverHost: normalized.serverHost,
|
||||
linkPreview: normalized.linkPreview
|
||||
linkPreview: normalized.linkPreview,
|
||||
openApiDocs: normalized.openApiDocs
|
||||
};
|
||||
}
|
||||
|
||||
@@ -218,6 +233,31 @@ export function isHttpsServerEnabled(): boolean {
|
||||
return getServerProtocol() === 'https';
|
||||
}
|
||||
|
||||
export function areOpenApiDocsEnabled(): boolean {
|
||||
if (hasEnvironmentOverride(process.env.OPENAPI_DOCS_ENABLED)) {
|
||||
return process.env.OPENAPI_DOCS_ENABLED.trim().toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
return getVariablesConfig().openApiDocs.enabled;
|
||||
}
|
||||
|
||||
export function setOpenApiDocsEnabled(enabled: boolean): OpenApiDocsConfig {
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const { parsed } = readRawVariables();
|
||||
const next = {
|
||||
...parsed,
|
||||
openApiDocs: { enabled }
|
||||
};
|
||||
|
||||
fs.writeFileSync(VARIABLES_FILE, JSON.stringify(next, null, 2) + '\n', 'utf8');
|
||||
ensureVariablesConfig();
|
||||
|
||||
return { enabled };
|
||||
}
|
||||
|
||||
export function getLinkPreviewConfig(): LinkPreviewConfig {
|
||||
return getVariablesConfig().linkPreview;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,12 @@ import {
|
||||
ServerMembershipEntity,
|
||||
ServerInviteEntity,
|
||||
ServerBanEntity,
|
||||
GameMatchMissEntity
|
||||
GameMatchMissEntity,
|
||||
ServerPluginRequirementEntity,
|
||||
ServerPluginEventDefinitionEntity,
|
||||
PluginDataEntity,
|
||||
ServerPluginSettingsEntity,
|
||||
PluginUserMetadataEntity
|
||||
} from '../entities';
|
||||
import { serverMigrations } from '../migrations';
|
||||
import {
|
||||
@@ -49,8 +54,18 @@ const DB_BACKUP = DB_FILE + '.bak';
|
||||
const DATA_DIR = path.dirname(DB_FILE);
|
||||
// SQLite files start with this 16-byte header string.
|
||||
const SQLITE_MAGIC = 'SQLite format 3\0';
|
||||
const SAVE_RETRY_DELAYS_MS = [25, 75, 150, 300, 600];
|
||||
const RETRYABLE_SAVE_ERROR_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
|
||||
const SAVE_RETRY_DELAYS_MS = [
|
||||
25,
|
||||
75,
|
||||
150,
|
||||
300,
|
||||
600
|
||||
];
|
||||
const RETRYABLE_SAVE_ERROR_CODES = new Set([
|
||||
'EPERM',
|
||||
'EACCES',
|
||||
'EBUSY'
|
||||
]);
|
||||
|
||||
let applicationDataSource: DataSource | undefined;
|
||||
let saveQueue: Promise<void> = Promise.resolve();
|
||||
@@ -250,7 +265,12 @@ export async function initDatabase(): Promise<void> {
|
||||
ServerMembershipEntity,
|
||||
ServerInviteEntity,
|
||||
ServerBanEntity,
|
||||
GameMatchMissEntity
|
||||
GameMatchMissEntity,
|
||||
ServerPluginRequirementEntity,
|
||||
ServerPluginEventDefinitionEntity,
|
||||
PluginDataEntity,
|
||||
ServerPluginSettingsEntity,
|
||||
PluginUserMetadataEntity
|
||||
],
|
||||
migrations: serverMigrations,
|
||||
synchronize: process.env.DB_SYNCHRONIZE === 'true',
|
||||
|
||||
35
server/src/entities/PluginDataEntity.ts
Normal file
35
server/src/entities/PluginDataEntity.ts
Normal 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;
|
||||
}
|
||||
38
server/src/entities/PluginUserMetadataEntity.ts
Normal file
38
server/src/entities/PluginUserMetadataEntity.ts
Normal 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;
|
||||
}
|
||||
41
server/src/entities/ServerPluginEventDefinitionEntity.ts
Normal file
41
server/src/entities/ServerPluginEventDefinitionEntity.ts
Normal 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;
|
||||
}
|
||||
36
server/src/entities/ServerPluginRequirementEntity.ts
Normal file
36
server/src/entities/ServerPluginRequirementEntity.ts
Normal 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;
|
||||
}
|
||||
26
server/src/entities/ServerPluginSettingsEntity.ts
Normal file
26
server/src/entities/ServerPluginSettingsEntity.ts
Normal 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;
|
||||
}
|
||||
@@ -10,3 +10,10 @@ export { ServerMembershipEntity } from './ServerMembershipEntity';
|
||||
export { ServerInviteEntity } from './ServerInviteEntity';
|
||||
export { ServerBanEntity } from './ServerBanEntity';
|
||||
export { GameMatchMissEntity } from './GameMatchMissEntity';
|
||||
export { ServerPluginRequirementEntity } from './ServerPluginRequirementEntity';
|
||||
export type { ServerPluginRequirementStatus } from './ServerPluginRequirementEntity';
|
||||
export { ServerPluginEventDefinitionEntity } from './ServerPluginEventDefinitionEntity';
|
||||
export type { ServerPluginEventDirection, ServerPluginEventScope } from './ServerPluginEventDefinitionEntity';
|
||||
export { PluginDataEntity } from './PluginDataEntity';
|
||||
export { ServerPluginSettingsEntity } from './ServerPluginSettingsEntity';
|
||||
export { PluginUserMetadataEntity } from './PluginUserMetadataEntity';
|
||||
|
||||
92
server/src/migrations/1000000000007-PluginSupport.ts
Normal file
92
server/src/migrations/1000000000007-PluginSupport.ts
Normal 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"`);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { RepairLegacyVoiceChannels1000000000003 } from './1000000000003-RepairLe
|
||||
import { NormalizeServerArrays1000000000004 } from './1000000000004-NormalizeServerArrays';
|
||||
import { ServerRoleAccessControl1000000000005 } from './1000000000005-ServerRoleAccessControl';
|
||||
import { GameMatchMisses1000000000006 } from './1000000000006-GameMatchMisses';
|
||||
import { PluginSupport1000000000007 } from './1000000000007-PluginSupport';
|
||||
|
||||
export const serverMigrations = [
|
||||
InitialSchema1000000000000,
|
||||
@@ -13,5 +14,6 @@ export const serverMigrations = [
|
||||
RepairLegacyVoiceChannels1000000000003,
|
||||
NormalizeServerArrays1000000000004,
|
||||
ServerRoleAccessControl1000000000005,
|
||||
GameMatchMisses1000000000006
|
||||
GameMatchMisses1000000000006,
|
||||
PluginSupport1000000000007
|
||||
];
|
||||
|
||||
@@ -6,6 +6,8 @@ import gamesRouter from './games';
|
||||
import proxyRouter from './proxy';
|
||||
import usersRouter from './users';
|
||||
import serversRouter from './servers';
|
||||
import pluginSupportRouter from './plugin-support';
|
||||
import openApiDocsRouter from './openapi-docs';
|
||||
import joinRequestsRouter from './join-requests';
|
||||
import { invitesApiRouter, invitePageRouter } from './invites';
|
||||
|
||||
@@ -16,6 +18,8 @@ export function registerRoutes(app: Express): void {
|
||||
app.use('/api/games', gamesRouter);
|
||||
app.use('/api', proxyRouter);
|
||||
app.use('/api/users', usersRouter);
|
||||
app.use('/api', openApiDocsRouter);
|
||||
app.use('/api/servers', pluginSupportRouter);
|
||||
app.use('/api/servers', serversRouter);
|
||||
app.use('/api/invites', invitesApiRouter);
|
||||
app.use('/api/requests', joinRequestsRouter);
|
||||
|
||||
106
server/src/routes/openapi-docs.ts
Normal file
106
server/src/routes/openapi-docs.ts
Normal 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;
|
||||
208
server/src/routes/plugin-support.ts
Normal file
208
server/src/routes/plugin-support.ts
Normal 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;
|
||||
523
server/src/services/plugin-support.service.ts
Normal file
523
server/src/services/plugin-support.service.ts
Normal 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;
|
||||
}
|
||||
221
server/src/websocket/handler-plugin.spec.ts
Normal file
221
server/src/websocket/handler-plugin.spec.ts
Normal 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'
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,11 @@ import {
|
||||
isOderIdConnectedToServer
|
||||
} from './broadcast';
|
||||
import { authorizeWebSocketJoin } from '../services/server-access.service';
|
||||
import {
|
||||
getPluginRequirementsSnapshot,
|
||||
PluginSupportError,
|
||||
validatePluginEventEnvelope
|
||||
} from '../services/plugin-support.service';
|
||||
|
||||
interface WsMessage {
|
||||
[key: string]: unknown;
|
||||
@@ -50,6 +55,29 @@ function readMessageId(value: unknown): string | undefined {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function sendPluginError(user: ConnectedUser, error: unknown, message: WsMessage): void {
|
||||
if (error instanceof PluginSupportError) {
|
||||
user.ws.send(JSON.stringify({
|
||||
type: 'plugin_error',
|
||||
serverId: typeof message['serverId'] === 'string' ? message['serverId'] : undefined,
|
||||
pluginId: typeof message['pluginId'] === 'string' ? message['pluginId'] : undefined,
|
||||
eventName: typeof message['eventName'] === 'string' ? message['eventName'] : undefined,
|
||||
eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined,
|
||||
code: error.code,
|
||||
message: error.message
|
||||
}));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Unhandled plugin websocket error:', error);
|
||||
user.ws.send(JSON.stringify({
|
||||
type: 'plugin_error',
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'Internal server error'
|
||||
}));
|
||||
}
|
||||
|
||||
/** Sends the current user list for a given server to a single connected user. */
|
||||
function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
||||
const users = getUniqueUsersInServer(serverId, user.oderId)
|
||||
@@ -64,6 +92,20 @@ function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
||||
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
|
||||
}
|
||||
|
||||
async function sendPluginRequirements(user: ConnectedUser, serverId: string): Promise<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 {
|
||||
const newOderId = readMessageId(message['oderId']) ?? connectionId;
|
||||
const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined;
|
||||
@@ -137,6 +179,7 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
|
||||
);
|
||||
|
||||
sendServerUsers(user, sid);
|
||||
await sendPluginRequirements(user, sid);
|
||||
|
||||
if (isNewIdentityMembership) {
|
||||
broadcastToServer(sid, {
|
||||
@@ -151,17 +194,22 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
|
||||
}
|
||||
}
|
||||
|
||||
function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||
async function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
|
||||
const viewSid = readMessageId(message['serverId']);
|
||||
|
||||
if (!viewSid)
|
||||
return;
|
||||
|
||||
if (!user.serverIds.has(viewSid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
user.viewedServerId = viewSid;
|
||||
connectedUsers.set(connectionId, user);
|
||||
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) viewing server ${viewSid}`);
|
||||
|
||||
sendServerUsers(user, viewSid);
|
||||
await sendPluginRequirements(user, viewSid);
|
||||
}
|
||||
|
||||
function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||
@@ -268,6 +316,52 @@ function handleStatusUpdate(user: ConnectedUser, message: WsMessage, connectionI
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePluginEvent(user: ConnectedUser, message: WsMessage): Promise<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> {
|
||||
const user = connectedUsers.get(connectionId);
|
||||
|
||||
@@ -290,7 +384,7 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
|
||||
break;
|
||||
|
||||
case 'view_server':
|
||||
handleViewServer(user, message, connectionId);
|
||||
await handleViewServer(user, message, connectionId);
|
||||
break;
|
||||
|
||||
case 'leave_server':
|
||||
@@ -315,6 +409,10 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
|
||||
handleStatusUpdate(user, message, connectionId);
|
||||
break;
|
||||
|
||||
case 'plugin_event':
|
||||
await handlePluginEvent(user, message);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('Unknown message type:', message.type);
|
||||
}
|
||||
|
||||
@@ -96,12 +96,12 @@
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "2.2MB",
|
||||
"maximumError": "2.38MB"
|
||||
"maximumWarning": "2.5MB",
|
||||
"maximumError": "2.6MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "4kB",
|
||||
"maximumWarning": "7kB",
|
||||
"maximumError": "8kB"
|
||||
}
|
||||
],
|
||||
|
||||
3
toju-app/public/plugins/e2e-all-api/README.md
Normal file
3
toju-app/public/plugins/e2e-all-api/README.md
Normal 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.
|
||||
6
toju-app/public/plugins/e2e-all-api/icon.svg
Normal file
6
toju-app/public/plugins/e2e-all-api/icon.svg
Normal 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 |
273
toju-app/public/plugins/e2e-all-api/main.js
Normal file
273
toju-app/public/plugins/e2e-all-api/main.js
Normal 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();
|
||||
}
|
||||
99
toju-app/public/plugins/e2e-all-api/toju.plugin.json
Normal file
99
toju-app/public/plugins/e2e-all-api/toju.plugin.json
Normal 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"
|
||||
}
|
||||
}
|
||||
17
toju-app/public/plugins/e2e-plugin-source.json
Normal file
17
toju-app/public/plugins/e2e-plugin-source.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -48,5 +48,15 @@ export const routes: Routes = [
|
||||
path: 'settings',
|
||||
loadComponent: () =>
|
||||
import('./features/settings/settings.component').then((module) => module.SettingsComponent)
|
||||
},
|
||||
{
|
||||
path: 'plugin-store',
|
||||
loadComponent: () =>
|
||||
import('./domains/plugins/feature/plugin-store/plugin-store.component').then((module) => module.PluginStoreComponent)
|
||||
},
|
||||
{
|
||||
path: 'plugins/:pluginId/:pageId',
|
||||
loadComponent: () =>
|
||||
import('./domains/plugins/feature/plugin-page-host/plugin-page-host.component').then((module) => module.PluginPageHostComponent)
|
||||
}
|
||||
];
|
||||
|
||||
@@ -49,4 +49,20 @@ export type {
|
||||
ChatAttachmentMeta
|
||||
} from '../../shared-kernel';
|
||||
|
||||
export type {
|
||||
PluginCapabilityId,
|
||||
PluginDataChangedMessage,
|
||||
PluginErrorMessage,
|
||||
PluginEventDefinitionSummary,
|
||||
PluginEventDirection,
|
||||
PluginEventEnvelope,
|
||||
PluginEventScope,
|
||||
PluginRequirementStatus,
|
||||
PluginRequirementSummary,
|
||||
PluginRequirementsChangedMessage,
|
||||
PluginRequirementsMessage,
|
||||
PluginRequirementsSnapshot,
|
||||
TojuPluginManifest
|
||||
} from '../../shared-kernel';
|
||||
|
||||
export type { ServerInfo } from '../../domains/server-directory';
|
||||
|
||||
@@ -124,6 +124,28 @@ export interface SavedThemeFileDescriptor {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface LocalPluginManifestDescriptor {
|
||||
discoveredAt: number;
|
||||
entrypointPath?: string;
|
||||
pluginRootUrl: string;
|
||||
manifest: unknown;
|
||||
manifestPath: string;
|
||||
pluginRoot: string;
|
||||
readmePath?: string;
|
||||
}
|
||||
|
||||
export interface LocalPluginDiscoveryError {
|
||||
manifestPath?: string;
|
||||
message: string;
|
||||
pluginRoot?: string;
|
||||
}
|
||||
|
||||
export interface LocalPluginDiscoveryResult {
|
||||
errors: LocalPluginDiscoveryError[];
|
||||
plugins: LocalPluginManifestDescriptor[];
|
||||
pluginsPath: string;
|
||||
}
|
||||
|
||||
export interface ExportUserDataResult {
|
||||
cancelled: boolean;
|
||||
exported: boolean;
|
||||
@@ -189,6 +211,8 @@ export interface ElectronApi {
|
||||
importUserData: () => Promise<ImportUserDataResult>;
|
||||
eraseUserData: () => Promise<EraseUserDataResult>;
|
||||
getSavedThemesPath: () => Promise<string>;
|
||||
getLocalPluginsPath: () => Promise<string>;
|
||||
listLocalPluginManifests: () => Promise<LocalPluginDiscoveryResult>;
|
||||
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
|
||||
readSavedTheme: (fileName: string) => Promise<string>;
|
||||
writeSavedTheme: (fileName: string, text: string) => Promise<boolean>;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export type SettingsPage =
|
||||
| 'general'
|
||||
| 'plugins'
|
||||
| 'theme'
|
||||
| 'network'
|
||||
| 'notifications'
|
||||
|
||||
@@ -15,6 +15,7 @@ infrastructure adapters and UI.
|
||||
| **direct-message** | One-to-one WebRTC messages, offline queueing, delivery state, and friends | `DirectMessageService`, `FriendService` |
|
||||
| **game-activity** | Local game detection, server metadata matching, P2P now-playing sync, and elapsed playtime formatting | `GameActivityService`, `formatGameActivityElapsed()` |
|
||||
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
|
||||
| **plugins** | Client-only plugin manifests, load ordering, registry state, and signal-server support metadata | `PluginHostService`, `PluginRegistryService` |
|
||||
| **profile-avatar** | Profile picture upload, crop/zoom editing, processing, local persistence, and P2P avatar sync | `ProfileAvatarFacade` |
|
||||
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
|
||||
| **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` |
|
||||
@@ -32,6 +33,7 @@ The larger domains also keep longer design notes in their own folders:
|
||||
- [chat/README.md](chat/README.md)
|
||||
- [direct-message/README.md](direct-message/README.md)
|
||||
- [notifications/README.md](notifications/README.md)
|
||||
- [plugins/README.md](plugins/README.md)
|
||||
- [profile-avatar/README.md](profile-avatar/README.md)
|
||||
- [screen-share/README.md](screen-share/README.md)
|
||||
- [server-directory/README.md](server-directory/README.md)
|
||||
|
||||
@@ -141,6 +141,20 @@
|
||||
(drop)="onDrop($event)"
|
||||
>
|
||||
<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()) {
|
||||
<button
|
||||
#klipyTrigger
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { ClipboardFilePayload } from '../../../../../../core/platform/elect
|
||||
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
|
||||
import { KlipyGif, KlipyService } from '../../../../application/services/klipy.service';
|
||||
import { Message } from '../../../../../../shared-kernel';
|
||||
import { PluginUiRegistryService } from '../../../../../plugins';
|
||||
import { ThemeNodeDirective } from '../../../../../theme';
|
||||
import type { RoomSignalSourceInput } from '../../../../../server-directory';
|
||||
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 markdown = inject(ChatMarkdownService);
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
private readonly pluginUi = inject(PluginUiRegistryService);
|
||||
|
||||
readonly pendingKlipyGif = signal<KlipyGif | null>(null);
|
||||
readonly pluginComposerActions = this.pluginUi.composerActionRecords;
|
||||
readonly toolbarVisible = signal(false);
|
||||
readonly dragActive = signal(false);
|
||||
readonly inputHovered = signal(false);
|
||||
@@ -219,6 +222,11 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
this.klipyGifPickerToggleRequested.emit();
|
||||
}
|
||||
|
||||
runPluginComposerAction(action: () => Promise<void> | void): void {
|
||||
void Promise.resolve()
|
||||
.then(() => action());
|
||||
}
|
||||
|
||||
getKlipyTriggerRect(): DOMRect | null {
|
||||
return this.klipyTrigger?.nativeElement.getBoundingClientRect() ?? null;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
<div class="mt-2 space-y-2">
|
||||
@for (att of attachmentsList; track att.id) {
|
||||
|
||||
@@ -38,6 +38,8 @@ import {
|
||||
User
|
||||
} from '../../../../../../shared-kernel';
|
||||
import { ThemeNodeDirective } from '../../../../../theme';
|
||||
import { PluginRenderHostComponent } from '../../../../../plugins/feature/plugin-render-host/plugin-render-host.component';
|
||||
import { PluginUiRegistryService } from '../../../../../plugins';
|
||||
|
||||
import {
|
||||
ChatAudioPlayerComponent,
|
||||
@@ -98,6 +100,7 @@ interface ChatMessageAttachmentViewModel extends Attachment {
|
||||
ChatMessageMarkdownComponent,
|
||||
ChatLinkEmbedComponent,
|
||||
UserAvatarComponent,
|
||||
PluginRenderHostComponent,
|
||||
ThemeNodeDirective
|
||||
],
|
||||
viewProviders: [
|
||||
@@ -124,6 +127,7 @@ export class ChatMessageItemComponent {
|
||||
|
||||
private readonly attachmentsSvc = inject(AttachmentFacade);
|
||||
private readonly klipy = inject(KlipyService);
|
||||
private readonly pluginUi = inject(PluginUiRegistryService);
|
||||
private readonly profileCard = inject(ProfileCardService);
|
||||
private readonly attachmentVersion = signal(this.attachmentsSvc.updated());
|
||||
|
||||
@@ -146,6 +150,7 @@ export class ChatMessageItemComponent {
|
||||
|
||||
readonly commonEmojis = COMMON_EMOJIS;
|
||||
readonly deletedMessageContent = DELETED_MESSAGE_CONTENT;
|
||||
readonly pluginEmbeds = computed(() => this.findPluginEmbeds(this.message().content));
|
||||
readonly isEditing = signal(false);
|
||||
readonly showEmojiPicker = signal(false);
|
||||
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 {
|
||||
this.editContent = this.message().content;
|
||||
this.isEditing.set(true);
|
||||
@@ -507,3 +534,15 @@ export class ChatMessageItemComponent {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
17
toju-app/src/app/domains/plugins/README.md
Normal file
17
toju-app/src/app/domains/plugins/README.md
Normal 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.
|
||||
@@ -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'));
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
};
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(/\/$/, '');
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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'
|
||||
}
|
||||
]));
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
}))));
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
16
toju-app/src/app/domains/plugins/index.ts
Normal file
16
toju-app/src/app/domains/plugins/index.ts
Normal 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';
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -250,6 +250,41 @@
|
||||
}
|
||||
</div>
|
||||
</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>
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ import { VoicePlaybackService } from '../../../domains/voice-connection';
|
||||
import { formatGameActivityElapsed } from '../../../domains/game-activity';
|
||||
import { ExternalLinkService } from '../../../core/platform/external-link.service';
|
||||
import { VoiceControlsComponent } from '../../../domains/voice-session/feature/voice-controls/voice-controls.component';
|
||||
import { PluginRenderHostComponent } from '../../../domains/plugins/feature/plugin-render-host/plugin-render-host.component';
|
||||
import { PluginUiRegistryService } from '../../../domains/plugins';
|
||||
import { isChannelNameTaken, normalizeChannelName } from '../../../store/rooms/room-channels.rules';
|
||||
import {
|
||||
canManageMember,
|
||||
@@ -89,6 +91,7 @@ type PanelMode = 'channels' | 'users';
|
||||
UserVolumeMenuComponent,
|
||||
UserAvatarComponent,
|
||||
ConfirmDialogComponent,
|
||||
PluginRenderHostComponent,
|
||||
ThemeNodeDirective
|
||||
],
|
||||
viewProviders: [
|
||||
@@ -124,6 +127,7 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
private readonly externalLinks = inject(ExternalLinkService);
|
||||
private readonly voiceActivity = inject(VoiceActivityService);
|
||||
private readonly voiceConnectivity = inject(VoiceConnectivityHealthService);
|
||||
private readonly pluginUi = inject(PluginUiRegistryService);
|
||||
private profileCardOpenTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private readonly activityTimer = setInterval(() => this.activityNow.set(Date.now()), 1_000);
|
||||
|
||||
@@ -137,6 +141,8 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||
textChannels = this.store.selectSignal(selectTextChannels);
|
||||
voiceChannels = this.store.selectSignal(selectVoiceChannels);
|
||||
pluginChannelSections = this.pluginUi.channelSectionRecords;
|
||||
pluginSidePanels = this.pluginUi.sidePanelRecords;
|
||||
localUserHasDesync = this.voiceConnectivity.localUserHasDesync;
|
||||
roomMembers = computed(() => this.currentRoom()?.members ?? []);
|
||||
roomMemberIdentifiers = computed(() => {
|
||||
|
||||
@@ -135,6 +135,9 @@
|
||||
@case ('general') {
|
||||
General
|
||||
}
|
||||
@case ('plugins') {
|
||||
Plugins
|
||||
}
|
||||
@case ('network') {
|
||||
Network
|
||||
}
|
||||
@@ -193,6 +196,9 @@
|
||||
@case ('general') {
|
||||
<app-general-settings />
|
||||
}
|
||||
@case ('plugins') {
|
||||
<app-plugin-manager (closed)="navigate('general')" />
|
||||
}
|
||||
@case ('network') {
|
||||
<app-network-settings />
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
lucideGlobe,
|
||||
lucideAudioLines,
|
||||
lucidePalette,
|
||||
lucidePackage,
|
||||
lucideSettings,
|
||||
lucideUsers,
|
||||
lucideBan,
|
||||
@@ -33,6 +34,7 @@ import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.
|
||||
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||
import { Room, UserRole } from '../../../shared-kernel';
|
||||
import { NotificationsSettingsComponent } from '../../../domains/notifications';
|
||||
import { PluginManagerComponent } from '../../../domains/plugins/feature/plugin-manager/plugin-manager.component';
|
||||
import { resolveLegacyRole, resolveRoomPermission } from '../../../domains/access-control';
|
||||
|
||||
import { GeneralSettingsComponent } from './general-settings/general-settings.component';
|
||||
@@ -62,6 +64,7 @@ import {
|
||||
GeneralSettingsComponent,
|
||||
NetworkSettingsComponent,
|
||||
NotificationsSettingsComponent,
|
||||
PluginManagerComponent,
|
||||
VoiceSettingsComponent,
|
||||
UpdatesSettingsComponent,
|
||||
DataSettingsComponent,
|
||||
@@ -81,6 +84,7 @@ import {
|
||||
lucideGlobe,
|
||||
lucideAudioLines,
|
||||
lucidePalette,
|
||||
lucidePackage,
|
||||
lucideSettings,
|
||||
lucideUsers,
|
||||
lucideBan,
|
||||
@@ -117,6 +121,7 @@ export class SettingsModalComponent {
|
||||
|
||||
readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [
|
||||
{ id: 'general', label: 'General', icon: 'lucideSettings' },
|
||||
{ id: 'plugins', label: 'Plugins', icon: 'lucidePackage' },
|
||||
{ id: 'theme', label: 'Theme Studio', icon: 'lucidePalette' },
|
||||
{ id: 'network', label: 'Network', icon: 'lucideGlobe' },
|
||||
{ id: 'notifications', label: 'Notifications', icon: 'lucideBell' },
|
||||
|
||||
@@ -18,6 +18,18 @@
|
||||
<h1 class="text-2xl font-bold text-foreground">Settings</h1>
|
||||
</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 -->
|
||||
<div class="bg-card border border-border rounded-lg p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
|
||||
@@ -20,7 +20,8 @@ import {
|
||||
lucideRefreshCw,
|
||||
lucideGlobe,
|
||||
lucideArrowLeft,
|
||||
lucideAudioLines
|
||||
lucideAudioLines,
|
||||
lucidePackage
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { ServerDirectoryFacade } from '../../domains/server-directory';
|
||||
@@ -47,7 +48,8 @@ import { STORAGE_KEY_CONNECTION_SETTINGS, STORAGE_KEY_VOICE_SETTINGS } from '../
|
||||
lucideRefreshCw,
|
||||
lucideGlobe,
|
||||
lucideArrowLeft,
|
||||
lucideAudioLines
|
||||
lucideAudioLines,
|
||||
lucidePackage
|
||||
})
|
||||
],
|
||||
templateUrl: './settings.component.html'
|
||||
@@ -173,6 +175,12 @@ export class SettingsComponent implements OnInit {
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
|
||||
openPluginStore(): void {
|
||||
const returnUrl = this.router.url.startsWith('/plugin-store') ? '/search' : this.router.url;
|
||||
|
||||
void this.router.navigate(['/plugin-store'], { queryParams: { returnUrl } });
|
||||
}
|
||||
|
||||
/** Load voice settings (noise reduction) from localStorage. */
|
||||
loadVoiceSettings(): void {
|
||||
const settings = localStorage.getItem(STORAGE_KEY_VOICE_SETTINGS);
|
||||
|
||||
@@ -85,6 +85,20 @@
|
||||
Login
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
@@ -131,6 +145,14 @@
|
||||
{{ inviteStatus() }}
|
||||
</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
|
||||
type="button"
|
||||
(click)="logout()"
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
lucideChevronLeft,
|
||||
lucideHash,
|
||||
lucideMenu,
|
||||
lucidePackage,
|
||||
lucideRefreshCw
|
||||
} from '@ng-icons/lucide';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
@@ -59,6 +60,7 @@ import { ThemeNodeDirective } from '../../../domains/theme';
|
||||
lucideChevronLeft,
|
||||
lucideHash,
|
||||
lucideMenu,
|
||||
lucidePackage,
|
||||
lucideRefreshCw })
|
||||
],
|
||||
templateUrl: './title-bar.component.html'
|
||||
@@ -179,6 +181,13 @@ export class TitleBarComponent {
|
||||
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. */
|
||||
private openLeaveConfirm() {
|
||||
this._showMenu.set(false);
|
||||
|
||||
@@ -10,5 +10,6 @@ export * from './chat-events';
|
||||
export * from './media-preferences';
|
||||
export * from './signaling-contracts';
|
||||
export * from './attachment-contracts';
|
||||
export * from './plugin-system.contracts';
|
||||
export * from './p2p-transfer.constants';
|
||||
export * from './p2p-transfer.utils';
|
||||
|
||||
193
toju-app/src/app/shared-kernel/plugin-system.contracts.ts
Normal file
193
toju-app/src/app/shared-kernel/plugin-system.contracts.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user