Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 53389ed3ad |
@@ -1,3 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
id: 'e2e.plugin-api',
|
|
||||||
activate(api) {
|
|
||||||
api?.logger?.info?.('E2E Plugin API Fixture activated');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -58,10 +58,6 @@ function buildSeededEndpointStorageState(
|
|||||||
function applySeededEndpointStorageState(storageState: SeededEndpointStorageState): void {
|
function applySeededEndpointStorageState(storageState: SeededEndpointStorageState): void {
|
||||||
try {
|
try {
|
||||||
const storage = window.localStorage;
|
const storage = window.localStorage;
|
||||||
const currentUserId = storage.getItem('metoyou_currentUserId')?.trim() || null;
|
|
||||||
const generalSettings = JSON.stringify({
|
|
||||||
reopenLastViewedChat: false
|
|
||||||
});
|
|
||||||
|
|
||||||
storage.setItem(storageState.key, JSON.stringify(storageState.endpoints));
|
storage.setItem(storageState.key, JSON.stringify(storageState.endpoints));
|
||||||
storage.setItem(storageState.removedKey, JSON.stringify([
|
storage.setItem(storageState.removedKey, JSON.stringify([
|
||||||
@@ -69,56 +65,11 @@ function applySeededEndpointStorageState(storageState: SeededEndpointStorageStat
|
|||||||
'toju-primary',
|
'toju-primary',
|
||||||
'toju-sweden'
|
'toju-sweden'
|
||||||
]));
|
]));
|
||||||
storage.setItem('metoyou_general_settings', generalSettings);
|
|
||||||
|
|
||||||
if (currentUserId) {
|
|
||||||
storage.setItem(`metoyou_general_settings__${encodeURIComponent(currentUserId)}`, generalSettings);
|
|
||||||
}
|
|
||||||
|
|
||||||
const keysToRemove: string[] = [];
|
|
||||||
|
|
||||||
for (let index = 0; index < storage.length; index += 1) {
|
|
||||||
const key = storage.key(index);
|
|
||||||
|
|
||||||
if (key === 'metoyou_lastViewedChat' || key?.startsWith('metoyou_lastViewedChat__')) {
|
|
||||||
keysToRemove.push(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const key of keysToRemove) {
|
|
||||||
storage.removeItem(key);
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// about:blank and some Playwright UI pages deny localStorage access.
|
// about:blank and some Playwright UI pages deny localStorage access.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function disableLastViewedChatResume(page: Page): Promise<void> {
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const currentUserId = localStorage.getItem('metoyou_currentUserId')?.trim() || null;
|
|
||||||
const generalSettings = JSON.stringify({ reopenLastViewedChat: false });
|
|
||||||
const keysToRemove: string[] = [];
|
|
||||||
|
|
||||||
localStorage.setItem('metoyou_general_settings', generalSettings);
|
|
||||||
|
|
||||||
if (currentUserId) {
|
|
||||||
localStorage.setItem(`metoyou_general_settings__${encodeURIComponent(currentUserId)}`, generalSettings);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let index = 0; index < localStorage.length; index += 1) {
|
|
||||||
const key = localStorage.key(index);
|
|
||||||
|
|
||||||
if (key === 'metoyou_lastViewedChat' || key?.startsWith('metoyou_lastViewedChat__')) {
|
|
||||||
keysToRemove.push(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const key of keysToRemove) {
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function installTestServerEndpoint(
|
export async function installTestServerEndpoint(
|
||||||
context: BrowserContext,
|
context: BrowserContext,
|
||||||
port: number = Number(process.env.TEST_SERVER_PORT) || 3099
|
port: number = Number(process.env.TEST_SERVER_PORT) || 3099
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
import { RegisterPage } from '../../pages/register.page';
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
||||||
import { disableLastViewedChatResume } from '../../helpers/seed-test-endpoint';
|
|
||||||
|
|
||||||
test.describe('Direct message flow', () => {
|
test.describe('Direct message flow', () => {
|
||||||
test.describe.configure({ timeout: 180_000 });
|
test.describe.configure({ timeout: 180_000 });
|
||||||
@@ -38,7 +37,6 @@ test.describe('Direct message flow', () => {
|
|||||||
test('shows friend and message actions on the search people list', async ({ createClient }) => {
|
test('shows friend and message actions on the search people list', async ({ createClient }) => {
|
||||||
const scenario = await createDmScenario(createClient);
|
const scenario = await createDmScenario(createClient);
|
||||||
|
|
||||||
await disableLastViewedChatResume(scenario.alice.page);
|
|
||||||
await scenario.alice.page.goto('/search', { waitUntil: 'domcontentloaded' });
|
await scenario.alice.page.goto('/search', { waitUntil: 'domcontentloaded' });
|
||||||
await expect(scenario.alice.page).toHaveURL(/\/search/, { timeout: 20_000 });
|
await expect(scenario.alice.page).toHaveURL(/\/search/, { timeout: 20_000 });
|
||||||
await expect(scenario.alice.page.locator('app-server-search')).toBeVisible({ timeout: 20_000 });
|
await expect(scenario.alice.page.locator('app-server-search')).toBeVisible({ timeout: 20_000 });
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ const NETSCAPE_LOOP_EXTENSION = Buffer.from([
|
|||||||
]);
|
]);
|
||||||
const CLIENT_LAUNCH_ARGS = ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'];
|
const CLIENT_LAUNCH_ARGS = ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'];
|
||||||
const VOICE_CHANNEL = 'General';
|
const VOICE_CHANNEL = 'General';
|
||||||
const AVATAR_SYNC_TIMEOUT_MS = 45_000;
|
|
||||||
|
|
||||||
test.describe('Profile avatar sync', () => {
|
test.describe('Profile avatar sync', () => {
|
||||||
test.describe.configure({ timeout: 240_000 });
|
test.describe.configure({ timeout: 240_000 });
|
||||||
@@ -599,7 +598,7 @@ async function expectSidebarAvatar(page: Page, displayName: string, expectedData
|
|||||||
|
|
||||||
return image.getAttribute('src');
|
return image.getAttribute('src');
|
||||||
}, {
|
}, {
|
||||||
timeout: AVATAR_SYNC_TIMEOUT_MS,
|
timeout: 20_000,
|
||||||
message: `${displayName} avatar src should update`
|
message: `${displayName} avatar src should update`
|
||||||
}).toBe(expectedDataUrl);
|
}).toBe(expectedDataUrl);
|
||||||
|
|
||||||
@@ -616,7 +615,7 @@ async function expectSidebarAvatar(page: Page, displayName: string, expectedData
|
|||||||
return img.complete && img.naturalWidth > 0 && img.naturalHeight > 0;
|
return img.complete && img.naturalWidth > 0 && img.naturalHeight > 0;
|
||||||
});
|
});
|
||||||
}, {
|
}, {
|
||||||
timeout: AVATAR_SYNC_TIMEOUT_MS,
|
timeout: 20_000,
|
||||||
message: `${displayName} avatar image should load`
|
message: `${displayName} avatar image should load`
|
||||||
}).toBe(true);
|
}).toBe(true);
|
||||||
}
|
}
|
||||||
@@ -636,7 +635,7 @@ async function expectChatMessageAvatar(page: Page, messageText: string, expected
|
|||||||
|
|
||||||
return image.getAttribute('src');
|
return image.getAttribute('src');
|
||||||
}, {
|
}, {
|
||||||
timeout: AVATAR_SYNC_TIMEOUT_MS,
|
timeout: 20_000,
|
||||||
message: `Chat message avatar for "${messageText}" should update`
|
message: `Chat message avatar for "${messageText}" should update`
|
||||||
}).toBe(expectedDataUrl);
|
}).toBe(expectedDataUrl);
|
||||||
}
|
}
|
||||||
@@ -663,7 +662,7 @@ async function expectVoiceControlsAvatar(page: Page, expectedDataUrl: string): P
|
|||||||
|
|
||||||
return image.getAttribute('src');
|
return image.getAttribute('src');
|
||||||
}, {
|
}, {
|
||||||
timeout: AVATAR_SYNC_TIMEOUT_MS,
|
timeout: 20_000,
|
||||||
message: 'Voice controls avatar should update'
|
message: 'Voice controls avatar should update'
|
||||||
}).toBe(expectedDataUrl);
|
}).toBe(expectedDataUrl);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,185 +0,0 @@
|
|||||||
import { type Page } from '@playwright/test';
|
|
||||||
import {
|
|
||||||
expect,
|
|
||||||
test,
|
|
||||||
type Client
|
|
||||||
} from '../../fixtures/multi-client';
|
|
||||||
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
|
||||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
|
||||||
import { RegisterPage } from '../../pages/register.page';
|
|
||||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
|
||||||
|
|
||||||
const PLUGIN_SOURCE_URL = 'http://localhost:4200/plugins/e2e-plugin-source.json';
|
|
||||||
const PLUGIN_TITLE = 'E2E All API Plugin';
|
|
||||||
const EDITED_MESSAGE = 'Plugin API edited message';
|
|
||||||
const ORIGINAL_MESSAGE = 'Plugin API original message';
|
|
||||||
const DELETED_MESSAGE = 'Plugin API deleted message';
|
|
||||||
const DELETED_MESSAGE_CONTENT = '[Message deleted]';
|
|
||||||
const PLUGIN_BOT_MESSAGE = 'Plugin bot message from all-api fixture';
|
|
||||||
const CUSTOM_EMBED_TEXT = 'E2E custom embed: Plugin API custom embed';
|
|
||||||
const SOUND_BOARD_TEXT = 'E2E soundboard ready';
|
|
||||||
const SOUND_BOARD_LABEL = 'E2E Soundboard';
|
|
||||||
const SOUND_BOARD_PLAYED_MESSAGE = 'E2E soundboard played Airhorn to voice channel';
|
|
||||||
const VOICE_CHANNEL = 'Plugin Voice';
|
|
||||||
|
|
||||||
test.describe('Plugin API multi-user runtime', () => {
|
|
||||||
test.describe.configure({ timeout: 180_000 });
|
|
||||||
|
|
||||||
test('runs chat, embed, soundboard, and profile APIs between two users', async ({ createClient }) => {
|
|
||||||
const scenario = await createPluginApiScenario(createClient);
|
|
||||||
|
|
||||||
await test.step('Install the server plugin as Alice', async () => {
|
|
||||||
await installGrantAndActivatePlugin(scenario.alice.page, true);
|
|
||||||
await closeSettingsModal(scenario.alice.page);
|
|
||||||
await expect(soundboardComposerButton(scenario.alice.page)).toBeVisible({ timeout: 20_000 });
|
|
||||||
await expect(scenario.alice.page.getByText(SOUND_BOARD_TEXT, { exact: true })).toBeVisible({ timeout: 20_000 });
|
|
||||||
await expect(scenario.alice.page.getByTestId('e2e-plugin-owned-dom')).toHaveAttribute('data-plugin-owner', 'e2e.all-api-plugin');
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step('Activate the server plugin for Bob as the embed/soundboard receiver', async () => {
|
|
||||||
await installGrantAndActivatePlugin(scenario.bob.page, false);
|
|
||||||
await closeSettingsModal(scenario.bob.page);
|
|
||||||
await expect(soundboardComposerButton(scenario.bob.page)).toBeVisible({ timeout: 20_000 });
|
|
||||||
await expect(scenario.bob.page.getByText(SOUND_BOARD_TEXT, { exact: true })).toBeVisible({ timeout: 20_000 });
|
|
||||||
await expect(scenario.bob.page.getByTestId('e2e-plugin-owned-dom')).toHaveAttribute('data-plugin-owner', 'e2e.all-api-plugin');
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step('Alice opens the plugin soundboard modal and plays a sound to voice', async () => {
|
|
||||||
await soundboardComposerButton(scenario.alice.page).click();
|
|
||||||
await expect(scenario.alice.page.getByRole('dialog', { name: SOUND_BOARD_LABEL })).toBeVisible({ timeout: 20_000 });
|
|
||||||
await expect(scenario.alice.page.getByTestId('e2e-soundboard-modal')).toHaveAttribute('data-plugin-owner', 'e2e.all-api-plugin');
|
|
||||||
await scenario.alice.page.getByRole('button', { name: 'Play airhorn to voice' }).click();
|
|
||||||
await expect(scenario.alice.page.getByTestId('e2e-soundboard-status')).toHaveText(SOUND_BOARD_PLAYED_MESSAGE, { timeout: 20_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step('Bob receives messages sent and edited by Alice through the plugin API', async () => {
|
|
||||||
await expect(scenario.bobMessages.getMessageItemByText(EDITED_MESSAGE)).toBeVisible({ timeout: 30_000 });
|
|
||||||
await expect(scenario.bobMessages.getMessageItemByText(ORIGINAL_MESSAGE)).toHaveCount(0);
|
|
||||||
await expect(scenario.bob.page.getByText('(edited)')).toBeVisible({ timeout: 20_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step('Bob sees plugin API deletion state and plugin-user messages', async () => {
|
|
||||||
await expect(scenario.bobMessages.getMessageItemByText(DELETED_MESSAGE_CONTENT)).toBeVisible({ timeout: 30_000 });
|
|
||||||
await expect(scenario.bobMessages.getMessageItemByText(DELETED_MESSAGE)).toHaveCount(0);
|
|
||||||
await expect(scenario.bobMessages.getMessageItemByText(PLUGIN_BOT_MESSAGE)).toBeVisible({ timeout: 30_000 });
|
|
||||||
await expect(scenario.bobMessages.getMessageItemByText(SOUND_BOARD_PLAYED_MESSAGE)).toBeVisible({ timeout: 30_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step('Bob renders Alice custom embed through the plugin embed API', async () => {
|
|
||||||
await expect(scenario.bob.page.getByTestId('plugin-message-embeds')).toContainText(CUSTOM_EMBED_TEXT, { timeout: 30_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step('Bob sees Alice profile name changed by the plugin API', async () => {
|
|
||||||
await expect(scenario.bobMessages.getMessageItemByText(EDITED_MESSAGE)).toContainText('Alice Plugin Renamed', { timeout: 30_000 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
interface PluginApiScenario {
|
|
||||||
alice: Client;
|
|
||||||
aliceRoom: ChatRoomPage;
|
|
||||||
bob: Client;
|
|
||||||
bobRoom: ChatRoomPage;
|
|
||||||
aliceMessages: ChatMessagesPage;
|
|
||||||
bobMessages: ChatMessagesPage;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createPluginApiScenario(createClient: () => Promise<Client>): Promise<PluginApiScenario> {
|
|
||||||
const suffix = uniqueName('plugin-api');
|
|
||||||
const serverName = `Plugin API Server ${suffix}`;
|
|
||||||
const alice = await createClient();
|
|
||||||
const bob = await createClient();
|
|
||||||
|
|
||||||
await registerUser(alice.page, `alice_${suffix}`, 'Alice');
|
|
||||||
await registerUser(bob.page, `bob_${suffix}`, 'Bob');
|
|
||||||
|
|
||||||
const aliceSearch = new ServerSearchPage(alice.page);
|
|
||||||
|
|
||||||
await aliceSearch.createServer(serverName, { description: 'Two-user plugin API E2E coverage' });
|
|
||||||
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 30_000 });
|
|
||||||
|
|
||||||
const aliceRoom = new ChatRoomPage(alice.page);
|
|
||||||
|
|
||||||
await aliceRoom.ensureVoiceChannelExists(VOICE_CHANNEL);
|
|
||||||
|
|
||||||
const bobSearch = new ServerSearchPage(bob.page);
|
|
||||||
|
|
||||||
await bobSearch.joinServerFromSearch(serverName);
|
|
||||||
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 30_000 });
|
|
||||||
|
|
||||||
const bobRoom = new ChatRoomPage(bob.page);
|
|
||||||
|
|
||||||
await aliceRoom.joinVoiceChannel(VOICE_CHANNEL);
|
|
||||||
await bobRoom.joinVoiceChannel(VOICE_CHANNEL);
|
|
||||||
await expect(aliceRoom.voiceControls).toBeVisible({ timeout: 30_000 });
|
|
||||||
await expect(bobRoom.voiceControls).toBeVisible({ timeout: 30_000 });
|
|
||||||
|
|
||||||
const aliceMessages = new ChatMessagesPage(alice.page);
|
|
||||||
const bobMessages = new ChatMessagesPage(bob.page);
|
|
||||||
|
|
||||||
await aliceMessages.waitForReady();
|
|
||||||
await bobMessages.waitForReady();
|
|
||||||
await expect(alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' })).toBeVisible({ timeout: 30_000 });
|
|
||||||
await expect(bob.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Alice' })).toBeVisible({ timeout: 30_000 });
|
|
||||||
|
|
||||||
return {
|
|
||||||
alice,
|
|
||||||
aliceRoom,
|
|
||||||
bob,
|
|
||||||
bobRoom,
|
|
||||||
aliceMessages,
|
|
||||||
bobMessages
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function registerUser(page: Page, username: string, displayName: string): Promise<void> {
|
|
||||||
const registerPage = new RegisterPage(page);
|
|
||||||
|
|
||||||
await registerPage.goto();
|
|
||||||
await registerPage.register(username, displayName, 'TestPass123!');
|
|
||||||
await expect(page).toHaveURL(/\/search/, { timeout: 30_000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function installGrantAndActivatePlugin(page: Page, installFromStore: boolean): Promise<void> {
|
|
||||||
await page.getByRole('button', { name: 'Plugins' }).click();
|
|
||||||
await expect(page).toHaveURL(/\/plugin-store/, { timeout: 20_000 });
|
|
||||||
await expect(page.getByTestId('plugin-store-page')).toBeVisible({ timeout: 20_000 });
|
|
||||||
|
|
||||||
if (installFromStore) {
|
|
||||||
await page.getByLabel('Plugin source manifest URL').fill(PLUGIN_SOURCE_URL);
|
|
||||||
await page.getByRole('button', { name: 'Add Source' }).click();
|
|
||||||
await expect(page.getByRole('heading', { name: PLUGIN_TITLE })).toBeVisible({ timeout: 20_000 });
|
|
||||||
await page.getByRole('button', { exact: true, name: /^(Install|Install to Server)$/ }).click();
|
|
||||||
await expect(page.getByRole('dialog', { name: PLUGIN_TITLE })).toBeVisible({ timeout: 10_000 });
|
|
||||||
await page.getByRole('button', { name: 'Install and Activate' }).click();
|
|
||||||
await expect(page.locator('article', { hasText: PLUGIN_TITLE }).getByText('Installed')).toBeVisible({ timeout: 20_000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Manage Plugins' }).click();
|
|
||||||
await expect(page.getByTestId('plugin-manager')).toBeVisible({ timeout: 20_000 });
|
|
||||||
await expect(page.locator('article', { hasText: PLUGIN_TITLE })).toBeVisible({ timeout: 20_000 });
|
|
||||||
await page.locator('article', { hasText: PLUGIN_TITLE })
|
|
||||||
.getByRole('button', { name: 'Select' })
|
|
||||||
.click();
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Grant all requested' }).click();
|
|
||||||
await page.getByRole('button', { name: 'Activate ready plugins' }).click();
|
|
||||||
await expect(page.locator('article', { hasText: PLUGIN_TITLE }).getByText('ready', { exact: true })).toBeVisible({ timeout: 30_000 });
|
|
||||||
await page.getByRole('button', { name: 'Logs' }).click();
|
|
||||||
await expect(page.getByText('all-api plugin completed')).toBeVisible({ timeout: 30_000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function closeSettingsModal(page: Page): Promise<void> {
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
await expect(page.getByTestId('plugin-manager')).toHaveCount(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function uniqueName(prefix: string): string {
|
|
||||||
return `${prefix}-${Date.now()}-${Math.random().toString(36)
|
|
||||||
.slice(2, 8)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function soundboardComposerButton(page: Page) {
|
|
||||||
return page.locator('app-chat-message-composer')
|
|
||||||
.getByRole('button', { exact: true, name: SOUND_BOARD_LABEL });
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import { expect, test } from '../../fixtures/multi-client';
|
|
||||||
import { RegisterPage } from '../../pages/register.page';
|
|
||||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
|
||||||
|
|
||||||
test.describe('Plugin manager UI', () => {
|
|
||||||
test.describe.configure({ timeout: 180_000 });
|
|
||||||
|
|
||||||
test('installs, grants, activates, and logs an all-API test plugin', async ({ createClient }) => {
|
|
||||||
const client = await createClient();
|
|
||||||
const { page } = client;
|
|
||||||
const suffix = Date.now();
|
|
||||||
const register = new RegisterPage(page);
|
|
||||||
const search = new ServerSearchPage(page);
|
|
||||||
|
|
||||||
await test.step('Register user and create server context', async () => {
|
|
||||||
await register.goto();
|
|
||||||
await register.register(`plugin_${suffix}`, 'Plugin Tester', 'TestPass123!');
|
|
||||||
await expect(page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
|
|
||||||
await search.createServer(`Plugin API Server ${suffix}`, {
|
|
||||||
description: 'Plugin manager UI E2E coverage'
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/room\//, { timeout: 30_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step('Open visible Plugins button', async () => {
|
|
||||||
await page.getByRole('button', { name: 'Plugins' }).click();
|
|
||||||
await expect(page).toHaveURL(/\/plugin-store/, { timeout: 10_000 });
|
|
||||||
await expect(page.getByTestId('plugin-store-page')).toBeVisible({ timeout: 10_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step('Install fixture plugin from source manifest', async () => {
|
|
||||||
await page.getByLabel('Plugin source manifest URL').fill('http://localhost:4200/plugins/e2e-plugin-source.json');
|
|
||||||
await page.getByRole('button', { name: 'Add Source' }).click();
|
|
||||||
await expect(page.getByRole('heading', { name: 'E2E All API Plugin' })).toBeVisible({ timeout: 15_000 });
|
|
||||||
await page.getByRole('button', { name: 'Readme' }).click();
|
|
||||||
await expect(page.getByText('Fixture plugin for Playwright coverage.')).toBeVisible({ timeout: 10_000 });
|
|
||||||
await page.getByRole('button', { exact: true, name: /^(Install|Install to Server)$/ }).click();
|
|
||||||
const installDialog = page.getByRole('dialog', { name: 'E2E All API Plugin' });
|
|
||||||
|
|
||||||
await expect(installDialog).toBeVisible({ timeout: 10_000 });
|
|
||||||
await expect(installDialog.getByText('Install to server', { exact: true })).toBeVisible();
|
|
||||||
await page.getByRole('button', { name: 'Install and Activate' }).click();
|
|
||||||
await expect(page.locator('article', { hasText: 'E2E All API Plugin' }).getByText('Installed')).toBeVisible({ timeout: 10_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step('Open plugin manager from the store page', async () => {
|
|
||||||
await page.getByRole('button', { name: 'Manage Plugins' }).click();
|
|
||||||
await expect(page.getByTestId('plugin-manager')).toBeVisible({ timeout: 10_000 });
|
|
||||||
await expect(page.getByTestId('plugin-manager').getByRole('heading', { name: 'Server plugins' })).toBeVisible();
|
|
||||||
await expect(page.getByText('E2E All API Plugin')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step('Grant capabilities and activate runtime', async () => {
|
|
||||||
const manager = page.getByTestId('plugin-manager');
|
|
||||||
const pluginCard = manager.locator('article', { hasText: 'E2E All API Plugin' });
|
|
||||||
|
|
||||||
await manager.getByRole('button', { name: 'Installed' }).click();
|
|
||||||
await expect(pluginCard).toBeVisible({ timeout: 10_000 });
|
|
||||||
await pluginCard.getByRole('button', { name: 'Select' }).click();
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Grant all requested' }).click();
|
|
||||||
await page.getByRole('button', { name: 'Activate ready plugins' }).click();
|
|
||||||
await expect(page.locator('article', { hasText: 'E2E All API Plugin' }).getByText('ready', { exact: true })).toBeVisible({ timeout: 20_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step('Verify plugin exercised APIs through logs and extension points', async () => {
|
|
||||||
const manager = page.getByTestId('plugin-manager');
|
|
||||||
|
|
||||||
await manager.getByRole('button', { name: 'Logs' }).click();
|
|
||||||
await expect(page.getByText('all-api plugin completed')).toBeVisible({ timeout: 20_000 });
|
|
||||||
await expect(page.getByText('all-api plugin ready')).toBeVisible({ timeout: 10_000 });
|
|
||||||
await manager.getByRole('button', { name: 'Extension points' }).click();
|
|
||||||
await expect(page.getByTestId('plugin-extension-counts')).toContainText('Settings pages');
|
|
||||||
await expect(page.getByTestId('plugin-extension-counts')).toContainText('Embed renderers');
|
|
||||||
await expect(page.getByTestId('plugin-extension-counts')).toContainText('1');
|
|
||||||
await expect(page.getByTestId('plugin-conflict-diagnostics')).toContainText(
|
|
||||||
'No duplicate route, action, embed, channel, panel, or settings contribution ids detected.'
|
|
||||||
);
|
|
||||||
|
|
||||||
await manager.getByRole('button', { exact: true, name: 'Requirements' }).click();
|
|
||||||
await expect(page.getByTestId('plugin-server-requirements')).toContainText('E2E All API Plugin');
|
|
||||||
await expect(page.getByTestId('plugin-server-requirements')).toContainText('enabled');
|
|
||||||
|
|
||||||
await manager.getByRole('button', { exact: true, name: 'Settings' }).click();
|
|
||||||
await expect(page.getByTestId('plugin-generated-settings')).toContainText('E2E settings contribution');
|
|
||||||
await expect(page.getByTestId('plugin-generated-settings')).toContainText('"enabled"');
|
|
||||||
|
|
||||||
await manager.getByRole('button', { exact: true, name: 'Docs' }).click();
|
|
||||||
await expect(page.getByTestId('plugin-installed-docs')).toContainText('Calls every public Toju plugin API surface');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,369 +0,0 @@
|
|||||||
import type { APIRequestContext, APIResponse } from '@playwright/test';
|
|
||||||
import WebSocket from 'ws';
|
|
||||||
import { expect, test } from '../../fixtures/multi-client';
|
|
||||||
import {
|
|
||||||
getPluginApiTestEvent,
|
|
||||||
readPluginApiTestManifest,
|
|
||||||
TEST_PLUGIN_ID,
|
|
||||||
TEST_PLUGIN_P2P_EVENT,
|
|
||||||
TEST_PLUGIN_RELAY_EVENT
|
|
||||||
} from '../../helpers/plugin-api-test-fixture';
|
|
||||||
|
|
||||||
const OWNER_USER_ID = 'plugin-api-owner';
|
|
||||||
|
|
||||||
interface CreatedServerResponse {
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PluginRequirementResponse {
|
|
||||||
requirement: {
|
|
||||||
pluginId: string;
|
|
||||||
reason?: string;
|
|
||||||
status: string;
|
|
||||||
versionRange?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PluginEventDefinitionResponse {
|
|
||||||
eventDefinition: {
|
|
||||||
direction: string;
|
|
||||||
eventName: string;
|
|
||||||
maxPayloadBytes: number;
|
|
||||||
pluginId: string;
|
|
||||||
scope: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PluginSnapshotResponse {
|
|
||||||
eventDefinitions: PluginEventDefinitionResponse['eventDefinition'][];
|
|
||||||
requirements: PluginRequirementResponse['requirement'][];
|
|
||||||
serverId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SocketMessage {
|
|
||||||
[key: string]: unknown;
|
|
||||||
type?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TestSocket {
|
|
||||||
close: () => Promise<void>;
|
|
||||||
messages: SocketMessage[];
|
|
||||||
send: (message: SocketMessage) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('Plugin support API', () => {
|
|
||||||
test('covers plugin requirement, event, data, and websocket APIs with the fixture plugin', async ({ request, testServer }) => {
|
|
||||||
const manifest = await readPluginApiTestManifest();
|
|
||||||
const server = await createServer(request, testServer.url, `Plugin API ${Date.now()}`);
|
|
||||||
const relayEvent = getPluginApiTestEvent(manifest, TEST_PLUGIN_RELAY_EVENT);
|
|
||||||
const p2pEvent = getPluginApiTestEvent(manifest, TEST_PLUGIN_P2P_EVENT);
|
|
||||||
const pluginsApi = `${testServer.url}/api/servers/${encodeURIComponent(server.id)}/plugins`;
|
|
||||||
|
|
||||||
await test.step('Initial snapshot is empty', async () => {
|
|
||||||
const snapshot = await expectJson<PluginSnapshotResponse>(await request.get(pluginsApi));
|
|
||||||
|
|
||||||
expect(snapshot).toEqual(expect.objectContaining({
|
|
||||||
eventDefinitions: [],
|
|
||||||
requirements: [],
|
|
||||||
serverId: server.id
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step('Requirement API enforces server management permission', async () => {
|
|
||||||
const response = await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, {
|
|
||||||
data: {
|
|
||||||
actorUserId: 'not-the-owner',
|
|
||||||
status: 'required'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const body = await expectJson<{ errorCode: string }>(response, 403);
|
|
||||||
|
|
||||||
expect(body.errorCode).toBe('NOT_AUTHORIZED');
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step('Requirement and event definition APIs persist the test plugin contract', async () => {
|
|
||||||
const requirement = await expectJson<PluginRequirementResponse>(await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, {
|
|
||||||
data: {
|
|
||||||
actorUserId: OWNER_USER_ID,
|
|
||||||
reason: manifest.description,
|
|
||||||
status: 'required',
|
|
||||||
versionRange: `^${manifest.version}`
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
expect(requirement.requirement).toEqual(expect.objectContaining({
|
|
||||||
pluginId: TEST_PLUGIN_ID,
|
|
||||||
reason: manifest.description,
|
|
||||||
status: 'required',
|
|
||||||
versionRange: `^${manifest.version}`
|
|
||||||
}));
|
|
||||||
|
|
||||||
const relayDefinition = await upsertEventDefinition(request, pluginsApi, relayEvent);
|
|
||||||
const p2pDefinition = await upsertEventDefinition(request, pluginsApi, p2pEvent);
|
|
||||||
|
|
||||||
expect(relayDefinition.eventDefinition).toEqual(expect.objectContaining({
|
|
||||||
direction: 'serverRelay',
|
|
||||||
eventName: TEST_PLUGIN_RELAY_EVENT,
|
|
||||||
pluginId: TEST_PLUGIN_ID,
|
|
||||||
scope: 'server'
|
|
||||||
}));
|
|
||||||
|
|
||||||
expect(p2pDefinition.eventDefinition).toEqual(expect.objectContaining({
|
|
||||||
direction: 'p2pHint',
|
|
||||||
eventName: TEST_PLUGIN_P2P_EVENT,
|
|
||||||
pluginId: TEST_PLUGIN_ID,
|
|
||||||
scope: 'user'
|
|
||||||
}));
|
|
||||||
|
|
||||||
const snapshot = await expectJson<PluginSnapshotResponse>(await request.get(pluginsApi));
|
|
||||||
|
|
||||||
expect(snapshot.requirements.map((entry) => entry.pluginId)).toEqual([TEST_PLUGIN_ID]);
|
|
||||||
expect(snapshot.eventDefinitions.map((entry) => entry.eventName).sort()).toEqual([TEST_PLUGIN_P2P_EVENT, TEST_PLUGIN_RELAY_EVENT]);
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step('Plugin data API refuses arbitrary server persistence', async () => {
|
|
||||||
const stored = await expectJson<{ errorCode: string }>(await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/data/settings`, {
|
|
||||||
data: {
|
|
||||||
actorUserId: OWNER_USER_ID,
|
|
||||||
schemaVersion: 1,
|
|
||||||
scope: 'server',
|
|
||||||
value: {
|
|
||||||
enabled: true,
|
|
||||||
pluginVersion: manifest.version
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}), 410);
|
|
||||||
|
|
||||||
expect(stored.errorCode).toBe('PLUGIN_DATA_DISABLED');
|
|
||||||
|
|
||||||
const listed = await expectJson<{ errorCode: string }>(await request.get(`${pluginsApi}/${TEST_PLUGIN_ID}/data`, {
|
|
||||||
params: {
|
|
||||||
key: 'settings',
|
|
||||||
scope: 'server',
|
|
||||||
userId: OWNER_USER_ID
|
|
||||||
}
|
|
||||||
}), 410);
|
|
||||||
|
|
||||||
expect(listed.errorCode).toBe('PLUGIN_DATA_DISABLED');
|
|
||||||
|
|
||||||
const afterDelete = await expectJson<{ errorCode: string }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/data/settings`, {
|
|
||||||
data: {
|
|
||||||
actorUserId: OWNER_USER_ID,
|
|
||||||
scope: 'server'
|
|
||||||
}
|
|
||||||
}), 410);
|
|
||||||
|
|
||||||
expect(afterDelete.errorCode).toBe('PLUGIN_DATA_DISABLED');
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step('WebSocket plugin API sends snapshots, relays server events, and rejects p2p relays', async () => {
|
|
||||||
const alice = await openTestSocket(testServer.url);
|
|
||||||
const bob = await openTestSocket(testServer.url);
|
|
||||||
|
|
||||||
try {
|
|
||||||
alice.send({ type: 'identify', oderId: OWNER_USER_ID, displayName: 'Plugin Owner' });
|
|
||||||
bob.send({ type: 'identify', oderId: 'plugin-api-peer', displayName: 'Plugin Peer' });
|
|
||||||
alice.send({ type: 'join_server', serverId: server.id });
|
|
||||||
bob.send({ type: 'join_server', serverId: server.id });
|
|
||||||
|
|
||||||
const aliceSnapshot = await waitForSocketMessage(alice, (message) => message.type === 'plugin_requirements');
|
|
||||||
const bobSnapshot = await waitForSocketMessage(bob, (message) => message.type === 'plugin_requirements');
|
|
||||||
const bobEventNames = (bobSnapshot['snapshot'] as PluginSnapshotResponse).eventDefinitions
|
|
||||||
.map((entry) => entry.eventName)
|
|
||||||
.sort();
|
|
||||||
|
|
||||||
expect((aliceSnapshot['snapshot'] as PluginSnapshotResponse).requirements[0]?.pluginId).toBe(TEST_PLUGIN_ID);
|
|
||||||
expect(bobEventNames).toEqual([TEST_PLUGIN_P2P_EVENT, TEST_PLUGIN_RELAY_EVENT]);
|
|
||||||
|
|
||||||
alice.send({
|
|
||||||
type: 'plugin_event',
|
|
||||||
eventId: 'relay-event-1',
|
|
||||||
eventName: TEST_PLUGIN_RELAY_EVENT,
|
|
||||||
payload: { message: 'hello from fixture plugin' },
|
|
||||||
pluginId: TEST_PLUGIN_ID,
|
|
||||||
serverId: server.id,
|
|
||||||
sourcePluginUserId: 'fixture-plugin-user'
|
|
||||||
});
|
|
||||||
|
|
||||||
const relayedEvent = await waitForSocketMessage(bob, (message) => message.type === 'plugin_event');
|
|
||||||
|
|
||||||
expect(relayedEvent).toEqual(expect.objectContaining({
|
|
||||||
eventId: 'relay-event-1',
|
|
||||||
eventName: TEST_PLUGIN_RELAY_EVENT,
|
|
||||||
pluginId: TEST_PLUGIN_ID,
|
|
||||||
serverId: server.id,
|
|
||||||
sourcePluginUserId: 'fixture-plugin-user',
|
|
||||||
sourceUserId: OWNER_USER_ID
|
|
||||||
}));
|
|
||||||
|
|
||||||
expect(relayedEvent['payload']).toEqual({ message: 'hello from fixture plugin' });
|
|
||||||
expect(typeof relayedEvent['emittedAt']).toBe('number');
|
|
||||||
|
|
||||||
alice.send({
|
|
||||||
type: 'plugin_event',
|
|
||||||
eventId: 'p2p-event-1',
|
|
||||||
eventName: TEST_PLUGIN_P2P_EVENT,
|
|
||||||
payload: { hint: true },
|
|
||||||
pluginId: TEST_PLUGIN_ID,
|
|
||||||
serverId: server.id
|
|
||||||
});
|
|
||||||
|
|
||||||
const p2pError = await waitForSocketMessage(
|
|
||||||
alice,
|
|
||||||
(message) => message.type === 'plugin_error' && message['eventId'] === 'p2p-event-1'
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(p2pError['code']).toBe('PLUGIN_EVENT_NOT_RELAYABLE');
|
|
||||||
|
|
||||||
alice.send({
|
|
||||||
type: 'plugin_event',
|
|
||||||
eventId: 'missing-event-1',
|
|
||||||
eventName: 'e2e:missing',
|
|
||||||
payload: {},
|
|
||||||
pluginId: TEST_PLUGIN_ID,
|
|
||||||
serverId: server.id
|
|
||||||
});
|
|
||||||
|
|
||||||
const missingError = await waitForSocketMessage(
|
|
||||||
alice,
|
|
||||||
(message) => message.type === 'plugin_error' && message['eventId'] === 'missing-event-1'
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(missingError['code']).toBe('PLUGIN_EVENT_NOT_REGISTERED');
|
|
||||||
} finally {
|
|
||||||
await Promise.all([alice.close(), bob.close()]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step('Delete APIs remove event definitions and requirements', async () => {
|
|
||||||
await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/events/${TEST_PLUGIN_RELAY_EVENT}`, {
|
|
||||||
data: { actorUserId: OWNER_USER_ID }
|
|
||||||
}));
|
|
||||||
|
|
||||||
await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/events/${TEST_PLUGIN_P2P_EVENT}`, {
|
|
||||||
data: { actorUserId: OWNER_USER_ID }
|
|
||||||
}));
|
|
||||||
|
|
||||||
await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, {
|
|
||||||
data: { actorUserId: OWNER_USER_ID }
|
|
||||||
}));
|
|
||||||
|
|
||||||
const snapshot = await expectJson<PluginSnapshotResponse>(await request.get(pluginsApi));
|
|
||||||
|
|
||||||
expect(snapshot.eventDefinitions).toEqual([]);
|
|
||||||
expect(snapshot.requirements).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
async function createServer(
|
|
||||||
request: APIRequestContext,
|
|
||||||
baseUrl: string,
|
|
||||||
serverName: string
|
|
||||||
): Promise<CreatedServerResponse> {
|
|
||||||
const response = await request.post(`${baseUrl}/api/servers`, {
|
|
||||||
data: {
|
|
||||||
channels: [
|
|
||||||
{
|
|
||||||
id: 'general-text',
|
|
||||||
name: 'general',
|
|
||||||
position: 0,
|
|
||||||
type: 'text'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
description: 'Server for plugin API E2E coverage',
|
|
||||||
id: `plugin-api-${Date.now()}`,
|
|
||||||
isPrivate: false,
|
|
||||||
name: serverName,
|
|
||||||
ownerId: OWNER_USER_ID,
|
|
||||||
ownerPublicKey: 'plugin-api-owner-public-key',
|
|
||||||
tags: ['plugins']
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return await expectJson<CreatedServerResponse>(response, 201);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function upsertEventDefinition(
|
|
||||||
request: APIRequestContext,
|
|
||||||
pluginsApi: string,
|
|
||||||
eventDefinition: ReturnType<typeof getPluginApiTestEvent>
|
|
||||||
): Promise<PluginEventDefinitionResponse> {
|
|
||||||
return await expectJson<PluginEventDefinitionResponse>(await request.put(
|
|
||||||
`${pluginsApi}/${TEST_PLUGIN_ID}/events/${encodeURIComponent(eventDefinition.eventName)}`,
|
|
||||||
{
|
|
||||||
data: {
|
|
||||||
actorUserId: OWNER_USER_ID,
|
|
||||||
direction: eventDefinition.direction,
|
|
||||||
maxPayloadBytes: eventDefinition.maxPayloadBytes,
|
|
||||||
schemaJson: '{"type":"object"}',
|
|
||||||
scope: eventDefinition.scope
|
|
||||||
}
|
|
||||||
}
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectJson<T>(response: APIResponse, status = 200): Promise<T> {
|
|
||||||
expect(response.status()).toBe(status);
|
|
||||||
|
|
||||||
return await response.json() as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openTestSocket(baseUrl: string): Promise<TestSocket> {
|
|
||||||
const socketUrl = baseUrl.replace(/^http/, 'ws');
|
|
||||||
const socket = new WebSocket(socketUrl);
|
|
||||||
const messages: SocketMessage[] = [];
|
|
||||||
|
|
||||||
socket.on('message', (data) => {
|
|
||||||
messages.push(JSON.parse(data.toString()) as SocketMessage);
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once('open', () => resolve());
|
|
||||||
socket.once('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitForSocketMessage({ messages, send: () => {}, close: async () => {} }, (message) => message.type === 'connected');
|
|
||||||
|
|
||||||
return {
|
|
||||||
close: async () => {
|
|
||||||
if (socket.readyState === WebSocket.CLOSED) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
socket.once('close', () => resolve());
|
|
||||||
socket.close();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
messages,
|
|
||||||
send: (message: SocketMessage) => {
|
|
||||||
socket.send(JSON.stringify(message));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForSocketMessage(
|
|
||||||
socket: Pick<TestSocket, 'messages'>,
|
|
||||||
predicate: (message: SocketMessage) => boolean,
|
|
||||||
timeoutMs = 10_000
|
|
||||||
): Promise<SocketMessage> {
|
|
||||||
const startedAt = Date.now();
|
|
||||||
|
|
||||||
return await new Promise((resolve, reject) => {
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
const message = socket.messages.find(predicate);
|
|
||||||
|
|
||||||
if (message) {
|
|
||||||
clearInterval(interval);
|
|
||||||
resolve(message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Date.now() - startedAt > timeoutMs) {
|
|
||||||
clearInterval(interval);
|
|
||||||
reject(new Error('Timed out waiting for websocket message'));
|
|
||||||
}
|
|
||||||
}, 25);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -28,6 +28,5 @@ Electron main-process package for MetoYou / Toju. This directory owns desktop bo
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- When adding a renderer-facing capability, update the Electron implementation, `preload.ts`, and the renderer bridge in `toju-app/` together.
|
- When adding a renderer-facing capability, update the Electron implementation, `preload.ts`, and the renderer bridge in `toju-app/` together.
|
||||||
- Plugin client data is stored in the local Electron SQLite database in the dedicated `plugin_data` table. Renderer plugins reach it through CQRS commands/queries exposed by the preload bridge; the signal server must not be used for arbitrary plugin data persistence.
|
|
||||||
- Treat `dist/electron/` and `dist-electron/` as generated output.
|
- Treat `dist/electron/` and `dist-electron/` as generated output.
|
||||||
- See [AGENTS.md](AGENTS.md) for package-level editing rules.
|
- See [AGENTS.md](AGENTS.md) for package-level editing rules.
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ import {
|
|||||||
ReactionEntity,
|
ReactionEntity,
|
||||||
BanEntity,
|
BanEntity,
|
||||||
AttachmentEntity,
|
AttachmentEntity,
|
||||||
MetaEntity,
|
MetaEntity
|
||||||
PluginDataEntity
|
|
||||||
} from '../../../entities';
|
} from '../../../entities';
|
||||||
|
|
||||||
export async function handleClearAllData(dataSource: DataSource): Promise<void> {
|
export async function handleClearAllData(dataSource: DataSource): Promise<void> {
|
||||||
@@ -28,5 +27,4 @@ export async function handleClearAllData(dataSource: DataSource): Promise<void>
|
|||||||
await dataSource.getRepository(BanEntity).clear();
|
await dataSource.getRepository(BanEntity).clear();
|
||||||
await dataSource.getRepository(AttachmentEntity).clear();
|
await dataSource.getRepository(AttachmentEntity).clear();
|
||||||
await dataSource.getRepository(MetaEntity).clear();
|
await dataSource.getRepository(MetaEntity).clear();
|
||||||
await dataSource.getRepository(PluginDataEntity).clear();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import { DataSource } from 'typeorm';
|
|
||||||
import { PluginDataEntity } from '../../../entities';
|
|
||||||
import { DeletePluginDataCommand } from '../../types';
|
|
||||||
|
|
||||||
export async function handleDeletePluginData(command: DeletePluginDataCommand, dataSource: DataSource): Promise<void> {
|
|
||||||
const { payload } = command;
|
|
||||||
|
|
||||||
await dataSource.getRepository(PluginDataEntity).delete({
|
|
||||||
key: payload.key,
|
|
||||||
pluginId: payload.pluginId,
|
|
||||||
scope: payload.scope,
|
|
||||||
serverId: payload.serverId ?? ''
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { DataSource } from 'typeorm';
|
|
||||||
import { MetaEntity } from '../../../entities';
|
|
||||||
import { SaveMetaCommand } from '../../types';
|
|
||||||
|
|
||||||
export async function handleSaveMeta(command: SaveMetaCommand, dataSource: DataSource): Promise<void> {
|
|
||||||
await dataSource.getRepository(MetaEntity).save({
|
|
||||||
key: command.payload.key,
|
|
||||||
value: command.payload.value
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { DataSource } from 'typeorm';
|
|
||||||
import { PluginDataEntity } from '../../../entities';
|
|
||||||
import { SavePluginDataCommand } from '../../types';
|
|
||||||
|
|
||||||
export async function handleSavePluginData(command: SavePluginDataCommand, dataSource: DataSource): Promise<void> {
|
|
||||||
const { payload } = command;
|
|
||||||
|
|
||||||
await dataSource.getRepository(PluginDataEntity).save({
|
|
||||||
key: payload.key,
|
|
||||||
pluginId: payload.pluginId,
|
|
||||||
scope: payload.scope,
|
|
||||||
serverId: payload.serverId ?? '',
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
valueJson: JSON.stringify(payload.value ?? null)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -18,10 +18,7 @@ import {
|
|||||||
SaveBanCommand,
|
SaveBanCommand,
|
||||||
RemoveBanCommand,
|
RemoveBanCommand,
|
||||||
SaveAttachmentCommand,
|
SaveAttachmentCommand,
|
||||||
DeleteAttachmentsForMessageCommand,
|
DeleteAttachmentsForMessageCommand
|
||||||
SavePluginDataCommand,
|
|
||||||
DeletePluginDataCommand,
|
|
||||||
SaveMetaCommand
|
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { handleSaveMessage } from './handlers/saveMessage';
|
import { handleSaveMessage } from './handlers/saveMessage';
|
||||||
import { handleDeleteMessage } from './handlers/deleteMessage';
|
import { handleDeleteMessage } from './handlers/deleteMessage';
|
||||||
@@ -39,9 +36,6 @@ import { handleSaveBan } from './handlers/saveBan';
|
|||||||
import { handleRemoveBan } from './handlers/removeBan';
|
import { handleRemoveBan } from './handlers/removeBan';
|
||||||
import { handleSaveAttachment } from './handlers/saveAttachment';
|
import { handleSaveAttachment } from './handlers/saveAttachment';
|
||||||
import { handleDeleteAttachmentsForMessage } from './handlers/deleteAttachmentsForMessage';
|
import { handleDeleteAttachmentsForMessage } from './handlers/deleteAttachmentsForMessage';
|
||||||
import { handleSavePluginData } from './handlers/savePluginData';
|
|
||||||
import { handleDeletePluginData } from './handlers/deletePluginData';
|
|
||||||
import { handleSaveMeta } from './handlers/saveMeta';
|
|
||||||
import { handleClearAllData } from './handlers/clearAllData';
|
import { handleClearAllData } from './handlers/clearAllData';
|
||||||
|
|
||||||
export const buildCommandHandlers = (dataSource: DataSource): Record<CommandTypeKey, (command: Command) => Promise<unknown>> => ({
|
export const buildCommandHandlers = (dataSource: DataSource): Record<CommandTypeKey, (command: Command) => Promise<unknown>> => ({
|
||||||
@@ -61,8 +55,5 @@ export const buildCommandHandlers = (dataSource: DataSource): Record<CommandType
|
|||||||
[CommandType.RemoveBan]: (cmd) => handleRemoveBan(cmd as RemoveBanCommand, dataSource),
|
[CommandType.RemoveBan]: (cmd) => handleRemoveBan(cmd as RemoveBanCommand, dataSource),
|
||||||
[CommandType.SaveAttachment]: (cmd) => handleSaveAttachment(cmd as SaveAttachmentCommand, dataSource),
|
[CommandType.SaveAttachment]: (cmd) => handleSaveAttachment(cmd as SaveAttachmentCommand, dataSource),
|
||||||
[CommandType.DeleteAttachmentsForMessage]: (cmd) => handleDeleteAttachmentsForMessage(cmd as DeleteAttachmentsForMessageCommand, dataSource),
|
[CommandType.DeleteAttachmentsForMessage]: (cmd) => handleDeleteAttachmentsForMessage(cmd as DeleteAttachmentsForMessageCommand, dataSource),
|
||||||
[CommandType.SavePluginData]: (cmd) => handleSavePluginData(cmd as SavePluginDataCommand, dataSource),
|
|
||||||
[CommandType.DeletePluginData]: (cmd) => handleDeletePluginData(cmd as DeletePluginDataCommand, dataSource),
|
|
||||||
[CommandType.SaveMeta]: (cmd) => handleSaveMeta(cmd as SaveMetaCommand, dataSource),
|
|
||||||
[CommandType.ClearAllData]: () => handleClearAllData(dataSource)
|
[CommandType.ClearAllData]: () => handleClearAllData(dataSource)
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import { DataSource } from 'typeorm';
|
|
||||||
import { MetaEntity } from '../../../entities';
|
|
||||||
import { GetMetaQuery } from '../../types';
|
|
||||||
|
|
||||||
export async function handleGetMeta(query: GetMetaQuery, dataSource: DataSource): Promise<string | null> {
|
|
||||||
const meta = await dataSource.getRepository(MetaEntity).findOne({
|
|
||||||
where: { key: query.payload.key }
|
|
||||||
});
|
|
||||||
|
|
||||||
return meta?.value ?? null;
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { DataSource } from 'typeorm';
|
|
||||||
import { PluginDataEntity } from '../../../entities';
|
|
||||||
import { GetPluginDataQuery } from '../../types';
|
|
||||||
|
|
||||||
export async function handleGetPluginData(query: GetPluginDataQuery, dataSource: DataSource): Promise<unknown> {
|
|
||||||
const { payload } = query;
|
|
||||||
const record = await dataSource.getRepository(PluginDataEntity).findOne({
|
|
||||||
where: {
|
|
||||||
key: payload.key,
|
|
||||||
pluginId: payload.pluginId,
|
|
||||||
scope: payload.scope,
|
|
||||||
serverId: payload.serverId ?? ''
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!record) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return JSON.parse(record.valueJson) as unknown;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,12 +8,11 @@ import {
|
|||||||
GetMessageByIdQuery,
|
GetMessageByIdQuery,
|
||||||
GetReactionsForMessageQuery,
|
GetReactionsForMessageQuery,
|
||||||
GetUserQuery,
|
GetUserQuery,
|
||||||
|
GetCurrentUserIdQuery,
|
||||||
GetRoomQuery,
|
GetRoomQuery,
|
||||||
GetBansForRoomQuery,
|
GetBansForRoomQuery,
|
||||||
IsUserBannedQuery,
|
IsUserBannedQuery,
|
||||||
GetAttachmentsForMessageQuery,
|
GetAttachmentsForMessageQuery
|
||||||
GetPluginDataQuery,
|
|
||||||
GetMetaQuery
|
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { handleGetMessages } from './handlers/getMessages';
|
import { handleGetMessages } from './handlers/getMessages';
|
||||||
import { handleGetMessagesSince } from './handlers/getMessagesSince';
|
import { handleGetMessagesSince } from './handlers/getMessagesSince';
|
||||||
@@ -29,8 +28,6 @@ import { handleGetBansForRoom } from './handlers/getBansForRoom';
|
|||||||
import { handleIsUserBanned } from './handlers/isUserBanned';
|
import { handleIsUserBanned } from './handlers/isUserBanned';
|
||||||
import { handleGetAttachmentsForMessage } from './handlers/getAttachmentsForMessage';
|
import { handleGetAttachmentsForMessage } from './handlers/getAttachmentsForMessage';
|
||||||
import { handleGetAllAttachments } from './handlers/getAllAttachments';
|
import { handleGetAllAttachments } from './handlers/getAllAttachments';
|
||||||
import { handleGetPluginData } from './handlers/getPluginData';
|
|
||||||
import { handleGetMeta } from './handlers/getMeta';
|
|
||||||
|
|
||||||
export const buildQueryHandlers = (dataSource: DataSource): Record<QueryTypeKey, (query: Query) => Promise<unknown>> => ({
|
export const buildQueryHandlers = (dataSource: DataSource): Record<QueryTypeKey, (query: Query) => Promise<unknown>> => ({
|
||||||
[QueryType.GetMessages]: (query) => handleGetMessages(query as GetMessagesQuery, dataSource),
|
[QueryType.GetMessages]: (query) => handleGetMessages(query as GetMessagesQuery, dataSource),
|
||||||
@@ -46,7 +43,5 @@ export const buildQueryHandlers = (dataSource: DataSource): Record<QueryTypeKey,
|
|||||||
[QueryType.GetBansForRoom]: (query) => handleGetBansForRoom(query as GetBansForRoomQuery, dataSource),
|
[QueryType.GetBansForRoom]: (query) => handleGetBansForRoom(query as GetBansForRoomQuery, dataSource),
|
||||||
[QueryType.IsUserBanned]: (query) => handleIsUserBanned(query as IsUserBannedQuery, dataSource),
|
[QueryType.IsUserBanned]: (query) => handleIsUserBanned(query as IsUserBannedQuery, dataSource),
|
||||||
[QueryType.GetAttachmentsForMessage]: (query) => handleGetAttachmentsForMessage(query as GetAttachmentsForMessageQuery, dataSource),
|
[QueryType.GetAttachmentsForMessage]: (query) => handleGetAttachmentsForMessage(query as GetAttachmentsForMessageQuery, dataSource),
|
||||||
[QueryType.GetAllAttachments]: () => handleGetAllAttachments(dataSource),
|
[QueryType.GetAllAttachments]: () => handleGetAllAttachments(dataSource)
|
||||||
[QueryType.GetPluginData]: (query) => handleGetPluginData(query as GetPluginDataQuery, dataSource),
|
|
||||||
[QueryType.GetMeta]: (query) => handleGetMeta(query as GetMetaQuery, dataSource)
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,9 +15,6 @@ export const CommandType = {
|
|||||||
RemoveBan: 'remove-ban',
|
RemoveBan: 'remove-ban',
|
||||||
SaveAttachment: 'save-attachment',
|
SaveAttachment: 'save-attachment',
|
||||||
DeleteAttachmentsForMessage: 'delete-attachments-for-message',
|
DeleteAttachmentsForMessage: 'delete-attachments-for-message',
|
||||||
SavePluginData: 'save-plugin-data',
|
|
||||||
DeletePluginData: 'delete-plugin-data',
|
|
||||||
SaveMeta: 'save-meta',
|
|
||||||
ClearAllData: 'clear-all-data'
|
ClearAllData: 'clear-all-data'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -37,9 +34,7 @@ export const QueryType = {
|
|||||||
GetBansForRoom: 'get-bans-for-room',
|
GetBansForRoom: 'get-bans-for-room',
|
||||||
IsUserBanned: 'is-user-banned',
|
IsUserBanned: 'is-user-banned',
|
||||||
GetAttachmentsForMessage: 'get-attachments-for-message',
|
GetAttachmentsForMessage: 'get-attachments-for-message',
|
||||||
GetAllAttachments: 'get-all-attachments',
|
GetAllAttachments: 'get-all-attachments'
|
||||||
GetPluginData: 'get-plugin-data',
|
|
||||||
GetMeta: 'get-meta'
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type QueryTypeKey = typeof QueryType[keyof typeof QueryType];
|
export type QueryTypeKey = typeof QueryType[keyof typeof QueryType];
|
||||||
@@ -177,16 +172,6 @@ export interface AttachmentPayload {
|
|||||||
savedPath?: string;
|
savedPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PluginDataScopePayload = 'local' | 'server';
|
|
||||||
|
|
||||||
export interface PluginDataPayload {
|
|
||||||
key: string;
|
|
||||||
pluginId: string;
|
|
||||||
scope: PluginDataScopePayload;
|
|
||||||
serverId?: string;
|
|
||||||
value: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SaveMessageCommand { type: typeof CommandType.SaveMessage; payload: { message: MessagePayload } }
|
export interface SaveMessageCommand { type: typeof CommandType.SaveMessage; payload: { message: MessagePayload } }
|
||||||
export interface DeleteMessageCommand { type: typeof CommandType.DeleteMessage; payload: { messageId: string } }
|
export interface DeleteMessageCommand { type: typeof CommandType.DeleteMessage; payload: { messageId: string } }
|
||||||
export interface UpdateMessageCommand { type: typeof CommandType.UpdateMessage; payload: { messageId: string; updates: Partial<MessagePayload> } }
|
export interface UpdateMessageCommand { type: typeof CommandType.UpdateMessage; payload: { messageId: string; updates: Partial<MessagePayload> } }
|
||||||
@@ -203,9 +188,6 @@ export interface SaveBanCommand { type: typeof CommandType.SaveBan; payload: { b
|
|||||||
export interface RemoveBanCommand { type: typeof CommandType.RemoveBan; payload: { oderId: string } }
|
export interface RemoveBanCommand { type: typeof CommandType.RemoveBan; payload: { oderId: string } }
|
||||||
export interface SaveAttachmentCommand { type: typeof CommandType.SaveAttachment; payload: { attachment: AttachmentPayload } }
|
export interface SaveAttachmentCommand { type: typeof CommandType.SaveAttachment; payload: { attachment: AttachmentPayload } }
|
||||||
export interface DeleteAttachmentsForMessageCommand { type: typeof CommandType.DeleteAttachmentsForMessage; payload: { messageId: string } }
|
export interface DeleteAttachmentsForMessageCommand { type: typeof CommandType.DeleteAttachmentsForMessage; payload: { messageId: string } }
|
||||||
export interface SavePluginDataCommand { type: typeof CommandType.SavePluginData; payload: PluginDataPayload }
|
|
||||||
export interface DeletePluginDataCommand { type: typeof CommandType.DeletePluginData; payload: Omit<PluginDataPayload, 'value'> }
|
|
||||||
export interface SaveMetaCommand { type: typeof CommandType.SaveMeta; payload: { key: string; value: string | null } }
|
|
||||||
export interface ClearAllDataCommand { type: typeof CommandType.ClearAllData; payload: Record<string, never> }
|
export interface ClearAllDataCommand { type: typeof CommandType.ClearAllData; payload: Record<string, never> }
|
||||||
|
|
||||||
export type Command =
|
export type Command =
|
||||||
@@ -225,9 +207,6 @@ export type Command =
|
|||||||
| RemoveBanCommand
|
| RemoveBanCommand
|
||||||
| SaveAttachmentCommand
|
| SaveAttachmentCommand
|
||||||
| DeleteAttachmentsForMessageCommand
|
| DeleteAttachmentsForMessageCommand
|
||||||
| SavePluginDataCommand
|
|
||||||
| DeletePluginDataCommand
|
|
||||||
| SaveMetaCommand
|
|
||||||
| ClearAllDataCommand;
|
| ClearAllDataCommand;
|
||||||
|
|
||||||
export interface GetMessagesQuery { type: typeof QueryType.GetMessages; payload: { roomId: string; limit?: number; offset?: number } }
|
export interface GetMessagesQuery { type: typeof QueryType.GetMessages; payload: { roomId: string; limit?: number; offset?: number } }
|
||||||
@@ -244,8 +223,6 @@ export interface GetBansForRoomQuery { type: typeof QueryType.GetBansForRoom; pa
|
|||||||
export interface IsUserBannedQuery { type: typeof QueryType.IsUserBanned; payload: { userId: string; roomId: string } }
|
export interface IsUserBannedQuery { type: typeof QueryType.IsUserBanned; payload: { userId: string; roomId: string } }
|
||||||
export interface GetAttachmentsForMessageQuery { type: typeof QueryType.GetAttachmentsForMessage; payload: { messageId: string } }
|
export interface GetAttachmentsForMessageQuery { type: typeof QueryType.GetAttachmentsForMessage; payload: { messageId: string } }
|
||||||
export interface GetAllAttachmentsQuery { type: typeof QueryType.GetAllAttachments; payload: Record<string, never> }
|
export interface GetAllAttachmentsQuery { type: typeof QueryType.GetAllAttachments; payload: Record<string, never> }
|
||||||
export interface GetPluginDataQuery { type: typeof QueryType.GetPluginData; payload: Omit<PluginDataPayload, 'value'> }
|
|
||||||
export interface GetMetaQuery { type: typeof QueryType.GetMeta; payload: { key: string } }
|
|
||||||
|
|
||||||
export type Query =
|
export type Query =
|
||||||
| GetMessagesQuery
|
| GetMessagesQuery
|
||||||
@@ -261,6 +238,4 @@ export type Query =
|
|||||||
| GetBansForRoomQuery
|
| GetBansForRoomQuery
|
||||||
| IsUserBannedQuery
|
| IsUserBannedQuery
|
||||||
| GetAttachmentsForMessageQuery
|
| GetAttachmentsForMessageQuery
|
||||||
| GetAllAttachmentsQuery
|
| GetAllAttachmentsQuery;
|
||||||
| GetPluginDataQuery
|
|
||||||
| GetMetaQuery;
|
|
||||||
|
|||||||
@@ -25,8 +25,7 @@ import {
|
|||||||
ReactionEntity,
|
ReactionEntity,
|
||||||
BanEntity,
|
BanEntity,
|
||||||
AttachmentEntity,
|
AttachmentEntity,
|
||||||
MetaEntity,
|
MetaEntity
|
||||||
PluginDataEntity
|
|
||||||
} from './entities';
|
} from './entities';
|
||||||
|
|
||||||
const projectRootDatabaseFilePath = path.join(__dirname, '..', '..', settings.databaseName);
|
const projectRootDatabaseFilePath = path.join(__dirname, '..', '..', settings.databaseName);
|
||||||
@@ -52,8 +51,7 @@ export const AppDataSource = new DataSource({
|
|||||||
ReactionEntity,
|
ReactionEntity,
|
||||||
BanEntity,
|
BanEntity,
|
||||||
AttachmentEntity,
|
AttachmentEntity,
|
||||||
MetaEntity,
|
MetaEntity
|
||||||
PluginDataEntity
|
|
||||||
],
|
],
|
||||||
migrations: [path.join(__dirname, 'migrations', '*.{ts,js}')],
|
migrations: [path.join(__dirname, 'migrations', '*.{ts,js}')],
|
||||||
synchronize: false,
|
synchronize: false,
|
||||||
|
|||||||
@@ -16,8 +16,7 @@ import {
|
|||||||
ReactionEntity,
|
ReactionEntity,
|
||||||
BanEntity,
|
BanEntity,
|
||||||
AttachmentEntity,
|
AttachmentEntity,
|
||||||
MetaEntity,
|
MetaEntity
|
||||||
PluginDataEntity
|
|
||||||
} from '../entities';
|
} from '../entities';
|
||||||
import { settings } from '../settings';
|
import { settings } from '../settings';
|
||||||
|
|
||||||
@@ -27,24 +26,6 @@ let dbBackupPath = '';
|
|||||||
|
|
||||||
// SQLite files start with this 16-byte header string.
|
// SQLite files start with this 16-byte header string.
|
||||||
const SQLITE_MAGIC = 'SQLite format 3\0';
|
const SQLITE_MAGIC = 'SQLite format 3\0';
|
||||||
const SAVE_RETRY_DELAYS_MS = [25, 75, 150, 300, 600];
|
|
||||||
const RETRYABLE_SAVE_ERROR_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
|
|
||||||
|
|
||||||
let saveQueue: Promise<void> = Promise.resolve();
|
|
||||||
|
|
||||||
function wait(ms: number): Promise<void> {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRetryableSaveError(error: unknown): boolean {
|
|
||||||
if (!error || typeof error !== 'object') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const code = (error as NodeJS.ErrnoException).code;
|
|
||||||
|
|
||||||
return typeof code === 'string' && RETRYABLE_SAVE_ERROR_CODES.has(code);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDataSource(): DataSource | undefined {
|
export function getDataSource(): DataSource | undefined {
|
||||||
return applicationDataSource;
|
return applicationDataSource;
|
||||||
@@ -106,47 +87,18 @@ function safeguardDbFile(): Uint8Array | undefined {
|
|||||||
* then rename it over the real file. rename() is atomic on the same
|
* then rename it over the real file. rename() is atomic on the same
|
||||||
* filesystem, so a crash mid-write can never leave a half-written DB.
|
* filesystem, so a crash mid-write can never leave a half-written DB.
|
||||||
*/
|
*/
|
||||||
async function replaceDatabaseFile(tmpPath: string): Promise<void> {
|
async function atomicSave(data: Uint8Array): Promise<void> {
|
||||||
for (let attempt = 0; ; attempt++) {
|
|
||||||
try {
|
|
||||||
await fsp.rename(tmpPath, dbFilePath);
|
|
||||||
return;
|
|
||||||
} catch (error) {
|
|
||||||
const delay = SAVE_RETRY_DELAYS_MS[attempt];
|
|
||||||
|
|
||||||
if (!isRetryableSaveError(error) || delay === undefined) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
await wait(delay);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeDatabaseSnapshot(snapshot: Buffer): Promise<void> {
|
|
||||||
const tmpPath = dbFilePath + '.tmp-' + randomBytes(6).toString('hex');
|
const tmpPath = dbFilePath + '.tmp-' + randomBytes(6).toString('hex');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fsp.writeFile(tmpPath, snapshot);
|
await fsp.writeFile(tmpPath, Buffer.from(data));
|
||||||
await replaceDatabaseFile(tmpPath);
|
await fsp.rename(tmpPath, dbFilePath);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await fsp.unlink(tmpPath).catch(() => {});
|
await fsp.unlink(tmpPath).catch(() => {});
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function atomicSave(data: Uint8Array): Promise<void> {
|
|
||||||
const snapshot = Buffer.from(data);
|
|
||||||
const saveTask = saveQueue.then(
|
|
||||||
() => writeDatabaseSnapshot(snapshot),
|
|
||||||
() => writeDatabaseSnapshot(snapshot)
|
|
||||||
);
|
|
||||||
|
|
||||||
saveQueue = saveTask.catch(() => {});
|
|
||||||
|
|
||||||
return saveTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function initializeDatabase(): Promise<void> {
|
export async function initializeDatabase(): Promise<void> {
|
||||||
const userDataPath = app.getPath('userData');
|
const userDataPath = app.getPath('userData');
|
||||||
const dbDir = path.join(userDataPath, 'metoyou');
|
const dbDir = path.join(userDataPath, 'metoyou');
|
||||||
@@ -172,8 +124,7 @@ export async function initializeDatabase(): Promise<void> {
|
|||||||
ReactionEntity,
|
ReactionEntity,
|
||||||
BanEntity,
|
BanEntity,
|
||||||
AttachmentEntity,
|
AttachmentEntity,
|
||||||
MetaEntity,
|
MetaEntity
|
||||||
PluginDataEntity
|
|
||||||
],
|
],
|
||||||
migrations: [path.join(__dirname, '..', 'migrations', '*.js'), path.join(__dirname, '..', 'migrations', '*.ts')],
|
migrations: [path.join(__dirname, '..', 'migrations', '*.js'), path.join(__dirname, '..', 'migrations', '*.ts')],
|
||||||
synchronize: false,
|
synchronize: false,
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
import {
|
|
||||||
Column,
|
|
||||||
Entity,
|
|
||||||
PrimaryColumn
|
|
||||||
} from 'typeorm';
|
|
||||||
|
|
||||||
@Entity('plugin_data')
|
|
||||||
export class PluginDataEntity {
|
|
||||||
@PrimaryColumn('text')
|
|
||||||
pluginId!: string;
|
|
||||||
|
|
||||||
@PrimaryColumn('text')
|
|
||||||
scope!: string;
|
|
||||||
|
|
||||||
@PrimaryColumn('text')
|
|
||||||
serverId!: string;
|
|
||||||
|
|
||||||
@PrimaryColumn('text')
|
|
||||||
key!: string;
|
|
||||||
|
|
||||||
@Column('text')
|
|
||||||
valueJson!: string;
|
|
||||||
|
|
||||||
@Column('integer')
|
|
||||||
updatedAt!: number;
|
|
||||||
}
|
|
||||||
@@ -10,4 +10,3 @@ export { ReactionEntity } from './ReactionEntity';
|
|||||||
export { BanEntity } from './BanEntity';
|
export { BanEntity } from './BanEntity';
|
||||||
export { AttachmentEntity } from './AttachmentEntity';
|
export { AttachmentEntity } from './AttachmentEntity';
|
||||||
export { MetaEntity } from './MetaEntity';
|
export { MetaEntity } from './MetaEntity';
|
||||||
export { PluginDataEntity } from './PluginDataEntity';
|
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ import {
|
|||||||
readSavedTheme,
|
readSavedTheme,
|
||||||
writeSavedTheme
|
writeSavedTheme
|
||||||
} from '../theme-library';
|
} from '../theme-library';
|
||||||
import { getLocalPluginsPath, listLocalPluginManifests } from '../plugin-library';
|
|
||||||
import {
|
import {
|
||||||
eraseUserData,
|
eraseUserData,
|
||||||
exportUserData,
|
exportUserData,
|
||||||
@@ -350,8 +349,6 @@ export function setupSystemHandlers(): void {
|
|||||||
ipcMain.handle('import-user-data', async () => await importUserData());
|
ipcMain.handle('import-user-data', async () => await importUserData());
|
||||||
ipcMain.handle('erase-user-data', async () => await eraseUserData());
|
ipcMain.handle('erase-user-data', async () => await eraseUserData());
|
||||||
ipcMain.handle('get-saved-themes-path', async () => await getSavedThemesPath());
|
ipcMain.handle('get-saved-themes-path', async () => await getSavedThemesPath());
|
||||||
ipcMain.handle('get-local-plugins-path', async () => await getLocalPluginsPath());
|
|
||||||
ipcMain.handle('list-local-plugin-manifests', async () => await listLocalPluginManifests());
|
|
||||||
ipcMain.handle('list-saved-themes', async () => await listSavedThemes());
|
ipcMain.handle('list-saved-themes', async () => await listSavedThemes());
|
||||||
ipcMain.handle('read-saved-theme', async (_event, fileName: string) => await readSavedTheme(fileName));
|
ipcMain.handle('read-saved-theme', async (_event, fileName: string) => await readSavedTheme(fileName));
|
||||||
ipcMain.handle('write-saved-theme', async (_event, fileName: string, text: string) => {
|
ipcMain.handle('write-saved-theme', async (_event, fileName: string, text: string) => {
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
|
||||||
|
|
||||||
export class AddPluginData1000000000008 implements MigrationInterface {
|
|
||||||
name = 'AddPluginData1000000000008';
|
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS "plugin_data" (
|
|
||||||
"pluginId" TEXT NOT NULL,
|
|
||||||
"scope" TEXT NOT NULL,
|
|
||||||
"serverId" TEXT NOT NULL DEFAULT '',
|
|
||||||
"key" TEXT NOT NULL,
|
|
||||||
"valueJson" TEXT NOT NULL,
|
|
||||||
"updatedAt" INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY ("pluginId", "scope", "serverId", "key")
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_plugin_data_plugin_scope" ON "plugin_data" ("pluginId", "scope")`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query(`DROP INDEX IF EXISTS "idx_plugin_data_plugin_scope"`);
|
|
||||||
await queryRunner.query(`DROP TABLE IF EXISTS "plugin_data"`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
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,28 +109,6 @@ export interface SavedThemeFileDescriptor {
|
|||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LocalPluginManifestDescriptor {
|
|
||||||
discoveredAt: number;
|
|
||||||
entrypointPath?: string;
|
|
||||||
pluginRootUrl: string;
|
|
||||||
manifest: unknown;
|
|
||||||
manifestPath: string;
|
|
||||||
pluginRoot: string;
|
|
||||||
readmePath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LocalPluginDiscoveryError {
|
|
||||||
manifestPath?: string;
|
|
||||||
message: string;
|
|
||||||
pluginRoot?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LocalPluginDiscoveryResult {
|
|
||||||
errors: LocalPluginDiscoveryError[];
|
|
||||||
plugins: LocalPluginManifestDescriptor[];
|
|
||||||
pluginsPath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExportUserDataResult {
|
export interface ExportUserDataResult {
|
||||||
cancelled: boolean;
|
cancelled: boolean;
|
||||||
exported: boolean;
|
exported: boolean;
|
||||||
@@ -203,8 +181,6 @@ export interface ElectronAPI {
|
|||||||
importUserData: () => Promise<ImportUserDataResult>;
|
importUserData: () => Promise<ImportUserDataResult>;
|
||||||
eraseUserData: () => Promise<EraseUserDataResult>;
|
eraseUserData: () => Promise<EraseUserDataResult>;
|
||||||
getSavedThemesPath: () => Promise<string>;
|
getSavedThemesPath: () => Promise<string>;
|
||||||
getLocalPluginsPath: () => Promise<string>;
|
|
||||||
listLocalPluginManifests: () => Promise<LocalPluginDiscoveryResult>;
|
|
||||||
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
|
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
|
||||||
readSavedTheme: (fileName: string) => Promise<string>;
|
readSavedTheme: (fileName: string) => Promise<string>;
|
||||||
writeSavedTheme: (fileName: string, text: string) => Promise<boolean>;
|
writeSavedTheme: (fileName: string, text: string) => Promise<boolean>;
|
||||||
@@ -318,8 +294,6 @@ const electronAPI: ElectronAPI = {
|
|||||||
importUserData: () => ipcRenderer.invoke('import-user-data'),
|
importUserData: () => ipcRenderer.invoke('import-user-data'),
|
||||||
eraseUserData: () => ipcRenderer.invoke('erase-user-data'),
|
eraseUserData: () => ipcRenderer.invoke('erase-user-data'),
|
||||||
getSavedThemesPath: () => ipcRenderer.invoke('get-saved-themes-path'),
|
getSavedThemesPath: () => ipcRenderer.invoke('get-saved-themes-path'),
|
||||||
getLocalPluginsPath: () => ipcRenderer.invoke('get-local-plugins-path'),
|
|
||||||
listLocalPluginManifests: () => ipcRenderer.invoke('list-local-plugin-manifests'),
|
|
||||||
listSavedThemes: () => ipcRenderer.invoke('list-saved-themes'),
|
listSavedThemes: () => ipcRenderer.invoke('list-saved-themes'),
|
||||||
readSavedTheme: (fileName) => ipcRenderer.invoke('read-saved-theme', fileName),
|
readSavedTheme: (fileName) => ipcRenderer.invoke('read-saved-theme', fileName),
|
||||||
writeSavedTheme: (fileName, text) => ipcRenderer.invoke('write-saved-theme', fileName, text),
|
writeSavedTheme: (fileName, text) => ipcRenderer.invoke('write-saved-theme', fileName, text),
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ Node/TypeScript signaling server for MetoYou / Toju. This package owns the publi
|
|||||||
- `SSL` can override the effective HTTP protocol, and `PORT` can override the effective port.
|
- `SSL` can override the effective HTTP protocol, and `PORT` can override the effective port.
|
||||||
- `DB_PATH` can override the SQLite database file location.
|
- `DB_PATH` can override the SQLite database file location.
|
||||||
- `data/variables.json` is normalized on startup and stores `klipyApiKey`, `rawgApiKey`, `releaseManifestUrl`, `serverPort`, `serverProtocol`, `serverHost`, and `linkPreview`.
|
- `data/variables.json` is normalized on startup and stores `klipyApiKey`, `rawgApiKey`, `releaseManifestUrl`, `serverPort`, `serverProtocol`, `serverHost`, and `linkPreview`.
|
||||||
- `openApiDocs.enabled` in `data/variables.json`, or `OPENAPI_DOCS_ENABLED=true`, exposes the plugin support OpenAPI document at `/api/openapi.json` and a small docs page at `/api/docs`. It is disabled by default. Plugin support is metadata-only: the server stores install requirements and event definitions, but arbitrary plugin data persistence is disabled.
|
|
||||||
- `RAWG_API_KEY` can override `rawgApiKey` for the `/api/games/match` now-playing metadata resolver. Successful matches include a preferred store link from RAWG store metadata, with Steam selected first when available. Negative game-match results are stored in the SQLite `game_match_misses` table so non-game process names do not repeatedly consume RAWG quota.
|
- `RAWG_API_KEY` can override `rawgApiKey` for the `/api/games/match` now-playing metadata resolver. Successful matches include a preferred store link from RAWG store metadata, with Steam selected first when available. Negative game-match results are stored in the SQLite `game_match_misses` table so non-game process names do not repeatedly consume RAWG quota.
|
||||||
- Packaged server builds store `metoyou.sqlite` in the OS app-data directory by default so upgrades do not overwrite runtime data. On first start, the server copies forward legacy packaged databases that still live beside the executable.
|
- Packaged server builds store `metoyou.sqlite` in the OS app-data directory by default so upgrades do not overwrite runtime data. On first start, the server copies forward legacy packaged databases that still live beside the executable.
|
||||||
- When HTTPS is enabled, certificates are read from the repository `.certs/` directory.
|
- When HTTPS is enabled, certificates are read from the repository `.certs/` directory.
|
||||||
|
|||||||
Binary file not shown.
@@ -10,10 +10,6 @@ export interface LinkPreviewConfig {
|
|||||||
maxCacheSizeMb: number;
|
maxCacheSizeMb: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenApiDocsConfig {
|
|
||||||
enabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ServerVariablesConfig {
|
export interface ServerVariablesConfig {
|
||||||
klipyApiKey: string;
|
klipyApiKey: string;
|
||||||
rawgApiKey: string;
|
rawgApiKey: string;
|
||||||
@@ -22,7 +18,6 @@ export interface ServerVariablesConfig {
|
|||||||
serverProtocol: ServerHttpProtocol;
|
serverProtocol: ServerHttpProtocol;
|
||||||
serverHost: string;
|
serverHost: string;
|
||||||
linkPreview: LinkPreviewConfig;
|
linkPreview: LinkPreviewConfig;
|
||||||
openApiDocs: OpenApiDocsConfig;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DATA_DIR = resolveRuntimePath('data');
|
const DATA_DIR = resolveRuntimePath('data');
|
||||||
@@ -107,14 +102,6 @@ function normalizeLinkPreviewConfig(value: unknown): LinkPreviewConfig {
|
|||||||
return { enabled, cacheTtlMinutes: cacheTtl, maxCacheSizeMb: maxSize };
|
return { enabled, cacheTtlMinutes: cacheTtl, maxCacheSizeMb: maxSize };
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeOpenApiDocsConfig(value: unknown): OpenApiDocsConfig {
|
|
||||||
const raw = (value && typeof value === 'object' && !Array.isArray(value))
|
|
||||||
? value as Record<string, unknown>
|
|
||||||
: {};
|
|
||||||
|
|
||||||
return { enabled: raw.enabled === true };
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasEnvironmentOverride(value: string | undefined): value is string {
|
function hasEnvironmentOverride(value: string | undefined): value is string {
|
||||||
return typeof value === 'string' && value.trim().length > 0;
|
return typeof value === 'string' && value.trim().length > 0;
|
||||||
}
|
}
|
||||||
@@ -162,8 +149,7 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
|||||||
serverPort: normalizeServerPort(remainingParsed.serverPort),
|
serverPort: normalizeServerPort(remainingParsed.serverPort),
|
||||||
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
|
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
|
||||||
serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress),
|
serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress),
|
||||||
linkPreview: normalizeLinkPreviewConfig(remainingParsed.linkPreview),
|
linkPreview: normalizeLinkPreviewConfig(remainingParsed.linkPreview)
|
||||||
openApiDocs: normalizeOpenApiDocsConfig(remainingParsed.openApiDocs)
|
|
||||||
};
|
};
|
||||||
const nextContents = JSON.stringify(normalized, null, 2) + '\n';
|
const nextContents = JSON.stringify(normalized, null, 2) + '\n';
|
||||||
|
|
||||||
@@ -178,8 +164,7 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
|||||||
serverPort: normalized.serverPort,
|
serverPort: normalized.serverPort,
|
||||||
serverProtocol: normalized.serverProtocol,
|
serverProtocol: normalized.serverProtocol,
|
||||||
serverHost: normalized.serverHost,
|
serverHost: normalized.serverHost,
|
||||||
linkPreview: normalized.linkPreview,
|
linkPreview: normalized.linkPreview
|
||||||
openApiDocs: normalized.openApiDocs
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,31 +218,6 @@ export function isHttpsServerEnabled(): boolean {
|
|||||||
return getServerProtocol() === 'https';
|
return getServerProtocol() === 'https';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function areOpenApiDocsEnabled(): boolean {
|
|
||||||
if (hasEnvironmentOverride(process.env.OPENAPI_DOCS_ENABLED)) {
|
|
||||||
return process.env.OPENAPI_DOCS_ENABLED.trim().toLowerCase() === 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
return getVariablesConfig().openApiDocs.enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setOpenApiDocsEnabled(enabled: boolean): OpenApiDocsConfig {
|
|
||||||
if (!fs.existsSync(DATA_DIR)) {
|
|
||||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { parsed } = readRawVariables();
|
|
||||||
const next = {
|
|
||||||
...parsed,
|
|
||||||
openApiDocs: { enabled }
|
|
||||||
};
|
|
||||||
|
|
||||||
fs.writeFileSync(VARIABLES_FILE, JSON.stringify(next, null, 2) + '\n', 'utf8');
|
|
||||||
ensureVariablesConfig();
|
|
||||||
|
|
||||||
return { enabled };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getLinkPreviewConfig(): LinkPreviewConfig {
|
export function getLinkPreviewConfig(): LinkPreviewConfig {
|
||||||
return getVariablesConfig().linkPreview;
|
return getVariablesConfig().linkPreview;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,12 +15,7 @@ import {
|
|||||||
ServerMembershipEntity,
|
ServerMembershipEntity,
|
||||||
ServerInviteEntity,
|
ServerInviteEntity,
|
||||||
ServerBanEntity,
|
ServerBanEntity,
|
||||||
GameMatchMissEntity,
|
GameMatchMissEntity
|
||||||
ServerPluginRequirementEntity,
|
|
||||||
ServerPluginEventDefinitionEntity,
|
|
||||||
PluginDataEntity,
|
|
||||||
ServerPluginSettingsEntity,
|
|
||||||
PluginUserMetadataEntity
|
|
||||||
} from '../entities';
|
} from '../entities';
|
||||||
import { serverMigrations } from '../migrations';
|
import { serverMigrations } from '../migrations';
|
||||||
import {
|
import {
|
||||||
@@ -54,35 +49,8 @@ const DB_BACKUP = DB_FILE + '.bak';
|
|||||||
const DATA_DIR = path.dirname(DB_FILE);
|
const DATA_DIR = path.dirname(DB_FILE);
|
||||||
// SQLite files start with this 16-byte header string.
|
// SQLite files start with this 16-byte header string.
|
||||||
const SQLITE_MAGIC = 'SQLite format 3\0';
|
const SQLITE_MAGIC = 'SQLite format 3\0';
|
||||||
const SAVE_RETRY_DELAYS_MS = [
|
|
||||||
25,
|
|
||||||
75,
|
|
||||||
150,
|
|
||||||
300,
|
|
||||||
600
|
|
||||||
];
|
|
||||||
const RETRYABLE_SAVE_ERROR_CODES = new Set([
|
|
||||||
'EPERM',
|
|
||||||
'EACCES',
|
|
||||||
'EBUSY'
|
|
||||||
]);
|
|
||||||
|
|
||||||
let applicationDataSource: DataSource | undefined;
|
let applicationDataSource: DataSource | undefined;
|
||||||
let saveQueue: Promise<void> = Promise.resolve();
|
|
||||||
|
|
||||||
function wait(ms: number): Promise<void> {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRetryableSaveError(error: unknown): boolean {
|
|
||||||
if (!error || typeof error !== 'object') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const code = (error as NodeJS.ErrnoException).code;
|
|
||||||
|
|
||||||
return typeof code === 'string' && RETRYABLE_SAVE_ERROR_CODES.has(code);
|
|
||||||
}
|
|
||||||
|
|
||||||
function restoreFromBackup(reason: string): Uint8Array | undefined {
|
function restoreFromBackup(reason: string): Uint8Array | undefined {
|
||||||
if (!fs.existsSync(DB_BACKUP)) {
|
if (!fs.existsSync(DB_BACKUP)) {
|
||||||
@@ -192,47 +160,18 @@ function resolveSqlJsConfig(): { locateFile: (file: string) => string } {
|
|||||||
* then rename it over the real file. rename() is atomic on the same
|
* then rename it over the real file. rename() is atomic on the same
|
||||||
* filesystem, so a crash mid-write can never leave a half-written DB.
|
* filesystem, so a crash mid-write can never leave a half-written DB.
|
||||||
*/
|
*/
|
||||||
async function replaceDatabaseFile(tmpPath: string): Promise<void> {
|
async function atomicSave(data: Uint8Array): Promise<void> {
|
||||||
for (let attempt = 0; ; attempt++) {
|
|
||||||
try {
|
|
||||||
await fsp.rename(tmpPath, DB_FILE);
|
|
||||||
return;
|
|
||||||
} catch (error) {
|
|
||||||
const delay = SAVE_RETRY_DELAYS_MS[attempt];
|
|
||||||
|
|
||||||
if (!isRetryableSaveError(error) || delay === undefined) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
await wait(delay);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeDatabaseSnapshot(snapshot: Buffer): Promise<void> {
|
|
||||||
const tmpPath = DB_FILE + '.tmp-' + randomBytes(6).toString('hex');
|
const tmpPath = DB_FILE + '.tmp-' + randomBytes(6).toString('hex');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fsp.writeFile(tmpPath, snapshot);
|
await fsp.writeFile(tmpPath, Buffer.from(data));
|
||||||
await replaceDatabaseFile(tmpPath);
|
await fsp.rename(tmpPath, DB_FILE);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await fsp.unlink(tmpPath).catch(() => {});
|
await fsp.unlink(tmpPath).catch(() => {});
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function atomicSave(data: Uint8Array): Promise<void> {
|
|
||||||
const snapshot = Buffer.from(data);
|
|
||||||
const saveTask = saveQueue.then(
|
|
||||||
() => writeDatabaseSnapshot(snapshot),
|
|
||||||
() => writeDatabaseSnapshot(snapshot)
|
|
||||||
);
|
|
||||||
|
|
||||||
saveQueue = saveTask.catch(() => {});
|
|
||||||
|
|
||||||
return saveTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDataSource(): DataSource {
|
export function getDataSource(): DataSource {
|
||||||
if (!applicationDataSource?.isInitialized) {
|
if (!applicationDataSource?.isInitialized) {
|
||||||
throw new Error('DataSource not initialised');
|
throw new Error('DataSource not initialised');
|
||||||
@@ -265,12 +204,7 @@ export async function initDatabase(): Promise<void> {
|
|||||||
ServerMembershipEntity,
|
ServerMembershipEntity,
|
||||||
ServerInviteEntity,
|
ServerInviteEntity,
|
||||||
ServerBanEntity,
|
ServerBanEntity,
|
||||||
GameMatchMissEntity,
|
GameMatchMissEntity
|
||||||
ServerPluginRequirementEntity,
|
|
||||||
ServerPluginEventDefinitionEntity,
|
|
||||||
PluginDataEntity,
|
|
||||||
ServerPluginSettingsEntity,
|
|
||||||
PluginUserMetadataEntity
|
|
||||||
],
|
],
|
||||||
migrations: serverMigrations,
|
migrations: serverMigrations,
|
||||||
synchronize: process.env.DB_SYNCHRONIZE === 'true',
|
synchronize: process.env.DB_SYNCHRONIZE === 'true',
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import {
|
|
||||||
Column,
|
|
||||||
Entity,
|
|
||||||
Index,
|
|
||||||
PrimaryColumn
|
|
||||||
} from 'typeorm';
|
|
||||||
|
|
||||||
export type ServerPluginRequirementStatus = 'required' | 'optional' | 'recommended' | 'blocked' | 'incompatible';
|
|
||||||
|
|
||||||
@Entity('server_plugin_requirements')
|
|
||||||
export class ServerPluginRequirementEntity {
|
|
||||||
@PrimaryColumn('text')
|
|
||||||
serverId!: string;
|
|
||||||
|
|
||||||
@PrimaryColumn('text')
|
|
||||||
pluginId!: string;
|
|
||||||
|
|
||||||
@Index()
|
|
||||||
@Column('text')
|
|
||||||
status!: ServerPluginRequirementStatus;
|
|
||||||
|
|
||||||
@Column('text', { nullable: true })
|
|
||||||
versionRange!: string | null;
|
|
||||||
|
|
||||||
@Column('text', { nullable: true })
|
|
||||||
reason!: string | null;
|
|
||||||
|
|
||||||
@Column('text', { nullable: true })
|
|
||||||
installUrl!: string | null;
|
|
||||||
|
|
||||||
@Column('text', { nullable: true })
|
|
||||||
sourceUrl!: string | null;
|
|
||||||
|
|
||||||
@Column('text', { nullable: true })
|
|
||||||
manifestJson!: string | null;
|
|
||||||
|
|
||||||
@Column('text', { nullable: true })
|
|
||||||
configuredBy!: string | null;
|
|
||||||
|
|
||||||
@Column('integer')
|
|
||||||
createdAt!: number;
|
|
||||||
|
|
||||||
@Column('integer')
|
|
||||||
updatedAt!: number;
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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,10 +10,3 @@ export { ServerMembershipEntity } from './ServerMembershipEntity';
|
|||||||
export { ServerInviteEntity } from './ServerInviteEntity';
|
export { ServerInviteEntity } from './ServerInviteEntity';
|
||||||
export { ServerBanEntity } from './ServerBanEntity';
|
export { ServerBanEntity } from './ServerBanEntity';
|
||||||
export { GameMatchMissEntity } from './GameMatchMissEntity';
|
export { GameMatchMissEntity } from './GameMatchMissEntity';
|
||||||
export { ServerPluginRequirementEntity } from './ServerPluginRequirementEntity';
|
|
||||||
export type { ServerPluginRequirementStatus } from './ServerPluginRequirementEntity';
|
|
||||||
export { ServerPluginEventDefinitionEntity } from './ServerPluginEventDefinitionEntity';
|
|
||||||
export type { ServerPluginEventDirection, ServerPluginEventScope } from './ServerPluginEventDefinitionEntity';
|
|
||||||
export { PluginDataEntity } from './PluginDataEntity';
|
|
||||||
export { ServerPluginSettingsEntity } from './ServerPluginSettingsEntity';
|
|
||||||
export { PluginUserMetadataEntity } from './PluginUserMetadataEntity';
|
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
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"`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
|
||||||
|
|
||||||
export class ServerPluginInstallMetadata1000000000008 implements MigrationInterface {
|
|
||||||
name = 'ServerPluginInstallMetadata1000000000008';
|
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query(`ALTER TABLE "server_plugin_requirements" ADD COLUMN "installUrl" TEXT`);
|
|
||||||
await queryRunner.query(`ALTER TABLE "server_plugin_requirements" ADD COLUMN "sourceUrl" TEXT`);
|
|
||||||
await queryRunner.query(`ALTER TABLE "server_plugin_requirements" ADD COLUMN "manifestJson" TEXT`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query(`CREATE TABLE "temporary_server_plugin_requirements" (
|
|
||||||
"serverId" TEXT NOT NULL,
|
|
||||||
"pluginId" TEXT NOT NULL,
|
|
||||||
"status" TEXT NOT NULL,
|
|
||||||
"versionRange" TEXT,
|
|
||||||
"reason" TEXT,
|
|
||||||
"configuredBy" TEXT,
|
|
||||||
"createdAt" INTEGER NOT NULL,
|
|
||||||
"updatedAt" INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY ("serverId", "pluginId")
|
|
||||||
)`);
|
|
||||||
await queryRunner.query(`INSERT INTO "temporary_server_plugin_requirements" ("serverId", "pluginId", "status", "versionRange", "reason", "configuredBy", "createdAt", "updatedAt")
|
|
||||||
SELECT "serverId", "pluginId", "status", "versionRange", "reason", "configuredBy", "createdAt", "updatedAt" FROM "server_plugin_requirements"`);
|
|
||||||
await queryRunner.query(`DROP TABLE "server_plugin_requirements"`);
|
|
||||||
await queryRunner.query(`ALTER TABLE "temporary_server_plugin_requirements" RENAME TO "server_plugin_requirements"`);
|
|
||||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_plugin_requirements_status" ON "server_plugin_requirements" ("status")`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,8 +5,6 @@ import { RepairLegacyVoiceChannels1000000000003 } from './1000000000003-RepairLe
|
|||||||
import { NormalizeServerArrays1000000000004 } from './1000000000004-NormalizeServerArrays';
|
import { NormalizeServerArrays1000000000004 } from './1000000000004-NormalizeServerArrays';
|
||||||
import { ServerRoleAccessControl1000000000005 } from './1000000000005-ServerRoleAccessControl';
|
import { ServerRoleAccessControl1000000000005 } from './1000000000005-ServerRoleAccessControl';
|
||||||
import { GameMatchMisses1000000000006 } from './1000000000006-GameMatchMisses';
|
import { GameMatchMisses1000000000006 } from './1000000000006-GameMatchMisses';
|
||||||
import { PluginSupport1000000000007 } from './1000000000007-PluginSupport';
|
|
||||||
import { ServerPluginInstallMetadata1000000000008 } from './1000000000008-ServerPluginInstallMetadata';
|
|
||||||
|
|
||||||
export const serverMigrations = [
|
export const serverMigrations = [
|
||||||
InitialSchema1000000000000,
|
InitialSchema1000000000000,
|
||||||
@@ -15,7 +13,5 @@ export const serverMigrations = [
|
|||||||
RepairLegacyVoiceChannels1000000000003,
|
RepairLegacyVoiceChannels1000000000003,
|
||||||
NormalizeServerArrays1000000000004,
|
NormalizeServerArrays1000000000004,
|
||||||
ServerRoleAccessControl1000000000005,
|
ServerRoleAccessControl1000000000005,
|
||||||
GameMatchMisses1000000000006,
|
GameMatchMisses1000000000006
|
||||||
PluginSupport1000000000007,
|
|
||||||
ServerPluginInstallMetadata1000000000008
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import gamesRouter from './games';
|
|||||||
import proxyRouter from './proxy';
|
import proxyRouter from './proxy';
|
||||||
import usersRouter from './users';
|
import usersRouter from './users';
|
||||||
import serversRouter from './servers';
|
import serversRouter from './servers';
|
||||||
import pluginSupportRouter from './plugin-support';
|
|
||||||
import openApiDocsRouter from './openapi-docs';
|
|
||||||
import joinRequestsRouter from './join-requests';
|
import joinRequestsRouter from './join-requests';
|
||||||
import { invitesApiRouter, invitePageRouter } from './invites';
|
import { invitesApiRouter, invitePageRouter } from './invites';
|
||||||
|
|
||||||
@@ -18,8 +16,6 @@ export function registerRoutes(app: Express): void {
|
|||||||
app.use('/api/games', gamesRouter);
|
app.use('/api/games', gamesRouter);
|
||||||
app.use('/api', proxyRouter);
|
app.use('/api', proxyRouter);
|
||||||
app.use('/api/users', usersRouter);
|
app.use('/api/users', usersRouter);
|
||||||
app.use('/api', openApiDocsRouter);
|
|
||||||
app.use('/api/servers', pluginSupportRouter);
|
|
||||||
app.use('/api/servers', serversRouter);
|
app.use('/api/servers', serversRouter);
|
||||||
app.use('/api/invites', invitesApiRouter);
|
app.use('/api/invites', invitesApiRouter);
|
||||||
app.use('/api/requests', joinRequestsRouter);
|
app.use('/api/requests', joinRequestsRouter);
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
import { Router } from 'express';
|
|
||||||
import { areOpenApiDocsEnabled, setOpenApiDocsEnabled } from '../config/variables';
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
function createOpenApiDocument(baseUrl: string) {
|
|
||||||
return {
|
|
||||||
openapi: '3.1.0',
|
|
||||||
info: {
|
|
||||||
title: 'MetoYou Plugin Support API',
|
|
||||||
version: '1.0.0',
|
|
||||||
description: 'Official HTTP endpoints for plugin install metadata and event definitions. '
|
|
||||||
+ 'Plugin code is never executed by the signal server.'
|
|
||||||
},
|
|
||||||
servers: [{ url: `${baseUrl}/api` }],
|
|
||||||
paths: {
|
|
||||||
'/servers/{serverId}/plugins': {
|
|
||||||
get: {
|
|
||||||
summary: 'Read plugin requirement snapshot',
|
|
||||||
parameters: [{ name: 'serverId', in: 'path', required: true, schema: { type: 'string' } }],
|
|
||||||
responses: { '200': { description: 'Plugin requirements and event definitions' } }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'/servers/{serverId}/plugins/{pluginId}/requirement': {
|
|
||||||
put: {
|
|
||||||
summary: 'Create or update a server plugin requirement',
|
|
||||||
responses: { '200': { description: 'Requirement saved' }, '403': { description: 'Not authorized' } }
|
|
||||||
},
|
|
||||||
delete: {
|
|
||||||
summary: 'Delete a server plugin requirement',
|
|
||||||
responses: { '200': { description: 'Requirement deleted' }, '403': { description: 'Not authorized' } }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'/servers/{serverId}/plugins/{pluginId}/events/{eventName}': {
|
|
||||||
put: {
|
|
||||||
summary: 'Create or update a plugin event definition',
|
|
||||||
responses: { '200': { description: 'Event definition saved' }, '403': { description: 'Not authorized' } }
|
|
||||||
},
|
|
||||||
delete: {
|
|
||||||
summary: 'Delete a plugin event definition',
|
|
||||||
responses: { '200': { description: 'Event definition deleted' }, '403': { description: 'Not authorized' } }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'/servers/{serverId}/plugins/{pluginId}/data': {
|
|
||||||
get: {
|
|
||||||
summary: 'Plugin data persistence disabled',
|
|
||||||
responses: { '410': { description: 'Plugin data persistence is disabled on the signal server' } }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'/servers/{serverId}/plugins/{pluginId}/data/{key}': {
|
|
||||||
put: {
|
|
||||||
summary: 'Plugin data persistence disabled',
|
|
||||||
responses: { '410': { description: 'Plugin data persistence is disabled on the signal server' } }
|
|
||||||
},
|
|
||||||
delete: {
|
|
||||||
summary: 'Plugin data persistence disabled',
|
|
||||||
responses: { '410': { description: 'Plugin data persistence is disabled on the signal server' } }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'/openapi/settings': {
|
|
||||||
get: { summary: 'Read OpenAPI docs setting', responses: { '200': { description: 'Setting value' } } },
|
|
||||||
put: { summary: 'Toggle OpenAPI docs exposure', responses: { '200': { description: 'Setting value' } } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function docsDisabledResponse() {
|
|
||||||
return { error: 'OpenAPI docs are disabled', errorCode: 'OPENAPI_DOCS_DISABLED' };
|
|
||||||
}
|
|
||||||
|
|
||||||
router.get('/openapi/settings', (_req, res) => {
|
|
||||||
res.json({ enabled: areOpenApiDocsEnabled() });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.put('/openapi/settings', (req, res) => {
|
|
||||||
res.json(setOpenApiDocsEnabled(req.body?.enabled === true));
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/openapi.json', (req, res) => {
|
|
||||||
if (!areOpenApiDocsEnabled()) {
|
|
||||||
res.status(404).json(docsDisabledResponse());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(createOpenApiDocument(`${req.protocol}://${req.get('host') ?? 'localhost'}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/docs', (_req, res) => {
|
|
||||||
if (!areOpenApiDocsEnabled()) {
|
|
||||||
res.status(404).json(docsDisabledResponse());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.type('html').send(`<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head><meta charset="utf-8"><title>MetoYou Plugin API Docs</title></head>
|
|
||||||
<body style="font-family:system-ui;margin:2rem;line-height:1.5">
|
|
||||||
<h1>MetoYou Plugin Support API</h1>
|
|
||||||
<p>Plugin support endpoints are available at <a href="/api/openapi.json">/api/openapi.json</a>.</p>
|
|
||||||
<p>The signal server stores plugin install metadata and event definitions only. It never executes plugin code or stores arbitrary plugin data.</p>
|
|
||||||
</body>
|
|
||||||
</html>`);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
import { Response, Router } from 'express';
|
|
||||||
import {
|
|
||||||
deletePluginEventDefinition,
|
|
||||||
deletePluginRequirement,
|
|
||||||
getPluginRequirementsSnapshot,
|
|
||||||
PluginSupportError,
|
|
||||||
upsertPluginEventDefinition,
|
|
||||||
upsertPluginRequirement
|
|
||||||
} from '../services/plugin-support.service';
|
|
||||||
import { broadcastToServer } from '../websocket/broadcast';
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
function sendPluginSupportError(error: unknown, res: Response): void {
|
|
||||||
if (error instanceof PluginSupportError) {
|
|
||||||
res.status(error.status).json({ error: error.message, errorCode: error.code });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error('Unhandled plugin support error:', error);
|
|
||||||
res.status(500).json({ error: 'Internal server error', errorCode: 'INTERNAL_ERROR' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function readActorUserId(value: unknown): string {
|
|
||||||
return typeof value === 'string' ? value.trim() : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function broadcastRequirementsSnapshot(serverId: string): Promise<void> {
|
|
||||||
const snapshot = await getPluginRequirementsSnapshot(serverId);
|
|
||||||
|
|
||||||
broadcastToServer(serverId, {
|
|
||||||
type: 'plugin_requirements_changed',
|
|
||||||
serverId,
|
|
||||||
snapshot
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
router.get('/:serverId/plugins', async (req, res) => {
|
|
||||||
try {
|
|
||||||
res.json(await getPluginRequirementsSnapshot(req.params.serverId));
|
|
||||||
} catch (error) {
|
|
||||||
sendPluginSupportError(error, res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.put('/:serverId/plugins/:pluginId/requirement', async (req, res) => {
|
|
||||||
const { serverId, pluginId } = req.params;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const requirement = await upsertPluginRequirement({
|
|
||||||
actorUserId: readActorUserId(req.body.actorUserId),
|
|
||||||
installUrl: req.body.installUrl,
|
|
||||||
manifest: req.body.manifest,
|
|
||||||
pluginId,
|
|
||||||
reason: req.body.reason,
|
|
||||||
serverId,
|
|
||||||
sourceUrl: req.body.sourceUrl,
|
|
||||||
status: req.body.status,
|
|
||||||
versionRange: req.body.versionRange
|
|
||||||
});
|
|
||||||
|
|
||||||
await broadcastRequirementsSnapshot(serverId);
|
|
||||||
res.json({ requirement });
|
|
||||||
} catch (error) {
|
|
||||||
sendPluginSupportError(error, res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.delete('/:serverId/plugins/:pluginId/requirement', async (req, res) => {
|
|
||||||
const { serverId, pluginId } = req.params;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await deletePluginRequirement({
|
|
||||||
actorUserId: readActorUserId(req.body.actorUserId),
|
|
||||||
pluginId,
|
|
||||||
serverId
|
|
||||||
});
|
|
||||||
|
|
||||||
await broadcastRequirementsSnapshot(serverId);
|
|
||||||
res.json({ ok: true });
|
|
||||||
} catch (error) {
|
|
||||||
sendPluginSupportError(error, res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.put('/:serverId/plugins/:pluginId/events/:eventName', async (req, res) => {
|
|
||||||
const { serverId, pluginId, eventName } = req.params;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const eventDefinition = await upsertPluginEventDefinition({
|
|
||||||
actorUserId: readActorUserId(req.body.actorUserId),
|
|
||||||
direction: req.body.direction,
|
|
||||||
eventName,
|
|
||||||
maxPayloadBytes: req.body.maxPayloadBytes,
|
|
||||||
pluginId,
|
|
||||||
rateLimitJson: req.body.rateLimitJson,
|
|
||||||
schemaJson: req.body.schemaJson,
|
|
||||||
scope: req.body.scope,
|
|
||||||
serverId
|
|
||||||
});
|
|
||||||
|
|
||||||
await broadcastRequirementsSnapshot(serverId);
|
|
||||||
res.json({ eventDefinition });
|
|
||||||
} catch (error) {
|
|
||||||
sendPluginSupportError(error, res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.delete('/:serverId/plugins/:pluginId/events/:eventName', async (req, res) => {
|
|
||||||
const { serverId, pluginId, eventName } = req.params;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await deletePluginEventDefinition({
|
|
||||||
actorUserId: readActorUserId(req.body.actorUserId),
|
|
||||||
eventName,
|
|
||||||
pluginId,
|
|
||||||
serverId
|
|
||||||
});
|
|
||||||
|
|
||||||
await broadcastRequirementsSnapshot(serverId);
|
|
||||||
res.json({ ok: true });
|
|
||||||
} catch (error) {
|
|
||||||
sendPluginSupportError(error, res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/:serverId/plugins/:pluginId/data', (_req, res) => {
|
|
||||||
res.status(410).json({
|
|
||||||
error: 'Plugin data persistence is disabled on the signal server',
|
|
||||||
errorCode: 'PLUGIN_DATA_DISABLED'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
router.put('/:serverId/plugins/:pluginId/data/:key', (_req, res) => {
|
|
||||||
res.status(410).json({
|
|
||||||
error: 'Plugin data persistence is disabled on the signal server',
|
|
||||||
errorCode: 'PLUGIN_DATA_DISABLED'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
router.delete('/:serverId/plugins/:pluginId/data/:key', (_req, res) => {
|
|
||||||
res.status(410).json({
|
|
||||||
error: 'Plugin data persistence is disabled on the signal server',
|
|
||||||
errorCode: 'PLUGIN_DATA_DISABLED'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -1,539 +0,0 @@
|
|||||||
import { getServerById } from '../cqrs';
|
|
||||||
import { getDataSource } from '../db/database';
|
|
||||||
import {
|
|
||||||
PluginDataEntity,
|
|
||||||
ServerPluginEventDefinitionEntity,
|
|
||||||
ServerPluginEventDirection,
|
|
||||||
ServerPluginEventScope,
|
|
||||||
ServerPluginRequirementEntity,
|
|
||||||
ServerPluginRequirementStatus
|
|
||||||
} from '../entities';
|
|
||||||
import { findServerMembership } from './server-access.service';
|
|
||||||
import { resolveServerPermission } from './server-permissions.service';
|
|
||||||
|
|
||||||
export const DEFAULT_PLUGIN_EVENT_MAX_PAYLOAD_BYTES = 64 * 1024;
|
|
||||||
|
|
||||||
const VALID_REQUIREMENT_STATUSES = new Set<ServerPluginRequirementStatus>([
|
|
||||||
'required',
|
|
||||||
'optional',
|
|
||||||
'recommended',
|
|
||||||
'blocked',
|
|
||||||
'incompatible'
|
|
||||||
]);
|
|
||||||
const VALID_EVENT_DIRECTIONS = new Set<ServerPluginEventDirection>([
|
|
||||||
'clientToServer',
|
|
||||||
'serverRelay',
|
|
||||||
'p2pHint'
|
|
||||||
]);
|
|
||||||
const VALID_EVENT_SCOPES = new Set<ServerPluginEventScope>([
|
|
||||||
'server',
|
|
||||||
'channel',
|
|
||||||
'user',
|
|
||||||
'plugin'
|
|
||||||
]);
|
|
||||||
const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9.-]{1,126}[a-z0-9]$/;
|
|
||||||
const EVENT_NAME_PATTERN = /^[a-z][a-z0-9.:-]{1,126}[a-z0-9]$/;
|
|
||||||
const DATA_KEY_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/;
|
|
||||||
const DATA_SCOPE_PATTERN = /^[a-zA-Z][a-zA-Z0-9._:-]{0,63}$/;
|
|
||||||
|
|
||||||
export interface PluginRequirementSummary {
|
|
||||||
installUrl?: string;
|
|
||||||
manifest?: unknown;
|
|
||||||
pluginId: string;
|
|
||||||
reason?: string;
|
|
||||||
sourceUrl?: string;
|
|
||||||
status: ServerPluginRequirementStatus;
|
|
||||||
updatedAt: number;
|
|
||||||
versionRange?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PluginEventDefinitionSummary {
|
|
||||||
direction: ServerPluginEventDirection;
|
|
||||||
eventName: string;
|
|
||||||
maxPayloadBytes: number;
|
|
||||||
pluginId: string;
|
|
||||||
scope: ServerPluginEventScope;
|
|
||||||
schemaJson?: string;
|
|
||||||
updatedAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PluginRequirementsSnapshot {
|
|
||||||
eventDefinitions: PluginEventDefinitionSummary[];
|
|
||||||
requirements: PluginRequirementSummary[];
|
|
||||||
serverId: string;
|
|
||||||
updatedAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PluginDataRecord {
|
|
||||||
key: string;
|
|
||||||
ownerId?: string;
|
|
||||||
pluginId: string;
|
|
||||||
schemaVersion: number;
|
|
||||||
scope: string;
|
|
||||||
serverId: string;
|
|
||||||
updatedAt: number;
|
|
||||||
updatedBy?: string;
|
|
||||||
value: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PluginEventEnvelope {
|
|
||||||
eventId?: string;
|
|
||||||
eventName: string;
|
|
||||||
payload: unknown;
|
|
||||||
pluginId: string;
|
|
||||||
serverId: string;
|
|
||||||
sourcePluginUserId?: string;
|
|
||||||
type: 'plugin_event';
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PluginSupportError extends Error {
|
|
||||||
constructor(
|
|
||||||
readonly status: number,
|
|
||||||
readonly code: string,
|
|
||||||
message: string
|
|
||||||
) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'PluginSupportError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function requirementRepository() {
|
|
||||||
return getDataSource().getRepository(ServerPluginRequirementEntity);
|
|
||||||
}
|
|
||||||
|
|
||||||
function eventDefinitionRepository() {
|
|
||||||
return getDataSource().getRepository(ServerPluginEventDefinitionEntity);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pluginDataRepository() {
|
|
||||||
return getDataSource().getRepository(PluginDataEntity);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeOptionalString(value: unknown, maxLength: number): string | null {
|
|
||||||
if (typeof value !== 'string') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalized = value.trim();
|
|
||||||
|
|
||||||
return normalized ? normalized.slice(0, maxLength) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertPattern(value: string, pattern: RegExp, code: string, label: string): void {
|
|
||||||
if (!pattern.test(value)) {
|
|
||||||
throw new PluginSupportError(400, code, `Invalid ${label}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePluginId(pluginId: unknown): string {
|
|
||||||
const normalized = normalizeOptionalString(pluginId, 128);
|
|
||||||
|
|
||||||
if (!normalized) {
|
|
||||||
throw new PluginSupportError(400, 'MISSING_PLUGIN_ID', 'Missing plugin id');
|
|
||||||
}
|
|
||||||
|
|
||||||
assertPattern(normalized, PLUGIN_ID_PATTERN, 'INVALID_PLUGIN_ID', 'plugin id');
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeEventName(eventName: unknown): string {
|
|
||||||
const normalized = normalizeOptionalString(eventName, 128);
|
|
||||||
|
|
||||||
if (!normalized) {
|
|
||||||
throw new PluginSupportError(400, 'MISSING_EVENT_NAME', 'Missing event name');
|
|
||||||
}
|
|
||||||
|
|
||||||
assertPattern(normalized, EVENT_NAME_PATTERN, 'INVALID_EVENT_NAME', 'event name');
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDataKey(key: unknown): string {
|
|
||||||
const normalized = normalizeOptionalString(key, 128);
|
|
||||||
|
|
||||||
if (!normalized) {
|
|
||||||
throw new PluginSupportError(400, 'MISSING_DATA_KEY', 'Missing data key');
|
|
||||||
}
|
|
||||||
|
|
||||||
assertPattern(normalized, DATA_KEY_PATTERN, 'INVALID_DATA_KEY', 'data key');
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDataScope(scope: unknown): string {
|
|
||||||
const normalized = normalizeOptionalString(scope, 64) ?? 'server';
|
|
||||||
|
|
||||||
assertPattern(normalized, DATA_SCOPE_PATTERN, 'INVALID_DATA_SCOPE', 'data scope');
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeOwnerId(ownerId: unknown): string {
|
|
||||||
return normalizeOptionalString(ownerId, 128) ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseJsonValue(valueJson: string): unknown {
|
|
||||||
try {
|
|
||||||
return JSON.parse(valueJson) as unknown;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseOptionalJsonValue(valueJson: string | null): unknown {
|
|
||||||
return valueJson ? parseJsonValue(valueJson) : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeJsonValue(value: unknown, code: string): string {
|
|
||||||
try {
|
|
||||||
return JSON.stringify(value ?? null);
|
|
||||||
} catch {
|
|
||||||
throw new PluginSupportError(400, code, 'Value must be JSON serializable');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toRequirementSummary(entity: ServerPluginRequirementEntity): PluginRequirementSummary {
|
|
||||||
return {
|
|
||||||
installUrl: entity.installUrl ?? undefined,
|
|
||||||
manifest: parseOptionalJsonValue(entity.manifestJson),
|
|
||||||
pluginId: entity.pluginId,
|
|
||||||
reason: entity.reason ?? undefined,
|
|
||||||
sourceUrl: entity.sourceUrl ?? undefined,
|
|
||||||
status: entity.status,
|
|
||||||
updatedAt: entity.updatedAt,
|
|
||||||
versionRange: entity.versionRange ?? undefined
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function toEventDefinitionSummary(entity: ServerPluginEventDefinitionEntity): PluginEventDefinitionSummary {
|
|
||||||
return {
|
|
||||||
direction: entity.direction,
|
|
||||||
eventName: entity.eventName,
|
|
||||||
maxPayloadBytes: entity.maxPayloadBytes,
|
|
||||||
pluginId: entity.pluginId,
|
|
||||||
scope: entity.scope,
|
|
||||||
schemaJson: entity.schemaJson ?? undefined,
|
|
||||||
updatedAt: entity.updatedAt
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function toPluginDataRecord(entity: PluginDataEntity): PluginDataRecord {
|
|
||||||
return {
|
|
||||||
key: entity.key,
|
|
||||||
ownerId: entity.ownerId || undefined,
|
|
||||||
pluginId: entity.pluginId,
|
|
||||||
schemaVersion: entity.schemaVersion,
|
|
||||||
scope: entity.scope,
|
|
||||||
serverId: entity.serverId,
|
|
||||||
updatedAt: entity.updatedAt,
|
|
||||||
updatedBy: entity.updatedBy ?? undefined,
|
|
||||||
value: parseJsonValue(entity.valueJson)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function assertServerExists(serverId: string) {
|
|
||||||
const server = await getServerById(serverId);
|
|
||||||
|
|
||||||
if (!server) {
|
|
||||||
throw new PluginSupportError(404, 'SERVER_NOT_FOUND', 'Server not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
return server;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function assertCanManagePluginSupport(serverId: string, actorUserId: string): Promise<void> {
|
|
||||||
const server = await assertServerExists(serverId);
|
|
||||||
|
|
||||||
if (!actorUserId || !resolveServerPermission(server, actorUserId, 'manageServer')) {
|
|
||||||
throw new PluginSupportError(403, 'NOT_AUTHORIZED', 'Not authorized');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function assertCanUsePluginData(serverId: string, actorUserId: string): Promise<void> {
|
|
||||||
const server = await assertServerExists(serverId);
|
|
||||||
|
|
||||||
if (!actorUserId) {
|
|
||||||
throw new PluginSupportError(400, 'MISSING_USER', 'Missing user id');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (server.ownerId === actorUserId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const membership = await findServerMembership(serverId, actorUserId);
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new PluginSupportError(403, 'NOT_MEMBER', 'Only joined users can access plugin data');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPluginRequirementsSnapshot(serverId: string): Promise<PluginRequirementsSnapshot> {
|
|
||||||
await assertServerExists(serverId);
|
|
||||||
|
|
||||||
const requirementQuery = requirementRepository().find({ where: { serverId } });
|
|
||||||
const eventDefinitionQuery = eventDefinitionRepository().find({ where: { serverId } });
|
|
||||||
const [requirements, eventDefinitions] = await Promise.all([requirementQuery, eventDefinitionQuery]);
|
|
||||||
const requirementSummaries = requirements
|
|
||||||
.map(toRequirementSummary)
|
|
||||||
.sort((first, second) => first.pluginId.localeCompare(second.pluginId));
|
|
||||||
const eventDefinitionSummaries = eventDefinitions
|
|
||||||
.map(toEventDefinitionSummary)
|
|
||||||
.sort((first, second) => `${first.pluginId}:${first.eventName}`.localeCompare(`${second.pluginId}:${second.eventName}`));
|
|
||||||
const updatedAt = Math.max(
|
|
||||||
0,
|
|
||||||
...requirementSummaries.map((requirement) => requirement.updatedAt),
|
|
||||||
...eventDefinitionSummaries.map((definition) => definition.updatedAt)
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
eventDefinitions: eventDefinitionSummaries,
|
|
||||||
requirements: requirementSummaries,
|
|
||||||
serverId,
|
|
||||||
updatedAt
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function upsertPluginRequirement(options: {
|
|
||||||
actorUserId: string;
|
|
||||||
installUrl?: unknown;
|
|
||||||
manifest?: unknown;
|
|
||||||
pluginId: string;
|
|
||||||
reason?: unknown;
|
|
||||||
serverId: string;
|
|
||||||
sourceUrl?: unknown;
|
|
||||||
status: unknown;
|
|
||||||
versionRange?: unknown;
|
|
||||||
}): Promise<PluginRequirementSummary> {
|
|
||||||
await assertCanManagePluginSupport(options.serverId, options.actorUserId);
|
|
||||||
|
|
||||||
const pluginId = normalizePluginId(options.pluginId);
|
|
||||||
const status = options.status;
|
|
||||||
|
|
||||||
if (!VALID_REQUIREMENT_STATUSES.has(status as ServerPluginRequirementStatus)) {
|
|
||||||
throw new PluginSupportError(400, 'INVALID_REQUIREMENT_STATUS', 'Invalid plugin requirement status');
|
|
||||||
}
|
|
||||||
|
|
||||||
const repo = requirementRepository();
|
|
||||||
const now = Date.now();
|
|
||||||
const existing = await repo.findOne({ where: { serverId: options.serverId, pluginId } });
|
|
||||||
const entity = repo.create({
|
|
||||||
serverId: options.serverId,
|
|
||||||
pluginId,
|
|
||||||
status: status as ServerPluginRequirementStatus,
|
|
||||||
versionRange: normalizeOptionalString(options.versionRange, 128),
|
|
||||||
reason: normalizeOptionalString(options.reason, 512),
|
|
||||||
installUrl: normalizeOptionalString(options.installUrl, 2_000),
|
|
||||||
sourceUrl: normalizeOptionalString(options.sourceUrl, 2_000),
|
|
||||||
manifestJson: options.manifest === undefined ? null : serializeJsonValue(options.manifest, 'INVALID_PLUGIN_MANIFEST_METADATA'),
|
|
||||||
configuredBy: options.actorUserId,
|
|
||||||
createdAt: existing?.createdAt ?? now,
|
|
||||||
updatedAt: now
|
|
||||||
});
|
|
||||||
|
|
||||||
await repo.save(entity);
|
|
||||||
return toRequirementSummary(entity);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deletePluginRequirement(options: {
|
|
||||||
actorUserId: string;
|
|
||||||
pluginId: string;
|
|
||||||
serverId: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
await assertCanManagePluginSupport(options.serverId, options.actorUserId);
|
|
||||||
await requirementRepository().delete({ serverId: options.serverId, pluginId: normalizePluginId(options.pluginId) });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function upsertPluginEventDefinition(options: {
|
|
||||||
actorUserId: string;
|
|
||||||
direction: unknown;
|
|
||||||
eventName: string;
|
|
||||||
maxPayloadBytes?: unknown;
|
|
||||||
pluginId: string;
|
|
||||||
rateLimitJson?: unknown;
|
|
||||||
schemaJson?: unknown;
|
|
||||||
scope: unknown;
|
|
||||||
serverId: string;
|
|
||||||
}): Promise<PluginEventDefinitionSummary> {
|
|
||||||
await assertCanManagePluginSupport(options.serverId, options.actorUserId);
|
|
||||||
|
|
||||||
const pluginId = normalizePluginId(options.pluginId);
|
|
||||||
const eventName = normalizeEventName(options.eventName);
|
|
||||||
const { direction, scope } = options;
|
|
||||||
|
|
||||||
if (!VALID_EVENT_DIRECTIONS.has(direction as ServerPluginEventDirection)) {
|
|
||||||
throw new PluginSupportError(400, 'INVALID_EVENT_DIRECTION', 'Invalid plugin event direction');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!VALID_EVENT_SCOPES.has(scope as ServerPluginEventScope)) {
|
|
||||||
throw new PluginSupportError(400, 'INVALID_EVENT_SCOPE', 'Invalid plugin event scope');
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxPayloadBytes = typeof options.maxPayloadBytes === 'number' && Number.isFinite(options.maxPayloadBytes)
|
|
||||||
? Math.max(1, Math.min(Math.floor(options.maxPayloadBytes), DEFAULT_PLUGIN_EVENT_MAX_PAYLOAD_BYTES))
|
|
||||||
: DEFAULT_PLUGIN_EVENT_MAX_PAYLOAD_BYTES;
|
|
||||||
const repo = eventDefinitionRepository();
|
|
||||||
const now = Date.now();
|
|
||||||
const existing = await repo.findOne({ where: { serverId: options.serverId, pluginId, eventName } });
|
|
||||||
const entity = repo.create({
|
|
||||||
serverId: options.serverId,
|
|
||||||
pluginId,
|
|
||||||
eventName,
|
|
||||||
direction: direction as ServerPluginEventDirection,
|
|
||||||
scope: scope as ServerPluginEventScope,
|
|
||||||
schemaJson: normalizeOptionalString(options.schemaJson, 10_000),
|
|
||||||
maxPayloadBytes,
|
|
||||||
rateLimitJson: normalizeOptionalString(options.rateLimitJson, 2_000),
|
|
||||||
createdAt: existing?.createdAt ?? now,
|
|
||||||
updatedAt: now
|
|
||||||
});
|
|
||||||
|
|
||||||
await repo.save(entity);
|
|
||||||
return toEventDefinitionSummary(entity);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deletePluginEventDefinition(options: {
|
|
||||||
actorUserId: string;
|
|
||||||
eventName: string;
|
|
||||||
pluginId: string;
|
|
||||||
serverId: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
await assertCanManagePluginSupport(options.serverId, options.actorUserId);
|
|
||||||
await eventDefinitionRepository().delete({
|
|
||||||
serverId: options.serverId,
|
|
||||||
pluginId: normalizePluginId(options.pluginId),
|
|
||||||
eventName: normalizeEventName(options.eventName)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listPluginData(options: {
|
|
||||||
actorUserId: string;
|
|
||||||
key?: unknown;
|
|
||||||
ownerId?: unknown;
|
|
||||||
pluginId: string;
|
|
||||||
scope?: unknown;
|
|
||||||
serverId: string;
|
|
||||||
}): Promise<PluginDataRecord[]> {
|
|
||||||
await assertCanUsePluginData(options.serverId, options.actorUserId);
|
|
||||||
|
|
||||||
const pluginId = normalizePluginId(options.pluginId);
|
|
||||||
const scope = options.scope === undefined ? undefined : normalizeDataScope(options.scope);
|
|
||||||
const ownerId = options.ownerId === undefined ? undefined : normalizeOwnerId(options.ownerId);
|
|
||||||
const key = options.key === undefined ? undefined : normalizeDataKey(options.key);
|
|
||||||
const query = pluginDataRepository()
|
|
||||||
.createQueryBuilder('data')
|
|
||||||
.where('data.serverId = :serverId', { serverId: options.serverId })
|
|
||||||
.andWhere('data.pluginId = :pluginId', { pluginId });
|
|
||||||
|
|
||||||
if (scope !== undefined) {
|
|
||||||
query.andWhere('data.scope = :scope', { scope });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ownerId !== undefined) {
|
|
||||||
query.andWhere('data.ownerId = :ownerId', { ownerId });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key !== undefined) {
|
|
||||||
query.andWhere('data.key = :key', { key });
|
|
||||||
}
|
|
||||||
|
|
||||||
const records = await query
|
|
||||||
.orderBy('data.scope', 'ASC')
|
|
||||||
.addOrderBy('data.ownerId', 'ASC')
|
|
||||||
.addOrderBy('data.key', 'ASC')
|
|
||||||
.getMany();
|
|
||||||
|
|
||||||
return records.map(toPluginDataRecord);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function upsertPluginData(options: {
|
|
||||||
actorUserId: string;
|
|
||||||
key: string;
|
|
||||||
ownerId?: unknown;
|
|
||||||
pluginId: string;
|
|
||||||
schemaVersion?: unknown;
|
|
||||||
scope?: unknown;
|
|
||||||
serverId: string;
|
|
||||||
value: unknown;
|
|
||||||
}): Promise<PluginDataRecord> {
|
|
||||||
await assertCanUsePluginData(options.serverId, options.actorUserId);
|
|
||||||
|
|
||||||
const pluginId = normalizePluginId(options.pluginId);
|
|
||||||
const scope = normalizeDataScope(options.scope);
|
|
||||||
const ownerId = scope === 'user' ? normalizeOwnerId(options.ownerId ?? options.actorUserId) : normalizeOwnerId(options.ownerId);
|
|
||||||
|
|
||||||
if (scope === 'user' && ownerId !== options.actorUserId) {
|
|
||||||
await assertCanManagePluginSupport(options.serverId, options.actorUserId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = normalizeDataKey(options.key);
|
|
||||||
const schemaVersion = typeof options.schemaVersion === 'number' && Number.isFinite(options.schemaVersion)
|
|
||||||
? Math.max(1, Math.floor(options.schemaVersion))
|
|
||||||
: 1;
|
|
||||||
const repo = pluginDataRepository();
|
|
||||||
const entity = repo.create({
|
|
||||||
serverId: options.serverId,
|
|
||||||
pluginId,
|
|
||||||
scope,
|
|
||||||
ownerId,
|
|
||||||
key,
|
|
||||||
valueJson: serializeJsonValue(options.value, 'INVALID_PLUGIN_DATA'),
|
|
||||||
schemaVersion,
|
|
||||||
updatedBy: options.actorUserId,
|
|
||||||
updatedAt: Date.now()
|
|
||||||
});
|
|
||||||
|
|
||||||
await repo.save(entity);
|
|
||||||
return toPluginDataRecord(entity);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deletePluginData(options: {
|
|
||||||
actorUserId: string;
|
|
||||||
key: string;
|
|
||||||
ownerId?: unknown;
|
|
||||||
pluginId: string;
|
|
||||||
scope?: unknown;
|
|
||||||
serverId: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
await assertCanUsePluginData(options.serverId, options.actorUserId);
|
|
||||||
|
|
||||||
const pluginId = normalizePluginId(options.pluginId);
|
|
||||||
const scope = normalizeDataScope(options.scope);
|
|
||||||
const ownerId = scope === 'user' ? normalizeOwnerId(options.ownerId ?? options.actorUserId) : normalizeOwnerId(options.ownerId);
|
|
||||||
|
|
||||||
if (scope === 'user' && ownerId !== options.actorUserId) {
|
|
||||||
await assertCanManagePluginSupport(options.serverId, options.actorUserId);
|
|
||||||
}
|
|
||||||
|
|
||||||
await pluginDataRepository().delete({
|
|
||||||
serverId: options.serverId,
|
|
||||||
pluginId,
|
|
||||||
scope,
|
|
||||||
ownerId,
|
|
||||||
key: normalizeDataKey(options.key)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function validatePluginEventEnvelope(envelope: PluginEventEnvelope): Promise<ServerPluginEventDefinitionEntity> {
|
|
||||||
const pluginId = normalizePluginId(envelope.pluginId);
|
|
||||||
const eventName = normalizeEventName(envelope.eventName);
|
|
||||||
const definition = await eventDefinitionRepository().findOne({
|
|
||||||
where: {
|
|
||||||
serverId: envelope.serverId,
|
|
||||||
pluginId,
|
|
||||||
eventName
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!definition) {
|
|
||||||
throw new PluginSupportError(404, 'PLUGIN_EVENT_NOT_REGISTERED', 'Plugin event is not registered for this server');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (definition.direction === 'p2pHint') {
|
|
||||||
throw new PluginSupportError(400, 'PLUGIN_EVENT_NOT_RELAYABLE', 'P2P plugin events must not be relayed by the signal server');
|
|
||||||
}
|
|
||||||
|
|
||||||
const payloadBytes = Buffer.byteLength(serializeJsonValue(envelope.payload, 'INVALID_PLUGIN_EVENT_PAYLOAD'), 'utf8');
|
|
||||||
|
|
||||||
if (payloadBytes > definition.maxPayloadBytes) {
|
|
||||||
throw new PluginSupportError(413, 'PLUGIN_EVENT_TOO_LARGE', 'Plugin event payload is too large');
|
|
||||||
}
|
|
||||||
|
|
||||||
return definition;
|
|
||||||
}
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
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,11 +8,6 @@ import {
|
|||||||
isOderIdConnectedToServer
|
isOderIdConnectedToServer
|
||||||
} from './broadcast';
|
} from './broadcast';
|
||||||
import { authorizeWebSocketJoin } from '../services/server-access.service';
|
import { authorizeWebSocketJoin } from '../services/server-access.service';
|
||||||
import {
|
|
||||||
getPluginRequirementsSnapshot,
|
|
||||||
PluginSupportError,
|
|
||||||
validatePluginEventEnvelope
|
|
||||||
} from '../services/plugin-support.service';
|
|
||||||
|
|
||||||
interface WsMessage {
|
interface WsMessage {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
@@ -55,29 +50,6 @@ function readMessageId(value: unknown): string | undefined {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendPluginError(user: ConnectedUser, error: unknown, message: WsMessage): void {
|
|
||||||
if (error instanceof PluginSupportError) {
|
|
||||||
user.ws.send(JSON.stringify({
|
|
||||||
type: 'plugin_error',
|
|
||||||
serverId: typeof message['serverId'] === 'string' ? message['serverId'] : undefined,
|
|
||||||
pluginId: typeof message['pluginId'] === 'string' ? message['pluginId'] : undefined,
|
|
||||||
eventName: typeof message['eventName'] === 'string' ? message['eventName'] : undefined,
|
|
||||||
eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined,
|
|
||||||
code: error.code,
|
|
||||||
message: error.message
|
|
||||||
}));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error('Unhandled plugin websocket error:', error);
|
|
||||||
user.ws.send(JSON.stringify({
|
|
||||||
type: 'plugin_error',
|
|
||||||
code: 'INTERNAL_ERROR',
|
|
||||||
message: 'Internal server error'
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sends the current user list for a given server to a single connected user. */
|
/** Sends the current user list for a given server to a single connected user. */
|
||||||
function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
||||||
const users = getUniqueUsersInServer(serverId, user.oderId)
|
const users = getUniqueUsersInServer(serverId, user.oderId)
|
||||||
@@ -92,20 +64,6 @@ function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
|||||||
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
|
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendPluginRequirements(user: ConnectedUser, serverId: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
const snapshot = await getPluginRequirementsSnapshot(serverId);
|
|
||||||
|
|
||||||
user.ws.send(JSON.stringify({
|
|
||||||
type: 'plugin_requirements',
|
|
||||||
serverId,
|
|
||||||
snapshot
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
|
||||||
sendPluginError(user, error, { type: 'plugin_requirements', serverId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||||
const newOderId = readMessageId(message['oderId']) ?? connectionId;
|
const newOderId = readMessageId(message['oderId']) ?? connectionId;
|
||||||
const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined;
|
const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined;
|
||||||
@@ -179,7 +137,6 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
|
|||||||
);
|
);
|
||||||
|
|
||||||
sendServerUsers(user, sid);
|
sendServerUsers(user, sid);
|
||||||
await sendPluginRequirements(user, sid);
|
|
||||||
|
|
||||||
if (isNewIdentityMembership) {
|
if (isNewIdentityMembership) {
|
||||||
broadcastToServer(sid, {
|
broadcastToServer(sid, {
|
||||||
@@ -194,22 +151,17 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
|
function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||||
const viewSid = readMessageId(message['serverId']);
|
const viewSid = readMessageId(message['serverId']);
|
||||||
|
|
||||||
if (!viewSid)
|
if (!viewSid)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (!user.serverIds.has(viewSid)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
user.viewedServerId = viewSid;
|
user.viewedServerId = viewSid;
|
||||||
connectedUsers.set(connectionId, user);
|
connectedUsers.set(connectionId, user);
|
||||||
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) viewing server ${viewSid}`);
|
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) viewing server ${viewSid}`);
|
||||||
|
|
||||||
sendServerUsers(user, viewSid);
|
sendServerUsers(user, viewSid);
|
||||||
await sendPluginRequirements(user, viewSid);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||||
@@ -316,52 +268,6 @@ function handleStatusUpdate(user: ConnectedUser, message: WsMessage, connectionI
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePluginEvent(user: ConnectedUser, message: WsMessage): Promise<void> {
|
|
||||||
const serverId = readMessageId(message['serverId']) ?? user.viewedServerId;
|
|
||||||
const pluginId = readMessageId(message['pluginId']);
|
|
||||||
const eventName = readMessageId(message['eventName']);
|
|
||||||
|
|
||||||
if (!serverId || !pluginId || !eventName || !user.serverIds.has(serverId)) {
|
|
||||||
user.ws.send(JSON.stringify({
|
|
||||||
type: 'plugin_error',
|
|
||||||
serverId,
|
|
||||||
pluginId,
|
|
||||||
eventName,
|
|
||||||
eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined,
|
|
||||||
code: 'INVALID_PLUGIN_EVENT',
|
|
||||||
message: 'Plugin event is missing required fields or server membership'
|
|
||||||
}));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await validatePluginEventEnvelope({
|
|
||||||
type: 'plugin_event',
|
|
||||||
serverId,
|
|
||||||
pluginId,
|
|
||||||
eventName,
|
|
||||||
eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined,
|
|
||||||
payload: message['payload'],
|
|
||||||
sourcePluginUserId: typeof message['sourcePluginUserId'] === 'string' ? message['sourcePluginUserId'] : undefined
|
|
||||||
});
|
|
||||||
|
|
||||||
broadcastToServer(serverId, {
|
|
||||||
type: 'plugin_event',
|
|
||||||
serverId,
|
|
||||||
pluginId,
|
|
||||||
eventName,
|
|
||||||
eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined,
|
|
||||||
payload: message['payload'],
|
|
||||||
sourcePluginUserId: typeof message['sourcePluginUserId'] === 'string' ? message['sourcePluginUserId'] : undefined,
|
|
||||||
sourceUserId: user.oderId,
|
|
||||||
emittedAt: Date.now()
|
|
||||||
}, user.oderId);
|
|
||||||
} catch (error) {
|
|
||||||
sendPluginError(user, error, message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handleWebSocketMessage(connectionId: string, message: WsMessage): Promise<void> {
|
export async function handleWebSocketMessage(connectionId: string, message: WsMessage): Promise<void> {
|
||||||
const user = connectedUsers.get(connectionId);
|
const user = connectedUsers.get(connectionId);
|
||||||
|
|
||||||
@@ -384,7 +290,7 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'view_server':
|
case 'view_server':
|
||||||
await handleViewServer(user, message, connectionId);
|
handleViewServer(user, message, connectionId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'leave_server':
|
case 'leave_server':
|
||||||
@@ -409,10 +315,6 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
|
|||||||
handleStatusUpdate(user, message, connectionId);
|
handleStatusUpdate(user, message, connectionId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'plugin_event':
|
|
||||||
await handlePluginEvent(user, message);
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
console.log('Unknown message type:', message.type);
|
console.log('Unknown message type:', message.type);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ import {
|
|||||||
} from './broadcast';
|
} from './broadcast';
|
||||||
import { handleWebSocketMessage } from './handler';
|
import { handleWebSocketMessage } from './handler';
|
||||||
|
|
||||||
type IncomingWebSocketMessage = Parameters<typeof handleWebSocketMessage>[1];
|
|
||||||
|
|
||||||
/** How often to ping all connected clients (ms). */
|
/** How often to ping all connected clients (ms). */
|
||||||
const PING_INTERVAL_MS = 30_000;
|
const PING_INTERVAL_MS = 30_000;
|
||||||
/** Maximum time a client can go without a pong before we consider it dead (ms). */
|
/** Maximum time a client can go without a pong before we consider it dead (ms). */
|
||||||
@@ -91,20 +89,12 @@ export function setupWebSocket(server: Server<typeof IncomingMessage, typeof Ser
|
|||||||
});
|
});
|
||||||
|
|
||||||
ws.on('message', async (data) => {
|
ws.on('message', async (data) => {
|
||||||
let message: IncomingWebSocketMessage;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
message = JSON.parse(data.toString()) as IncomingWebSocketMessage;
|
const message = JSON.parse(data.toString());
|
||||||
} catch (err) {
|
|
||||||
console.error('Invalid WebSocket message:', err);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await handleWebSocketMessage(connectionId, message);
|
await handleWebSocketMessage(connectionId, message);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('WebSocket message handler failed:', err);
|
console.error('Invalid WebSocket message:', err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -96,13 +96,13 @@
|
|||||||
"budgets": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
"maximumWarning": "10mb",
|
"maximumWarning": "2.2MB",
|
||||||
"maximumError": "20mb"
|
"maximumError": "2.38MB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
"maximumWarning": "10mb",
|
"maximumWarning": "4kB",
|
||||||
"maximumError": "20mb"
|
"maximumError": "8kB"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputHashing": "all"
|
"outputHashing": "all"
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 319 B |
@@ -1,293 +0,0 @@
|
|||||||
const tinyWave = 'data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAESsAACJWAAACABAAZGF0YQAAAAA=';
|
|
||||||
const originalMessage = 'Plugin API original message';
|
|
||||||
const editedMessage = 'Plugin API edited message';
|
|
||||||
const deletedMessage = 'Plugin API deleted message';
|
|
||||||
const embedMessage = 'toju:embed:e2e.coverage:{"title":"Plugin API custom embed","body":"Rendered by plugin API"}';
|
|
||||||
const soundboardPlayedMessage = 'E2E soundboard played Airhorn to voice channel';
|
|
||||||
|
|
||||||
export async function activate(context) {
|
|
||||||
const api = context.api;
|
|
||||||
const currentUser = api.profile.getCurrent();
|
|
||||||
const shouldMutateChat = !currentUser?.displayName?.includes('Bob');
|
|
||||||
const pluginUserId = api.server.registerPluginUser({
|
|
||||||
displayName: 'E2E Plugin Bot',
|
|
||||||
id: 'e2e-plugin-bot'
|
|
||||||
});
|
|
||||||
|
|
||||||
context.subscriptions.push(api.ui.registerSettingsPage('coverage', {
|
|
||||||
label: 'E2E Coverage',
|
|
||||||
render: () => 'E2E settings contribution'
|
|
||||||
}));
|
|
||||||
context.subscriptions.push(api.ui.registerAppPage('coverage', {
|
|
||||||
label: 'E2E Page',
|
|
||||||
path: '/plugins/e2e/coverage',
|
|
||||||
render: () => 'E2E page contribution'
|
|
||||||
}));
|
|
||||||
context.subscriptions.push(api.ui.registerSidePanel('coverage', {
|
|
||||||
label: 'E2E Soundboard',
|
|
||||||
render: () => 'E2E soundboard ready'
|
|
||||||
}));
|
|
||||||
context.subscriptions.push(api.ui.registerChannelSection('coverage', {
|
|
||||||
label: 'E2E Soundboard',
|
|
||||||
type: 'custom'
|
|
||||||
}));
|
|
||||||
context.subscriptions.push(api.ui.registerComposerAction('coverage', {
|
|
||||||
icon: 'SFX',
|
|
||||||
label: 'E2E Soundboard',
|
|
||||||
run: () => openSoundboardModal(api, pluginUserId)
|
|
||||||
}));
|
|
||||||
context.subscriptions.push(api.ui.registerProfileAction('coverage', {
|
|
||||||
label: 'E2E Profile',
|
|
||||||
run: () => api.logger.info('profile action ran')
|
|
||||||
}));
|
|
||||||
context.subscriptions.push(api.ui.registerToolbarAction('coverage', {
|
|
||||||
label: 'E2E Toolbar',
|
|
||||||
run: () => api.logger.info('toolbar action ran')
|
|
||||||
}));
|
|
||||||
context.subscriptions.push(api.ui.registerEmbedRenderer('coverage', {
|
|
||||||
embedType: 'e2e.coverage',
|
|
||||||
render: (payload) => `E2E custom embed: ${payload?.title ?? 'missing title'}`
|
|
||||||
}));
|
|
||||||
|
|
||||||
const injectedBadge = document.createElement('div');
|
|
||||||
|
|
||||||
injectedBadge.dataset.testid = 'e2e-plugin-owned-dom';
|
|
||||||
injectedBadge.textContent = 'E2E plugin-owned DOM injected into chat';
|
|
||||||
injectedBadge.style.position = 'absolute';
|
|
||||||
injectedBadge.style.left = '1rem';
|
|
||||||
injectedBadge.style.bottom = '5.5rem';
|
|
||||||
injectedBadge.style.zIndex = '20';
|
|
||||||
injectedBadge.style.border = '1px solid hsl(var(--border))';
|
|
||||||
injectedBadge.style.borderRadius = '0.5rem';
|
|
||||||
injectedBadge.style.padding = '0.35rem 0.5rem';
|
|
||||||
injectedBadge.style.background = 'hsl(var(--card))';
|
|
||||||
injectedBadge.style.color = 'hsl(var(--foreground))';
|
|
||||||
injectedBadge.style.fontSize = '0.75rem';
|
|
||||||
context.subscriptions.push(api.ui.mountElement('chat-owned-badge', {
|
|
||||||
element: injectedBadge,
|
|
||||||
target: 'app-chat-messages'
|
|
||||||
}));
|
|
||||||
|
|
||||||
context.subscriptions.push(api.events.subscribeServer({ eventName: 'e2e:server', handler: () => {} }));
|
|
||||||
context.subscriptions.push(api.events.subscribeP2p({ eventName: 'e2e:p2p', handler: () => {} }));
|
|
||||||
|
|
||||||
api.storage.set('coverage', { ok: true });
|
|
||||||
api.storage.get('coverage');
|
|
||||||
await api.clientData.write('coverage', { ok: true });
|
|
||||||
await api.clientData.read('coverage');
|
|
||||||
await api.serverData.write('coverage', { ok: true });
|
|
||||||
await api.serverData.read('coverage');
|
|
||||||
|
|
||||||
api.profile.update({
|
|
||||||
description: 'Updated by E2E plugin',
|
|
||||||
displayName: `${currentUser?.displayName || 'E2E Plugin User'} Plugin Renamed`
|
|
||||||
});
|
|
||||||
api.profile.updateAvatar({
|
|
||||||
avatarHash: 'e2e-plugin-avatar',
|
|
||||||
avatarMime: 'image/svg+xml',
|
|
||||||
avatarUrl: '/plugins/e2e-all-api/icon.svg'
|
|
||||||
});
|
|
||||||
|
|
||||||
api.users.getCurrent();
|
|
||||||
api.users.list();
|
|
||||||
api.users.readMembers();
|
|
||||||
api.users.setRole(pluginUserId, 'member');
|
|
||||||
api.users.kick(pluginUserId);
|
|
||||||
api.users.ban(pluginUserId, 'E2E coverage');
|
|
||||||
|
|
||||||
api.roles.list();
|
|
||||||
api.roles.setAssignments([]);
|
|
||||||
|
|
||||||
api.channels.list();
|
|
||||||
api.channels.addAudioChannel({ id: 'e2e-audio', name: 'E2E Audio', position: 90 });
|
|
||||||
api.channels.addVideoChannel({ id: 'e2e-video', name: 'E2E Video', position: 91 });
|
|
||||||
api.channels.select('general');
|
|
||||||
api.channels.rename('e2e-audio', 'E2E Audio Renamed');
|
|
||||||
|
|
||||||
api.server.getCurrent();
|
|
||||||
api.server.updatePermissions({ allowVoice: true });
|
|
||||||
api.server.updateSettings({
|
|
||||||
name: api.server.getCurrent()?.name,
|
|
||||||
topic: 'Updated by E2E plugin'
|
|
||||||
});
|
|
||||||
|
|
||||||
api.messages.readCurrent();
|
|
||||||
if (shouldMutateChat) {
|
|
||||||
const sentMessage = api.messages.send(originalMessage);
|
|
||||||
|
|
||||||
api.messages.edit(sentMessage.id, editedMessage);
|
|
||||||
|
|
||||||
const removableMessage = api.messages.send(deletedMessage);
|
|
||||||
|
|
||||||
api.messages.delete(removableMessage.id);
|
|
||||||
api.messages.send(embedMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
api.messages.sendAsPluginUser({
|
|
||||||
content: 'Plugin bot message from all-api fixture',
|
|
||||||
pluginUserId
|
|
||||||
});
|
|
||||||
api.messages.moderateDelete('missing-message-id');
|
|
||||||
api.messages.sync(api.messages.readCurrent());
|
|
||||||
context.subscriptions.push(api.messageBus.subscribe({
|
|
||||||
handler: () => {},
|
|
||||||
latestMessageLimit: 5,
|
|
||||||
replayLatest: true,
|
|
||||||
topic: 'e2e:latest'
|
|
||||||
}));
|
|
||||||
api.messageBus.publish({
|
|
||||||
includeLatestMessages: true,
|
|
||||||
includeSelf: true,
|
|
||||||
latestMessageLimit: 5,
|
|
||||||
payload: { ok: true },
|
|
||||||
topic: 'e2e:latest'
|
|
||||||
});
|
|
||||||
api.messageBus.sendLatestMessages({
|
|
||||||
limit: 5,
|
|
||||||
topic: 'e2e:latest'
|
|
||||||
});
|
|
||||||
|
|
||||||
api.p2p.connectedPeers();
|
|
||||||
api.p2p.broadcastData('e2e:p2p', { ok: true });
|
|
||||||
api.p2p.sendData('missing-peer', 'e2e:p2p', { ok: true });
|
|
||||||
api.events.publishServer('e2e:server', { ok: true });
|
|
||||||
api.events.publishP2p('e2e:p2p', { ok: true });
|
|
||||||
|
|
||||||
api.media.setOutputVolume(0.8);
|
|
||||||
api.media.setInputVolume(0.8);
|
|
||||||
await api.media.playAudioClip({ url: tinyWave, volume: 0 }).catch((error) => api.logger.warn('audio clip rejected', String(error)));
|
|
||||||
await api.media.addCustomVideoStream({ label: 'e2e-video', stream: new MediaStream() });
|
|
||||||
|
|
||||||
const audioContext = new AudioContext();
|
|
||||||
const destination = audioContext.createMediaStreamDestination();
|
|
||||||
|
|
||||||
await api.media.addCustomAudioStream({ label: 'e2e-audio', stream: destination.stream }).catch((error) => api.logger.warn('audio stream rejected', String(error)));
|
|
||||||
await audioContext.close();
|
|
||||||
|
|
||||||
api.storage.remove('coverage');
|
|
||||||
await api.clientData.remove('coverage');
|
|
||||||
await api.serverData.remove('coverage');
|
|
||||||
api.logger.info('all-api plugin completed');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ready(context) {
|
|
||||||
context.api.logger.info('all-api plugin ready');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deactivate(context) {
|
|
||||||
context.api.logger.info('all-api plugin deactivated');
|
|
||||||
}
|
|
||||||
|
|
||||||
function openSoundboardModal(api, pluginUserId) {
|
|
||||||
document.querySelector('[data-testid="e2e-soundboard-modal"]')?.remove();
|
|
||||||
|
|
||||||
const overlay = document.createElement('div');
|
|
||||||
|
|
||||||
overlay.dataset.testid = 'e2e-soundboard-modal';
|
|
||||||
overlay.setAttribute('role', 'dialog');
|
|
||||||
overlay.setAttribute('aria-modal', 'true');
|
|
||||||
overlay.setAttribute('aria-label', 'E2E Soundboard');
|
|
||||||
overlay.style.position = 'fixed';
|
|
||||||
overlay.style.inset = '0';
|
|
||||||
overlay.style.zIndex = '9999';
|
|
||||||
overlay.style.display = 'grid';
|
|
||||||
overlay.style.placeItems = 'center';
|
|
||||||
overlay.style.background = 'rgb(0 0 0 / 0.45)';
|
|
||||||
|
|
||||||
const panel = document.createElement('section');
|
|
||||||
|
|
||||||
panel.style.width = 'min(24rem, calc(100vw - 2rem))';
|
|
||||||
panel.style.border = '1px solid hsl(var(--border))';
|
|
||||||
panel.style.borderRadius = '0.5rem';
|
|
||||||
panel.style.padding = '1rem';
|
|
||||||
panel.style.color = 'hsl(var(--foreground))';
|
|
||||||
panel.style.background = 'hsl(var(--card))';
|
|
||||||
panel.style.boxShadow = '0 1.25rem 3rem rgb(0 0 0 / 0.25)';
|
|
||||||
|
|
||||||
const title = document.createElement('h2');
|
|
||||||
|
|
||||||
title.textContent = 'E2E Soundboard';
|
|
||||||
title.style.margin = '0 0 0.75rem';
|
|
||||||
title.style.fontSize = '1rem';
|
|
||||||
|
|
||||||
const status = document.createElement('p');
|
|
||||||
|
|
||||||
status.dataset.testid = 'e2e-soundboard-status';
|
|
||||||
status.textContent = 'Ready to play to voice channel';
|
|
||||||
status.style.margin = '0 0 1rem';
|
|
||||||
status.style.color = 'hsl(var(--muted-foreground))';
|
|
||||||
status.style.fontSize = '0.875rem';
|
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
|
||||||
|
|
||||||
actions.style.display = 'flex';
|
|
||||||
actions.style.gap = '0.5rem';
|
|
||||||
actions.style.justifyContent = 'flex-end';
|
|
||||||
|
|
||||||
const closeButton = document.createElement('button');
|
|
||||||
|
|
||||||
closeButton.type = 'button';
|
|
||||||
closeButton.textContent = 'Close';
|
|
||||||
closeButton.style.border = '1px solid hsl(var(--border))';
|
|
||||||
closeButton.style.borderRadius = '0.375rem';
|
|
||||||
closeButton.style.padding = '0.5rem 0.75rem';
|
|
||||||
closeButton.style.background = 'transparent';
|
|
||||||
closeButton.style.color = 'hsl(var(--foreground))';
|
|
||||||
closeButton.addEventListener('click', () => overlay.remove());
|
|
||||||
|
|
||||||
const playButton = document.createElement('button');
|
|
||||||
|
|
||||||
playButton.type = 'button';
|
|
||||||
playButton.textContent = 'Play airhorn to voice';
|
|
||||||
playButton.style.border = '0';
|
|
||||||
playButton.style.borderRadius = '0.375rem';
|
|
||||||
playButton.style.padding = '0.5rem 0.75rem';
|
|
||||||
playButton.style.background = 'hsl(var(--primary))';
|
|
||||||
playButton.style.color = 'hsl(var(--primary-foreground))';
|
|
||||||
playButton.addEventListener('click', async () => {
|
|
||||||
playButton.disabled = true;
|
|
||||||
status.textContent = 'Playing Airhorn to voice channel';
|
|
||||||
|
|
||||||
try {
|
|
||||||
await playSoundboardClipToVoice(api);
|
|
||||||
api.p2p.broadcastData('e2e:p2p', { sound: 'airhorn', source: 'soundboard' });
|
|
||||||
api.events.publishP2p('e2e:p2p', { sound: 'airhorn', source: 'soundboard' });
|
|
||||||
api.messages.sendAsPluginUser({ content: soundboardPlayedMessage, pluginUserId });
|
|
||||||
api.logger.info('soundboard played to voice channel');
|
|
||||||
status.textContent = soundboardPlayedMessage;
|
|
||||||
} catch (error) {
|
|
||||||
status.textContent = error instanceof Error ? error.message : 'Soundboard playback failed';
|
|
||||||
api.logger.warn('soundboard playback failed', String(error));
|
|
||||||
} finally {
|
|
||||||
playButton.disabled = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.append(closeButton, playButton);
|
|
||||||
panel.append(title, status, actions);
|
|
||||||
overlay.append(panel);
|
|
||||||
api.ui.mountElement('soundboard-modal', {
|
|
||||||
element: overlay,
|
|
||||||
target: 'body'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function playSoundboardClipToVoice(api) {
|
|
||||||
const audioContext = new AudioContext();
|
|
||||||
const oscillator = audioContext.createOscillator();
|
|
||||||
const gain = audioContext.createGain();
|
|
||||||
const destination = audioContext.createMediaStreamDestination();
|
|
||||||
|
|
||||||
oscillator.type = 'square';
|
|
||||||
oscillator.frequency.value = 330;
|
|
||||||
gain.gain.value = 0.08;
|
|
||||||
oscillator.connect(gain);
|
|
||||||
gain.connect(destination);
|
|
||||||
oscillator.start();
|
|
||||||
|
|
||||||
await api.media.addCustomAudioStream({ label: 'e2e-soundboard-airhorn', stream: destination.stream });
|
|
||||||
await api.media.playAudioClip({ url: tinyWave, volume: 0 }).catch((error) => api.logger.warn('soundboard preview rejected', String(error)));
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
||||||
oscillator.stop();
|
|
||||||
await audioContext.close();
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
{
|
|
||||||
"schemaVersion": 1,
|
|
||||||
"id": "e2e.all-api-plugin",
|
|
||||||
"title": "E2E All API Plugin",
|
|
||||||
"description": "Calls every public Toju plugin API surface for user-facing Playwright coverage.",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"kind": "client",
|
|
||||||
"scope": "server",
|
|
||||||
"apiVersion": "1.0.0",
|
|
||||||
"compatibility": {
|
|
||||||
"minimumTojuVersion": "1.0.0",
|
|
||||||
"verifiedTojuVersion": "1.0.0"
|
|
||||||
},
|
|
||||||
"entrypoint": "./main.js",
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "MetoYou Tests",
|
|
||||||
"url": "https://git.azaaxin.com/myxelium/Toju"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"homepage": "https://git.azaaxin.com/myxelium/Toju",
|
|
||||||
"readme": "./README.md",
|
|
||||||
"capabilities": [
|
|
||||||
"profile.read",
|
|
||||||
"profile.write",
|
|
||||||
"users.read",
|
|
||||||
"users.manage",
|
|
||||||
"roles.read",
|
|
||||||
"roles.manage",
|
|
||||||
"messages.read",
|
|
||||||
"messages.send",
|
|
||||||
"messages.editOwn",
|
|
||||||
"messages.deleteOwn",
|
|
||||||
"messages.moderate",
|
|
||||||
"messages.sync",
|
|
||||||
"channels.read",
|
|
||||||
"channels.manage",
|
|
||||||
"server.read",
|
|
||||||
"server.manage",
|
|
||||||
"p2p.data",
|
|
||||||
"p2p.media",
|
|
||||||
"media.playAudio",
|
|
||||||
"media.addAudioStream",
|
|
||||||
"media.addVideoStream",
|
|
||||||
"audio.volume",
|
|
||||||
"audio.effects",
|
|
||||||
"ui.settings",
|
|
||||||
"ui.pages",
|
|
||||||
"ui.sidePanel",
|
|
||||||
"ui.channelsSection",
|
|
||||||
"ui.embeds",
|
|
||||||
"ui.dom",
|
|
||||||
"storage.local",
|
|
||||||
"storage.serverData.read",
|
|
||||||
"storage.serverData.write",
|
|
||||||
"events.server.publish",
|
|
||||||
"events.server.subscribe",
|
|
||||||
"events.p2p.publish",
|
|
||||||
"events.p2p.subscribe"
|
|
||||||
],
|
|
||||||
"events": [
|
|
||||||
{
|
|
||||||
"eventName": "e2e:server",
|
|
||||||
"direction": "serverRelay",
|
|
||||||
"scope": "server",
|
|
||||||
"maxPayloadBytes": 2048
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"eventName": "e2e:p2p",
|
|
||||||
"direction": "p2pHint",
|
|
||||||
"scope": "user",
|
|
||||||
"maxPayloadBytes": 2048
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"data": [
|
|
||||||
{
|
|
||||||
"key": "coverage",
|
|
||||||
"scope": "server",
|
|
||||||
"storage": "serverData"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"settings": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"enabled": {
|
|
||||||
"type": "boolean",
|
|
||||||
"default": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ui": {
|
|
||||||
"settingsPages": ["coverage"],
|
|
||||||
"sidePanels": ["coverage"],
|
|
||||||
"channelSections": ["coverage"]
|
|
||||||
},
|
|
||||||
"pluginUser": {
|
|
||||||
"displayName": "E2E Plugin Bot",
|
|
||||||
"label": "All API fixture"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "MetoYou E2E Plugin Source",
|
|
||||||
"plugins": [
|
|
||||||
{
|
|
||||||
"id": "e2e.all-api-plugin",
|
|
||||||
"title": "E2E All API Plugin",
|
|
||||||
"description": "Test plugin that calls every public Toju plugin API surface.",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"scope": "server",
|
|
||||||
"author": "MetoYou Tests",
|
|
||||||
"image": "./e2e-all-api/icon.svg",
|
|
||||||
"github": "https://git.azaaxin.com/myxelium/Toju",
|
|
||||||
"homepage": "https://git.azaaxin.com/myxelium/Toju",
|
|
||||||
"install": "./e2e-all-api/toju.plugin.json",
|
|
||||||
"readme": "./e2e-all-api/README.md"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -48,15 +48,5 @@ export const routes: Routes = [
|
|||||||
path: 'settings',
|
path: 'settings',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/settings/settings.component').then((module) => module.SettingsComponent)
|
import('./features/settings/settings.component').then((module) => module.SettingsComponent)
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'plugin-store',
|
|
||||||
loadComponent: () =>
|
|
||||||
import('./domains/plugins/feature/plugin-store/plugin-store.component').then((module) => module.PluginStoreComponent)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'plugins/:pluginId/:pageId',
|
|
||||||
loadComponent: () =>
|
|
||||||
import('./domains/plugins/feature/plugin-page-host/plugin-page-host.component').then((module) => module.PluginPageHostComponent)
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -49,20 +49,4 @@ export type {
|
|||||||
ChatAttachmentMeta
|
ChatAttachmentMeta
|
||||||
} from '../../shared-kernel';
|
} from '../../shared-kernel';
|
||||||
|
|
||||||
export type {
|
|
||||||
PluginCapabilityId,
|
|
||||||
PluginDataChangedMessage,
|
|
||||||
PluginErrorMessage,
|
|
||||||
PluginEventDefinitionSummary,
|
|
||||||
PluginEventDirection,
|
|
||||||
PluginEventEnvelope,
|
|
||||||
PluginEventScope,
|
|
||||||
PluginRequirementStatus,
|
|
||||||
PluginRequirementSummary,
|
|
||||||
PluginRequirementsChangedMessage,
|
|
||||||
PluginRequirementsMessage,
|
|
||||||
PluginRequirementsSnapshot,
|
|
||||||
TojuPluginManifest
|
|
||||||
} from '../../shared-kernel';
|
|
||||||
|
|
||||||
export type { ServerInfo } from '../../domains/server-directory';
|
export type { ServerInfo } from '../../domains/server-directory';
|
||||||
|
|||||||
@@ -124,28 +124,6 @@ export interface SavedThemeFileDescriptor {
|
|||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LocalPluginManifestDescriptor {
|
|
||||||
discoveredAt: number;
|
|
||||||
entrypointPath?: string;
|
|
||||||
pluginRootUrl: string;
|
|
||||||
manifest: unknown;
|
|
||||||
manifestPath: string;
|
|
||||||
pluginRoot: string;
|
|
||||||
readmePath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LocalPluginDiscoveryError {
|
|
||||||
manifestPath?: string;
|
|
||||||
message: string;
|
|
||||||
pluginRoot?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LocalPluginDiscoveryResult {
|
|
||||||
errors: LocalPluginDiscoveryError[];
|
|
||||||
plugins: LocalPluginManifestDescriptor[];
|
|
||||||
pluginsPath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExportUserDataResult {
|
export interface ExportUserDataResult {
|
||||||
cancelled: boolean;
|
cancelled: boolean;
|
||||||
exported: boolean;
|
exported: boolean;
|
||||||
@@ -211,8 +189,6 @@ export interface ElectronApi {
|
|||||||
importUserData: () => Promise<ImportUserDataResult>;
|
importUserData: () => Promise<ImportUserDataResult>;
|
||||||
eraseUserData: () => Promise<EraseUserDataResult>;
|
eraseUserData: () => Promise<EraseUserDataResult>;
|
||||||
getSavedThemesPath: () => Promise<string>;
|
getSavedThemesPath: () => Promise<string>;
|
||||||
getLocalPluginsPath: () => Promise<string>;
|
|
||||||
listLocalPluginManifests: () => Promise<LocalPluginDiscoveryResult>;
|
|
||||||
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
|
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
|
||||||
readSavedTheme: (fileName: string) => Promise<string>;
|
readSavedTheme: (fileName: string) => Promise<string>;
|
||||||
writeSavedTheme: (fileName: string, text: string) => Promise<boolean>;
|
writeSavedTheme: (fileName: string, text: string) => Promise<boolean>;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Injectable, signal } from '@angular/core';
|
|||||||
|
|
||||||
export type SettingsPage =
|
export type SettingsPage =
|
||||||
| 'general'
|
| 'general'
|
||||||
| 'plugins'
|
|
||||||
| 'theme'
|
| 'theme'
|
||||||
| 'network'
|
| 'network'
|
||||||
| 'notifications'
|
| 'notifications'
|
||||||
@@ -11,7 +10,6 @@ export type SettingsPage =
|
|||||||
| 'data'
|
| 'data'
|
||||||
| 'debugging'
|
| 'debugging'
|
||||||
| 'server'
|
| 'server'
|
||||||
| 'serverPlugins'
|
|
||||||
| 'members'
|
| 'members'
|
||||||
| 'bans'
|
| 'bans'
|
||||||
| 'permissions';
|
| 'permissions';
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ infrastructure adapters and UI.
|
|||||||
| **direct-message** | One-to-one WebRTC messages, offline queueing, delivery state, and friends | `DirectMessageService`, `FriendService` |
|
| **direct-message** | One-to-one WebRTC messages, offline queueing, delivery state, and friends | `DirectMessageService`, `FriendService` |
|
||||||
| **game-activity** | Local game detection, server metadata matching, P2P now-playing sync, and elapsed playtime formatting | `GameActivityService`, `formatGameActivityElapsed()` |
|
| **game-activity** | Local game detection, server metadata matching, P2P now-playing sync, and elapsed playtime formatting | `GameActivityService`, `formatGameActivityElapsed()` |
|
||||||
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
|
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
|
||||||
| **plugins** | Client-only plugin manifests, load ordering, registry state, and signal-server support metadata | `PluginHostService`, `PluginRegistryService` |
|
|
||||||
| **profile-avatar** | Profile picture upload, crop/zoom editing, processing, local persistence, and P2P avatar sync | `ProfileAvatarFacade` |
|
| **profile-avatar** | Profile picture upload, crop/zoom editing, processing, local persistence, and P2P avatar sync | `ProfileAvatarFacade` |
|
||||||
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
|
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
|
||||||
| **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` |
|
| **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` |
|
||||||
@@ -33,7 +32,6 @@ The larger domains also keep longer design notes in their own folders:
|
|||||||
- [chat/README.md](chat/README.md)
|
- [chat/README.md](chat/README.md)
|
||||||
- [direct-message/README.md](direct-message/README.md)
|
- [direct-message/README.md](direct-message/README.md)
|
||||||
- [notifications/README.md](notifications/README.md)
|
- [notifications/README.md](notifications/README.md)
|
||||||
- [plugins/README.md](plugins/README.md)
|
|
||||||
- [profile-avatar/README.md](profile-avatar/README.md)
|
- [profile-avatar/README.md](profile-avatar/README.md)
|
||||||
- [screen-share/README.md](screen-share/README.md)
|
- [screen-share/README.md](screen-share/README.md)
|
||||||
- [server-directory/README.md](server-directory/README.md)
|
- [server-directory/README.md](server-directory/README.md)
|
||||||
|
|||||||
@@ -141,20 +141,6 @@
|
|||||||
(drop)="onDrop($event)"
|
(drop)="onDrop($event)"
|
||||||
>
|
>
|
||||||
<div class="absolute bottom-3 right-3 z-10 flex items-center gap-2 m-0.5">
|
<div class="absolute bottom-3 right-3 z-10 flex items-center gap-2 m-0.5">
|
||||||
@for (record of pluginComposerActions(); track record.id) {
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="runPluginComposerAction(record.contribution.run)"
|
|
||||||
class="inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-2xl border border-border/70 bg-secondary/55 px-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground shadow-sm backdrop-blur-md transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/35 hover:bg-secondary/90 hover:text-foreground"
|
|
||||||
[class.opacity-100]="inputHovered()"
|
|
||||||
[class.opacity-70]="!inputHovered()"
|
|
||||||
[attr.aria-label]="record.contribution.label"
|
|
||||||
[title]="record.contribution.label"
|
|
||||||
>
|
|
||||||
<span>{{ record.contribution.icon ?? record.contribution.label }}</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (klipyEnabled()) {
|
@if (klipyEnabled()) {
|
||||||
<button
|
<button
|
||||||
#klipyTrigger
|
#klipyTrigger
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import type { ClipboardFilePayload } from '../../../../../../core/platform/elect
|
|||||||
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
|
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
|
||||||
import { KlipyGif, KlipyService } from '../../../../application/services/klipy.service';
|
import { KlipyGif, KlipyService } from '../../../../application/services/klipy.service';
|
||||||
import { Message } from '../../../../../../shared-kernel';
|
import { Message } from '../../../../../../shared-kernel';
|
||||||
import { PluginUiRegistryService } from '../../../../../plugins';
|
|
||||||
import { ThemeNodeDirective } from '../../../../../theme';
|
import { ThemeNodeDirective } from '../../../../../theme';
|
||||||
import type { RoomSignalSourceInput } from '../../../../../server-directory';
|
import type { RoomSignalSourceInput } from '../../../../../server-directory';
|
||||||
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive';
|
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive';
|
||||||
@@ -83,10 +82,8 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
|||||||
private readonly klipy = inject(KlipyService);
|
private readonly klipy = inject(KlipyService);
|
||||||
private readonly markdown = inject(ChatMarkdownService);
|
private readonly markdown = inject(ChatMarkdownService);
|
||||||
private readonly electronBridge = inject(ElectronBridgeService);
|
private readonly electronBridge = inject(ElectronBridgeService);
|
||||||
private readonly pluginUi = inject(PluginUiRegistryService);
|
|
||||||
|
|
||||||
readonly pendingKlipyGif = signal<KlipyGif | null>(null);
|
readonly pendingKlipyGif = signal<KlipyGif | null>(null);
|
||||||
readonly pluginComposerActions = this.pluginUi.composerActionRecords;
|
|
||||||
readonly toolbarVisible = signal(false);
|
readonly toolbarVisible = signal(false);
|
||||||
readonly dragActive = signal(false);
|
readonly dragActive = signal(false);
|
||||||
readonly inputHovered = signal(false);
|
readonly inputHovered = signal(false);
|
||||||
@@ -222,11 +219,6 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
|||||||
this.klipyGifPickerToggleRequested.emit();
|
this.klipyGifPickerToggleRequested.emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
runPluginComposerAction(action: () => Promise<void> | void): void {
|
|
||||||
void Promise.resolve()
|
|
||||||
.then(() => action());
|
|
||||||
}
|
|
||||||
|
|
||||||
getKlipyTriggerRect(): DOMRect | null {
|
getKlipyTriggerRect(): DOMRect | null {
|
||||||
return this.klipyTrigger?.nativeElement.getBoundingClientRect() ?? null;
|
return this.klipyTrigger?.nativeElement.getBoundingClientRect() ?? null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,20 +115,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (pluginEmbeds().length > 0) {
|
|
||||||
<div class="mt-2 space-y-2" data-testid="plugin-message-embeds">
|
|
||||||
@for (embed of pluginEmbeds(); track embed.id) {
|
|
||||||
<article class="rounded-md border border-border bg-secondary/30 p-3">
|
|
||||||
<div class="mb-2 flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
|
||||||
<span>{{ embed.contribution.embedType }}</span>
|
|
||||||
<span>{{ embed.pluginId }}</span>
|
|
||||||
</div>
|
|
||||||
<app-plugin-render-host [render]="embed.render" />
|
|
||||||
</article>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (attachmentsList.length > 0) {
|
@if (attachmentsList.length > 0) {
|
||||||
<div class="mt-2 space-y-2">
|
<div class="mt-2 space-y-2">
|
||||||
@for (att of attachmentsList; track att.id) {
|
@for (att of attachmentsList; track att.id) {
|
||||||
|
|||||||
@@ -38,8 +38,6 @@ import {
|
|||||||
User
|
User
|
||||||
} from '../../../../../../shared-kernel';
|
} from '../../../../../../shared-kernel';
|
||||||
import { ThemeNodeDirective } from '../../../../../theme';
|
import { ThemeNodeDirective } from '../../../../../theme';
|
||||||
import { PluginRenderHostComponent } from '../../../../../plugins/feature/plugin-render-host/plugin-render-host.component';
|
|
||||||
import { PluginUiRegistryService } from '../../../../../plugins';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ChatAudioPlayerComponent,
|
ChatAudioPlayerComponent,
|
||||||
@@ -100,7 +98,6 @@ interface ChatMessageAttachmentViewModel extends Attachment {
|
|||||||
ChatMessageMarkdownComponent,
|
ChatMessageMarkdownComponent,
|
||||||
ChatLinkEmbedComponent,
|
ChatLinkEmbedComponent,
|
||||||
UserAvatarComponent,
|
UserAvatarComponent,
|
||||||
PluginRenderHostComponent,
|
|
||||||
ThemeNodeDirective
|
ThemeNodeDirective
|
||||||
],
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
@@ -127,7 +124,6 @@ export class ChatMessageItemComponent {
|
|||||||
|
|
||||||
private readonly attachmentsSvc = inject(AttachmentFacade);
|
private readonly attachmentsSvc = inject(AttachmentFacade);
|
||||||
private readonly klipy = inject(KlipyService);
|
private readonly klipy = inject(KlipyService);
|
||||||
private readonly pluginUi = inject(PluginUiRegistryService);
|
|
||||||
private readonly profileCard = inject(ProfileCardService);
|
private readonly profileCard = inject(ProfileCardService);
|
||||||
private readonly attachmentVersion = signal(this.attachmentsSvc.updated());
|
private readonly attachmentVersion = signal(this.attachmentsSvc.updated());
|
||||||
|
|
||||||
@@ -150,7 +146,6 @@ export class ChatMessageItemComponent {
|
|||||||
|
|
||||||
readonly commonEmojis = COMMON_EMOJIS;
|
readonly commonEmojis = COMMON_EMOJIS;
|
||||||
readonly deletedMessageContent = DELETED_MESSAGE_CONTENT;
|
readonly deletedMessageContent = DELETED_MESSAGE_CONTENT;
|
||||||
readonly pluginEmbeds = computed(() => this.findPluginEmbeds(this.message().content));
|
|
||||||
readonly isEditing = signal(false);
|
readonly isEditing = signal(false);
|
||||||
readonly showEmojiPicker = signal(false);
|
readonly showEmojiPicker = signal(false);
|
||||||
readonly senderUser = computed<User>(() => {
|
readonly senderUser = computed<User>(() => {
|
||||||
@@ -196,28 +191,6 @@ export class ChatMessageItemComponent {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
private findPluginEmbeds(content: string) {
|
|
||||||
const match = /^toju:embed:([a-zA-Z0-9._:-]+):([\s\S]*)$/.exec(content.trim());
|
|
||||||
|
|
||||||
if (!match) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const [
|
|
||||||
,
|
|
||||||
embedType,
|
|
||||||
payloadText
|
|
||||||
] = match;
|
|
||||||
const payload = parseEmbedPayload(payloadText);
|
|
||||||
|
|
||||||
return this.pluginUi.embedRecords()
|
|
||||||
.filter((record) => record.contribution.embedType === embedType)
|
|
||||||
.map((record) => ({
|
|
||||||
...record,
|
|
||||||
render: () => record.contribution.render(payload)
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
startEdit(): void {
|
startEdit(): void {
|
||||||
this.editContent = this.message().content;
|
this.editContent = this.message().content;
|
||||||
this.isEditing.set(true);
|
this.isEditing.set(true);
|
||||||
@@ -534,15 +507,3 @@ export class ChatMessageItemComponent {
|
|||||||
return this.attachmentsSvc.getForMessage(this.message().id).find((attachment) => attachment.id === attachmentId);
|
return this.attachmentsSvc.getForMessage(this.message().id).find((attachment) => attachment.id === attachmentId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseEmbedPayload(payloadText: string | undefined): unknown {
|
|
||||||
if (!payloadText?.trim()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return JSON.parse(payloadText) as unknown;
|
|
||||||
} catch {
|
|
||||||
return payloadText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -173,8 +173,7 @@ export class GameActivityService implements OnDestroy {
|
|||||||
const matchedGame = await this.matchRunningGame(processNames);
|
const matchedGame = await this.matchRunningGame(processNames);
|
||||||
|
|
||||||
this.ngZone.run(() => this.applyMatchedGame(matchedGame));
|
this.ngZone.run(() => this.applyMatchedGame(matchedGame));
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.warn('[GameActivity] Failed to scan running processes', error);
|
|
||||||
return;
|
return;
|
||||||
} finally {
|
} finally {
|
||||||
this.scanInFlight = false;
|
this.scanInFlight = false;
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
# Plugins Domain
|
|
||||||
|
|
||||||
Owns the client-only plugin runtime foundation: manifest validation, deterministic load ordering, registry state, local manifest discovery, capability grants, browser-imported client entrypoints, disposable UI extension registries, plugin logs, and typed access to signal-server plugin support metadata.
|
|
||||||
|
|
||||||
The signal server stores plugin install metadata and event definitions, but it must never execute plugin code or store arbitrary plugin data. Executable plugin loading belongs to the renderer/Electron boundary and should enter this domain through `PluginHostService`.
|
|
||||||
|
|
||||||
Desktop local plugins are discovered from the Electron app data `plugins` folder. Discovery reads `toju-plugin.json` or `plugin.json` from immediate child folders and resolves declared entrypoint/readme paths only when they stay inside that plugin folder.
|
|
||||||
|
|
||||||
The standalone plugin store is available from the title bar Plugins button, the title-bar Plugin Store menu item, the legacy Settings page button, and the Plugin Manager header. It owns source manifest management, search, readmes, install/update/uninstall actions, and links back to installed-plugin management. Manifest `kind` describes runtime shape (`client` or `library`), while top-level manifest `scope` describes installation scope: omit it or use `scope: "client"` for global client plugins, and use `scope: "server"` for chat-server plugins. Server-scoped store entries are presented as Install to Server, Update Server, or Remove from Server.
|
|
||||||
|
|
||||||
The plugin manager UI is split between Settings -> Client plugins for global client plugins and Settings -> Server -> Server plugins for chat-server plugins. The two pages filter by manifest `scope` and include installed plugins, capability grant toggles, per-plugin activate/reload/unload actions, runtime logs, extension-point counts, server requirements, generated settings, and docs.
|
|
||||||
|
|
||||||
The Store tab consumes user-managed HTTP(S), `file://`, or absolute local-path source manifests. Local-path sources and entrypoints are read through the Electron desktop file bridge. A source manifest can expose a `plugins` array whose entries include `id`, `title`, `description`, `version`, `scope`, `author`/`authors`, `image`/`imageUrl`, `github`/`githubUrl`, `install`/`installUrl`/`manifestUrl`, and `readme`/`readmeUrl`. Installing a `scope: "server"` plugin fetches the linked plugin manifest, validates it, registers it with the client registry, and persists the basic install metadata as a server plugin requirement. Required server plugins are installed on each member client when that chat server opens; optional server plugins stay listed as server requirements but are not auto-installed. Installing a `scope: "client"` plugin persists it locally for the current desktop/browser client.
|
|
||||||
|
|
||||||
The server-side plugin support API is metadata-only. The signal server can keep plugin id, requirement status, version range, install/source URLs, and the validated manifest snapshot needed for member clients to install required plugins. Plugin `serverData` API calls are handled as local per-user/per-server client state; HTTP plugin data persistence on the signal server returns `PLUGIN_DATA_DISABLED`.
|
|
||||||
|
|
||||||
Plugin data that belongs to the current client uses the Electron database when the desktop bridge is available. The plugin runtime writes `api.clientData.*` and `api.serverData.*` records to Electron's dedicated `plugin_data` table, with renderer localStorage as the browser fallback. The legacy synchronous `api.storage.*` surface remains local and mirrors writes to the same Electron table when possible; plugins that need guaranteed database reads should use the async `api.clientData.*` methods.
|
|
||||||
|
|
||||||
Plugins can communicate over a plugin-only message bus through `api.messageBus`. It sends `plugin-message-bus` data-channel events that are ignored by the normal chat message reducers/effects, can target a peer or broadcast to connected users, and can include a bounded latest-message snapshot filtered by channel, timestamp, and deletion state.
|
|
||||||
|
|
||||||
Desktop plugin preferences that belong to the local user, including capability grants, disabled plugin ids, and previously activated plugin ids, are persisted through Electron's local database meta table with renderer localStorage as the browser fallback.
|
|
||||||
|
|
||||||
Runtime activation is explicit. `PluginHostService.activateReadyPlugins()` imports browser-safe plugin entrypoints from URL-resolvable manifests, passes a frozen `TojuClientPluginApi`, runs `activate`, then runs `ready` after the load-order pass. Successfully activated plugin ids are remembered locally, and store-installed plugins are reactivated for the active server when their persisted manifests load again. `deactivate` runs during unload/reload, disposables are cleaned in reverse order, and UI contributions are removed by plugin id.
|
|
||||||
|
|
||||||
Plugins that need fully custom UI can call `api.ui.mountElement(id, { target, element, position })` with the `ui.dom` capability. The runtime tags mounted elements with plugin ownership metadata, replaces duplicate mounts for the same plugin/id pair, and removes remaining mounted elements when the plugin is unloaded.
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
import {
|
|
||||||
Injectable,
|
|
||||||
computed,
|
|
||||||
inject,
|
|
||||||
signal
|
|
||||||
} from '@angular/core';
|
|
||||||
import { getUserScopedStorageKey } from '../../../../core/storage/current-user-storage';
|
|
||||||
import type { PluginCapabilityId, TojuPluginManifest } from '../../../../shared-kernel';
|
|
||||||
import { PluginDesktopStateService } from './plugin-desktop-state.service';
|
|
||||||
|
|
||||||
const STORAGE_KEY_PLUGIN_CAPABILITIES = 'metoyou_plugin_capability_grants';
|
|
||||||
|
|
||||||
export class PluginCapabilityError extends Error {
|
|
||||||
constructor(pluginId: string, capability: PluginCapabilityId) {
|
|
||||||
super(`Plugin ${pluginId} needs capability ${capability}`);
|
|
||||||
this.name = 'PluginCapabilityError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class PluginCapabilityService {
|
|
||||||
readonly grants = computed(() => this.grantsSignal());
|
|
||||||
|
|
||||||
private readonly desktopState = inject(PluginDesktopStateService);
|
|
||||||
private readonly grantsSignal = signal<Record<string, PluginCapabilityId[]>>(this.loadGrants());
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
void this.loadDesktopGrants();
|
|
||||||
}
|
|
||||||
|
|
||||||
grant(pluginId: string, capability: PluginCapabilityId): void {
|
|
||||||
this.grantsSignal.update((grants) => ({
|
|
||||||
...grants,
|
|
||||||
[pluginId]: Array.from(new Set([...(grants[pluginId] ?? []), capability])).sort()
|
|
||||||
}));
|
|
||||||
|
|
||||||
void this.saveGrants();
|
|
||||||
}
|
|
||||||
|
|
||||||
grantAll(manifest: TojuPluginManifest): void {
|
|
||||||
this.grantsSignal.update((grants) => ({
|
|
||||||
...grants,
|
|
||||||
[manifest.id]: [...(manifest.capabilities ?? [])].sort()
|
|
||||||
}));
|
|
||||||
|
|
||||||
void this.saveGrants();
|
|
||||||
}
|
|
||||||
|
|
||||||
revoke(pluginId: string, capability: PluginCapabilityId): void {
|
|
||||||
this.grantsSignal.update((grants) => ({
|
|
||||||
...grants,
|
|
||||||
[pluginId]: (grants[pluginId] ?? []).filter((entry) => entry !== capability)
|
|
||||||
}));
|
|
||||||
|
|
||||||
void this.saveGrants();
|
|
||||||
}
|
|
||||||
|
|
||||||
revokeAll(pluginId: string): void {
|
|
||||||
this.grantsSignal.update((grants) => {
|
|
||||||
const { [pluginId]: _removed, ...next } = grants;
|
|
||||||
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
void this.saveGrants();
|
|
||||||
}
|
|
||||||
|
|
||||||
has(pluginId: string, capability: PluginCapabilityId): boolean {
|
|
||||||
return this.grants()[pluginId]?.includes(capability) ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
assert(pluginId: string, capability: PluginCapabilityId): void {
|
|
||||||
if (!this.has(pluginId, capability)) {
|
|
||||||
throw new PluginCapabilityError(pluginId, capability);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
missing(manifest: TojuPluginManifest): PluginCapabilityId[] {
|
|
||||||
return (manifest.capabilities ?? []).filter((capability) => !this.has(manifest.id, capability));
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadGrants(): Record<string, PluginCapabilityId[]> {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(getUserScopedStorageKey(STORAGE_KEY_PLUGIN_CAPABILITIES));
|
|
||||||
|
|
||||||
if (!raw) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = JSON.parse(raw) as unknown;
|
|
||||||
|
|
||||||
return isGrantRecord(parsed) ? parsed : {};
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadDesktopGrants(): Promise<void> {
|
|
||||||
const grants = await this.desktopState.readJson<Record<string, PluginCapabilityId[]>>(STORAGE_KEY_PLUGIN_CAPABILITIES, this.grantsSignal());
|
|
||||||
|
|
||||||
if (isGrantRecord(grants)) {
|
|
||||||
this.grantsSignal.set(grants);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async saveGrants(): Promise<void> {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(
|
|
||||||
getUserScopedStorageKey(STORAGE_KEY_PLUGIN_CAPABILITIES),
|
|
||||||
JSON.stringify(this.grantsSignal())
|
|
||||||
);
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
await this.desktopState.writeJson(STORAGE_KEY_PLUGIN_CAPABILITIES, this.grantsSignal());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isGrantRecord(value: unknown): value is Record<string, PluginCapabilityId[]> {
|
|
||||||
return !!value
|
|
||||||
&& typeof value === 'object'
|
|
||||||
&& !Array.isArray(value)
|
|
||||||
&& Object.values(value).every((entry) => Array.isArray(entry) && entry.every((item) => typeof item === 'string'));
|
|
||||||
}
|
|
||||||
@@ -1,596 +0,0 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
|
||||||
import { Store } from '@ngrx/store';
|
|
||||||
import { Subscription } from 'rxjs';
|
|
||||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
|
||||||
import { VoiceConnectionFacade } from '../../../voice-connection/application/facades/voice-connection.facade';
|
|
||||||
import type {
|
|
||||||
Channel,
|
|
||||||
ChatEvent,
|
|
||||||
Message,
|
|
||||||
PluginCapabilityId,
|
|
||||||
PluginEventEnvelope,
|
|
||||||
TojuPluginManifest,
|
|
||||||
User
|
|
||||||
} from '../../../../shared-kernel';
|
|
||||||
import { MessagesActions } from '../../../../store/messages/messages.actions';
|
|
||||||
import { selectCurrentRoomMessages } from '../../../../store/messages/messages.selectors';
|
|
||||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
|
||||||
import {
|
|
||||||
selectActiveChannelId,
|
|
||||||
selectCurrentRoom,
|
|
||||||
selectCurrentRoomChannels,
|
|
||||||
selectCurrentRoomId
|
|
||||||
} from '../../../../store/rooms/rooms.selectors';
|
|
||||||
import { UsersActions } from '../../../../store/users/users.actions';
|
|
||||||
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
|
|
||||||
import type {
|
|
||||||
PluginApiAvatarUpdate,
|
|
||||||
PluginApiChannelRequest,
|
|
||||||
PluginApiCustomStreamRequest,
|
|
||||||
PluginApiMessageAsPluginUserRequest,
|
|
||||||
PluginApiServerSettingsUpdate,
|
|
||||||
TojuClientPluginApi
|
|
||||||
} from '../../domain/models/plugin-api.models';
|
|
||||||
import { PluginCapabilityService } from './plugin-capability.service';
|
|
||||||
import { PluginLoggerService } from './plugin-logger.service';
|
|
||||||
import { PluginMessageBusService } from './plugin-message-bus.service';
|
|
||||||
import { PluginStorageService } from './plugin-storage.service';
|
|
||||||
import { PluginUiRegistryService } from './plugin-ui-registry.service';
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class PluginClientApiService {
|
|
||||||
private readonly capabilities = inject(PluginCapabilityService);
|
|
||||||
private readonly logger = inject(PluginLoggerService);
|
|
||||||
private readonly messageBus = inject(PluginMessageBusService);
|
|
||||||
private readonly realtime = inject(RealtimeSessionFacade);
|
|
||||||
private readonly store = inject(Store);
|
|
||||||
private readonly storage = inject(PluginStorageService);
|
|
||||||
private readonly uiRegistry = inject(PluginUiRegistryService);
|
|
||||||
private readonly voice = inject(VoiceConnectionFacade);
|
|
||||||
|
|
||||||
private readonly currentMessages = this.store.selectSignal(selectCurrentRoomMessages);
|
|
||||||
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
|
||||||
private readonly currentRoomChannels = this.store.selectSignal(selectCurrentRoomChannels);
|
|
||||||
private readonly currentRoomId = this.store.selectSignal(selectCurrentRoomId);
|
|
||||||
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
|
||||||
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
|
||||||
private readonly users = this.store.selectSignal(selectAllUsers);
|
|
||||||
|
|
||||||
createApi(manifest: TojuPluginManifest): TojuClientPluginApi {
|
|
||||||
const pluginId = manifest.id;
|
|
||||||
const requireCapability = (capability: PluginCapabilityId): void => this.capabilities.assert(pluginId, capability);
|
|
||||||
const assertEvent = (eventName: string): void => this.assertDeclaredEvent(manifest, eventName);
|
|
||||||
|
|
||||||
return deepFreeze<TojuClientPluginApi>({
|
|
||||||
channels: {
|
|
||||||
addAudioChannel: (request) => {
|
|
||||||
requireCapability('channels.manage');
|
|
||||||
this.store.dispatch(RoomsActions.addChannel({ channel: createChannel(request, 'voice') }));
|
|
||||||
},
|
|
||||||
addVideoChannel: (request) => {
|
|
||||||
requireCapability('channels.manage');
|
|
||||||
this.uiRegistry.registerChannelSection(pluginId, request.id ?? request.name, {
|
|
||||||
label: request.name,
|
|
||||||
order: request.position,
|
|
||||||
type: 'video'
|
|
||||||
});
|
|
||||||
},
|
|
||||||
list: () => {
|
|
||||||
requireCapability('channels.read');
|
|
||||||
return this.currentRoomChannels();
|
|
||||||
},
|
|
||||||
remove: (channelId) => {
|
|
||||||
requireCapability('channels.manage');
|
|
||||||
this.store.dispatch(RoomsActions.removeChannel({ channelId }));
|
|
||||||
},
|
|
||||||
rename: (channelId, name) => {
|
|
||||||
requireCapability('channels.manage');
|
|
||||||
this.store.dispatch(RoomsActions.renameChannel({ channelId, name }));
|
|
||||||
},
|
|
||||||
select: (channelId) => {
|
|
||||||
requireCapability('channels.read');
|
|
||||||
this.store.dispatch(RoomsActions.selectChannel({ channelId }));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
events: {
|
|
||||||
publishP2p: (eventName, payload) => {
|
|
||||||
requireCapability('events.p2p.publish');
|
|
||||||
assertEvent(eventName);
|
|
||||||
this.broadcastPluginEvent(pluginId, eventName, payload, 'p2p');
|
|
||||||
},
|
|
||||||
publishServer: (eventName, payload) => {
|
|
||||||
requireCapability('events.server.publish');
|
|
||||||
assertEvent(eventName);
|
|
||||||
this.publishServerPluginEvent(pluginId, eventName, payload);
|
|
||||||
},
|
|
||||||
subscribeP2p: (subscription) => {
|
|
||||||
requireCapability('events.p2p.subscribe');
|
|
||||||
assertEvent(subscription.eventName);
|
|
||||||
return this.rememberSubscription(pluginId, subscription.eventName);
|
|
||||||
},
|
|
||||||
subscribeServer: (subscription) => {
|
|
||||||
requireCapability('events.server.subscribe');
|
|
||||||
assertEvent(subscription.eventName);
|
|
||||||
return this.subscribeServerPluginEvent(pluginId, subscription.eventName, subscription.handler);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
logger: {
|
|
||||||
debug: (message, data) => this.logger.debug(pluginId, message, data),
|
|
||||||
error: (message, data) => this.logger.error(pluginId, message, data),
|
|
||||||
info: (message, data) => this.logger.info(pluginId, message, data),
|
|
||||||
warn: (message, data) => this.logger.warn(pluginId, message, data)
|
|
||||||
},
|
|
||||||
clientData: {
|
|
||||||
read: async (key) => {
|
|
||||||
requireCapability('storage.local');
|
|
||||||
return await this.storage.readClientData(pluginId, key);
|
|
||||||
},
|
|
||||||
remove: async (key) => {
|
|
||||||
requireCapability('storage.local');
|
|
||||||
await this.storage.removeClientData(pluginId, key);
|
|
||||||
},
|
|
||||||
write: async (key, value) => {
|
|
||||||
requireCapability('storage.local');
|
|
||||||
await this.storage.writeClientData(pluginId, key, value);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
media: {
|
|
||||||
addCustomAudioStream: async (request) => {
|
|
||||||
requireCapability('media.addAudioStream');
|
|
||||||
await this.voice.setLocalStream(request.stream);
|
|
||||||
},
|
|
||||||
addCustomVideoStream: async (_request: PluginApiCustomStreamRequest) => {
|
|
||||||
requireCapability('media.addVideoStream');
|
|
||||||
this.logger.info(pluginId, 'Video stream contribution registered');
|
|
||||||
},
|
|
||||||
playAudioClip: async (request) => {
|
|
||||||
requireCapability('media.playAudio');
|
|
||||||
await playAudioClip(request.url, request.volume);
|
|
||||||
},
|
|
||||||
setInputVolume: (volume) => {
|
|
||||||
requireCapability('audio.volume');
|
|
||||||
this.voice.setInputVolume(volume);
|
|
||||||
},
|
|
||||||
setOutputVolume: (volume) => {
|
|
||||||
requireCapability('audio.volume');
|
|
||||||
this.voice.setOutputVolume(volume);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
messages: {
|
|
||||||
delete: (messageId) => {
|
|
||||||
requireCapability('messages.deleteOwn');
|
|
||||||
this.deletePluginMessage(messageId);
|
|
||||||
},
|
|
||||||
edit: (messageId, content) => {
|
|
||||||
requireCapability('messages.editOwn');
|
|
||||||
this.editPluginMessage(messageId, content);
|
|
||||||
},
|
|
||||||
moderateDelete: (messageId) => {
|
|
||||||
requireCapability('messages.moderate');
|
|
||||||
this.store.dispatch(MessagesActions.adminDeleteMessage({ messageId }));
|
|
||||||
},
|
|
||||||
readCurrent: () => {
|
|
||||||
requireCapability('messages.read');
|
|
||||||
return this.currentMessages();
|
|
||||||
},
|
|
||||||
send: (content, channelId) => {
|
|
||||||
requireCapability('messages.send');
|
|
||||||
return this.sendPluginMessage(content, channelId);
|
|
||||||
},
|
|
||||||
sendAsPluginUser: (request) => {
|
|
||||||
requireCapability('messages.send');
|
|
||||||
this.receivePluginUserMessage(pluginId, request);
|
|
||||||
},
|
|
||||||
sync: (messages) => {
|
|
||||||
requireCapability('messages.sync');
|
|
||||||
this.store.dispatch(MessagesActions.syncMessages({ messages }));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
messageBus: {
|
|
||||||
publish: (request) => {
|
|
||||||
requireCapability('events.p2p.publish');
|
|
||||||
|
|
||||||
if (request.includeLatestMessages) {
|
|
||||||
requireCapability('messages.read');
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.messageBus.publish(pluginId, request);
|
|
||||||
},
|
|
||||||
sendLatestMessages: (request = {}) => {
|
|
||||||
requireCapability('events.p2p.publish');
|
|
||||||
requireCapability('messages.read');
|
|
||||||
return this.messageBus.sendLatestMessages(pluginId, request);
|
|
||||||
},
|
|
||||||
subscribe: (subscription) => {
|
|
||||||
requireCapability('events.p2p.subscribe');
|
|
||||||
|
|
||||||
if (subscription.replayLatest) {
|
|
||||||
requireCapability('messages.read');
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.messageBus.subscribe(pluginId, subscription);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
p2p: {
|
|
||||||
broadcastData: (eventName, payload) => {
|
|
||||||
requireCapability('p2p.data');
|
|
||||||
this.broadcastPluginEvent(pluginId, eventName, payload, 'p2p');
|
|
||||||
},
|
|
||||||
connectedPeers: () => {
|
|
||||||
requireCapability('p2p.data');
|
|
||||||
return this.voice.getConnectedPeers();
|
|
||||||
},
|
|
||||||
sendData: (peerId, eventName, payload) => {
|
|
||||||
requireCapability('p2p.data');
|
|
||||||
this.broadcastPluginEvent(pluginId, eventName, { payload, peerId }, 'p2p');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
profile: {
|
|
||||||
getCurrent: () => {
|
|
||||||
requireCapability('profile.read');
|
|
||||||
return this.currentUser() ?? null;
|
|
||||||
},
|
|
||||||
update: (profile) => {
|
|
||||||
requireCapability('profile.write');
|
|
||||||
this.store.dispatch(UsersActions.updateCurrentUserProfile({
|
|
||||||
profile: {
|
|
||||||
...profile,
|
|
||||||
profileUpdatedAt: Date.now()
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
updateAvatar: (avatar: PluginApiAvatarUpdate) => {
|
|
||||||
requireCapability('profile.write');
|
|
||||||
this.store.dispatch(UsersActions.updateCurrentUserAvatar({
|
|
||||||
avatar: {
|
|
||||||
...avatar,
|
|
||||||
avatarUpdatedAt: Date.now()
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
roles: {
|
|
||||||
list: () => {
|
|
||||||
requireCapability('roles.read');
|
|
||||||
return this.currentRoom()?.roles ?? [];
|
|
||||||
},
|
|
||||||
setAssignments: (assignments) => {
|
|
||||||
requireCapability('roles.manage');
|
|
||||||
this.updateRoomAccessControl({ roleAssignments: assignments });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
getCurrent: () => {
|
|
||||||
requireCapability('server.read');
|
|
||||||
return this.currentRoom();
|
|
||||||
},
|
|
||||||
registerPluginUser: (request) => {
|
|
||||||
requireCapability('users.manage');
|
|
||||||
const userId = request.id ?? `${pluginId}:${slug(request.displayName)}`;
|
|
||||||
|
|
||||||
this.store.dispatch(UsersActions.userJoined({
|
|
||||||
user: {
|
|
||||||
avatarUrl: request.avatarUrl,
|
|
||||||
displayName: request.displayName,
|
|
||||||
id: userId,
|
|
||||||
isOnline: true,
|
|
||||||
joinedAt: Date.now(),
|
|
||||||
oderId: userId,
|
|
||||||
role: 'member',
|
|
||||||
status: 'online',
|
|
||||||
username: userId
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
return userId;
|
|
||||||
},
|
|
||||||
updatePermissions: (permissions) => {
|
|
||||||
requireCapability('server.manage');
|
|
||||||
this.store.dispatch(RoomsActions.updateRoomPermissions({ roomId: this.requireRoomId(), permissions }));
|
|
||||||
},
|
|
||||||
updateSettings: (settings: PluginApiServerSettingsUpdate) => {
|
|
||||||
requireCapability('server.manage');
|
|
||||||
this.store.dispatch(RoomsActions.updateRoomSettings({
|
|
||||||
roomId: this.requireRoomId(),
|
|
||||||
settings: {
|
|
||||||
description: settings.description,
|
|
||||||
hasPassword: !!settings.password,
|
|
||||||
isPrivate: settings.isPrivate ?? this.currentRoom()?.isPrivate ?? false,
|
|
||||||
maxUsers: settings.maxUsers,
|
|
||||||
name: settings.name ?? this.currentRoom()?.name ?? 'Server',
|
|
||||||
password: settings.password,
|
|
||||||
rules: [],
|
|
||||||
topic: settings.topic
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
serverData: {
|
|
||||||
read: async (key) => {
|
|
||||||
requireCapability('storage.serverData.read');
|
|
||||||
return await this.storage.readServerData(pluginId, key);
|
|
||||||
},
|
|
||||||
remove: async (key) => {
|
|
||||||
requireCapability('storage.serverData.write');
|
|
||||||
await this.storage.removeServerData(pluginId, key);
|
|
||||||
},
|
|
||||||
write: async (key, value) => {
|
|
||||||
requireCapability('storage.serverData.write');
|
|
||||||
await this.storage.writeServerData(pluginId, key, value);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
storage: {
|
|
||||||
get: (key) => {
|
|
||||||
requireCapability('storage.local');
|
|
||||||
return this.storage.getLocal(pluginId, key);
|
|
||||||
},
|
|
||||||
remove: (key) => {
|
|
||||||
requireCapability('storage.local');
|
|
||||||
this.storage.removeLocal(pluginId, key);
|
|
||||||
},
|
|
||||||
set: (key, value) => {
|
|
||||||
requireCapability('storage.local');
|
|
||||||
this.storage.setLocal(pluginId, key, value);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
ui: {
|
|
||||||
registerAppPage: (id, contribution) => {
|
|
||||||
requireCapability('ui.pages');
|
|
||||||
return this.uiRegistry.registerAppPage(pluginId, id, contribution);
|
|
||||||
},
|
|
||||||
registerChannelSection: (id, contribution) => {
|
|
||||||
requireCapability('ui.channelsSection');
|
|
||||||
return this.uiRegistry.registerChannelSection(pluginId, id, contribution);
|
|
||||||
},
|
|
||||||
registerComposerAction: (id, contribution) => {
|
|
||||||
requireCapability('ui.pages');
|
|
||||||
return this.uiRegistry.registerComposerAction(pluginId, id, contribution);
|
|
||||||
},
|
|
||||||
registerEmbedRenderer: (id, contribution) => {
|
|
||||||
requireCapability('ui.embeds');
|
|
||||||
return this.uiRegistry.registerEmbedRenderer(pluginId, id, contribution);
|
|
||||||
},
|
|
||||||
mountElement: (id, request) => {
|
|
||||||
requireCapability('ui.dom');
|
|
||||||
return this.uiRegistry.mountElement(pluginId, id, request);
|
|
||||||
},
|
|
||||||
registerProfileAction: (id, contribution) => {
|
|
||||||
requireCapability('ui.pages');
|
|
||||||
return this.uiRegistry.registerProfileAction(pluginId, id, contribution);
|
|
||||||
},
|
|
||||||
registerSettingsPage: (id, contribution) => {
|
|
||||||
requireCapability('ui.settings');
|
|
||||||
return this.uiRegistry.registerSettingsPage(pluginId, id, contribution);
|
|
||||||
},
|
|
||||||
registerSidePanel: (id, contribution) => {
|
|
||||||
requireCapability('ui.sidePanel');
|
|
||||||
return this.uiRegistry.registerSidePanel(pluginId, id, contribution);
|
|
||||||
},
|
|
||||||
registerToolbarAction: (id, contribution) => {
|
|
||||||
requireCapability('ui.pages');
|
|
||||||
return this.uiRegistry.registerToolbarAction(pluginId, id, contribution);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
users: {
|
|
||||||
ban: (userId, reason) => {
|
|
||||||
requireCapability('users.manage');
|
|
||||||
this.store.dispatch(UsersActions.banUser({ reason, userId }));
|
|
||||||
},
|
|
||||||
getCurrent: () => {
|
|
||||||
requireCapability('users.read');
|
|
||||||
return this.currentUser() ?? null;
|
|
||||||
},
|
|
||||||
kick: (userId) => {
|
|
||||||
requireCapability('users.manage');
|
|
||||||
this.store.dispatch(UsersActions.kickUser({ userId }));
|
|
||||||
},
|
|
||||||
list: () => {
|
|
||||||
requireCapability('users.read');
|
|
||||||
return this.users();
|
|
||||||
},
|
|
||||||
readMembers: () => {
|
|
||||||
requireCapability('users.read');
|
|
||||||
return this.currentRoom()?.members ?? [];
|
|
||||||
},
|
|
||||||
setRole: (userId, role: User['role']) => {
|
|
||||||
requireCapability('roles.manage');
|
|
||||||
this.store.dispatch(UsersActions.updateUserRole({ role, userId }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private assertDeclaredEvent(manifest: TojuPluginManifest, eventName: string): void {
|
|
||||||
const declared = manifest.events?.some((event) => event.eventName === eventName) ?? false;
|
|
||||||
|
|
||||||
if (!declared) {
|
|
||||||
throw new Error(`Plugin ${manifest.id} did not declare event ${eventName}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private broadcastPluginEvent(pluginId: string, eventName: string, payload: unknown, target: 'p2p' | 'server'): void {
|
|
||||||
const roomId = this.currentRoomId() ?? 'local';
|
|
||||||
const event: PluginEventEnvelope = {
|
|
||||||
emittedAt: Date.now(),
|
|
||||||
eventId: createId(),
|
|
||||||
eventName,
|
|
||||||
payload,
|
|
||||||
pluginId,
|
|
||||||
serverId: roomId,
|
|
||||||
type: 'plugin_event'
|
|
||||||
};
|
|
||||||
|
|
||||||
this.voice.broadcastMessage({
|
|
||||||
data: JSON.stringify({ event, target }),
|
|
||||||
roomId,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
type: 'plugin-event'
|
|
||||||
} as unknown as ChatEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
private publishServerPluginEvent(pluginId: string, eventName: string, payload: unknown): void {
|
|
||||||
this.realtime.sendRawMessage({
|
|
||||||
type: 'plugin_event',
|
|
||||||
eventId: createId(),
|
|
||||||
eventName,
|
|
||||||
payload,
|
|
||||||
pluginId,
|
|
||||||
serverId: this.requireRoomId()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private subscribeServerPluginEvent(
|
|
||||||
pluginId: string,
|
|
||||||
eventName: string,
|
|
||||||
handler: (event: PluginEventEnvelope) => void
|
|
||||||
) {
|
|
||||||
const subscription = new Subscription();
|
|
||||||
|
|
||||||
subscription.add(this.realtime.onSignalingMessage.subscribe((message) => {
|
|
||||||
const record = message as Record<string, unknown>;
|
|
||||||
|
|
||||||
if (record['type'] !== 'plugin_event' || record['pluginId'] !== pluginId || record['eventName'] !== eventName) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
handler(message as PluginEventEnvelope);
|
|
||||||
}));
|
|
||||||
|
|
||||||
this.logger.info(pluginId, `Subscribed to server event ${eventName}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
dispose: () => {
|
|
||||||
subscription.unsubscribe();
|
|
||||||
this.logger.info(pluginId, `Unsubscribed from server event ${eventName}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private receivePluginUserMessage(pluginId: string, request: PluginApiMessageAsPluginUserRequest): void {
|
|
||||||
const roomId = this.requireRoomId();
|
|
||||||
const message: Message = {
|
|
||||||
channelId: request.channelId ?? this.activeChannelId() ?? undefined,
|
|
||||||
content: request.content,
|
|
||||||
id: createId(),
|
|
||||||
isDeleted: false,
|
|
||||||
reactions: [],
|
|
||||||
roomId,
|
|
||||||
senderId: request.pluginUserId,
|
|
||||||
senderName: request.pluginUserId,
|
|
||||||
timestamp: Date.now()
|
|
||||||
};
|
|
||||||
|
|
||||||
this.logger.info(pluginId, 'Plugin user message emitted', { messageId: message.id });
|
|
||||||
this.store.dispatch(MessagesActions.receiveMessage({ message }));
|
|
||||||
this.voice.broadcastMessage({ type: 'chat-message', message } as unknown as ChatEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
private deletePluginMessage(messageId: string): void {
|
|
||||||
this.store.dispatch(MessagesActions.deleteMessageSuccess({ messageId }));
|
|
||||||
this.voice.broadcastMessage({
|
|
||||||
deletedAt: Date.now(),
|
|
||||||
messageId,
|
|
||||||
type: 'message-deleted'
|
|
||||||
} as unknown as ChatEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
private editPluginMessage(messageId: string, content: string): void {
|
|
||||||
const editedAt = Date.now();
|
|
||||||
|
|
||||||
this.store.dispatch(MessagesActions.editMessageSuccess({
|
|
||||||
content,
|
|
||||||
editedAt,
|
|
||||||
messageId
|
|
||||||
}));
|
|
||||||
|
|
||||||
this.voice.broadcastMessage({
|
|
||||||
content,
|
|
||||||
editedAt,
|
|
||||||
messageId,
|
|
||||||
type: 'message-edited'
|
|
||||||
} as unknown as ChatEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
private sendPluginMessage(content: string, channelId?: string): Message {
|
|
||||||
const currentUser = this.currentUser();
|
|
||||||
const roomId = this.requireRoomId();
|
|
||||||
const message: Message = {
|
|
||||||
channelId: channelId ?? this.activeChannelId() ?? 'general',
|
|
||||||
content,
|
|
||||||
id: createId(),
|
|
||||||
isDeleted: false,
|
|
||||||
reactions: [],
|
|
||||||
roomId,
|
|
||||||
senderId: currentUser?.id ?? 'plugin',
|
|
||||||
senderName: currentUser?.displayName || currentUser?.username || 'Plugin',
|
|
||||||
timestamp: Date.now()
|
|
||||||
};
|
|
||||||
|
|
||||||
this.store.dispatch(MessagesActions.sendMessageSuccess({ message }));
|
|
||||||
this.voice.broadcastMessage({ type: 'chat-message', message } as unknown as ChatEvent);
|
|
||||||
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
private rememberSubscription(pluginId: string, eventName: string) {
|
|
||||||
this.logger.info(pluginId, `Subscribed to ${eventName}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
dispose: () => this.logger.info(pluginId, `Unsubscribed from ${eventName}`)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private requireRoomId(): string {
|
|
||||||
const roomId = this.currentRoomId();
|
|
||||||
|
|
||||||
if (!roomId) {
|
|
||||||
throw new Error('No active server');
|
|
||||||
}
|
|
||||||
|
|
||||||
return roomId;
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateRoomAccessControl(changes: Parameters<typeof RoomsActions.updateRoomAccessControl>[0]['changes']): void {
|
|
||||||
this.store.dispatch(RoomsActions.updateRoomAccessControl({
|
|
||||||
changes,
|
|
||||||
roomId: this.requireRoomId()
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createChannel(request: PluginApiChannelRequest, type: Channel['type']): Channel {
|
|
||||||
return {
|
|
||||||
id: request.id ?? slug(request.name),
|
|
||||||
name: request.name,
|
|
||||||
position: request.position ?? Date.now(),
|
|
||||||
type
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createId(): string {
|
|
||||||
return globalThis.crypto?.randomUUID?.() ?? `plugin-${Date.now()}-${Math.random().toString(36)
|
|
||||||
.slice(2)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function deepFreeze<TValue extends object>(value: TValue): TValue {
|
|
||||||
for (const propertyValue of Object.values(value)) {
|
|
||||||
if (propertyValue && typeof propertyValue === 'object') {
|
|
||||||
deepFreeze(propertyValue as Record<string, unknown>);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.freeze(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function playAudioClip(url: string, volume = 1): Promise<void> {
|
|
||||||
const audio = new Audio(url);
|
|
||||||
|
|
||||||
audio.volume = Math.max(0, Math.min(1, volume));
|
|
||||||
await audio.play();
|
|
||||||
}
|
|
||||||
|
|
||||||
function slug(value: string): string {
|
|
||||||
return value.trim().toLowerCase()
|
|
||||||
.replace(/[^a-z0-9.-]+/g, '-')
|
|
||||||
.replace(/(^-+|-+$)/g, '') || createId();
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
|
||||||
import { getUserScopedStorageKey } from '../../../../core/storage/current-user-storage';
|
|
||||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class PluginDesktopStateService {
|
|
||||||
private readonly electronBridge = inject(ElectronBridgeService);
|
|
||||||
|
|
||||||
async readJson<TValue>(key: string, fallback: TValue): Promise<TValue> {
|
|
||||||
const raw = await this.readRaw(key);
|
|
||||||
|
|
||||||
if (!raw) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return JSON.parse(raw) as TValue;
|
|
||||||
} catch {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async writeJson(key: string, value: unknown): Promise<void> {
|
|
||||||
await this.writeRaw(key, JSON.stringify(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async readRaw(key: string): Promise<string | null> {
|
|
||||||
const api = this.electronBridge.getApi();
|
|
||||||
|
|
||||||
if (api) {
|
|
||||||
return await api.query<string | null>({
|
|
||||||
type: 'get-meta',
|
|
||||||
payload: { key }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return localStorage.getItem(getUserScopedStorageKey(key));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async writeRaw(key: string, value: string): Promise<void> {
|
|
||||||
const api = this.electronBridge.getApi();
|
|
||||||
|
|
||||||
if (api) {
|
|
||||||
await api.command({
|
|
||||||
type: 'save-meta',
|
|
||||||
payload: { key, value }
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorage.setItem(getUserScopedStorageKey(key), value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
import { Injector } from '@angular/core';
|
|
||||||
import type { TojuPluginManifest } from '../../../../shared-kernel';
|
|
||||||
import { DEVELOPMENT_PLUGIN_MANIFEST } from '../../development/development-plugin';
|
|
||||||
import type { LocalPluginDiscoveryResult } from '../../domain/models/plugin-runtime.models';
|
|
||||||
import { LocalPluginDiscoveryService } from '../../infrastructure/local-plugin-discovery.service';
|
|
||||||
import { PluginCapabilityService } from './plugin-capability.service';
|
|
||||||
import { PluginClientApiService } from './plugin-client-api.service';
|
|
||||||
import { PluginDesktopStateService } from './plugin-desktop-state.service';
|
|
||||||
import { PluginHostService } from './plugin-host.service';
|
|
||||||
import { PluginLoggerService } from './plugin-logger.service';
|
|
||||||
import { PluginRegistryService } from './plugin-registry.service';
|
|
||||||
import { PluginUiRegistryService } from './plugin-ui-registry.service';
|
|
||||||
|
|
||||||
const TEST_PLUGIN_MANIFEST = createTestPluginManifest();
|
|
||||||
|
|
||||||
describe('PluginHostService', () => {
|
|
||||||
let discoveryResult: LocalPluginDiscoveryResult;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
discoveryResult = {
|
|
||||||
errors: [],
|
|
||||||
plugins: [],
|
|
||||||
pluginsPath: '/plugins'
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
it('registers discovered test plugin manifests', async () => {
|
|
||||||
discoveryResult = {
|
|
||||||
errors: [],
|
|
||||||
plugins: [
|
|
||||||
{
|
|
||||||
discoveredAt: 1,
|
|
||||||
entrypointPath: '/plugins/api-test-plugin/dist/main.js',
|
|
||||||
manifest: TEST_PLUGIN_MANIFEST,
|
|
||||||
manifestPath: '/plugins/api-test-plugin/toju-plugin.json',
|
|
||||||
pluginRoot: '/plugins/api-test-plugin',
|
|
||||||
readmePath: '/plugins/api-test-plugin/README.md'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
pluginsPath: '/plugins'
|
|
||||||
};
|
|
||||||
|
|
||||||
const host = createHostService(() => discoveryResult);
|
|
||||||
const result = await host.discoverLocalPlugins();
|
|
||||||
|
|
||||||
expect(result.errors).toEqual([]);
|
|
||||||
expect(result.registered.map((plugin) => plugin.manifest.id)).toEqual([TEST_PLUGIN_MANIFEST.id]);
|
|
||||||
const readyManifestIds = host.getReadyManifests().map((manifest) => manifest.id);
|
|
||||||
|
|
||||||
expect(readyManifestIds.sort()).toEqual([DEVELOPMENT_PLUGIN_MANIFEST.id, TEST_PLUGIN_MANIFEST.id].sort());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('registers the built-in development plugin in development builds', () => {
|
|
||||||
const host = createHostService(() => discoveryResult);
|
|
||||||
|
|
||||||
expect(host.getReadyManifests().map((manifest) => manifest.id)).toEqual([DEVELOPMENT_PLUGIN_MANIFEST.id]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps discovery and validation failures visible to callers', async () => {
|
|
||||||
discoveryResult = {
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
manifestPath: '/plugins/broken/plugin.json',
|
|
||||||
message: 'Unexpected end of JSON input',
|
|
||||||
pluginRoot: '/plugins/broken'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
plugins: [
|
|
||||||
{
|
|
||||||
discoveredAt: 1,
|
|
||||||
manifest: {
|
|
||||||
...TEST_PLUGIN_MANIFEST,
|
|
||||||
entrypoint: undefined
|
|
||||||
},
|
|
||||||
manifestPath: '/plugins/invalid/toju-plugin.json',
|
|
||||||
pluginRoot: '/plugins/invalid'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
pluginsPath: '/plugins'
|
|
||||||
};
|
|
||||||
|
|
||||||
const host = createHostService(() => discoveryResult);
|
|
||||||
const result = await host.discoverLocalPlugins();
|
|
||||||
|
|
||||||
expect(result.registered).toEqual([]);
|
|
||||||
expect(result.errors.map((error) => error.pluginRoot)).toEqual(['/plugins/broken', '/plugins/invalid']);
|
|
||||||
|
|
||||||
expect(result.errors[1]?.message).toContain('client plugins require an entrypoint');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function createHostService(readDiscoveryResult: () => LocalPluginDiscoveryResult): PluginHostService {
|
|
||||||
const injector = Injector.create({
|
|
||||||
providers: [
|
|
||||||
PluginHostService,
|
|
||||||
PluginRegistryService,
|
|
||||||
{
|
|
||||||
provide: PluginCapabilityService,
|
|
||||||
useValue: {
|
|
||||||
missing: vi.fn(() => [])
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: PluginDesktopStateService,
|
|
||||||
useValue: {
|
|
||||||
readJson: vi.fn(async (_key: string, fallback: unknown) => fallback),
|
|
||||||
writeJson: vi.fn(async () => undefined)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: PluginClientApiService,
|
|
||||||
useValue: {
|
|
||||||
createApi: vi.fn(() => ({}))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: PluginLoggerService,
|
|
||||||
useValue: {
|
|
||||||
error: vi.fn(),
|
|
||||||
info: vi.fn(),
|
|
||||||
warn: vi.fn()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: PluginUiRegistryService,
|
|
||||||
useValue: {
|
|
||||||
unregisterPlugin: vi.fn()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: LocalPluginDiscoveryService,
|
|
||||||
useValue: {
|
|
||||||
discoverManifests: vi.fn(async () => readDiscoveryResult())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
return injector.get(PluginHostService);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTestPluginManifest(): TojuPluginManifest {
|
|
||||||
return {
|
|
||||||
apiVersion: '1.0.0',
|
|
||||||
capabilities: [
|
|
||||||
'storage.serverData.read',
|
|
||||||
'storage.serverData.write',
|
|
||||||
'events.server.publish'
|
|
||||||
],
|
|
||||||
compatibility: {
|
|
||||||
minimumTojuVersion: '1.0.0'
|
|
||||||
},
|
|
||||||
description: 'Fixture plugin used by automated tests for plugin support APIs.',
|
|
||||||
entrypoint: './dist/main.js',
|
|
||||||
events: [
|
|
||||||
{
|
|
||||||
direction: 'serverRelay',
|
|
||||||
eventName: 'e2e:relay',
|
|
||||||
maxPayloadBytes: 2048,
|
|
||||||
scope: 'server'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
id: 'e2e.plugin-api',
|
|
||||||
kind: 'client',
|
|
||||||
schemaVersion: 1,
|
|
||||||
title: 'E2E Plugin API Fixture',
|
|
||||||
version: '1.0.0'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,423 +0,0 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
|
||||||
import { environment } from '../../../../../environments/environment';
|
|
||||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
|
||||||
import type { TojuPluginManifest } from '../../../../shared-kernel';
|
|
||||||
import {
|
|
||||||
DEVELOPMENT_PLUGIN_ENTRYPOINT,
|
|
||||||
DEVELOPMENT_PLUGIN_MANIFEST,
|
|
||||||
DEVELOPMENT_PLUGIN_MODULE
|
|
||||||
} from '../../development/development-plugin';
|
|
||||||
import type {
|
|
||||||
TojuClientPluginModule,
|
|
||||||
TojuPluginActivationContext,
|
|
||||||
TojuPluginDisposable
|
|
||||||
} from '../../domain/models/plugin-api.models';
|
|
||||||
import type {
|
|
||||||
LocalPluginDiscoveryError,
|
|
||||||
LocalPluginRegistrationResult,
|
|
||||||
RegisteredPlugin
|
|
||||||
} from '../../domain/models/plugin-runtime.models';
|
|
||||||
import { LocalPluginDiscoveryService } from '../../infrastructure/local-plugin-discovery.service';
|
|
||||||
import { PluginCapabilityService } from './plugin-capability.service';
|
|
||||||
import { PluginDesktopStateService } from './plugin-desktop-state.service';
|
|
||||||
import { PluginClientApiService } from './plugin-client-api.service';
|
|
||||||
import { PluginLoggerService } from './plugin-logger.service';
|
|
||||||
import { PluginRegistryService } from './plugin-registry.service';
|
|
||||||
import { PluginUiRegistryService } from './plugin-ui-registry.service';
|
|
||||||
|
|
||||||
interface ActivePluginRuntime {
|
|
||||||
context: TojuPluginActivationContext;
|
|
||||||
moduleObjectUrl?: string;
|
|
||||||
module: TojuClientPluginModule;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STORAGE_KEY_PLUGIN_ACTIVATION = 'metoyou_plugin_activation_state';
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class PluginHostService {
|
|
||||||
private readonly apiFactory = inject(PluginClientApiService);
|
|
||||||
private readonly capabilities = inject(PluginCapabilityService);
|
|
||||||
private readonly desktopState = inject(PluginDesktopStateService);
|
|
||||||
private readonly electronBridge = inject(ElectronBridgeService, { optional: true });
|
|
||||||
private readonly localDiscovery = inject(LocalPluginDiscoveryService);
|
|
||||||
private readonly logger = inject(PluginLoggerService);
|
|
||||||
private readonly registry = inject(PluginRegistryService);
|
|
||||||
private readonly uiRegistry = inject(PluginUiRegistryService);
|
|
||||||
private readonly activePlugins = new Map<string, ActivePluginRuntime>();
|
|
||||||
private readonly activationStateReady: Promise<void>;
|
|
||||||
private activatedPluginIds = new Set<string>();
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.registerDevelopmentPlugin();
|
|
||||||
this.activationStateReady = this.loadActivationState();
|
|
||||||
}
|
|
||||||
|
|
||||||
registerLocalManifest(manifestValue: unknown, sourcePath?: string): RegisteredPlugin {
|
|
||||||
return this.registry.registerManifest(manifestValue, sourcePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
async discoverLocalPlugins(): Promise<LocalPluginRegistrationResult> {
|
|
||||||
const discovery = await this.localDiscovery.discoverManifests();
|
|
||||||
const registered: RegisteredPlugin[] = [];
|
|
||||||
const errors: LocalPluginDiscoveryError[] = [...discovery.errors];
|
|
||||||
|
|
||||||
for (const descriptor of discovery.plugins) {
|
|
||||||
try {
|
|
||||||
registered.push(this.registerLocalManifest(descriptor.manifest, descriptor.pluginRootUrl ?? descriptor.pluginRoot));
|
|
||||||
} catch (error) {
|
|
||||||
errors.push({
|
|
||||||
manifestPath: descriptor.manifestPath,
|
|
||||||
message: error instanceof Error ? error.message : 'Plugin manifest validation failed',
|
|
||||||
pluginRoot: descriptor.pluginRoot
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
discovery,
|
|
||||||
errors,
|
|
||||||
registered
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
getReadyManifests(): TojuPluginManifest[] {
|
|
||||||
return this.registry.loadOrder().ordered;
|
|
||||||
}
|
|
||||||
|
|
||||||
async activateReadyPlugins(): Promise<void> {
|
|
||||||
await this.activationStateReady;
|
|
||||||
|
|
||||||
const activated: TojuPluginActivationContext[] = [];
|
|
||||||
|
|
||||||
for (const manifest of this.registry.loadOrder().ordered) {
|
|
||||||
const entry = this.registry.find(manifest.id);
|
|
||||||
|
|
||||||
if (!entry || !entry.enabled || this.activePlugins.has(manifest.id)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.activatePlugin(entry);
|
|
||||||
|
|
||||||
const active = this.activePlugins.get(manifest.id);
|
|
||||||
|
|
||||||
if (active) {
|
|
||||||
activated.push(active.context);
|
|
||||||
this.activatedPluginIds.add(active.context.pluginId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.saveActivationState();
|
|
||||||
|
|
||||||
await this.runReadyHooks(activated);
|
|
||||||
}
|
|
||||||
|
|
||||||
async activatePluginById(pluginId: string): Promise<void> {
|
|
||||||
await this.activationStateReady;
|
|
||||||
|
|
||||||
if (this.activePlugins.has(pluginId)) {
|
|
||||||
this.activatedPluginIds.add(pluginId);
|
|
||||||
await this.saveActivationState();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = this.registry.find(pluginId);
|
|
||||||
|
|
||||||
if (!entry?.enabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.activatePlugin(entry);
|
|
||||||
|
|
||||||
const active = this.activePlugins.get(pluginId);
|
|
||||||
|
|
||||||
if (!active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.activatedPluginIds.add(pluginId);
|
|
||||||
await this.saveActivationState();
|
|
||||||
await this.runReadyHooks([active.context]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async rememberActivation(pluginId: string): Promise<void> {
|
|
||||||
await this.activationStateReady;
|
|
||||||
this.activatedPluginIds.add(pluginId);
|
|
||||||
await this.saveActivationState();
|
|
||||||
}
|
|
||||||
|
|
||||||
async activatePersistedPlugins(): Promise<void> {
|
|
||||||
await this.activationStateReady;
|
|
||||||
|
|
||||||
const activated: TojuPluginActivationContext[] = [];
|
|
||||||
|
|
||||||
for (const manifest of this.registry.loadOrder().ordered) {
|
|
||||||
if (!this.activatedPluginIds.has(manifest.id) || this.activePlugins.has(manifest.id)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = this.registry.find(manifest.id);
|
|
||||||
|
|
||||||
if (!entry?.enabled) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.activatePlugin(entry);
|
|
||||||
|
|
||||||
const active = this.activePlugins.get(manifest.id);
|
|
||||||
|
|
||||||
if (active) {
|
|
||||||
activated.push(active.context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.runReadyHooks(activated);
|
|
||||||
}
|
|
||||||
|
|
||||||
isPluginActive(pluginId: string): boolean {
|
|
||||||
return this.activePlugins.has(pluginId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async deactivatePlugin(pluginId: string, options: { forgetActivation?: boolean } = {}): Promise<void> {
|
|
||||||
await this.activationStateReady;
|
|
||||||
|
|
||||||
const active = this.activePlugins.get(pluginId);
|
|
||||||
|
|
||||||
if (!active) {
|
|
||||||
if (options.forgetActivation) {
|
|
||||||
this.activatedPluginIds.delete(pluginId);
|
|
||||||
await this.saveActivationState();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.registry.setState(pluginId, 'unloaded');
|
|
||||||
this.uiRegistry.unregisterPlugin(pluginId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.registry.setState(pluginId, 'unloading');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await active.module.deactivate?.(active.context);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn(pluginId, 'Plugin deactivate failed', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const disposable of [...active.context.subscriptions].reverse()) {
|
|
||||||
safeDispose(disposable, pluginId, this.logger);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.uiRegistry.unregisterPlugin(pluginId);
|
|
||||||
this.activePlugins.delete(pluginId);
|
|
||||||
this.revokeModuleObjectUrl(pluginId);
|
|
||||||
|
|
||||||
if (options.forgetActivation) {
|
|
||||||
this.activatedPluginIds.delete(pluginId);
|
|
||||||
await this.saveActivationState();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.registry.setState(pluginId, 'unloaded');
|
|
||||||
}
|
|
||||||
|
|
||||||
async deactivateAll(): Promise<void> {
|
|
||||||
const pluginIds = Array.from(this.activePlugins.keys()).reverse();
|
|
||||||
|
|
||||||
for (const pluginId of pluginIds) {
|
|
||||||
await this.deactivatePlugin(pluginId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async reloadPlugin(pluginId: string): Promise<void> {
|
|
||||||
await this.deactivatePlugin(pluginId);
|
|
||||||
|
|
||||||
const entry = this.registry.find(pluginId);
|
|
||||||
|
|
||||||
if (entry?.enabled) {
|
|
||||||
await this.activatePlugin(entry);
|
|
||||||
|
|
||||||
if (this.activePlugins.has(pluginId)) {
|
|
||||||
this.activatedPluginIds.add(pluginId);
|
|
||||||
await this.saveActivationState();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
markLoaded(pluginId: string): void {
|
|
||||||
this.registry.setState(pluginId, 'loaded');
|
|
||||||
}
|
|
||||||
|
|
||||||
markFailed(pluginId: string): void {
|
|
||||||
this.registry.setState(pluginId, 'failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
private async runReadyHooks(contexts: TojuPluginActivationContext[]): Promise<void> {
|
|
||||||
for (const context of contexts) {
|
|
||||||
const active = this.activePlugins.get(context.pluginId);
|
|
||||||
|
|
||||||
if (!active?.module.ready) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await active.module.ready(context);
|
|
||||||
this.registry.setState(context.pluginId, 'ready');
|
|
||||||
} catch (error) {
|
|
||||||
this.failPlugin(context.pluginId, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async activatePlugin(entry: RegisteredPlugin): Promise<void> {
|
|
||||||
const manifest = entry.manifest;
|
|
||||||
const missingCapabilities = this.capabilities.missing(manifest);
|
|
||||||
|
|
||||||
if (missingCapabilities.length > 0) {
|
|
||||||
this.registry.setFailed(manifest.id, `Missing capabilities: ${missingCapabilities.join(', ')}`);
|
|
||||||
this.logger.warn(manifest.id, 'Plugin blocked by missing capability grants', missingCapabilities);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!manifest.entrypoint) {
|
|
||||||
this.registry.setState(manifest.id, 'ready');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.registry.setState(manifest.id, 'loading');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { module, moduleObjectUrl } = await this.loadPluginModule(manifest, entry.sourcePath);
|
|
||||||
const context: TojuPluginActivationContext = {
|
|
||||||
api: this.apiFactory.createApi(manifest),
|
|
||||||
manifest,
|
|
||||||
pluginId: manifest.id,
|
|
||||||
subscriptions: []
|
|
||||||
};
|
|
||||||
|
|
||||||
await module.activate?.(context);
|
|
||||||
this.activePlugins.set(manifest.id, { context, module, moduleObjectUrl });
|
|
||||||
this.registry.setState(manifest.id, 'loaded');
|
|
||||||
this.logger.info(manifest.id, 'Plugin activated');
|
|
||||||
} catch (error) {
|
|
||||||
this.failPlugin(manifest.id, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private failPlugin(pluginId: string, error: unknown): void {
|
|
||||||
const message = error instanceof Error ? error.message : 'Plugin activation failed';
|
|
||||||
|
|
||||||
this.registry.setFailed(pluginId, message);
|
|
||||||
this.logger.error(pluginId, message, error);
|
|
||||||
this.uiRegistry.unregisterPlugin(pluginId);
|
|
||||||
this.activePlugins.delete(pluginId);
|
|
||||||
this.revokeModuleObjectUrl(pluginId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadPluginModule(
|
|
||||||
manifest: TojuPluginManifest,
|
|
||||||
sourcePath?: string
|
|
||||||
): Promise<{ module: TojuClientPluginModule; moduleObjectUrl?: string }> {
|
|
||||||
if (manifest.entrypoint === DEVELOPMENT_PLUGIN_ENTRYPOINT) {
|
|
||||||
return { module: DEVELOPMENT_PLUGIN_MODULE };
|
|
||||||
}
|
|
||||||
|
|
||||||
const entrypointUrl = this.resolveEntrypoint(manifest, sourcePath);
|
|
||||||
|
|
||||||
if (entrypointUrl.startsWith('file://')) {
|
|
||||||
const moduleObjectUrl = await this.createLocalModuleObjectUrl(entrypointUrl);
|
|
||||||
const module = await import(/* @vite-ignore */ moduleObjectUrl) as TojuClientPluginModule;
|
|
||||||
|
|
||||||
return { module, moduleObjectUrl };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
module: await import(/* @vite-ignore */ entrypointUrl) as TojuClientPluginModule
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async createLocalModuleObjectUrl(entrypointUrl: string): Promise<string> {
|
|
||||||
const api = this.electronBridge?.getApi();
|
|
||||||
|
|
||||||
if (!api) {
|
|
||||||
throw new Error('Local plugin entrypoints require the desktop app');
|
|
||||||
}
|
|
||||||
|
|
||||||
const base64Data = await api.readFile(fileUrlToPath(entrypointUrl));
|
|
||||||
const bytes = Uint8Array.from(atob(base64Data), (character) => character.charCodeAt(0));
|
|
||||||
const source = new TextDecoder().decode(bytes);
|
|
||||||
|
|
||||||
return URL.createObjectURL(new Blob([source], { type: 'text/javascript' }));
|
|
||||||
}
|
|
||||||
|
|
||||||
private revokeModuleObjectUrl(pluginId: string): void {
|
|
||||||
const moduleObjectUrl = this.activePlugins.get(pluginId)?.moduleObjectUrl;
|
|
||||||
|
|
||||||
if (moduleObjectUrl) {
|
|
||||||
URL.revokeObjectURL(moduleObjectUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private registerDevelopmentPlugin(): void {
|
|
||||||
if (environment.production) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.registry.registerManifest(DEVELOPMENT_PLUGIN_MANIFEST, DEVELOPMENT_PLUGIN_ENTRYPOINT);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn(DEVELOPMENT_PLUGIN_MANIFEST.id, 'Development plugin registration failed', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadActivationState(): Promise<void> {
|
|
||||||
const state = await this.desktopState.readJson<{ activatedPluginIds?: string[] }>(STORAGE_KEY_PLUGIN_ACTIVATION, {});
|
|
||||||
|
|
||||||
this.activatedPluginIds = new Set(
|
|
||||||
Array.isArray(state.activatedPluginIds)
|
|
||||||
? state.activatedPluginIds.filter((pluginId): pluginId is string => typeof pluginId === 'string')
|
|
||||||
: []
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async saveActivationState(): Promise<void> {
|
|
||||||
await this.desktopState.writeJson(STORAGE_KEY_PLUGIN_ACTIVATION, {
|
|
||||||
activatedPluginIds: Array.from(this.activatedPluginIds).sort()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveEntrypoint(manifest: TojuPluginManifest, sourcePath?: string): string {
|
|
||||||
if (!manifest.entrypoint) {
|
|
||||||
throw new Error('Plugin entrypoint is missing');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return new URL(manifest.entrypoint).toString();
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
if (sourcePath?.startsWith('http://') || sourcePath?.startsWith('https://') || sourcePath?.startsWith('file://')) {
|
|
||||||
return new URL(manifest.entrypoint, sourcePath).toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (manifest.entrypoint.startsWith('/')) {
|
|
||||||
return manifest.entrypoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Plugin ${manifest.id} has no browser-importable entrypoint`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function fileUrlToPath(fileUrl: string): string {
|
|
||||||
const url = new URL(fileUrl);
|
|
||||||
const decodedPath = decodeURIComponent(url.pathname);
|
|
||||||
|
|
||||||
if (/^\/[A-Za-z]:\//.test(decodedPath)) {
|
|
||||||
return decodedPath.slice(1).replace(/\//g, '\\');
|
|
||||||
}
|
|
||||||
|
|
||||||
return decodedPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeDispose(disposable: TojuPluginDisposable, pluginId: string, logger: PluginLoggerService): void {
|
|
||||||
try {
|
|
||||||
disposable.dispose();
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn(pluginId, 'Plugin disposable failed', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
|
||||||
import { Store } from '@ngrx/store';
|
|
||||||
import { Subscription } from 'rxjs';
|
|
||||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
|
||||||
import type {
|
|
||||||
ChatEvent,
|
|
||||||
Message,
|
|
||||||
User
|
|
||||||
} from '../../../../shared-kernel';
|
|
||||||
import { selectCurrentRoomMessages } from '../../../../store/messages/messages.selectors';
|
|
||||||
import { selectCurrentRoomId } from '../../../../store/rooms/rooms.selectors';
|
|
||||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
|
||||||
import type {
|
|
||||||
PluginApiMessageBusEnvelope,
|
|
||||||
PluginApiMessageBusLatestRequest,
|
|
||||||
PluginApiMessageBusPublishRequest,
|
|
||||||
PluginApiMessageBusSubscription,
|
|
||||||
TojuPluginDisposable
|
|
||||||
} from '../../domain/models/plugin-api.models';
|
|
||||||
|
|
||||||
const DEFAULT_LATEST_MESSAGE_LIMIT = 50;
|
|
||||||
const MAX_LATEST_MESSAGE_LIMIT = 250;
|
|
||||||
const LATEST_MESSAGES_TOPIC = 'latest-messages';
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class PluginMessageBusService {
|
|
||||||
private readonly realtime = inject(RealtimeSessionFacade);
|
|
||||||
private readonly store = inject(Store);
|
|
||||||
private readonly currentMessages = this.store.selectSignal(selectCurrentRoomMessages);
|
|
||||||
private readonly currentRoomId = this.store.selectSignal(selectCurrentRoomId);
|
|
||||||
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
|
||||||
private readonly localSubscriptions = new Map<string, Set<PluginApiMessageBusSubscription>>();
|
|
||||||
|
|
||||||
publish(pluginId: string, request: PluginApiMessageBusPublishRequest): PluginApiMessageBusEnvelope {
|
|
||||||
const envelope = this.createEnvelope(pluginId, request.topic, request.payload, request, request.includeLatestMessages === true);
|
|
||||||
|
|
||||||
this.sendEnvelope(envelope, request.targetPeerId);
|
|
||||||
|
|
||||||
if (request.includeSelf) {
|
|
||||||
this.dispatchLocal(envelope);
|
|
||||||
}
|
|
||||||
|
|
||||||
return envelope;
|
|
||||||
}
|
|
||||||
|
|
||||||
sendLatestMessages(pluginId: string, request: PluginApiMessageBusLatestRequest = {}): PluginApiMessageBusEnvelope {
|
|
||||||
const envelope = this.createEnvelope(pluginId, request.topic ?? LATEST_MESSAGES_TOPIC, undefined, request, true);
|
|
||||||
|
|
||||||
this.sendEnvelope(envelope, request.targetPeerId);
|
|
||||||
|
|
||||||
return envelope;
|
|
||||||
}
|
|
||||||
|
|
||||||
subscribe(pluginId: string, subscription: PluginApiMessageBusSubscription): TojuPluginDisposable {
|
|
||||||
const pluginSubscriptions = this.localSubscriptions.get(pluginId) ?? new Set<PluginApiMessageBusSubscription>();
|
|
||||||
const realtimeSubscription = new Subscription();
|
|
||||||
|
|
||||||
pluginSubscriptions.add(subscription);
|
|
||||||
this.localSubscriptions.set(pluginId, pluginSubscriptions);
|
|
||||||
|
|
||||||
realtimeSubscription.add(this.realtime.onMessageReceived.subscribe((event) => {
|
|
||||||
const envelope = readPluginMessageBusEnvelope(event);
|
|
||||||
|
|
||||||
if (!envelope || envelope.pluginId !== pluginId || !matchesSubscription(envelope, subscription)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
subscription.handler(envelope);
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (subscription.replayLatest) {
|
|
||||||
subscription.handler(this.createEnvelope(
|
|
||||||
pluginId,
|
|
||||||
subscription.topic ?? LATEST_MESSAGES_TOPIC,
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
channelId: subscription.channelId,
|
|
||||||
limit: subscription.latestMessageLimit
|
|
||||||
},
|
|
||||||
true
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
dispose: () => {
|
|
||||||
realtimeSubscription.unsubscribe();
|
|
||||||
pluginSubscriptions.delete(subscription);
|
|
||||||
|
|
||||||
if (pluginSubscriptions.size === 0) {
|
|
||||||
this.localSubscriptions.delete(pluginId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private createEnvelope(
|
|
||||||
pluginId: string,
|
|
||||||
topic: string,
|
|
||||||
payload: unknown,
|
|
||||||
request: PluginApiMessageBusLatestRequest,
|
|
||||||
includeMessages: boolean
|
|
||||||
): PluginApiMessageBusEnvelope {
|
|
||||||
const currentUser = this.currentUser();
|
|
||||||
const envelope: PluginApiMessageBusEnvelope = {
|
|
||||||
eventId: createId(),
|
|
||||||
pluginId,
|
|
||||||
roomId: this.requireRoomId(),
|
|
||||||
sentAt: Date.now(),
|
|
||||||
sourceUserId: readUserId(currentUser),
|
|
||||||
topic
|
|
||||||
};
|
|
||||||
|
|
||||||
if (request.channelId) {
|
|
||||||
envelope.channelId = request.channelId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payload !== undefined) {
|
|
||||||
envelope.payload = payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (includeMessages) {
|
|
||||||
envelope.messages = this.latestMessages(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
return envelope;
|
|
||||||
}
|
|
||||||
|
|
||||||
private latestMessages(request: PluginApiMessageBusLatestRequest): Message[] {
|
|
||||||
const limit = clampLimit(request.limit);
|
|
||||||
const sinceTimestamp = typeof request.sinceTimestamp === 'number' ? request.sinceTimestamp : null;
|
|
||||||
|
|
||||||
return this.currentMessages()
|
|
||||||
.filter((message) => !request.channelId || message.channelId === request.channelId)
|
|
||||||
.filter((message) => request.includeDeleted || !message.isDeleted)
|
|
||||||
.filter((message) => sinceTimestamp === null || message.timestamp > sinceTimestamp)
|
|
||||||
.slice(-limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
private sendEnvelope(envelope: PluginApiMessageBusEnvelope, targetPeerId?: string): void {
|
|
||||||
const event: ChatEvent = {
|
|
||||||
pluginMessage: envelope,
|
|
||||||
roomId: envelope.roomId,
|
|
||||||
timestamp: envelope.sentAt,
|
|
||||||
type: 'plugin-message-bus'
|
|
||||||
};
|
|
||||||
|
|
||||||
if (targetPeerId) {
|
|
||||||
this.realtime.sendToPeer(targetPeerId, event);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.realtime.broadcastMessage(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
private dispatchLocal(envelope: PluginApiMessageBusEnvelope): void {
|
|
||||||
for (const subscription of this.localSubscriptions.get(envelope.pluginId) ?? []) {
|
|
||||||
if (matchesSubscription(envelope, subscription)) {
|
|
||||||
subscription.handler(envelope);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private requireRoomId(): string {
|
|
||||||
const roomId = this.currentRoomId();
|
|
||||||
|
|
||||||
if (!roomId) {
|
|
||||||
throw new Error('No active server');
|
|
||||||
}
|
|
||||||
|
|
||||||
return roomId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function readPluginMessageBusEnvelope(event: ChatEvent): PluginApiMessageBusEnvelope | null {
|
|
||||||
if (event.type !== 'plugin-message-bus' || !isRecord(event.pluginMessage)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const envelope = event.pluginMessage;
|
|
||||||
|
|
||||||
if (typeof envelope['eventId'] !== 'string'
|
|
||||||
|| typeof envelope['pluginId'] !== 'string'
|
|
||||||
|| typeof envelope['roomId'] !== 'string'
|
|
||||||
|| typeof envelope['sentAt'] !== 'number'
|
|
||||||
|| typeof envelope['topic'] !== 'string') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
channelId: typeof envelope['channelId'] === 'string' ? envelope['channelId'] : undefined,
|
|
||||||
eventId: envelope['eventId'],
|
|
||||||
messages: Array.isArray(envelope['messages']) ? envelope['messages'].filter(isMessage) : undefined,
|
|
||||||
payload: envelope['payload'],
|
|
||||||
pluginId: envelope['pluginId'],
|
|
||||||
roomId: envelope['roomId'],
|
|
||||||
sentAt: envelope['sentAt'],
|
|
||||||
sourcePeerId: event.fromPeerId,
|
|
||||||
sourceUserId: typeof envelope['sourceUserId'] === 'string' ? envelope['sourceUserId'] : undefined,
|
|
||||||
topic: envelope['topic']
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchesSubscription(envelope: PluginApiMessageBusEnvelope, subscription: PluginApiMessageBusSubscription): boolean {
|
|
||||||
return (!subscription.topic || subscription.topic === envelope.topic)
|
|
||||||
&& (!subscription.channelId || subscription.channelId === envelope.channelId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function clampLimit(limit: number | undefined): number {
|
|
||||||
if (typeof limit !== 'number' || !Number.isFinite(limit)) {
|
|
||||||
return DEFAULT_LATEST_MESSAGE_LIMIT;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.max(1, Math.min(MAX_LATEST_MESSAGE_LIMIT, Math.floor(limit)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function readUserId(user: User | null): string | undefined {
|
|
||||||
return user?.oderId || user?.id || undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return !!value && typeof value === 'object';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMessage(value: unknown): value is Message {
|
|
||||||
return isRecord(value)
|
|
||||||
&& typeof value['id'] === 'string'
|
|
||||||
&& typeof value['roomId'] === 'string'
|
|
||||||
&& typeof value['senderId'] === 'string'
|
|
||||||
&& typeof value['content'] === 'string'
|
|
||||||
&& typeof value['timestamp'] === 'number';
|
|
||||||
}
|
|
||||||
|
|
||||||
function createId(): string {
|
|
||||||
return globalThis.crypto?.randomUUID?.() ?? `plugin-bus-${Date.now()}-${Math.random().toString(36)
|
|
||||||
.slice(2)}`;
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
import {
|
|
||||||
Injectable,
|
|
||||||
type Signal,
|
|
||||||
computed,
|
|
||||||
inject,
|
|
||||||
signal
|
|
||||||
} from '@angular/core';
|
|
||||||
import {
|
|
||||||
RegisteredPlugin,
|
|
||||||
type PluginLoadOrderResult,
|
|
||||||
type PluginRuntimeState
|
|
||||||
} from '../../domain/models/plugin-runtime.models';
|
|
||||||
import { resolvePluginLoadOrder } from '../../domain/logic/plugin-dependency-resolver.logic';
|
|
||||||
import { validateTojuPluginManifest } from '../../domain/logic/plugin-manifest-validation.logic';
|
|
||||||
import { PluginDesktopStateService } from './plugin-desktop-state.service';
|
|
||||||
|
|
||||||
const STORAGE_KEY_PLUGIN_REGISTRY_STATE = 'metoyou_plugin_registry_state';
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class PluginRegistryService {
|
|
||||||
readonly entries: Signal<RegisteredPlugin[]>;
|
|
||||||
readonly enabledEntries: Signal<RegisteredPlugin[]>;
|
|
||||||
readonly loadOrder: Signal<PluginLoadOrderResult>;
|
|
||||||
|
|
||||||
private readonly desktopState = inject(PluginDesktopStateService);
|
|
||||||
private readonly entriesSignal = signal<RegisteredPlugin[]>([]);
|
|
||||||
private disabledPluginIds = new Set<string>();
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.entries = this.entriesSignal.asReadonly();
|
|
||||||
this.enabledEntries = computed(() => this.entries().filter((entry) => entry.enabled));
|
|
||||||
this.loadOrder = computed<PluginLoadOrderResult>(() =>
|
|
||||||
resolvePluginLoadOrder(this.entries().map((entry) => ({ enabled: entry.enabled, manifest: entry.manifest })))
|
|
||||||
);
|
|
||||||
|
|
||||||
void this.loadRegistryState();
|
|
||||||
}
|
|
||||||
|
|
||||||
clear(): void {
|
|
||||||
this.entriesSignal.set([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
registerManifest(manifestValue: unknown, sourcePath?: string): RegisteredPlugin {
|
|
||||||
const validation = validateTojuPluginManifest(manifestValue);
|
|
||||||
|
|
||||||
if (!validation.manifest) {
|
|
||||||
throw new Error(validation.issues.map((issue) => `${issue.path}: ${issue.message}`).join('\n'));
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingIndex = this.entries().findIndex((entry) => entry.manifest.id === validation.manifest?.id);
|
|
||||||
const entry: RegisteredPlugin = {
|
|
||||||
enabled: !this.disabledPluginIds.has(validation.manifest.id),
|
|
||||||
manifest: validation.manifest,
|
|
||||||
sourcePath,
|
|
||||||
state: validation.valid ? 'validated' : 'blocked',
|
|
||||||
validationIssues: validation.issues
|
|
||||||
};
|
|
||||||
|
|
||||||
if (existingIndex >= 0) {
|
|
||||||
this.entriesSignal.update((entries) => entries.map((candidate, index) => index === existingIndex ? entry : candidate));
|
|
||||||
} else {
|
|
||||||
this.entriesSignal.update((entries) => [...entries, entry]);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.syncLoadState();
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEnabled(pluginId: string, enabled: boolean): void {
|
|
||||||
if (enabled) {
|
|
||||||
this.disabledPluginIds.delete(pluginId);
|
|
||||||
} else {
|
|
||||||
this.disabledPluginIds.add(pluginId);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.entriesSignal.update((entries) => entries.map((entry) => entry.manifest.id === pluginId
|
|
||||||
? {
|
|
||||||
...entry,
|
|
||||||
enabled,
|
|
||||||
state: enabled ? entry.state === 'disabled' ? 'validated' : entry.state : 'disabled'
|
|
||||||
}
|
|
||||||
: entry));
|
|
||||||
|
|
||||||
this.syncLoadState();
|
|
||||||
void this.saveRegistryState();
|
|
||||||
}
|
|
||||||
|
|
||||||
unregister(pluginId: string): void {
|
|
||||||
this.entriesSignal.update((entries) => entries.filter((entry) => entry.manifest.id !== pluginId));
|
|
||||||
this.syncLoadState();
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(pluginId: string, state: PluginRuntimeState): void {
|
|
||||||
this.entriesSignal.update((entries) => entries.map((entry) => entry.manifest.id === pluginId
|
|
||||||
? { ...entry, error: undefined, state }
|
|
||||||
: entry));
|
|
||||||
}
|
|
||||||
|
|
||||||
setFailed(pluginId: string, error: string): void {
|
|
||||||
this.entriesSignal.update((entries) => entries.map((entry) => entry.manifest.id === pluginId
|
|
||||||
? { ...entry, error, state: 'failed' }
|
|
||||||
: entry));
|
|
||||||
}
|
|
||||||
|
|
||||||
find(pluginId: string): RegisteredPlugin | undefined {
|
|
||||||
return this.entries().find((entry) => entry.manifest.id === pluginId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private syncLoadState(): void {
|
|
||||||
const loadOrder = this.loadOrder();
|
|
||||||
const blockedIds = new Set(loadOrder.blocked.map((blocker) => blocker.pluginId));
|
|
||||||
const loadIndexes = new Map(loadOrder.ordered.map((manifest, index) => [manifest.id, index]));
|
|
||||||
|
|
||||||
this.entriesSignal.update((entries) => entries.map((entry) => {
|
|
||||||
const loadIndex = loadIndexes.get(entry.manifest.id);
|
|
||||||
|
|
||||||
if (!entry.enabled) {
|
|
||||||
return { ...entry, loadIndex: undefined, state: 'disabled' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (blockedIds.has(entry.manifest.id)) {
|
|
||||||
return { ...entry, loadIndex: undefined, state: 'blocked' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...entry,
|
|
||||||
loadIndex,
|
|
||||||
state: loadIndex === undefined ? entry.state : 'ready'
|
|
||||||
};
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadRegistryState(): Promise<void> {
|
|
||||||
const state = await this.desktopState.readJson<{ disabledPluginIds?: string[] }>(STORAGE_KEY_PLUGIN_REGISTRY_STATE, {});
|
|
||||||
|
|
||||||
this.disabledPluginIds = new Set(
|
|
||||||
Array.isArray(state.disabledPluginIds)
|
|
||||||
? state.disabledPluginIds.filter((pluginId): pluginId is string => typeof pluginId === 'string')
|
|
||||||
: []
|
|
||||||
);
|
|
||||||
|
|
||||||
this.entriesSignal.update((entries) => entries.map((entry) => ({
|
|
||||||
...entry,
|
|
||||||
enabled: !this.disabledPluginIds.has(entry.manifest.id)
|
|
||||||
})));
|
|
||||||
|
|
||||||
this.syncLoadState();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async saveRegistryState(): Promise<void> {
|
|
||||||
await this.desktopState.writeJson(STORAGE_KEY_PLUGIN_REGISTRY_STATE, {
|
|
||||||
disabledPluginIds: Array.from(this.disabledPluginIds).sort()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
/* 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;
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import { HttpClient } from '@angular/common/http';
|
|
||||||
import { Injectable, inject } from '@angular/core';
|
|
||||||
import { Observable } from 'rxjs';
|
|
||||||
import type {
|
|
||||||
PluginEventDefinitionSummary,
|
|
||||||
PluginRequirementStatus,
|
|
||||||
PluginRequirementSummary,
|
|
||||||
PluginRequirementsSnapshot,
|
|
||||||
TojuPluginManifest
|
|
||||||
} from '../../../../shared-kernel';
|
|
||||||
|
|
||||||
export interface UpsertPluginRequirementRequest {
|
|
||||||
actorUserId: string;
|
|
||||||
installUrl?: string;
|
|
||||||
manifest?: TojuPluginManifest;
|
|
||||||
reason?: string;
|
|
||||||
sourceUrl?: string;
|
|
||||||
status: PluginRequirementStatus;
|
|
||||||
versionRange?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpsertPluginEventDefinitionRequest {
|
|
||||||
actorUserId: string;
|
|
||||||
direction: 'clientToServer' | 'serverRelay' | 'p2pHint';
|
|
||||||
maxPayloadBytes?: number;
|
|
||||||
rateLimitJson?: string;
|
|
||||||
schemaJson?: string;
|
|
||||||
scope: 'server' | 'channel' | 'user' | 'plugin';
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class PluginRequirementService {
|
|
||||||
private readonly http = inject(HttpClient);
|
|
||||||
|
|
||||||
getSnapshot(apiBaseUrl: string, serverId: string): Observable<PluginRequirementsSnapshot> {
|
|
||||||
return this.http.get<PluginRequirementsSnapshot>(`${this.apiBase(apiBaseUrl)}/servers/${encodeURIComponent(serverId)}/plugins`);
|
|
||||||
}
|
|
||||||
|
|
||||||
upsertRequirement(
|
|
||||||
apiBaseUrl: string,
|
|
||||||
serverId: string,
|
|
||||||
pluginId: string,
|
|
||||||
request: UpsertPluginRequirementRequest
|
|
||||||
): Observable<{ requirement: PluginRequirementSummary }> {
|
|
||||||
return this.http.put<{ requirement: PluginRequirementSummary }>(
|
|
||||||
`${this.apiBase(apiBaseUrl)}/servers/${encodeURIComponent(serverId)}/plugins/${encodeURIComponent(pluginId)}/requirement`,
|
|
||||||
request
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteRequirement(apiBaseUrl: string, serverId: string, pluginId: string, actorUserId: string): Observable<{ ok: boolean }> {
|
|
||||||
return this.http.delete<{ ok: boolean }>(
|
|
||||||
`${this.apiBase(apiBaseUrl)}/servers/${encodeURIComponent(serverId)}/plugins/${encodeURIComponent(pluginId)}/requirement`,
|
|
||||||
{ body: { actorUserId } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
upsertEventDefinition(
|
|
||||||
apiBaseUrl: string,
|
|
||||||
serverId: string,
|
|
||||||
pluginId: string,
|
|
||||||
eventName: string,
|
|
||||||
request: UpsertPluginEventDefinitionRequest
|
|
||||||
): Observable<{ eventDefinition: PluginEventDefinitionSummary }> {
|
|
||||||
const eventUrl = `${this.apiBase(apiBaseUrl)}/servers/${encodeURIComponent(serverId)}`
|
|
||||||
+ `/plugins/${encodeURIComponent(pluginId)}/events/${encodeURIComponent(eventName)}`;
|
|
||||||
|
|
||||||
return this.http.put<{ eventDefinition: PluginEventDefinitionSummary }>(
|
|
||||||
eventUrl,
|
|
||||||
request
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private apiBase(apiBaseUrl: string): string {
|
|
||||||
return apiBaseUrl.replace(/\/$/, '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
|
||||||
import { Store } from '@ngrx/store';
|
|
||||||
import { getUserScopedStorageKey } from '../../../../core/storage/current-user-storage';
|
|
||||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
|
||||||
import { selectCurrentRoomId } from '../../../../store/rooms/rooms.selectors';
|
|
||||||
|
|
||||||
const STORAGE_PREFIX_PLUGIN_LOCAL = 'metoyou_plugin_local';
|
|
||||||
const STORAGE_PREFIX_PLUGIN_SERVER_LOCAL = 'metoyou_plugin_server_local';
|
|
||||||
|
|
||||||
type PluginDataScope = 'local' | 'server';
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class PluginStorageService {
|
|
||||||
private readonly electronBridge = inject(ElectronBridgeService);
|
|
||||||
private readonly store = inject(Store);
|
|
||||||
private readonly currentRoomId = this.store.selectSignal(selectCurrentRoomId);
|
|
||||||
|
|
||||||
getLocal(pluginId: string, key: string): unknown {
|
|
||||||
return this.read(`${STORAGE_PREFIX_PLUGIN_LOCAL}:${pluginId}:${key}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
removeLocal(pluginId: string, key: string): void {
|
|
||||||
localStorage.removeItem(getUserScopedStorageKey(`${STORAGE_PREFIX_PLUGIN_LOCAL}:${pluginId}:${key}`));
|
|
||||||
void this.deleteFromClientDatabase(pluginId, 'local', key);
|
|
||||||
}
|
|
||||||
|
|
||||||
setLocal(pluginId: string, key: string, value: unknown): void {
|
|
||||||
this.write(`${STORAGE_PREFIX_PLUGIN_LOCAL}:${pluginId}:${key}`, value);
|
|
||||||
void this.writeToClientDatabase(pluginId, 'local', key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
async readClientData(pluginId: string, key: string): Promise<unknown> {
|
|
||||||
return await this.readScopedData(pluginId, 'local', key);
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeClientData(pluginId: string, key: string): Promise<void> {
|
|
||||||
localStorage.removeItem(getUserScopedStorageKey(`${STORAGE_PREFIX_PLUGIN_LOCAL}:${pluginId}:${key}`));
|
|
||||||
await this.deleteFromClientDatabase(pluginId, 'local', key);
|
|
||||||
}
|
|
||||||
|
|
||||||
async writeClientData(pluginId: string, key: string, value: unknown): Promise<void> {
|
|
||||||
this.write(`${STORAGE_PREFIX_PLUGIN_LOCAL}:${pluginId}:${key}`, value);
|
|
||||||
await this.writeToClientDatabase(pluginId, 'local', key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
async readServerData(pluginId: string, key: string): Promise<unknown> {
|
|
||||||
return await this.readScopedData(pluginId, 'server', key, this.requireRoomId());
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeServerData(pluginId: string, key: string): Promise<void> {
|
|
||||||
localStorage.removeItem(getUserScopedStorageKey(this.serverLocalKey(pluginId, key)));
|
|
||||||
await this.deleteFromClientDatabase(pluginId, 'server', key, this.requireRoomId());
|
|
||||||
}
|
|
||||||
|
|
||||||
async writeServerData(pluginId: string, key: string, value: unknown): Promise<void> {
|
|
||||||
this.write(this.serverLocalKey(pluginId, key), value);
|
|
||||||
await this.writeToClientDatabase(pluginId, 'server', key, value, this.requireRoomId());
|
|
||||||
}
|
|
||||||
|
|
||||||
private async readScopedData(pluginId: string, scope: PluginDataScope, key: string, serverId?: string): Promise<unknown> {
|
|
||||||
const api = this.electronBridge.getApi();
|
|
||||||
|
|
||||||
if (api) {
|
|
||||||
return await api.query({
|
|
||||||
type: 'get-plugin-data',
|
|
||||||
payload: {
|
|
||||||
key,
|
|
||||||
pluginId,
|
|
||||||
scope,
|
|
||||||
serverId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.read(scope === 'server'
|
|
||||||
? this.serverLocalKey(pluginId, key)
|
|
||||||
: `${STORAGE_PREFIX_PLUGIN_LOCAL}:${pluginId}:${key}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async writeToClientDatabase(pluginId: string, scope: PluginDataScope, key: string, value: unknown, serverId?: string): Promise<void> {
|
|
||||||
const api = this.electronBridge.getApi();
|
|
||||||
|
|
||||||
if (!api) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await api.command({
|
|
||||||
type: 'save-plugin-data',
|
|
||||||
payload: {
|
|
||||||
key,
|
|
||||||
pluginId,
|
|
||||||
scope,
|
|
||||||
serverId,
|
|
||||||
value
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async deleteFromClientDatabase(pluginId: string, scope: PluginDataScope, key: string, serverId?: string): Promise<void> {
|
|
||||||
const api = this.electronBridge.getApi();
|
|
||||||
|
|
||||||
if (!api) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await api.command({
|
|
||||||
type: 'delete-plugin-data',
|
|
||||||
payload: {
|
|
||||||
key,
|
|
||||||
pluginId,
|
|
||||||
scope,
|
|
||||||
serverId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private serverLocalKey(pluginId: string, key: string): string {
|
|
||||||
const roomId = this.requireRoomId();
|
|
||||||
|
|
||||||
return `${STORAGE_PREFIX_PLUGIN_SERVER_LOCAL}:${roomId}:${pluginId}:${key}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private requireRoomId(): string {
|
|
||||||
const roomId = this.currentRoomId();
|
|
||||||
|
|
||||||
if (!roomId) {
|
|
||||||
throw new Error('No active server for plugin server data');
|
|
||||||
}
|
|
||||||
|
|
||||||
return roomId;
|
|
||||||
}
|
|
||||||
|
|
||||||
private read(key: string): unknown {
|
|
||||||
const raw = localStorage.getItem(getUserScopedStorageKey(key));
|
|
||||||
|
|
||||||
if (!raw) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return JSON.parse(raw) as unknown;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private write(key: string, value: unknown): void {
|
|
||||||
localStorage.setItem(getUserScopedStorageKey(key), JSON.stringify(value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
import { Injector } from '@angular/core';
|
|
||||||
import type { TojuPluginManifest } from '../../../../shared-kernel';
|
|
||||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
|
||||||
import { PluginStoreService } from './plugin-store.service';
|
|
||||||
import { PluginHostService } from './plugin-host.service';
|
|
||||||
import { PluginDesktopStateService } from './plugin-desktop-state.service';
|
|
||||||
import { PluginRequirementService } from './plugin-requirement.service';
|
|
||||||
import { PluginRegistryService } from './plugin-registry.service';
|
|
||||||
import type { PluginStoreEntry } from '../../domain/models/plugin-store.models';
|
|
||||||
|
|
||||||
describe('PluginStoreService', () => {
|
|
||||||
let fetchMock: ReturnType<typeof vi.fn>;
|
|
||||||
let registerLocalManifest: ReturnType<typeof vi.fn>;
|
|
||||||
let unregister: ReturnType<typeof vi.fn>;
|
|
||||||
let storage: Storage;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
storage = createMemoryStorage();
|
|
||||||
vi.stubGlobal('localStorage', storage);
|
|
||||||
fetchMock = vi.fn();
|
|
||||||
registerLocalManifest = vi.fn((manifest: TojuPluginManifest, sourcePath?: string) => ({
|
|
||||||
enabled: true,
|
|
||||||
manifest,
|
|
||||||
sourcePath,
|
|
||||||
state: 'validated',
|
|
||||||
validationIssues: []
|
|
||||||
}));
|
|
||||||
|
|
||||||
unregister = vi.fn();
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
storage.clear();
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('loads plugin entries from source manifests and resolves relative links', async () => {
|
|
||||||
fetchMock.mockResolvedValueOnce(jsonResponse({
|
|
||||||
plugins: [
|
|
||||||
{
|
|
||||||
author: 'Ada Example',
|
|
||||||
description: 'Adds better channel tools.',
|
|
||||||
github: 'https://github.com/example/better-channels',
|
|
||||||
id: 'example.better-channels',
|
|
||||||
image: './images/better.png',
|
|
||||||
install: './better/toju-plugin.json',
|
|
||||||
readme: './better/README.md',
|
|
||||||
title: 'Better Channels',
|
|
||||||
version: '1.2.0'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
title: 'Example Plugins'
|
|
||||||
}));
|
|
||||||
|
|
||||||
const service = createService(registerLocalManifest, unregister);
|
|
||||||
|
|
||||||
await service.addSourceUrl('https://plugins.example.test/index.json#latest');
|
|
||||||
|
|
||||||
expect(service.sourceUrls()).toEqual(['https://plugins.example.test/index.json']);
|
|
||||||
expect(service.sources()[0]?.title).toBe('Example Plugins');
|
|
||||||
expect(service.availablePlugins()).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
author: 'Ada Example',
|
|
||||||
githubUrl: 'https://github.com/example/better-channels',
|
|
||||||
id: 'example.better-channels',
|
|
||||||
imageUrl: 'https://plugins.example.test/images/better.png',
|
|
||||||
installUrl: 'https://plugins.example.test/better/toju-plugin.json',
|
|
||||||
readmeUrl: 'https://plugins.example.test/better/README.md',
|
|
||||||
sourceTitle: 'Example Plugins',
|
|
||||||
title: 'Better Channels',
|
|
||||||
version: '1.2.0'
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts local source manifest paths and resolves relative file links', async () => {
|
|
||||||
const localSourceManifest = {
|
|
||||||
plugins: [
|
|
||||||
{
|
|
||||||
description: 'Local plugin source.',
|
|
||||||
id: 'example.local-plugin',
|
|
||||||
image: './icon.svg',
|
|
||||||
install: './toju-plugin.json',
|
|
||||||
readme: './README.md',
|
|
||||||
title: 'Local Plugin',
|
|
||||||
version: '1.0.0'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
title: 'Local Plugins'
|
|
||||||
};
|
|
||||||
const readFile = vi.fn(async () => toBase64(JSON.stringify(localSourceManifest)));
|
|
||||||
const service = createService(registerLocalManifest, unregister, { readFile });
|
|
||||||
|
|
||||||
await service.addSourceUrl('/home/ludde/Desktop/TestPlugin/plugin-source.json');
|
|
||||||
|
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
|
||||||
expect(readFile).toHaveBeenCalledWith('/home/ludde/Desktop/TestPlugin/plugin-source.json');
|
|
||||||
expect(service.sourceUrls()).toEqual(['file:///home/ludde/Desktop/TestPlugin/plugin-source.json']);
|
|
||||||
expect(service.availablePlugins()).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
id: 'example.local-plugin',
|
|
||||||
imageUrl: 'file:///home/ludde/Desktop/TestPlugin/icon.svg',
|
|
||||||
installUrl: 'file:///home/ludde/Desktop/TestPlugin/toju-plugin.json',
|
|
||||||
readmeUrl: 'file:///home/ludde/Desktop/TestPlugin/README.md',
|
|
||||||
sourceTitle: 'Local Plugins'
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('installs, detects updates, and uninstalls store plugins', async () => {
|
|
||||||
const manifest = createManifest({ version: '1.0.0' });
|
|
||||||
const plugin = createStoreEntry({ version: '1.0.0' });
|
|
||||||
|
|
||||||
fetchMock.mockResolvedValueOnce(jsonResponse(manifest));
|
|
||||||
|
|
||||||
const service = createService(registerLocalManifest, unregister);
|
|
||||||
|
|
||||||
await service.installPlugin(plugin);
|
|
||||||
|
|
||||||
expect(registerLocalManifest).toHaveBeenCalledWith(manifest, plugin.installUrl);
|
|
||||||
expect(service.installedPlugins()[0]?.manifest.id).toBe(plugin.id);
|
|
||||||
expect(service.getActionLabel(plugin)).toBe('Uninstall');
|
|
||||||
expect(service.getActionLabel(createStoreEntry({ version: '1.1.0' }))).toBe('Update');
|
|
||||||
|
|
||||||
await service.uninstallPlugin(plugin.id);
|
|
||||||
|
|
||||||
expect(unregister).toHaveBeenCalledWith(plugin.id);
|
|
||||||
expect(service.installedPlugins()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('loads plugin readmes as markdown text', async () => {
|
|
||||||
const plugin = createStoreEntry({ readmeUrl: 'https://plugins.example.test/better/README.md' });
|
|
||||||
|
|
||||||
fetchMock.mockResolvedValueOnce(textResponse('# Better Channels'));
|
|
||||||
|
|
||||||
const service = createService(registerLocalManifest, unregister);
|
|
||||||
const readme = await service.loadReadme(plugin);
|
|
||||||
|
|
||||||
expect(readme).toEqual({
|
|
||||||
markdown: '# Better Channels',
|
|
||||||
pluginId: plugin.id,
|
|
||||||
title: plugin.title,
|
|
||||||
url: plugin.readmeUrl
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function createService(
|
|
||||||
registerLocalManifest: ReturnType<typeof vi.fn>,
|
|
||||||
unregister: ReturnType<typeof vi.fn>,
|
|
||||||
electronApi: { readFile: (filePath: string) => Promise<string> } | null = null
|
|
||||||
): PluginStoreService {
|
|
||||||
const injector = Injector.create({
|
|
||||||
providers: [
|
|
||||||
PluginStoreService,
|
|
||||||
{
|
|
||||||
provide: ElectronBridgeService,
|
|
||||||
useValue: {
|
|
||||||
getApi: vi.fn(() => electronApi)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: PluginHostService,
|
|
||||||
useValue: {
|
|
||||||
activatePersistedPlugins: vi.fn(async () => {}),
|
|
||||||
deactivatePlugin: vi.fn(async () => {}),
|
|
||||||
registerLocalManifest
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: PluginDesktopStateService,
|
|
||||||
useValue: {
|
|
||||||
readJson: vi.fn(async (_key: string, fallback: unknown) => fallback),
|
|
||||||
writeJson: vi.fn(async () => undefined)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: PluginRegistryService,
|
|
||||||
useValue: { unregister }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: PluginRequirementService,
|
|
||||||
useValue: {}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
return injector.get(PluginStoreService);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toBase64(value: string): string {
|
|
||||||
return Buffer.from(value, 'utf8').toString('base64');
|
|
||||||
}
|
|
||||||
|
|
||||||
function createManifest(overrides: Partial<TojuPluginManifest> = {}): TojuPluginManifest {
|
|
||||||
return {
|
|
||||||
apiVersion: '1.0.0',
|
|
||||||
compatibility: {
|
|
||||||
minimumTojuVersion: '1.0.0'
|
|
||||||
},
|
|
||||||
description: 'Adds better channel tools.',
|
|
||||||
entrypoint: './dist/main.js',
|
|
||||||
id: 'example.better-channels',
|
|
||||||
kind: 'client',
|
|
||||||
schemaVersion: 1,
|
|
||||||
title: 'Better Channels',
|
|
||||||
version: '1.0.0',
|
|
||||||
...overrides
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createStoreEntry(overrides: Partial<PluginStoreEntry> = {}): PluginStoreEntry {
|
|
||||||
return {
|
|
||||||
author: 'Ada Example',
|
|
||||||
description: 'Adds better channel tools.',
|
|
||||||
githubUrl: 'https://github.com/example/better-channels',
|
|
||||||
id: 'example.better-channels',
|
|
||||||
imageUrl: 'https://plugins.example.test/images/better.png',
|
|
||||||
installUrl: 'https://plugins.example.test/better/toju-plugin.json',
|
|
||||||
readmeUrl: 'https://plugins.example.test/better/README.md',
|
|
||||||
sourceTitle: 'Example Plugins',
|
|
||||||
sourceUrl: 'https://plugins.example.test/index.json',
|
|
||||||
title: 'Better Channels',
|
|
||||||
version: '1.0.0',
|
|
||||||
...overrides
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function jsonResponse(value: unknown): Response {
|
|
||||||
return {
|
|
||||||
json: vi.fn(async () => value),
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
text: vi.fn(async () => JSON.stringify(value))
|
|
||||||
} as unknown as Response;
|
|
||||||
}
|
|
||||||
|
|
||||||
function textResponse(value: string): Response {
|
|
||||||
return {
|
|
||||||
json: vi.fn(async () => JSON.parse(value) as unknown),
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
text: vi.fn(async () => value)
|
|
||||||
} as unknown as Response;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMemoryStorage(): Storage {
|
|
||||||
const values = new Map<string, string>();
|
|
||||||
|
|
||||||
return {
|
|
||||||
get length(): number {
|
|
||||||
return values.size;
|
|
||||||
},
|
|
||||||
clear: vi.fn(() => values.clear()),
|
|
||||||
getItem: vi.fn((key: string) => values.get(key) ?? null),
|
|
||||||
key: vi.fn((index: number) => Array.from(values.keys())[index] ?? null),
|
|
||||||
removeItem: vi.fn((key: string) => values.delete(key)),
|
|
||||||
setItem: vi.fn((key: string, value: string) => values.set(key, value))
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,849 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
|
||||||
DestroyRef,
|
|
||||||
Injectable,
|
|
||||||
computed,
|
|
||||||
effect,
|
|
||||||
inject,
|
|
||||||
signal
|
|
||||||
} from '@angular/core';
|
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
||||||
import { Store } from '@ngrx/store';
|
|
||||||
import { firstValueFrom } from 'rxjs';
|
|
||||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
|
||||||
import { getUserScopedStorageKey } from '../../../../core/storage/current-user-storage';
|
|
||||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
|
||||||
import type {
|
|
||||||
PluginRequirementSummary,
|
|
||||||
TojuPluginInstallScope,
|
|
||||||
TojuPluginManifest
|
|
||||||
} from '../../../../shared-kernel';
|
|
||||||
import { selectCurrentRoomId, selectCurrentRoomName } from '../../../../store/rooms/rooms.selectors';
|
|
||||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
|
||||||
import { ServerDirectoryFacade } from '../../../server-directory';
|
|
||||||
import { getPluginInstallScope } from '../../domain/logic/plugin-install-scope.logic';
|
|
||||||
import { validateTojuPluginManifest } from '../../domain/logic/plugin-manifest-validation.logic';
|
|
||||||
import type {
|
|
||||||
InstalledStorePlugin,
|
|
||||||
PersistedPluginStoreState,
|
|
||||||
PluginStoreEntry,
|
|
||||||
PluginStoreInstallState,
|
|
||||||
PluginStoreActionLabel,
|
|
||||||
PluginStoreReadme,
|
|
||||||
PluginStoreSourceResult
|
|
||||||
} from '../../domain/models/plugin-store.models';
|
|
||||||
import { PluginHostService } from './plugin-host.service';
|
|
||||||
import { PluginDesktopStateService } from './plugin-desktop-state.service';
|
|
||||||
import { PluginRequirementService } from './plugin-requirement.service';
|
|
||||||
import { PluginRegistryService } from './plugin-registry.service';
|
|
||||||
|
|
||||||
const STORE_SCHEMA_VERSION = 1;
|
|
||||||
const STORAGE_KEY_PLUGIN_STORE = 'metoyou_plugin_store';
|
|
||||||
const DEFAULT_STORE_STATE: PersistedPluginStoreState = {
|
|
||||||
installedPlugins: [],
|
|
||||||
sourceUrls: []
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface PluginStoreInstallOptions {
|
|
||||||
activate?: boolean;
|
|
||||||
manifest?: TojuPluginManifest;
|
|
||||||
optional?: boolean;
|
|
||||||
serverId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class PluginStoreService {
|
|
||||||
private readonly electronBridge = inject(ElectronBridgeService);
|
|
||||||
private readonly desktopState = inject(PluginDesktopStateService);
|
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
|
||||||
private readonly host = inject(PluginHostService);
|
|
||||||
private readonly pluginRequirements = inject(PluginRequirementService);
|
|
||||||
private readonly realtime = inject(RealtimeSessionFacade, { optional: true });
|
|
||||||
private readonly registry = inject(PluginRegistryService);
|
|
||||||
private readonly serverDirectory = inject(ServerDirectoryFacade, { optional: true });
|
|
||||||
private readonly store = inject(Store, { optional: true });
|
|
||||||
private readonly currentRoomId = this.store?.selectSignal(selectCurrentRoomId) ?? null;
|
|
||||||
private readonly currentRoomName = this.store?.selectSignal(selectCurrentRoomName) ?? null;
|
|
||||||
private readonly currentUser = this.store?.selectSignal(selectCurrentUser) ?? null;
|
|
||||||
private readonly sourceUrlsSignal = signal<string[]>([]);
|
|
||||||
private readonly sourcesSignal = signal<PluginStoreSourceResult[]>([]);
|
|
||||||
private readonly clientInstalledPluginsSignal = signal<InstalledStorePlugin[]>([]);
|
|
||||||
private readonly serverInstalledPluginsSignal = signal<InstalledStorePlugin[]>([]);
|
|
||||||
private readonly loadingSignal = signal(false);
|
|
||||||
private refreshAbortController: AbortController | null = null;
|
|
||||||
private refreshVersion = 0;
|
|
||||||
private installedLoadVersion = 0;
|
|
||||||
private stateMutated = false;
|
|
||||||
|
|
||||||
readonly sourceUrls = this.sourceUrlsSignal.asReadonly();
|
|
||||||
readonly sources = this.sourcesSignal.asReadonly();
|
|
||||||
readonly installedPlugins = computed(() => {
|
|
||||||
const installedPlugins = this.clientInstalledPluginsSignal().concat(this.serverInstalledPluginsSignal());
|
|
||||||
|
|
||||||
return installedPlugins.sort(sortInstalledPlugins);
|
|
||||||
});
|
|
||||||
readonly isLoading = this.loadingSignal.asReadonly();
|
|
||||||
readonly availablePlugins = computed(() => this.sources().flatMap((source) => source.plugins));
|
|
||||||
readonly hasActiveServerInstallScope = computed(() => !!this.currentRoomId?.());
|
|
||||||
readonly installedById = computed(() => new Map(this.installedPlugins().map((plugin) => [plugin.manifest.id, plugin])));
|
|
||||||
readonly installScopeLabel = computed(() => this.currentRoomName?.() || 'this device');
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
const state = this.loadState();
|
|
||||||
|
|
||||||
this.sourceUrlsSignal.set(state.sourceUrls);
|
|
||||||
void this.applyInstalledPlugins(state.installedPlugins, 'client');
|
|
||||||
|
|
||||||
if (this.currentRoomId && this.currentUser && this.serverDirectory) {
|
|
||||||
effect(() => {
|
|
||||||
const roomId = this.currentRoomId?.() ?? null;
|
|
||||||
const actorUserId = this.currentActorUserId();
|
|
||||||
|
|
||||||
void this.loadInstalledPluginsForScope(roomId, actorUserId);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.realtime?.onSignalingMessage
|
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
||||||
.subscribe((message) => {
|
|
||||||
if (isPluginRequirementsChangedMessage(message) && message.serverId === this.currentRoomId?.()) {
|
|
||||||
void this.loadInstalledPluginsForScope(message.serverId, this.currentActorUserId());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void this.loadDesktopState();
|
|
||||||
}
|
|
||||||
|
|
||||||
async addSourceUrl(rawUrl: string): Promise<void> {
|
|
||||||
const sourceUrl = normalizeSourceUrl(rawUrl, 'Plugin source URL');
|
|
||||||
|
|
||||||
if (this.sourceUrls().includes(sourceUrl)) {
|
|
||||||
throw new Error('Plugin source already exists');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.sourceUrlsSignal.update((sourceUrls) => [...sourceUrls, sourceUrl]);
|
|
||||||
this.saveState();
|
|
||||||
await this.refreshSources();
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeSourceUrl(sourceUrl: string): Promise<void> {
|
|
||||||
this.sourceUrlsSignal.update((sourceUrls) => sourceUrls.filter((candidate) => candidate !== sourceUrl));
|
|
||||||
this.sourcesSignal.update((sources) => sources.filter((source) => source.url !== sourceUrl));
|
|
||||||
this.saveState();
|
|
||||||
await this.refreshSources();
|
|
||||||
}
|
|
||||||
|
|
||||||
async refreshSources(): Promise<void> {
|
|
||||||
const currentRefresh = this.refreshVersion + 1;
|
|
||||||
const abortController = new AbortController();
|
|
||||||
|
|
||||||
this.refreshVersion = currentRefresh;
|
|
||||||
this.refreshAbortController?.abort();
|
|
||||||
this.refreshAbortController = abortController;
|
|
||||||
this.loadingSignal.set(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const sources = await Promise.all(this.sourceUrls().map((sourceUrl) => this.loadSource(sourceUrl, abortController.signal)));
|
|
||||||
|
|
||||||
if (this.refreshVersion === currentRefresh) {
|
|
||||||
this.sourcesSignal.set(sources);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (this.refreshVersion === currentRefresh) {
|
|
||||||
this.refreshAbortController = null;
|
|
||||||
this.loadingSignal.set(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async installPlugin(plugin: PluginStoreEntry, options: PluginStoreInstallOptions = {}): Promise<InstalledStorePlugin> {
|
|
||||||
if (!plugin.installUrl) {
|
|
||||||
throw new Error('Plugin does not provide an install manifest URL');
|
|
||||||
}
|
|
||||||
|
|
||||||
const manifest = options.manifest ?? await this.fetchPluginManifest(plugin.installUrl);
|
|
||||||
const installScope = getPluginInstallScope(manifest);
|
|
||||||
const targetServerId = installScope === 'server' ? (options.serverId ?? this.currentRoomId?.() ?? null) : null;
|
|
||||||
|
|
||||||
if (installScope === 'server' && !targetServerId) {
|
|
||||||
throw new Error('Open a chat server before installing server-scoped plugins');
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const currentScopePlugins = installScope === 'server'
|
|
||||||
? await this.installedPluginsForServer(targetServerId)
|
|
||||||
: this.installedPluginsForScope(installScope);
|
|
||||||
const existing = currentScopePlugins.find((candidate) => candidate.manifest.id === manifest.id);
|
|
||||||
const installedPlugin: InstalledStorePlugin = {
|
|
||||||
installedAt: existing?.installedAt ?? now,
|
|
||||||
installUrl: plugin.installUrl,
|
|
||||||
manifest,
|
|
||||||
sourceUrl: plugin.sourceUrl,
|
|
||||||
updatedAt: now
|
|
||||||
};
|
|
||||||
const nextInstalledPlugins = currentScopePlugins
|
|
||||||
.filter((candidate) => candidate.manifest.id !== manifest.id)
|
|
||||||
.concat(installedPlugin)
|
|
||||||
.sort(sortInstalledPlugins);
|
|
||||||
|
|
||||||
if (installScope === 'server') {
|
|
||||||
await this.saveServerPluginRequirement(installedPlugin, targetServerId, options.optional === true ? 'optional' : 'required');
|
|
||||||
} else {
|
|
||||||
await this.persistInstalledPlugins(nextInstalledPlugins, installScope);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (installScope === 'client' || targetServerId === this.currentRoomId?.()) {
|
|
||||||
this.host.registerLocalManifest(manifest, plugin.installUrl);
|
|
||||||
|
|
||||||
if (installScope === 'client' || options.optional !== true) {
|
|
||||||
this.setInstalledPluginsForScope(installScope, nextInstalledPlugins);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.activate) {
|
|
||||||
await this.host.activatePluginById(manifest.id);
|
|
||||||
}
|
|
||||||
} else if (options.activate) {
|
|
||||||
await this.host.rememberActivation(manifest.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return installedPlugin;
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadInstallManifest(plugin: PluginStoreEntry): Promise<TojuPluginManifest> {
|
|
||||||
if (!plugin.installUrl) {
|
|
||||||
throw new Error('Plugin does not provide an install manifest URL');
|
|
||||||
}
|
|
||||||
|
|
||||||
return await this.fetchPluginManifest(plugin.installUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
async uninstallPlugin(pluginId: string, scope?: TojuPluginInstallScope): Promise<void> {
|
|
||||||
const installScope = scope ?? this.findInstalledPluginScope(pluginId) ?? 'client';
|
|
||||||
const nextInstalledPlugins = this.installedPluginsForScope(installScope).filter((installedPlugin) => installedPlugin.manifest.id !== pluginId);
|
|
||||||
|
|
||||||
if (installScope === 'server') {
|
|
||||||
await this.deleteServerPluginRequirement(pluginId);
|
|
||||||
} else {
|
|
||||||
await this.persistInstalledPlugins(nextInstalledPlugins, installScope);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.host.deactivatePlugin(pluginId, { forgetActivation: true });
|
|
||||||
this.registry.unregister(pluginId);
|
|
||||||
this.setInstalledPluginsForScope(installScope, nextInstalledPlugins);
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadReadme(plugin: PluginStoreEntry): Promise<PluginStoreReadme> {
|
|
||||||
if (!plugin.readmeUrl) {
|
|
||||||
throw new Error('Plugin does not provide a readme URL');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
markdown: await this.fetchText(plugin.readmeUrl, 'text/markdown,text/plain,*/*'),
|
|
||||||
pluginId: plugin.id,
|
|
||||||
title: plugin.title,
|
|
||||||
url: plugin.readmeUrl
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
getInstallState(plugin: PluginStoreEntry): PluginStoreInstallState {
|
|
||||||
const installed = this.installedPluginForScope(plugin.id, getStoreEntryInstallScope(plugin));
|
|
||||||
|
|
||||||
if (!installed) {
|
|
||||||
return 'notInstalled';
|
|
||||||
}
|
|
||||||
|
|
||||||
return compareVersions(plugin.version, installed.manifest.version) > 0
|
|
||||||
? 'updateAvailable'
|
|
||||||
: 'installed';
|
|
||||||
}
|
|
||||||
|
|
||||||
getActionLabel(plugin: PluginStoreEntry): PluginStoreActionLabel {
|
|
||||||
const state = this.getInstallState(plugin);
|
|
||||||
const serverScoped = getStoreEntryInstallScope(plugin) === 'server';
|
|
||||||
|
|
||||||
if (state === 'updateAvailable') {
|
|
||||||
return serverScoped ? 'Update Server' : 'Update';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state === 'installed') {
|
|
||||||
return serverScoped ? 'Remove from Server' : 'Uninstall';
|
|
||||||
}
|
|
||||||
|
|
||||||
return serverScoped ? 'Install to Server' : 'Install';
|
|
||||||
}
|
|
||||||
|
|
||||||
canInstallPlugin(plugin: PluginStoreEntry): boolean {
|
|
||||||
return getStoreEntryInstallScope(plugin) !== 'server' || this.hasActiveServerInstallScope();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadSource(sourceUrl: string, signal: AbortSignal): Promise<PluginStoreSourceResult> {
|
|
||||||
try {
|
|
||||||
const sourceValue = await this.fetchJson(sourceUrl, signal);
|
|
||||||
|
|
||||||
return parsePluginSource(sourceUrl, sourceValue);
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
error: error instanceof Error ? error.message : 'Unable to load plugin source',
|
|
||||||
plugins: [],
|
|
||||||
url: sourceUrl
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchPluginManifest(manifestUrl: string): Promise<TojuPluginManifest> {
|
|
||||||
const manifestValue = await this.fetchJson(manifestUrl);
|
|
||||||
const validation = validateTojuPluginManifest(manifestValue);
|
|
||||||
|
|
||||||
if (!validation.manifest) {
|
|
||||||
throw new Error(validation.issues.map((issue) => `${issue.path}: ${issue.message}`).join('\n'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return validation.manifest;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchJson(url: string, signal?: AbortSignal): Promise<unknown> {
|
|
||||||
return JSON.parse(await this.fetchText(url, 'application/json', signal)) as unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchText(url: string, accept: string, signal?: AbortSignal): Promise<string> {
|
|
||||||
if (url.startsWith('file://')) {
|
|
||||||
return await this.readLocalFileUrl(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url, { headers: { Accept: accept }, signal });
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Request returned ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await response.text();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async readLocalFileUrl(fileUrl: string): Promise<string> {
|
|
||||||
const api = this.electronBridge.getApi();
|
|
||||||
|
|
||||||
if (!api) {
|
|
||||||
throw new Error('Local plugin source paths require the desktop app');
|
|
||||||
}
|
|
||||||
|
|
||||||
const base64Data = await api.readFile(fileUrlToPath(fileUrl));
|
|
||||||
const bytes = Uint8Array.from(atob(base64Data), (character) => character.charCodeAt(0));
|
|
||||||
|
|
||||||
return new TextDecoder().decode(bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async applyInstalledPlugins(installedPlugins: InstalledStorePlugin[], scope: TojuPluginInstallScope): Promise<void> {
|
|
||||||
const usableInstalledPlugins: InstalledStorePlugin[] = [];
|
|
||||||
const scopedInstalledPlugins = installedPlugins.filter((installedPlugin) => getPluginInstallScope(installedPlugin.manifest) === scope);
|
|
||||||
const nextIds = new Set(scopedInstalledPlugins.map((installedPlugin) => installedPlugin.manifest.id));
|
|
||||||
|
|
||||||
for (const previousPlugin of this.installedPluginsForScope(scope)) {
|
|
||||||
if (!nextIds.has(previousPlugin.manifest.id)) {
|
|
||||||
await this.host.deactivatePlugin(previousPlugin.manifest.id);
|
|
||||||
this.registry.unregister(previousPlugin.manifest.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const installedPlugin of scopedInstalledPlugins) {
|
|
||||||
try {
|
|
||||||
this.host.registerLocalManifest(installedPlugin.manifest, installedPlugin.installUrl);
|
|
||||||
usableInstalledPlugins.push(installedPlugin);
|
|
||||||
} catch {
|
|
||||||
// Corrupt persisted manifests are ignored so the store can recover on next install.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setInstalledPluginsForScope(scope, usableInstalledPlugins);
|
|
||||||
|
|
||||||
await this.host.activatePersistedPlugins();
|
|
||||||
|
|
||||||
if (usableInstalledPlugins.length !== scopedInstalledPlugins.length) {
|
|
||||||
if (scope === 'client') {
|
|
||||||
await this.persistInstalledPlugins(usableInstalledPlugins, scope);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadInstalledPluginsForScope(roomId: string | null, actorUserId: string | null): Promise<void> {
|
|
||||||
const currentLoad = this.installedLoadVersion + 1;
|
|
||||||
|
|
||||||
this.installedLoadVersion = currentLoad;
|
|
||||||
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
if (!roomId || !actorUserId || !this.serverDirectory) {
|
|
||||||
if (this.installedLoadVersion === currentLoad) {
|
|
||||||
await this.applyInstalledPlugins([], 'server');
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const installedPlugins = await this.readServerInstalledPlugins(roomId);
|
|
||||||
|
|
||||||
if (this.installedLoadVersion === currentLoad && this.currentRoomId?.() === roomId) {
|
|
||||||
await this.applyInstalledPlugins(installedPlugins, 'server');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if (this.installedLoadVersion === currentLoad && this.currentRoomId?.() === roomId) {
|
|
||||||
await this.applyInstalledPlugins([], 'server');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async persistInstalledPlugins(
|
|
||||||
installedPlugins: InstalledStorePlugin[],
|
|
||||||
scope: TojuPluginInstallScope,
|
|
||||||
serverId?: string | null
|
|
||||||
): Promise<void> {
|
|
||||||
const roomId = serverId ?? this.currentRoomId?.() ?? null;
|
|
||||||
const actorUserId = this.currentActorUserId();
|
|
||||||
|
|
||||||
if (scope === 'server') {
|
|
||||||
if (!roomId || !actorUserId || !this.serverDirectory) {
|
|
||||||
throw new Error('Open a chat server before saving server-scoped plugins');
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(installedPlugins.map((installedPlugin) => this.saveServerPluginRequirement(installedPlugin, roomId, 'required')));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.clientInstalledPluginsSignal.set(installedPlugins);
|
|
||||||
this.saveState();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async readServerInstalledPlugins(roomId: string): Promise<InstalledStorePlugin[]> {
|
|
||||||
if (!this.serverDirectory) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const snapshot = await firstValueFrom(this.pluginRequirements.getSnapshot(this.serverDirectory.getApiBaseUrl(), roomId));
|
|
||||||
|
|
||||||
return snapshot.requirements
|
|
||||||
.map((requirement) => installedPluginFromRequirement(requirement))
|
|
||||||
.filter((installedPlugin): installedPlugin is InstalledStorePlugin => !!installedPlugin)
|
|
||||||
.sort(sortInstalledPlugins);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async saveServerPluginRequirement(
|
|
||||||
installedPlugin: InstalledStorePlugin,
|
|
||||||
roomId: string | null,
|
|
||||||
status: 'optional' | 'required'
|
|
||||||
): Promise<void> {
|
|
||||||
const actorUserId = this.currentActorUserId();
|
|
||||||
|
|
||||||
if (!roomId || !actorUserId || !this.serverDirectory) {
|
|
||||||
throw new Error('Open a chat server before saving server-scoped plugins');
|
|
||||||
}
|
|
||||||
|
|
||||||
await firstValueFrom(this.pluginRequirements.upsertRequirement(
|
|
||||||
this.serverDirectory.getApiBaseUrl(),
|
|
||||||
roomId,
|
|
||||||
installedPlugin.manifest.id,
|
|
||||||
{
|
|
||||||
actorUserId,
|
|
||||||
installUrl: installedPlugin.installUrl,
|
|
||||||
manifest: installedPlugin.manifest,
|
|
||||||
reason: installedPlugin.manifest.description,
|
|
||||||
sourceUrl: installedPlugin.sourceUrl,
|
|
||||||
status,
|
|
||||||
versionRange: `^${installedPlugin.manifest.version}`
|
|
||||||
}
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async deleteServerPluginRequirement(pluginId: string): Promise<void> {
|
|
||||||
const roomId = this.currentRoomId?.() ?? null;
|
|
||||||
const actorUserId = this.currentActorUserId();
|
|
||||||
|
|
||||||
if (!roomId || !actorUserId || !this.serverDirectory) {
|
|
||||||
throw new Error('Open a chat server before removing server-scoped plugins');
|
|
||||||
}
|
|
||||||
|
|
||||||
await firstValueFrom(this.pluginRequirements.deleteRequirement(this.serverDirectory.getApiBaseUrl(), roomId, pluginId, actorUserId));
|
|
||||||
}
|
|
||||||
|
|
||||||
private currentActorUserId(): string | null {
|
|
||||||
const user = this.currentUser?.() ?? null;
|
|
||||||
|
|
||||||
return user?.oderId || user?.id || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async installedPluginsForServer(serverId: string | null): Promise<InstalledStorePlugin[]> {
|
|
||||||
if (!serverId) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (serverId === this.currentRoomId?.()) {
|
|
||||||
return this.serverInstalledPluginsSignal();
|
|
||||||
}
|
|
||||||
|
|
||||||
const actorUserId = this.currentActorUserId();
|
|
||||||
|
|
||||||
if (!actorUserId || !this.serverDirectory) {
|
|
||||||
throw new Error('Unable to read server plugins without an active user and server directory');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await this.readServerInstalledPlugins(serverId);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadState(): PersistedPluginStoreState {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(getUserScopedStorageKey(STORAGE_KEY_PLUGIN_STORE));
|
|
||||||
|
|
||||||
if (!raw) {
|
|
||||||
return { ...DEFAULT_STORE_STATE };
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalizePersistedState(JSON.parse(raw) as unknown);
|
|
||||||
} catch {
|
|
||||||
return { ...DEFAULT_STORE_STATE };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private saveState(): void {
|
|
||||||
this.stateMutated = true;
|
|
||||||
|
|
||||||
const state = {
|
|
||||||
installedPlugins: this.clientInstalledPluginsSignal(),
|
|
||||||
schemaVersion: STORE_SCHEMA_VERSION,
|
|
||||||
sourceUrls: this.sourceUrls()
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
localStorage.setItem(getUserScopedStorageKey(STORAGE_KEY_PLUGIN_STORE), JSON.stringify(state));
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
void this.desktopState.writeJson(STORAGE_KEY_PLUGIN_STORE, state);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadDesktopState(): Promise<void> {
|
|
||||||
const state = await this.desktopState.readJson<PersistedPluginStoreState>(STORAGE_KEY_PLUGIN_STORE, this.loadState());
|
|
||||||
|
|
||||||
if (this.stateMutated) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalized = normalizePersistedState(state);
|
|
||||||
const sourceUrlsChanged = JSON.stringify(normalized.sourceUrls) !== JSON.stringify(this.sourceUrls());
|
|
||||||
|
|
||||||
if (sourceUrlsChanged) {
|
|
||||||
this.sourceUrlsSignal.set(normalized.sourceUrls);
|
|
||||||
void this.refreshSources();
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.applyInstalledPlugins(normalized.installedPlugins, 'client');
|
|
||||||
}
|
|
||||||
|
|
||||||
private installedPluginsForScope(scope: TojuPluginInstallScope): InstalledStorePlugin[] {
|
|
||||||
return scope === 'server' ? this.serverInstalledPluginsSignal() : this.clientInstalledPluginsSignal();
|
|
||||||
}
|
|
||||||
|
|
||||||
private setInstalledPluginsForScope(scope: TojuPluginInstallScope, installedPlugins: InstalledStorePlugin[]): void {
|
|
||||||
if (scope === 'server') {
|
|
||||||
this.serverInstalledPluginsSignal.set(installedPlugins);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.clientInstalledPluginsSignal.set(installedPlugins);
|
|
||||||
}
|
|
||||||
|
|
||||||
private installedPluginForScope(pluginId: string, scope: TojuPluginInstallScope): InstalledStorePlugin | undefined {
|
|
||||||
return this.installedPluginsForScope(scope).find((installedPlugin) => installedPlugin.manifest.id === pluginId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private findInstalledPluginScope(pluginId: string): TojuPluginInstallScope | null {
|
|
||||||
if (this.serverInstalledPluginsSignal().some((installedPlugin) => installedPlugin.manifest.id === pluginId)) {
|
|
||||||
return 'server';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.clientInstalledPluginsSignal().some((installedPlugin) => installedPlugin.manifest.id === pluginId)) {
|
|
||||||
return 'client';
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPluginRequirementsChangedMessage(message: unknown): message is { serverId: string; type: string } {
|
|
||||||
if (!isRecord(message)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (message['type'] === 'plugin_requirements' || message['type'] === 'plugin_requirements_changed')
|
|
||||||
&& typeof message['serverId'] === 'string';
|
|
||||||
}
|
|
||||||
|
|
||||||
function installedPluginFromRequirement(requirement: PluginRequirementSummary): InstalledStorePlugin | null {
|
|
||||||
if (requirement.status === 'optional' || requirement.status === 'blocked' || requirement.status === 'incompatible') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const manifest = requirement.manifest;
|
|
||||||
|
|
||||||
if (!manifest || !isInstalledStorePlugin({ manifest })) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
installedAt: requirement.updatedAt,
|
|
||||||
installUrl: requirement.installUrl,
|
|
||||||
manifest,
|
|
||||||
sourceUrl: requirement.sourceUrl,
|
|
||||||
updatedAt: requirement.updatedAt
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePluginSource(sourceUrl: string, sourceValue: unknown): PluginStoreSourceResult {
|
|
||||||
const sourceRecord = isRecord(sourceValue) ? sourceValue : {};
|
|
||||||
const sourceTitle = readString(sourceRecord, 'title', 'name') ?? new URL(sourceUrl).hostname;
|
|
||||||
const rawPlugins = Array.isArray(sourceValue)
|
|
||||||
? sourceValue
|
|
||||||
: Array.isArray(sourceRecord['plugins'])
|
|
||||||
? sourceRecord['plugins']
|
|
||||||
: Array.isArray(sourceRecord['items'])
|
|
||||||
? sourceRecord['items']
|
|
||||||
: [];
|
|
||||||
const plugins = rawPlugins
|
|
||||||
.map((entry) => parsePluginEntry(sourceUrl, sourceTitle, entry))
|
|
||||||
.filter((entry): entry is PluginStoreEntry => !!entry)
|
|
||||||
.sort((left, right) => left.title.localeCompare(right.title));
|
|
||||||
|
|
||||||
return {
|
|
||||||
loadedAt: Date.now(),
|
|
||||||
plugins,
|
|
||||||
title: sourceTitle,
|
|
||||||
url: sourceUrl
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortInstalledPlugins(left: InstalledStorePlugin, right: InstalledStorePlugin): number {
|
|
||||||
return left.manifest.title.localeCompare(right.manifest.title);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePluginEntry(sourceUrl: string, sourceTitle: string, value: unknown): PluginStoreEntry | null {
|
|
||||||
if (!isRecord(value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = readString(value, 'id', 'pluginId');
|
|
||||||
const version = readString(value, 'version') ?? '0.0.0';
|
|
||||||
|
|
||||||
if (!id) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
author: readAuthor(value),
|
|
||||||
description: readString(value, 'description', 'summary') ?? '',
|
|
||||||
githubUrl: resolveOptionalUrl(sourceUrl, readGithubUrl(value)),
|
|
||||||
homepageUrl: resolveOptionalUrl(sourceUrl, readString(value, 'homepage', 'homepageUrl', 'website')),
|
|
||||||
id,
|
|
||||||
imageUrl: resolveOptionalUrl(sourceUrl, readString(value, 'image', 'imageUrl', 'icon', 'iconUrl', 'banner')),
|
|
||||||
installUrl: resolveOptionalUrl(sourceUrl, readString(value, 'install', 'installUrl', 'manifest', 'manifestUrl')),
|
|
||||||
readmeUrl: resolveOptionalUrl(sourceUrl, readString(value, 'readme', 'readmeUrl')),
|
|
||||||
scope: readPluginInstallScope(value),
|
|
||||||
sourceTitle,
|
|
||||||
sourceUrl,
|
|
||||||
title: readString(value, 'title', 'name') ?? id,
|
|
||||||
version
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStoreEntryInstallScope(plugin: PluginStoreEntry): TojuPluginInstallScope {
|
|
||||||
return plugin.scope === 'server' ? 'server' : 'client';
|
|
||||||
}
|
|
||||||
|
|
||||||
function readPluginInstallScope(record: Record<string, unknown>): TojuPluginInstallScope | undefined {
|
|
||||||
const scope = readString(record, 'scope', 'installScope', 'pluginScope');
|
|
||||||
|
|
||||||
return scope === 'server' || scope === 'client' ? scope : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePersistedState(value: unknown): PersistedPluginStoreState {
|
|
||||||
if (!isRecord(value)) {
|
|
||||||
return { ...DEFAULT_STORE_STATE };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
installedPlugins: Array.isArray(value['installedPlugins'])
|
|
||||||
? value['installedPlugins'].filter(isInstalledStorePlugin)
|
|
||||||
: [],
|
|
||||||
sourceUrls: Array.isArray(value['sourceUrls'])
|
|
||||||
? value['sourceUrls']
|
|
||||||
.filter((entry): entry is string => typeof entry === 'string')
|
|
||||||
.map((entry) => normalizeOptionalSourceUrl(entry))
|
|
||||||
.filter((entry): entry is string => !!entry)
|
|
||||||
: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function isInstalledStorePlugin(value: unknown): value is InstalledStorePlugin {
|
|
||||||
if (!isRecord(value) || !isRecord(value['manifest'])) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const validation = validateTojuPluginManifest(value['manifest']);
|
|
||||||
|
|
||||||
return !!validation.manifest;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function readString(record: Record<string, unknown>, ...keys: string[]): string | undefined {
|
|
||||||
for (const key of keys) {
|
|
||||||
const value = record[key];
|
|
||||||
|
|
||||||
if (typeof value === 'string' && value.trim()) {
|
|
||||||
return value.trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readAuthor(record: Record<string, unknown>): string | undefined {
|
|
||||||
const author = readString(record, 'author');
|
|
||||||
|
|
||||||
if (author) {
|
|
||||||
return author;
|
|
||||||
}
|
|
||||||
|
|
||||||
const authors = record['authors'];
|
|
||||||
|
|
||||||
if (!Array.isArray(authors)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return authors
|
|
||||||
.map((entry) => isRecord(entry) ? readString(entry, 'name') : typeof entry === 'string' ? entry.trim() : '')
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(', ') || undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readGithubUrl(record: Record<string, unknown>): string | undefined {
|
|
||||||
const directUrl = readString(record, 'github', 'githubUrl');
|
|
||||||
|
|
||||||
if (directUrl) {
|
|
||||||
return directUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
const repository = record['repository'];
|
|
||||||
|
|
||||||
return isRecord(repository) ? readString(repository, 'url') : typeof repository === 'string' ? repository.trim() : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSourceUrl(rawUrl: string, label: string): string {
|
|
||||||
const url = normalizeOptionalSourceUrl(rawUrl);
|
|
||||||
|
|
||||||
if (!url) {
|
|
||||||
throw new Error(`${label} must be an http, https, file URL, or absolute local path`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeOptionalSourceUrl(rawUrl: string): string | undefined {
|
|
||||||
const trimmedUrl = rawUrl.trim();
|
|
||||||
|
|
||||||
if (!trimmedUrl) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = new URL(trimmedUrl);
|
|
||||||
|
|
||||||
if (!isAllowedPluginSourceProtocol(url.protocol)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
url.hash = '';
|
|
||||||
return url.toString();
|
|
||||||
} catch {
|
|
||||||
return localPathToFileUrl(trimmedUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveOptionalUrl(sourceUrl: string, rawUrl?: string): string | undefined {
|
|
||||||
if (!rawUrl) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = new URL(rawUrl, sourceUrl);
|
|
||||||
|
|
||||||
if (!isAllowedPluginSourceProtocol(url.protocol)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
url.hash = '';
|
|
||||||
return url.toString();
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAllowedPluginSourceProtocol(protocol: string): boolean {
|
|
||||||
return protocol === 'http:' || protocol === 'https:' || protocol === 'file:';
|
|
||||||
}
|
|
||||||
|
|
||||||
function localPathToFileUrl(filePath: string): string | undefined {
|
|
||||||
if (!isAbsoluteLocalPath(filePath)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedPath = filePath.replace(/\\/g, '/');
|
|
||||||
const pathWithLeadingSlash = /^[A-Za-z]:\//.test(normalizedPath)
|
|
||||||
? `/${normalizedPath}`
|
|
||||||
: normalizedPath;
|
|
||||||
|
|
||||||
return `file://${pathWithLeadingSlash.split('/')
|
|
||||||
.map(encodeURIComponent)
|
|
||||||
.join('/')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fileUrlToPath(fileUrl: string): string {
|
|
||||||
const url = new URL(fileUrl);
|
|
||||||
const decodedPath = decodeURIComponent(url.pathname);
|
|
||||||
|
|
||||||
if (/^\/[A-Za-z]:\//.test(decodedPath)) {
|
|
||||||
return decodedPath.slice(1).replace(/\//g, '\\');
|
|
||||||
}
|
|
||||||
|
|
||||||
return decodedPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAbsoluteLocalPath(filePath: string): boolean {
|
|
||||||
return filePath.startsWith('/') || /^[A-Za-z]:[\\/]/.test(filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
function compareVersions(leftVersion: string, rightVersion: string): number {
|
|
||||||
const leftParts = parseVersion(leftVersion);
|
|
||||||
const rightParts = parseVersion(rightVersion);
|
|
||||||
|
|
||||||
for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) {
|
|
||||||
const leftPart = leftParts[index] ?? 0;
|
|
||||||
const rightPart = rightParts[index] ?? 0;
|
|
||||||
|
|
||||||
if (leftPart !== rightPart) {
|
|
||||||
return leftPart - rightPart;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return leftVersion.localeCompare(rightVersion);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseVersion(version: string): number[] {
|
|
||||||
return version
|
|
||||||
.split(/[.+-]/)
|
|
||||||
.slice(0, 3)
|
|
||||||
.map((part) => Number.parseInt(part, 10))
|
|
||||||
.map((part) => Number.isFinite(part) ? part : 0);
|
|
||||||
}
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
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'
|
|
||||||
}
|
|
||||||
]));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import type { TojuPluginInstallScope, TojuPluginManifest } from '../../../../shared-kernel';
|
|
||||||
|
|
||||||
export function getPluginInstallScope(manifest: Pick<TojuPluginManifest, 'scope'>): TojuPluginInstallScope {
|
|
||||||
return manifest.scope === 'server' ? 'server' : 'client';
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
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('accepts server-scoped client plugin manifests', () => {
|
|
||||||
const result = validateTojuPluginManifest(createManifest({
|
|
||||||
scope: 'server'
|
|
||||||
}));
|
|
||||||
|
|
||||||
expect(result.valid).toBe(true);
|
|
||||||
expect(result.manifest?.scope).toBe('server');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects unknown plugin install scopes', () => {
|
|
||||||
const result = validateTojuPluginManifest({
|
|
||||||
...createManifest(),
|
|
||||||
scope: 'workspace'
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.valid).toBe(false);
|
|
||||||
expect(result.issues).toContainEqual({
|
|
||||||
message: 'scope must be client or server',
|
|
||||||
path: 'scope',
|
|
||||||
severity: 'error'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
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 (value['scope'] !== undefined && value['scope'] !== 'client' && value['scope'] !== 'server') {
|
|
||||||
pushIssue(issues, 'scope', 'scope must be client or server');
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
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 PluginApiMessageBusEnvelope {
|
|
||||||
channelId?: string;
|
|
||||||
eventId: string;
|
|
||||||
messages?: Message[];
|
|
||||||
payload?: unknown;
|
|
||||||
pluginId: string;
|
|
||||||
roomId: string;
|
|
||||||
sentAt: number;
|
|
||||||
sourcePeerId?: string;
|
|
||||||
sourceUserId?: string;
|
|
||||||
topic: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PluginApiMessageBusLatestRequest {
|
|
||||||
channelId?: string;
|
|
||||||
includeDeleted?: boolean;
|
|
||||||
limit?: number;
|
|
||||||
sinceTimestamp?: number;
|
|
||||||
targetPeerId?: string;
|
|
||||||
topic?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PluginApiMessageBusPublishRequest extends PluginApiMessageBusLatestRequest {
|
|
||||||
includeLatestMessages?: boolean;
|
|
||||||
includeSelf?: boolean;
|
|
||||||
payload?: unknown;
|
|
||||||
topic: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PluginApiMessageBusSubscription {
|
|
||||||
channelId?: string;
|
|
||||||
handler: (event: PluginApiMessageBusEnvelope) => void;
|
|
||||||
latestMessageLimit?: number;
|
|
||||||
replayLatest?: boolean;
|
|
||||||
topic?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 clientData: {
|
|
||||||
read: (key: string) => Promise<unknown>;
|
|
||||||
remove: (key: string) => Promise<void>;
|
|
||||||
write: (key: string, value: unknown) => Promise<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 messageBus: {
|
|
||||||
publish: (request: PluginApiMessageBusPublishRequest) => PluginApiMessageBusEnvelope;
|
|
||||||
sendLatestMessages: (request?: PluginApiMessageBusLatestRequest) => PluginApiMessageBusEnvelope;
|
|
||||||
subscribe: (subscription: PluginApiMessageBusSubscription) => TojuPluginDisposable;
|
|
||||||
};
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
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[];
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import type { TojuPluginInstallScope, TojuPluginManifest } from '../../../../shared-kernel';
|
|
||||||
|
|
||||||
export type PluginStoreInstallState = 'installed' | 'notInstalled' | 'updateAvailable';
|
|
||||||
export type PluginStoreActionLabel = 'Install' | 'Install to Server' | 'Remove from Server' | 'Uninstall' | 'Update' | 'Update Server';
|
|
||||||
|
|
||||||
export interface PluginStoreEntry {
|
|
||||||
author?: string;
|
|
||||||
description: string;
|
|
||||||
githubUrl?: string;
|
|
||||||
homepageUrl?: string;
|
|
||||||
id: string;
|
|
||||||
imageUrl?: string;
|
|
||||||
installUrl?: string;
|
|
||||||
readmeUrl?: string;
|
|
||||||
scope?: TojuPluginInstallScope;
|
|
||||||
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[];
|
|
||||||
}
|
|
||||||
@@ -1,461 +0,0 @@
|
|||||||
<!-- 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">{{ managerTitle() }}</h2>
|
|
||||||
<p class="truncate text-xs text-muted-foreground">{{ managerDescription() }}</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" />
|
|
||||||
</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">{{ emptyTitle() }}</p>
|
|
||||||
<p class="mt-1 text-sm text-muted-foreground">{{ emptyBody() }}</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 || !entry.enabled || isActive(entry)"
|
|
||||||
(click)="activate(entry)"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucidePlay"
|
|
||||||
size="14"
|
|
||||||
/>
|
|
||||||
Activate
|
|
||||||
</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>
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import {
|
|
||||||
Component,
|
|
||||||
EventEmitter,
|
|
||||||
Output,
|
|
||||||
computed,
|
|
||||||
inject,
|
|
||||||
input,
|
|
||||||
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, TojuPluginInstallScope } 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 { getPluginInstallScope } from '../../domain/logic/plugin-install-scope.logic';
|
|
||||||
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 scope = input<TojuPluginInstallScope>('client');
|
|
||||||
|
|
||||||
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 allEntries = this.registry.entries;
|
|
||||||
readonly entries = computed(() => this.allEntries().filter((entry) => this.entryBelongsToScope(entry)));
|
|
||||||
readonly managerTitle = computed(() => this.scope() === 'server' ? 'Server plugins' : 'Client plugins');
|
|
||||||
readonly managerDescription = computed(() => this.scope() === 'server'
|
|
||||||
? 'Plugins installed for the current chat server.'
|
|
||||||
: 'Global client plugins installed on this device.');
|
|
||||||
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.appPageRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
|
|
||||||
channelSections: this.uiRegistry.channelSectionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
|
|
||||||
composerActions: this.uiRegistry.composerActionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
|
|
||||||
embeds: this.uiRegistry.embedRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
|
|
||||||
profileActions: this.uiRegistry.profileActionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
|
|
||||||
settingsPages: this.uiRegistry.settingsPageRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
|
|
||||||
sidePanels: this.uiRegistry.sidePanelRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
|
|
||||||
toolbarActions: this.uiRegistry.toolbarActionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length
|
|
||||||
}));
|
|
||||||
readonly requirementComparisons = computed(() => this.scope() === 'server' ? this.requirementState.comparisons() : []);
|
|
||||||
readonly uiConflicts = computed(() => this.uiRegistry.conflicts()
|
|
||||||
.filter((conflict) => conflict.pluginIds.some((pluginId) => this.hasVisiblePlugin(pluginId))));
|
|
||||||
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 emptyTitle = computed(() => this.scope() === 'server' ? 'No server plugins installed.' : 'No client plugins installed.');
|
|
||||||
readonly emptyBody = computed(() => this.scope() === 'server'
|
|
||||||
? 'Server-scoped plugins use scope: server in toju-plugin.json.'
|
|
||||||
: 'Client-scoped plugins use scope: client or omit scope in toju-plugin.json.');
|
|
||||||
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 activate(entry: RegisteredPlugin): Promise<void> {
|
|
||||||
this.busyPluginId.set(entry.manifest.id);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.host.activatePluginById(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, { forgetActivation: true });
|
|
||||||
} 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
isActive(entry: RegisteredPlugin): boolean {
|
|
||||||
return this.host.isPluginActive(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;
|
|
||||||
}
|
|
||||||
|
|
||||||
private entryBelongsToScope(entry: RegisteredPlugin): boolean {
|
|
||||||
return getPluginInstallScope(entry.manifest) === this.scope();
|
|
||||||
}
|
|
||||||
|
|
||||||
private hasVisiblePlugin(pluginId: string): boolean {
|
|
||||||
return this.entries().some((entry) => entry.manifest.id === pluginId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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')
|
|
||||||
}))));
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,420 +0,0 @@
|
|||||||
<!-- 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 for {{ store.installScopeLabel() }} · {{ 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="text"
|
|
||||||
[(ngModel)]="newSourceUrl"
|
|
||||||
(keyup.enter)="addSourceUrl()"
|
|
||||||
placeholder="https://example.com/plugins.json or /home/me/plugins/source.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)"
|
|
||||||
[title]="serverInstallButtonTitle(plugin)"
|
|
||||||
class="plugin-store__primary-button plugin-card__primary-action"
|
|
||||||
[class.plugin-card__primary-action--danger]="store.getActionLabel(plugin) === 'Uninstall' || store.getActionLabel(plugin) === 'Remove from Server'"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
@if (serverInstallDialog(); as dialog) {
|
|
||||||
<div
|
|
||||||
class="plugin-store__modal-backdrop"
|
|
||||||
role="presentation"
|
|
||||||
></div>
|
|
||||||
<section
|
|
||||||
class="plugin-store__install-modal"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="server-plugin-install-title"
|
|
||||||
>
|
|
||||||
<header class="plugin-store__install-header">
|
|
||||||
<div>
|
|
||||||
<p>Server plugin install</p>
|
|
||||||
<h2 id="server-plugin-install-title">{{ dialog.manifest.title }}</h2>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="closeServerInstallDialog()"
|
|
||||||
class="plugin-store__icon-button"
|
|
||||||
title="Cancel install"
|
|
||||||
>
|
|
||||||
<ng-icon name="lucideX" />
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="plugin-store__install-body">
|
|
||||||
<label class="plugin-store__field">
|
|
||||||
<span>Install to server</span>
|
|
||||||
<select
|
|
||||||
[value]="dialog.selectedServerId"
|
|
||||||
[disabled]="serverInstallBusy()"
|
|
||||||
(change)="selectServerInstallTarget($any($event.target).value)"
|
|
||||||
>
|
|
||||||
@for (server of manageableServers(); track trackServer($index, server)) {
|
|
||||||
<option [value]="server.id">{{ server.name }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="plugin-store__capability-row">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
[checked]="serverInstallOptional()"
|
|
||||||
[disabled]="serverInstallBusy()"
|
|
||||||
(change)="serverInstallOptional.set($any($event.target).checked)"
|
|
||||||
/>
|
|
||||||
<span>Optional for server members</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="plugin-store__capability-list">
|
|
||||||
<div class="plugin-store__capability-list-header">
|
|
||||||
<h3>Capabilities</h3>
|
|
||||||
<span>{{ dialog.manifest.capabilities?.length ?? 0 }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ((dialog.manifest.capabilities?.length ?? 0) > 0) {
|
|
||||||
@for (capability of dialog.manifest.capabilities; track trackInstallCapability($index, capability)) {
|
|
||||||
<label class="plugin-store__capability-row">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
[checked]="selectedCapabilityIds().has(capability)"
|
|
||||||
[disabled]="serverInstallBusy()"
|
|
||||||
(change)="toggleInstallCapability(capability, $any($event.target).checked)"
|
|
||||||
/>
|
|
||||||
<span>{{ capability }}</span>
|
|
||||||
</label>
|
|
||||||
}
|
|
||||||
} @else {
|
|
||||||
<p class="plugin-store__muted-text">This plugin requests no capabilities.</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (serverInstallError()) {
|
|
||||||
<p class="plugin-store__error-banner">{{ serverInstallError() }}</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="plugin-store__install-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="closeServerInstallDialog()"
|
|
||||||
[disabled]="serverInstallBusy()"
|
|
||||||
class="plugin-store__secondary-button"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="confirmServerInstall()"
|
|
||||||
[disabled]="serverInstallBusy() || !dialog.selectedServerId"
|
|
||||||
class="plugin-store__primary-button"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucidePlus"
|
|
||||||
[class.is-spinning]="serverInstallBusy()"
|
|
||||||
/>
|
|
||||||
Install and Activate
|
|
||||||
</button>
|
|
||||||
</footer>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
</main>
|
|
||||||
@@ -1,607 +0,0 @@
|
|||||||
: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;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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__modal-backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 80;
|
|
||||||
background: rgb(0 0 0 / 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__install-modal {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 81;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
display: flex;
|
|
||||||
width: min(34rem, calc(100vw - 2rem));
|
|
||||||
max-height: min(42rem, calc(100vh - 2rem));
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
border: 1px solid hsl(var(--border));
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
background: hsl(var(--card));
|
|
||||||
box-shadow: 0 1.5rem 4rem rgb(0 0 0 / 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__install-header,
|
|
||||||
.plugin-store__install-actions,
|
|
||||||
.plugin-store__capability-list-header,
|
|
||||||
.plugin-store__capability-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__install-header {
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
border-bottom: 1px solid hsl(var(--border));
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__install-header p,
|
|
||||||
.plugin-store__install-header h2,
|
|
||||||
.plugin-store__capability-list-header h3,
|
|
||||||
.plugin-store__muted-text {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__install-header p,
|
|
||||||
.plugin-store__field span,
|
|
||||||
.plugin-store__capability-list-header span,
|
|
||||||
.plugin-store__muted-text {
|
|
||||||
color: hsl(var(--muted-foreground));
|
|
||||||
font-size: 0.78rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__install-header h2 {
|
|
||||||
margin-top: 0.2rem;
|
|
||||||
font-size: 1.05rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__install-body {
|
|
||||||
display: grid;
|
|
||||||
min-height: 0;
|
|
||||||
gap: 1rem;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__field {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__field select {
|
|
||||||
min-height: 2.25rem;
|
|
||||||
border: 1px solid hsl(var(--border));
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
padding: 0.45rem 0.65rem;
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
background: hsl(var(--secondary));
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__capability-list {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__capability-list-header {
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__capability-list-header h3 {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__capability-row {
|
|
||||||
gap: 0.55rem;
|
|
||||||
border: 1px solid hsl(var(--border));
|
|
||||||
border-radius: 0.45rem;
|
|
||||||
padding: 0.5rem 0.6rem;
|
|
||||||
background: hsl(var(--background) / 0.5);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__capability-row input {
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__install-actions {
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 0.5rem;
|
|
||||||
border-top: 1px solid hsl(var(--border));
|
|
||||||
padding: 0.85rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,502 +0,0 @@
|
|||||||
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 { Store as NgRxStore } from '@ngrx/store';
|
|
||||||
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 { resolveLegacyRole, resolveRoomPermission } from '../../../access-control';
|
|
||||||
import type {
|
|
||||||
PluginCapabilityId,
|
|
||||||
Room,
|
|
||||||
TojuPluginManifest,
|
|
||||||
User
|
|
||||||
} from '../../../../shared-kernel';
|
|
||||||
import { selectCurrentRoom, selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
|
|
||||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
|
||||||
import { PluginCapabilityService } from '../../application/services/plugin-capability.service';
|
|
||||||
import { PluginStoreService } from '../../application/services/plugin-store.service';
|
|
||||||
import type { PluginStoreEntry, PluginStoreReadme } from '../../domain/models/plugin-store.models';
|
|
||||||
|
|
||||||
interface ServerPluginInstallDialog {
|
|
||||||
manifest: TojuPluginManifest;
|
|
||||||
plugin: PluginStoreEntry;
|
|
||||||
selectedServerId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@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 capabilities = inject(PluginCapabilityService);
|
|
||||||
readonly ngrxStore = inject(NgRxStore);
|
|
||||||
readonly savedRooms = this.ngrxStore.selectSignal(selectSavedRooms);
|
|
||||||
readonly currentRoom = this.ngrxStore.selectSignal(selectCurrentRoom);
|
|
||||||
readonly currentUser = this.ngrxStore.selectSignal(selectCurrentUser);
|
|
||||||
readonly manageableServers = computed(() => {
|
|
||||||
const user = this.currentUser();
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const roomsById = new Map(this.savedRooms().map((room) => [room.id, room]));
|
|
||||||
const currentRoom = this.currentRoom();
|
|
||||||
|
|
||||||
if (currentRoom) {
|
|
||||||
roomsById.set(currentRoom.id, currentRoom);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(roomsById.values())
|
|
||||||
.filter((room) => this.canManageServerPlugins(room, user));
|
|
||||||
});
|
|
||||||
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);
|
|
||||||
readonly serverInstallDialog = signal<ServerPluginInstallDialog | null>(null);
|
|
||||||
readonly selectedCapabilityIds = signal<Set<PluginCapabilityId>>(new Set());
|
|
||||||
readonly serverInstallOptional = signal(false);
|
|
||||||
readonly serverInstallError = signal<string | null>(null);
|
|
||||||
readonly serverInstallBusy = signal(false);
|
|
||||||
|
|
||||||
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' || action === 'Remove from Server') {
|
|
||||||
await this.store.uninstallPlugin(plugin.id, plugin.scope);
|
|
||||||
} else if (this.isServerScopedPlugin(plugin)) {
|
|
||||||
await this.openServerInstallDialog(plugin);
|
|
||||||
} 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
async openServerInstallDialog(plugin: PluginStoreEntry): Promise<void> {
|
|
||||||
this.actionBusyPluginId.set(plugin.id);
|
|
||||||
this.serverInstallError.set(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const manifest = await this.store.loadInstallManifest(plugin);
|
|
||||||
const selectedServerId = this.defaultServerInstallTargetId();
|
|
||||||
|
|
||||||
if (!selectedServerId) {
|
|
||||||
throw new Error('You need owner or Manage Server access on a chat server before installing server plugins');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.selectedCapabilityIds.set(new Set(manifest.capabilities ?? []));
|
|
||||||
this.serverInstallOptional.set(false);
|
|
||||||
this.serverInstallDialog.set({ manifest, plugin, selectedServerId });
|
|
||||||
} catch (error) {
|
|
||||||
if (this.destroyed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.actionError.set(error instanceof Error ? error.message : 'Unable to prepare server plugin install');
|
|
||||||
} finally {
|
|
||||||
if (!this.destroyed) {
|
|
||||||
this.actionBusyPluginId.set(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
closeServerInstallDialog(): void {
|
|
||||||
if (this.serverInstallBusy()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.serverInstallDialog.set(null);
|
|
||||||
this.serverInstallError.set(null);
|
|
||||||
this.serverInstallOptional.set(false);
|
|
||||||
this.selectedCapabilityIds.set(new Set());
|
|
||||||
}
|
|
||||||
|
|
||||||
selectServerInstallTarget(serverId: string): void {
|
|
||||||
this.serverInstallDialog.update((dialog) => dialog ? { ...dialog, selectedServerId: serverId } : dialog);
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleInstallCapability(capability: PluginCapabilityId, checked: boolean): void {
|
|
||||||
this.selectedCapabilityIds.update((capabilities) => {
|
|
||||||
const nextCapabilities = new Set(capabilities);
|
|
||||||
|
|
||||||
if (checked) {
|
|
||||||
nextCapabilities.add(capability);
|
|
||||||
} else {
|
|
||||||
nextCapabilities.delete(capability);
|
|
||||||
}
|
|
||||||
|
|
||||||
return nextCapabilities;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async confirmServerInstall(): Promise<void> {
|
|
||||||
const dialog = this.serverInstallDialog();
|
|
||||||
|
|
||||||
if (!dialog) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.serverInstallBusy.set(true);
|
|
||||||
this.serverInstallError.set(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (const capability of dialog.manifest.capabilities ?? []) {
|
|
||||||
if (this.selectedCapabilityIds().has(capability)) {
|
|
||||||
this.capabilities.grant(dialog.manifest.id, capability);
|
|
||||||
} else {
|
|
||||||
this.capabilities.revoke(dialog.manifest.id, capability);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.store.installPlugin(dialog.plugin, {
|
|
||||||
activate: true,
|
|
||||||
manifest: dialog.manifest,
|
|
||||||
optional: this.serverInstallOptional(),
|
|
||||||
serverId: dialog.selectedServerId
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this.destroyed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.serverInstallDialog.set(null);
|
|
||||||
this.serverInstallOptional.set(false);
|
|
||||||
this.selectedCapabilityIds.set(new Set());
|
|
||||||
} catch (error) {
|
|
||||||
if (this.destroyed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.serverInstallError.set(error instanceof Error ? error.message : 'Unable to install server plugin');
|
|
||||||
} finally {
|
|
||||||
if (!this.destroyed) {
|
|
||||||
this.serverInstallBusy.set(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
goBack(): void {
|
|
||||||
void this.router.navigateByUrl(this.getReturnUrl());
|
|
||||||
}
|
|
||||||
|
|
||||||
async openManager(): Promise<void> {
|
|
||||||
const currentRoomId = this.currentRoom()?.id;
|
|
||||||
|
|
||||||
await this.router.navigateByUrl(this.getReturnUrl());
|
|
||||||
this.settingsModal.open(this.store.hasActiveServerInstallScope() ? 'serverPlugins' : 'plugins', currentRoomId);
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
|| !this.canRunPrimaryAction(plugin)
|
|
||||||
|| (!plugin.installUrl && this.store.getInstallState(plugin) !== 'installed');
|
|
||||||
}
|
|
||||||
|
|
||||||
canRunPrimaryAction(plugin: PluginStoreEntry): boolean {
|
|
||||||
if (!this.isServerScopedPlugin(plugin)) {
|
|
||||||
return this.store.canInstallPlugin(plugin);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.manageableServers().length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
primaryActionIcon(plugin: PluginStoreEntry): string {
|
|
||||||
const action = this.store.getActionLabel(plugin);
|
|
||||||
|
|
||||||
if (action === 'Uninstall') {
|
|
||||||
return 'lucideTrash2';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === 'Remove from Server') {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
trackServer(index: number, server: Room): string {
|
|
||||||
return server.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
trackInstallCapability(index: number, capability: PluginCapabilityId): string {
|
|
||||||
return capability;
|
|
||||||
}
|
|
||||||
|
|
||||||
isServerScopedPlugin(plugin: PluginStoreEntry): boolean {
|
|
||||||
return plugin.scope === 'server';
|
|
||||||
}
|
|
||||||
|
|
||||||
serverInstallButtonTitle(plugin: PluginStoreEntry): string {
|
|
||||||
return this.isServerScopedPlugin(plugin) && this.manageableServers().length === 0
|
|
||||||
? 'Requires owner or Manage Server access on a chat server'
|
|
||||||
: this.store.getActionLabel(plugin);
|
|
||||||
}
|
|
||||||
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
|
|
||||||
private defaultServerInstallTargetId(): string | null {
|
|
||||||
const currentRoomId = this.currentRoom()?.id ?? null;
|
|
||||||
|
|
||||||
return this.manageableServers().find((room) => room.id === currentRoomId)?.id ?? this.manageableServers()[0]?.id ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private canManageServerPlugins(room: Room, user: User): boolean {
|
|
||||||
return resolveLegacyRole(room, user) === 'host' || resolveRoomPermission(room, user, 'manageServer');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
export * from './application/services/plugin-capability.service';
|
|
||||||
export * from './application/services/plugin-client-api.service';
|
|
||||||
export * from './application/services/plugin-desktop-state.service';
|
|
||||||
export * from './application/services/plugin-host.service';
|
|
||||||
export * from './application/services/plugin-logger.service';
|
|
||||||
export * from './application/services/plugin-message-bus.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';
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
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'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user