From 6920f93b4117e42f1abc895c8fe616d226897586 Mon Sep 17 00:00:00 2001 From: Myx Date: Wed, 29 Apr 2026 01:14:14 +0200 Subject: [PATCH 1/9] feat: plugins v1 --- .../plugins/api-test-plugin/README.md | 3 + .../plugins/api-test-plugin/dist/main.js | 6 + .../plugins/api-test-plugin/toju-plugin.json | 49 ++ e2e/helpers/plugin-api-test-fixture.ts | 42 ++ .../plugins/plugin-api-two-users.spec.ts | 179 ++++++ e2e/tests/plugins/plugin-manager-ui.spec.ts | 88 +++ e2e/tests/plugins/plugin-support-api.spec.ts | 405 +++++++++++++ electron/ipc/system.ts | 3 + electron/plugin-library.spec.ts | 126 ++++ electron/plugin-library.ts | 165 ++++++ electron/preload.ts | 26 + server/README.md | 1 + server/data/metoyou.sqlite | Bin 172032 -> 233472 bytes server/src/config/variables.ts | 44 +- server/src/db/database.ts | 28 +- server/src/entities/PluginDataEntity.ts | 35 ++ .../src/entities/PluginUserMetadataEntity.ts | 38 ++ .../ServerPluginEventDefinitionEntity.ts | 41 ++ .../entities/ServerPluginRequirementEntity.ts | 36 ++ .../entities/ServerPluginSettingsEntity.ts | 26 + server/src/entities/index.ts | 7 + .../migrations/1000000000007-PluginSupport.ts | 92 +++ server/src/migrations/index.ts | 4 +- server/src/routes/index.ts | 4 + server/src/routes/openapi-docs.ts | 106 ++++ server/src/routes/plugin-support.ts | 208 +++++++ server/src/services/plugin-support.service.ts | 523 +++++++++++++++++ server/src/websocket/handler-plugin.spec.ts | 221 +++++++ server/src/websocket/handler.ts | 102 +++- toju-app/angular.json | 6 +- toju-app/public/plugins/e2e-all-api/README.md | 3 + toju-app/public/plugins/e2e-all-api/icon.svg | 6 + toju-app/public/plugins/e2e-all-api/main.js | 273 +++++++++ .../plugins/e2e-all-api/toju.plugin.json | 99 ++++ .../public/plugins/e2e-plugin-source.json | 17 + toju-app/src/app/app.routes.ts | 10 + toju-app/src/app/core/models/index.ts | 16 + .../platform/electron/electron-api.models.ts | 24 + .../core/services/settings-modal.service.ts | 1 + toju-app/src/app/domains/README.md | 2 + .../chat-message-composer.component.html | 14 + .../chat-message-composer.component.ts | 8 + .../chat-message-item.component.html | 14 + .../chat-message-item.component.ts | 39 ++ toju-app/src/app/domains/plugins/README.md | 17 + .../services/plugin-capability.service.ts | 106 ++++ .../services/plugin-client-api.service.ts | 555 ++++++++++++++++++ .../services/plugin-host.service.spec.ts | 161 +++++ .../services/plugin-host.service.ts | 255 ++++++++ .../services/plugin-logger.service.ts | 64 ++ .../services/plugin-registry.service.ts | 117 ++++ .../plugin-requirement-state.service.ts | 202 +++++++ .../services/plugin-requirement.service.ts | 66 +++ .../services/plugin-storage.service.ts | 109 ++++ .../services/plugin-store.service.spec.ts | 198 +++++++ .../services/plugin-store.service.ts | 453 ++++++++++++++ .../services/plugin-ui-registry.service.ts | 237 ++++++++ .../plugins/development/development-plugin.ts | 43 ++ .../plugin-dependency-resolver.logic.spec.ts | 82 +++ .../logic/plugin-dependency-resolver.logic.ts | 251 ++++++++ .../plugin-manifest-validation.logic.spec.ts | 86 +++ .../logic/plugin-manifest-validation.logic.ts | 204 +++++++ .../domain/models/plugin-api.models.ts | 226 +++++++ .../domain/models/plugin-runtime.models.ts | 81 +++ .../domain/models/plugin-store.models.ts | 46 ++ .../plugin-manager.component.html | 449 ++++++++++++++ .../plugin-manager.component.ts | 208 +++++++ .../plugin-page-host.component.html | 17 + .../plugin-page-host.component.ts | 42 ++ .../plugin-render-host.component.ts | 44 ++ .../plugin-store/plugin-store.component.html | 314 ++++++++++ .../plugin-store/plugin-store.component.scss | 490 ++++++++++++++++ .../plugin-store/plugin-store.component.ts | 310 ++++++++++ toju-app/src/app/domains/plugins/index.ts | 16 + .../local-plugin-discovery.service.spec.ts | 114 ++++ .../local-plugin-discovery.service.ts | 46 ++ .../rooms-side-panel.component.html | 35 ++ .../rooms-side-panel.component.ts | 6 + .../settings-modal.component.html | 6 + .../settings-modal.component.ts | 5 + .../features/settings/settings.component.html | 12 + .../features/settings/settings.component.ts | 12 +- .../shell/title-bar/title-bar.component.html | 22 + .../shell/title-bar/title-bar.component.ts | 9 + toju-app/src/app/shared-kernel/index.ts | 1 + .../shared-kernel/plugin-system.contracts.ts | 193 ++++++ 86 files changed, 9036 insertions(+), 14 deletions(-) create mode 100644 e2e/fixtures/plugins/api-test-plugin/README.md create mode 100644 e2e/fixtures/plugins/api-test-plugin/dist/main.js create mode 100644 e2e/fixtures/plugins/api-test-plugin/toju-plugin.json create mode 100644 e2e/helpers/plugin-api-test-fixture.ts create mode 100644 e2e/tests/plugins/plugin-api-two-users.spec.ts create mode 100644 e2e/tests/plugins/plugin-manager-ui.spec.ts create mode 100644 e2e/tests/plugins/plugin-support-api.spec.ts create mode 100644 electron/plugin-library.spec.ts create mode 100644 electron/plugin-library.ts create mode 100644 server/src/entities/PluginDataEntity.ts create mode 100644 server/src/entities/PluginUserMetadataEntity.ts create mode 100644 server/src/entities/ServerPluginEventDefinitionEntity.ts create mode 100644 server/src/entities/ServerPluginRequirementEntity.ts create mode 100644 server/src/entities/ServerPluginSettingsEntity.ts create mode 100644 server/src/migrations/1000000000007-PluginSupport.ts create mode 100644 server/src/routes/openapi-docs.ts create mode 100644 server/src/routes/plugin-support.ts create mode 100644 server/src/services/plugin-support.service.ts create mode 100644 server/src/websocket/handler-plugin.spec.ts create mode 100644 toju-app/public/plugins/e2e-all-api/README.md create mode 100644 toju-app/public/plugins/e2e-all-api/icon.svg create mode 100644 toju-app/public/plugins/e2e-all-api/main.js create mode 100644 toju-app/public/plugins/e2e-all-api/toju.plugin.json create mode 100644 toju-app/public/plugins/e2e-plugin-source.json create mode 100644 toju-app/src/app/domains/plugins/README.md create mode 100644 toju-app/src/app/domains/plugins/application/services/plugin-capability.service.ts create mode 100644 toju-app/src/app/domains/plugins/application/services/plugin-client-api.service.ts create mode 100644 toju-app/src/app/domains/plugins/application/services/plugin-host.service.spec.ts create mode 100644 toju-app/src/app/domains/plugins/application/services/plugin-host.service.ts create mode 100644 toju-app/src/app/domains/plugins/application/services/plugin-logger.service.ts create mode 100644 toju-app/src/app/domains/plugins/application/services/plugin-registry.service.ts create mode 100644 toju-app/src/app/domains/plugins/application/services/plugin-requirement-state.service.ts create mode 100644 toju-app/src/app/domains/plugins/application/services/plugin-requirement.service.ts create mode 100644 toju-app/src/app/domains/plugins/application/services/plugin-storage.service.ts create mode 100644 toju-app/src/app/domains/plugins/application/services/plugin-store.service.spec.ts create mode 100644 toju-app/src/app/domains/plugins/application/services/plugin-store.service.ts create mode 100644 toju-app/src/app/domains/plugins/application/services/plugin-ui-registry.service.ts create mode 100644 toju-app/src/app/domains/plugins/development/development-plugin.ts create mode 100644 toju-app/src/app/domains/plugins/domain/logic/plugin-dependency-resolver.logic.spec.ts create mode 100644 toju-app/src/app/domains/plugins/domain/logic/plugin-dependency-resolver.logic.ts create mode 100644 toju-app/src/app/domains/plugins/domain/logic/plugin-manifest-validation.logic.spec.ts create mode 100644 toju-app/src/app/domains/plugins/domain/logic/plugin-manifest-validation.logic.ts create mode 100644 toju-app/src/app/domains/plugins/domain/models/plugin-api.models.ts create mode 100644 toju-app/src/app/domains/plugins/domain/models/plugin-runtime.models.ts create mode 100644 toju-app/src/app/domains/plugins/domain/models/plugin-store.models.ts create mode 100644 toju-app/src/app/domains/plugins/feature/plugin-manager/plugin-manager.component.html create mode 100644 toju-app/src/app/domains/plugins/feature/plugin-manager/plugin-manager.component.ts create mode 100644 toju-app/src/app/domains/plugins/feature/plugin-page-host/plugin-page-host.component.html create mode 100644 toju-app/src/app/domains/plugins/feature/plugin-page-host/plugin-page-host.component.ts create mode 100644 toju-app/src/app/domains/plugins/feature/plugin-render-host/plugin-render-host.component.ts create mode 100644 toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.html create mode 100644 toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.scss create mode 100644 toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.ts create mode 100644 toju-app/src/app/domains/plugins/index.ts create mode 100644 toju-app/src/app/domains/plugins/infrastructure/local-plugin-discovery.service.spec.ts create mode 100644 toju-app/src/app/domains/plugins/infrastructure/local-plugin-discovery.service.ts create mode 100644 toju-app/src/app/shared-kernel/plugin-system.contracts.ts diff --git a/e2e/fixtures/plugins/api-test-plugin/README.md b/e2e/fixtures/plugins/api-test-plugin/README.md new file mode 100644 index 0000000..a9eebab --- /dev/null +++ b/e2e/fixtures/plugins/api-test-plugin/README.md @@ -0,0 +1,3 @@ +# E2E Plugin API Fixture + +This plugin is intentionally tiny. Tests use its manifest to exercise plugin discovery, server support metadata, server data, and plugin event relay APIs without executing plugin code. \ No newline at end of file diff --git a/e2e/fixtures/plugins/api-test-plugin/dist/main.js b/e2e/fixtures/plugins/api-test-plugin/dist/main.js new file mode 100644 index 0000000..b778b6a --- /dev/null +++ b/e2e/fixtures/plugins/api-test-plugin/dist/main.js @@ -0,0 +1,6 @@ +export default { + id: 'e2e.plugin-api', + activate(api) { + api?.logger?.info?.('E2E Plugin API Fixture activated'); + } +}; \ No newline at end of file diff --git a/e2e/fixtures/plugins/api-test-plugin/toju-plugin.json b/e2e/fixtures/plugins/api-test-plugin/toju-plugin.json new file mode 100644 index 0000000..2116a72 --- /dev/null +++ b/e2e/fixtures/plugins/api-test-plugin/toju-plugin.json @@ -0,0 +1,49 @@ +{ + "apiVersion": "1.0.0", + "capabilities": [ + "storage.serverData.read", + "storage.serverData.write", + "events.server.publish", + "events.server.subscribe", + "events.p2p.publish", + "events.p2p.subscribe" + ], + "compatibility": { + "minimumTojuVersion": "1.0.0", + "verifiedTojuVersion": "1.0.0" + }, + "data": [ + { + "key": "settings", + "scope": "server", + "storage": "serverData" + }, + { + "key": "presence", + "scope": "user", + "storage": "serverData" + } + ], + "description": "Fixture plugin used by automated tests for plugin support APIs.", + "entrypoint": "./dist/main.js", + "events": [ + { + "direction": "serverRelay", + "eventName": "e2e:relay", + "maxPayloadBytes": 2048, + "scope": "server" + }, + { + "direction": "p2pHint", + "eventName": "e2e:p2p", + "maxPayloadBytes": 512, + "scope": "user" + } + ], + "id": "e2e.plugin-api", + "kind": "client", + "readme": "./README.md", + "schemaVersion": 1, + "title": "E2E Plugin API Fixture", + "version": "1.0.0" +} \ No newline at end of file diff --git a/e2e/helpers/plugin-api-test-fixture.ts b/e2e/helpers/plugin-api-test-fixture.ts new file mode 100644 index 0000000..d95bda2 --- /dev/null +++ b/e2e/helpers/plugin-api-test-fixture.ts @@ -0,0 +1,42 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +export const TEST_PLUGIN_FIXTURE_DIR = join(__dirname, '..', 'fixtures', 'plugins', 'api-test-plugin'); +export const TEST_PLUGIN_ID = 'e2e.plugin-api'; +export const TEST_PLUGIN_RELAY_EVENT = 'e2e:relay'; +export const TEST_PLUGIN_P2P_EVENT = 'e2e:p2p'; + +export interface PluginApiTestManifestEvent { + direction: 'clientToServer' | 'serverRelay' | 'p2pHint'; + eventName: string; + maxPayloadBytes?: number; + scope: 'server' | 'channel' | 'user' | 'plugin'; +} + +export interface PluginApiTestManifest { + description: string; + events: PluginApiTestManifestEvent[]; + id: string; + title: string; + version: string; +} + +export async function readPluginApiTestManifest(): Promise { + const manifestPath = join(TEST_PLUGIN_FIXTURE_DIR, 'toju-plugin.json'); + const manifestText = await readFile(manifestPath, 'utf8'); + + return JSON.parse(manifestText) as PluginApiTestManifest; +} + +export function getPluginApiTestEvent( + manifest: PluginApiTestManifest, + eventName: string +): PluginApiTestManifestEvent { + const eventDefinition = manifest.events.find((event) => event.eventName === eventName); + + if (!eventDefinition) { + throw new Error(`Expected fixture plugin to define ${eventName}`); + } + + return eventDefinition; +} diff --git a/e2e/tests/plugins/plugin-api-two-users.spec.ts b/e2e/tests/plugins/plugin-api-two-users.spec.ts new file mode 100644 index 0000000..32dc392 --- /dev/null +++ b/e2e/tests/plugins/plugin-api-two-users.spec.ts @@ -0,0 +1,179 @@ +import { type Page } from '@playwright/test'; +import { + expect, + test, + type Client +} from '../../fixtures/multi-client'; +import { ChatMessagesPage } from '../../pages/chat-messages.page'; +import { ChatRoomPage } from '../../pages/chat-room.page'; +import { RegisterPage } from '../../pages/register.page'; +import { ServerSearchPage } from '../../pages/server-search.page'; + +const PLUGIN_SOURCE_URL = 'http://localhost:4200/plugins/e2e-plugin-source.json'; +const PLUGIN_TITLE = 'E2E All API Plugin'; +const EDITED_MESSAGE = 'Plugin API edited message'; +const ORIGINAL_MESSAGE = 'Plugin API original message'; +const DELETED_MESSAGE = 'Plugin API deleted message'; +const DELETED_MESSAGE_CONTENT = '[Message deleted]'; +const PLUGIN_BOT_MESSAGE = 'Plugin bot message from all-api fixture'; +const CUSTOM_EMBED_TEXT = 'E2E custom embed: Plugin API custom embed'; +const SOUND_BOARD_TEXT = 'E2E soundboard ready'; +const SOUND_BOARD_LABEL = 'E2E Soundboard'; +const SOUND_BOARD_PLAYED_MESSAGE = 'E2E soundboard played Airhorn to voice channel'; +const VOICE_CHANNEL = 'Plugin Voice'; + +test.describe('Plugin API multi-user runtime', () => { + test.describe.configure({ timeout: 180_000 }); + + test('runs chat, embed, soundboard, and profile APIs between two users', async ({ createClient }) => { + const scenario = await createPluginApiScenario(createClient); + + await test.step('Install and activate the plugin for Bob as the embed/soundboard receiver', async () => { + await installGrantAndActivatePlugin(scenario.bob.page); + await closeSettingsModal(scenario.bob.page); + await expect(soundboardComposerButton(scenario.bob.page)).toBeVisible({ timeout: 20_000 }); + await expect(scenario.bob.page.getByText(SOUND_BOARD_TEXT, { exact: true })).toBeVisible({ timeout: 20_000 }); + await expect(scenario.bob.page.getByTestId('e2e-plugin-owned-dom')).toHaveAttribute('data-plugin-owner', 'e2e.all-api-plugin'); + }); + + await test.step('Install and activate the plugin for Alice as the API driver', async () => { + await installGrantAndActivatePlugin(scenario.alice.page); + await closeSettingsModal(scenario.alice.page); + await expect(soundboardComposerButton(scenario.alice.page)).toBeVisible({ timeout: 20_000 }); + await expect(scenario.alice.page.getByText(SOUND_BOARD_TEXT, { exact: true })).toBeVisible({ timeout: 20_000 }); + await expect(scenario.alice.page.getByTestId('e2e-plugin-owned-dom')).toHaveAttribute('data-plugin-owner', 'e2e.all-api-plugin'); + }); + + await test.step('Alice opens the plugin soundboard modal and plays a sound to voice', async () => { + await soundboardComposerButton(scenario.alice.page).click(); + await expect(scenario.alice.page.getByRole('dialog', { name: SOUND_BOARD_LABEL })).toBeVisible({ timeout: 20_000 }); + await expect(scenario.alice.page.getByTestId('e2e-soundboard-modal')).toHaveAttribute('data-plugin-owner', 'e2e.all-api-plugin'); + await scenario.alice.page.getByRole('button', { name: 'Play airhorn to voice' }).click(); + await expect(scenario.alice.page.getByTestId('e2e-soundboard-status')).toHaveText(SOUND_BOARD_PLAYED_MESSAGE, { timeout: 20_000 }); + }); + + await test.step('Bob receives messages sent and edited by Alice through the plugin API', async () => { + await expect(scenario.bobMessages.getMessageItemByText(EDITED_MESSAGE)).toBeVisible({ timeout: 30_000 }); + await expect(scenario.bobMessages.getMessageItemByText(ORIGINAL_MESSAGE)).toHaveCount(0); + await expect(scenario.bob.page.getByText('(edited)')).toBeVisible({ timeout: 20_000 }); + }); + + await test.step('Bob sees plugin API deletion state and plugin-user messages', async () => { + await expect(scenario.bobMessages.getMessageItemByText(DELETED_MESSAGE_CONTENT)).toBeVisible({ timeout: 30_000 }); + await expect(scenario.bobMessages.getMessageItemByText(DELETED_MESSAGE)).toHaveCount(0); + await expect(scenario.bobMessages.getMessageItemByText(PLUGIN_BOT_MESSAGE)).toBeVisible({ timeout: 30_000 }); + await expect(scenario.bobMessages.getMessageItemByText(SOUND_BOARD_PLAYED_MESSAGE)).toBeVisible({ timeout: 30_000 }); + }); + + await test.step('Bob renders Alice custom embed through the plugin embed API', async () => { + await expect(scenario.bob.page.getByTestId('plugin-message-embeds')).toContainText(CUSTOM_EMBED_TEXT, { timeout: 30_000 }); + }); + + await test.step('Bob sees Alice profile name changed by the plugin API', async () => { + await expect(scenario.bobMessages.getMessageItemByText(EDITED_MESSAGE)).toContainText('Alice Plugin Renamed', { timeout: 30_000 }); + }); + }); +}); + +interface PluginApiScenario { + alice: Client; + aliceRoom: ChatRoomPage; + bob: Client; + bobRoom: ChatRoomPage; + aliceMessages: ChatMessagesPage; + bobMessages: ChatMessagesPage; +} + +async function createPluginApiScenario(createClient: () => Promise): Promise { + const suffix = uniqueName('plugin-api'); + const serverName = `Plugin API Server ${suffix}`; + const alice = await createClient(); + const bob = await createClient(); + + await registerUser(alice.page, `alice_${suffix}`, 'Alice'); + await registerUser(bob.page, `bob_${suffix}`, 'Bob'); + + const aliceSearch = new ServerSearchPage(alice.page); + + await aliceSearch.createServer(serverName, { description: 'Two-user plugin API E2E coverage' }); + await expect(alice.page).toHaveURL(/\/room\//, { timeout: 30_000 }); + + const aliceRoom = new ChatRoomPage(alice.page); + + await aliceRoom.ensureVoiceChannelExists(VOICE_CHANNEL); + + const bobSearch = new ServerSearchPage(bob.page); + + await bobSearch.joinServerFromSearch(serverName); + await expect(bob.page).toHaveURL(/\/room\//, { timeout: 30_000 }); + + const bobRoom = new ChatRoomPage(bob.page); + + await aliceRoom.joinVoiceChannel(VOICE_CHANNEL); + await bobRoom.joinVoiceChannel(VOICE_CHANNEL); + await expect(aliceRoom.voiceControls).toBeVisible({ timeout: 30_000 }); + await expect(bobRoom.voiceControls).toBeVisible({ timeout: 30_000 }); + + const aliceMessages = new ChatMessagesPage(alice.page); + const bobMessages = new ChatMessagesPage(bob.page); + + await aliceMessages.waitForReady(); + await bobMessages.waitForReady(); + await expect(alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' })).toBeVisible({ timeout: 30_000 }); + await expect(bob.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Alice' })).toBeVisible({ timeout: 30_000 }); + + return { + alice, + aliceRoom, + bob, + bobRoom, + aliceMessages, + bobMessages + }; +} + +async function registerUser(page: Page, username: string, displayName: string): Promise { + const registerPage = new RegisterPage(page); + + await registerPage.goto(); + await registerPage.register(username, displayName, 'TestPass123!'); + await expect(page).toHaveURL(/\/search/, { timeout: 30_000 }); +} + +async function installGrantAndActivatePlugin(page: Page): Promise { + await page.getByRole('button', { name: 'Plugins' }).click(); + await expect(page).toHaveURL(/\/plugin-store/, { timeout: 20_000 }); + await expect(page.getByTestId('plugin-store-page')).toBeVisible({ timeout: 20_000 }); + await page.getByPlaceholder('https://example.com/plugins.json').fill(PLUGIN_SOURCE_URL); + await page.getByRole('button', { name: 'Add Source' }).click(); + await expect(page.getByRole('heading', { name: PLUGIN_TITLE })).toBeVisible({ timeout: 20_000 }); + await page.getByRole('button', { exact: true, name: 'Install' }).click(); + await expect(page.locator('article', { hasText: PLUGIN_TITLE }).getByText('Installed')).toBeVisible({ timeout: 20_000 }); + await page.getByRole('button', { name: 'Manage Plugins' }).click(); + await expect(page.getByTestId('plugin-manager')).toBeVisible({ timeout: 20_000 }); + await expect(page.locator('article', { hasText: PLUGIN_TITLE })).toBeVisible({ timeout: 20_000 }); + await page.locator('article', { hasText: PLUGIN_TITLE }) + .getByRole('button', { name: 'Select' }) + .click(); + + await page.getByRole('button', { name: 'Grant all requested' }).click(); + await page.getByRole('button', { name: 'Activate ready plugins' }).click(); + await expect(page.locator('article', { hasText: PLUGIN_TITLE }).getByText('ready', { exact: true })).toBeVisible({ timeout: 30_000 }); + await page.getByRole('button', { name: 'Logs' }).click(); + await expect(page.getByText('all-api plugin completed')).toBeVisible({ timeout: 30_000 }); +} + +async function closeSettingsModal(page: Page): Promise { + await page.keyboard.press('Escape'); + await expect(page.getByTestId('plugin-manager')).toHaveCount(0); +} + +function uniqueName(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36) + .slice(2, 8)}`; +} + +function soundboardComposerButton(page: Page) { + return page.locator('app-chat-message-composer') + .getByRole('button', { exact: true, name: SOUND_BOARD_LABEL }); +} diff --git a/e2e/tests/plugins/plugin-manager-ui.spec.ts b/e2e/tests/plugins/plugin-manager-ui.spec.ts new file mode 100644 index 0000000..5e57995 --- /dev/null +++ b/e2e/tests/plugins/plugin-manager-ui.spec.ts @@ -0,0 +1,88 @@ +import { expect, test } from '../../fixtures/multi-client'; +import { RegisterPage } from '../../pages/register.page'; +import { ServerSearchPage } from '../../pages/server-search.page'; + +test.describe('Plugin manager UI', () => { + test.describe.configure({ timeout: 180_000 }); + + test('installs, grants, activates, and logs an all-API test plugin', async ({ createClient }) => { + const client = await createClient(); + const { page } = client; + const suffix = Date.now(); + const register = new RegisterPage(page); + const search = new ServerSearchPage(page); + + await test.step('Register user and create server context', async () => { + await register.goto(); + await register.register(`plugin_${suffix}`, 'Plugin Tester', 'TestPass123!'); + await expect(page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 }); + await search.createServer(`Plugin API Server ${suffix}`, { + description: 'Plugin manager UI E2E coverage' + }); + + await expect(page).toHaveURL(/\/room\//, { timeout: 30_000 }); + }); + + await test.step('Open visible Plugins button', async () => { + await page.getByRole('button', { name: 'Plugins' }).click(); + await expect(page).toHaveURL(/\/plugin-store/, { timeout: 10_000 }); + await expect(page.getByTestId('plugin-store-page')).toBeVisible({ timeout: 10_000 }); + }); + + await test.step('Install fixture plugin from source manifest', async () => { + await page.getByPlaceholder('https://example.com/plugins.json').fill('http://localhost:4200/plugins/e2e-plugin-source.json'); + await page.getByRole('button', { name: 'Add Source' }).click(); + await expect(page.getByRole('heading', { name: 'E2E All API Plugin' })).toBeVisible({ timeout: 15_000 }); + await page.getByRole('button', { name: 'Readme' }).click(); + await expect(page.getByText('Fixture plugin for Playwright coverage.')).toBeVisible({ timeout: 10_000 }); + await page.getByRole('button', { exact: true, name: 'Install' }).click(); + await expect(page.locator('article', { hasText: 'E2E All API Plugin' }).getByText('Installed')).toBeVisible({ timeout: 10_000 }); + }); + + await test.step('Open plugin manager from the store page', async () => { + await page.getByRole('button', { name: 'Manage Plugins' }).click(); + await expect(page.getByTestId('plugin-manager')).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId('plugin-manager').getByRole('heading', { name: 'Plugins' })).toBeVisible(); + await expect(page.getByText('Development Plugin')).toBeVisible(); + }); + + await test.step('Grant capabilities and activate runtime', async () => { + const manager = page.getByTestId('plugin-manager'); + const pluginCard = manager.locator('article', { hasText: 'E2E All API Plugin' }); + + await manager.getByRole('button', { name: 'Installed' }).click(); + await expect(pluginCard).toBeVisible({ timeout: 10_000 }); + await pluginCard.getByRole('button', { name: 'Select' }).click(); + + await page.getByRole('button', { name: 'Grant all requested' }).click(); + await page.getByRole('button', { name: 'Activate ready plugins' }).click(); + await expect(page.locator('article', { hasText: 'E2E All API Plugin' }).getByText('ready', { exact: true })).toBeVisible({ timeout: 20_000 }); + }); + + await test.step('Verify plugin exercised APIs through logs and extension points', async () => { + const manager = page.getByTestId('plugin-manager'); + + await manager.getByRole('button', { name: 'Logs' }).click(); + await expect(page.getByText('all-api plugin completed')).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText('all-api plugin ready')).toBeVisible({ timeout: 10_000 }); + await manager.getByRole('button', { name: 'Extension points' }).click(); + await expect(page.getByTestId('plugin-extension-counts')).toContainText('Settings pages'); + await expect(page.getByTestId('plugin-extension-counts')).toContainText('Embed renderers'); + await expect(page.getByTestId('plugin-extension-counts')).toContainText('1'); + await expect(page.getByTestId('plugin-conflict-diagnostics')).toContainText( + 'No duplicate route, action, embed, channel, panel, or settings contribution ids detected.' + ); + + await manager.getByRole('button', { exact: true, name: 'Requirements' }).click(); + await expect(page.getByTestId('plugin-server-requirements')).toContainText('E2E All API Plugin'); + await expect(page.getByTestId('plugin-server-requirements')).toContainText('enabled'); + + await manager.getByRole('button', { exact: true, name: 'Settings' }).click(); + await expect(page.getByTestId('plugin-generated-settings')).toContainText('E2E settings contribution'); + await expect(page.getByTestId('plugin-generated-settings')).toContainText('"enabled"'); + + await manager.getByRole('button', { exact: true, name: 'Docs' }).click(); + await expect(page.getByTestId('plugin-installed-docs')).toContainText('Calls every public Toju plugin API surface'); + }); + }); +}); diff --git a/e2e/tests/plugins/plugin-support-api.spec.ts b/e2e/tests/plugins/plugin-support-api.spec.ts new file mode 100644 index 0000000..1b3aa8b --- /dev/null +++ b/e2e/tests/plugins/plugin-support-api.spec.ts @@ -0,0 +1,405 @@ +import type { APIRequestContext, APIResponse } from '@playwright/test'; +import WebSocket from 'ws'; +import { expect, test } from '../../fixtures/multi-client'; +import { + getPluginApiTestEvent, + readPluginApiTestManifest, + TEST_PLUGIN_ID, + TEST_PLUGIN_P2P_EVENT, + TEST_PLUGIN_RELAY_EVENT +} from '../../helpers/plugin-api-test-fixture'; + +const OWNER_USER_ID = 'plugin-api-owner'; + +interface CreatedServerResponse { + id: string; +} + +interface PluginRequirementResponse { + requirement: { + pluginId: string; + reason?: string; + status: string; + versionRange?: string; + }; +} + +interface PluginEventDefinitionResponse { + eventDefinition: { + direction: string; + eventName: string; + maxPayloadBytes: number; + pluginId: string; + scope: string; + }; +} + +interface PluginDataResponse { + record: { + key: string; + ownerId?: string; + pluginId: string; + schemaVersion: number; + scope: string; + value: unknown; + }; +} + +interface PluginDataListResponse { + records: PluginDataResponse['record'][]; +} + +interface PluginSnapshotResponse { + eventDefinitions: PluginEventDefinitionResponse['eventDefinition'][]; + requirements: PluginRequirementResponse['requirement'][]; + serverId: string; +} + +interface SocketMessage { + [key: string]: unknown; + type?: string; +} + +interface TestSocket { + close: () => Promise; + messages: SocketMessage[]; + send: (message: SocketMessage) => void; +} + +test.describe('Plugin support API', () => { + test('covers plugin requirement, event, data, and websocket APIs with the fixture plugin', async ({ request, testServer }) => { + const manifest = await readPluginApiTestManifest(); + const server = await createServer(request, testServer.url, `Plugin API ${Date.now()}`); + const relayEvent = getPluginApiTestEvent(manifest, TEST_PLUGIN_RELAY_EVENT); + const p2pEvent = getPluginApiTestEvent(manifest, TEST_PLUGIN_P2P_EVENT); + const pluginsApi = `${testServer.url}/api/servers/${encodeURIComponent(server.id)}/plugins`; + + await test.step('Initial snapshot is empty', async () => { + const snapshot = await expectJson(await request.get(pluginsApi)); + + expect(snapshot).toEqual(expect.objectContaining({ + eventDefinitions: [], + requirements: [], + serverId: server.id + })); + }); + + await test.step('Requirement API enforces server management permission', async () => { + const response = await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, { + data: { + actorUserId: 'not-the-owner', + status: 'required' + } + }); + const body = await expectJson<{ errorCode: string }>(response, 403); + + expect(body.errorCode).toBe('NOT_AUTHORIZED'); + }); + + await test.step('Requirement and event definition APIs persist the test plugin contract', async () => { + const requirement = await expectJson(await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, { + data: { + actorUserId: OWNER_USER_ID, + reason: manifest.description, + status: 'required', + versionRange: `^${manifest.version}` + } + })); + + expect(requirement.requirement).toEqual(expect.objectContaining({ + pluginId: TEST_PLUGIN_ID, + reason: manifest.description, + status: 'required', + versionRange: `^${manifest.version}` + })); + + const relayDefinition = await upsertEventDefinition(request, pluginsApi, relayEvent); + const p2pDefinition = await upsertEventDefinition(request, pluginsApi, p2pEvent); + + expect(relayDefinition.eventDefinition).toEqual(expect.objectContaining({ + direction: 'serverRelay', + eventName: TEST_PLUGIN_RELAY_EVENT, + pluginId: TEST_PLUGIN_ID, + scope: 'server' + })); + + expect(p2pDefinition.eventDefinition).toEqual(expect.objectContaining({ + direction: 'p2pHint', + eventName: TEST_PLUGIN_P2P_EVENT, + pluginId: TEST_PLUGIN_ID, + scope: 'user' + })); + + const snapshot = await expectJson(await request.get(pluginsApi)); + + expect(snapshot.requirements.map((entry) => entry.pluginId)).toEqual([TEST_PLUGIN_ID]); + expect(snapshot.eventDefinitions.map((entry) => entry.eventName).sort()).toEqual([TEST_PLUGIN_P2P_EVENT, TEST_PLUGIN_RELAY_EVENT]); + }); + + await test.step('Plugin data API stores, lists, and deletes server scoped data', async () => { + const stored = await expectJson(await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/data/settings`, { + data: { + actorUserId: OWNER_USER_ID, + schemaVersion: 1, + scope: 'server', + value: { + enabled: true, + pluginVersion: manifest.version + } + } + })); + + expect(stored.record).toEqual(expect.objectContaining({ + key: 'settings', + pluginId: TEST_PLUGIN_ID, + schemaVersion: 1, + scope: 'server', + value: { + enabled: true, + pluginVersion: manifest.version + } + })); + + const listed = await expectJson(await request.get(`${pluginsApi}/${TEST_PLUGIN_ID}/data`, { + params: { + key: 'settings', + scope: 'server', + userId: OWNER_USER_ID + } + })); + + expect(listed.records).toHaveLength(1); + expect(listed.records[0]?.value).toEqual({ + enabled: true, + pluginVersion: manifest.version + }); + + await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/data/settings`, { + data: { + actorUserId: OWNER_USER_ID, + scope: 'server' + } + })); + + const afterDelete = await expectJson(await request.get(`${pluginsApi}/${TEST_PLUGIN_ID}/data`, { + params: { + key: 'settings', + scope: 'server', + userId: OWNER_USER_ID + } + })); + + expect(afterDelete.records).toEqual([]); + }); + + await test.step('WebSocket plugin API sends snapshots, relays server events, and rejects p2p relays', async () => { + const alice = await openTestSocket(testServer.url); + const bob = await openTestSocket(testServer.url); + + try { + alice.send({ type: 'identify', oderId: OWNER_USER_ID, displayName: 'Plugin Owner' }); + bob.send({ type: 'identify', oderId: 'plugin-api-peer', displayName: 'Plugin Peer' }); + alice.send({ type: 'join_server', serverId: server.id }); + bob.send({ type: 'join_server', serverId: server.id }); + + const aliceSnapshot = await waitForSocketMessage(alice, (message) => message.type === 'plugin_requirements'); + const bobSnapshot = await waitForSocketMessage(bob, (message) => message.type === 'plugin_requirements'); + const bobEventNames = (bobSnapshot['snapshot'] as PluginSnapshotResponse).eventDefinitions + .map((entry) => entry.eventName) + .sort(); + + expect((aliceSnapshot['snapshot'] as PluginSnapshotResponse).requirements[0]?.pluginId).toBe(TEST_PLUGIN_ID); + expect(bobEventNames).toEqual([TEST_PLUGIN_P2P_EVENT, TEST_PLUGIN_RELAY_EVENT]); + + alice.send({ + type: 'plugin_event', + eventId: 'relay-event-1', + eventName: TEST_PLUGIN_RELAY_EVENT, + payload: { message: 'hello from fixture plugin' }, + pluginId: TEST_PLUGIN_ID, + serverId: server.id, + sourcePluginUserId: 'fixture-plugin-user' + }); + + const relayedEvent = await waitForSocketMessage(bob, (message) => message.type === 'plugin_event'); + + expect(relayedEvent).toEqual(expect.objectContaining({ + eventId: 'relay-event-1', + eventName: TEST_PLUGIN_RELAY_EVENT, + pluginId: TEST_PLUGIN_ID, + serverId: server.id, + sourcePluginUserId: 'fixture-plugin-user', + sourceUserId: OWNER_USER_ID + })); + + expect(relayedEvent['payload']).toEqual({ message: 'hello from fixture plugin' }); + expect(typeof relayedEvent['emittedAt']).toBe('number'); + + alice.send({ + type: 'plugin_event', + eventId: 'p2p-event-1', + eventName: TEST_PLUGIN_P2P_EVENT, + payload: { hint: true }, + pluginId: TEST_PLUGIN_ID, + serverId: server.id + }); + + const p2pError = await waitForSocketMessage( + alice, + (message) => message.type === 'plugin_error' && message['eventId'] === 'p2p-event-1' + ); + + expect(p2pError['code']).toBe('PLUGIN_EVENT_NOT_RELAYABLE'); + + alice.send({ + type: 'plugin_event', + eventId: 'missing-event-1', + eventName: 'e2e:missing', + payload: {}, + pluginId: TEST_PLUGIN_ID, + serverId: server.id + }); + + const missingError = await waitForSocketMessage( + alice, + (message) => message.type === 'plugin_error' && message['eventId'] === 'missing-event-1' + ); + + expect(missingError['code']).toBe('PLUGIN_EVENT_NOT_REGISTERED'); + } finally { + await Promise.all([alice.close(), bob.close()]); + } + }); + + await test.step('Delete APIs remove event definitions and requirements', async () => { + await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/events/${TEST_PLUGIN_RELAY_EVENT}`, { + data: { actorUserId: OWNER_USER_ID } + })); + + await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/events/${TEST_PLUGIN_P2P_EVENT}`, { + data: { actorUserId: OWNER_USER_ID } + })); + + await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, { + data: { actorUserId: OWNER_USER_ID } + })); + + const snapshot = await expectJson(await request.get(pluginsApi)); + + expect(snapshot.eventDefinitions).toEqual([]); + expect(snapshot.requirements).toEqual([]); + }); + }); +}); + +async function createServer( + request: APIRequestContext, + baseUrl: string, + serverName: string +): Promise { + const response = await request.post(`${baseUrl}/api/servers`, { + data: { + channels: [ + { + id: 'general-text', + name: 'general', + position: 0, + type: 'text' + } + ], + description: 'Server for plugin API E2E coverage', + id: `plugin-api-${Date.now()}`, + isPrivate: false, + name: serverName, + ownerId: OWNER_USER_ID, + ownerPublicKey: 'plugin-api-owner-public-key', + tags: ['plugins'] + } + }); + + return await expectJson(response, 201); +} + +async function upsertEventDefinition( + request: APIRequestContext, + pluginsApi: string, + eventDefinition: ReturnType +): Promise { + return await expectJson(await request.put( + `${pluginsApi}/${TEST_PLUGIN_ID}/events/${encodeURIComponent(eventDefinition.eventName)}`, + { + data: { + actorUserId: OWNER_USER_ID, + direction: eventDefinition.direction, + maxPayloadBytes: eventDefinition.maxPayloadBytes, + schemaJson: '{"type":"object"}', + scope: eventDefinition.scope + } + } + )); +} + +async function expectJson(response: APIResponse, status = 200): Promise { + expect(response.status()).toBe(status); + + return await response.json() as T; +} + +async function openTestSocket(baseUrl: string): Promise { + const socketUrl = baseUrl.replace(/^http/, 'ws'); + const socket = new WebSocket(socketUrl); + const messages: SocketMessage[] = []; + + socket.on('message', (data) => { + messages.push(JSON.parse(data.toString()) as SocketMessage); + }); + + await new Promise((resolve, reject) => { + socket.once('open', () => resolve()); + socket.once('error', reject); + }); + + await waitForSocketMessage({ messages, send: () => {}, close: async () => {} }, (message) => message.type === 'connected'); + + return { + close: async () => { + if (socket.readyState === WebSocket.CLOSED) { + return; + } + + await new Promise((resolve) => { + socket.once('close', () => resolve()); + socket.close(); + }); + }, + messages, + send: (message: SocketMessage) => { + socket.send(JSON.stringify(message)); + } + }; +} + +async function waitForSocketMessage( + socket: Pick, + predicate: (message: SocketMessage) => boolean, + timeoutMs = 10_000 +): Promise { + const startedAt = Date.now(); + + return await new Promise((resolve, reject) => { + const interval = setInterval(() => { + const message = socket.messages.find(predicate); + + if (message) { + clearInterval(interval); + resolve(message); + return; + } + + if (Date.now() - startedAt > timeoutMs) { + clearInterval(interval); + reject(new Error('Timed out waiting for websocket message')); + } + }, 25); + }); +} diff --git a/electron/ipc/system.ts b/electron/ipc/system.ts index 4bad4f5..4f44ea3 100644 --- a/electron/ipc/system.ts +++ b/electron/ipc/system.ts @@ -49,6 +49,7 @@ import { readSavedTheme, writeSavedTheme } from '../theme-library'; +import { getLocalPluginsPath, listLocalPluginManifests } from '../plugin-library'; import { eraseUserData, exportUserData, @@ -349,6 +350,8 @@ export function setupSystemHandlers(): void { ipcMain.handle('import-user-data', async () => await importUserData()); ipcMain.handle('erase-user-data', async () => await eraseUserData()); ipcMain.handle('get-saved-themes-path', async () => await getSavedThemesPath()); + ipcMain.handle('get-local-plugins-path', async () => await getLocalPluginsPath()); + ipcMain.handle('list-local-plugin-manifests', async () => await listLocalPluginManifests()); ipcMain.handle('list-saved-themes', async () => await listSavedThemes()); ipcMain.handle('read-saved-theme', async (_event, fileName: string) => await readSavedTheme(fileName)); ipcMain.handle('write-saved-theme', async (_event, fileName: string, text: string) => { diff --git a/electron/plugin-library.spec.ts b/electron/plugin-library.spec.ts new file mode 100644 index 0000000..d48ffff --- /dev/null +++ b/electron/plugin-library.spec.ts @@ -0,0 +1,126 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; +import { + cp, + mkdtemp, + mkdir, + rm, + writeFile +} from 'fs/promises'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { TEST_PLUGIN_FIXTURE_DIR, TEST_PLUGIN_ID } from '../e2e/helpers/plugin-api-test-fixture'; + +const { mockGetPath } = vi.hoisted(() => ({ + mockGetPath: vi.fn() +})); + +vi.mock('electron', () => ({ + app: { + getPath: mockGetPath + } +})); + +import { getLocalPluginsPath, listLocalPluginManifests } from './plugin-library'; + +describe('plugin-library', () => { + let userDataPath: string; + + beforeEach(async () => { + userDataPath = await mkdtemp(join(tmpdir(), 'metoyou-plugin-library-')); + mockGetPath.mockReturnValue(userDataPath); + }); + + afterEach(async () => { + await rm(userDataPath, { recursive: true, force: true }); + mockGetPath.mockReset(); + }); + + it('creates and reports the local plugins folder', async () => { + const pluginsPath = await getLocalPluginsPath(); + const result = await listLocalPluginManifests(); + + expect(pluginsPath).toBe(join(userDataPath, 'plugins')); + expect(result).toEqual({ + errors: [], + plugins: [], + pluginsPath + }); + }); + + it('discovers immediate child plugin manifests and safe relative files', async () => { + const pluginRoot = join(userDataPath, 'plugins', 'api-test-plugin'); + + await cp(TEST_PLUGIN_FIXTURE_DIR, pluginRoot, { recursive: true }); + + const result = await listLocalPluginManifests(); + + expect(result.errors).toEqual([]); + expect(result.plugins).toHaveLength(1); + expect(result.plugins[0]).toEqual(expect.objectContaining({ + entrypointPath: join(pluginRoot, 'dist', 'main.js'), + manifestPath: join(pluginRoot, 'toju-plugin.json'), + pluginRoot, + readmePath: join(pluginRoot, 'README.md') + })); + + expect(result.plugins[0]?.manifest).toEqual(expect.objectContaining({ id: TEST_PLUGIN_ID })); + }); + + it('reports invalid JSON and keeps scanning other plugins', async () => { + const invalidRoot = join(userDataPath, 'plugins', 'invalid-plugin'); + const validRoot = join(userDataPath, 'plugins', 'valid-plugin'); + + await mkdir(invalidRoot, { recursive: true }); + await mkdir(validRoot, { recursive: true }); + await writeFile(join(invalidRoot, 'plugin.json'), '{', 'utf8'); + await writeFile(join(validRoot, 'plugin.json'), JSON.stringify({ + apiVersion: '1.0.0', + compatibility: { minimumTojuVersion: '1.0.0' }, + description: 'Valid plugin', + entrypoint: './main.js', + id: 'valid.plugin', + kind: 'client', + schemaVersion: 1, + title: 'Valid Plugin', + version: '1.0.0' + }), 'utf8'); + + const result = await listLocalPluginManifests(); + + expect(result.plugins.map((plugin) => plugin.pluginRoot)).toEqual([validRoot]); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toEqual(expect.objectContaining({ + manifestPath: join(invalidRoot, 'plugin.json'), + pluginRoot: invalidRoot + })); + }); + + it('does not resolve entrypoints outside the plugin folder', async () => { + const pluginRoot = join(userDataPath, 'plugins', 'unsafe-plugin'); + + await mkdir(pluginRoot, { recursive: true }); + await writeFile(join(userDataPath, 'plugins', 'outside.js'), 'export default {};', 'utf8'); + await writeFile(join(pluginRoot, 'plugin.json'), JSON.stringify({ + apiVersion: '1.0.0', + compatibility: { minimumTojuVersion: '1.0.0' }, + description: 'Unsafe plugin', + entrypoint: '../outside.js', + id: 'unsafe.plugin', + kind: 'client', + schemaVersion: 1, + title: 'Unsafe Plugin', + version: '1.0.0' + }), 'utf8'); + + const result = await listLocalPluginManifests(); + + expect(result.plugins[0]?.entrypointPath).toBeUndefined(); + }); +}); diff --git a/electron/plugin-library.ts b/electron/plugin-library.ts new file mode 100644 index 0000000..ef43040 --- /dev/null +++ b/electron/plugin-library.ts @@ -0,0 +1,165 @@ +import { app } from 'electron'; +import * as fsp from 'fs/promises'; +import * as path from 'path'; +import { pathToFileURL } from 'url'; + +const PLUGINS_FOLDER_NAME = 'plugins'; +const MANIFEST_FILE_NAMES = ['toju-plugin.json', 'plugin.json'] as const; + +export interface LocalPluginManifestDescriptor { + discoveredAt: number; + entrypointPath?: string; + pluginRootUrl: string; + manifest: unknown; + manifestPath: string; + pluginRoot: string; + readmePath?: string; +} + +export interface LocalPluginDiscoveryError { + manifestPath?: string; + message: string; + pluginRoot?: string; +} + +export interface LocalPluginDiscoveryResult { + errors: LocalPluginDiscoveryError[]; + plugins: LocalPluginManifestDescriptor[]; + pluginsPath: string; +} + +function resolvePluginsPath(): string { + return path.join(app.getPath('userData'), PLUGINS_FOLDER_NAME); +} + +async function ensurePluginsPath(): Promise { + const pluginsPath = resolvePluginsPath(); + + await fsp.mkdir(pluginsPath, { recursive: true }); + + return pluginsPath; +} + +async function realpathOrSelf(filePath: string): Promise { + try { + return await fsp.realpath(filePath); + } catch { + return filePath; + } +} + +function isPathInside(parentPath: string, candidatePath: string): boolean { + const relativePath = path.relative(parentPath, candidatePath); + + return !!relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath); +} + +function readManifestPath(manifestRecord: Record, key: string): string | undefined { + const value = manifestRecord[key]; + + return typeof value === 'string' && value.trim() + ? value.trim() + : undefined; +} + +async function resolveManifestRelativeFile(pluginRoot: string, relativeFilePath: string | undefined): Promise { + if (!relativeFilePath || path.isAbsolute(relativeFilePath)) { + return undefined; + } + + const normalizedPath = path.normalize(relativeFilePath); + + if (normalizedPath.startsWith('..')) { + return undefined; + } + + const candidatePath = path.join(pluginRoot, normalizedPath); + const [realPluginRoot, realCandidatePath] = await Promise.all([realpathOrSelf(pluginRoot), realpathOrSelf(candidatePath)]); + + if (!isPathInside(realPluginRoot, realCandidatePath)) { + return undefined; + } + + try { + const stats = await fsp.stat(realCandidatePath); + + return stats.isFile() ? realCandidatePath : undefined; + } catch { + return undefined; + } +} + +async function findManifestPath(pluginRoot: string): Promise { + for (const fileName of MANIFEST_FILE_NAMES) { + const manifestPath = path.join(pluginRoot, fileName); + + try { + const stats = await fsp.stat(manifestPath); + + if (stats.isFile()) { + return manifestPath; + } + } catch { + // Missing manifest candidates are expected while scanning folders. + } + } + + return undefined; +} + +async function readPluginManifest(pluginRoot: string, manifestPath: string): Promise { + const text = await fsp.readFile(manifestPath, 'utf8'); + const manifest = JSON.parse(text) as unknown; + const manifestRecord = manifest && typeof manifest === 'object' && !Array.isArray(manifest) + ? manifest as Record + : {}; + const entrypointPromise = resolveManifestRelativeFile(pluginRoot, readManifestPath(manifestRecord, 'entrypoint')); + const readmePromise = resolveManifestRelativeFile(pluginRoot, readManifestPath(manifestRecord, 'readme')); + const [entrypointPath, readmePath] = await Promise.all([entrypointPromise, readmePromise]); + + return { + discoveredAt: Date.now(), + entrypointPath, + pluginRootUrl: pathToFileURL(pluginRoot + path.sep).toString(), + manifest, + manifestPath, + pluginRoot, + readmePath + }; +} + +export async function getLocalPluginsPath(): Promise { + return await ensurePluginsPath(); +} + +export async function listLocalPluginManifests(): Promise { + const pluginsPath = await ensurePluginsPath(); + const entries = await fsp.readdir(pluginsPath, { withFileTypes: true }); + const plugins: LocalPluginManifestDescriptor[] = []; + const errors: LocalPluginDiscoveryError[] = []; + + for (const entry of entries.filter((candidate) => candidate.isDirectory())) { + const pluginRoot = path.join(pluginsPath, entry.name); + const manifestPath = await findManifestPath(pluginRoot); + + if (!manifestPath) { + continue; + } + + try { + plugins.push(await readPluginManifest(pluginRoot, manifestPath)); + } catch (error) { + errors.push({ + manifestPath, + message: error instanceof Error ? error.message : 'Unable to read plugin manifest', + pluginRoot + }); + } + } + + return { + errors, + plugins: plugins.sort((left, right) => left.pluginRoot.localeCompare(right.pluginRoot)), + pluginsPath + }; +} diff --git a/electron/preload.ts b/electron/preload.ts index 22a8ce0..3b96d27 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -109,6 +109,28 @@ export interface SavedThemeFileDescriptor { path: string; } +export interface LocalPluginManifestDescriptor { + discoveredAt: number; + entrypointPath?: string; + pluginRootUrl: string; + manifest: unknown; + manifestPath: string; + pluginRoot: string; + readmePath?: string; +} + +export interface LocalPluginDiscoveryError { + manifestPath?: string; + message: string; + pluginRoot?: string; +} + +export interface LocalPluginDiscoveryResult { + errors: LocalPluginDiscoveryError[]; + plugins: LocalPluginManifestDescriptor[]; + pluginsPath: string; +} + export interface ExportUserDataResult { cancelled: boolean; exported: boolean; @@ -181,6 +203,8 @@ export interface ElectronAPI { importUserData: () => Promise; eraseUserData: () => Promise; getSavedThemesPath: () => Promise; + getLocalPluginsPath: () => Promise; + listLocalPluginManifests: () => Promise; listSavedThemes: () => Promise; readSavedTheme: (fileName: string) => Promise; writeSavedTheme: (fileName: string, text: string) => Promise; @@ -294,6 +318,8 @@ const electronAPI: ElectronAPI = { importUserData: () => ipcRenderer.invoke('import-user-data'), eraseUserData: () => ipcRenderer.invoke('erase-user-data'), getSavedThemesPath: () => ipcRenderer.invoke('get-saved-themes-path'), + getLocalPluginsPath: () => ipcRenderer.invoke('get-local-plugins-path'), + listLocalPluginManifests: () => ipcRenderer.invoke('list-local-plugin-manifests'), listSavedThemes: () => ipcRenderer.invoke('list-saved-themes'), readSavedTheme: (fileName) => ipcRenderer.invoke('read-saved-theme', fileName), writeSavedTheme: (fileName, text) => ipcRenderer.invoke('write-saved-theme', fileName, text), diff --git a/server/README.md b/server/README.md index ac50250..3776fe0 100644 --- a/server/README.md +++ b/server/README.md @@ -20,6 +20,7 @@ Node/TypeScript signaling server for MetoYou / Toju. This package owns the publi - `SSL` can override the effective HTTP protocol, and `PORT` can override the effective port. - `DB_PATH` can override the SQLite database file location. - `data/variables.json` is normalized on startup and stores `klipyApiKey`, `rawgApiKey`, `releaseManifestUrl`, `serverPort`, `serverProtocol`, `serverHost`, and `linkPreview`. +- `openApiDocs.enabled` in `data/variables.json`, or `OPENAPI_DOCS_ENABLED=true`, exposes the plugin support OpenAPI document at `/api/openapi.json` and a small docs page at `/api/docs`. It is disabled by default. - `RAWG_API_KEY` can override `rawgApiKey` for the `/api/games/match` now-playing metadata resolver. Successful matches include a preferred store link from RAWG store metadata, with Steam selected first when available. Negative game-match results are stored in the SQLite `game_match_misses` table so non-game process names do not repeatedly consume RAWG quota. - Packaged server builds store `metoyou.sqlite` in the OS app-data directory by default so upgrades do not overwrite runtime data. On first start, the server copies forward legacy packaged databases that still live beside the executable. - When HTTPS is enabled, certificates are read from the repository `.certs/` directory. diff --git a/server/data/metoyou.sqlite b/server/data/metoyou.sqlite index a9ce375095c6b3dcdf90de3fe6d8dfe8780a4324..03cbbf68368637e90dc9ed136afb4486acae6719 100644 GIT binary patch delta 15502 zcmcgz3w%`7nV&oFxpN;$APixEnFK-@lS$_N2oDK@Bs>BHQgy|RcV_M+Q^ayFJ_St&1Lervn`#*kA zrdzXVushbbZg60rKb~|tazFk#I$$luEbTI`mtVyt!#%5Fi3FdJ^L(R!o2f6a)FYKz zTCHGjU_N8E(Vx>hXf>#NpAxRu>N=}N=qimX5($L4sLLLWMmf8Oi@5AuIKtcA5zZNk z`XhWK%5#Yb{PRX5L6^tp@ZgC&c;u-e|z% zj(9nzgLi~DC@~ZeOCjz$Ui(3xVVg$RSvf*iYEo;C%^rPd!Fr`u%NiA|aa6yEVD}zZ z*OpQ-ZEDYZb5k2u|A^AEPb%2Iv%g~h#6CI7tU1gYNmUggs??@_)zz%3R9RBGt1Xna zQFD{}BlTsfQI!R3@t?}zNO@&NXRXPsG?>jM{9`to-F!3{jJTZka3JWldt837i`U_{ z`}k-GTIck-Ji*kfJy-cBS9IDfSQe!5ztUtk$9nyfDmv>eWS282^R6oPCzPjWSZ#QZ1v*K&w&ksyl0mQY?_zmPqowb}rHz>#Gc^ zJMI67RBwNTk8{cX_~d}PvtA}Ce0f)A6!Kg6__qE&eiE)}3GH^tYg&P8`sGrW=ej8) zmaS?zuIiVIPLPypT83(xI`K+vYGIFV^o{GLyBRc35$q@MRM^>1Qp`gQluDr-Rw{^L zLZKK=RhKod8LHNHc6Mqso{-lY33z;Vjt}}^7zCX5Kp+^hM}00AALjjjo{N-Ht91FT z%Gm)J{3k|MWT+!1@o?ey3kez%+BqJG{N4y0aqW%h=Oi=;v=c3+V)M0mod6+7~ zZl^uub%yL-Uzl_Gd{I8ag;U&_*&9k$@E#}_zw5oB8KQ!)+vVqi99Z4ub=y6VvWFng z4)d_j8}@|4T-cZ9EW|&c;a%K>(?iom4Spzwcf-u#b%Z^3kJl5m2fPu^?v1#eA&)=g z^Z7lgmZ8~>lAG{4R&aK7c=S3_4ZVKN-hclT{Z8(E@{_gTc@5PhJZs3^3VI)VlD4oO zYMk9^B|tgP-O64?eMBB*y2;(lG2TKv|4*bL(*B$E9B!XI;BW-NE5Ps`563yZ zo*G0%Lq)jISgA(LQe57Oh^4r$1ra3|6u!y_XCh`PzBB_7OL2+mA~6(pc^w|R*Y6rF zJN{QyE9zBpH@lO)is_~;bSvs9BT2pnz4tnEjM>AUgdSZ;@1yRcI#3_ePf_EjAB}F- zgLQkWy3gn)kEX0=r%}e2z{hVe zZUC$8&e%aClrTYIGn6t_Vl$L7oUs|49Ow3>r`)Qv2-t;7(STjJ6b;ygOCcf;UQLw3 z6kWIkdrg1joLa3?lqp7a?|;T3@Dm*&f-zlL7FmhU`c+dE52ST_2#4wc#YR{q=cKRR zLm00=}7=woAz`-xL}(y&%RwwMV;tS=2kUCbDNuh_L<7BOOq^+outY3p8MYFfLO;Iu9E zT2p6J`n3m%srB8k`S0pQyZ^3Uy#4QTPROdZ+q9+@GZL7Yx{D@_7mYaUbfzm?h>>;a zs~#jw>Bf5qD&4i8cqP5=LE=U=YzvI(GY=BxB|GNZb*7dUVy7y}g*NdCJ`Nk7t^s^5 z>5B842V-%*m+wo$!Q;!`G^VPF$&-r6q>t|M&_dnQ3#%syBXf%gE40 zI#W{4YF*#5c;!-S&4l+g*14oL^HVbr?+^2d#0q{}O=>MM zN1sWzr`bcq31nR znbk}={UV*Bub|D;8R{-73?p>`6%DZ3&ZS(37Dz7O`yn=-$Ubijxj*<~`6 zGL;szk`!B6f@He?d19(y5F1$n-!-9;da;qE$WCnJBAvE#mT9c>IpQ!enWd`~^g;T1 zx{7XN;>=?DG{cPPPY?txG^snQWCw{aKSzj$VBPA@>QeccS}|XtL&!guiZ-Af>P~Y> zN-`QKtf3S`iLEf=Rv^A`p#>5IGD^sjm58k{;8t9qQsww)o6(^6s5@t+7o8#Ijy->x z@S=X(r+`fbY%}P6X#2r*GYiM`XNm1J+A)?g)L697DTT0=u8x#WpvI`{sMXYD<6E%H*^8&=orVU3N`FfKu>Lyz zYW)=5yShhoyL8{sHR;sam$iqqJG7T;?OLVgCCxs~W=*Tcq&}zqnY!){^+xp~b*1XB zs^6;aRc%(ySDsfMRi>0ZO0SY6{y-cdzC-j7EwF&6h1#{6>S~Q9ZrzydiT5WrMLM#G zxa-9)-V73V4-6d&TGnXHW=u;Y;{(7!zYyV%cOt^UJL^|#>gq5d)|ceF;$(rD-*KF>v#&QYFwqmxmc2%Z|@V+<9C6btl5u)wig=jsy zY%4XjwWtO_3-|X9NJVBaqW;@yMD0H9T%obsF%|UV_k(?*JieGlGIzg?WTJ1|mS>7j zgcC7qvNvk=by#y4Qx%x1nAN7SSWxj?lCvhF(GCd!Y27Uxad-c3)^pxgjnk&_wLco&)Xzn5J2E8{ zdx;CX92a)ETewuy)Pze54?wGW_;@e3$vV&rb;!>^Qa2q#QXR*dmTKzjk5Pl00>ff@p6!w=_)?Z#ItL>DT5bo}O_Fl7XQ6bkO<9ZPH<@=%7XYaY+ zc5xxqEu*?IwE2ySFEcM70bN!eMCL{eVaR<>XelV7*XGR z0Z|XVU~`F7L8_q>Svpf&z?eG&b7$C``NcY9#R5juKh8tcgY#^TLTav5=u4m~&)f*q zkssbz+k|FSH2;L7-2sQoi42=Th%lD5hjgi6xcwHoQzZok%p( z6qLwQ^UX>`c2&CP;+fRZ?DSfDd#wX}90ml;#S=1!yT*-obP#uU-}KKNbwV8;Mvi8; zKhs(nM9hJ4#0-qr&5?+{98rRZ*#9Xa`ai9!%@Cb|EHMi)F^!4oy4gS!G#Xq%(fBfm znP3MfQfZ#0F_|EH!#1Hhc^<(t1>|=9{_|nx3E`%QVCHMloyuwzEyU8!>CcrZL z3FF3P#}Su3?ziH8@=xq1$eP$q&v zILIri#Ed1)wi#H7SEdATo|1R&Mbt;`^-sr2yjdk#9gtO^Lx_R7^uK^TLHDj(IGY(O z@yL__&QtQ#c|^VWynh;2;+d!fvI>-79Z%I`!FpR2*5MX)2zY!#B2UTB)+6qB*88Vo zCGLqzAZwzMyGB4;S=`faV&MSxnJ0$kr=wJ6}IZR3z^g z7zg?7dFDz?zA{)LDk{=5AXBIZ_5bnlr$0clw|-DxE-K1&4(=Oge&+yrD<*HXm0=q@ zWi|#ZxAD{0p`o>8$#Fl6O^oNU{ApIe(^-BV)adE!+OT9>kP%x`#08*Yg{?u2dE)$s zAUc2S!+Kh5jNA!eg2=1k6T=`7x=ZsB8JP|dULnA+~w;;t>CC8s1i|A3B1k6sIc?%`|U z{A07h`rpSVuj!9(?B)7k8y9aEQ>(-j;*Z4;Ux?Y%xK8MMY;T6V3Sk-b2pX8~p%b+# zd|>P4;PlrMhwr1S0IkOeN<0+58ff#Uz$M z3z5B9X(v#fHQc~Je6X+anm7PH(lRj6kxL=^=3Yb(_M-1=Ye`1I?xus(UuYXU1Q%BZ zmS@+o&D3k`T$W{o&**i`8_WT?6B&X#Egp{P$Kkj>kGY7cU;X@e$Y{sQihSQrzfun{eF9Qw~?t%=5D=!1i2F`*EheLD` zX`|nSTAAD03wUqeh=kS-2o>)IsDEmG48%$4s@%2AEvi*FgKQIX9Ci_0w1 zCI*-3lIy22w@9-n%$2J%Vrmh6B&Nz&Q3IwHSuFLa4Gy{eb*K?VnA!@J4Tg+-(bOPe z`NF71qb#@lsn94ZSml)EDw`pHAtM!26^csgK85%p9K{H*JK-~VD{UEL%9J-zrC!!o zqD$*iF6$?kN}MEJgJ1AFM}@?^&EF{Q=%?w&V8?YI{XP0N_*k-)?xjQYoXhBybPMgH>uD=p zNt4tU)L*GTQKzZjQqNLPP>)bQr0%3{r$);cy=oLlm4-kU@rQzIC3x`8H4^;sxz$-{ zZI@uru2mB3KD|jRQ+1XiqGWV8c6? zNU;8`#Tf_}(Jd0Z=l(@m*l@7~_wQaP!J)+qBzXO_`4ZfAb{;9<=EG%fPje<4ZefEN z2v@2B3BG*DpM^%B1W)#RC3yJz9tj?L!JUOOToODu&zXU6q3Fm!0bLqO+4o=3D8cOk zy99@yZjhkxt@tBl5DO7;TBAS{!iy*VY)U8m9t5efO;R@OwE$A%dV)A zAbY$z1L5MxnuXgfS!kXq!AI_$A;DX?=@Pv8yg3W&r%CWT>#H(wrEu!xR0%)xU6TZ# zIx$6pk2X|F@V4`lv+&AE61??;iY(k(o`GI{SvoGJ^IN`p#cz{f6KFABQ1 zC{o&oJpbQewJ-kHs)b)~SnVeVpZZ$EYG3@~^QB*FSS={H`0EX;1r4oVYgjEPxI_%A zh5lZG!fIdqhPG|*lb=>Vx&?WkiWye0!>ocChV4L{5r$@qQNE=F$Ck2&UT_0|1Epd4 zpAO6a7oQ8Ohh~WtC?)8&l)XVI(TA{I_~hWV=qVih=}?VWfl`8bOQ}F<7C3(5d!K^^ zR9}3)@{wV}QLp>Ef>L~}Ajb`i{+M2&Y1MsCRi!B>E>Rw*e+MseKOaMM!gJbXey!DFQ4Qha6!@KES4MGq4nrY!60!%U6J5D_{yG)^>r5n?n${m6tu@k zL3?6u`qz7jX{jB=bc3j^EmD&jA!bsN%<@Ek-(GsUA>70Fa^HYYn`!6$L}mKP2MEIy zXG1RbZg2qV%tsbO=-qzJWHOUP(RQ5Pl3to3%qARP2<6){QnnRdt*I-BEMSo=AgS$g z=EojN`TiY)E}d!CEMk`vH$P({WG@-?OK3jqY>IYW8f zT2tGsJXgu{5R~wKyWOEPtyn?aIH`zZ;EUg`2p^60#gg!Ky1+vwymld1nJT$T;cGF- z^I2l1%xMI|YQVg92wqK9)0Nn65eOv>BZWApjHcAc(Acby&|*6-PyMT=s;rmWx`x|^ zf;<*)OY(`D)Ss2p%is@r_?5BVSQ44O#%fJ1(pRU_`m)qJq*3l;d-NvABC(&%mRUAi zp`0zRa%xSRS74_TGLBK$yhNe*O(3|98x?vqphtX7I@4tka|^e?5YHQnZ0*W-TzNQc zS0c$J2Xm&cKz|6B7jWxH-J`0yEK{u_qBWVJv$`F;=1YTfAt<&+RF!G=x<;+3yCtv9 zd4(4YHu<}z|BGN@*>FrX5Qcv=ZrCV=RB(OWsht{2>OM^wg`z!T-Ggy7=cF#yTT&{c zS?&^FkTbD62$DNwr=1aV0}PZVVx%qGXA|OzyZEgG;JS&HeLeqlX{diliU`V zBIB)Gq9=8?W=bZxDh4wZBpb!Io#GM(YnHXGef`p9OFLxiTfBc0-xf(oOF-%~olb>T oH*x0q(#fb=@;;c-rHbJxKI@J54qJ~55QKG<82-X?{{UhAZ&X(unE(I) delta 1197 zcmZvbZ%iCj5WwHO-DU63dv6xolR_2l>=6z`iU?_}fzZU5OFsxHT~lL`(shSL%O)SO_&HDzW$l8VkmEM=^YGlbOuU z?EK!$+nL09k@(n`EVzg1gpe{RAt_W!FjZc4uk==Qmum7A@Q&1EBi`ZPe$a6e3xiKK z>-to&Rku%femQa9 zBWDkT>dRLW1QSR^!V<&flJSJ%B%BjIl}1C;Z+*0+ZvXwj@YzwiR40v#KG{b;BZT+i z1TVsBmd0*=1^pr*W*OmgY=FmcNF;d_o7sYB7bEOA+rdgjog{zm&$iRAex=rAAlQw~ zqMaA=;zXvVlq`hz@wlvH zLc*%Wh)jyg57TS_hp<^D&dM6#$<)rn2TCJL!?D}blxZ^jVCKI2P@fkRoiSL5! z6xhx!2;06}@V6j_oP~YjM^Vq8XVVg#X4vJ?n7NLarUyKlMy#)=!nWw8Ph*qH%@Tgs z-^H!J_J{3rz0~AY@D7cRe<}5Lk#EoU(t7M#uQ0PD_dm$CSHgDuG>r*CnVA<@_1mpQ zvJ~$b+9CqL%%Y8}0jqUd!q3o{9M=HQic&{9NJA-%r=?6&jl)*e<>owY-f@%9gJ(ck)eIrsQPSA17Rqq~HC|83X?U D%Pm!; diff --git a/server/src/config/variables.ts b/server/src/config/variables.ts index ff95193..401ebb5 100644 --- a/server/src/config/variables.ts +++ b/server/src/config/variables.ts @@ -10,6 +10,10 @@ export interface LinkPreviewConfig { maxCacheSizeMb: number; } +export interface OpenApiDocsConfig { + enabled: boolean; +} + export interface ServerVariablesConfig { klipyApiKey: string; rawgApiKey: string; @@ -18,6 +22,7 @@ export interface ServerVariablesConfig { serverProtocol: ServerHttpProtocol; serverHost: string; linkPreview: LinkPreviewConfig; + openApiDocs: OpenApiDocsConfig; } const DATA_DIR = resolveRuntimePath('data'); @@ -102,6 +107,14 @@ function normalizeLinkPreviewConfig(value: unknown): LinkPreviewConfig { return { enabled, cacheTtlMinutes: cacheTtl, maxCacheSizeMb: maxSize }; } +function normalizeOpenApiDocsConfig(value: unknown): OpenApiDocsConfig { + const raw = (value && typeof value === 'object' && !Array.isArray(value)) + ? value as Record + : {}; + + return { enabled: raw.enabled === true }; +} + function hasEnvironmentOverride(value: string | undefined): value is string { return typeof value === 'string' && value.trim().length > 0; } @@ -149,7 +162,8 @@ export function ensureVariablesConfig(): ServerVariablesConfig { serverPort: normalizeServerPort(remainingParsed.serverPort), serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol), serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress), - linkPreview: normalizeLinkPreviewConfig(remainingParsed.linkPreview) + linkPreview: normalizeLinkPreviewConfig(remainingParsed.linkPreview), + openApiDocs: normalizeOpenApiDocsConfig(remainingParsed.openApiDocs) }; const nextContents = JSON.stringify(normalized, null, 2) + '\n'; @@ -164,7 +178,8 @@ export function ensureVariablesConfig(): ServerVariablesConfig { serverPort: normalized.serverPort, serverProtocol: normalized.serverProtocol, serverHost: normalized.serverHost, - linkPreview: normalized.linkPreview + linkPreview: normalized.linkPreview, + openApiDocs: normalized.openApiDocs }; } @@ -218,6 +233,31 @@ export function isHttpsServerEnabled(): boolean { return getServerProtocol() === 'https'; } +export function areOpenApiDocsEnabled(): boolean { + if (hasEnvironmentOverride(process.env.OPENAPI_DOCS_ENABLED)) { + return process.env.OPENAPI_DOCS_ENABLED.trim().toLowerCase() === 'true'; + } + + return getVariablesConfig().openApiDocs.enabled; +} + +export function setOpenApiDocsEnabled(enabled: boolean): OpenApiDocsConfig { + if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR, { recursive: true }); + } + + const { parsed } = readRawVariables(); + const next = { + ...parsed, + openApiDocs: { enabled } + }; + + fs.writeFileSync(VARIABLES_FILE, JSON.stringify(next, null, 2) + '\n', 'utf8'); + ensureVariablesConfig(); + + return { enabled }; +} + export function getLinkPreviewConfig(): LinkPreviewConfig { return getVariablesConfig().linkPreview; } diff --git a/server/src/db/database.ts b/server/src/db/database.ts index 1babc42..526f210 100644 --- a/server/src/db/database.ts +++ b/server/src/db/database.ts @@ -15,7 +15,12 @@ import { ServerMembershipEntity, ServerInviteEntity, ServerBanEntity, - GameMatchMissEntity + GameMatchMissEntity, + ServerPluginRequirementEntity, + ServerPluginEventDefinitionEntity, + PluginDataEntity, + ServerPluginSettingsEntity, + PluginUserMetadataEntity } from '../entities'; import { serverMigrations } from '../migrations'; import { @@ -49,8 +54,18 @@ const DB_BACKUP = DB_FILE + '.bak'; const DATA_DIR = path.dirname(DB_FILE); // SQLite files start with this 16-byte header string. const SQLITE_MAGIC = 'SQLite format 3\0'; -const SAVE_RETRY_DELAYS_MS = [25, 75, 150, 300, 600]; -const RETRYABLE_SAVE_ERROR_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']); +const SAVE_RETRY_DELAYS_MS = [ + 25, + 75, + 150, + 300, + 600 +]; +const RETRYABLE_SAVE_ERROR_CODES = new Set([ + 'EPERM', + 'EACCES', + 'EBUSY' +]); let applicationDataSource: DataSource | undefined; let saveQueue: Promise = Promise.resolve(); @@ -250,7 +265,12 @@ export async function initDatabase(): Promise { ServerMembershipEntity, ServerInviteEntity, ServerBanEntity, - GameMatchMissEntity + GameMatchMissEntity, + ServerPluginRequirementEntity, + ServerPluginEventDefinitionEntity, + PluginDataEntity, + ServerPluginSettingsEntity, + PluginUserMetadataEntity ], migrations: serverMigrations, synchronize: process.env.DB_SYNCHRONIZE === 'true', diff --git a/server/src/entities/PluginDataEntity.ts b/server/src/entities/PluginDataEntity.ts new file mode 100644 index 0000000..952fe9b --- /dev/null +++ b/server/src/entities/PluginDataEntity.ts @@ -0,0 +1,35 @@ +import { + Column, + Entity, + PrimaryColumn +} from 'typeorm'; + +@Entity('plugin_data') +export class PluginDataEntity { + @PrimaryColumn('text') + serverId!: string; + + @PrimaryColumn('text') + pluginId!: string; + + @PrimaryColumn('text') + scope!: string; + + @PrimaryColumn('text') + ownerId!: string; + + @PrimaryColumn('text') + key!: string; + + @Column('text') + valueJson!: string; + + @Column('integer', { default: 1 }) + schemaVersion!: number; + + @Column('text', { nullable: true }) + updatedBy!: string | null; + + @Column('integer') + updatedAt!: number; +} diff --git a/server/src/entities/PluginUserMetadataEntity.ts b/server/src/entities/PluginUserMetadataEntity.ts new file mode 100644 index 0000000..751a1ba --- /dev/null +++ b/server/src/entities/PluginUserMetadataEntity.ts @@ -0,0 +1,38 @@ +import { + Column, + Entity, + PrimaryColumn +} from 'typeorm'; + +@Entity('plugin_user_metadata') +export class PluginUserMetadataEntity { + @PrimaryColumn('text') + serverId!: string; + + @PrimaryColumn('text') + pluginId!: string; + + @PrimaryColumn('text') + pluginUserId!: string; + + @Column('text') + displayName!: string; + + @Column('text', { nullable: true }) + avatarHash!: string | null; + + @Column('text', { nullable: true }) + avatarMime!: string | null; + + @Column('integer', { nullable: true }) + avatarUpdatedAt!: number | null; + + @Column('text') + roleIdsJson!: string; + + @Column('integer') + createdAt!: number; + + @Column('integer') + updatedAt!: number; +} diff --git a/server/src/entities/ServerPluginEventDefinitionEntity.ts b/server/src/entities/ServerPluginEventDefinitionEntity.ts new file mode 100644 index 0000000..9b56506 --- /dev/null +++ b/server/src/entities/ServerPluginEventDefinitionEntity.ts @@ -0,0 +1,41 @@ +import { + Column, + Entity, + PrimaryColumn +} from 'typeorm'; + +export type ServerPluginEventDirection = 'clientToServer' | 'serverRelay' | 'p2pHint'; +export type ServerPluginEventScope = 'server' | 'channel' | 'user' | 'plugin'; + +@Entity('server_plugin_event_definitions') +export class ServerPluginEventDefinitionEntity { + @PrimaryColumn('text') + serverId!: string; + + @PrimaryColumn('text') + pluginId!: string; + + @PrimaryColumn('text') + eventName!: string; + + @Column('text') + direction!: ServerPluginEventDirection; + + @Column('text') + scope!: ServerPluginEventScope; + + @Column('text', { nullable: true }) + schemaJson!: string | null; + + @Column('integer') + maxPayloadBytes!: number; + + @Column('text', { nullable: true }) + rateLimitJson!: string | null; + + @Column('integer') + createdAt!: number; + + @Column('integer') + updatedAt!: number; +} diff --git a/server/src/entities/ServerPluginRequirementEntity.ts b/server/src/entities/ServerPluginRequirementEntity.ts new file mode 100644 index 0000000..de8bfb8 --- /dev/null +++ b/server/src/entities/ServerPluginRequirementEntity.ts @@ -0,0 +1,36 @@ +import { + Column, + Entity, + Index, + PrimaryColumn +} from 'typeorm'; + +export type ServerPluginRequirementStatus = 'required' | 'optional' | 'recommended' | 'blocked' | 'incompatible'; + +@Entity('server_plugin_requirements') +export class ServerPluginRequirementEntity { + @PrimaryColumn('text') + serverId!: string; + + @PrimaryColumn('text') + pluginId!: string; + + @Index() + @Column('text') + status!: ServerPluginRequirementStatus; + + @Column('text', { nullable: true }) + versionRange!: string | null; + + @Column('text', { nullable: true }) + reason!: string | null; + + @Column('text', { nullable: true }) + configuredBy!: string | null; + + @Column('integer') + createdAt!: number; + + @Column('integer') + updatedAt!: number; +} diff --git a/server/src/entities/ServerPluginSettingsEntity.ts b/server/src/entities/ServerPluginSettingsEntity.ts new file mode 100644 index 0000000..4ebc6f9 --- /dev/null +++ b/server/src/entities/ServerPluginSettingsEntity.ts @@ -0,0 +1,26 @@ +import { + Column, + Entity, + PrimaryColumn +} from 'typeorm'; + +@Entity('server_plugin_settings') +export class ServerPluginSettingsEntity { + @PrimaryColumn('text') + serverId!: string; + + @PrimaryColumn('text') + pluginId!: string; + + @Column('text') + settingsJson!: string; + + @Column('integer', { default: 1 }) + schemaVersion!: number; + + @Column('text', { nullable: true }) + updatedBy!: string | null; + + @Column('integer') + updatedAt!: number; +} diff --git a/server/src/entities/index.ts b/server/src/entities/index.ts index b1d2a82..b857605 100644 --- a/server/src/entities/index.ts +++ b/server/src/entities/index.ts @@ -10,3 +10,10 @@ export { ServerMembershipEntity } from './ServerMembershipEntity'; export { ServerInviteEntity } from './ServerInviteEntity'; export { ServerBanEntity } from './ServerBanEntity'; export { GameMatchMissEntity } from './GameMatchMissEntity'; +export { ServerPluginRequirementEntity } from './ServerPluginRequirementEntity'; +export type { ServerPluginRequirementStatus } from './ServerPluginRequirementEntity'; +export { ServerPluginEventDefinitionEntity } from './ServerPluginEventDefinitionEntity'; +export type { ServerPluginEventDirection, ServerPluginEventScope } from './ServerPluginEventDefinitionEntity'; +export { PluginDataEntity } from './PluginDataEntity'; +export { ServerPluginSettingsEntity } from './ServerPluginSettingsEntity'; +export { PluginUserMetadataEntity } from './PluginUserMetadataEntity'; diff --git a/server/src/migrations/1000000000007-PluginSupport.ts b/server/src/migrations/1000000000007-PluginSupport.ts new file mode 100644 index 0000000..747e45a --- /dev/null +++ b/server/src/migrations/1000000000007-PluginSupport.ts @@ -0,0 +1,92 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class PluginSupport1000000000007 implements MigrationInterface { + name = 'PluginSupport1000000000007'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "server_plugin_requirements" ( + "serverId" TEXT NOT NULL, + "pluginId" TEXT NOT NULL, + "status" TEXT NOT NULL, + "versionRange" TEXT, + "reason" TEXT, + "configuredBy" TEXT, + "createdAt" INTEGER NOT NULL, + "updatedAt" INTEGER NOT NULL, + PRIMARY KEY ("serverId", "pluginId") + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "idx_server_plugin_requirements_status" + ON "server_plugin_requirements" ("status") + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "server_plugin_event_definitions" ( + "serverId" TEXT NOT NULL, + "pluginId" TEXT NOT NULL, + "eventName" TEXT NOT NULL, + "direction" TEXT NOT NULL, + "scope" TEXT NOT NULL, + "schemaJson" TEXT, + "maxPayloadBytes" INTEGER NOT NULL, + "rateLimitJson" TEXT, + "createdAt" INTEGER NOT NULL, + "updatedAt" INTEGER NOT NULL, + PRIMARY KEY ("serverId", "pluginId", "eventName") + ) + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "plugin_data" ( + "serverId" TEXT NOT NULL, + "pluginId" TEXT NOT NULL, + "scope" TEXT NOT NULL, + "ownerId" TEXT NOT NULL, + "key" TEXT NOT NULL, + "valueJson" TEXT NOT NULL, + "schemaVersion" INTEGER NOT NULL DEFAULT 1, + "updatedBy" TEXT, + "updatedAt" INTEGER NOT NULL, + PRIMARY KEY ("serverId", "pluginId", "scope", "ownerId", "key") + ) + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "server_plugin_settings" ( + "serverId" TEXT NOT NULL, + "pluginId" TEXT NOT NULL, + "settingsJson" TEXT NOT NULL, + "schemaVersion" INTEGER NOT NULL DEFAULT 1, + "updatedBy" TEXT, + "updatedAt" INTEGER NOT NULL, + PRIMARY KEY ("serverId", "pluginId") + ) + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "plugin_user_metadata" ( + "serverId" TEXT NOT NULL, + "pluginId" TEXT NOT NULL, + "pluginUserId" TEXT NOT NULL, + "displayName" TEXT NOT NULL, + "avatarHash" TEXT, + "avatarMime" TEXT, + "avatarUpdatedAt" INTEGER, + "roleIdsJson" TEXT NOT NULL, + "createdAt" INTEGER NOT NULL, + "updatedAt" INTEGER NOT NULL, + PRIMARY KEY ("serverId", "pluginId", "pluginUserId") + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "plugin_user_metadata"`); + await queryRunner.query(`DROP TABLE IF EXISTS "server_plugin_settings"`); + await queryRunner.query(`DROP TABLE IF EXISTS "plugin_data"`); + await queryRunner.query(`DROP TABLE IF EXISTS "server_plugin_event_definitions"`); + await queryRunner.query(`DROP TABLE IF EXISTS "server_plugin_requirements"`); + } +} diff --git a/server/src/migrations/index.ts b/server/src/migrations/index.ts index 564089b..617d28b 100644 --- a/server/src/migrations/index.ts +++ b/server/src/migrations/index.ts @@ -5,6 +5,7 @@ import { RepairLegacyVoiceChannels1000000000003 } from './1000000000003-RepairLe import { NormalizeServerArrays1000000000004 } from './1000000000004-NormalizeServerArrays'; import { ServerRoleAccessControl1000000000005 } from './1000000000005-ServerRoleAccessControl'; import { GameMatchMisses1000000000006 } from './1000000000006-GameMatchMisses'; +import { PluginSupport1000000000007 } from './1000000000007-PluginSupport'; export const serverMigrations = [ InitialSchema1000000000000, @@ -13,5 +14,6 @@ export const serverMigrations = [ RepairLegacyVoiceChannels1000000000003, NormalizeServerArrays1000000000004, ServerRoleAccessControl1000000000005, - GameMatchMisses1000000000006 + GameMatchMisses1000000000006, + PluginSupport1000000000007 ]; diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index 874efc1..8c6ba7c 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -6,6 +6,8 @@ import gamesRouter from './games'; import proxyRouter from './proxy'; import usersRouter from './users'; import serversRouter from './servers'; +import pluginSupportRouter from './plugin-support'; +import openApiDocsRouter from './openapi-docs'; import joinRequestsRouter from './join-requests'; import { invitesApiRouter, invitePageRouter } from './invites'; @@ -16,6 +18,8 @@ export function registerRoutes(app: Express): void { app.use('/api/games', gamesRouter); app.use('/api', proxyRouter); app.use('/api/users', usersRouter); + app.use('/api', openApiDocsRouter); + app.use('/api/servers', pluginSupportRouter); app.use('/api/servers', serversRouter); app.use('/api/invites', invitesApiRouter); app.use('/api/requests', joinRequestsRouter); diff --git a/server/src/routes/openapi-docs.ts b/server/src/routes/openapi-docs.ts new file mode 100644 index 0000000..68ed4be --- /dev/null +++ b/server/src/routes/openapi-docs.ts @@ -0,0 +1,106 @@ +import { Router } from 'express'; +import { areOpenApiDocsEnabled, setOpenApiDocsEnabled } from '../config/variables'; + +const router = Router(); + +function createOpenApiDocument(baseUrl: string) { + return { + openapi: '3.1.0', + info: { + title: 'MetoYou Plugin Support API', + version: '1.0.0', + description: 'Official HTTP endpoints for plugin metadata, event definitions, and plugin data. ' + + 'Plugin code is never executed by the signal server.' + }, + servers: [{ url: `${baseUrl}/api` }], + paths: { + '/servers/{serverId}/plugins': { + get: { + summary: 'Read plugin requirement snapshot', + parameters: [{ name: 'serverId', in: 'path', required: true, schema: { type: 'string' } }], + responses: { '200': { description: 'Plugin requirements and event definitions' } } + } + }, + '/servers/{serverId}/plugins/{pluginId}/requirement': { + put: { + summary: 'Create or update a server plugin requirement', + responses: { '200': { description: 'Requirement saved' }, '403': { description: 'Not authorized' } } + }, + delete: { + summary: 'Delete a server plugin requirement', + responses: { '200': { description: 'Requirement deleted' }, '403': { description: 'Not authorized' } } + } + }, + '/servers/{serverId}/plugins/{pluginId}/events/{eventName}': { + put: { + summary: 'Create or update a plugin event definition', + responses: { '200': { description: 'Event definition saved' }, '403': { description: 'Not authorized' } } + }, + delete: { + summary: 'Delete a plugin event definition', + responses: { '200': { description: 'Event definition deleted' }, '403': { description: 'Not authorized' } } + } + }, + '/servers/{serverId}/plugins/{pluginId}/data': { + get: { + summary: 'List plugin data records', + responses: { '200': { description: 'Plugin data records' }, '403': { description: 'Not a server member' } } + } + }, + '/servers/{serverId}/plugins/{pluginId}/data/{key}': { + put: { + summary: 'Write plugin data', + responses: { '200': { description: 'Plugin data saved' }, '403': { description: 'Not a server member' } } + }, + delete: { + summary: 'Delete plugin data', + responses: { '200': { description: 'Plugin data deleted' }, '403': { description: 'Not a server member' } } + } + }, + '/openapi/settings': { + get: { summary: 'Read OpenAPI docs setting', responses: { '200': { description: 'Setting value' } } }, + put: { summary: 'Toggle OpenAPI docs exposure', responses: { '200': { description: 'Setting value' } } } + } + } + }; +} + +function docsDisabledResponse() { + return { error: 'OpenAPI docs are disabled', errorCode: 'OPENAPI_DOCS_DISABLED' }; +} + +router.get('/openapi/settings', (_req, res) => { + res.json({ enabled: areOpenApiDocsEnabled() }); +}); + +router.put('/openapi/settings', (req, res) => { + res.json(setOpenApiDocsEnabled(req.body?.enabled === true)); +}); + +router.get('/openapi.json', (req, res) => { + if (!areOpenApiDocsEnabled()) { + res.status(404).json(docsDisabledResponse()); + return; + } + + res.json(createOpenApiDocument(`${req.protocol}://${req.get('host') ?? 'localhost'}`)); +}); + +router.get('/docs', (_req, res) => { + if (!areOpenApiDocsEnabled()) { + res.status(404).json(docsDisabledResponse()); + return; + } + + res.type('html').send(` + +MetoYou Plugin API Docs + +

MetoYou Plugin Support API

+

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

+

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

+ +`); +}); + +export default router; diff --git a/server/src/routes/plugin-support.ts b/server/src/routes/plugin-support.ts new file mode 100644 index 0000000..97424dc --- /dev/null +++ b/server/src/routes/plugin-support.ts @@ -0,0 +1,208 @@ +import { Response, Router } from 'express'; +import { + deletePluginData, + deletePluginEventDefinition, + deletePluginRequirement, + getPluginRequirementsSnapshot, + listPluginData, + PluginSupportError, + upsertPluginData, + upsertPluginEventDefinition, + upsertPluginRequirement +} from '../services/plugin-support.service'; +import { broadcastToServer } from '../websocket/broadcast'; + +const router = Router(); + +function sendPluginSupportError(error: unknown, res: Response): void { + if (error instanceof PluginSupportError) { + res.status(error.status).json({ error: error.message, errorCode: error.code }); + return; + } + + console.error('Unhandled plugin support error:', error); + res.status(500).json({ error: 'Internal server error', errorCode: 'INTERNAL_ERROR' }); +} + +function readActorUserId(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +async function broadcastRequirementsSnapshot(serverId: string): Promise { + const snapshot = await getPluginRequirementsSnapshot(serverId); + + broadcastToServer(serverId, { + type: 'plugin_requirements_changed', + serverId, + snapshot + }); +} + +router.get('/:serverId/plugins', async (req, res) => { + try { + res.json(await getPluginRequirementsSnapshot(req.params.serverId)); + } catch (error) { + sendPluginSupportError(error, res); + } +}); + +router.put('/:serverId/plugins/:pluginId/requirement', async (req, res) => { + const { serverId, pluginId } = req.params; + + try { + const requirement = await upsertPluginRequirement({ + actorUserId: readActorUserId(req.body.actorUserId), + pluginId, + reason: req.body.reason, + serverId, + status: req.body.status, + versionRange: req.body.versionRange + }); + + await broadcastRequirementsSnapshot(serverId); + res.json({ requirement }); + } catch (error) { + sendPluginSupportError(error, res); + } +}); + +router.delete('/:serverId/plugins/:pluginId/requirement', async (req, res) => { + const { serverId, pluginId } = req.params; + + try { + await deletePluginRequirement({ + actorUserId: readActorUserId(req.body.actorUserId), + pluginId, + serverId + }); + + await broadcastRequirementsSnapshot(serverId); + res.json({ ok: true }); + } catch (error) { + sendPluginSupportError(error, res); + } +}); + +router.put('/:serverId/plugins/:pluginId/events/:eventName', async (req, res) => { + const { serverId, pluginId, eventName } = req.params; + + try { + const eventDefinition = await upsertPluginEventDefinition({ + actorUserId: readActorUserId(req.body.actorUserId), + direction: req.body.direction, + eventName, + maxPayloadBytes: req.body.maxPayloadBytes, + pluginId, + rateLimitJson: req.body.rateLimitJson, + schemaJson: req.body.schemaJson, + scope: req.body.scope, + serverId + }); + + await broadcastRequirementsSnapshot(serverId); + res.json({ eventDefinition }); + } catch (error) { + sendPluginSupportError(error, res); + } +}); + +router.delete('/:serverId/plugins/:pluginId/events/:eventName', async (req, res) => { + const { serverId, pluginId, eventName } = req.params; + + try { + await deletePluginEventDefinition({ + actorUserId: readActorUserId(req.body.actorUserId), + eventName, + pluginId, + serverId + }); + + await broadcastRequirementsSnapshot(serverId); + res.json({ ok: true }); + } catch (error) { + sendPluginSupportError(error, res); + } +}); + +router.get('/:serverId/plugins/:pluginId/data', async (req, res) => { + const { serverId, pluginId } = req.params; + + try { + const records = await listPluginData({ + actorUserId: readActorUserId(req.query.userId), + key: req.query.key, + ownerId: req.query.ownerId, + pluginId, + scope: req.query.scope, + serverId + }); + + res.json({ records }); + } catch (error) { + sendPluginSupportError(error, res); + } +}); + +router.put('/:serverId/plugins/:pluginId/data/:key', async (req, res) => { + const { serverId, pluginId, key } = req.params; + + try { + const record = await upsertPluginData({ + actorUserId: readActorUserId(req.body.actorUserId), + key, + ownerId: req.body.ownerId, + pluginId, + schemaVersion: req.body.schemaVersion, + scope: req.body.scope, + serverId, + value: req.body.value + }); + + broadcastToServer(serverId, { + type: 'plugin_data_changed', + serverId, + pluginId: record.pluginId, + scope: record.scope, + ownerId: record.ownerId, + key: record.key, + updatedAt: record.updatedAt + }); + + res.json({ record }); + } catch (error) { + sendPluginSupportError(error, res); + } +}); + +router.delete('/:serverId/plugins/:pluginId/data/:key', async (req, res) => { + const { serverId, pluginId, key } = req.params; + const scope = req.body.scope ?? req.query.scope; + const ownerId = req.body.ownerId ?? req.query.ownerId; + + try { + await deletePluginData({ + actorUserId: readActorUserId(req.body.actorUserId), + key, + ownerId, + pluginId, + scope, + serverId + }); + + broadcastToServer(serverId, { + type: 'plugin_data_changed', + serverId, + pluginId, + scope: typeof scope === 'string' ? scope : 'server', + ownerId: typeof ownerId === 'string' && ownerId.trim() ? ownerId.trim() : undefined, + key, + updatedAt: Date.now() + }); + + res.json({ ok: true }); + } catch (error) { + sendPluginSupportError(error, res); + } +}); + +export default router; diff --git a/server/src/services/plugin-support.service.ts b/server/src/services/plugin-support.service.ts new file mode 100644 index 0000000..53501c1 --- /dev/null +++ b/server/src/services/plugin-support.service.ts @@ -0,0 +1,523 @@ +import { getServerById } from '../cqrs'; +import { getDataSource } from '../db/database'; +import { + PluginDataEntity, + ServerPluginEventDefinitionEntity, + ServerPluginEventDirection, + ServerPluginEventScope, + ServerPluginRequirementEntity, + ServerPluginRequirementStatus +} from '../entities'; +import { findServerMembership } from './server-access.service'; +import { resolveServerPermission } from './server-permissions.service'; + +export const DEFAULT_PLUGIN_EVENT_MAX_PAYLOAD_BYTES = 64 * 1024; + +const VALID_REQUIREMENT_STATUSES = new Set([ + 'required', + 'optional', + 'recommended', + 'blocked', + 'incompatible' +]); +const VALID_EVENT_DIRECTIONS = new Set([ + 'clientToServer', + 'serverRelay', + 'p2pHint' +]); +const VALID_EVENT_SCOPES = new Set([ + 'server', + 'channel', + 'user', + 'plugin' +]); +const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9.-]{1,126}[a-z0-9]$/; +const EVENT_NAME_PATTERN = /^[a-z][a-z0-9.:-]{1,126}[a-z0-9]$/; +const DATA_KEY_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/; +const DATA_SCOPE_PATTERN = /^[a-zA-Z][a-zA-Z0-9._:-]{0,63}$/; + +export interface PluginRequirementSummary { + pluginId: string; + reason?: string; + status: ServerPluginRequirementStatus; + updatedAt: number; + versionRange?: string; +} + +export interface PluginEventDefinitionSummary { + direction: ServerPluginEventDirection; + eventName: string; + maxPayloadBytes: number; + pluginId: string; + scope: ServerPluginEventScope; + schemaJson?: string; + updatedAt: number; +} + +export interface PluginRequirementsSnapshot { + eventDefinitions: PluginEventDefinitionSummary[]; + requirements: PluginRequirementSummary[]; + serverId: string; + updatedAt: number; +} + +export interface PluginDataRecord { + key: string; + ownerId?: string; + pluginId: string; + schemaVersion: number; + scope: string; + serverId: string; + updatedAt: number; + updatedBy?: string; + value: unknown; +} + +export interface PluginEventEnvelope { + eventId?: string; + eventName: string; + payload: unknown; + pluginId: string; + serverId: string; + sourcePluginUserId?: string; + type: 'plugin_event'; +} + +export class PluginSupportError extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string + ) { + super(message); + this.name = 'PluginSupportError'; + } +} + +function requirementRepository() { + return getDataSource().getRepository(ServerPluginRequirementEntity); +} + +function eventDefinitionRepository() { + return getDataSource().getRepository(ServerPluginEventDefinitionEntity); +} + +function pluginDataRepository() { + return getDataSource().getRepository(PluginDataEntity); +} + +function normalizeOptionalString(value: unknown, maxLength: number): string | null { + if (typeof value !== 'string') { + return null; + } + + const normalized = value.trim(); + + return normalized ? normalized.slice(0, maxLength) : null; +} + +function assertPattern(value: string, pattern: RegExp, code: string, label: string): void { + if (!pattern.test(value)) { + throw new PluginSupportError(400, code, `Invalid ${label}`); + } +} + +function normalizePluginId(pluginId: unknown): string { + const normalized = normalizeOptionalString(pluginId, 128); + + if (!normalized) { + throw new PluginSupportError(400, 'MISSING_PLUGIN_ID', 'Missing plugin id'); + } + + assertPattern(normalized, PLUGIN_ID_PATTERN, 'INVALID_PLUGIN_ID', 'plugin id'); + return normalized; +} + +function normalizeEventName(eventName: unknown): string { + const normalized = normalizeOptionalString(eventName, 128); + + if (!normalized) { + throw new PluginSupportError(400, 'MISSING_EVENT_NAME', 'Missing event name'); + } + + assertPattern(normalized, EVENT_NAME_PATTERN, 'INVALID_EVENT_NAME', 'event name'); + return normalized; +} + +function normalizeDataKey(key: unknown): string { + const normalized = normalizeOptionalString(key, 128); + + if (!normalized) { + throw new PluginSupportError(400, 'MISSING_DATA_KEY', 'Missing data key'); + } + + assertPattern(normalized, DATA_KEY_PATTERN, 'INVALID_DATA_KEY', 'data key'); + return normalized; +} + +function normalizeDataScope(scope: unknown): string { + const normalized = normalizeOptionalString(scope, 64) ?? 'server'; + + assertPattern(normalized, DATA_SCOPE_PATTERN, 'INVALID_DATA_SCOPE', 'data scope'); + return normalized; +} + +function normalizeOwnerId(ownerId: unknown): string { + return normalizeOptionalString(ownerId, 128) ?? ''; +} + +function parseJsonValue(valueJson: string): unknown { + try { + return JSON.parse(valueJson) as unknown; + } catch { + return null; + } +} + +function serializeJsonValue(value: unknown, code: string): string { + try { + return JSON.stringify(value ?? null); + } catch { + throw new PluginSupportError(400, code, 'Value must be JSON serializable'); + } +} + +function toRequirementSummary(entity: ServerPluginRequirementEntity): PluginRequirementSummary { + return { + pluginId: entity.pluginId, + reason: entity.reason ?? undefined, + status: entity.status, + updatedAt: entity.updatedAt, + versionRange: entity.versionRange ?? undefined + }; +} + +function toEventDefinitionSummary(entity: ServerPluginEventDefinitionEntity): PluginEventDefinitionSummary { + return { + direction: entity.direction, + eventName: entity.eventName, + maxPayloadBytes: entity.maxPayloadBytes, + pluginId: entity.pluginId, + scope: entity.scope, + schemaJson: entity.schemaJson ?? undefined, + updatedAt: entity.updatedAt + }; +} + +function toPluginDataRecord(entity: PluginDataEntity): PluginDataRecord { + return { + key: entity.key, + ownerId: entity.ownerId || undefined, + pluginId: entity.pluginId, + schemaVersion: entity.schemaVersion, + scope: entity.scope, + serverId: entity.serverId, + updatedAt: entity.updatedAt, + updatedBy: entity.updatedBy ?? undefined, + value: parseJsonValue(entity.valueJson) + }; +} + +async function assertServerExists(serverId: string) { + const server = await getServerById(serverId); + + if (!server) { + throw new PluginSupportError(404, 'SERVER_NOT_FOUND', 'Server not found'); + } + + return server; +} + +export async function assertCanManagePluginSupport(serverId: string, actorUserId: string): Promise { + const server = await assertServerExists(serverId); + + if (!actorUserId || !resolveServerPermission(server, actorUserId, 'manageServer')) { + throw new PluginSupportError(403, 'NOT_AUTHORIZED', 'Not authorized'); + } +} + +export async function assertCanUsePluginData(serverId: string, actorUserId: string): Promise { + const server = await assertServerExists(serverId); + + if (!actorUserId) { + throw new PluginSupportError(400, 'MISSING_USER', 'Missing user id'); + } + + if (server.ownerId === actorUserId) { + return; + } + + const membership = await findServerMembership(serverId, actorUserId); + + if (!membership) { + throw new PluginSupportError(403, 'NOT_MEMBER', 'Only joined users can access plugin data'); + } +} + +export async function getPluginRequirementsSnapshot(serverId: string): Promise { + await assertServerExists(serverId); + + const requirementQuery = requirementRepository().find({ where: { serverId } }); + const eventDefinitionQuery = eventDefinitionRepository().find({ where: { serverId } }); + const [requirements, eventDefinitions] = await Promise.all([requirementQuery, eventDefinitionQuery]); + const requirementSummaries = requirements + .map(toRequirementSummary) + .sort((first, second) => first.pluginId.localeCompare(second.pluginId)); + const eventDefinitionSummaries = eventDefinitions + .map(toEventDefinitionSummary) + .sort((first, second) => `${first.pluginId}:${first.eventName}`.localeCompare(`${second.pluginId}:${second.eventName}`)); + const updatedAt = Math.max( + 0, + ...requirementSummaries.map((requirement) => requirement.updatedAt), + ...eventDefinitionSummaries.map((definition) => definition.updatedAt) + ); + + return { + eventDefinitions: eventDefinitionSummaries, + requirements: requirementSummaries, + serverId, + updatedAt + }; +} + +export async function upsertPluginRequirement(options: { + actorUserId: string; + pluginId: string; + reason?: unknown; + serverId: string; + status: unknown; + versionRange?: unknown; +}): Promise { + await assertCanManagePluginSupport(options.serverId, options.actorUserId); + + const pluginId = normalizePluginId(options.pluginId); + const status = options.status; + + if (!VALID_REQUIREMENT_STATUSES.has(status as ServerPluginRequirementStatus)) { + throw new PluginSupportError(400, 'INVALID_REQUIREMENT_STATUS', 'Invalid plugin requirement status'); + } + + const repo = requirementRepository(); + const now = Date.now(); + const existing = await repo.findOne({ where: { serverId: options.serverId, pluginId } }); + const entity = repo.create({ + serverId: options.serverId, + pluginId, + status: status as ServerPluginRequirementStatus, + versionRange: normalizeOptionalString(options.versionRange, 128), + reason: normalizeOptionalString(options.reason, 512), + configuredBy: options.actorUserId, + createdAt: existing?.createdAt ?? now, + updatedAt: now + }); + + await repo.save(entity); + return toRequirementSummary(entity); +} + +export async function deletePluginRequirement(options: { + actorUserId: string; + pluginId: string; + serverId: string; +}): Promise { + await assertCanManagePluginSupport(options.serverId, options.actorUserId); + await requirementRepository().delete({ serverId: options.serverId, pluginId: normalizePluginId(options.pluginId) }); +} + +export async function upsertPluginEventDefinition(options: { + actorUserId: string; + direction: unknown; + eventName: string; + maxPayloadBytes?: unknown; + pluginId: string; + rateLimitJson?: unknown; + schemaJson?: unknown; + scope: unknown; + serverId: string; +}): Promise { + await assertCanManagePluginSupport(options.serverId, options.actorUserId); + + const pluginId = normalizePluginId(options.pluginId); + const eventName = normalizeEventName(options.eventName); + const { direction, scope } = options; + + if (!VALID_EVENT_DIRECTIONS.has(direction as ServerPluginEventDirection)) { + throw new PluginSupportError(400, 'INVALID_EVENT_DIRECTION', 'Invalid plugin event direction'); + } + + if (!VALID_EVENT_SCOPES.has(scope as ServerPluginEventScope)) { + throw new PluginSupportError(400, 'INVALID_EVENT_SCOPE', 'Invalid plugin event scope'); + } + + const maxPayloadBytes = typeof options.maxPayloadBytes === 'number' && Number.isFinite(options.maxPayloadBytes) + ? Math.max(1, Math.min(Math.floor(options.maxPayloadBytes), DEFAULT_PLUGIN_EVENT_MAX_PAYLOAD_BYTES)) + : DEFAULT_PLUGIN_EVENT_MAX_PAYLOAD_BYTES; + const repo = eventDefinitionRepository(); + const now = Date.now(); + const existing = await repo.findOne({ where: { serverId: options.serverId, pluginId, eventName } }); + const entity = repo.create({ + serverId: options.serverId, + pluginId, + eventName, + direction: direction as ServerPluginEventDirection, + scope: scope as ServerPluginEventScope, + schemaJson: normalizeOptionalString(options.schemaJson, 10_000), + maxPayloadBytes, + rateLimitJson: normalizeOptionalString(options.rateLimitJson, 2_000), + createdAt: existing?.createdAt ?? now, + updatedAt: now + }); + + await repo.save(entity); + return toEventDefinitionSummary(entity); +} + +export async function deletePluginEventDefinition(options: { + actorUserId: string; + eventName: string; + pluginId: string; + serverId: string; +}): Promise { + await assertCanManagePluginSupport(options.serverId, options.actorUserId); + await eventDefinitionRepository().delete({ + serverId: options.serverId, + pluginId: normalizePluginId(options.pluginId), + eventName: normalizeEventName(options.eventName) + }); +} + +export async function listPluginData(options: { + actorUserId: string; + key?: unknown; + ownerId?: unknown; + pluginId: string; + scope?: unknown; + serverId: string; +}): Promise { + await assertCanUsePluginData(options.serverId, options.actorUserId); + + const pluginId = normalizePluginId(options.pluginId); + const scope = options.scope === undefined ? undefined : normalizeDataScope(options.scope); + const ownerId = options.ownerId === undefined ? undefined : normalizeOwnerId(options.ownerId); + const key = options.key === undefined ? undefined : normalizeDataKey(options.key); + const query = pluginDataRepository() + .createQueryBuilder('data') + .where('data.serverId = :serverId', { serverId: options.serverId }) + .andWhere('data.pluginId = :pluginId', { pluginId }); + + if (scope !== undefined) { + query.andWhere('data.scope = :scope', { scope }); + } + + if (ownerId !== undefined) { + query.andWhere('data.ownerId = :ownerId', { ownerId }); + } + + if (key !== undefined) { + query.andWhere('data.key = :key', { key }); + } + + const records = await query + .orderBy('data.scope', 'ASC') + .addOrderBy('data.ownerId', 'ASC') + .addOrderBy('data.key', 'ASC') + .getMany(); + + return records.map(toPluginDataRecord); +} + +export async function upsertPluginData(options: { + actorUserId: string; + key: string; + ownerId?: unknown; + pluginId: string; + schemaVersion?: unknown; + scope?: unknown; + serverId: string; + value: unknown; +}): Promise { + await assertCanUsePluginData(options.serverId, options.actorUserId); + + const pluginId = normalizePluginId(options.pluginId); + const scope = normalizeDataScope(options.scope); + const ownerId = scope === 'user' ? normalizeOwnerId(options.ownerId ?? options.actorUserId) : normalizeOwnerId(options.ownerId); + + if (scope === 'user' && ownerId !== options.actorUserId) { + await assertCanManagePluginSupport(options.serverId, options.actorUserId); + } + + const key = normalizeDataKey(options.key); + const schemaVersion = typeof options.schemaVersion === 'number' && Number.isFinite(options.schemaVersion) + ? Math.max(1, Math.floor(options.schemaVersion)) + : 1; + const repo = pluginDataRepository(); + const entity = repo.create({ + serverId: options.serverId, + pluginId, + scope, + ownerId, + key, + valueJson: serializeJsonValue(options.value, 'INVALID_PLUGIN_DATA'), + schemaVersion, + updatedBy: options.actorUserId, + updatedAt: Date.now() + }); + + await repo.save(entity); + return toPluginDataRecord(entity); +} + +export async function deletePluginData(options: { + actorUserId: string; + key: string; + ownerId?: unknown; + pluginId: string; + scope?: unknown; + serverId: string; +}): Promise { + await assertCanUsePluginData(options.serverId, options.actorUserId); + + const pluginId = normalizePluginId(options.pluginId); + const scope = normalizeDataScope(options.scope); + const ownerId = scope === 'user' ? normalizeOwnerId(options.ownerId ?? options.actorUserId) : normalizeOwnerId(options.ownerId); + + if (scope === 'user' && ownerId !== options.actorUserId) { + await assertCanManagePluginSupport(options.serverId, options.actorUserId); + } + + await pluginDataRepository().delete({ + serverId: options.serverId, + pluginId, + scope, + ownerId, + key: normalizeDataKey(options.key) + }); +} + +export async function validatePluginEventEnvelope(envelope: PluginEventEnvelope): Promise { + const pluginId = normalizePluginId(envelope.pluginId); + const eventName = normalizeEventName(envelope.eventName); + const definition = await eventDefinitionRepository().findOne({ + where: { + serverId: envelope.serverId, + pluginId, + eventName + } + }); + + if (!definition) { + throw new PluginSupportError(404, 'PLUGIN_EVENT_NOT_REGISTERED', 'Plugin event is not registered for this server'); + } + + if (definition.direction === 'p2pHint') { + throw new PluginSupportError(400, 'PLUGIN_EVENT_NOT_RELAYABLE', 'P2P plugin events must not be relayed by the signal server'); + } + + const payloadBytes = Buffer.byteLength(serializeJsonValue(envelope.payload, 'INVALID_PLUGIN_EVENT_PAYLOAD'), 'utf8'); + + if (payloadBytes > definition.maxPayloadBytes) { + throw new PluginSupportError(413, 'PLUGIN_EVENT_TOO_LARGE', 'Plugin event payload is too large'); + } + + return definition; +} diff --git a/server/src/websocket/handler-plugin.spec.ts b/server/src/websocket/handler-plugin.spec.ts new file mode 100644 index 0000000..247081d --- /dev/null +++ b/server/src/websocket/handler-plugin.spec.ts @@ -0,0 +1,221 @@ +import { + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; +import { WebSocket } from 'ws'; +import { ConnectedUser } from './types'; +import { connectedUsers } from './state'; + +const pluginSupportMocks = vi.hoisted(() => { + class MockPluginSupportError extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string + ) { + super(message); + this.name = 'PluginSupportError'; + } + } + + return { + getPluginRequirementsSnapshot: vi.fn(), + PluginSupportError: MockPluginSupportError, + validatePluginEventEnvelope: vi.fn() + }; +}); + +vi.mock('../services/server-access.service', () => ({ + authorizeWebSocketJoin: vi.fn(async () => ({ allowed: true as const })) +})); + +vi.mock('../services/plugin-support.service', () => pluginSupportMocks); + +import { handleWebSocketMessage } from './handler'; + +interface SentMessageStore { + sentMessages: string[]; +} + +function createMockWs(): WebSocket & SentMessageStore { + const sentMessages: string[] = []; + const socket = { + readyState: WebSocket.OPEN, + send: (data: string) => { + sentMessages.push(data); + }, + close: () => {}, + sentMessages + } as unknown as WebSocket & SentMessageStore; + + return socket; +} + +function createConnectedUser( + connectionId: string, + oderId: string, + overrides: Partial = {} +): ConnectedUser { + const user: ConnectedUser = { + displayName: `User ${oderId}`, + lastPong: Date.now(), + oderId, + serverIds: new Set(), + ws: createMockWs(), + ...overrides + }; + + connectedUsers.set(connectionId, user); + return user; +} + +function readSentMessages(user: ConnectedUser): Record[] { + return (user.ws as unknown as SentMessageStore).sentMessages.map((messageText) => JSON.parse(messageText) as Record); +} + +describe('server websocket handler - plugin support', () => { + beforeEach(() => { + connectedUsers.clear(); + pluginSupportMocks.getPluginRequirementsSnapshot.mockReset(); + pluginSupportMocks.validatePluginEventEnvelope.mockReset(); + pluginSupportMocks.getPluginRequirementsSnapshot.mockResolvedValue({ + eventDefinitions: [], + requirements: [], + serverId: 'server-1', + updatedAt: 0 + }); + + pluginSupportMocks.validatePluginEventEnvelope.mockResolvedValue({ direction: 'serverRelay' }); + }); + + it('sends plugin requirement snapshots after joining a server', async () => { + const alice = createConnectedUser('conn-1', 'alice'); + + pluginSupportMocks.getPluginRequirementsSnapshot.mockResolvedValue({ + eventDefinitions: [ + { + direction: 'serverRelay', + eventName: 'e2e:relay', + maxPayloadBytes: 2048, + pluginId: 'e2e.plugin-api', + scope: 'server', + updatedAt: 2 + } + ], + requirements: [ + { + pluginId: 'e2e.plugin-api', + status: 'required', + updatedAt: 1 + } + ], + serverId: 'server-1', + updatedAt: 2 + }); + + await handleWebSocketMessage('conn-1', { type: 'join_server', serverId: 'server-1' }); + + const messages = readSentMessages(alice); + const pluginRequirements = messages.find((message) => message['type'] === 'plugin_requirements'); + + expect(pluginRequirements?.['serverId']).toBe('server-1'); + expect(pluginRequirements?.['snapshot']).toEqual(expect.objectContaining({ updatedAt: 2 })); + }); + + it('validates and relays plugin events to other joined users', async () => { + const alice = createConnectedUser('conn-1', 'alice', { viewedServerId: 'server-1' }); + const bob = createConnectedUser('conn-2', 'bob', { viewedServerId: 'server-1' }); + + alice.serverIds.add('server-1'); + bob.serverIds.add('server-1'); + + await handleWebSocketMessage('conn-1', { + type: 'plugin_event', + eventId: 'event-1', + eventName: 'e2e:relay', + payload: { ok: true }, + pluginId: 'e2e.plugin-api', + serverId: 'server-1', + sourcePluginUserId: 'fixture-user' + }); + + expect(pluginSupportMocks.validatePluginEventEnvelope).toHaveBeenCalledWith({ + type: 'plugin_event', + eventId: 'event-1', + eventName: 'e2e:relay', + payload: { ok: true }, + pluginId: 'e2e.plugin-api', + serverId: 'server-1', + sourcePluginUserId: 'fixture-user' + }); + + const bobMessages = readSentMessages(bob); + const relayedEvent = bobMessages.find((message) => message['type'] === 'plugin_event'); + + expect(relayedEvent).toEqual(expect.objectContaining({ + eventId: 'event-1', + eventName: 'e2e:relay', + pluginId: 'e2e.plugin-api', + serverId: 'server-1', + sourcePluginUserId: 'fixture-user', + sourceUserId: 'alice' + })); + + expect(typeof relayedEvent?.['emittedAt']).toBe('number'); + }); + + it('returns plugin errors for invalid plugin event messages', async () => { + const alice = createConnectedUser('conn-1', 'alice'); + + await handleWebSocketMessage('conn-1', { + type: 'plugin_event', + eventName: 'e2e:relay', + pluginId: 'e2e.plugin-api', + serverId: 'server-1' + }); + + const pluginError = readSentMessages(alice).find((message) => message['type'] === 'plugin_error'); + + expect(pluginError).toEqual(expect.objectContaining({ + code: 'INVALID_PLUGIN_EVENT', + eventName: 'e2e:relay', + pluginId: 'e2e.plugin-api', + serverId: 'server-1' + })); + + expect(pluginSupportMocks.validatePluginEventEnvelope).not.toHaveBeenCalled(); + }); + + it('forwards plugin support validation errors to the sending user', async () => { + const alice = createConnectedUser('conn-1', 'alice', { viewedServerId: 'server-1' }); + + alice.serverIds.add('server-1'); + pluginSupportMocks.validatePluginEventEnvelope.mockRejectedValue(new pluginSupportMocks.PluginSupportError( + 400, + 'PLUGIN_EVENT_NOT_RELAYABLE', + 'P2P plugin events must not be relayed by the signal server' + )); + + await handleWebSocketMessage('conn-1', { + type: 'plugin_event', + eventId: 'event-p2p', + eventName: 'e2e:p2p', + payload: { hint: true }, + pluginId: 'e2e.plugin-api', + serverId: 'server-1' + }); + + const pluginError = readSentMessages(alice).find((message) => message['type'] === 'plugin_error'); + + expect(pluginError).toEqual(expect.objectContaining({ + code: 'PLUGIN_EVENT_NOT_RELAYABLE', + eventId: 'event-p2p', + eventName: 'e2e:p2p', + pluginId: 'e2e.plugin-api', + serverId: 'server-1' + })); + }); +}); diff --git a/server/src/websocket/handler.ts b/server/src/websocket/handler.ts index 1d6299e..51ab38b 100644 --- a/server/src/websocket/handler.ts +++ b/server/src/websocket/handler.ts @@ -8,6 +8,11 @@ import { isOderIdConnectedToServer } from './broadcast'; import { authorizeWebSocketJoin } from '../services/server-access.service'; +import { + getPluginRequirementsSnapshot, + PluginSupportError, + validatePluginEventEnvelope +} from '../services/plugin-support.service'; interface WsMessage { [key: string]: unknown; @@ -50,6 +55,29 @@ function readMessageId(value: unknown): string | undefined { return normalized; } +function sendPluginError(user: ConnectedUser, error: unknown, message: WsMessage): void { + if (error instanceof PluginSupportError) { + user.ws.send(JSON.stringify({ + type: 'plugin_error', + serverId: typeof message['serverId'] === 'string' ? message['serverId'] : undefined, + pluginId: typeof message['pluginId'] === 'string' ? message['pluginId'] : undefined, + eventName: typeof message['eventName'] === 'string' ? message['eventName'] : undefined, + eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined, + code: error.code, + message: error.message + })); + + return; + } + + console.error('Unhandled plugin websocket error:', error); + user.ws.send(JSON.stringify({ + type: 'plugin_error', + code: 'INTERNAL_ERROR', + message: 'Internal server error' + })); +} + /** Sends the current user list for a given server to a single connected user. */ function sendServerUsers(user: ConnectedUser, serverId: string): void { const users = getUniqueUsersInServer(serverId, user.oderId) @@ -64,6 +92,20 @@ function sendServerUsers(user: ConnectedUser, serverId: string): void { user.ws.send(JSON.stringify({ type: 'server_users', serverId, users })); } +async function sendPluginRequirements(user: ConnectedUser, serverId: string): Promise { + try { + const snapshot = await getPluginRequirementsSnapshot(serverId); + + user.ws.send(JSON.stringify({ + type: 'plugin_requirements', + serverId, + snapshot + })); + } catch (error) { + sendPluginError(user, error, { type: 'plugin_requirements', serverId }); + } +} + function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void { const newOderId = readMessageId(message['oderId']) ?? connectionId; const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined; @@ -137,6 +179,7 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect ); sendServerUsers(user, sid); + await sendPluginRequirements(user, sid); if (isNewIdentityMembership) { broadcastToServer(sid, { @@ -151,17 +194,22 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect } } -function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId: string): void { +async function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise { const viewSid = readMessageId(message['serverId']); if (!viewSid) return; + if (!user.serverIds.has(viewSid)) { + return; + } + user.viewedServerId = viewSid; connectedUsers.set(connectionId, user); console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) viewing server ${viewSid}`); sendServerUsers(user, viewSid); + await sendPluginRequirements(user, viewSid); } function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId: string): void { @@ -268,6 +316,52 @@ function handleStatusUpdate(user: ConnectedUser, message: WsMessage, connectionI } } +async function handlePluginEvent(user: ConnectedUser, message: WsMessage): Promise { + const serverId = readMessageId(message['serverId']) ?? user.viewedServerId; + const pluginId = readMessageId(message['pluginId']); + const eventName = readMessageId(message['eventName']); + + if (!serverId || !pluginId || !eventName || !user.serverIds.has(serverId)) { + user.ws.send(JSON.stringify({ + type: 'plugin_error', + serverId, + pluginId, + eventName, + eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined, + code: 'INVALID_PLUGIN_EVENT', + message: 'Plugin event is missing required fields or server membership' + })); + + return; + } + + try { + await validatePluginEventEnvelope({ + type: 'plugin_event', + serverId, + pluginId, + eventName, + eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined, + payload: message['payload'], + sourcePluginUserId: typeof message['sourcePluginUserId'] === 'string' ? message['sourcePluginUserId'] : undefined + }); + + broadcastToServer(serverId, { + type: 'plugin_event', + serverId, + pluginId, + eventName, + eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined, + payload: message['payload'], + sourcePluginUserId: typeof message['sourcePluginUserId'] === 'string' ? message['sourcePluginUserId'] : undefined, + sourceUserId: user.oderId, + emittedAt: Date.now() + }, user.oderId); + } catch (error) { + sendPluginError(user, error, message); + } +} + export async function handleWebSocketMessage(connectionId: string, message: WsMessage): Promise { const user = connectedUsers.get(connectionId); @@ -290,7 +384,7 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe break; case 'view_server': - handleViewServer(user, message, connectionId); + await handleViewServer(user, message, connectionId); break; case 'leave_server': @@ -315,6 +409,10 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe handleStatusUpdate(user, message, connectionId); break; + case 'plugin_event': + await handlePluginEvent(user, message); + break; + default: console.log('Unknown message type:', message.type); } diff --git a/toju-app/angular.json b/toju-app/angular.json index 652f152..26aa127 100644 --- a/toju-app/angular.json +++ b/toju-app/angular.json @@ -96,12 +96,12 @@ "budgets": [ { "type": "initial", - "maximumWarning": "2.2MB", - "maximumError": "2.38MB" + "maximumWarning": "2.5MB", + "maximumError": "2.6MB" }, { "type": "anyComponentStyle", - "maximumWarning": "4kB", + "maximumWarning": "7kB", "maximumError": "8kB" } ], diff --git a/toju-app/public/plugins/e2e-all-api/README.md b/toju-app/public/plugins/e2e-all-api/README.md new file mode 100644 index 0000000..58a8f81 --- /dev/null +++ b/toju-app/public/plugins/e2e-all-api/README.md @@ -0,0 +1,3 @@ +# E2E All API Plugin + +Fixture plugin for Playwright coverage. It calls every public Toju plugin API surface, registers UI contributions, writes storage, publishes events, creates plugin user data, and logs completion. diff --git a/toju-app/public/plugins/e2e-all-api/icon.svg b/toju-app/public/plugins/e2e-all-api/icon.svg new file mode 100644 index 0000000..3306fe4 --- /dev/null +++ b/toju-app/public/plugins/e2e-all-api/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/toju-app/public/plugins/e2e-all-api/main.js b/toju-app/public/plugins/e2e-all-api/main.js new file mode 100644 index 0000000..68919f3 --- /dev/null +++ b/toju-app/public/plugins/e2e-all-api/main.js @@ -0,0 +1,273 @@ +const tinyWave = 'data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAESsAACJWAAACABAAZGF0YQAAAAA='; +const originalMessage = 'Plugin API original message'; +const editedMessage = 'Plugin API edited message'; +const deletedMessage = 'Plugin API deleted message'; +const embedMessage = 'toju:embed:e2e.coverage:{"title":"Plugin API custom embed","body":"Rendered by plugin API"}'; +const soundboardPlayedMessage = 'E2E soundboard played Airhorn to voice channel'; + +export async function activate(context) { + const api = context.api; + const currentUser = api.profile.getCurrent(); + const shouldMutateChat = !currentUser?.displayName?.includes('Bob'); + const pluginUserId = api.server.registerPluginUser({ + displayName: 'E2E Plugin Bot', + id: 'e2e-plugin-bot' + }); + + context.subscriptions.push(api.ui.registerSettingsPage('coverage', { + label: 'E2E Coverage', + render: () => 'E2E settings contribution' + })); + context.subscriptions.push(api.ui.registerAppPage('coverage', { + label: 'E2E Page', + path: '/plugins/e2e/coverage', + render: () => 'E2E page contribution' + })); + context.subscriptions.push(api.ui.registerSidePanel('coverage', { + label: 'E2E Soundboard', + render: () => 'E2E soundboard ready' + })); + context.subscriptions.push(api.ui.registerChannelSection('coverage', { + label: 'E2E Soundboard', + type: 'custom' + })); + context.subscriptions.push(api.ui.registerComposerAction('coverage', { + icon: 'SFX', + label: 'E2E Soundboard', + run: () => openSoundboardModal(api, pluginUserId) + })); + context.subscriptions.push(api.ui.registerProfileAction('coverage', { + label: 'E2E Profile', + run: () => api.logger.info('profile action ran') + })); + context.subscriptions.push(api.ui.registerToolbarAction('coverage', { + label: 'E2E Toolbar', + run: () => api.logger.info('toolbar action ran') + })); + context.subscriptions.push(api.ui.registerEmbedRenderer('coverage', { + embedType: 'e2e.coverage', + render: (payload) => `E2E custom embed: ${payload?.title ?? 'missing title'}` + })); + + const injectedBadge = document.createElement('div'); + + injectedBadge.dataset.testid = 'e2e-plugin-owned-dom'; + injectedBadge.textContent = 'E2E plugin-owned DOM injected into chat'; + injectedBadge.style.position = 'absolute'; + injectedBadge.style.left = '1rem'; + injectedBadge.style.bottom = '5.5rem'; + injectedBadge.style.zIndex = '20'; + injectedBadge.style.border = '1px solid hsl(var(--border))'; + injectedBadge.style.borderRadius = '0.5rem'; + injectedBadge.style.padding = '0.35rem 0.5rem'; + injectedBadge.style.background = 'hsl(var(--card))'; + injectedBadge.style.color = 'hsl(var(--foreground))'; + injectedBadge.style.fontSize = '0.75rem'; + context.subscriptions.push(api.ui.mountElement('chat-owned-badge', { + element: injectedBadge, + target: 'app-chat-messages' + })); + + context.subscriptions.push(api.events.subscribeServer({ eventName: 'e2e:server', handler: () => {} })); + context.subscriptions.push(api.events.subscribeP2p({ eventName: 'e2e:p2p', handler: () => {} })); + + api.storage.set('coverage', { ok: true }); + api.storage.get('coverage'); + await api.serverData.write('coverage', { ok: true }); + await api.serverData.read('coverage'); + + api.profile.update({ + description: 'Updated by E2E plugin', + displayName: `${currentUser?.displayName || 'E2E Plugin User'} Plugin Renamed` + }); + api.profile.updateAvatar({ + avatarHash: 'e2e-plugin-avatar', + avatarMime: 'image/svg+xml', + avatarUrl: '/plugins/e2e-all-api/icon.svg' + }); + + api.users.getCurrent(); + api.users.list(); + api.users.readMembers(); + api.users.setRole(pluginUserId, 'member'); + api.users.kick(pluginUserId); + api.users.ban(pluginUserId, 'E2E coverage'); + + api.roles.list(); + api.roles.setAssignments([]); + + api.channels.list(); + api.channels.addAudioChannel({ id: 'e2e-audio', name: 'E2E Audio', position: 90 }); + api.channels.addVideoChannel({ id: 'e2e-video', name: 'E2E Video', position: 91 }); + api.channels.select('general'); + api.channels.rename('e2e-audio', 'E2E Audio Renamed'); + + api.server.getCurrent(); + api.server.updatePermissions({ allowVoice: true }); + api.server.updateSettings({ + name: api.server.getCurrent()?.name, + topic: 'Updated by E2E plugin' + }); + + api.messages.readCurrent(); + if (shouldMutateChat) { + const sentMessage = api.messages.send(originalMessage); + + api.messages.edit(sentMessage.id, editedMessage); + + const removableMessage = api.messages.send(deletedMessage); + + api.messages.delete(removableMessage.id); + api.messages.send(embedMessage); + } + + api.messages.sendAsPluginUser({ + content: 'Plugin bot message from all-api fixture', + pluginUserId + }); + api.messages.moderateDelete('missing-message-id'); + api.messages.sync(api.messages.readCurrent()); + + api.p2p.connectedPeers(); + api.p2p.broadcastData('e2e:p2p', { ok: true }); + api.p2p.sendData('missing-peer', 'e2e:p2p', { ok: true }); + api.events.publishServer('e2e:server', { ok: true }); + api.events.publishP2p('e2e:p2p', { ok: true }); + + api.media.setOutputVolume(0.8); + api.media.setInputVolume(0.8); + await api.media.playAudioClip({ url: tinyWave, volume: 0 }).catch((error) => api.logger.warn('audio clip rejected', String(error))); + await api.media.addCustomVideoStream({ label: 'e2e-video', stream: new MediaStream() }); + + const audioContext = new AudioContext(); + const destination = audioContext.createMediaStreamDestination(); + + await api.media.addCustomAudioStream({ label: 'e2e-audio', stream: destination.stream }).catch((error) => api.logger.warn('audio stream rejected', String(error))); + await audioContext.close(); + + api.storage.remove('coverage'); + await api.serverData.remove('coverage'); + api.logger.info('all-api plugin completed'); +} + +export function ready(context) { + context.api.logger.info('all-api plugin ready'); +} + +export function deactivate(context) { + context.api.logger.info('all-api plugin deactivated'); +} + +function openSoundboardModal(api, pluginUserId) { + document.querySelector('[data-testid="e2e-soundboard-modal"]')?.remove(); + + const overlay = document.createElement('div'); + + overlay.dataset.testid = 'e2e-soundboard-modal'; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.setAttribute('aria-label', 'E2E Soundboard'); + overlay.style.position = 'fixed'; + overlay.style.inset = '0'; + overlay.style.zIndex = '9999'; + overlay.style.display = 'grid'; + overlay.style.placeItems = 'center'; + overlay.style.background = 'rgb(0 0 0 / 0.45)'; + + const panel = document.createElement('section'); + + panel.style.width = 'min(24rem, calc(100vw - 2rem))'; + panel.style.border = '1px solid hsl(var(--border))'; + panel.style.borderRadius = '0.5rem'; + panel.style.padding = '1rem'; + panel.style.color = 'hsl(var(--foreground))'; + panel.style.background = 'hsl(var(--card))'; + panel.style.boxShadow = '0 1.25rem 3rem rgb(0 0 0 / 0.25)'; + + const title = document.createElement('h2'); + + title.textContent = 'E2E Soundboard'; + title.style.margin = '0 0 0.75rem'; + title.style.fontSize = '1rem'; + + const status = document.createElement('p'); + + status.dataset.testid = 'e2e-soundboard-status'; + status.textContent = 'Ready to play to voice channel'; + status.style.margin = '0 0 1rem'; + status.style.color = 'hsl(var(--muted-foreground))'; + status.style.fontSize = '0.875rem'; + + const actions = document.createElement('div'); + + actions.style.display = 'flex'; + actions.style.gap = '0.5rem'; + actions.style.justifyContent = 'flex-end'; + + const closeButton = document.createElement('button'); + + closeButton.type = 'button'; + closeButton.textContent = 'Close'; + closeButton.style.border = '1px solid hsl(var(--border))'; + closeButton.style.borderRadius = '0.375rem'; + closeButton.style.padding = '0.5rem 0.75rem'; + closeButton.style.background = 'transparent'; + closeButton.style.color = 'hsl(var(--foreground))'; + closeButton.addEventListener('click', () => overlay.remove()); + + const playButton = document.createElement('button'); + + playButton.type = 'button'; + playButton.textContent = 'Play airhorn to voice'; + playButton.style.border = '0'; + playButton.style.borderRadius = '0.375rem'; + playButton.style.padding = '0.5rem 0.75rem'; + playButton.style.background = 'hsl(var(--primary))'; + playButton.style.color = 'hsl(var(--primary-foreground))'; + playButton.addEventListener('click', async () => { + playButton.disabled = true; + status.textContent = 'Playing Airhorn to voice channel'; + + try { + await playSoundboardClipToVoice(api); + api.p2p.broadcastData('e2e:p2p', { sound: 'airhorn', source: 'soundboard' }); + api.events.publishP2p('e2e:p2p', { sound: 'airhorn', source: 'soundboard' }); + api.messages.sendAsPluginUser({ content: soundboardPlayedMessage, pluginUserId }); + api.logger.info('soundboard played to voice channel'); + status.textContent = soundboardPlayedMessage; + } catch (error) { + status.textContent = error instanceof Error ? error.message : 'Soundboard playback failed'; + api.logger.warn('soundboard playback failed', String(error)); + } finally { + playButton.disabled = false; + } + }); + + actions.append(closeButton, playButton); + panel.append(title, status, actions); + overlay.append(panel); + api.ui.mountElement('soundboard-modal', { + element: overlay, + target: 'body' + }); +} + +async function playSoundboardClipToVoice(api) { + const audioContext = new AudioContext(); + const oscillator = audioContext.createOscillator(); + const gain = audioContext.createGain(); + const destination = audioContext.createMediaStreamDestination(); + + oscillator.type = 'square'; + oscillator.frequency.value = 330; + gain.gain.value = 0.08; + oscillator.connect(gain); + gain.connect(destination); + oscillator.start(); + + await api.media.addCustomAudioStream({ label: 'e2e-soundboard-airhorn', stream: destination.stream }); + await api.media.playAudioClip({ url: tinyWave, volume: 0 }).catch((error) => api.logger.warn('soundboard preview rejected', String(error))); + await new Promise((resolve) => setTimeout(resolve, 150)); + oscillator.stop(); + await audioContext.close(); +} diff --git a/toju-app/public/plugins/e2e-all-api/toju.plugin.json b/toju-app/public/plugins/e2e-all-api/toju.plugin.json new file mode 100644 index 0000000..fc58135 --- /dev/null +++ b/toju-app/public/plugins/e2e-all-api/toju.plugin.json @@ -0,0 +1,99 @@ +{ + "schemaVersion": 1, + "id": "e2e.all-api-plugin", + "title": "E2E All API Plugin", + "description": "Calls every public Toju plugin API surface for user-facing Playwright coverage.", + "version": "1.0.0", + "kind": "client", + "apiVersion": "1.0.0", + "compatibility": { + "minimumTojuVersion": "1.0.0", + "verifiedTojuVersion": "1.0.0" + }, + "entrypoint": "./main.js", + "authors": [ + { + "name": "MetoYou Tests", + "url": "https://git.azaaxin.com/myxelium/Toju" + } + ], + "homepage": "https://git.azaaxin.com/myxelium/Toju", + "readme": "./README.md", + "capabilities": [ + "profile.read", + "profile.write", + "users.read", + "users.manage", + "roles.read", + "roles.manage", + "messages.read", + "messages.send", + "messages.editOwn", + "messages.deleteOwn", + "messages.moderate", + "messages.sync", + "channels.read", + "channels.manage", + "server.read", + "server.manage", + "p2p.data", + "p2p.media", + "media.playAudio", + "media.addAudioStream", + "media.addVideoStream", + "audio.volume", + "audio.effects", + "ui.settings", + "ui.pages", + "ui.sidePanel", + "ui.channelsSection", + "ui.embeds", + "ui.dom", + "storage.local", + "storage.serverData.read", + "storage.serverData.write", + "events.server.publish", + "events.server.subscribe", + "events.p2p.publish", + "events.p2p.subscribe" + ], + "events": [ + { + "eventName": "e2e:server", + "direction": "serverRelay", + "scope": "server", + "maxPayloadBytes": 2048 + }, + { + "eventName": "e2e:p2p", + "direction": "p2pHint", + "scope": "user", + "maxPayloadBytes": 2048 + } + ], + "data": [ + { + "key": "coverage", + "scope": "server", + "storage": "serverData" + } + ], + "settings": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": true + } + } + }, + "ui": { + "settingsPages": ["coverage"], + "sidePanels": ["coverage"], + "channelSections": ["coverage"] + }, + "pluginUser": { + "displayName": "E2E Plugin Bot", + "label": "All API fixture" + } +} diff --git a/toju-app/public/plugins/e2e-plugin-source.json b/toju-app/public/plugins/e2e-plugin-source.json new file mode 100644 index 0000000..ad41cfe --- /dev/null +++ b/toju-app/public/plugins/e2e-plugin-source.json @@ -0,0 +1,17 @@ +{ + "title": "MetoYou E2E Plugin Source", + "plugins": [ + { + "id": "e2e.all-api-plugin", + "title": "E2E All API Plugin", + "description": "Test plugin that calls every public Toju plugin API surface.", + "version": "1.0.0", + "author": "MetoYou Tests", + "image": "./e2e-all-api/icon.svg", + "github": "https://git.azaaxin.com/myxelium/Toju", + "homepage": "https://git.azaaxin.com/myxelium/Toju", + "install": "./e2e-all-api/toju.plugin.json", + "readme": "./e2e-all-api/README.md" + } + ] +} diff --git a/toju-app/src/app/app.routes.ts b/toju-app/src/app/app.routes.ts index 3c54a41..9d5ac29 100644 --- a/toju-app/src/app/app.routes.ts +++ b/toju-app/src/app/app.routes.ts @@ -48,5 +48,15 @@ export const routes: Routes = [ path: 'settings', loadComponent: () => import('./features/settings/settings.component').then((module) => module.SettingsComponent) + }, + { + path: 'plugin-store', + loadComponent: () => + import('./domains/plugins/feature/plugin-store/plugin-store.component').then((module) => module.PluginStoreComponent) + }, + { + path: 'plugins/:pluginId/:pageId', + loadComponent: () => + import('./domains/plugins/feature/plugin-page-host/plugin-page-host.component').then((module) => module.PluginPageHostComponent) } ]; diff --git a/toju-app/src/app/core/models/index.ts b/toju-app/src/app/core/models/index.ts index 8f58525..663739e 100644 --- a/toju-app/src/app/core/models/index.ts +++ b/toju-app/src/app/core/models/index.ts @@ -49,4 +49,20 @@ export type { ChatAttachmentMeta } from '../../shared-kernel'; +export type { + PluginCapabilityId, + PluginDataChangedMessage, + PluginErrorMessage, + PluginEventDefinitionSummary, + PluginEventDirection, + PluginEventEnvelope, + PluginEventScope, + PluginRequirementStatus, + PluginRequirementSummary, + PluginRequirementsChangedMessage, + PluginRequirementsMessage, + PluginRequirementsSnapshot, + TojuPluginManifest +} from '../../shared-kernel'; + export type { ServerInfo } from '../../domains/server-directory'; diff --git a/toju-app/src/app/core/platform/electron/electron-api.models.ts b/toju-app/src/app/core/platform/electron/electron-api.models.ts index 61cc2cc..5e9fbdb 100644 --- a/toju-app/src/app/core/platform/electron/electron-api.models.ts +++ b/toju-app/src/app/core/platform/electron/electron-api.models.ts @@ -124,6 +124,28 @@ export interface SavedThemeFileDescriptor { path: string; } +export interface LocalPluginManifestDescriptor { + discoveredAt: number; + entrypointPath?: string; + pluginRootUrl: string; + manifest: unknown; + manifestPath: string; + pluginRoot: string; + readmePath?: string; +} + +export interface LocalPluginDiscoveryError { + manifestPath?: string; + message: string; + pluginRoot?: string; +} + +export interface LocalPluginDiscoveryResult { + errors: LocalPluginDiscoveryError[]; + plugins: LocalPluginManifestDescriptor[]; + pluginsPath: string; +} + export interface ExportUserDataResult { cancelled: boolean; exported: boolean; @@ -189,6 +211,8 @@ export interface ElectronApi { importUserData: () => Promise; eraseUserData: () => Promise; getSavedThemesPath: () => Promise; + getLocalPluginsPath: () => Promise; + listLocalPluginManifests: () => Promise; listSavedThemes: () => Promise; readSavedTheme: (fileName: string) => Promise; writeSavedTheme: (fileName: string, text: string) => Promise; diff --git a/toju-app/src/app/core/services/settings-modal.service.ts b/toju-app/src/app/core/services/settings-modal.service.ts index d9b8563..6137ce8 100644 --- a/toju-app/src/app/core/services/settings-modal.service.ts +++ b/toju-app/src/app/core/services/settings-modal.service.ts @@ -2,6 +2,7 @@ import { Injectable, signal } from '@angular/core'; export type SettingsPage = | 'general' + | 'plugins' | 'theme' | 'network' | 'notifications' diff --git a/toju-app/src/app/domains/README.md b/toju-app/src/app/domains/README.md index b2e3abf..d43ae82 100644 --- a/toju-app/src/app/domains/README.md +++ b/toju-app/src/app/domains/README.md @@ -15,6 +15,7 @@ infrastructure adapters and UI. | **direct-message** | One-to-one WebRTC messages, offline queueing, delivery state, and friends | `DirectMessageService`, `FriendService` | | **game-activity** | Local game detection, server metadata matching, P2P now-playing sync, and elapsed playtime formatting | `GameActivityService`, `formatGameActivityElapsed()` | | **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` | +| **plugins** | Client-only plugin manifests, load ordering, registry state, and signal-server support metadata | `PluginHostService`, `PluginRegistryService` | | **profile-avatar** | Profile picture upload, crop/zoom editing, processing, local persistence, and P2P avatar sync | `ProfileAvatarFacade` | | **screen-share** | Source picker, quality presets | `ScreenShareFacade` | | **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` | @@ -32,6 +33,7 @@ The larger domains also keep longer design notes in their own folders: - [chat/README.md](chat/README.md) - [direct-message/README.md](direct-message/README.md) - [notifications/README.md](notifications/README.md) +- [plugins/README.md](plugins/README.md) - [profile-avatar/README.md](profile-avatar/README.md) - [screen-share/README.md](screen-share/README.md) - [server-directory/README.md](server-directory/README.md) diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.html index 81e0655..ad35e28 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.html +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.html @@ -141,6 +141,20 @@ (drop)="onDrop($event)" >
+ @for (record of pluginComposerActions(); track record.id) { + + } + @if (klipyEnabled()) { +
+

Plugins

+

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

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

{{ item.label }}

+

{{ item.value }}

+
+ } +
+ +
+

Conflict diagnostics

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

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

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

+ No server plugin requirements for the current room. +

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

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

+

{{ comparison.pluginId }}

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

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

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

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

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

{{ comparison.requirement.reason }}

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

{{ plugin.manifest.title }} settings

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

{{ page.contribution.label }}

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

This plugin does not declare a settings schema.

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

{{ plugin.manifest.title }}

+

{{ plugin.manifest.description }}

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

No plugins installed.

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

No logs for selected plugin.

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

{{ log.message }}

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

No plugins installed.

+

Use Store tab or local plugin folder discovery.

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

{{ entry.manifest.title }}

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

{{ entry.manifest.description }}

+

{{ entry.manifest.id }}

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

{{ entry.error }}

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

{{ pageRecord.pluginId }}

+

{{ pageRecord.contribution.label }}

+
+ +
+
+ } @else { +
+

Plugin page unavailable

+

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

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

Plugin Store

+

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

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

{{ sourceError() }}

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

{{ actionError() }}

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

{{ readmeError() }}

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

{{ plugin.title }}

+

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

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

{{ plugin.description }}

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

No plugins found

+

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

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

Plugins

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

{{ record.contribution.label }}

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

Settings

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

Plugins

-

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

+

{{ managerTitle() }}

+

{{ managerDescription() }}

@@ -331,8 +331,8 @@ name="lucidePackage" size="28" /> -

No plugins installed.

-

Use Store tab or local plugin folder discovery.

+

{{ emptyTitle() }}

+

{{ emptyBody() }}

} @else { @for (entry of entries(); track trackEntry($index, entry)) { @@ -370,6 +370,18 @@ /> {{ entry.enabled ? 'Disable' : 'Enable' }} + + + +
+ + + + +
+
+

Capabilities

+ {{ dialog.manifest.capabilities?.length ?? 0 }} +
+ + @if ((dialog.manifest.capabilities?.length ?? 0) > 0) { + @for (capability of dialog.manifest.capabilities; track trackInstallCapability($index, capability)) { + + } + } @else { +

This plugin requests no capabilities.

+ } +
+ + @if (serverInstallError()) { +

{{ serverInstallError() }}

+ } +
+ +
+ + +
+ + } diff --git a/toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.scss b/toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.scss index 575dc83..0444bb1 100644 --- a/toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.scss +++ b/toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.scss @@ -262,6 +262,7 @@ ng-icon { .plugin-store__source-row { gap: 0.375rem; + min-width: 0; } .plugin-store__source-filter, @@ -401,7 +402,6 @@ ng-icon { font-size: 0.72rem; font-weight: 700; text-transform: uppercase; - letter-spacing: 0.08em; } .plugin-store__readme pre { @@ -414,6 +414,123 @@ ng-icon { 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; diff --git a/toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.ts b/toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.ts index 3b7cb00..e0c1898 100644 --- a/toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.ts +++ b/toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.ts @@ -9,6 +9,7 @@ import { } 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, @@ -24,9 +25,25 @@ import { } 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, @@ -54,6 +71,28 @@ import type { PluginStoreEntry, PluginStoreReadme } from '../../domain/models/pl }) 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(() => { @@ -96,6 +135,11 @@ export class PluginStoreComponent implements OnInit { readonly readme = signal(null); readonly readmeError = signal(null); readonly readmeLoadingPluginId = signal(null); + readonly serverInstallDialog = signal(null); + readonly selectedCapabilityIds = signal>(new Set()); + readonly serverInstallOptional = signal(false); + readonly serverInstallError = signal(null); + readonly serverInstallBusy = signal(false); private destroyed = false; private readonly destroyRef = inject(DestroyRef); @@ -181,8 +225,10 @@ export class PluginStoreComponent implements OnInit { this.actionBusyPluginId.set(plugin.id); try { - if (action === 'Uninstall') { - this.store.uninstallPlugin(plugin.id); + 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); } @@ -229,13 +275,118 @@ export class PluginStoreComponent implements OnInit { this.readmeError.set(null); } + async openServerInstallDialog(plugin: PluginStoreEntry): Promise { + 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 { + 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 { + const currentRoomId = this.currentRoom()?.id; + await this.router.navigateByUrl(this.getReturnUrl()); - this.settingsModal.open('plugins'); + this.settingsModal.open(this.store.hasActiveServerInstallScope() ? 'serverPlugins' : 'plugins', currentRoomId); } selectSource(sourceUrl: string | null): void { @@ -262,9 +413,18 @@ export class PluginStoreComponent implements OnInit { 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); @@ -272,6 +432,10 @@ export class PluginStoreComponent implements OnInit { return 'lucideTrash2'; } + if (action === 'Remove from Server') { + return 'lucideTrash2'; + } + return 'lucidePlus'; } @@ -287,6 +451,24 @@ export class PluginStoreComponent implements OnInit { } } + 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, @@ -307,4 +489,14 @@ export class PluginStoreComponent implements OnInit { 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'); + } } diff --git a/toju-app/src/app/domains/plugins/index.ts b/toju-app/src/app/domains/plugins/index.ts index 102b374..a4e0edb 100644 --- a/toju-app/src/app/domains/plugins/index.ts +++ b/toju-app/src/app/domains/plugins/index.ts @@ -1,7 +1,9 @@ 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'; diff --git a/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html b/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html index 80e9027..b81de82 100644 --- a/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html +++ b/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html @@ -34,7 +34,7 @@ @if (panelMode() === 'channels') { -
+
0) {
@for (record of pluginSidePanels(); track record.id) { -
+

{{ record.contribution.label }}

diff --git a/toju-app/src/app/features/settings/settings-modal/settings-modal.component.html b/toju-app/src/app/features/settings/settings-modal/settings-modal.component.html index 06bc181..0e4bab6 100644 --- a/toju-app/src/app/features/settings/settings-modal/settings-modal.component.html +++ b/toju-app/src/app/features/settings/settings-modal/settings-modal.component.html @@ -136,7 +136,7 @@ General } @case ('plugins') { - Plugins + Client Plugins } @case ('network') { Network @@ -162,6 +162,9 @@ @case ('server') { Server Settings } + @case ('serverPlugins') { + Server Plugins + } @case ('members') { Members } @@ -197,7 +200,10 @@ } @case ('plugins') { - + } @case ('network') { @@ -306,6 +312,21 @@ [isAdmin]="isSelectedServerOwner()" /> } + @case ('serverPlugins') { + @if (currentRoom()) { + + } @else { +
+

Open this server to manage plugins

+

+ Server plugin installs and activation are shown for the currently open chat server. Select or open {{ selectedServer()?.name || 'this server' }} in the app, then return here. +

+
+ } + } @case ('members') { { - const viewedRoom = this.currentRoom()?.id === room.id ? (this.currentRoom() ?? room) : room; + 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) => { + const viewedRoom = currentRoom?.id === room.id ? currentRoom : room; const role = resolveLegacyRole(viewedRoom, user); return ( @@ -162,11 +170,12 @@ export class SettingsModalComponent { selectedServerId = signal(null); selectedServer = computed(() => { const id = this.selectedServerId(); + const currentRoom = this.currentRoom(); if (!id) return null; - return this.manageableRooms().find((room) => room.id === id) ?? null; + return currentRoom?.id === id ? currentRoom : (this.manageableRooms().find((room) => room.id === id) ?? null); }); showServerTabs = computed(() => { @@ -238,6 +247,13 @@ export class SettingsModalComponent { return this.selectedServerRole() === 'host'; }); + isSelectedServerCurrent = computed(() => { + const selectedServerId = this.selectedServerId(); + const currentRoomId = this.currentRoom()?.id ?? null; + + return !!selectedServerId && selectedServerId === currentRoomId; + }); + animating = signal(false); showThirdPartyLicenses = signal(false); diff --git a/toju-app/src/app/shared-kernel/chat-events.ts b/toju-app/src/app/shared-kernel/chat-events.ts index f249248..a800452 100644 --- a/toju-app/src/app/shared-kernel/chat-events.ts +++ b/toju-app/src/app/shared-kernel/chat-events.ts @@ -87,6 +87,7 @@ export interface ChatEventBase { directMessage?: DirectMessageEventPayload; directMessageStatus?: DirectMessageStatusEventPayload; directMessageMutation?: DirectMessageMutationEventPayload; + pluginMessage?: unknown; } export interface ChatMessageEvent extends ChatEventBase { @@ -390,6 +391,11 @@ export interface DirectMessageMutationPeerEvent extends ChatEventBase { directMessageMutation: DirectMessageMutationEventPayload; } +export interface PluginMessageBusPeerEvent extends ChatEventBase { + type: 'plugin-message-bus'; + pluginMessage: unknown; +} + /** Discriminated union of all P2P chat events. Narrow via `event.type`. */ export type ChatEvent = | ChatMessageEvent @@ -442,7 +448,8 @@ export type ChatEvent = | ChannelsUpdateEvent | DirectMessagePeerEvent | DirectMessageStatusPeerEvent - | DirectMessageMutationPeerEvent; + | DirectMessageMutationPeerEvent + | PluginMessageBusPeerEvent; /** All possible `type` values, derived from the union. */ export type ChatEventType = ChatEvent['type']; diff --git a/toju-app/src/app/shared-kernel/plugin-system.contracts.ts b/toju-app/src/app/shared-kernel/plugin-system.contracts.ts index 8d54783..af0d7ac 100644 --- a/toju-app/src/app/shared-kernel/plugin-system.contracts.ts +++ b/toju-app/src/app/shared-kernel/plugin-system.contracts.ts @@ -24,6 +24,7 @@ export const PLUGIN_EVENT_SCOPES = [ ] as const; export type PluginEventScope = typeof PLUGIN_EVENT_SCOPES[number]; +export type TojuPluginInstallScope = 'client' | 'server'; export const PLUGIN_CAPABILITIES = [ 'profile.read', @@ -67,8 +68,11 @@ export const PLUGIN_CAPABILITIES = [ export type PluginCapabilityId = typeof PLUGIN_CAPABILITIES[number]; export interface PluginRequirementSummary { + installUrl?: string; + manifest?: TojuPluginManifest; pluginId: string; reason?: string; + sourceUrl?: string; status: PluginRequirementStatus; updatedAt: number; versionRange?: string; @@ -186,6 +190,7 @@ export interface TojuPluginManifest { requires?: { id: string; versionRange?: string }[]; }; schemaVersion: 1; + scope?: TojuPluginInstallScope; settings?: Record; title: string; ui?: Record; From d261bac0ed092cced467639040cb0a087dbd87bc Mon Sep 17 00:00:00 2001 From: Myx Date: Wed, 29 Apr 2026 15:24:56 +0200 Subject: [PATCH 3/9] feat: plugins v1.7 --- electron/api/auth-store.ts | 70 + electron/api/docs-html.ts | 122 + electron/api/http-helpers.ts | 107 + electron/api/index.ts | 8 + electron/api/local-api-server.ts | 276 ++ electron/api/openapi.ts | 241 ++ electron/api/router.ts | 294 ++ electron/app/lifecycle.ts | 11 + .../commands/handlers/deletePluginData.ts | 2 +- .../cqrs/commands/handlers/savePluginData.ts | 2 +- .../cqrs/queries/handlers/getPluginData.ts | 2 +- electron/desktop-settings.ts | 82 +- electron/entities/PluginDataEntity.ts | 2 +- electron/ipc/system.ts | 22 + .../migrations/1000000000008-AddPluginData.ts | 2 +- electron/preload.ts | 29 + package-lock.json | 2918 ++++++++++++++++- package.json | 5 + server/data/metoyou.sqlite | Bin 249856 -> 253952 bytes ...00000000008-ServerPluginInstallMetadata.ts | 2 +- toju-app/src/app/app.ts | 3 + .../platform/electron/electron-api.models.ts | 25 + .../core/services/settings-modal.service.ts | 1 + .../chat-message-item.component.html | 23 +- toju-app/src/app/domains/plugins/README.md | 4 +- .../services/plugin-bootstrap.service.ts | 9 + .../services/plugin-client-api.service.ts | 36 +- .../services/plugin-host.service.ts | 136 +- .../services/plugin-message-bus.service.ts | 4 +- .../plugin-requirement-state.service.ts | 20 +- .../services/plugin-store.service.spec.ts | 47 +- .../services/plugin-store.service.ts | 382 ++- .../domain/models/plugin-store.models.ts | 4 + .../plugin-manager.component.ts | 3 +- .../plugin-store/plugin-store.component.html | 388 ++- .../plugin-store/plugin-store.component.scss | 607 ---- .../plugin-store/plugin-store.component.ts | 143 +- toju-app/src/app/domains/plugins/index.ts | 1 + .../rooms-side-panel.component.html | 14 +- .../local-api-settings.component.html | 177 + .../local-api-settings.component.ts | 237 ++ .../settings-modal.component.html | 11 +- .../settings-modal.component.ts | 11 + .../shell/title-bar/title-bar.component.html | 1 - .../shared-kernel/plugin-system.contracts.ts | 4 + 45 files changed, 5621 insertions(+), 867 deletions(-) create mode 100644 electron/api/auth-store.ts create mode 100644 electron/api/docs-html.ts create mode 100644 electron/api/http-helpers.ts create mode 100644 electron/api/index.ts create mode 100644 electron/api/local-api-server.ts create mode 100644 electron/api/openapi.ts create mode 100644 electron/api/router.ts create mode 100644 toju-app/src/app/domains/plugins/application/services/plugin-bootstrap.service.ts delete mode 100644 toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.scss create mode 100644 toju-app/src/app/features/settings/settings-modal/local-api-settings/local-api-settings.component.html create mode 100644 toju-app/src/app/features/settings/settings-modal/local-api-settings/local-api-settings.component.ts diff --git a/electron/api/auth-store.ts b/electron/api/auth-store.ts new file mode 100644 index 0000000..36d6878 --- /dev/null +++ b/electron/api/auth-store.ts @@ -0,0 +1,70 @@ +import { randomBytes } from 'crypto'; + +export interface IssuedToken { + token: string; + userId: string; + username: string; + displayName: string; + signalingServerUrl: string; + issuedAt: number; + expiresAt: number; +} + +const TOKEN_TTL_MS = 24 * 60 * 60 * 1000; + +const tokens = new Map(); + +export function issueToken(params: { + userId: string; + username: string; + displayName: string; + signalingServerUrl: string; +}): IssuedToken { + const token = randomBytes(32).toString('hex'); + const issuedAt = Date.now(); + const issued: IssuedToken = { + token, + issuedAt, + expiresAt: issuedAt + TOKEN_TTL_MS, + userId: params.userId, + username: params.username, + displayName: params.displayName, + signalingServerUrl: params.signalingServerUrl + }; + + tokens.set(token, issued); + return issued; +} + +export function consumeToken(token: string): IssuedToken | null { + const issued = tokens.get(token); + + if (!issued) { + return null; + } + + if (issued.expiresAt < Date.now()) { + tokens.delete(token); + return null; + } + + return issued; +} + +export function revokeToken(token: string): void { + tokens.delete(token); +} + +export function clearAllTokens(): void { + tokens.clear(); +} + +export function pruneExpiredTokens(): void { + const now = Date.now(); + + for (const [token, issued] of tokens) { + if (issued.expiresAt < now) { + tokens.delete(token); + } + } +} diff --git a/electron/api/docs-html.ts b/electron/api/docs-html.ts new file mode 100644 index 0000000..6b7bf0d --- /dev/null +++ b/electron/api/docs-html.ts @@ -0,0 +1,122 @@ +import { promises as fs } from 'fs'; +import * as path from 'path'; + +function getScalarBundleCandidates(): string[] { + const processWithResources = process as NodeJS.Process & { resourcesPath?: string }; + const candidates: string[] = []; + + if (processWithResources.resourcesPath) { + candidates.push(path.join(processWithResources.resourcesPath, 'scalar', 'api-reference.js')); + } + + candidates.push(path.join(process.cwd(), 'node_modules', '@scalar', 'api-reference', 'dist', 'browser', 'standalone.js')); + + try { + candidates.push(path.join(path.dirname(require.resolve('@scalar/api-reference')), 'browser', 'standalone.js')); + } catch { + // ignore; the packaged app path above is the production path + } + + return candidates; +} + +export async function getScalarApiReferenceBundlePath(): Promise { + for (const candidate of getScalarBundleCandidates()) { + try { + await fs.access(candidate); + return candidate; + } catch { + // try the next candidate + } + } + + return null; +} + +export function getDocsHtml(specUrl: string): string { + const scalarConfig = { + url: specUrl, + theme: 'default', + layout: 'modern', + proxyUrl: '', + telemetry: false, + persistAuth: false, + showDeveloperTools: 'never', + hideDownloadButton: false, + hideTestRequestButton: false, + hideClientButton: false, + externalUrls: { + dashboardUrl: '', + registryUrl: '', + proxyUrl: '', + apiBaseUrl: '' + }, + agent: { + disabled: true, + hideAddApi: true + }, + mcp: { + disabled: true + } + }; + + return ` + + + + + + MetoYou Local API + + + +
+ + + + +`; +} diff --git a/electron/api/http-helpers.ts b/electron/api/http-helpers.ts new file mode 100644 index 0000000..926a6bc --- /dev/null +++ b/electron/api/http-helpers.ts @@ -0,0 +1,107 @@ +import { IncomingMessage, ServerResponse } from 'http'; + +export interface RequestContext { + method: string; + url: URL; + pathname: string; + headers: IncomingMessage['headers']; + remoteAddress: string; + bearerToken: string | null; +} + +const MAX_BODY_BYTES = 1 * 1024 * 1024; // 1 MiB + +export function getBearerToken(headers: IncomingMessage['headers']): string | null { + const raw = headers.authorization; + + if (typeof raw !== 'string') { + return null; + } + + const trimmed = raw.trim(); + + if (!/^bearer\s+/iu.test(trimmed)) { + return null; + } + + const token = trimmed.replace(/^bearer\s+/iu, '').trim(); + + return token.length > 0 ? token : null; +} + +export async function readJsonBody(req: IncomingMessage): Promise { + const length = Number(req.headers['content-length'] ?? 0); + + if (length > MAX_BODY_BYTES) { + throw new HttpError(413, 'Request body too large', 'BODY_TOO_LARGE'); + } + + const chunks: Buffer[] = []; + let received = 0; + + for await (const chunk of req) { + const buffer = chunk instanceof Buffer ? chunk : Buffer.from(chunk as string); + + received += buffer.length; + + if (received > MAX_BODY_BYTES) { + throw new HttpError(413, 'Request body too large', 'BODY_TOO_LARGE'); + } + + chunks.push(buffer); + } + + if (chunks.length === 0) { + return {} as T; + } + + const raw = Buffer.concat(chunks).toString('utf8'); + + try { + return JSON.parse(raw) as T; + } catch { + throw new HttpError(400, 'Invalid JSON body', 'INVALID_JSON'); + } +} + +export function sendJson(res: ServerResponse, status: number, payload: unknown): void { + if (!res.headersSent) { + res.statusCode = status; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + } + + res.end(JSON.stringify(payload)); +} + +export function sendText(res: ServerResponse, status: number, text: string, contentType = 'text/plain; charset=utf-8'): void { + if (!res.headersSent) { + res.statusCode = status; + res.setHeader('Content-Type', contentType); + res.setHeader('Cache-Control', 'no-store'); + } + + res.end(text); +} + +export class HttpError extends Error { + readonly status: number; + readonly code: string; + + constructor(status: number, message: string, code: string) { + super(message); + this.status = status; + this.code = code; + } +} + +export function sendError(res: ServerResponse, error: unknown): void { + if (error instanceof HttpError) { + sendJson(res, error.status, { error: error.message, errorCode: error.code }); + return; + } + + const message = error instanceof Error ? error.message : 'Internal server error'; + + sendJson(res, 500, { error: message, errorCode: 'INTERNAL_ERROR' }); +} diff --git a/electron/api/index.ts b/electron/api/index.ts new file mode 100644 index 0000000..24b03bd --- /dev/null +++ b/electron/api/index.ts @@ -0,0 +1,8 @@ +export { + applyLocalApiSettings, + getLocalApiSnapshot, + startLocalApiServer, + stopLocalApiServer, + type LocalApiSnapshot, + type LocalApiStatus +} from './local-api-server'; diff --git a/electron/api/local-api-server.ts b/electron/api/local-api-server.ts new file mode 100644 index 0000000..8c6b1c6 --- /dev/null +++ b/electron/api/local-api-server.ts @@ -0,0 +1,276 @@ +import { createServer, IncomingMessage, Server, ServerResponse } from 'http'; +import { createReadStream } from 'fs'; +import { AddressInfo } from 'net'; +import { pipeline } from 'stream/promises'; +import { getDataSource } from '../db/database'; +import { LocalApiSettings, readDesktopSettings } from '../desktop-settings'; +import { authenticate, matchRoute } from './router'; +import { clearAllTokens } from './auth-store'; +import { + HttpError, + RequestContext, + getBearerToken, + readJsonBody, + sendError, + sendJson, + sendText +} from './http-helpers'; + +export type LocalApiStatus = 'stopped' | 'starting' | 'running' | 'error'; + +export interface LocalApiSnapshot { + status: LocalApiStatus; + host: string | null; + port: number | null; + baseUrl: string | null; + error: string | null; + exposeOnLan: boolean; + scalarEnabled: boolean; +} + +let server: Server | null = null; +let currentStatus: LocalApiStatus = 'stopped'; +let currentBindHost: string | null = null; +let currentBindPort: number | null = null; +let currentError: string | null = null; +let activeSettings: LocalApiSettings | null = null; + +function pickBindHost(settings: LocalApiSettings): string { + return settings.exposeOnLan ? '0.0.0.0' : '127.0.0.1'; +} + +function buildBaseUrl(host: string, port: number): string { + const safeHost = host === '0.0.0.0' ? '127.0.0.1' : host; + return `http://${safeHost}:${port}`; +} + +async function sendFile(res: ServerResponse, status: number, filePath: string, contentType: string): Promise { + if (!res.headersSent) { + res.statusCode = status; + res.setHeader('Content-Type', contentType); + res.setHeader('Cache-Control', 'no-store'); + } + + await pipeline(createReadStream(filePath), res); +} + +export function getLocalApiSnapshot(): LocalApiSnapshot { + const settings = activeSettings ?? readDesktopSettings().localApi; + + return { + status: currentStatus, + host: currentBindHost, + port: currentBindPort, + baseUrl: currentBindHost && currentBindPort ? buildBaseUrl(currentBindHost, currentBindPort) : null, + error: currentError, + exposeOnLan: settings.exposeOnLan, + scalarEnabled: settings.scalarEnabled + }; +} + +async function handleRequest(req: IncomingMessage, res: ServerResponse, settings: LocalApiSettings): Promise { + // CORS for loopback origin only. Local-first; not a public API. + const origin = req.headers.origin; + const allowOrigin = origin && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/iu.test(origin) ? origin : 'null'; + + res.setHeader('Access-Control-Allow-Origin', allowOrigin); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + res.setHeader('Access-Control-Max-Age', '600'); + + if (req.method === 'OPTIONS') { + res.statusCode = 204; + res.end(); + return; + } + + let urlObj: URL; + + try { + urlObj = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); + } catch { + sendJson(res, 400, { error: 'Invalid URL', errorCode: 'INVALID_URL' }); + return; + } + + const requestContext: RequestContext = { + method: (req.method ?? 'GET').toUpperCase(), + url: urlObj, + pathname: urlObj.pathname, + headers: req.headers, + remoteAddress: req.socket.remoteAddress ?? '', + bearerToken: getBearerToken(req.headers) + }; + + const { match, methodNotAllowed } = matchRoute(requestContext.method, requestContext.pathname); + + if (!match) { + if (methodNotAllowed) { + sendJson(res, 405, { error: 'Method not allowed', errorCode: 'METHOD_NOT_ALLOWED' }); + } else { + sendJson(res, 404, { error: 'Not found', errorCode: 'NOT_FOUND' }); + } + return; + } + + if (match.requiresAuth) { + const issued = authenticate(requestContext.bearerToken); + + if (!issued) { + sendJson(res, 401, { error: 'Authentication required', errorCode: 'UNAUTHORIZED' }); + return; + } + } + + const dataSource = getDataSource(); + + if (!dataSource || !dataSource.isInitialized) { + sendJson(res, 503, { error: 'Database not initialised', errorCode: 'DB_UNAVAILABLE' }); + return; + } + + let bodyCache: unknown | undefined; + + try { + const baseUrl = buildBaseUrl(currentBindHost ?? '127.0.0.1', currentBindPort ?? settings.port); + const result = await match.handler({ + request: requestContext, + settings, + baseUrl, + dataSource, + bodyBuffer: async () => { + if (bodyCache === undefined) { + bodyCache = await readJsonBody(req); + } + return bodyCache; + } + }); + + if (result.status === 204) { + res.statusCode = 204; + res.end(); + return; + } + + if (result.filePath) { + await sendFile(res, result.status, result.filePath, result.contentType ?? 'application/octet-stream'); + return; + } + + if (result.rawBody !== undefined) { + sendText(res, result.status, result.rawBody, result.contentType ?? 'text/plain; charset=utf-8'); + return; + } + + sendJson(res, result.status, result.body); + } catch (error) { + if (!(error instanceof HttpError)) { + console.error('[LocalApi] Request handler error:', error); + } + sendError(res, error); + } +} + +export interface StartResult { + ok: boolean; + snapshot: LocalApiSnapshot; +} + +export async function startLocalApiServer(settings: LocalApiSettings): Promise { + if (server) { + await stopLocalApiServer(); + } + + activeSettings = { ...settings, allowedSignalingServers: [...settings.allowedSignalingServers] }; + currentStatus = 'starting'; + currentError = null; + currentBindHost = pickBindHost(settings); + currentBindPort = settings.port; + + const httpServer = createServer((req, res) => { + void handleRequest(req, res, activeSettings!).catch((error) => { + console.error('[LocalApi] Unhandled request error:', error); + try { + sendError(res, error); + } catch { + // ignore + } + }); + }); + + return await new Promise((resolve) => { + httpServer.once('error', (error) => { + currentStatus = 'error'; + currentError = (error as Error).message; + currentBindPort = null; + server = null; + activeSettings = null; + console.error('[LocalApi] Failed to start:', error); + resolve({ ok: false, snapshot: getLocalApiSnapshot() }); + }); + + httpServer.listen(settings.port, pickBindHost(settings), () => { + const address = httpServer.address() as AddressInfo | null; + + server = httpServer; + currentStatus = 'running'; + currentBindPort = address?.port ?? settings.port; + currentError = null; + console.log(`[LocalApi] Listening on http://${currentBindHost}:${currentBindPort}`); + resolve({ ok: true, snapshot: getLocalApiSnapshot() }); + }); + }); +} + +export async function stopLocalApiServer(): Promise { + const httpServer = server; + + if (!httpServer) { + currentStatus = 'stopped'; + currentBindHost = null; + currentBindPort = null; + activeSettings = null; + return getLocalApiSnapshot(); + } + + await new Promise((resolve) => { + httpServer.close(() => resolve()); + // close() waits for connections; force-close keep-alives so it returns promptly. + httpServer.closeAllConnections?.(); + }); + + server = null; + currentStatus = 'stopped'; + currentBindHost = null; + currentBindPort = null; + currentError = null; + activeSettings = null; + clearAllTokens(); + console.log('[LocalApi] Stopped'); + return getLocalApiSnapshot(); +} + +export async function applyLocalApiSettings(): Promise { + const settings = readDesktopSettings().localApi; + + if (!settings.enabled) { + return await stopLocalApiServer(); + } + + // If already running with the same bind config, no-op (settings like + // scalarEnabled / allowedSignalingServers are read on every request). + if ( + server + && activeSettings + && currentStatus === 'running' + && activeSettings.port === settings.port + && activeSettings.exposeOnLan === settings.exposeOnLan + ) { + activeSettings = { ...settings, allowedSignalingServers: [...settings.allowedSignalingServers] }; + return getLocalApiSnapshot(); + } + + const result = await startLocalApiServer(settings); + + return result.snapshot; +} diff --git a/electron/api/openapi.ts b/electron/api/openapi.ts new file mode 100644 index 0000000..3d90f7c --- /dev/null +++ b/electron/api/openapi.ts @@ -0,0 +1,241 @@ +export interface OpenApiBuildOptions { + baseUrl: string; + appVersion: string; +} + +export function buildOpenApiDocument(options: OpenApiBuildOptions): unknown { + const { baseUrl, appVersion } = options; + + return { + openapi: '3.1.0', + info: { + title: 'MetoYou Local Desktop API', + version: appVersion, + description: + 'Authenticated local HTTP API exposed by the MetoYou desktop app. ' + + 'Authentication is performed against a configured signaling server. ' + + 'Bearer tokens issued here are scoped to this device only.' + }, + servers: [{ url: baseUrl }], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'opaque' + } + }, + schemas: { + Error: { + type: 'object', + required: ['error'], + properties: { + error: { type: 'string' }, + errorCode: { type: 'string' } + } + }, + LoginRequest: { + type: 'object', + required: ['username', 'password', 'serverUrl'], + properties: { + username: { type: 'string' }, + password: { type: 'string' }, + serverUrl: { + type: 'string', + format: 'uri', + description: 'Base URL of the signaling server to authenticate against. Must be in the allowed list configured in the desktop app.' + } + } + }, + LoginResponse: { + type: 'object', + required: ['token', 'expiresAt', 'user'], + properties: { + token: { type: 'string' }, + expiresAt: { type: 'integer', format: 'int64' }, + user: { $ref: '#/components/schemas/AuthUser' } + } + }, + AuthUser: { + type: 'object', + required: ['id', 'username', 'displayName'], + properties: { + id: { type: 'string' }, + username: { type: 'string' }, + displayName: { type: 'string' } + } + }, + Profile: { + type: 'object', + properties: { + id: { type: 'string' }, + username: { type: 'string' }, + displayName: { type: 'string' }, + description: { type: 'string' }, + avatarUrl: { type: 'string' }, + status: { type: 'string' } + } + }, + Room: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' } + }, + additionalProperties: true + }, + Message: { + type: 'object', + properties: { + id: { type: 'string' }, + roomId: { type: 'string' }, + channelId: { type: 'string' }, + senderId: { type: 'string' }, + senderName: { type: 'string' }, + content: { type: 'string' }, + timestamp: { type: 'integer', format: 'int64' }, + editedAt: { type: 'integer', format: 'int64' }, + isDeleted: { type: 'boolean' } + }, + additionalProperties: true + } + } + }, + security: [{ bearerAuth: [] }], + paths: { + '/api/health': { + get: { + security: [], + summary: 'Liveness probe', + responses: { + '200': { + description: 'Service is alive', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + status: { type: 'string' }, + version: { type: 'string' } + } + } + } + } + } + } + } + }, + '/api/openapi.json': { + get: { + security: [], + summary: 'OpenAPI specification', + responses: { '200': { description: 'This document' } } + } + }, + '/api/auth/login': { + post: { + security: [], + summary: 'Exchange username/password (validated by a signaling server) for a bearer token', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/LoginRequest' } + } + } + }, + responses: { + '200': { + description: 'Token issued', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/LoginResponse' } + } + } + }, + '401': { + description: 'Invalid credentials', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/Error' } } + } + }, + '403': { + description: 'Signaling server URL not allowed', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/Error' } } + } + } + } + } + }, + '/api/auth/logout': { + post: { + summary: 'Revoke the current bearer token', + responses: { '204': { description: 'Token revoked' } } + } + }, + '/api/profile': { + get: { + summary: 'Get the current user profile', + responses: { + '200': { + description: 'Current user profile', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Profile' } + } + } + }, + '404': { + description: 'No current user is set on this device', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/Error' } } + } + } + } + } + }, + '/api/rooms': { + get: { + summary: 'List rooms (servers) known to this device', + responses: { + '200': { + description: 'Rooms array', + content: { + 'application/json': { + schema: { + type: 'array', + items: { $ref: '#/components/schemas/Room' } + } + } + } + } + } + } + }, + '/api/rooms/{roomId}/messages': { + get: { + summary: 'List messages for a room', + parameters: [ + { name: 'roomId', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'limit', in: 'query', required: false, schema: { type: 'integer', minimum: 1, maximum: 500 } }, + { name: 'offset', in: 'query', required: false, schema: { type: 'integer', minimum: 0 } } + ], + responses: { + '200': { + description: 'Messages array', + content: { + 'application/json': { + schema: { + type: 'array', + items: { $ref: '#/components/schemas/Message' } + } + } + } + } + } + } + } + } + }; +} diff --git a/electron/api/router.ts b/electron/api/router.ts new file mode 100644 index 0000000..067d302 --- /dev/null +++ b/electron/api/router.ts @@ -0,0 +1,294 @@ +import { app, net } from 'electron'; +import { DataSource } from 'typeorm'; +import { buildQueryHandlers } from '../cqrs/queries'; +import { QueryType, QueryTypeKey, Query } from '../cqrs/types'; +import { issueToken, consumeToken, revokeToken, IssuedToken } from './auth-store'; +import { buildOpenApiDocument } from './openapi'; +import { HttpError, RequestContext, readJsonBody } from './http-helpers'; +import { getDocsHtml, getScalarApiReferenceBundlePath } from './docs-html'; +import { LocalApiSettings } from '../desktop-settings'; + +export interface RouteResponse { + status: number; + body: unknown; + contentType?: string; + filePath?: string; + rawBody?: string; +} + +export interface RouteContext { + request: RequestContext; + settings: LocalApiSettings; + baseUrl: string; + dataSource: DataSource; + bodyBuffer: () => Promise; +} + +type RouteHandler = (context: RouteContext) => Promise; + +interface RouteMatch { + handler: RouteHandler; + params: Record; + requiresAuth: boolean; +} + +interface RouteDefinition { + method: string; + pattern: RegExp; + paramKeys: string[]; + handler: RouteHandler; + requiresAuth: boolean; +} + +function compilePattern(template: string): { pattern: RegExp; paramKeys: string[] } { + const paramKeys: string[] = []; + const escaped = template.replace(/[.*+?^${}()|[\]\\]/g, (match) => { + if (match === '*' || match === '+' || match === '?') + return `\\${match}`; + return `\\${match}`; + }); + const source = template.replace(/\{([^}]+)\}/g, (_full, key: string) => { + paramKeys.push(key); + return '([^/]+)'; + }); + void escaped; + + return { pattern: new RegExp(`^${source}$`), paramKeys }; +} + +function defineRoute(method: string, template: string, handler: RouteHandler, requiresAuth: boolean): RouteDefinition { + const compiled = compilePattern(template); + + return { method: method.toUpperCase(), pattern: compiled.pattern, paramKeys: compiled.paramKeys, handler, requiresAuth }; +} + +function runQuery(dataSource: DataSource, query: Query): Promise { + const handlers = buildQueryHandlers(dataSource) as Record Promise>; + const handler = handlers[query.type as QueryTypeKey]; + + if (!handler) { + throw new HttpError(500, `No handler registered for query: ${query.type}`, 'UNKNOWN_QUERY'); + } + + return handler(query) as Promise; +} + +function clampInt(value: unknown, min: number, max: number, fallback: number): number { + const parsed = typeof value === 'string' ? Number(value) : NaN; + + if (!Number.isFinite(parsed)) + return fallback; + + return Math.max(min, Math.min(max, Math.floor(parsed))); +} + +const ROUTES: RouteDefinition[] = [ + defineRoute('GET', '/api/health', async (ctx): Promise => ({ + status: 200, + body: { status: 'ok', version: app.getVersion(), timestamp: Date.now(), exposeOnLan: ctx.settings.exposeOnLan } + }), false), + + defineRoute('GET', '/api/openapi.json', async (ctx): Promise => ({ + status: 200, + body: buildOpenApiDocument({ baseUrl: ctx.baseUrl, appVersion: app.getVersion() }) + }), false), + + defineRoute('GET', '/docs', async (ctx): Promise => { + if (!ctx.settings.scalarEnabled) { + return { + status: 404, + body: null, + contentType: 'text/plain; charset=utf-8', + rawBody: 'API documentation is disabled. Enable Scalar in desktop settings to view it.' + }; + } + + return { + status: 200, + body: null, + contentType: 'text/html; charset=utf-8', + rawBody: getDocsHtml(`${ctx.baseUrl}/api/openapi.json`) + }; + }, false), + + defineRoute('GET', '/scalar/api-reference.js', async (ctx): Promise => { + if (!ctx.settings.scalarEnabled) { + return { + status: 404, + body: null, + contentType: 'text/plain; charset=utf-8', + rawBody: 'API documentation is disabled. Enable Scalar in desktop settings to view it.' + }; + } + + const bundlePath = await getScalarApiReferenceBundlePath(); + + if (!bundlePath) { + throw new HttpError(503, 'Scalar API reference bundle is not available in this build', 'SCALAR_BUNDLE_MISSING'); + } + + return { + status: 200, + body: null, + contentType: 'application/javascript; charset=utf-8', + filePath: bundlePath + }; + }, false), + + defineRoute('POST', '/api/auth/login', async (ctx): Promise => { + const body = await ctx.bodyBuffer() as { username?: unknown; password?: unknown; serverUrl?: unknown }; + const username = typeof body.username === 'string' ? body.username.trim() : ''; + const password = typeof body.password === 'string' ? body.password : ''; + const serverUrl = typeof body.serverUrl === 'string' ? body.serverUrl.trim().replace(/\/+$/u, '') : ''; + + if (!username || !password || !serverUrl) { + throw new HttpError(400, 'username, password, and serverUrl are required', 'INVALID_REQUEST'); + } + + if (!/^https?:\/\//iu.test(serverUrl)) { + throw new HttpError(400, 'serverUrl must be an http or https URL', 'INVALID_REQUEST'); + } + + if (ctx.settings.allowedSignalingServers.length === 0) { + throw new HttpError(403, 'No signaling servers are allowed for local API authentication. Add one in desktop settings.', 'NO_ALLOWED_SERVERS'); + } + + if (!ctx.settings.allowedSignalingServers.includes(serverUrl)) { + throw new HttpError(403, 'Signaling server URL is not in the allowed list', 'SERVER_NOT_ALLOWED'); + } + + let response: Response; + + try { + response = await net.fetch(`${serverUrl}/api/users/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }) + }); + } catch (error) { + throw new HttpError(502, `Failed to reach signaling server: ${(error as Error).message}`, 'UPSTREAM_UNREACHABLE'); + } + + if (response.status === 401 || response.status === 403) { + throw new HttpError(401, 'Invalid credentials', 'INVALID_CREDENTIALS'); + } + + if (!response.ok) { + throw new HttpError(502, `Signaling server rejected login (${response.status})`, 'UPSTREAM_ERROR'); + } + + const remote = await response.json() as { id?: string; username?: string; displayName?: string }; + + if (!remote.id || !remote.username) { + throw new HttpError(502, 'Signaling server returned an unexpected response', 'UPSTREAM_BAD_RESPONSE'); + } + + const issued = issueToken({ + userId: remote.id, + username: remote.username, + displayName: remote.displayName ?? remote.username, + signalingServerUrl: serverUrl + }); + + return { + status: 200, + body: { + token: issued.token, + expiresAt: issued.expiresAt, + user: { + id: issued.userId, + username: issued.username, + displayName: issued.displayName + } + } + }; + }, false), + + defineRoute('POST', '/api/auth/logout', async (ctx): Promise => { + if (ctx.request.bearerToken) { + revokeToken(ctx.request.bearerToken); + } + + return { status: 204, body: null }; + }, true), + + defineRoute('GET', '/api/profile', async (ctx): Promise => { + const user = await runQuery(ctx.dataSource, { + type: QueryType.GetCurrentUser, + payload: {} + }); + + if (!user) { + throw new HttpError(404, 'No current user is set on this device', 'NO_CURRENT_USER'); + } + + return { status: 200, body: user }; + }, true), + + defineRoute('GET', '/api/rooms', async (ctx): Promise => { + const rooms = await runQuery(ctx.dataSource, { + type: QueryType.GetAllRooms, + payload: {} + }); + + return { status: 200, body: rooms ?? [] }; + }, true), + + defineRoute('GET', '/api/rooms/{roomId}/messages', async (ctx): Promise => { + const roomId = ctx.request.url.pathname.match(/\/api\/rooms\/([^/]+)\/messages$/u)?.[1]; + + if (!roomId) + throw new HttpError(400, 'roomId is required', 'INVALID_REQUEST'); + + const limit = clampInt(ctx.request.url.searchParams.get('limit'), 1, 500, 100); + const offset = clampInt(ctx.request.url.searchParams.get('offset'), 0, Number.MAX_SAFE_INTEGER, 0); + + const messages = await runQuery(ctx.dataSource, { + type: QueryType.GetMessages, + payload: { roomId: decodeURIComponent(roomId), limit, offset } + }); + + return { status: 200, body: messages ?? [] }; + }, true) +]; + +export interface RoutingResult { + match: RouteMatch | null; + methodNotAllowed: boolean; +} + +export function matchRoute(method: string, pathname: string): RoutingResult { + let methodNotAllowed = false; + + for (const route of ROUTES) { + const result = route.pattern.exec(pathname); + + if (!result) + continue; + + if (route.method !== method) { + methodNotAllowed = true; + continue; + } + + const params: Record = {}; + + for (let index = 0; index < route.paramKeys.length; index++) { + params[route.paramKeys[index]] = result[index + 1]; + } + + return { + match: { handler: route.handler, params, requiresAuth: route.requiresAuth }, + methodNotAllowed: false + }; + } + + return { match: null, methodNotAllowed }; +} + +export function authenticate(token: string | null): IssuedToken | null { + if (!token) + return null; + + return consumeToken(token); +} diff --git a/electron/app/lifecycle.ts b/electron/app/lifecycle.ts index 8621657..2202308 100644 --- a/electron/app/lifecycle.ts +++ b/electron/app/lifecycle.ts @@ -2,6 +2,7 @@ import { app, BrowserWindow } from 'electron'; import { cleanupLinuxScreenShareAudioRouting } from '../audio/linux-screen-share-routing'; import { initializeDesktopUpdater, shutdownDesktopUpdater } from '../update/desktop-updater'; import { synchronizeAutoStartSetting } from './auto-start'; +import { applyLocalApiSettings, stopLocalApiServer } from '../api'; import { initializeDatabase, destroyDatabase, @@ -21,6 +22,14 @@ import { } from '../ipc'; import { startIdleMonitor, stopIdleMonitor } from '../idle/idle-monitor'; +function startLocalApiAfterWindowReady(): void { + setImmediate(() => { + void applyLocalApiSettings().catch((error: unknown) => { + console.error('[LocalApi] Failed to apply settings after window startup:', error); + }); + }); +} + export function registerAppLifecycle(): void { app.whenReady().then(async () => { const dockIconPath = getDockIconPath(); @@ -35,6 +44,7 @@ export function registerAppLifecycle(): void { await synchronizeAutoStartSetting(); initializeDesktopUpdater(); await createWindow(); + startLocalApiAfterWindowReady(); startIdleMonitor(); app.on('activate', () => { @@ -60,6 +70,7 @@ export function registerAppLifecycle(): void { event.preventDefault(); shutdownDesktopUpdater(); stopIdleMonitor(); + await stopLocalApiServer(); await cleanupLinuxScreenShareAudioRouting(); await destroyDatabase(); app.quit(); diff --git a/electron/cqrs/commands/handlers/deletePluginData.ts b/electron/cqrs/commands/handlers/deletePluginData.ts index f7c7071..9b83873 100644 --- a/electron/cqrs/commands/handlers/deletePluginData.ts +++ b/electron/cqrs/commands/handlers/deletePluginData.ts @@ -11,4 +11,4 @@ export async function handleDeletePluginData(command: DeletePluginDataCommand, d scope: payload.scope, serverId: payload.serverId ?? '' }); -} \ No newline at end of file +} diff --git a/electron/cqrs/commands/handlers/savePluginData.ts b/electron/cqrs/commands/handlers/savePluginData.ts index 4b57347..f2b3cec 100644 --- a/electron/cqrs/commands/handlers/savePluginData.ts +++ b/electron/cqrs/commands/handlers/savePluginData.ts @@ -13,4 +13,4 @@ export async function handleSavePluginData(command: SavePluginDataCommand, dataS updatedAt: Date.now(), valueJson: JSON.stringify(payload.value ?? null) }); -} \ No newline at end of file +} diff --git a/electron/cqrs/queries/handlers/getPluginData.ts b/electron/cqrs/queries/handlers/getPluginData.ts index a0a4035..c557b02 100644 --- a/electron/cqrs/queries/handlers/getPluginData.ts +++ b/electron/cqrs/queries/handlers/getPluginData.ts @@ -22,4 +22,4 @@ export async function handleGetPluginData(query: GetPluginDataQuery, dataSource: } catch { return null; } -} \ No newline at end of file +} diff --git a/electron/desktop-settings.ts b/electron/desktop-settings.ts index 041248b..d3319fe 100644 --- a/electron/desktop-settings.ts +++ b/electron/desktop-settings.ts @@ -4,11 +4,20 @@ import * as path from 'path'; export type AutoUpdateMode = 'auto' | 'off' | 'version'; +export interface LocalApiSettings { + enabled: boolean; + port: number; + exposeOnLan: boolean; + scalarEnabled: boolean; + allowedSignalingServers: string[]; +} + export interface DesktopSettings { autoUpdateMode: AutoUpdateMode; autoStart: boolean; closeToTray: boolean; hardwareAcceleration: boolean; + localApi: LocalApiSettings; manifestUrls: string[]; preferredVersion: string | null; vaapiVideoEncode: boolean; @@ -19,11 +28,20 @@ export interface DesktopSettingsSnapshot extends DesktopSettings { restartRequired: boolean; } +const DEFAULT_LOCAL_API_SETTINGS: LocalApiSettings = { + enabled: false, + port: 17878, + exposeOnLan: false, + scalarEnabled: false, + allowedSignalingServers: [] +}; + const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { autoUpdateMode: 'auto', autoStart: true, closeToTray: true, hardwareAcceleration: true, + localApi: { ...DEFAULT_LOCAL_API_SETTINGS }, manifestUrls: [], preferredVersion: null, vaapiVideoEncode: false @@ -61,6 +79,60 @@ function normalizeManifestUrls(value: unknown): string[] { return manifestUrls; } +function normalizePort(value: unknown, fallback: number): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return fallback; + } + + const port = Math.floor(value); + + if (port < 1 || port > 65535) { + return fallback; + } + + return port; +} + +function normalizeAllowedSignalingServers(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + + const urls: string[] = []; + + for (const entry of value) { + if (typeof entry !== 'string') { + continue; + } + + const trimmed = entry.trim().replace(/\/+$/u, ''); + + if (!trimmed || urls.includes(trimmed)) { + continue; + } + + if (!/^https?:\/\//iu.test(trimmed)) { + continue; + } + + urls.push(trimmed); + } + + return urls; +} + +function normalizeLocalApiSettings(value: unknown): LocalApiSettings { + const source = (value && typeof value === 'object') ? value as Partial : {}; + + return { + enabled: typeof source.enabled === 'boolean' ? source.enabled : DEFAULT_LOCAL_API_SETTINGS.enabled, + port: normalizePort(source.port, DEFAULT_LOCAL_API_SETTINGS.port), + exposeOnLan: typeof source.exposeOnLan === 'boolean' ? source.exposeOnLan : DEFAULT_LOCAL_API_SETTINGS.exposeOnLan, + scalarEnabled: typeof source.scalarEnabled === 'boolean' ? source.scalarEnabled : DEFAULT_LOCAL_API_SETTINGS.scalarEnabled, + allowedSignalingServers: normalizeAllowedSignalingServers(source.allowedSignalingServers) + }; +} + export function getDesktopSettingsSnapshot(): DesktopSettingsSnapshot { const storedSettings = readDesktopSettings(); const runtimeHardwareAcceleration = app.isHardwareAccelerationEnabled(); @@ -97,6 +169,7 @@ export function readDesktopSettings(): DesktopSettings { hardwareAcceleration: typeof parsed.hardwareAcceleration === 'boolean' ? parsed.hardwareAcceleration : DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration, + localApi: normalizeLocalApiSettings(parsed.localApi), manifestUrls: normalizeManifestUrls(parsed.manifestUrls), preferredVersion: normalizePreferredVersion(parsed.preferredVersion) }; @@ -106,9 +179,13 @@ export function readDesktopSettings(): DesktopSettings { } export function updateDesktopSettings(patch: Partial): DesktopSettingsSnapshot { + const previousSettings = readDesktopSettings(); const mergedSettings = { - ...readDesktopSettings(), - ...patch + ...previousSettings, + ...patch, + localApi: patch.localApi + ? { ...previousSettings.localApi, ...patch.localApi } + : previousSettings.localApi }; const nextSettings: DesktopSettings = { autoUpdateMode: normalizeAutoUpdateMode(mergedSettings.autoUpdateMode), @@ -121,6 +198,7 @@ export function updateDesktopSettings(patch: Partial): DesktopS hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean' ? mergedSettings.hardwareAcceleration : DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration, + localApi: normalizeLocalApiSettings(mergedSettings.localApi), manifestUrls: normalizeManifestUrls(mergedSettings.manifestUrls), preferredVersion: normalizePreferredVersion(mergedSettings.preferredVersion), vaapiVideoEncode: typeof mergedSettings.vaapiVideoEncode === 'boolean' diff --git a/electron/entities/PluginDataEntity.ts b/electron/entities/PluginDataEntity.ts index 35cfe52..fb09b4e 100644 --- a/electron/entities/PluginDataEntity.ts +++ b/electron/entities/PluginDataEntity.ts @@ -23,4 +23,4 @@ export class PluginDataEntity { @Column('integer') updatedAt!: number; -} \ No newline at end of file +} diff --git a/electron/ipc/system.ts b/electron/ipc/system.ts index 4f44ea3..cbf335f 100644 --- a/electron/ipc/system.ts +++ b/electron/ipc/system.ts @@ -18,6 +18,10 @@ import { updateDesktopSettings, type DesktopSettings } from '../desktop-settings'; +import { + applyLocalApiSettings, + getLocalApiSnapshot +} from '../api'; import { activateLinuxScreenShareAudioRouting, deactivateLinuxScreenShareAudioRouting, @@ -452,9 +456,27 @@ export function setupSystemHandlers(): void { await synchronizeAutoStartSetting(snapshot.autoStart); updateCloseToTraySetting(snapshot.closeToTray); await handleDesktopSettingsChanged(); + await applyLocalApiSettings(); return snapshot; }); + ipcMain.handle('get-local-api-status', () => getLocalApiSnapshot()); + + ipcMain.handle('open-local-api-docs', async () => { + const snapshot = getLocalApiSnapshot(); + + if (snapshot.status !== 'running' || !snapshot.baseUrl) { + return { opened: false, reason: 'Local API is not running' }; + } + + if (!snapshot.scalarEnabled) { + return { opened: false, reason: 'Scalar docs are disabled' }; + } + + await shell.openExternal(`${snapshot.baseUrl}/docs`); + return { opened: true }; + }); + ipcMain.handle('relaunch-app', () => { app.relaunch(); app.exit(0); diff --git a/electron/migrations/1000000000008-AddPluginData.ts b/electron/migrations/1000000000008-AddPluginData.ts index 7275cfe..fbcbe6a 100644 --- a/electron/migrations/1000000000008-AddPluginData.ts +++ b/electron/migrations/1000000000008-AddPluginData.ts @@ -22,4 +22,4 @@ export class AddPluginData1000000000008 implements MigrationInterface { await queryRunner.query(`DROP INDEX IF EXISTS "idx_plugin_data_plugin_scope"`); await queryRunner.query(`DROP TABLE IF EXISTS "plugin_data"`); } -} \ No newline at end of file +} diff --git a/electron/preload.ts b/electron/preload.ts index 3b96d27..bc2a034 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -119,6 +119,26 @@ export interface LocalPluginManifestDescriptor { readmePath?: string; } +export interface LocalApiSettings { + enabled: boolean; + port: number; + exposeOnLan: boolean; + scalarEnabled: boolean; + allowedSignalingServers: string[]; +} + +export type LocalApiStatus = 'stopped' | 'starting' | 'running' | 'error'; + +export interface LocalApiSnapshot { + status: LocalApiStatus; + host: string | null; + port: number | null; + baseUrl: string | null; + error: string | null; + exposeOnLan: boolean; + scalarEnabled: boolean; +} + export interface LocalPluginDiscoveryError { manifestPath?: string; message: string; @@ -215,10 +235,12 @@ export interface ElectronAPI { autoStart: boolean; closeToTray: boolean; hardwareAcceleration: boolean; + localApi: LocalApiSettings; manifestUrls: string[]; preferredVersion: string | null; runtimeHardwareAcceleration: boolean; restartRequired: boolean; + vaapiVideoEncode: boolean; }>; showDesktopNotification: (payload: DesktopNotificationPayload) => Promise; requestWindowAttention: () => Promise; @@ -235,6 +257,7 @@ export interface ElectronAPI { autoStart?: boolean; closeToTray?: boolean; hardwareAcceleration?: boolean; + localApi?: Partial; manifestUrls?: string[]; preferredVersion?: string | null; vaapiVideoEncode?: boolean; @@ -243,11 +266,15 @@ export interface ElectronAPI { autoStart: boolean; closeToTray: boolean; hardwareAcceleration: boolean; + localApi: LocalApiSettings; manifestUrls: string[]; preferredVersion: string | null; runtimeHardwareAcceleration: boolean; restartRequired: boolean; + vaapiVideoEncode: boolean; }>; + getLocalApiStatus: () => Promise; + openLocalApiDocs: () => Promise<{ opened: boolean; reason?: string }>; relaunchApp: () => Promise; onDeepLinkReceived: (listener: (url: string) => void) => () => void; readClipboardFiles: () => Promise; @@ -357,6 +384,8 @@ const electronAPI: ElectronAPI = { }; }, setDesktopSettings: (patch) => ipcRenderer.invoke('set-desktop-settings', patch), + getLocalApiStatus: () => ipcRenderer.invoke('get-local-api-status'), + openLocalApiDocs: () => ipcRenderer.invoke('open-local-api-docs'), relaunchApp: () => ipcRenderer.invoke('relaunch-app'), onDeepLinkReceived: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, url: string) => { diff --git a/package-lock.json b/package-lock.json index 6047d28..5c77629 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "@ngrx/entity": "^21.0.1", "@ngrx/store": "^21.0.1", "@ngrx/store-devtools": "^21.0.1", + "@scalar/api-reference": "^1.53.1", "@spartan-ng/brain": "^0.0.1-alpha.589", "@spartan-ng/cli": "^0.0.1-alpha.589", "@spartan-ng/ui-core": "^0.0.1-alpha.380", @@ -86,6 +87,69 @@ "wait-on": "^7.2.0" } }, + "node_modules/@ai-sdk/gateway": { + "version": "3.0.13", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.13.tgz", + "integrity": "sha512-g7nE4PFtngOZNZSy1lOPpkC+FAiHxqBJXqyRMEG7NUrEVZlz5goBdtHg1YgWRJIX776JTXAmbOI5JreAKVAsVA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.2", + "@ai-sdk/provider-utils": "4.0.5", + "@vercel/oidc": "3.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.2.tgz", + "integrity": "sha512-HrEmNt/BH/hkQ7zpi2o6N3k1ZR1QTb7z85WYhYygiTxOQuaml4CMtHCWRbric5WPU+RNsYI7r1EpyVQMKO1pYw==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.5.tgz", + "integrity": "sha512-Ow/X/SEkeExTTc1x+nYLB9ZHK2WUId8+9TlkamAx7Tl9vxU+cKzWx2dwjgMHeCN6twrgwkLrrtqckQeO4mxgVA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.2", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/vue": { + "version": "3.0.33", + "resolved": "https://registry.npmjs.org/@ai-sdk/vue/-/vue-3.0.33.tgz", + "integrity": "sha512-czM9Js3a7f+Eo35gjEYEeJYUoPvMg5Dfi4bOLyDBghLqn0gaVg8yTmTaSuHCg+3K/+1xPjyXd4+2XcQIohWWiQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider-utils": "4.0.5", + "ai": "6.0.33", + "swrv": "^1.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "vue": "^3.3.4" + } + }, "node_modules/@algolia/abtesting": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.14.1.tgz", @@ -1100,13 +1164,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -1412,12 +1476,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.5" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -2646,9 +2710,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -2746,6 +2810,38 @@ "@lezer/css": "^1.1.7" } }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.11", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", + "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.12" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, "node_modules/@codemirror/lang-json": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", @@ -2756,6 +2852,35 @@ "@lezer/json": "^1.0.0" } }, + "node_modules/@codemirror/lang-xml": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz", + "integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/xml": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-yaml": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz", + "integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.0.0", + "@lezer/yaml": "^1.0.0" + } + }, "node_modules/@codemirror/language": { "version": "6.12.3", "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", @@ -4952,6 +5077,80 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/core/node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom/node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@floating-ui/vue": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/vue/-/vue-1.1.9.tgz", + "integrity": "sha512-BfNqNW6KA83Nexspgb9DZuz578R7HT8MZw1CfK9I6Ah4QReNWEJsXWHN+SdmOVLNGmTPDi+fDT535Df5PzMLbQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4", + "@floating-ui/utils": "^0.2.10", + "vue-demi": ">=0.13.0" + } + }, + "node_modules/@floating-ui/vue/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/@gar/promise-retry": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.2.tgz", @@ -4999,6 +5198,33 @@ "@hapi/hoek": "^9.0.0" } }, + "node_modules/@headlessui/tailwindcss": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@headlessui/tailwindcss/-/tailwindcss-0.2.2.tgz", + "integrity": "sha512-xNe42KjdyA4kfUKLLPGzME9zkH7Q3rOZ5huFihWNWOQFxnItxPB3/67yBI8/qBfY8nwBRx5GHn4VprsoluVMGw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "tailwindcss": "^3.0 || ^4.0" + } + }, + "node_modules/@headlessui/vue": { + "version": "1.7.23", + "resolved": "https://registry.npmjs.org/@headlessui/vue/-/vue-1.7.23.tgz", + "integrity": "sha512-JzdCNqurrtuu0YW6QaDtR2PIYCKPUWq28csDyMvN4zmGccmE7lz40Is6hc3LA4HFeCI7sekZ/PQMTNmn9I/4Wg==", + "license": "MIT", + "dependencies": { + "@tanstack/vue-virtual": "^3.0.0-beta.60" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, "node_modules/@hono/node-server": { "version": "1.19.11", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", @@ -5449,6 +5675,24 @@ } } }, + "node_modules/@internationalized/date": { + "version": "3.12.1", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.1.tgz", + "integrity": "sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@internationalized/number": { + "version": "3.6.6", + "resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.6.tgz", + "integrity": "sha512-iFgmQaXHE0vytNfpLZWOC2mEJCBRzcUxt53Xf/yCXG93lRvqas237i3r7X4RKMwO3txiyZD4mQjKAByFv6UGSQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -5826,6 +6070,28 @@ "@lezer/common": "^1.3.0" } }, + "node_modules/@lezer/html": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, "node_modules/@lezer/json": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", @@ -5846,6 +6112,28 @@ "@lezer/common": "^1.0.0" } }, + "node_modules/@lezer/xml": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz", + "integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/yaml": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz", + "integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.4.0" + } + }, "node_modules/@listr2/prompt-adapter-inquirer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.5.tgz", @@ -9004,6 +9292,15 @@ "node": ">=12" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@oxc-project/types": { "version": "0.96.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.96.0.tgz", @@ -9343,6 +9640,12 @@ "typescript": "^3 || ^4 || ^5" } }, + "node_modules/@phosphor-icons/core": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@phosphor-icons/core/-/core-2.1.1.tgz", + "integrity": "sha512-v4ARvrip4qBCImOE5rmPUylOEK4iiED9ZyKjcvzuezqMaiRASCHKcRIuvvxL/twvLpkfnEODCOJp5dM4eZilxQ==", + "license": "MIT" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -9382,6 +9685,17 @@ "node": ">=18" } }, + "node_modules/@replit/codemirror-css-color-picker": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@replit/codemirror-css-color-picker/-/codemirror-css-color-picker-6.3.0.tgz", + "integrity": "sha512-19biDANghUm7Fz7L1SNMIhK48tagaWuCOHj4oPPxc7hxPGkTVY2lU/jVZ8tsbTKQPVG7BO2CBDzs7CBwb20t4A==", + "license": "MIT", + "peerDependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-beta.47", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.47.tgz", @@ -10233,6 +10547,565 @@ } } }, + "node_modules/@scalar/agent-chat": { + "version": "0.10.10", + "resolved": "https://registry.npmjs.org/@scalar/agent-chat/-/agent-chat-0.10.10.tgz", + "integrity": "sha512-UYyjq6VfWzTPm1hXykyvI/F3oLjrmy2cWG9fdAc5d8XDRMeHo87s62wC7sCsupKqDfyNYxbeaUT0RLx2iXPntQ==", + "license": "MIT", + "dependencies": { + "@ai-sdk/vue": "3.0.33", + "@scalar/api-client": "3.3.1", + "@scalar/components": "0.22.5", + "@scalar/helpers": "0.5.3", + "@scalar/icons": "0.7.2", + "@scalar/json-magic": "0.12.9", + "@scalar/openapi-types": "0.8.0", + "@scalar/themes": "0.15.3", + "@scalar/types": "0.9.3", + "@scalar/use-toasts": "0.10.2", + "@scalar/workspace-store": "0.47.1", + "@vueuse/core": "13.9.0", + "ai": "6.0.33", + "js-base64": "^3.7.8", + "neverpanic": "0.0.7", + "truncate-json": "3.0.1", + "vue": "^3.5.30", + "zod": "^4.3.5" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/api-client": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@scalar/api-client/-/api-client-3.3.1.tgz", + "integrity": "sha512-baqrMM9T7uToNILwnqVolutDFN1YKwnErVj9+TriEwNJ3X1McVxh1qd8zCFs30/dB30hxgQWYFixdm0Zlf+NZw==", + "license": "MIT", + "dependencies": { + "@headlessui/tailwindcss": "^0.2.2", + "@headlessui/vue": "1.7.23", + "@scalar/components": "0.22.5", + "@scalar/helpers": "0.5.3", + "@scalar/icons": "0.7.2", + "@scalar/json-magic": "0.12.9", + "@scalar/oas-utils": "0.13.4", + "@scalar/openapi-types": "0.8.0", + "@scalar/postman-to-openapi": "0.7.2", + "@scalar/sidebar": "0.9.6", + "@scalar/snippetz": "0.9.3", + "@scalar/themes": "0.15.3", + "@scalar/typebox": "^0.1.3", + "@scalar/types": "0.9.3", + "@scalar/use-codemirror": "0.14.11", + "@scalar/use-hooks": "0.4.3", + "@scalar/use-toasts": "0.10.2", + "@scalar/validation": "0.3.0", + "@scalar/workspace-store": "0.47.1", + "@types/har-format": "^1.2.16", + "@vueuse/core": "13.9.0", + "@vueuse/integrations": "13.9.0", + "cookie": "1.1.1", + "focus-trap": "^7.8.0", + "fuse.js": "^7.1.0", + "js-base64": "^3.7.8", + "monaco-editor": "0.55.1", + "monaco-yaml": "5.4.1", + "nanoid": "^5.1.6", + "pretty-ms": "^9.3.0", + "radix-vue": "^1.9.17", + "set-cookie-parser": "3.1.0", + "shell-quote": "^1.8.3", + "vite-plugin-monaco-editor": "^1.1.0", + "vue": "^3.5.30", + "vue-router": "5.0.4", + "yaml": "^2.8.0", + "zod": "^4.3.5" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/api-client/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@scalar/api-client/node_modules/nanoid": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.9.tgz", + "integrity": "sha512-ZUvP7KeBLe3OZ1ypw6dI/TzYJuvHP77IM4Ry73waSQTLn8/g8rpdjfyVAh7t1/+FjBtG4lCP42MEbDxOsRpBMw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/@scalar/api-reference": { + "version": "1.53.1", + "resolved": "https://registry.npmjs.org/@scalar/api-reference/-/api-reference-1.53.1.tgz", + "integrity": "sha512-4z2UMrVx3EWeSjOzRA9tNonRL1NpJsd9w798HwaAxP+1rfpaBbjUjcqu0fP0eE2OfYCF4S1wS0libRAQdbhrHg==", + "license": "MIT", + "dependencies": { + "@headlessui/vue": "1.7.23", + "@scalar/agent-chat": "0.10.10", + "@scalar/api-client": "3.3.1", + "@scalar/code-highlight": "0.3.4", + "@scalar/components": "0.22.5", + "@scalar/helpers": "0.5.3", + "@scalar/icons": "0.7.2", + "@scalar/oas-utils": "0.13.4", + "@scalar/sidebar": "0.9.6", + "@scalar/snippetz": "0.9.3", + "@scalar/themes": "0.15.3", + "@scalar/types": "0.9.3", + "@scalar/use-hooks": "0.4.3", + "@scalar/use-toasts": "0.10.2", + "@scalar/workspace-store": "0.47.1", + "@unhead/vue": "^2.1.4", + "@vueuse/core": "13.9.0", + "fuse.js": "^7.1.0", + "github-slugger": "2.0.0", + "microdiff": "^1.5.0", + "nanoid": "^5.1.6", + "vue": "^3.5.30", + "yaml": "^2.8.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/api-reference/node_modules/nanoid": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.9.tgz", + "integrity": "sha512-ZUvP7KeBLe3OZ1ypw6dI/TzYJuvHP77IM4Ry73waSQTLn8/g8rpdjfyVAh7t1/+FjBtG4lCP42MEbDxOsRpBMw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/@scalar/code-highlight": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@scalar/code-highlight/-/code-highlight-0.3.4.tgz", + "integrity": "sha512-gGr3D8bfInwZDHsxamYIaG72Wr+kRNX8d4zcOflAfQJ0ZfvqoVbYhhkiSd6K+DLySItK9lWl/cgjJdtKWlT2ig==", + "license": "MIT", + "dependencies": { + "hast-util-to-text": "^4.0.2", + "highlight.js": "^11.11.1", + "lowlight": "^3.3.0", + "rehype-external-links": "^3.0.0", + "rehype-format": "^5.0.1", + "rehype-parse": "^9.0.1", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "rehype-stringify": "^10.0.1", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "remark-stringify": "^11.0.0", + "unified": "^11.0.5", + "unist-util-visit": "^5.1.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/components": { + "version": "0.22.5", + "resolved": "https://registry.npmjs.org/@scalar/components/-/components-0.22.5.tgz", + "integrity": "sha512-Mjg8mGJdXZN+FTCrIwGvDkqrCMgSr3155dQrQX5AeECUNpfR+aNcoWq/I/i+9ws1Jrkz6pQw0GokCHrOI58ZDw==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "0.2.10", + "@floating-ui/vue": "1.1.9", + "@headlessui/vue": "1.7.23", + "@scalar/code-highlight": "0.3.4", + "@scalar/helpers": "0.5.3", + "@scalar/icons": "0.7.2", + "@scalar/themes": "0.15.3", + "@scalar/use-hooks": "0.4.3", + "@vueuse/core": "13.9.0", + "cva": "1.0.0-beta.4", + "nanoid": "^5.1.6", + "radix-vue": "^1.9.17", + "vue": "^3.5.30", + "vue-component-type-helpers": "^3.2.6" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/components/node_modules/nanoid": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.9.tgz", + "integrity": "sha512-ZUvP7KeBLe3OZ1ypw6dI/TzYJuvHP77IM4Ry73waSQTLn8/g8rpdjfyVAh7t1/+FjBtG4lCP42MEbDxOsRpBMw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/@scalar/helpers": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@scalar/helpers/-/helpers-0.5.3.tgz", + "integrity": "sha512-PgQmhuV0oRoHtaqH0OhyCcSY9t35qm8ThNeuUMEAKeN+hW1ijBnJiUADpxaIfXPbLrpN9sjyYza0A16WFbLttg==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/icons": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@scalar/icons/-/icons-0.7.2.tgz", + "integrity": "sha512-21L2y/D6oU7wZHHa9i6FK98cZ+XH4HX9+e69uNpvlp4awRUpz6ifNHOLlxI607bq+Yz4G313gnV0uyUHwZ/pig==", + "license": "MIT", + "dependencies": { + "@phosphor-icons/core": "^2.1.1", + "@types/node": "^24.1.0", + "chalk": "^5.6.2", + "vue": "^3.5.30" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/icons/node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@scalar/json-magic": { + "version": "0.12.9", + "resolved": "https://registry.npmjs.org/@scalar/json-magic/-/json-magic-0.12.9.tgz", + "integrity": "sha512-wabHE3heo0usLlneDeOjMNs2ES8bREJ3ySc2WPiHIXdzAmy+ERU6g9Al4w3mwgJueOceAkIP6W+yY/DmSCI4uA==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.5.3", + "pathe": "^2.0.3", + "yaml": "^2.8.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/oas-utils": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/@scalar/oas-utils/-/oas-utils-0.13.4.tgz", + "integrity": "sha512-kAtxCbs+JMVKmpuvzMVXCxBchvpp/l0VNLvNGtkLAbddccG4aqi0d10TZ8n9IQTdy/AASPa/2p5IHAj9W/frgQ==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.5.3", + "@scalar/themes": "0.15.3", + "@scalar/types": "0.9.3", + "@scalar/workspace-store": "0.47.1", + "flatted": "^3.4.0", + "github-slugger": "2.0.0", + "vue": "^3.5.30", + "yaml": "^2.8.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/openapi-types": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@scalar/openapi-types/-/openapi-types-0.8.0.tgz", + "integrity": "sha512-WmaxVSfvY5K/TwcG2B2TU1WOe1As1uc2s7myswtP6dBlcjU3hM08SApxv/jmyGaCE8t4gO5BBhmHY4pDUfmr2g==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/openapi-upgrader": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@scalar/openapi-upgrader/-/openapi-upgrader-0.2.6.tgz", + "integrity": "sha512-pvEmfSCDNYR4+lygidUqfo+shzyp4OSh9+UgK110rzA8Oot6WbJBM03Fuq3M255G7G6R9iXyfsebB7MBUocPkw==", + "license": "MIT", + "dependencies": { + "@scalar/openapi-types": "0.8.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/postman-to-openapi": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@scalar/postman-to-openapi/-/postman-to-openapi-0.7.2.tgz", + "integrity": "sha512-+nZ8xLYuudqmDkY0X2rGX3BGOwshGA/4UpG5KvP5s+H+5cnH1IhTB5QL0nb4doqClPjLN2lBxb3AI53TQXutGQ==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.5.3", + "@scalar/openapi-types": "0.8.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/sidebar": { + "version": "0.9.6", + "resolved": "https://registry.npmjs.org/@scalar/sidebar/-/sidebar-0.9.6.tgz", + "integrity": "sha512-RSzUl1U3eI8QPU2AgGFvv+RWPzTdfKdZyIau4ggjzY2pvaK0MketuybpBV5sqlGnhMesAyUYItEdkBf3gui4VQ==", + "license": "MIT", + "dependencies": { + "@scalar/components": "0.22.5", + "@scalar/helpers": "0.5.3", + "@scalar/icons": "0.7.2", + "@scalar/themes": "0.15.3", + "@scalar/use-hooks": "0.4.3", + "@scalar/workspace-store": "0.47.1", + "vue": "^3.5.30" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/snippetz": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@scalar/snippetz/-/snippetz-0.9.3.tgz", + "integrity": "sha512-y9a/Kyw5DOIv2QxA3KBjKL49px8fS1KPoNf3og7/ok1L3xs26tUh1KsCdPntHnYnMyVdeiuNv0S/4wME7bsTlQ==", + "license": "MIT", + "dependencies": { + "@scalar/types": "0.9.3", + "js-base64": "^3.7.8", + "stringify-object": "^6.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/themes": { + "version": "0.15.3", + "resolved": "https://registry.npmjs.org/@scalar/themes/-/themes-0.15.3.tgz", + "integrity": "sha512-KIGMVglWKxVcdPsdjiXDgyAYhCh53w0qoKRG/cmfP+N4OwR0pk0WzFaMzBscu+sKoZ8SMvZqbXyODO5CBtyD3w==", + "license": "MIT", + "dependencies": { + "nanoid": "^5.1.6" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/themes/node_modules/nanoid": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.9.tgz", + "integrity": "sha512-ZUvP7KeBLe3OZ1ypw6dI/TzYJuvHP77IM4Ry73waSQTLn8/g8rpdjfyVAh7t1/+FjBtG4lCP42MEbDxOsRpBMw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/@scalar/typebox": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@scalar/typebox/-/typebox-0.1.3.tgz", + "integrity": "sha512-lU055AUccECZMIfGA0z/C1StYmboAYIPJLDFBzOO81yXBi35Pxdq+I4fWX6iUZ8qcoHneiLGk9jAUM1rA93iEg==", + "license": "MIT" + }, + "node_modules/@scalar/types": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@scalar/types/-/types-0.9.3.tgz", + "integrity": "sha512-/cEFjVa8PxRIDyhcWKh7McT8pm5O0kbafzd1jvpVq69sgIIq0gJ0P1sCcPye6qJ2k478PK7VmpK9FxZcr6D4Kw==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.5.3", + "nanoid": "^5.1.6", + "type-fest": "^5.3.1", + "zod": "^4.3.5" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/types/node_modules/nanoid": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.9.tgz", + "integrity": "sha512-ZUvP7KeBLe3OZ1ypw6dI/TzYJuvHP77IM4Ry73waSQTLn8/g8rpdjfyVAh7t1/+FjBtG4lCP42MEbDxOsRpBMw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/@scalar/types/node_modules/type-fest": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", + "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@scalar/use-codemirror": { + "version": "0.14.11", + "resolved": "https://registry.npmjs.org/@scalar/use-codemirror/-/use-codemirror-0.14.11.tgz", + "integrity": "sha512-5wtC4pUjzhy72j3aAueJg+fh9KflevJvXMn0YscsxiDTvqwIzeZcHe1N9VNtvzDXgLblEeBT6D0+Vs+boyExxg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.18.3", + "@codemirror/commands": "^6.7.1", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-html": "^6.4.8", + "@codemirror/lang-json": "^6.0.0", + "@codemirror/lang-xml": "^6.0.0", + "@codemirror/lang-yaml": "^6.1.2", + "@codemirror/language": "^6.10.7", + "@codemirror/lint": "^6.8.4", + "@codemirror/state": "^6.5.0", + "@codemirror/view": "^6.35.3", + "@lezer/common": "^1.2.3", + "@lezer/highlight": "^1.2.1", + "@replit/codemirror-css-color-picker": "^6.3.0", + "vue": "^3.5.30" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/use-hooks": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@scalar/use-hooks/-/use-hooks-0.4.3.tgz", + "integrity": "sha512-dhDWGwqtiVshrAv/bpJ9qPt2Mdbbqyqvtvl2Fau+S9iv7Trsc2XDbfBc40cckSj6EhajgR4EHiuCR0E4DyaveQ==", + "license": "MIT", + "dependencies": { + "@scalar/use-toasts": "0.10.2", + "@vueuse/core": "13.9.0", + "cva": "1.0.0-beta.4", + "tailwind-merge": "3.5.0", + "vue": "^3.5.30", + "zod": "^4.3.5" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/use-hooks/node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/@scalar/use-toasts": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@scalar/use-toasts/-/use-toasts-0.10.2.tgz", + "integrity": "sha512-1iHQFbDXv0YQRp13aa63S5EcTJ5K8T0ocnLxk+nziloPrLjKt6jdRt6vOHsLSv5sm9kFKcVKNQTQgialmKCOGA==", + "license": "MIT", + "dependencies": { + "vue": "^3.5.30", + "vue-sonner": "^1.3.2" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/validation": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@scalar/validation/-/validation-0.3.0.tgz", + "integrity": "sha512-4X/AP3JO23DuYxs1MMjn6IlT9gyrKPCuZj8ybTB9QIjC+3tSJLpQOwZg7HEyyz2HoVwOt9jdef2jO3RXW7DqTw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@scalar/workspace-store": { + "version": "0.47.1", + "resolved": "https://registry.npmjs.org/@scalar/workspace-store/-/workspace-store-0.47.1.tgz", + "integrity": "sha512-Qo1jzKQtYwm4kYdTb0HtbdIpsAKtkG19DX/Jy20ZvRk+OshyC0e/YLkHAAEjII89HW8VdpJcIem6fLYbBk1XgQ==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.5.3", + "@scalar/json-magic": "0.12.9", + "@scalar/openapi-upgrader": "0.2.6", + "@scalar/snippetz": "0.9.3", + "@scalar/typebox": "0.1.3", + "@scalar/types": "0.9.3", + "@scalar/validation": "0.3.0", + "github-slugger": "2.0.0", + "js-base64": "^3.7.8", + "type-fest": "^5.3.1", + "vue": "^3.5.30", + "yaml": "^2.8.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/workspace-store/node_modules/type-fest": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", + "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@schematics/angular": { "version": "21.2.1", "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.1.tgz", @@ -10908,6 +11781,15 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@swc/helpers": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz", + "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@szmarczak/http-timer": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", @@ -10921,6 +11803,32 @@ "node": ">=10" } }, + "node_modules/@tanstack/virtual-core": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz", + "integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/vue-virtual": { + "version": "3.13.24", + "resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.24.tgz", + "integrity": "sha512-A0k2qF0zFSUStXSZkGXABouXr2Tw2Ztl/cVIYG9qy84uR8W7UNjAcX3DvzBS3YnDcwvLxab8v7dbmYBZ39itDA==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.14.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.0.0" + } + }, "node_modules/@timephy/rnnoise-wasm": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@timephy/rnnoise-wasm/-/rnnoise-wasm-1.0.0.tgz", @@ -11434,6 +12342,21 @@ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", "license": "MIT" }, + "node_modules/@types/har-format": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz", + "integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==", + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/http-cache-semantics": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", @@ -11680,6 +12603,12 @@ "license": "MIT", "optional": true }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -12310,6 +13239,37 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@unhead/vue": { + "version": "2.1.13", + "resolved": "https://registry.npmjs.org/@unhead/vue/-/vue-2.1.13.tgz", + "integrity": "sha512-HYy0shaHRnLNW9r85gppO8IiGz0ONWVV3zGdlT8CQ0tbTwixznJCIiyqV4BSV1aIF1jJIye0pd1p/k6Eab8Z/A==", + "license": "MIT", + "dependencies": { + "hookable": "^6.0.1", + "unhead": "2.1.13" + }, + "funding": { + "url": "https://github.com/sponsors/harlan-zw" + }, + "peerDependencies": { + "vue": ">=3.5.18" + } + }, + "node_modules/@vercel/oidc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz", + "integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, "node_modules/@vitejs/plugin-basic-ssl": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.0.tgz", @@ -12463,6 +13423,303 @@ "dev": true, "license": "MIT" }, + "node_modules/@vue-macros/common": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.2.tgz", + "integrity": "sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==", + "license": "MIT", + "dependencies": { + "@vue/compiler-sfc": "^3.5.22", + "ast-kit": "^2.1.2", + "local-pkg": "^1.1.2", + "magic-string-ast": "^1.0.2", + "unplugin-utils": "^0.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/vue-macros" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.2.25" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.33.tgz", + "integrity": "sha512-3PZLQwFw4Za3TC8t0FvTy3wI16Kt+pmwcgNZca4Pj9iWL2E72a/gZlpBtAJvEdDMdCxdG/qq0C7PN0bsJuv0Rw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.33", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-core/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@vue/compiler-core/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.33.tgz", + "integrity": "sha512-PXq0yrfCLzzL07rbXO4awtXY1Z06LG2eu6Adg3RJFa/j3Cii217XxxLXG22N330gw7GmALCY0Z8RgXEviwgpjA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.33", + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.33.tgz", + "integrity": "sha512-UTUvRO9cY+rROrx/pvN9P5Z7FgA6QGfokUCfhQE4EnmUj3rVnK+CHI0LsEO1pg+I7//iRYMUfcNcCPe7tg0CoA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.33", + "@vue/compiler-dom": "3.5.33", + "@vue/compiler-ssr": "3.5.33", + "@vue/shared": "3.5.33", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.10", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-sfc/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/@vue/compiler-sfc/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.33.tgz", + "integrity": "sha512-IErjYdnj1qIupG5xxiVIYiiRvDhGWV4zuh/RCrwfYpuL+HWQzeU6lCk/nF9r7olWMnjKxCAkOctT2qFWFkzb1A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.33", + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/devtools-api": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.1.1.tgz", + "integrity": "sha512-bsDMJ07b3GN1puVwJb/fyFnj/U2imyswK5UQVLZwVl7O05jDrt6BHxeG5XffmOOdasOj/bOmIjxJvGPxU7pcqw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^8.1.1" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.1.1.tgz", + "integrity": "sha512-gVBaBv++i+adg4JpH71k9ppl4soyR7Y2McEqO5YNgv0BI1kMZ7BDX5gnwkZ5COYgiCyhejZG+yGNrBAjj6Coqg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^8.1.1", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" + } + }, + "node_modules/@vue/devtools-kit/node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/@vue/devtools-shared": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.1.1.tgz", + "integrity": "sha512-+h4ttmJYl/txpxHKaoZcaKpC+pvckgLzIDiSQlaQ7kKthKh8KuwoLW2D8hPJEnqKzXOvu15UHEoGyngAXCz0EQ==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.33.tgz", + "integrity": "sha512-p8UfIqyIhb0rYGlSgSBV+lPhF2iUSBcRy7enhTmPqKWadHy9kcOFYF1AejYBP9P+avnd3OBbD49DU4pLWX/94A==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.33.tgz", + "integrity": "sha512-UpFF45RI9//a7rvq7RdOQblb4tup7hHG9QsmIrxkFQLzQ7R8/iNQ5LE15NhLZ1/WcHMU2b47u6P33CPUelHyIQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.33", + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.33.tgz", + "integrity": "sha512-IOxMsAOwquhfITgmOgaPYl7/j8gKUxUFoflRc+u4LxyD3+783xne8vNta1PONVCvCV9A0w7hkyEepINDqfO0tw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.33", + "@vue/runtime-core": "3.5.33", + "@vue/shared": "3.5.33", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.33.tgz", + "integrity": "sha512-0xylq/8/h44lVG0pZFknv1XIdEgymq2E9n59uTWJBG+dIgiT0TMCSsxrN7nO16Z0MU0MPjFcguBbZV8Itk52Hw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.33", + "@vue/shared": "3.5.33" + }, + "peerDependencies": { + "vue": "3.5.33" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.33.tgz", + "integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "13.9.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-13.9.0.tgz", + "integrity": "sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "13.9.0", + "@vueuse/shared": "13.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/integrations": { + "version": "13.9.0", + "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-13.9.0.tgz", + "integrity": "sha512-SDobKBbPIOe0cVL7QxMzGkuUGHvWTdihi9zOrrWaWUgFKe15cwEcwfWmgrcNzjT6kHnNmWuTajPHoIzUjYNYYQ==", + "license": "MIT", + "dependencies": { + "@vueuse/core": "13.9.0", + "@vueuse/shared": "13.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "async-validator": "^4", + "axios": "^1", + "change-case": "^5", + "drauu": "^0.4", + "focus-trap": "^7", + "fuse.js": "^7", + "idb-keyval": "^6", + "jwt-decode": "^4", + "nprogress": "^0.2", + "qrcode": "^1.5", + "sortablejs": "^1", + "universal-cookie": "^7 || ^8", + "vue": "^3.5.0" + }, + "peerDependenciesMeta": { + "async-validator": { + "optional": true + }, + "axios": { + "optional": true + }, + "change-case": { + "optional": true + }, + "drauu": { + "optional": true + }, + "focus-trap": { + "optional": true + }, + "fuse.js": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "jwt-decode": { + "optional": true + }, + "nprogress": { + "optional": true + }, + "qrcode": { + "optional": true + }, + "sortablejs": { + "optional": true + }, + "universal-cookie": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "13.9.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.9.0.tgz", + "integrity": "sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "13.9.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-13.9.0.tgz", + "integrity": "sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -12809,6 +14066,24 @@ "node": ">=8" } }, + "node_modules/ai": { + "version": "6.0.33", + "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.33.tgz", + "integrity": "sha512-bVokbmy2E2QF6Efl+5hOJx5MRWoacZ/CZY/y1E+VcewknvGlgaiCzMu8Xgddz6ArFJjiMFNUPHKxAhIePE4rmg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "3.0.13", + "@ai-sdk/provider": "3.0.2", + "@ai-sdk/provider-utils": "4.0.5", + "@opentelemetry/api": "1.9.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/ajv": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", @@ -13262,6 +14537,18 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/aria-query": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", @@ -13311,6 +14598,38 @@ "node": ">=12" } }, + "node_modules/ast-kit": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", + "integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "pathe": "^2.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/ast-walker-scope": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.8.3.tgz", + "integrity": "sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.4", + "ast-kit": "^2.1.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -13654,6 +14973,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -14239,6 +15567,26 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chardet": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", @@ -14616,6 +15964,16 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", @@ -14958,6 +16316,18 @@ "node": ">= 0.6" } }, + "node_modules/convert-hrtime": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/convert-hrtime/-/convert-hrtime-5.0.0.tgz", + "integrity": "sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", @@ -15476,6 +16846,32 @@ "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", "license": "CC0-1.0" }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/cva": { + "version": "1.0.0-beta.4", + "resolved": "https://registry.npmjs.org/cva/-/cva-1.0.0-beta.4.tgz", + "integrity": "sha512-F/JS9hScapq4DBVQXcK85l9U91M6ePeXoBMSp7vypzShoefUBxjQTo3g3935PUHgQd+IW77DjbPRIxugy4/GCQ==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + }, + "peerDependencies": { + "typescript": ">= 4.5.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/cytoscape": { "version": "3.33.1", "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", @@ -16227,6 +17623,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, "node_modules/delaunator": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", @@ -17757,7 +19159,6 @@ "version": "3.0.6", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", - "dev": true, "license": "MIT", "engines": { "node": ">=18.0.0" @@ -17865,6 +19266,12 @@ "express": ">= 4.11" } }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "license": "MIT" + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -18187,11 +19594,20 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "license": "ISC" }, + "node_modules/focus-trap": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", + "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", + "license": "MIT", + "dependencies": { + "tabbable": "^6.4.0" + } + }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -18671,6 +20087,31 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/function-timeout": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/function-timeout/-/function-timeout-1.0.2.tgz", + "integrity": "sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fuse.js": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.3.0.tgz", + "integrity": "sha512-plz8RVjfcDedTGfVngWH1jmJvBvAwi1v2jecfDerbEnMcmOYUEEwKFTHbNoCiYyzaK2Ws8lABkTCcRSqCY1q4w==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/krisk" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -18731,6 +20172,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-own-enumerable-keys": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/get-own-enumerable-keys/-/get-own-enumerable-keys-1.0.0.tgz", + "integrity": "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -18767,6 +20220,12 @@ "dev": true, "license": "MIT" }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "license": "ISC" + }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -19007,6 +20466,15 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/guess-json-indent": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/guess-json-indent/-/guess-json-indent-3.0.1.tgz", + "integrity": "sha512-LWZ3Vr8BG7DHE3TzPYFqkhjNRw4vYgFSsv2nfMuHklAlOfiy54/EwiDQuQfFVLxENCVv20wpbjfTayooQHrEhQ==", + "license": "MIT", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/hachure-fill": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", @@ -19089,6 +20557,339 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-embedded": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-embedded/-/hast-util-embedded-3.0.0.tgz", + "integrity": "sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-is-element": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-format": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hast-util-format/-/hast-util-format-1.1.0.tgz", + "integrity": "sha512-yY1UDz6bC9rDvCWHpx12aIBGRG7krurX0p0Fm6pT547LwDIZZiNr8a+IHDogorAdreULSEzP82Nlv5SZkHZcjA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-minify-whitespace": "^1.0.0", + "hast-util-phrasing": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "html-whitespace-sensitive-tag-names": "^3.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/hast-util-from-html/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-has-property": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-has-property/-/hast-util-has-property-3.0.0.tgz", + "integrity": "sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-body-ok-link": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-is-body-ok-link/-/hast-util-is-body-ok-link-3.0.1.tgz", + "integrity": "sha512-0qpnzOBLztXHbHQenVB8uNuxTnm/QBFUOmdOSsEn7GnBtyY07+ENTWVFBAnXd/zEgd9/SUG3lRY7hSIBWRgGpQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-minify-whitespace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hast-util-minify-whitespace/-/hast-util-minify-whitespace-1.0.1.tgz", + "integrity": "sha512-L96fPOVpnclQE0xzdWb/D12VT5FabA7SnZOUMtL1DbXmYiHJMXZvFkIZfiMmTCNJHUeO2K9UYNXoVyfz+QHuOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-phrasing": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-phrasing/-/hast-util-phrasing-3.0.1.tgz", + "integrity": "sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-has-property": "^3.0.0", + "hast-util-is-body-ok-link": "^3.0.0", + "hast-util-is-element": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/hast-util-raw/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/hast-util-sanitize": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", + "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "unist-util-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -19098,6 +20899,15 @@ "he": "bin/he" } }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/homedir-polyfill": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", @@ -19120,6 +20930,12 @@ "node": ">=16.9.0" } }, + "node_modules/hookable": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-6.1.1.tgz", + "integrity": "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==", + "license": "MIT" + }, "node_modules/hosted-git-info": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz", @@ -19213,6 +21029,26 @@ ], "license": "MIT" }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/html-whitespace-sensitive-tag-names": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-whitespace-sensitive-tag-names/-/html-whitespace-sensitive-tag-names-3.0.1.tgz", + "integrity": "sha512-q+310vW8zmymYHALr1da4HyXUQ0zgiIwIicEfotYPWGN0OJVEN/58IJ3A4GBYcEq3LGAZqKb+ugvP0GNB9CEAA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/htmlparser2": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz", @@ -19666,6 +21502,21 @@ "postcss": "^8.1.0" } }, + "node_modules/identifier-regex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/identifier-regex/-/identifier-regex-1.0.1.tgz", + "integrity": "sha512-ZrYyM0sozNPZlvBvE7Oq9Bn44n0qKGrYu5sQ0JzMUnjIhpgWYE2JB6aBoFwEYdPjqj7jPyxXTMJiHDOxDfd8yw==", + "license": "MIT", + "dependencies": { + "reserved-identifiers": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -19842,6 +21693,18 @@ "node": ">= 0.10" } }, + "node_modules/is-absolute-url": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-4.0.1.tgz", + "integrity": "sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -19952,6 +21815,22 @@ "node": ">=0.10.0" } }, + "node_modules/is-identifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-identifier/-/is-identifier-1.0.1.tgz", + "integrity": "sha512-HQ5v4rEJ7REUV54bCd2l5FaD299SGDEn2UPoVXaTHAyGviLq2menVUD2udi3trQ32uvB6LdAh/0ck2EuizrtpA==", + "license": "MIT", + "dependencies": { + "identifier-regex": "^1.0.0", + "super-regex": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", @@ -20025,6 +21904,18 @@ "node": ">=0.12.0" } }, + "node_modules/is-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-3.0.0.tgz", + "integrity": "sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-plain-obj": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", @@ -20053,6 +21944,18 @@ "dev": true, "license": "MIT" }, + "node_modules/is-regexp": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-3.1.0.tgz", + "integrity": "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-typed-array": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", @@ -20378,6 +22281,12 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-base64": { + "version": "3.7.8", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", + "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", + "license": "BSD-3-Clause" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -20424,6 +22333,12 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -21215,6 +23130,40 @@ "node": ">=8.9.0" } }, + "node_modules/local-pkg": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", + "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/local-pkg/node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "license": "MIT" + }, + "node_modules/local-pkg/node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -21401,6 +23350,21 @@ "node": ">=8" } }, + "node_modules/lowlight": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", + "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.0.0", + "highlight.js": "~11.11.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -21423,12 +23387,55 @@ "version": "0.30.19", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", - "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magic-string-ast": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-1.0.3.tgz", + "integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==", + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.19" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/make-asynchronous": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/make-asynchronous/-/make-asynchronous-1.1.0.tgz", + "integrity": "sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==", + "license": "MIT", + "dependencies": { + "p-event": "^6.0.0", + "type-fest": "^4.6.0", + "web-worker": "^1.5.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-asynchronous/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/make-dir": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", @@ -21702,6 +23709,27 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-to-markdown": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", @@ -21841,6 +23869,12 @@ "node": ">= 0.6" } }, + "node_modules/microdiff": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/microdiff/-/microdiff-1.5.0.tgz", + "integrity": "sha512-Drq+/THMvDdzRYrK0oxJmOKiC24ayUV8ahrt8l3oRK51PWt6gdtrIGrlIH3pT/lFh1z93FbAcidtsHcWbnRz8Q==", + "license": "MIT" + }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -22764,6 +24798,109 @@ "ufo": "^1.6.1" } }, + "node_modules/monaco-editor": { + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", + "license": "MIT", + "dependencies": { + "dompurify": "3.2.7", + "marked": "14.0.0" + } + }, + "node_modules/monaco-editor/node_modules/dompurify": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/monaco-editor/node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/monaco-languageserver-types": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/monaco-languageserver-types/-/monaco-languageserver-types-0.4.0.tgz", + "integrity": "sha512-QQ3BZiU5LYkJElGncSNb5AKoJ/LCs6YBMCJMAz9EA7v+JaOdn3kx2cXpPTcZfKA5AEsR0vc97sAw+5mdNhVBmw==", + "license": "MIT", + "dependencies": { + "monaco-types": "^0.1.0", + "vscode-languageserver-protocol": "^3.0.0", + "vscode-uri": "^3.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/remcohaszing" + } + }, + "node_modules/monaco-marker-data-provider": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/monaco-marker-data-provider/-/monaco-marker-data-provider-1.2.5.tgz", + "integrity": "sha512-5ZdcYukhPwgYMCvlZ9H5uWs5jc23BQ8fFF5AhSIdrz5mvYLsqGZ58ZLxTv8rCX6+AxdJ8+vxg1HVSk+F2bLosg==", + "license": "MIT", + "dependencies": { + "monaco-types": "^0.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/remcohaszing" + } + }, + "node_modules/monaco-types": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/monaco-types/-/monaco-types-0.1.2.tgz", + "integrity": "sha512-8LwfrlWXsedHwAL41xhXyqzPibS8IqPuIXr9NdORhonS495c2/wky+sI1PRLvMCuiI0nqC2NH1six9hdiRY4Xg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/remcohaszing" + } + }, + "node_modules/monaco-worker-manager": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/monaco-worker-manager/-/monaco-worker-manager-2.0.1.tgz", + "integrity": "sha512-kdPL0yvg5qjhKPNVjJoym331PY/5JC11aPJXtCZNwWRvBr6jhkIamvYAyiY5P1AWFmNOy0aRDRoMdZfa71h8kg==", + "license": "MIT", + "peerDependencies": { + "monaco-editor": ">=0.30.0" + } + }, + "node_modules/monaco-yaml": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/monaco-yaml/-/monaco-yaml-5.4.1.tgz", + "integrity": "sha512-YQ6d/Ei98Uk073SJLFbwuSi95qhnl8F8NNmIUqN2XhDt9psZN2LqQ1T7pPQ866NJb2wFj44IrjnANgpa2jTfag==", + "license": "MIT", + "workspaces": [ + "examples/*" + ], + "dependencies": { + "jsonc-parser": "^3.0.0", + "monaco-languageserver-types": "^0.4.0", + "monaco-marker-data-provider": "^1.0.0", + "monaco-types": "^0.1.0", + "monaco-worker-manager": "^2.0.0", + "path-browserify": "^1.0.0", + "prettier": "^3.0.0", + "vscode-languageserver-textdocument": "^1.0.0", + "vscode-languageserver-types": "^3.0.0", + "vscode-uri": "^3.0.0", + "yaml": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/remcohaszing" + }, + "peerDependencies": { + "monaco-editor": ">=0.36" + } + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -22814,6 +24951,12 @@ "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" } }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "license": "MIT" + }, "node_modules/multicast-dns": { "version": "7.2.5", "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", @@ -22950,6 +25093,15 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "license": "MIT" }, + "node_modules/neverpanic": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/neverpanic/-/neverpanic-0.0.7.tgz", + "integrity": "sha512-GFRTSX2JAEATOCQYlyFkR+9FJPl0pD24toE1foqYAsL6aPLlRKn6L0UFOtJhZCxEbDv+SUsiW4AcPs9cIFwkFw==", + "license": "MIT", + "peerDependencies": { + "typescript": "5" + } + }, "node_modules/ngx-remark": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/ngx-remark/-/ngx-remark-0.2.2.tgz", @@ -23924,6 +26076,21 @@ "node": ">=8" } }, + "node_modules/p-event": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/p-event/-/p-event-6.0.1.tgz", + "integrity": "sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==", + "license": "MIT", + "dependencies": { + "p-timeout": "^6.1.2" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-is-promise": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-3.0.0.tgz", @@ -24003,6 +26170,18 @@ "node": ">= 4" } }, + "node_modules/p-timeout": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz", + "integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -24089,6 +26268,18 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse-node-version": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz", @@ -24296,6 +26487,12 @@ "dev": true, "license": "MIT" }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -25014,9 +27211,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", "funding": [ { "type": "opencollective", @@ -25796,7 +27993,6 @@ "version": "3.8.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", - "dev": true, "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" @@ -25847,6 +28043,21 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/prismjs": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", @@ -25903,6 +28114,16 @@ "node": ">=10" } }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -25964,6 +28185,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -25997,6 +28234,140 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/radix-vue": { + "version": "1.9.17", + "resolved": "https://registry.npmjs.org/radix-vue/-/radix-vue-1.9.17.tgz", + "integrity": "sha512-mVCu7I2vXt1L2IUYHTt0sZMz7s1K2ZtqKeTIxG3yC5mMFfLBG4FtE1FDeRMpDd+Hhg/ybi9+iXmAP1ISREndoQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.6.7", + "@floating-ui/vue": "^1.1.0", + "@internationalized/date": "^3.5.4", + "@internationalized/number": "^3.5.3", + "@tanstack/vue-virtual": "^3.8.1", + "@vueuse/core": "^10.11.0", + "@vueuse/shared": "^10.11.0", + "aria-hidden": "^1.2.4", + "defu": "^6.1.4", + "fast-deep-equal": "^3.1.3", + "nanoid": "^5.0.7" + }, + "peerDependencies": { + "vue": ">= 3.2.0" + } + }, + "node_modules/radix-vue/node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, + "node_modules/radix-vue/node_modules/@vueuse/core": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz", + "integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "10.11.1", + "@vueuse/shared": "10.11.1", + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/radix-vue/node_modules/@vueuse/core/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/radix-vue/node_modules/@vueuse/metadata": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz", + "integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/radix-vue/node_modules/@vueuse/shared": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz", + "integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==", + "license": "MIT", + "dependencies": { + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/radix-vue/node_modules/@vueuse/shared/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/radix-vue/node_modules/nanoid": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.9.tgz", + "integrity": "sha512-ZUvP7KeBLe3OZ1ypw6dI/TzYJuvHP77IM4Ry73waSQTLn8/g8rpdjfyVAh7t1/+FjBtG4lCP42MEbDxOsRpBMw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, "node_modules/rambda": { "version": "9.4.2", "resolved": "https://registry.npmjs.org/rambda/-/rambda-9.4.2.tgz", @@ -26226,6 +28597,97 @@ "regjsparser": "bin/parser" } }, + "node_modules/rehype-external-links": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/rehype-external-links/-/rehype-external-links-3.0.0.tgz", + "integrity": "sha512-yp+e5N9V3C6bwBeAC4n796kc86M4gJCdlVhiMTxIrJG5UHDMh+PJANf9heqORJbt1nrCbDwIlAZKjANIaVBbvw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-is-element": "^3.0.0", + "is-absolute-url": "^4.0.0", + "space-separated-tokens": "^2.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-format": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/rehype-format/-/rehype-format-5.0.1.tgz", + "integrity": "sha512-zvmVru9uB0josBVpr946OR8ui7nJEdzZobwLOOqHb/OOD88W0Vk2SqLwoVOj0fM6IPCCO6TaV9CvQvJMWwukFQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-format": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-parse": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz", + "integrity": "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-html": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-sanitize": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", + "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-sanitize": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-stringify": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", + "integrity": "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-html": "^9.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark": { "version": "15.0.1", "resolved": "https://registry.npmjs.org/remark/-/remark-15.0.1.tgz", @@ -26291,6 +28753,23 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-stringify": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", @@ -26348,6 +28827,18 @@ "url": "https://github.com/sponsors/jet2jet" } }, + "node_modules/reserved-identifiers": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/reserved-identifiers/-/reserved-identifiers-1.2.0.tgz", + "integrity": "sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -27224,6 +29715,12 @@ } } }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "license": "MIT" + }, "node_modules/secure-compare": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/secure-compare/-/secure-compare-3.0.1.tgz", @@ -27463,6 +29960,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -27920,6 +30423,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/spawn-command": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz", @@ -28141,6 +30654,24 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-byte-length": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/string-byte-length/-/string-byte-length-3.0.1.tgz", + "integrity": "sha512-yJ8vP0HMwZ54CcA8S8mKoXbkezpZHANFtmafFo8lGxZThCQcAwRHjdFabuSLgOzxj9OFJcmssmiAvmcOK4O2Hw==", + "license": "MIT", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/string-byte-slice": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/string-byte-slice/-/string-byte-slice-3.0.1.tgz", + "integrity": "sha512-GWv2K4lYyd2+AhmKH3BV+OVx62xDX+99rSLfKpaqFiQU7uOMaUY1tDjdrRD4gsrCr9lTyjMgjna7tZcCOw+Smg==", + "license": "MIT", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/string-width": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", @@ -28209,6 +30740,38 @@ "node": ">=8" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-object": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-6.0.0.tgz", + "integrity": "sha512-6f94vIED6vmJJfh3lyVsVWxCYSfI5uM+16ntED/Ql37XIyV6kj0mRAAiTeMMc/QLYIaizC3bUprQ8pQnDDrKfA==", + "license": "BSD-2-Clause", + "dependencies": { + "get-own-enumerable-keys": "^1.0.0", + "is-identifier": "^1.0.1", + "is-obj": "^3.0.0", + "is-regexp": "^3.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-ansi": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", @@ -28361,6 +30924,23 @@ "node": ">= 8.0" } }, + "node_modules/super-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/super-regex/-/super-regex-1.1.0.tgz", + "integrity": "sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ==", + "license": "MIT", + "dependencies": { + "function-timeout": "^1.0.1", + "make-asynchronous": "^1.0.1", + "time-span": "^5.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -28447,6 +31027,15 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/swrv": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/swrv/-/swrv-1.2.0.tgz", + "integrity": "sha512-lH/g4UcNyj+7lzK4eRGT4C68Q4EhQ6JtM9otPRIASfhhzfLWtbZPHcMuhuba7S9YVYuxkMUGImwMyGpfbkH07A==", + "license": "Apache-2.0", + "peerDependencies": { + "vue": ">=3.2.26 < 4" + } + }, "node_modules/sync-child-process": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz", @@ -28484,6 +31073,24 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "license": "MIT" + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tailwind-merge": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz", @@ -28910,6 +31517,21 @@ "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", "license": "MIT" }, + "node_modules/time-span": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/time-span/-/time-span-5.1.0.tgz", + "integrity": "sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==", + "license": "MIT", + "dependencies": { + "convert-hrtime": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tiny-async-pool": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", @@ -29079,6 +31701,16 @@ "tree-kill": "cli.js" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/trough": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", @@ -29089,6 +31721,20 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/truncate-json": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/truncate-json/-/truncate-json-3.0.1.tgz", + "integrity": "sha512-QVsbr1WhGLq2F0oDyYbqtOXcf3gcnL8C9H5EX8bBwAr8ZWvWGJzukpPrDrWgJMrNtgDbo74BIjI4kJu3q2xQWw==", + "license": "MIT", + "dependencies": { + "guess-json-indent": "^3.0.1", + "string-byte-length": "^3.0.1", + "string-byte-slice": "^3.0.1" + }, + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/truncate-utf8-bytes": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", @@ -29941,6 +32587,18 @@ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, + "node_modules/unhead": { + "version": "2.1.13", + "resolved": "https://registry.npmjs.org/unhead/-/unhead-2.1.13.tgz", + "integrity": "sha512-jO9M1sI6b2h/1KpIu4Jeu+ptumLmUKboRRLxys5pYHFeT+lqTzfNHbYUX9bxVDhC1FBszAGuWcUVlmvIPsah8Q==", + "license": "MIT", + "dependencies": { + "hookable": "^6.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/harlan-zw" + } + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", @@ -30049,6 +32707,20 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unist-util-is": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", @@ -30062,6 +32734,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unist-util-stringify-position": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", @@ -30122,6 +32807,36 @@ "node": ">= 0.8" } }, + "node_modules/unplugin": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz", + "integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/unplugin-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.1.tgz", + "integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==", + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, "node_modules/untildify": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/untildify/-/untildify-3.0.3.tgz", @@ -30276,6 +32991,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vfile-message": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", @@ -30365,6 +33094,15 @@ } } }, + "node_modules/vite-plugin-monaco-editor": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/vite-plugin-monaco-editor/-/vite-plugin-monaco-editor-1.1.0.tgz", + "integrity": "sha512-IvtUqZotrRoVqwT0PBBDIZPNraya3BxN/bfcNfnxZ5rkJiGcNtO5eAOWWSgT7zullIAEqQwxMU83yL9J5k7gww==", + "license": "MIT", + "peerDependencies": { + "monaco-editor": ">=0.33.0" + } + }, "node_modules/vite/node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -30998,6 +33736,121 @@ "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", "license": "MIT" }, + "node_modules/vue": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.33.tgz", + "integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.33", + "@vue/compiler-sfc": "3.5.33", + "@vue/runtime-dom": "3.5.33", + "@vue/server-renderer": "3.5.33", + "@vue/shared": "3.5.33" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.2.7.tgz", + "integrity": "sha512-+gPp5YGmhfsj1IN+xUo7y0fb4clfnOiiUA39y07yW1VzCRjzVgwLbtmdWlghh7mXrPsEaYc7rrIir/HT6C8vYQ==", + "license": "MIT" + }, + "node_modules/vue-router": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.4.tgz", + "integrity": "sha512-lCqDLCI2+fKVRl2OzXuzdSWmxXFLQRxQbmHugnRpTMyYiT+hNaycV0faqG5FBHDXoYrZ6MQcX87BvbY8mQ20Bg==", + "license": "MIT", + "dependencies": { + "@babel/generator": "^7.28.6", + "@vue-macros/common": "^3.1.1", + "@vue/devtools-api": "^8.0.6", + "ast-walker-scope": "^0.8.3", + "chokidar": "^5.0.0", + "json5": "^2.2.3", + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "mlly": "^1.8.0", + "muggle-string": "^0.4.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "scule": "^1.3.0", + "tinyglobby": "^0.2.15", + "unplugin": "^3.0.0", + "unplugin-utils": "^0.3.1", + "yaml": "^2.8.2" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@pinia/colada": ">=0.21.2", + "@vue/compiler-sfc": "^3.5.17", + "pinia": "^3.0.4", + "vue": "^3.5.0" + }, + "peerDependenciesMeta": { + "@pinia/colada": { + "optional": true + }, + "@vue/compiler-sfc": { + "optional": true + }, + "pinia": { + "optional": true + } + } + }, + "node_modules/vue-router/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/vue-router/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/vue-router/node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/vue-sonner": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/vue-sonner/-/vue-sonner-1.3.2.tgz", + "integrity": "sha512-UbZ48E9VIya3ToiRHAZUbodKute/z/M1iT8/3fU8zEbwBRE11AKuHikssv18LMk2gTTr6eMQT4qf6JoLHWuj/A==", + "license": "MIT" + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -31069,6 +33922,22 @@ "license": "MIT", "optional": true }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/web-worker": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", + "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -31679,6 +34548,12 @@ } } }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "license": "MIT" + }, "node_modules/webpack/node_modules/eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", @@ -32224,7 +35099,6 @@ "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 4809d11..4d3aef1 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "@ngrx/entity": "^21.0.1", "@ngrx/store": "^21.0.1", "@ngrx/store-devtools": "^21.0.1", + "@scalar/api-reference": "^1.53.1", "@spartan-ng/brain": "^0.0.1-alpha.589", "@spartan-ng/cli": "^0.0.1-alpha.589", "@spartan-ng/ui-core": "^0.0.1-alpha.380", @@ -169,6 +170,10 @@ "filter": [ "**/*" ] + }, + { + "from": "node_modules/@scalar/api-reference/dist/browser/standalone.js", + "to": "scalar/api-reference.js" } ], "nodeGypRebuild": false, diff --git a/server/data/metoyou.sqlite b/server/data/metoyou.sqlite index 14a5f57dec35b9ff43339537aa9f53c33226b01f..78ebdb4e7a8a69b744ebc234aa9b44f735631d43 100644 GIT binary patch delta 9847 zcmb7Jdw3Jql^@M$B+ckC4#pv_Lo6GFY(Pe%w+tqw0h~v-un^}(2#lo>7M3L;$>#0x zU>*q}!NGv?AOupAH+k3r=k=v2*<`zv5cszJFeDJ*%eG0AHcd9^Hf@sKb7$sCHtn{~ zA0Geiy}x_Tx#yg@ntN{7KCbHxuIC2^$IApk%)HM&lXSfN!qUEJV~mzf1h>idt!=gS zJ8QR9Z`m|ZRg$tu>y5^yak907_6L1of5;bc`2;cG^7$gXt2ylET_IkK`h<|XSqyl8 z+#ZdJA#Yd|_@Lh%@y9~mW_Qdj26$gE<`qMMW`E4fH}jD|%)kY=Ha@kr=_s)M|;sD?9pQO!byGU?mZZY>9H)OjwQ1ZhQ zwtSCGr>`B2iN+eLNrztJUBNwP>#<(TEoK`me_~&< zEn~OaUN%2#_FC4OH(3@iX-lp7cH2zw>g9}Lt`u|of}v=0vnv>jMqNHJ=y8Q3A-5|W zj)5-&LBTJErXX`Yv7kQ?fDyMC@c@wzx|#!iw<{KoxxId`7>)|!WJL7&!S|jh3QEB3 z@_G5NE8HB2xoSmqf(!2dkjc#)91$fr>t~gtx ztI0q2nXX3X%=cVH*61do@qv5KbkGzS&}%$jYhfGMvCJpT5#|9VJ>Wj?(}FaboKk`` z8ln=U(Gb-j!5b(2vsX?D(r82~K^hHF2@)>|-aw$)n=ikIymH{)-*xi^;TF#N`BT3BLeat6nd`IA?Y6Q_u)gtl}- zbP4UzcxsN?u+Ksb`vNg3w29G#VB>OHSxcSo8sXlQS=$)j4b<^p)h-pVdv%ap+$y zl4YQ6UVT%QLS;K5u4Q6U%m|6WXU^(w0nyeK1f&(Dn)N~Jioufex+PZSWzFyXy>6ne z1f|X3k>BfnL|RDhE7(GbncQEwuLev0sQWKYf@Xv4f#-~cEIbFPZo-r_UgWl$mYC+5 zuBH#re$zC=Y-`x^-_}~L(=<+hvvG&v=dcdO*llzea|WMbqG7B-Z+wk5aiU?3@o8=$ zy~q$Z+(Q4m;V1O8u}HhpcZHZ`yS&>6xJ~vm~hREQ>7HTf&wa z%Q%a{{FV7b>Qm}JsKe%?=BL;;_ICDP*y+@x=H1i|s++B4$Fnx(E9Qb}(xEjtx;l-qpF~}JS{G>N5u$nxRk1ec04KO?6Hin%)W93acB(Xgg!AUMC|EQ zA|Ymo7ptj8=!&-4JDL+hveh0*C3Bfn!oD(ic{ zDkFKmK14jL4Dt0rJan*}@{{g*kJbjuMRASLoDhj;G-?1-rKhzXKkj)->+s=@C$$b9 zcRYc_^YvK9F!7ITR4=AJrgdObhy!TRU^Q=VYqO`tj1)(k&lCGJRRsqI})Q-ZEA5z-| zs~<$xfkJGxX59mdc5i)%C+^qm_l~qQuL>&oW4F4V;wmL&^}7(wT_477+o`rgxZSI^ z3jyAtws8cA`!t7mi5_jyK4QD#4|jb*_q|$2VO94)heYzQF05x8lFJwExLa)(?AWTB zf5{^6LcK`Qh#=jmsH^7-PTit)6b#y|>W4UD{hJhr`s(>Gu~AiBFs)lv;v1=C1CGC& zkktu=@OPn4C_luEmk{E8^ed z%ArFp!?9S!Q4>%H*1NKdnx^CHy`B+HWp6JRSzuv9tn4}1r;Dhu_(@|J5$ z_Il%kdrah0-TI+3uMZAhzm0sQlo~XCnm6tvPfF%abJpBqUTj`qZZyv@^X5tB3Ui5> zX8*!|#(v12W{1u8+S#$JiTQ^4Bl96M#2jPZ zU|wXNVD>OQ%tmGvlVFxIH!{~UGZ;5Bk-37gFofw#(?_QFO(#tMW_ro=jHz;;X{YH< z(>hby6gAyq`myPUCZDOsbfu}pq&NQA_$hn=bQmNvMU%R{*;T*XMhs&7*m&YF#s_X7 z4q@E4lXwy1ZXYp#@rGkWe*rHcUck7MA)eQ8%RNKHa~R*H{gsE>HEfGde7G<<8~G2(HI8#&@JjOVrx2QZ$qh3Lb$s*L!lnUa*% z-)iq69>oKd_Y?aIm?8FQ_@+m1CLY0f|F4L>1w5O07~_41h&>puJxDx+@tX6*g9RKV z9w=Z*N8GP9uDkn2B9C!2N9@Kp@-?xmfL9VbF`j8BdNFS3Cw3HY192b5Gu|e8G~7sk zLu@bLjl{hOOAz};{YS(-ctF37*rwsRA513h#`s(xv9*B9h`TU88zb)2@HMCB5nC`G zdW+axz;lRA7@s;yY}9Z=+D}N`xRH9B*igVBLe{X|bB5?*DOv}H+9w1h3`=))Vn}_l zUW1kIZeCY_W7cALe9xK!tX-|b%y)~9tYRVlAQ10{_?%4<_C|nu7|e5WIUe zJmc5B**RHNFq)&Xa;_M?h6beJ+AW>6s)o^=vGc+^y6>@u>57I;oi(ZkbU6QKXY;&J z`F%756%E~;PF2Hb4)wh!-~S#<@F@!9&Pl3*(G&xEXLU|=k2=n7MS-;YI0>2Akl&Q= zQjmSqH@n{@zbS$2{R_%MlK7S|nu%|Ft4cnnwwcNNSEtFpvO_3tbpNz*ms%jH>+nX*wf@13HghNPV!>x4wYC8Xo7=2RSx9O;CR6X77y9?yu; zJNPar+uQUWIrSecqIzm|E}lz>)%Df05(zuf`=jC#?WzhOMa66+6Hn*jsU)I)uf%pt}0cHw$BE&i4>f2s@E^hFFi|E%}ZxeF$iDX%1k^b!lK%p&kRF3Fk+#Fs3#NV{KwK3x~!0x4WtG>$FC;PK?HL^HwHdZlNnGCd8b0NoQLs z3S|bxfC4eQIvL69f>fyl=h0+ROl0c{+Q3N27Hp>0DvYK*>AI+p6F@D3P(q0#to*No zJYB7nR|-8sG^%tg%)zqThIii-kBX^<6LG&%qN(dhB_OAPrZ&Y`OpN5R`Kmm%n1(aE zkjR_Qku6nItJP2DI%I;9)!7!9RP4;32vUV|Ouf2>{JwMK?1rgpRoYLW`U9Dzy>Im( z2n0mo{fcNg2R+xulR4p^#`xVos-r5fhS-w~oVhhfplWU4(G5z$*cKO@HCtE%hQR_9hL+Tm4>Czl~3#hB2Z z$blH`ZOO0wfV|R~f}nN8#g)~IK{NPvzJS77UB9SWD^Ix6s3-rk56E#+E|r2YU<&E< ze6UgB!G@yrL7JyiVA?Fj70M8{3{63En}y+{*`$!pwxn`sRLl8cbK}Wu8oY`I#kOWK z3If{}VaL}+giN%0@p_o3>@H297cDv(&!S|~qNMFP+V%iD1`10;XciOTh2i^0imO^!EjQL~p8|J)uow><8)l9J` z*oW8*+sIC4Ddv6V5OY7XmbrroFjts9G`(ipYs#6fH`$G!8ecN@7+Z`pjbjbJGaNVc z8@3vn4fTdG^f~%4-9m}YKVH2N>D$dt{~qh%lgSX$va4& z?$5g8x_;fAx*K&@5nsR$(p0Hd_RtOoO%E?hEf`jyg1!FhSB{~B(}rVSnWv{sL)=TM zRSPcdP-ZPrW-Z}mH|=tvStE;C3s74cR^39A4l`&{C*zc-(Gw=n^srjjFbK?#-x>lY zoVLp4I=ZY3m{0^me`xH@Ry1~Zt5bHVQi^3Upx{G0VoFC2AnMiwVRE13Pg zJ~a7AP?;Na%2Ti!T#NxV7$WAb62x3w;*=+2rh43$a#^6_qd%hVY(&)C8=Z13rs8@8 zFfl~T-af=!)aR6IFcTl{fr+8QtW=nlVcDrHvS^%CtlJeki+wQpOy6TL8BU!}c@kEG zDm_gBbvdfYfANuj`m>W|7OO5s~8A%!ck7jOv%7KI@&Po240C+*F`dAnS`O3h)& zG*|*sa_aQ2K+H}1f8~(J(^XZVq>xZWr0`}dyx9)&m-QN^Io}J2?ls)WM%YkI*_^jQDs)NLcRj| zPNI2KIaMCYFgpI;v_|KZ$KeQ%tSEweMs~ogqi@~_v!JZtkT2Iv#%VbsL67dqAzn1c z%Vk<7NF$4JC_Xqfv`jeq(AQ{E2*n)p)pK>aNEK@jC2? zH`DHr$Iw+0MAlu{k%|Zr+xro*q2D22rU}Gbio1{t3a>%oHI&OGI90vidoLQxy^Y3Z zyj?DHIKzD4LO2@@{i5L;-CijR)e@&{Q!}+#Csy37em5!9jUc9x-sqIAm{~}Q;_ir; zGd@C0{YOsOf|+p%yqz&8ujZOvRR87E`1k^&xkl~G7zCg z4=R>_J~!I=;4D1!`r{#GG~|#8O*P)_M(lh0m!AP1)VnGqT0;A$QlD2IAZ7m|^s5RL zu!>H+Ixnx(?a|;3yU<|iu8Q?oe-P;pBK`ZPe}M+6FC6O>^-$Q?vZII)&A1t8P(gF7 z#qS>u8Cr57>GvDQD7-O_HJUgay5ehs#2NM=+VMRVt5uWG1p)f?Lc92WL#;AcTf0j6 z%0jOI{`^odzIgzy)O$p!&>eBE#BWZ)eZ`vvg?PPM%JX)pd*r3}43Ov}FCNgO9kBmr zzPs%Z->yYl+36(sELQKaF8wK0=0GT5PSBatQnGMK0V=R_5wUGPBKqe$vzS=S-NHo% zA|~L+CG(x{pd+LDR4RNHjMzq`gh%0d%2z1c7P{|P0DnkC>+@*H`@A!)=mL*oUBe&{ zvwBF!3=}DyDNMv~0uVJQWf7A)iI~4U=}clK_B=2#REY3l3B*=Mo0by8Bo;F#OP)ov zqh}onjfQ26h-2on+jMsTZ}7J9t#o-g{KPA_FX|JtW&Ti##2hZISgvLkx{-ljN*+pQ zLz#;aH274cB97C}3-3w27j65Q3*Bh&$?n0Ik5P@(7*vL$IRnAB2P>Sh>i_@% delta 960 zcmX|=acEUl9LLW&@4ma;bI$Mgd+*7pm&G&}I7PHJ!Z|leisnEZ8xN9gsEln=8d)%i zro0drg1I+ey$g>S77J>#t$Cr_wAfH^ByYA=TO*P7#{Ni2GMo0tv^sZ+|D5}~zx(C$ z{r-OU-sBi1PgCj%yEw`itLj-*EsCSVd-^s;Y_J#|#ryaU{0m3G@>WM~Y3Gn#kchtr z@o>6sxWs$5>L(GgNikB?OhYtF<<#XxSN((OH@~+6NxE$3lx5Q>^JLus_0(x^boa}9 zYS+j%23eYgV%bLJdV;!?go~uG2ZUgP2W^9LIs0qYM%De&0;|Qiq$RVtTtnSnGfel>L z69Gzs}jg~BahC*tuU z$Ia(>iQApy5r5?bzb-UEoWYlH2;#8ak4^GPo!%6wur^wWxI27}KchNYHtcPCoyx4l z>g5KRMtkcUt#9q!kB?LV(e}?==(YMWZL2$KL0wk;@_=lTJ7lHG$Wr-~ zMCXo^_qTEJqA>gR!+vj}=s#jjQ*X?#Juc3z2<=nH+=A0$%ItMm`o{*u+qN~+bZPeF z+0NiPlTJP;uAcOQ#`xD>((5#m#+t#i=Q>4DeC8*=6%Wc!Go#|(3e)QcGpmJ+QIZ%Y z(cu4r%oSl6jWxcTT&|ifEo6QW_t5yv2YT+@X3lLR{oM&2S3EN%mgG$Rfm{4kaJnCV z7<%5X{83C=GT0=iaYp0jZ~2bGo2|r#hi`x2PZ4?ee38+j7fs$EfAK#N84DHZ5w~D0 PwB9(pul; manifestUrls?: string[]; preferredVersion?: string | null; vaapiVideoEncode?: boolean; } +export interface LocalApiSettings { + enabled: boolean; + port: number; + exposeOnLan: boolean; + scalarEnabled: boolean; + allowedSignalingServers: string[]; +} + +export type LocalApiStatus = 'stopped' | 'starting' | 'running' | 'error'; + +export interface LocalApiSnapshot { + status: LocalApiStatus; + host: string | null; + port: number | null; + baseUrl: string | null; + error: string | null; + exposeOnLan: boolean; + scalarEnabled: boolean; +} + export interface DesktopNotificationPayload { body: string; requestAttention: boolean; @@ -230,6 +253,8 @@ export interface ElectronApi { restartToApplyUpdate: () => Promise; onAutoUpdateStateChanged: (listener: (state: DesktopUpdateState) => void) => () => void; setDesktopSettings: (patch: DesktopSettingsPatch) => Promise; + getLocalApiStatus: () => Promise; + openLocalApiDocs: () => Promise<{ opened: boolean; reason?: string }>; relaunchApp: () => Promise; onDeepLinkReceived: (listener: (url: string) => void) => () => void; readClipboardFiles: () => Promise; diff --git a/toju-app/src/app/core/services/settings-modal.service.ts b/toju-app/src/app/core/services/settings-modal.service.ts index 140053d..fe73b1d 100644 --- a/toju-app/src/app/core/services/settings-modal.service.ts +++ b/toju-app/src/app/core/services/settings-modal.service.ts @@ -8,6 +8,7 @@ export type SettingsPage = | 'notifications' | 'voice' | 'updates' + | 'localApi' | 'data' | 'debugging' | 'server' diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html index 0d13261..c308900 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html @@ -91,16 +91,18 @@ @if (msg.isDeleted) {
{{ deletedMessageContent }}
} @else { - @if (requiresRichMarkdown(msg.content)) { - @defer { -
- -
- } @placeholder { + @if (pluginEmbeds().length === 0) { + @if (requiresRichMarkdown(msg.content)) { + @defer { +
+ +
+ } @placeholder { +
{{ msg.content }}
+ } + } @else {
{{ msg.content }}
} - } @else { -
{{ msg.content }}
} @if (msg.linkMetadata?.length) { @@ -116,7 +118,10 @@ } @if (pluginEmbeds().length > 0) { -
+
@for (embed of pluginEmbeds(); track embed.id) {
diff --git a/toju-app/src/app/domains/plugins/README.md b/toju-app/src/app/domains/plugins/README.md index 9a3f27e..757c708 100644 --- a/toju-app/src/app/domains/plugins/README.md +++ b/toju-app/src/app/domains/plugins/README.md @@ -10,7 +10,9 @@ The standalone plugin store is available from the title bar Plugins button, the 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 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`, `bundle`/`bundleUrl`, 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. + +Store plugins can be published as cached browser bundles by adding `bundle` or `bundleUrl` to the source manifest entry. The bundle is a browser-safe ESM JavaScript file. During install, Electron downloads the bundle into app data under `plugin-bundles///main.js`, writes a cached manifest next to it, and registers the plugin from that local cached manifest path. If no bundle URL is provided and the manifest entrypoint is a relative browser module, Electron caches that entrypoint path instead. Browser-only clients still load directly from the source URL. Saved store sources refresh during app bootstrap; when a source advertises a higher version for an installed plugin, the store attempts to update the local cached bundle and persisted install metadata automatically. 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`. diff --git a/toju-app/src/app/domains/plugins/application/services/plugin-bootstrap.service.ts b/toju-app/src/app/domains/plugins/application/services/plugin-bootstrap.service.ts new file mode 100644 index 0000000..e4216ec --- /dev/null +++ b/toju-app/src/app/domains/plugins/application/services/plugin-bootstrap.service.ts @@ -0,0 +1,9 @@ +import { Injectable, inject } from '@angular/core'; +import { PluginRequirementStateService } from './plugin-requirement-state.service'; +import { PluginStoreService } from './plugin-store.service'; + +@Injectable({ providedIn: 'root' }) +export class PluginBootstrapService { + readonly requirementState = inject(PluginRequirementStateService); + readonly store = inject(PluginStoreService); +} diff --git a/toju-app/src/app/domains/plugins/application/services/plugin-client-api.service.ts b/toju-app/src/app/domains/plugins/application/services/plugin-client-api.service.ts index a6e894a..037c9eb 100644 --- a/toju-app/src/app/domains/plugins/application/services/plugin-client-api.service.ts +++ b/toju-app/src/app/domains/plugins/application/services/plugin-client-api.service.ts @@ -2,6 +2,7 @@ import { Injectable, inject } from '@angular/core'; import { Store } from '@ngrx/store'; import { Subscription } from 'rxjs'; import { RealtimeSessionFacade } from '../../../../core/realtime'; +import { DatabaseService } from '../../../../infrastructure/persistence'; import { VoiceConnectionFacade } from '../../../voice-connection/application/facades/voice-connection.facade'; import type { Channel, @@ -40,6 +41,7 @@ import { PluginUiRegistryService } from './plugin-ui-registry.service'; @Injectable({ providedIn: 'root' }) export class PluginClientApiService { private readonly capabilities = inject(PluginCapabilityService); + private readonly db = inject(DatabaseService); private readonly logger = inject(PluginLoggerService); private readonly messageBus = inject(PluginMessageBusService); private readonly realtime = inject(RealtimeSessionFacade); @@ -159,11 +161,11 @@ export class PluginClientApiService { messages: { delete: (messageId) => { requireCapability('messages.deleteOwn'); - this.deletePluginMessage(messageId); + this.deletePluginMessage(pluginId, messageId); }, edit: (messageId, content) => { requireCapability('messages.editOwn'); - this.editPluginMessage(messageId, content); + this.editPluginMessage(pluginId, messageId, content); }, moderateDelete: (messageId) => { requireCapability('messages.moderate'); @@ -175,7 +177,7 @@ export class PluginClientApiService { }, send: (content, channelId) => { requireCapability('messages.send'); - return this.sendPluginMessage(content, channelId); + return this.sendPluginMessage(pluginId, content, channelId); }, sendAsPluginUser: (request) => { requireCapability('messages.send'); @@ -481,11 +483,18 @@ export class PluginClientApiService { }; this.logger.info(pluginId, 'Plugin user message emitted', { messageId: message.id }); + this.persistPluginMessage(pluginId, message); this.store.dispatch(MessagesActions.receiveMessage({ message })); this.voice.broadcastMessage({ type: 'chat-message', message } as unknown as ChatEvent); } - private deletePluginMessage(messageId: string): void { + private deletePluginMessage(pluginId: string, messageId: string): void { + this.persistPluginMessageUpdate(pluginId, messageId, { + content: '[Message deleted]', + editedAt: Date.now(), + isDeleted: true + }); + this.store.dispatch(MessagesActions.deleteMessageSuccess({ messageId })); this.voice.broadcastMessage({ deletedAt: Date.now(), @@ -494,9 +503,11 @@ export class PluginClientApiService { } as unknown as ChatEvent); } - private editPluginMessage(messageId: string, content: string): void { + private editPluginMessage(pluginId: string, messageId: string, content: string): void { const editedAt = Date.now(); + this.persistPluginMessageUpdate(pluginId, messageId, { content, editedAt }); + this.store.dispatch(MessagesActions.editMessageSuccess({ content, editedAt, @@ -511,7 +522,7 @@ export class PluginClientApiService { } as unknown as ChatEvent); } - private sendPluginMessage(content: string, channelId?: string): Message { + private sendPluginMessage(pluginId: string, content: string, channelId?: string): Message { const currentUser = this.currentUser(); const roomId = this.requireRoomId(); const message: Message = { @@ -526,12 +537,25 @@ export class PluginClientApiService { timestamp: Date.now() }; + this.persistPluginMessage(pluginId, message); this.store.dispatch(MessagesActions.sendMessageSuccess({ message })); this.voice.broadcastMessage({ type: 'chat-message', message } as unknown as ChatEvent); return message; } + private persistPluginMessage(pluginId: string, message: Message): void { + void this.db.saveMessage(message).catch((error: unknown) => { + this.logger.warn(pluginId, 'Failed to persist plugin message', error); + }); + } + + private persistPluginMessageUpdate(pluginId: string, messageId: string, updates: Partial): void { + void this.db.updateMessage(messageId, updates).catch((error: unknown) => { + this.logger.warn(pluginId, 'Failed to persist plugin message update', error); + }); + } + private rememberSubscription(pluginId: string, eventName: string) { this.logger.info(pluginId, `Subscribed to ${eventName}`); diff --git a/toju-app/src/app/domains/plugins/application/services/plugin-host.service.ts b/toju-app/src/app/domains/plugins/application/services/plugin-host.service.ts index 628b990..729cee9 100644 --- a/toju-app/src/app/domains/plugins/application/services/plugin-host.service.ts +++ b/toju-app/src/app/domains/plugins/application/services/plugin-host.service.ts @@ -44,6 +44,7 @@ export class PluginHostService { private readonly registry = inject(PluginRegistryService); private readonly uiRegistry = inject(PluginUiRegistryService); private readonly activePlugins = new Map(); + private readonly activationRequests = new Map>(); private readonly activationStateReady: Promise; private activatedPluginIds = new Set(); @@ -96,11 +97,10 @@ export class PluginHostService { continue; } - await this.activatePlugin(entry); - + const didActivate = await this.activatePlugin(entry); const active = this.activePlugins.get(manifest.id); - if (active) { + if (didActivate && active) { activated.push(active.context); this.activatedPluginIds.add(active.context.pluginId); } @@ -126,11 +126,10 @@ export class PluginHostService { return; } - await this.activatePlugin(entry); - + const didActivate = await this.activatePlugin(entry); const active = this.activePlugins.get(pluginId); - if (!active) { + if (!didActivate || !active) { return; } @@ -161,11 +160,10 @@ export class PluginHostService { continue; } - await this.activatePlugin(entry); - + const didActivate = await this.activatePlugin(entry); const active = this.activePlugins.get(manifest.id); - if (active) { + if (didActivate && active) { activated.push(active.context); } } @@ -265,19 +263,46 @@ export class PluginHostService { } } - private async activatePlugin(entry: RegisteredPlugin): Promise { + private async activatePlugin(entry: RegisteredPlugin): Promise { + const pluginId = entry.manifest.id; + + if (this.activePlugins.has(pluginId)) { + return false; + } + + const pendingActivation = this.activationRequests.get(pluginId); + + if (pendingActivation) { + await pendingActivation; + return false; + } + + const activation = this.activatePluginInternal(entry); + + this.activationRequests.set(pluginId, activation); + + try { + return await activation; + } finally { + if (this.activationRequests.get(pluginId) === activation) { + this.activationRequests.delete(pluginId); + } + } + } + + private async activatePluginInternal(entry: RegisteredPlugin): Promise { 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; + return false; } if (!manifest.entrypoint) { this.registry.setState(manifest.id, 'ready'); - return; + return false; } this.registry.setState(manifest.id, 'loading'); @@ -291,12 +316,14 @@ export class PluginHostService { subscriptions: [] }; - await module.activate?.(context); + await this.runWithPluginRuntimeGuards(manifest.id, () => module.activate?.(context)); this.activePlugins.set(manifest.id, { context, module, moduleObjectUrl }); this.registry.setState(manifest.id, 'loaded'); this.logger.info(manifest.id, 'Plugin activated'); + return true; } catch (error) { this.failPlugin(manifest.id, error); + return false; } } @@ -310,6 +337,27 @@ export class PluginHostService { this.revokeModuleObjectUrl(pluginId); } + private async runWithPluginRuntimeGuards(pluginId: string, activate: () => Promise | void): Promise { + const originalMutationObserver = globalThis.MutationObserver; + + if (!originalMutationObserver) { + await activate(); + return; + } + + const guardedMutationObserver = createGuardedMutationObserver(originalMutationObserver, pluginId, this.logger); + + globalThis.MutationObserver = guardedMutationObserver; + + try { + await activate(); + } finally { + if (globalThis.MutationObserver === guardedMutationObserver) { + globalThis.MutationObserver = originalMutationObserver; + } + } + } + private async loadPluginModule( manifest: TojuPluginManifest, sourcePath?: string @@ -391,6 +439,10 @@ export class PluginHostService { return new URL(manifest.entrypoint).toString(); } catch {} + if (manifest.bundle?.url && !sourcePath?.startsWith('file://')) { + return manifest.bundle.url; + } + if (sourcePath?.startsWith('http://') || sourcePath?.startsWith('https://') || sourcePath?.startsWith('file://')) { return new URL(manifest.entrypoint, sourcePath).toString(); } @@ -421,3 +473,61 @@ function safeDispose(disposable: TojuPluginDisposable, pluginId: string, logger: logger.warn(pluginId, 'Plugin disposable failed', error); } } + +function createGuardedMutationObserver( + NativeMutationObserver: typeof MutationObserver, + pluginId: string, + logger: PluginLoggerService +): typeof MutationObserver { + return class GuardedPluginMutationObserver implements MutationObserver { + private readonly nativeObserver: MutationObserver; + private readonly observations: { options?: MutationObserverInit; target: Node }[] = []; + private isDispatching = false; + + constructor(private readonly callback: MutationCallback) { + this.nativeObserver = new NativeMutationObserver((records) => this.dispatch(records)); + } + + observe(target: Node, options?: MutationObserverInit): void { + const existing = this.observations.find((observation) => observation.target === target); + + if (existing) { + existing.options = options; + } else { + this.observations.push({ options, target }); + } + + this.nativeObserver.observe(target, options); + } + + disconnect(): void { + this.observations.length = 0; + this.nativeObserver.disconnect(); + } + + takeRecords(): MutationRecord[] { + return this.nativeObserver.takeRecords(); + } + + private dispatch(records: MutationRecord[]): void { + if (this.isDispatching) { + return; + } + + this.isDispatching = true; + this.nativeObserver.disconnect(); + + try { + this.callback(records, this); + } catch (error) { + logger.warn(pluginId, 'Plugin MutationObserver callback failed', error); + } finally { + this.isDispatching = false; + + for (const observation of this.observations) { + this.nativeObserver.observe(observation.target, observation.options); + } + } + } + }; +} diff --git a/toju-app/src/app/domains/plugins/application/services/plugin-message-bus.service.ts b/toju-app/src/app/domains/plugins/application/services/plugin-message-bus.service.ts index f480b0e..77ec922 100644 --- a/toju-app/src/app/domains/plugins/application/services/plugin-message-bus.service.ts +++ b/toju-app/src/app/domains/plugins/application/services/plugin-message-bus.service.ts @@ -100,7 +100,7 @@ export class PluginMessageBusService { request: PluginApiMessageBusLatestRequest, includeMessages: boolean ): PluginApiMessageBusEnvelope { - const currentUser = this.currentUser(); + const currentUser = this.currentUser() ?? null; const envelope: PluginApiMessageBusEnvelope = { eventId: createId(), pluginId, @@ -233,4 +233,4 @@ function isMessage(value: unknown): value is Message { function createId(): string { return globalThis.crypto?.randomUUID?.() ?? `plugin-bus-${Date.now()}-${Math.random().toString(36) .slice(2)}`; -} \ No newline at end of file +} diff --git a/toju-app/src/app/domains/plugins/application/services/plugin-requirement-state.service.ts b/toju-app/src/app/domains/plugins/application/services/plugin-requirement-state.service.ts index fc180d4..fb7b3f6 100644 --- a/toju-app/src/app/domains/plugins/application/services/plugin-requirement-state.service.ts +++ b/toju-app/src/app/domains/plugins/application/services/plugin-requirement-state.service.ts @@ -15,8 +15,8 @@ import type { TojuPluginManifest } from '../../../../shared-kernel'; import { RealtimeSessionFacade } from '../../../../core/realtime'; -import { selectCurrentRoomId } from '../../../../store/rooms/rooms.selectors'; -import { ServerDirectoryFacade } from '../../../server-directory'; +import { selectCurrentRoom, selectCurrentRoomId } from '../../../../store/rooms/rooms.selectors'; +import { ServerDirectoryFacade, type ServerSourceSelector } from '../../../server-directory'; import { PluginRegistryService } from './plugin-registry.service'; import { PluginRequirementService } from './plugin-requirement.service'; @@ -44,6 +44,7 @@ export class PluginRequirementStateService { private readonly serverDirectory = inject(ServerDirectoryFacade); private readonly store = inject(Store); + private readonly currentRoom = this.store.selectSignal(selectCurrentRoom); private readonly currentRoomId = this.store.selectSignal(selectCurrentRoomId); private readonly snapshotsSignal = signal>({}); private readonly refreshErrorsSignal = signal>({}); @@ -111,7 +112,7 @@ export class PluginRequirementStateService { } try { - const apiBaseUrl = this.serverDirectory.getApiBaseUrl(); + const apiBaseUrl = this.serverDirectory.getApiBaseUrl(this.currentRoomSourceSelector()); const snapshot = await new Promise((resolve, reject) => { this.pluginRequirements.getSnapshot(apiBaseUrl, roomId).subscribe({ error: reject, @@ -144,6 +145,19 @@ export class PluginRequirementStateService { })); } + private currentRoomSourceSelector(): ServerSourceSelector | undefined { + const room = this.currentRoom(); + + if (!room?.sourceId && !room?.sourceUrl) { + return undefined; + } + + return { + sourceId: room.sourceId, + sourceUrl: room.sourceUrl + }; + } + private resolveStatus( requirement: PluginRequirementSummary, entry: { enabled: boolean; manifest: TojuPluginManifest } | undefined diff --git a/toju-app/src/app/domains/plugins/application/services/plugin-store.service.spec.ts b/toju-app/src/app/domains/plugins/application/services/plugin-store.service.spec.ts index dc18c6b..deae055 100644 --- a/toju-app/src/app/domains/plugins/application/services/plugin-store.service.spec.ts +++ b/toju-app/src/app/domains/plugins/application/services/plugin-store.service.spec.ts @@ -129,6 +129,45 @@ describe('PluginStoreService', () => { expect(service.installedPlugins()).toEqual([]); }); + it('caches plugin bundle entrypoints locally before registering installed plugins', async () => { + const manifest = createManifest({ entrypoint: './dist/main.js' }); + const plugin = createStoreEntry({ + bundleUrl: 'https://plugins.example.test/better/bundle.js', + version: '1.0.0' + }); + const electronApi = { + ensureDir: vi.fn(async () => true), + getAppDataPath: vi.fn(async () => '/tmp/metoyou-user-data'), + writeFile: vi.fn(async () => true) + }; + + fetchMock + .mockResolvedValueOnce(jsonResponse(manifest)) + .mockResolvedValueOnce(textResponse('export function activate() {}')); + + const service = createService(registerLocalManifest, unregister, electronApi); + + await service.installPlugin(plugin); + + expect(electronApi.ensureDir).toHaveBeenCalledWith('/tmp/metoyou-user-data/plugin-bundles/example.better-channels/1.0.0'); + expect(electronApi.writeFile).toHaveBeenCalledWith( + '/tmp/metoyou-user-data/plugin-bundles/example.better-channels/1.0.0/main.js', + expect.any(String) + ); + + expect(registerLocalManifest).toHaveBeenCalledWith( + expect.objectContaining({ + bundle: { + entrypoint: './main.js', + url: plugin.bundleUrl + }, + entrypoint: './main.js', + id: manifest.id + }), + 'file:///tmp/metoyou-user-data/plugin-bundles/example.better-channels/1.0.0/toju-plugin.json' + ); + }); + it('loads plugin readmes as markdown text', async () => { const plugin = createStoreEntry({ readmeUrl: 'https://plugins.example.test/better/README.md' }); @@ -149,7 +188,12 @@ describe('PluginStoreService', () => { function createService( registerLocalManifest: ReturnType, unregister: ReturnType, - electronApi: { readFile: (filePath: string) => Promise } | null = null + electronApi: { + ensureDir?: (dirPath: string) => Promise; + getAppDataPath?: () => Promise; + readFile?: (filePath: string) => Promise; + writeFile?: (filePath: string, data: string) => Promise; + } | null = null ): PluginStoreService { const injector = Injector.create({ providers: [ @@ -165,6 +209,7 @@ function createService( useValue: { activatePersistedPlugins: vi.fn(async () => {}), deactivatePlugin: vi.fn(async () => {}), + isPluginActive: vi.fn(() => false), registerLocalManifest } }, diff --git a/toju-app/src/app/domains/plugins/application/services/plugin-store.service.ts b/toju-app/src/app/domains/plugins/application/services/plugin-store.service.ts index 85409de..99a494b 100644 --- a/toju-app/src/app/domains/plugins/application/services/plugin-store.service.ts +++ b/toju-app/src/app/domains/plugins/application/services/plugin-store.service.ts @@ -18,9 +18,14 @@ import type { TojuPluginInstallScope, TojuPluginManifest } from '../../../../shared-kernel'; -import { selectCurrentRoomId, selectCurrentRoomName } from '../../../../store/rooms/rooms.selectors'; +import { + selectCurrentRoom, + selectCurrentRoomId, + selectCurrentRoomName, + selectSavedRooms +} from '../../../../store/rooms/rooms.selectors'; import { selectCurrentUser } from '../../../../store/users/users.selectors'; -import { ServerDirectoryFacade } from '../../../server-directory'; +import { ServerDirectoryFacade, type ServerSourceSelector } from '../../../server-directory'; import { getPluginInstallScope } from '../../domain/logic/plugin-install-scope.logic'; import { validateTojuPluginManifest } from '../../domain/logic/plugin-manifest-validation.logic'; import type { @@ -39,6 +44,7 @@ import { PluginRegistryService } from './plugin-registry.service'; const STORE_SCHEMA_VERSION = 1; const STORAGE_KEY_PLUGIN_STORE = 'metoyou_plugin_store'; +const PLUGIN_CACHE_DIR = 'plugin-bundles'; const DEFAULT_STORE_STATE: PersistedPluginStoreState = { installedPlugins: [], sourceUrls: [] @@ -62,8 +68,10 @@ export class PluginStoreService { private readonly registry = inject(PluginRegistryService); private readonly serverDirectory = inject(ServerDirectoryFacade, { optional: true }); private readonly store = inject(Store, { optional: true }); + private readonly currentRoom = this.store?.selectSignal(selectCurrentRoom) ?? null; private readonly currentRoomId = this.store?.selectSignal(selectCurrentRoomId) ?? null; private readonly currentRoomName = this.store?.selectSignal(selectCurrentRoomName) ?? null; + private readonly savedRooms = this.store?.selectSignal(selectSavedRooms) ?? null; private readonly currentUser = this.store?.selectSignal(selectCurrentUser) ?? null; private readonly sourceUrlsSignal = signal([]); private readonly sourcesSignal = signal([]); @@ -73,6 +81,7 @@ export class PluginStoreService { private refreshAbortController: AbortController | null = null; private refreshVersion = 0; private installedLoadVersion = 0; + private autoUpdateInProgress = false; private stateMutated = false; readonly sourceUrls = this.sourceUrlsSignal.asReadonly(); @@ -94,6 +103,10 @@ export class PluginStoreService { this.sourceUrlsSignal.set(state.sourceUrls); void this.applyInstalledPlugins(state.installedPlugins, 'client'); + if (state.sourceUrls.length > 0) { + void this.refreshSources(); + } + if (this.currentRoomId && this.currentUser && this.serverDirectory) { effect(() => { const roomId = this.currentRoomId?.() ?? null; @@ -147,6 +160,7 @@ export class PluginStoreService { if (this.refreshVersion === currentRefresh) { this.sourcesSignal.set(sources); + void this.autoUpdateInstalledPlugins(); } } finally { if (this.refreshVersion === currentRefresh) { @@ -161,54 +175,88 @@ export class PluginStoreService { throw new Error('Plugin does not provide an install manifest URL'); } - const manifest = options.manifest ?? await this.fetchPluginManifest(plugin.installUrl); + const manifest = this.withStoreBundleMetadata(options.manifest ?? await this.fetchPluginManifest(plugin.installUrl), plugin); 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 targetServerId = this.resolveInstallTargetServerId(installScope, options.serverId); 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 = { + const installedPlugin = await this.cacheInstalledPlugin({ + bundleUrl: manifest.bundle?.url ?? plugin.bundleUrl, 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); - } + await this.persistInstallResult(installScope, targetServerId, nextInstalledPlugins, installedPlugin, options); + await this.registerInstallResult(installScope, targetServerId, nextInstalledPlugins, installedPlugin, options); return installedPlugin; } + private resolveInstallTargetServerId(installScope: TojuPluginInstallScope, requestedServerId: string | undefined): string | null { + if (installScope !== 'server') { + return null; + } + + const targetServerId = requestedServerId ?? this.currentRoomId?.() ?? null; + + if (!targetServerId) { + throw new Error('Open a chat server before installing server-scoped plugins'); + } + + return targetServerId; + } + + private async persistInstallResult( + installScope: TojuPluginInstallScope, + targetServerId: string | null, + nextInstalledPlugins: InstalledStorePlugin[], + installedPlugin: InstalledStorePlugin, + options: PluginStoreInstallOptions + ): Promise { + if (installScope === 'server') { + await this.saveServerPluginRequirement(installedPlugin, targetServerId, options.optional === true ? 'optional' : 'required'); + return; + } + + await this.persistInstalledPlugins(nextInstalledPlugins, installScope); + } + + private async registerInstallResult( + installScope: TojuPluginInstallScope, + targetServerId: string | null, + nextInstalledPlugins: InstalledStorePlugin[], + installedPlugin: InstalledStorePlugin, + options: PluginStoreInstallOptions + ): Promise { + if (installScope !== 'client' && targetServerId !== this.currentRoomId?.()) { + if (options.activate) { + await this.host.rememberActivation(installedPlugin.manifest.id); + } + + return; + } + + this.host.registerLocalManifest(installedPlugin.manifest, installedPlugin.cachedSourcePath ?? installedPlugin.installUrl); + + if (installScope === 'client' || options.optional !== true) { + this.setInstalledPluginsForScope(installScope, nextInstalledPlugins); + } + + if (options.activate) { + await this.host.activatePluginById(installedPlugin.manifest.id); + } + } + async loadInstallManifest(plugin: PluginStoreEntry): Promise { if (!plugin.installUrl) { throw new Error('Plugin does not provide an install manifest URL'); @@ -217,21 +265,32 @@ export class PluginStoreService { return await this.fetchPluginManifest(plugin.installUrl); } - async uninstallPlugin(pluginId: string, scope?: TojuPluginInstallScope): Promise { + async uninstallPlugin(pluginId: string, scope?: TojuPluginInstallScope, options: { serverId?: string } = {}): Promise { const installScope = scope ?? this.findInstalledPluginScope(pluginId) ?? 'client'; - const nextInstalledPlugins = this.installedPluginsForScope(installScope).filter((installedPlugin) => installedPlugin.manifest.id !== pluginId); + const currentInstalledPlugins = installScope === 'server' + ? await this.installedPluginsForServer(options.serverId ?? this.currentRoomId?.() ?? null) + : this.installedPluginsForScope(installScope); + const nextInstalledPlugins = currentInstalledPlugins.filter((installedPlugin) => installedPlugin.manifest.id !== pluginId); if (installScope === 'server') { - await this.deleteServerPluginRequirement(pluginId); + await this.deleteServerPluginRequirement(pluginId, options.serverId); } else { await this.persistInstalledPlugins(nextInstalledPlugins, installScope); } + if (installScope === 'server' && options.serverId && options.serverId !== this.currentRoomId?.()) { + return; + } + await this.host.deactivatePlugin(pluginId, { forgetActivation: true }); this.registry.unregister(pluginId); this.setInstalledPluginsForScope(installScope, nextInstalledPlugins); } + async loadInstalledPluginsForServer(serverId: string): Promise { + return await this.installedPluginsForServer(serverId); + } + async loadReadme(plugin: PluginStoreEntry): Promise { if (!plugin.readmeUrl) { throw new Error('Plugin does not provide a readme URL'); @@ -332,6 +391,107 @@ export class PluginStoreService { return new TextDecoder().decode(bytes); } + private withStoreBundleMetadata(manifest: TojuPluginManifest, plugin: PluginStoreEntry): TojuPluginManifest { + if (!plugin.bundleUrl || manifest.bundle?.url) { + return manifest; + } + + return { + ...manifest, + bundle: { + entrypoint: './main.js', + url: plugin.bundleUrl + } + }; + } + + private async cacheInstalledPlugin(installedPlugin: InstalledStorePlugin): Promise { + if (installedPlugin.cachedSourcePath) { + return installedPlugin; + } + + const api = this.electronBridge.getApi(); + const entrypointSourceUrl = this.resolvePluginBundleSourceUrl(installedPlugin); + const cachedEntrypoint = this.resolveCachedEntrypointPath(installedPlugin.manifest); + + if (!api || !entrypointSourceUrl || !cachedEntrypoint) { + return installedPlugin; + } + + const cachedManifest = this.toCachedRuntimeManifest(installedPlugin.manifest, cachedEntrypoint); + const appDataPath = await api.getAppDataPath(); + const pluginCacheDir = joinLocalPath( + appDataPath, + PLUGIN_CACHE_DIR, + sanitizePathSegment(installedPlugin.manifest.id), + sanitizePathSegment(installedPlugin.manifest.version) + ); + const manifestPath = joinLocalPath(pluginCacheDir, 'toju-plugin.json'); + const entrypointPath = joinLocalPath(pluginCacheDir, cachedEntrypoint); + const cacheRootUrl = localPathToFileUrl(manifestPath); + + if (!cacheRootUrl) { + return installedPlugin; + } + + await api.ensureDir(dirnameLocalPath(entrypointPath)); + await api.writeFile(entrypointPath, bytesToBase64(new TextEncoder().encode(await this.fetchText(entrypointSourceUrl, 'text/javascript,*/*')))); + await api.writeFile(manifestPath, bytesToBase64(new TextEncoder().encode(JSON.stringify(cachedManifest, null, 2)))); + + return { + ...installedPlugin, + bundleUrl: installedPlugin.bundleUrl ?? installedPlugin.manifest.bundle?.url, + cachedAt: Date.now(), + cachedSourcePath: cacheRootUrl, + manifest: cachedManifest + }; + } + + private toCachedRuntimeManifest(manifest: TojuPluginManifest, cachedEntrypoint: string): TojuPluginManifest { + if (!manifest.bundle?.url) { + return manifest; + } + + return { + ...manifest, + entrypoint: cachedEntrypoint.startsWith('./') ? cachedEntrypoint : `./${cachedEntrypoint}` + }; + } + + private resolvePluginBundleSourceUrl(installedPlugin: InstalledStorePlugin): string | null { + const bundleUrl = installedPlugin.bundleUrl ?? installedPlugin.manifest.bundle?.url; + + if (bundleUrl) { + return bundleUrl; + } + + const entrypoint = installedPlugin.manifest.entrypoint; + + if (!entrypoint || !installedPlugin.installUrl || isAbsolutePluginUrl(entrypoint)) { + return null; + } + + return resolveOptionalUrl(installedPlugin.installUrl, entrypoint) ?? null; + } + + private resolveCachedEntrypointPath(manifest: TojuPluginManifest): string | null { + const entrypoint = manifest.bundle?.url + ? manifest.bundle.entrypoint ?? './main.js' + : manifest.entrypoint; + + if (!entrypoint || isAbsolutePluginUrl(entrypoint)) { + return null; + } + + const normalized = entrypoint.replace(/^\.\//, '').replace(/\\/g, '/'); + + if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) { + return null; + } + + return normalized; + } + private async applyInstalledPlugins(installedPlugins: InstalledStorePlugin[], scope: TojuPluginInstallScope): Promise { const usableInstalledPlugins: InstalledStorePlugin[] = []; const scopedInstalledPlugins = installedPlugins.filter((installedPlugin) => getPluginInstallScope(installedPlugin.manifest) === scope); @@ -346,8 +506,10 @@ export class PluginStoreService { for (const installedPlugin of scopedInstalledPlugins) { try { - this.host.registerLocalManifest(installedPlugin.manifest, installedPlugin.installUrl); - usableInstalledPlugins.push(installedPlugin); + const cachedPlugin = await this.cacheInstalledPlugin(installedPlugin); + + this.host.registerLocalManifest(cachedPlugin.manifest, cachedPlugin.cachedSourcePath ?? cachedPlugin.installUrl); + usableInstalledPlugins.push(cachedPlugin); } catch { // Corrupt persisted manifests are ignored so the store can recover on next install. } @@ -355,15 +517,77 @@ export class PluginStoreService { this.setInstalledPluginsForScope(scope, usableInstalledPlugins); - await this.host.activatePersistedPlugins(); + if (scope === 'server') { + await this.activateServerPlugins(usableInstalledPlugins); + } else { + await this.host.activatePersistedPlugins(); + } if (usableInstalledPlugins.length !== scopedInstalledPlugins.length) { if (scope === 'client') { await this.persistInstalledPlugins(usableInstalledPlugins, scope); } + } else if ( + scope === 'client' + && usableInstalledPlugins.some((plugin, index) => plugin.cachedSourcePath !== scopedInstalledPlugins[index]?.cachedSourcePath) + ) { + await this.persistInstalledPlugins(usableInstalledPlugins, scope); } } + private async activateServerPlugins(installedPlugins: InstalledStorePlugin[]): Promise { + for (const installedPlugin of installedPlugins) { + await this.host.activatePluginById(installedPlugin.manifest.id); + } + } + + private async autoUpdateInstalledPlugins(): Promise { + if (this.autoUpdateInProgress || this.sources().length === 0) { + return; + } + + this.autoUpdateInProgress = true; + + try { + await this.autoUpdateScope('client'); + + if (this.currentRoomId?.()) { + await this.autoUpdateScope('server'); + } + } finally { + this.autoUpdateInProgress = false; + } + } + + private async autoUpdateScope(scope: TojuPluginInstallScope): Promise { + for (const installedPlugin of this.installedPluginsForScope(scope)) { + const update = this.findUpdateCandidate(installedPlugin, scope); + + if (!update) { + continue; + } + + try { + await this.installPlugin(update, { + activate: this.host.isPluginActive(installedPlugin.manifest.id), + serverId: scope === 'server' ? this.currentRoomId?.() ?? undefined : undefined + }); + } catch {} + } + } + + private findUpdateCandidate(installedPlugin: InstalledStorePlugin, scope: TojuPluginInstallScope): PluginStoreEntry | null { + const candidates = this.availablePlugins().filter((plugin) => { + return plugin.id === installedPlugin.manifest.id + && getStoreEntryInstallScope(plugin) === scope + && (!installedPlugin.sourceUrl || plugin.sourceUrl === installedPlugin.sourceUrl); + }); + + return candidates + .filter((plugin) => compareVersions(plugin.version, installedPlugin.manifest.version) > 0) + .sort((left, right) => compareVersions(right.version, left.version))[0] ?? null; + } + private async loadInstalledPluginsForScope(roomId: string | null, actorUserId: string | null): Promise { const currentLoad = this.installedLoadVersion + 1; @@ -418,7 +642,7 @@ export class PluginStoreService { return []; } - const snapshot = await firstValueFrom(this.pluginRequirements.getSnapshot(this.serverDirectory.getApiBaseUrl(), roomId)); + const snapshot = await firstValueFrom(this.pluginRequirements.getSnapshot(this.getPluginApiBaseUrl(roomId), roomId)); return snapshot.requirements .map((requirement) => installedPluginFromRequirement(requirement)) @@ -438,7 +662,7 @@ export class PluginStoreService { } await firstValueFrom(this.pluginRequirements.upsertRequirement( - this.serverDirectory.getApiBaseUrl(), + this.getPluginApiBaseUrl(roomId), roomId, installedPlugin.manifest.id, { @@ -453,15 +677,51 @@ export class PluginStoreService { )); } - private async deleteServerPluginRequirement(pluginId: string): Promise { - const roomId = this.currentRoomId?.() ?? null; + private async deleteServerPluginRequirement(pluginId: string, serverId?: string): Promise { + const roomId = serverId ?? 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)); + await firstValueFrom(this.pluginRequirements.deleteRequirement(this.getPluginApiBaseUrl(roomId), roomId, pluginId, actorUserId)); + } + + private getPluginApiBaseUrl(serverId: string): string { + const selector = this.serverSourceSelector(serverId); + + return this.serverDirectory?.getApiBaseUrl(selector) ?? ''; + } + + private serverSourceSelector(serverId: string): ServerSourceSelector | undefined { + if (serverId === this.currentRoomId?.()) { + return this.currentRoomSourceSelector(); + } + + const room = this.savedRooms?.().find((candidate) => candidate.id === serverId) ?? null; + + if (!room?.sourceId && !room?.sourceUrl) { + return undefined; + } + + return { + sourceId: room.sourceId, + sourceUrl: room.sourceUrl + }; + } + + private currentRoomSourceSelector(): ServerSourceSelector | undefined { + const room = this.currentRoom?.() ?? null; + + if (!room?.sourceId && !room?.sourceUrl) { + return undefined; + } + + return { + sourceId: room.sourceId, + sourceUrl: room.sourceUrl + }; } private currentActorUserId(): string | null { @@ -591,6 +851,7 @@ function installedPluginFromRequirement(requirement: PluginRequirementSummary): } return { + bundleUrl: manifest.bundle?.url, installedAt: requirement.updatedAt, installUrl: requirement.installUrl, manifest, @@ -640,6 +901,7 @@ function parsePluginEntry(sourceUrl: string, sourceTitle: string, value: unknown return { author: readAuthor(value), + bundleUrl: resolveOptionalUrl(sourceUrl, readString(value, 'bundle', 'bundleUrl')), description: readString(value, 'description', 'summary') ?? '', githubUrl: resolveOptionalUrl(sourceUrl, readGithubUrl(value)), homepageUrl: resolveOptionalUrl(sourceUrl, readString(value, 'homepage', 'homepageUrl', 'website')), @@ -794,6 +1056,44 @@ function isAllowedPluginSourceProtocol(protocol: string): boolean { return protocol === 'http:' || protocol === 'https:' || protocol === 'file:'; } +function isAbsolutePluginUrl(value: string): boolean { + try { + const url = new URL(value); + + return isAllowedPluginSourceProtocol(url.protocol); + } catch { + return false; + } +} + +function sanitizePathSegment(value: string): string { + return value.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 128) || 'plugin'; +} + +function joinLocalPath(...parts: string[]): string { + return parts + .map((part, index) => index === 0 ? part.replace(/[\\/]+$/, '') : part.replace(/^[\\/]+|[\\/]+$/g, '')) + .filter(Boolean) + .join('/'); +} + +function dirnameLocalPath(filePath: string): string { + const normalized = filePath.replace(/\\/g, '/'); + const index = normalized.lastIndexOf('/'); + + return index > 0 ? normalized.slice(0, index) : normalized; +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ''; + + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + + return btoa(binary); +} + function localPathToFileUrl(filePath: string): string | undefined { if (!isAbsoluteLocalPath(filePath)) { return undefined; diff --git a/toju-app/src/app/domains/plugins/domain/models/plugin-store.models.ts b/toju-app/src/app/domains/plugins/domain/models/plugin-store.models.ts index bff2a99..cd4f9a3 100644 --- a/toju-app/src/app/domains/plugins/domain/models/plugin-store.models.ts +++ b/toju-app/src/app/domains/plugins/domain/models/plugin-store.models.ts @@ -5,6 +5,7 @@ export type PluginStoreActionLabel = 'Install' | 'Install to Server' | 'Remove f export interface PluginStoreEntry { author?: string; + bundleUrl?: string; description: string; githubUrl?: string; homepageUrl?: string; @@ -28,6 +29,9 @@ export interface PluginStoreSourceResult { } export interface InstalledStorePlugin { + bundleUrl?: string; + cachedAt?: number; + cachedSourcePath?: string; installedAt: number; installUrl?: string; manifest: TojuPluginManifest; diff --git a/toju-app/src/app/domains/plugins/feature/plugin-manager/plugin-manager.component.ts b/toju-app/src/app/domains/plugins/feature/plugin-manager/plugin-manager.component.ts index 99212be..80eb064 100644 --- a/toju-app/src/app/domains/plugins/feature/plugin-manager/plugin-manager.component.ts +++ b/toju-app/src/app/domains/plugins/feature/plugin-manager/plugin-manager.component.ts @@ -61,6 +61,7 @@ type PluginManagerTab = 'docs' | 'extensions' | 'installed' | 'logs' | 'requirem }) export class PluginManagerComponent { @Output() readonly closed = new EventEmitter(); + @Output() readonly storeOpened = new EventEmitter(); readonly scope = input('client'); @@ -149,7 +150,7 @@ export class PluginManagerComponent { openStore(): void { const returnUrl = this.router.url.startsWith('/plugin-store') ? '/search' : this.router.url; - this.closed.emit(); + this.storeOpened.emit(); void this.router.navigate(['/plugin-store'], { queryParams: { returnUrl } }); } diff --git a/toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.html b/toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.html index 36543bd..3da3da2 100644 --- a/toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.html +++ b/toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.html @@ -1,255 +1,332 @@
-
-
+
+
-
- +
+
-
-

Plugin Store

-

+

+

Plugin Store

+

{{ installedCount() }} installed for {{ store.installScopeLabel() }} · {{ totalSourcePlugins() }} available · {{ sourceCount() }} sources

-
+
-
-
-
+
diff --git a/toju-app/src/app/features/settings/settings-modal/server-settings/server-settings.component.ts b/toju-app/src/app/features/settings/settings-modal/server-settings/server-settings.component.ts index d986fad..6924e9c 100644 --- a/toju-app/src/app/features/settings/settings-modal/server-settings/server-settings.component.ts +++ b/toju-app/src/app/features/settings/settings-modal/server-settings/server-settings.component.ts @@ -12,9 +12,12 @@ import { NgIcon, provideIcons } from '@ng-icons/core'; import { Store } from '@ngrx/store'; import { lucideCheck, + lucideImage, lucideTrash2, lucideLock, - lucideUnlock + lucideUnlock, + lucideUpload, + lucideX } from '@ng-icons/lucide'; import { Room } from '../../../../shared-kernel'; @@ -34,9 +37,12 @@ import { SettingsModalService } from '../../../../core/services/settings-modal.s viewProviders: [ provideIcons({ lucideCheck, + lucideImage, lucideTrash2, lucideLock, - lucideUnlock + lucideUnlock, + lucideUpload, + lucideX }) ], templateUrl: './server-settings.component.html' @@ -49,6 +55,10 @@ export class ServerSettingsComponent { server = input(null); /** Whether the current user is admin of this server. */ isAdmin = input(false); + /** Whether the current user can manage this server's icon. */ + canManageIcon = input(false); + /** Whether the current user can delete this server. */ + canDeleteServer = input(false); roomName = ''; roomDescription = ''; @@ -59,6 +69,7 @@ export class ServerSettingsComponent { roomPassword = ''; maxUsers = 0; showDeleteConfirm = signal(false); + iconError = signal(null); saveSuccess = signal(null); private saveTimeout: ReturnType | null = null; @@ -170,6 +181,64 @@ export class ServerSettingsComponent { this.modal.navigate('network'); } + onServerIconSelected(event: Event): void { + const inputElement = event.target as HTMLInputElement; + const file = inputElement.files?.[0]; + + inputElement.value = ''; + + if (!file || !this.canManageIcon()) { + return; + } + + if (!file.type.startsWith('image/')) { + this.iconError.set('Choose an image file.'); + return; + } + + if (file.size > 512 * 1024) { + this.iconError.set('Choose an image smaller than 512 KB.'); + return; + } + + const reader = new FileReader(); + + reader.onload = () => { + const room = this.server(); + const icon = typeof reader.result === 'string' ? reader.result : ''; + + if (!room || !icon) { + this.iconError.set('Could not read that image.'); + return; + } + + this.iconError.set(null); + this.store.dispatch(RoomsActions.updateServerIcon({ + roomId: room.id, + icon + })); + this.showSaveSuccess('icon'); + }; + + reader.onerror = () => this.iconError.set('Could not read that image.'); + reader.readAsDataURL(file); + } + + removeServerIcon(): void { + const room = this.server(); + + if (!room || !this.canManageIcon()) { + return; + } + + this.iconError.set(null); + this.store.dispatch(RoomsActions.updateServerIcon({ + roomId: room.id, + icon: '' + })); + this.showSaveSuccess('icon'); + } + private showSaveSuccess(key: string): void { this.saveSuccess.set(key); diff --git a/toju-app/src/app/features/settings/settings-modal/settings-modal.component.html b/toju-app/src/app/features/settings/settings-modal/settings-modal.component.html index 23cd6b2..8405810 100644 --- a/toju-app/src/app/features/settings/settings-modal/settings-modal.component.html +++ b/toju-app/src/app/features/settings/settings-modal/settings-modal.component.html @@ -316,7 +316,9 @@ @case ('server') { } @case ('serverPlugins') { diff --git a/toju-app/src/app/features/settings/settings-modal/settings-modal.component.ts b/toju-app/src/app/features/settings/settings-modal/settings-modal.component.ts index 6762dca..8460777 100644 --- a/toju-app/src/app/features/settings/settings-modal/settings-modal.component.ts +++ b/toju-app/src/app/features/settings/settings-modal/settings-modal.component.ts @@ -165,6 +165,7 @@ export class SettingsModalComponent { resolveRoomPermission(viewedRoom, user, 'manageServer') || resolveRoomPermission(viewedRoom, user, 'manageRoles') || resolveRoomPermission(viewedRoom, user, 'manageChannels') || + resolveRoomPermission(viewedRoom, user, 'manageIcon') || resolveRoomPermission(viewedRoom, user, 'manageBans') || resolveRoomPermission(viewedRoom, user, 'kickMembers') || resolveRoomPermission(viewedRoom, user, 'banMembers') @@ -208,6 +209,7 @@ export class SettingsModalComponent { resolveRoomPermission(server, user, 'manageServer') || resolveRoomPermission(server, user, 'manageRoles') || resolveRoomPermission(server, user, 'manageChannels') || + resolveRoomPermission(server, user, 'manageIcon') || resolveRoomPermission(server, user, 'manageBans') || resolveRoomPermission(server, user, 'kickMembers') || resolveRoomPermission(server, user, 'banMembers')) @@ -252,6 +254,20 @@ export class SettingsModalComponent { return this.selectedServerRole() === 'host'; }); + canManageSelectedServerSettings = computed(() => { + const server = this.selectedServer(); + const user = this.currentUser(); + + return !!server && !!user && (resolveLegacyRole(server, user) === 'host' || resolveRoomPermission(server, user, 'manageServer')); + }); + + canManageSelectedServerIcon = computed(() => { + const server = this.selectedServer(); + const user = this.currentUser(); + + return !!server && !!user && (resolveLegacyRole(server, user) === 'host' || resolveRoomPermission(server, user, 'manageIcon')); + }); + isSelectedServerCurrent = computed(() => { const selectedServerId = this.selectedServerId(); const currentRoomId = this.currentRoom()?.id ?? null; diff --git a/toju-app/src/app/infrastructure/realtime/signaling/signaling-message-handler.ts b/toju-app/src/app/infrastructure/realtime/signaling/signaling-message-handler.ts index 72963b9..5ac9808 100644 --- a/toju-app/src/app/infrastructure/realtime/signaling/signaling-message-handler.ts +++ b/toju-app/src/app/infrastructure/realtime/signaling/signaling-message-handler.ts @@ -34,6 +34,9 @@ export type IncomingSignalingMessage = Omit, 'type' | users?: SignalingUserSummary[]; displayName?: string; fromUserId?: string; + icon?: string; + iconUpdatedAt?: number; + targetUserId?: string; }; interface IncomingSignalingMessageHandlerDependencies { @@ -60,9 +63,7 @@ export class IncomingSignalingMessageHandler { /** Tracks when we first started waiting for a remote-initiated offer from each peer. */ private readonly nonInitiatorWaitStart = new Map(); - constructor( - private readonly dependencies: IncomingSignalingMessageHandlerDependencies - ) {} + constructor(private readonly dependencies: IncomingSignalingMessageHandlerDependencies) {} handleMessage(message: IncomingSignalingMessage, signalUrl: string): void { this.dependencies.logger.info('Signaling message', { @@ -76,6 +77,7 @@ export class IncomingSignalingMessageHandler { return; case SIGNALING_TYPE_SERVER_USERS: + case 'server_icon_sync_peers': this.handleServerUsersSignalingMessage(message, signalUrl); return; @@ -138,11 +140,9 @@ export class IncomingSignalingMessageHandler { } for (const user of users) { - if (!user.oderId) - continue; + if (!user.oderId) continue; - if (localOderId && user.oderId === localOderId) - continue; + if (localOderId && user.oderId === localOderId) continue; this.clearUserJoinedFallbackOffer(user.oderId); @@ -295,9 +295,9 @@ export class IncomingSignalingMessageHandler { const hasRemainingSharedServers = Array.isArray(message.serverIds) ? this.dependencies.signalingCoordinator.replacePeerSharedServers(message.oderId, signalUrl, message.serverIds) - : (message.serverId + : message.serverId ? this.dependencies.signalingCoordinator.untrackPeerFromServer(message.oderId, signalUrl, message.serverId) - : false); + : false; if (!hasRemainingSharedServers) { this.dependencies.peerManager.removePeer(message.oderId); @@ -310,11 +310,9 @@ export class IncomingSignalingMessageHandler { const fromUserId = message.fromUserId; const sdp = message.payload?.sdp; - if (!fromUserId || !sdp) - return; + if (!fromUserId || !sdp) return; - if (fromUserId === this.dependencies.getLocalOderId()) - return; + if (fromUserId === this.dependencies.getLocalOderId()) return; this.clearUserJoinedFallbackOffer(fromUserId); this.nonInitiatorWaitStart.delete(fromUserId); @@ -334,11 +332,9 @@ export class IncomingSignalingMessageHandler { const fromUserId = message.fromUserId; const sdp = message.payload?.sdp; - if (!fromUserId || !sdp) - return; + if (!fromUserId || !sdp) return; - if (fromUserId === this.dependencies.getLocalOderId()) - return; + if (fromUserId === this.dependencies.getLocalOderId()) return; this.clearUserJoinedFallbackOffer(fromUserId); @@ -350,11 +346,9 @@ export class IncomingSignalingMessageHandler { const fromUserId = message.fromUserId; const candidate = message.payload?.candidate; - if (!fromUserId || !candidate) - return; + if (!fromUserId || !candidate) return; - if (fromUserId === this.dependencies.getLocalOderId()) - return; + if (fromUserId === this.dependencies.getLocalOderId()) return; this.clearUserJoinedFallbackOffer(fromUserId); @@ -513,18 +507,15 @@ export class IncomingSignalingMessageHandler { } private shouldInitiatePeer(peerId: string, localOderId: string | null = this.dependencies.getLocalOderId()): boolean { - if (!localOderId) - return false; + if (!localOderId) return false; - if (peerId === localOderId) - return false; + if (peerId === localOderId) return false; return localOderId < peerId; } private hasActivePeerConnection(peer: PeerData | undefined): boolean { - if (!peer) - return false; + if (!peer) return false; const connectionState = peer.connection?.connectionState; @@ -532,13 +523,11 @@ export class IncomingSignalingMessageHandler { } private isPeerConnectionNegotiating(peer: PeerData | undefined): boolean { - if (!peer || this.hasActivePeerConnection(peer)) - return false; + if (!peer || this.hasActivePeerConnection(peer)) return false; const connectionState = peer.connection?.connectionState; - if (connectionState === 'closed' || connectionState === 'failed') - return false; + if (connectionState === 'closed' || connectionState === 'failed') return false; const signalingState = peer.connection?.signalingState; const ageMs = Date.now() - peer.createdAt; @@ -546,13 +535,11 @@ export class IncomingSignalingMessageHandler { // If a local offer (or pranswer) has already been sent, the peer is actively // negotiating with the remote side. Use a much longer grace period so that // a slow signaling round-trip does not trigger a premature teardown. - if (signalingState === 'have-local-offer' || signalingState === 'have-local-pranswer') - return ageMs < PEER_NEGOTIATION_OFFER_SENT_GRACE_MS; + if (signalingState === 'have-local-offer' || signalingState === 'have-local-pranswer') return ageMs < PEER_NEGOTIATION_OFFER_SENT_GRACE_MS; // ICE negotiation in progress (offer/answer exchange already complete, candidates being checked). // TURN relay can take 5-15 s on high-latency networks, so use the same extended grace. - if (connectionState === 'connecting') - return ageMs < PEER_NEGOTIATION_OFFER_SENT_GRACE_MS; + if (connectionState === 'connecting') return ageMs < PEER_NEGOTIATION_OFFER_SENT_GRACE_MS; return ageMs < PEER_NEGOTIATION_GRACE_MS; } diff --git a/toju-app/src/app/store/rooms/room-settings.effects.ts b/toju-app/src/app/store/rooms/room-settings.effects.ts index ba46661..3043075 100644 --- a/toju-app/src/app/store/rooms/room-settings.effects.ts +++ b/toju-app/src/app/store/rooms/room-settings.effects.ts @@ -308,18 +308,29 @@ export class RoomSettingsEffects { updateServerIcon$ = createEffect(() => this.actions$.pipe( ofType(RoomsActions.updateServerIcon), - withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)), + withLatestFrom( + this.store.select(selectCurrentUser), + this.store.select(selectCurrentRoom), + this.store.select(selectSavedRooms) + ), mergeMap(([ { roomId, icon }, currentUser, - currentRoom + currentRoom, + savedRooms ]) => { - if (!currentUser || !currentRoom || currentRoom.id !== roomId) { - return of(RoomsActions.updateServerIconFailure({ error: 'Not in room' })); + if (!currentUser) { + return of(RoomsActions.updateServerIconFailure({ error: 'Not logged in' })); } - const isOwner = currentRoom.hostId === currentUser.id; - const canByRole = resolveRoomPermission(currentRoom, currentUser, 'manageIcon'); + const room = resolveRoom(roomId, currentRoom, savedRooms); + + if (!room) { + return of(RoomsActions.updateServerIconFailure({ error: 'Room not found' })); + } + + const isOwner = room.hostId === currentUser.id || room.hostId === currentUser.oderId; + const canByRole = resolveRoomPermission(room, currentUser, 'manageIcon'); if (!isOwner && !canByRole) { return of(RoomsActions.updateServerIconFailure({ error: 'Permission denied' })); @@ -329,15 +340,32 @@ export class RoomSettingsEffects { const changes: Partial = { icon, iconUpdatedAt }; - this.db.updateRoom(roomId, changes); + this.db.updateRoom(room.id, changes); this.webrtc.broadcastMessage({ type: 'server-icon-update', - roomId, + roomId: room.id, icon, iconUpdatedAt }); + this.webrtc.sendRawMessage({ + type: 'server_icon_available', + serverId: room.id, + iconUpdatedAt + }); - return of(RoomsActions.updateServerIconSuccess({ roomId, + this.serverDirectory.updateServer(room.id, { + currentOwnerId: currentUser.id, + actingRole: isOwner ? 'host' : undefined, + icon, + iconUpdatedAt + }, { + sourceId: room.sourceId, + sourceUrl: room.sourceUrl + }).subscribe({ + error: () => {} + }); + + return of(RoomsActions.updateServerIconSuccess({ roomId: room.id, icon, iconUpdatedAt })); }) diff --git a/toju-app/src/app/store/rooms/room-state-sync.effects.ts b/toju-app/src/app/store/rooms/room-state-sync.effects.ts index fd399b3..6bb2656 100644 --- a/toju-app/src/app/store/rooms/room-state-sync.effects.ts +++ b/toju-app/src/app/store/rooms/room-state-sync.effects.ts @@ -1,44 +1,17 @@ /* eslint-disable @typescript-eslint/member-ordering */ import { Injectable, inject } from '@angular/core'; -import { - Actions, - createEffect, - ofType -} from '@ngrx/effects'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; import { Store, type Action } from '@ngrx/store'; -import { - of, - from, - EMPTY -} from 'rxjs'; -import { - map, - mergeMap, - withLatestFrom, - tap, - switchMap, - catchError -} from 'rxjs/operators'; +import { of, from, EMPTY } from 'rxjs'; +import { map, mergeMap, withLatestFrom, tap, switchMap, catchError } from 'rxjs/operators'; import { RoomsActions } from './rooms.actions'; import { UsersActions } from '../users/users.actions'; import { selectCurrentUser, selectAllUsers } from '../users/users.selectors'; -import { - selectActiveChannelId, - selectCurrentRoom, - selectSavedRooms -} from './rooms.selectors'; +import { selectActiveChannelId, selectCurrentRoom, selectSavedRooms } from './rooms.selectors'; import { RealtimeSessionFacade } from '../../core/realtime'; import { DatabaseService } from '../../infrastructure/persistence'; import { resolveRoomPermission } from '../../domains/access-control'; -import type { - ChatEvent, - Room, - RoomSettings, - RoomPermissions, - BanEntry, - User, - VoiceState -} from '../../shared-kernel'; +import type { ChatEvent, Room, RoomSettings, RoomPermissions, BanEntry, User, VoiceState } from '../../shared-kernel'; import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service'; import { hasRoomBanForUser } from '../../domains/access-control'; import { RECONNECT_SOUND_GRACE_MS } from '../../core/constants'; @@ -55,6 +28,8 @@ import { } from './rooms.helpers'; import type { RoomPresenceSignalingMessage } from './rooms.helpers'; +const SERVER_ICON_SYNC_REQUEST_DELAYS_MS = [1_500, 3_000, 5_000, 8_000]; + /** * NgRx effects for real-time state synchronisation: signaling presence * events (server_users, user_joined, user_left, access_denied), P2P @@ -75,6 +50,7 @@ export class RoomStateSyncEffects { * preventing false join/leave sounds during state refreshes. */ private knownVoiceUsers = new Set(); + private pendingServerIconRequestsByPeer = new Map>(); /** * When a user leaves (e.g. socket drops), record the timestamp so * that a rapid re-join (reconnect) does not trigger a false @@ -87,17 +63,8 @@ export class RoomStateSyncEffects { /** Handles WebRTC signaling events for user presence (join, leave, server_users). */ signalingMessages$ = createEffect(() => this.webrtc.onSignalingMessage.pipe( - withLatestFrom( - this.store.select(selectCurrentUser), - this.store.select(selectCurrentRoom), - this.store.select(selectSavedRooms) - ), - mergeMap(([ - message, - currentUser, - currentRoom, - savedRooms - ]) => { + withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom), this.store.select(selectSavedRooms)), + mergeMap(([message, currentUser, currentRoom, savedRooms]) => { const signalingMessage: RoomPresenceSignalingMessage = message; const myId = currentUser?.oderId || currentUser?.id; const viewedServerId = currentRoom?.id; @@ -106,8 +73,7 @@ export class RoomStateSyncEffects { switch (signalingMessage.type) { case 'server_users': { - if (!Array.isArray(signalingMessage.users) || !signalingMessage.serverId) - return EMPTY; + if (!Array.isArray(signalingMessage.users) || !signalingMessage.serverId) return EMPTY; const syncedUsers = signalingMessage.users .filter((user) => user.oderId !== myId) @@ -136,11 +102,9 @@ export class RoomStateSyncEffects { } case 'user_joined': { - if (!signalingMessage.serverId || signalingMessage.oderId === myId) - return EMPTY; + if (!signalingMessage.serverId || signalingMessage.oderId === myId) return EMPTY; - if (!signalingMessage.oderId) - return EMPTY; + if (!signalingMessage.oderId) return EMPTY; const joinedUser = { oderId: signalingMessage.oderId, @@ -168,12 +132,9 @@ export class RoomStateSyncEffects { } case 'user_left': { - if (!signalingMessage.oderId) - return EMPTY; + if (!signalingMessage.oderId) return EMPTY; - const remainingServerIds = Array.isArray(signalingMessage.serverIds) - ? signalingMessage.serverIds - : undefined; + const remainingServerIds = Array.isArray(signalingMessage.serverIds) ? signalingMessage.serverIds : undefined; if (!remainingServerIds || remainingServerIds.length === 0) { if (this.knownVoiceUsers.has(signalingMessage.oderId)) { @@ -199,24 +160,15 @@ export class RoomStateSyncEffects { } case 'status_update': { - if (!signalingMessage.oderId || !signalingMessage.status) - return EMPTY; + if (!signalingMessage.oderId || !signalingMessage.status) return EMPTY; - const validStatuses = [ - 'online', - 'away', - 'busy', - 'offline' - ]; + const validStatuses = ['online', 'away', 'busy', 'offline']; - if (!validStatuses.includes(signalingMessage.status)) - return EMPTY; + if (!validStatuses.includes(signalingMessage.status)) return EMPTY; // 'offline' from the server means the user chose Invisible; // display them as disconnected to other users. - const mappedStatus = signalingMessage.status === 'offline' - ? 'disconnected' - : signalingMessage.status as 'online' | 'away' | 'busy'; + const mappedStatus = signalingMessage.status === 'offline' ? 'disconnected' : (signalingMessage.status as 'online' | 'away' | 'busy'); return [ UsersActions.updateRemoteUserStatus({ @@ -227,21 +179,75 @@ export class RoomStateSyncEffects { } case 'access_denied': { - if (isWrongServer(signalingMessage.serverId, viewedServerId)) - return EMPTY; + if (isWrongServer(signalingMessage.serverId, viewedServerId)) return EMPTY; - if (signalingMessage.reason !== 'SERVER_NOT_FOUND') - return EMPTY; + if (signalingMessage.reason !== 'SERVER_NOT_FOUND') return EMPTY; // When multiple signal URLs are configured, the room may already // be successfully joined on a different signal server. Only show // the reconnect notice when the room is not reachable at all. - if (signalingMessage.serverId && this.webrtc.hasJoinedServer(signalingMessage.serverId)) - return EMPTY; + if (signalingMessage.serverId && this.webrtc.hasJoinedServer(signalingMessage.serverId)) return EMPTY; return [RoomsActions.setSignalServerReconnecting({ isReconnecting: true })]; } + case 'server_icon_sync_peers': { + if (!signalingMessage.serverId || !Array.isArray(signalingMessage.users)) { + return EMPTY; + } + + const serverId = signalingMessage.serverId; + + for (const user of signalingMessage.users) { + if (!user.oderId || user.oderId === myId) { + continue; + } + + this.queueServerIconSyncRequest(user.oderId, serverId); + this.webrtc.sendRawMessage({ + type: 'server_icon_peer_request', + targetUserId: user.oderId, + serverId + }); + } + + return EMPTY; + } + + case 'server_icon_peer_request': { + const serverId = signalingMessage.serverId; + const targetUserId = signalingMessage.fromUserId; + const room = resolveRoom(serverId, currentRoom, savedRooms); + + if (!serverId || !targetUserId || !room?.icon) { + return EMPTY; + } + + this.webrtc.sendRawMessage({ + type: 'server_icon_peer_data', + targetUserId, + serverId, + icon: room.icon, + iconUpdatedAt: room.iconUpdatedAt || 0 + }); + + return EMPTY; + } + + case 'server_icon_peer_data': { + if (!signalingMessage.serverId || typeof signalingMessage.icon !== 'string') { + return EMPTY; + } + + return of( + RoomsActions.receiveSearchServerIcon({ + roomId: signalingMessage.serverId, + icon: signalingMessage.icon, + iconUpdatedAt: signalingMessage.iconUpdatedAt || Date.now() + }) + ); + } + default: return EMPTY; } @@ -257,8 +263,7 @@ export class RoomStateSyncEffects { this.webrtc.onPeerConnected.pipe( withLatestFrom(this.store.select(selectCurrentRoom)), tap(([peerId, room]) => { - if (!room) - return; + if (!room) return; this.webrtc.sendToPeer(peerId, { type: 'server-state-request', @@ -273,12 +278,16 @@ export class RoomStateSyncEffects { roomEntryServerStateSync$ = createEffect( () => this.actions$.pipe( - ofType( - RoomsActions.createRoomSuccess, - RoomsActions.joinRoomSuccess, - RoomsActions.viewServerSuccess - ), + ofType(RoomsActions.createRoomSuccess, RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess), tap(({ room }) => { + if (room.iconUpdatedAt) { + this.webrtc.sendRawMessage({ + type: 'server_icon_available', + serverId: room.id, + iconUpdatedAt: room.iconUpdatedAt + }); + } + for (const peerId of this.webrtc.getConnectedPeers()) { try { this.webrtc.sendToPeer(peerId, { @@ -304,14 +313,7 @@ export class RoomStateSyncEffects { this.store.select(selectCurrentUser), this.store.select(selectActiveChannelId) ), - mergeMap(([ - event, - currentRoom, - savedRooms, - allUsers, - currentUser, - activeChannelId - ]) => { + mergeMap(([event, currentRoom, savedRooms, allUsers, currentUser, activeChannelId]) => { switch (event.type) { case 'voice-state': return this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'voice'); @@ -351,8 +353,7 @@ export class RoomStateSyncEffects { this.webrtc.onPeerConnected.pipe( withLatestFrom(this.store.select(selectCurrentRoom)), tap(([_peerId, room]) => { - if (!room) - return; + if (!room) return; const iconUpdatedAt = room.iconUpdatedAt || 0; @@ -366,18 +367,29 @@ export class RoomStateSyncEffects { { dispatch: false } ); + /** Sends queued discovery icon requests as soon as a temporary peer channel opens. */ + peerConnectedDiscoveryIconSync$ = createEffect( + () => + this.webrtc.onPeerConnected.pipe( + tap((peerId) => { + const serverIds = this.pendingServerIconRequestsByPeer.get(peerId); + + if (!serverIds) return; + + for (const serverId of serverIds) { + this.sendServerIconSyncRequest(peerId, serverId); + } + }) + ), + { dispatch: false } + ); + // ── Voice / Screen / Camera handlers ─────────────────────────── - private handleVoiceOrScreenState( - event: ChatEvent, - allUsers: User[], - currentUser: User | null, - kind: 'voice' | 'screen' | 'camera' - ) { + private handleVoiceOrScreenState(event: ChatEvent, allUsers: User[], currentUser: User | null, kind: 'voice' | 'screen' | 'camera') { const userId: string | undefined = event.fromPeerId ?? event.oderId; - if (!userId) - return EMPTY; + if (!userId) return EMPTY; const existingUser = allUsers.find((user) => user.id === userId || user.oderId === userId); const userExists = !!existingUser; @@ -385,18 +397,17 @@ export class RoomStateSyncEffects { if (kind === 'voice') { const vs = event.voiceState as Partial | undefined; - if (!vs) - return EMPTY; + if (!vs) return EMPTY; - const presenceRefreshAction = vs.serverId && !existingUser?.presenceServerIds?.includes(vs.serverId) - ? UsersActions.userJoined({ - user: buildSignalingUser( - { oderId: userId, - displayName: event.displayName || existingUser?.displayName || 'User' }, - { presenceServerIds: [vs.serverId] } - ) - }) - : null; + const presenceRefreshAction = + vs.serverId && !existingUser?.presenceServerIds?.includes(vs.serverId) + ? UsersActions.userJoined({ + user: buildSignalingUser( + { oderId: userId, displayName: event.displayName || existingUser?.displayName || 'User' }, + { presenceServerIds: [vs.serverId] } + ) + }) + : null; // Detect voice-connection transitions to play join/leave sounds. const weAreInVoice = this.webrtc.isVoiceConnected(); const nowConnected = vs.isConnected ?? false; @@ -427,8 +438,7 @@ export class RoomStateSyncEffects { return of( UsersActions.userJoined({ user: buildSignalingUser( - { oderId: userId, - displayName: event.displayName || 'User' }, + { oderId: userId, displayName: event.displayName || 'User' }, { presenceServerIds: vs.serverId ? [vs.serverId] : undefined, voiceState: { @@ -453,8 +463,7 @@ export class RoomStateSyncEffects { actions.push(presenceRefreshAction); } - actions.push(UsersActions.updateVoiceState({ userId, - voiceState: vs })); + actions.push(UsersActions.updateVoiceState({ userId, voiceState: vs })); return actions; } @@ -462,17 +471,12 @@ export class RoomStateSyncEffects { if (kind === 'screen') { const isSharing = event.isScreenSharing as boolean | undefined; - if (isSharing === undefined) - return EMPTY; + if (isSharing === undefined) return EMPTY; if (!userExists) { return of( UsersActions.userJoined({ - user: buildSignalingUser( - { oderId: userId, - displayName: event.displayName || 'User' }, - { screenShareState: { isSharing } } - ) + user: buildSignalingUser({ oderId: userId, displayName: event.displayName || 'User' }, { screenShareState: { isSharing } }) }) ); } @@ -487,17 +491,12 @@ export class RoomStateSyncEffects { const isCameraEnabled = event.isCameraEnabled as boolean | undefined; - if (isCameraEnabled === undefined) - return EMPTY; + if (isCameraEnabled === undefined) return EMPTY; if (!userExists) { return of( UsersActions.userJoined({ - user: buildSignalingUser( - { oderId: userId, - displayName: event.displayName || 'User' }, - { cameraState: { isEnabled: isCameraEnabled } } - ) + user: buildSignalingUser({ oderId: userId, displayName: event.displayName || 'User' }, { cameraState: { isEnabled: isCameraEnabled } }) }) ); } @@ -510,12 +509,7 @@ export class RoomStateSyncEffects { ); } - private handleVoiceChannelMove( - event: ChatEvent, - currentRoom: Room | null, - savedRooms: Room[], - currentUser: User | null - ) { + private handleVoiceChannelMove(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[], currentUser: User | null) { const targetUserId = typeof event.targetUserId === 'string' ? event.targetUserId : null; const serverId = typeof event.roomId === 'string' ? event.roomId : currentUser?.voiceState?.serverId; const nextVoiceState = event.voiceState as Partial | undefined; @@ -566,22 +560,23 @@ export class RoomStateSyncEffects { voiceState: updatedVoiceState }); - return of(UsersActions.updateVoiceState({ - userId: currentUser.id, - voiceState: updatedVoiceState - })); + return of( + UsersActions.updateVoiceState({ + userId: currentUser.id, + voiceState: updatedVoiceState + }) + ); } - private isSameVoiceRoom( - voiceState: Partial | undefined, - currentUserVoiceState: Partial | undefined - ): boolean { - return !!voiceState?.isConnected - && !!currentUserVoiceState?.isConnected - && !!voiceState.roomId - && !!voiceState.serverId - && voiceState.roomId === currentUserVoiceState.roomId - && voiceState.serverId === currentUserVoiceState.serverId; + private isSameVoiceRoom(voiceState: Partial | undefined, currentUserVoiceState: Partial | undefined): boolean { + return ( + !!voiceState?.isConnected && + !!currentUserVoiceState?.isConnected && + !!voiceState.roomId && + !!voiceState.serverId && + voiceState.roomId === currentUserVoiceState.roomId && + voiceState.serverId === currentUserVoiceState.serverId + ); } /** @@ -614,8 +609,7 @@ export class RoomStateSyncEffects { const room = resolveRoom(roomId, currentRoom, savedRooms); const fromPeerId = event.fromPeerId; - if (!room || !fromPeerId) - return EMPTY; + if (!room || !fromPeerId) return EMPTY; return from(this.db.getBansForRoom(room.id)).pipe( tap((bans) => { @@ -630,18 +624,12 @@ export class RoomStateSyncEffects { ); } - private handleServerStateFull( - event: ChatEvent, - currentRoom: Room | null, - savedRooms: Room[], - currentUser: { id: string; oderId: string } | null - ) { + private handleServerStateFull(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[], currentUser: { id: string; oderId: string } | null) { const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id; const room = resolveRoom(roomId, currentRoom, savedRooms); const incomingRoom = event.room as Partial | undefined; - if (!room || !incomingRoom) - return EMPTY; + if (!room || !incomingRoom) return EMPTY; const roomChanges = { ...sanitizeRoomSnapshot(incomingRoom), @@ -651,19 +639,17 @@ export class RoomStateSyncEffects { return this.syncBansToLocalRoom(room.id, bans).pipe( mergeMap(() => { - const actions: (ReturnType + const actions: ( + | ReturnType | ReturnType - | ReturnType)[] = [ + | ReturnType + )[] = [ RoomsActions.updateRoom({ roomId: room.id, changes: roomChanges }) ]; - const isCurrentUserBanned = hasRoomBanForUser( - bans, - currentUser, - getPersistedCurrentUserId() - ); + const isCurrentUserBanned = hasRoomBanForUser(bans, currentUser, getPersistedCurrentUserId()); if (currentRoom?.id === room.id) { actions.push(UsersActions.loadBansSuccess({ bans })); @@ -684,8 +670,7 @@ export class RoomStateSyncEffects { const room = resolveRoom(roomId, currentRoom, savedRooms); const settings = event.settings as Partial | undefined; - if (!room || !settings) - return EMPTY; + if (!room || !settings) return EMPTY; return of( RoomsActions.updateRoom({ @@ -699,7 +684,9 @@ export class RoomStateSyncEffects { hasPassword: typeof settings.hasPassword === 'boolean' ? settings.hasPassword - : (typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password), + : typeof room.hasPassword === 'boolean' + ? room.hasPassword + : !!room.password, maxUsers: settings.maxUsers ?? room.maxUsers } }) @@ -712,17 +699,13 @@ export class RoomStateSyncEffects { const permissions = event.permissions as Partial | undefined; const incomingRoom = event.room as Partial | undefined; - if (!room || (!permissions && !incomingRoom)) - return EMPTY; + if (!room || (!permissions && !incomingRoom)) return EMPTY; return of( RoomsActions.updateRoom({ roomId: room.id, changes: { - permissions: permissions - ? { ...(room.permissions || {}), - ...permissions } as RoomPermissions - : room.permissions, + permissions: permissions ? ({ ...(room.permissions || {}), ...permissions } as RoomPermissions) : room.permissions, roles: Array.isArray(incomingRoom?.roles) ? incomingRoom.roles : room.roles, roleAssignments: Array.isArray(incomingRoom?.roleAssignments) ? incomingRoom.roleAssignments : room.roleAssignments, channelPermissions: Array.isArray(incomingRoom?.channelPermissions) ? incomingRoom.channelPermissions : room.channelPermissions, @@ -732,12 +715,7 @@ export class RoomStateSyncEffects { ); } - private handleChannelsUpdate( - event: ChatEvent, - currentRoom: Room | null, - savedRooms: Room[], - activeChannelId: string - ): Action[] { + private handleChannelsUpdate(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[], activeChannelId: string): Action[] { const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id; const room = resolveRoom(roomId, currentRoom, savedRooms); const channels = Array.isArray(event.channels) ? event.channels : null; @@ -754,8 +732,7 @@ export class RoomStateSyncEffects { ]; if (!channels.some((channel) => channel.id === activeChannelId)) { - const fallbackChannelId = channels.find((channel) => channel.type === 'text')?.id - ?? 'general'; + const fallbackChannelId = channels.find((channel) => channel.type === 'text')?.id ?? 'general'; actions.push(RoomsActions.selectChannel({ channelId: fallbackChannelId })); } @@ -769,8 +746,7 @@ export class RoomStateSyncEffects { const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id; const room = resolveRoom(roomId, currentRoom, savedRooms); - if (!room) - return EMPTY; + if (!room) return EMPTY; const remoteUpdated = event.iconUpdatedAt || 0; const localUpdated = room.iconUpdatedAt || 0; @@ -789,8 +765,7 @@ export class RoomStateSyncEffects { const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id; const room = resolveRoom(roomId, currentRoom, savedRooms); - if (!room) - return EMPTY; + if (!room) return EMPTY; if (event.fromPeerId) { this.webrtc.sendToPeer(event.fromPeerId, { @@ -809,20 +784,17 @@ export class RoomStateSyncEffects { const room = resolveRoom(roomId, currentRoom, savedRooms); const senderId = event.fromPeerId; - if (!room || typeof event.icon !== 'string' || !senderId) - return EMPTY; + if (!room || typeof event.icon !== 'string' || !senderId) return this.handleSearchResultIconData(event, roomId); return this.store.select(selectAllUsers).pipe( map((users) => users.find((user) => user.id === senderId)), mergeMap((sender) => { - if (!sender) - return EMPTY; + if (!sender) return EMPTY; const isOwner = room.hostId === sender.id; const canByRole = resolveRoomPermission(room, sender, 'manageIcon'); - if (!isOwner && !canByRole) - return EMPTY; + if (!isOwner && !canByRole) return EMPTY; const updates: Partial = { icon: event.icon, @@ -830,23 +802,63 @@ export class RoomStateSyncEffects { }; this.db.updateRoom(room.id, updates); - return of(RoomsActions.updateRoom({ roomId: room.id, - changes: updates })); + this.webrtc.sendRawMessage({ + type: 'server_icon_available', + serverId: room.id, + iconUpdatedAt: updates.iconUpdatedAt + }); + return of(RoomsActions.updateRoom({ roomId: room.id, changes: updates })); }) ); } + private handleSearchResultIconData(event: ChatEvent, roomId: string | undefined) { + if (!roomId || typeof event.icon !== 'string') { + return EMPTY; + } + + const iconUpdatedAt = event.iconUpdatedAt || Date.now(); + + return of( + RoomsActions.receiveSearchServerIcon({ + roomId, + icon: event.icon, + iconUpdatedAt + }) + ); + } + + private queueServerIconSyncRequest(peerId: string, serverId: string): void { + const pendingServerIds = this.pendingServerIconRequestsByPeer.get(peerId) ?? new Set(); + + pendingServerIds.add(serverId); + this.pendingServerIconRequestsByPeer.set(peerId, pendingServerIds); + this.scheduleServerIconSyncRequests(peerId, serverId); + } + + private scheduleServerIconSyncRequests(peerId: string, serverId: string): void { + for (const delayMs of SERVER_ICON_SYNC_REQUEST_DELAYS_MS) { + setTimeout(() => { + this.sendServerIconSyncRequest(peerId, serverId); + }, delayMs); + } + } + + private sendServerIconSyncRequest(peerId: string, serverId: string): void { + this.webrtc.sendToPeer(peerId, { + type: 'server-icon-request', + roomId: serverId + }); + } + // ── Internal helpers ─────────────────────────────────────────── private syncBansToLocalRoom(roomId: string, bans: BanEntry[]) { return from(this.db.getBansForRoom(roomId)).pipe( switchMap((localBans) => { const nextIds = new Set(bans.map((ban) => ban.oderId)); - const removals = localBans - .filter((ban) => !nextIds.has(ban.oderId)) - .map((ban) => this.db.removeBan(ban.oderId)); - const saves = bans.map((ban) => this.db.saveBan({ ...ban, - roomId })); + const removals = localBans.filter((ban) => !nextIds.has(ban.oderId)).map((ban) => this.db.removeBan(ban.oderId)); + const saves = bans.map((ban) => this.db.saveBan({ ...ban, roomId })); return from(Promise.all([...removals, ...saves])); }) diff --git a/toju-app/src/app/store/rooms/rooms.actions.ts b/toju-app/src/app/store/rooms/rooms.actions.ts index 0e7a7b6..6f1d293 100644 --- a/toju-app/src/app/store/rooms/rooms.actions.ts +++ b/toju-app/src/app/store/rooms/rooms.actions.ts @@ -72,6 +72,7 @@ export const RoomsActions = createActionGroup({ 'Update Server Icon': props<{ roomId: string; icon: string }>(), 'Update Server Icon Success': props<{ roomId: string; icon: string; iconUpdatedAt: number }>(), 'Update Server Icon Failure': props<{ error: string }>(), + 'Receive Search Server Icon': props<{ roomId: string; icon: string; iconUpdatedAt: number }>(), 'Set Current Room': props<{ room: Room }>(), 'Clear Current Room': emptyProps(), diff --git a/toju-app/src/app/store/rooms/rooms.effects.ts b/toju-app/src/app/store/rooms/rooms.effects.ts index ad17235..e8f5d6f 100644 --- a/toju-app/src/app/store/rooms/rooms.effects.ts +++ b/toju-app/src/app/store/rooms/rooms.effects.ts @@ -229,6 +229,8 @@ export class RoomsEffects { isPrivate: room.isPrivate, userCount: 1, maxUsers: room.maxUsers || 50, + icon: room.icon, + iconUpdatedAt: room.iconUpdatedAt, tags: [], channels: room.channels ?? defaultChannels() }, endpoint ? { @@ -288,6 +290,8 @@ export class RoomsEffects { const resolvedRoom: Room = { ...room, isPrivate: typeof serverInfo?.isPrivate === 'boolean' ? serverInfo.isPrivate : room.isPrivate, + icon: serverInfo?.icon ?? room.icon, + iconUpdatedAt: serverInfo?.iconUpdatedAt ?? room.iconUpdatedAt, channels: resolveRoomChannels(room.channels, serverInfo?.channels), slowModeInterval: serverInfo?.slowModeInterval ?? room.slowModeInterval, roles: serverInfo?.roles ?? room.roles, @@ -309,6 +313,8 @@ export class RoomsEffects { roles: resolvedRoom.roles, roleAssignments: resolvedRoom.roleAssignments, channelPermissions: resolvedRoom.channelPermissions, + icon: resolvedRoom.icon, + iconUpdatedAt: resolvedRoom.iconUpdatedAt, hasPassword: resolvedRoom.hasPassword, isPrivate: resolvedRoom.isPrivate }); @@ -337,6 +343,8 @@ export class RoomsEffects { createdAt: Date.now(), userCount: 1, maxUsers: 50, + icon: serverInfo.icon, + iconUpdatedAt: serverInfo.iconUpdatedAt, channels: resolveRoomChannels(undefined, serverInfo.channels), slowModeInterval: serverInfo.slowModeInterval, roles: serverInfo.roles, @@ -372,6 +380,8 @@ export class RoomsEffects { createdAt: serverData.createdAt || Date.now(), userCount: serverData.userCount, maxUsers: serverData.maxUsers, + icon: serverData.icon, + iconUpdatedAt: serverData.iconUpdatedAt, channels: resolveRoomChannels(undefined, serverData.channels), slowModeInterval: serverData.slowModeInterval, roles: serverData.roles, @@ -557,6 +567,8 @@ export class RoomsEffects { hasPassword: !!serverData.hasPassword, isPrivate: serverData.isPrivate, maxUsers: serverData.maxUsers, + icon: serverData.icon ?? room.icon, + iconUpdatedAt: serverData.iconUpdatedAt ?? room.iconUpdatedAt, channels: resolveRoomChannels(room.channels, serverData.channels), slowModeInterval: serverData.slowModeInterval ?? room.slowModeInterval, roles: serverData.roles ?? room.roles, diff --git a/toju-app/src/app/store/rooms/rooms.helpers.ts b/toju-app/src/app/store/rooms/rooms.helpers.ts index edf20fa..b0fdaa7 100644 --- a/toju-app/src/app/store/rooms/rooms.helpers.ts +++ b/toju-app/src/app/store/rooms/rooms.helpers.ts @@ -1,30 +1,18 @@ import { v4 as uuidv4 } from 'uuid'; -import { - Room, - BanEntry, - User -} from '../../shared-kernel'; +import { Room, BanEntry, User } from '../../shared-kernel'; import { resolveLegacyRole, resolveRoomPermission } from '../../domains/access-control'; import { findRoomMember } from './room-members.helpers'; import { ROOM_URL_PATTERN } from '../../core/constants'; /** Build a minimal User object from signaling payload. */ -export function buildSignalingUser( - data: { oderId: string; displayName?: string; status?: string }, - extras: Record = {} -) { +export function buildSignalingUser(data: { oderId: string; displayName?: string; status?: string }, extras: Record = {}) { const displayName = data.displayName?.trim() || 'User'; - const rawStatus = ([ - 'online', - 'away', - 'busy', - 'offline' - ] as const).includes(data.status as 'online') - ? data.status as 'online' | 'away' | 'busy' | 'offline' + const rawStatus = (['online', 'away', 'busy', 'offline'] as const).includes(data.status as 'online') + ? (data.status as 'online' | 'away' | 'busy' | 'offline') : 'online'; // 'offline' from the server means the user chose Invisible; // display them as disconnected to other users. - const status = rawStatus === 'offline' ? 'disconnected' as const : rawStatus; + const status = rawStatus === 'offline' ? ('disconnected' as const) : rawStatus; return { oderId: data.oderId, @@ -43,8 +31,7 @@ export function buildSignalingUser( export function buildKnownUserExtras(room: Room | null, identifier: string): Record { const knownMember = room ? findRoomMember(room.members ?? [], identifier) : undefined; - if (!knownMember) - return {}; + if (!knownMember) return {}; return { username: knownMember.username, @@ -60,10 +47,7 @@ export function buildKnownUserExtras(room: Room | null, identifier: string): Rec } /** Returns true when the message's server ID does not match the viewed server. */ -export function isWrongServer( - msgServerId: string | undefined, - viewedServerId: string | undefined -): boolean { +export function isWrongServer(msgServerId: string | undefined, viewedServerId: string | undefined): boolean { return !!(msgServerId && viewedServerId && msgServerId !== viewedServerId); } @@ -110,9 +94,7 @@ export function reconcileRoomSnapshotChannels( } if (hasPersistedChannels(cachedChannels) && hasPersistedChannels(incomingChannels)) { - return incomingChannels.length >= cachedChannels.length - ? incomingChannels - : cachedChannels; + return incomingChannels.length >= cachedChannels.length ? incomingChannels : cachedChannels; } if (hasPersistedChannels(incomingChannels)) { @@ -122,10 +104,7 @@ export function reconcileRoomSnapshotChannels( return undefined; } -export function resolveTextChannelId( - channels: Room['channels'] | undefined, - preferredChannelId?: string | null -): string | null { +export function resolveTextChannelId(channels: Room['channels'] | undefined, preferredChannelId?: string | null): string | null { const textChannels = (channels ?? []).filter((channel) => channel.type === 'text'); if (preferredChannelId && textChannels.some((channel) => channel.id === preferredChannelId)) { @@ -136,11 +115,9 @@ export function resolveTextChannelId( } export function resolveRoom(roomId: string | undefined, currentRoom: Room | null, savedRooms: Room[]): Room | null { - if (!roomId) - return currentRoom; + if (!roomId) return currentRoom; - if (currentRoom?.id === roomId) - return currentRoom; + if (currentRoom?.id === roomId) return currentRoom; return savedRooms.find((room) => room.id === roomId) ?? null; } @@ -152,9 +129,7 @@ export function sanitizeRoomSnapshot(room: Partial): Partial { topic: typeof room.topic === 'string' ? room.topic : undefined, hostId: typeof room.hostId === 'string' ? room.hostId : undefined, hasPassword: - typeof room.hasPassword === 'boolean' - ? room.hasPassword - : (typeof room.password === 'string' ? room.password.trim().length > 0 : undefined), + typeof room.hasPassword === 'boolean' ? room.hasPassword : typeof room.password === 'string' ? room.password.trim().length > 0 : undefined, isPrivate: typeof room.isPrivate === 'boolean' ? room.isPrivate : undefined, maxUsers: typeof room.maxUsers === 'number' ? room.maxUsers : undefined, icon: typeof room.icon === 'string' ? room.icon : undefined, @@ -173,8 +148,7 @@ export function sanitizeRoomSnapshot(room: Partial): Partial { } export function normalizeIncomingBans(roomId: string, bans: unknown): BanEntry[] { - if (!Array.isArray(bans)) - return []; + if (!Array.isArray(bans)) return []; const now = Date.now(); @@ -225,6 +199,9 @@ export interface RoomPresenceSignalingMessage { oderId?: string; displayName?: string; description?: string; + fromUserId?: string; + icon?: string; + iconUpdatedAt?: number; profileUpdatedAt?: number; status?: string; } diff --git a/toju-app/src/app/store/rooms/rooms.reducer.ts b/toju-app/src/app/store/rooms/rooms.reducer.ts index 542cb5e..02bf2b6 100644 --- a/toju-app/src/app/store/rooms/rooms.reducer.ts +++ b/toju-app/src/app/store/rooms/rooms.reducer.ts @@ -4,11 +4,7 @@ import { normalizeRoomAccessControl } from '../../domains/access-control'; import { type ServerInfo } from '../../domains/server-directory'; import { RoomsActions } from './rooms.actions'; import { defaultChannels } from './room-channels.defaults'; -import { - isChannelNameTaken, - normalizeChannelName, - normalizeRoomChannels -} from './room-channels.rules'; +import { isChannelNameTaken, normalizeChannelName, normalizeRoomChannels } from './room-channels.rules'; import { pruneRoomMembers } from './room-members.helpers'; /** Deduplicate rooms by id, keeping the last occurrence */ @@ -35,9 +31,7 @@ function enrichRoom(room: Room): Room { function resolveActiveTextChannelId(channels: Room['channels'], currentActiveChannelId: string): string { const textChannels = (channels ?? []).filter((channel) => channel.type === 'text'); - return textChannels.some((channel) => channel.id === currentActiveChannelId) - ? currentActiveChannelId - : (textChannels[0]?.id ?? 'general'); + return textChannels.some((channel) => channel.id === currentActiveChannelId) ? currentActiveChannelId : (textChannels[0]?.id ?? 'general'); } function getDefaultTextChannelId(room: Room): string { @@ -47,7 +41,7 @@ function getDefaultTextChannelId(room: Room): string { /** Upsert a room into a saved-rooms list (add or replace by id) */ function upsertRoom(savedRooms: Room[], room: Room): Room[] { const normalizedRoom = enrichRoom(room); - const idx = savedRooms.findIndex(existingRoom => existingRoom.id === room.id); + const idx = savedRooms.findIndex((existingRoom) => existingRoom.id === room.id); if (idx >= 0) { const updated = [...savedRooms]; @@ -250,8 +244,7 @@ export const roomsReducer = createReducer( })), on(RoomsActions.updateRoomSettingsSuccess, (state, { roomId, settings }) => { - const baseRoom = state.savedRooms.find((savedRoom) => savedRoom.id === roomId) - || (state.currentRoom?.id === roomId ? state.currentRoom : null); + const baseRoom = state.savedRooms.find((savedRoom) => savedRoom.id === roomId) || (state.currentRoom?.id === roomId ? state.currentRoom : null); if (!baseRoom) { return { @@ -270,9 +263,9 @@ export const roomsReducer = createReducer( hasPassword: typeof settings.hasPassword === 'boolean' ? settings.hasPassword - : (typeof settings.password === 'string' + : typeof settings.password === 'string' ? settings.password.trim().length > 0 - : baseRoom.hasPassword), + : baseRoom.hasPassword, maxUsers: settings.maxUsers }); @@ -330,33 +323,28 @@ export const roomsReducer = createReducer( // Update room on(RoomsActions.updateRoom, (state, { roomId, changes }) => { - const baseRoom = state.savedRooms.find((savedRoom) => savedRoom.id === roomId) - || (state.currentRoom?.id === roomId ? state.currentRoom : null); + const baseRoom = state.savedRooms.find((savedRoom) => savedRoom.id === roomId) || (state.currentRoom?.id === roomId ? state.currentRoom : null); - if (!baseRoom) - return state; + if (!baseRoom) return state; - const updatedRoom = enrichRoom({ ...baseRoom, - ...changes }); + const updatedRoom = enrichRoom({ ...baseRoom, ...changes }); return { ...state, currentRoom: state.currentRoom?.id === roomId ? updatedRoom : state.currentRoom, savedRooms: upsertRoom(state.savedRooms, updatedRoom), - activeChannelId: state.currentRoom?.id === roomId - ? resolveActiveTextChannelId(updatedRoom.channels, state.activeChannelId) - : state.activeChannelId + activeChannelId: + state.currentRoom?.id === roomId ? resolveActiveTextChannelId(updatedRoom.channels, state.activeChannelId) : state.activeChannelId }; }), // Update server icon success on(RoomsActions.updateServerIconSuccess, (state, { roomId, icon, iconUpdatedAt }) => { - if (state.currentRoom?.id !== roomId) - return state; + const baseRoom = state.savedRooms.find((savedRoom) => savedRoom.id === roomId) || (state.currentRoom?.id === roomId ? state.currentRoom : null); - const updatedRoom = enrichRoom({ ...state.currentRoom, - icon, - iconUpdatedAt }); + if (!baseRoom) return state; + + const updatedRoom = enrichRoom({ ...baseRoom, icon, iconUpdatedAt }); return { ...state, @@ -365,13 +353,18 @@ export const roomsReducer = createReducer( }; }), + on(RoomsActions.receiveSearchServerIcon, (state, { roomId, icon, iconUpdatedAt }) => ({ + ...state, + searchResults: state.searchResults.map((server) => + server.id === roomId && (!server.icon || (server.iconUpdatedAt ?? 0) < iconUpdatedAt) ? { ...server, icon, iconUpdatedAt } : server + ) + })), + // Receive room update on(RoomsActions.receiveRoomUpdate, (state, { room }) => { - if (!state.currentRoom) - return state; + if (!state.currentRoom) return state; - const updatedRoom = enrichRoom({ ...state.currentRoom, - ...room }); + const updatedRoom = enrichRoom({ ...state.currentRoom, ...room }); return { ...state, @@ -410,27 +403,17 @@ export const roomsReducer = createReducer( })), on(RoomsActions.addChannel, (state, { channel }) => { - if (!state.currentRoom) - return state; + if (!state.currentRoom) return state; const existing = state.currentRoom.channels || defaultChannels(); const normalizedName = normalizeChannelName(channel.name); - if ( - !normalizedName - || existing.some((entry) => entry.id === channel.id) - || isChannelNameTaken(existing, normalizedName, channel.type) - ) { + if (!normalizedName || existing.some((entry) => entry.id === channel.id) || isChannelNameTaken(existing, normalizedName, channel.type)) { return state; } - const updatedChannels = [ - ...existing, - { ...channel, - name: normalizedName } - ]; - const updatedRoom = { ...state.currentRoom, - channels: updatedChannels }; + const updatedChannels = [...existing, { ...channel, name: normalizedName }]; + const updatedRoom = { ...state.currentRoom, channels: updatedChannels }; return { ...state, @@ -441,13 +424,11 @@ export const roomsReducer = createReducer( }), on(RoomsActions.removeChannel, (state, { channelId }) => { - if (!state.currentRoom) - return state; + if (!state.currentRoom) return state; const existing = state.currentRoom.channels || defaultChannels(); - const updatedChannels = existing.filter(channel => channel.id !== channelId); - const updatedRoom = { ...state.currentRoom, - channels: updatedChannels }; + const updatedChannels = existing.filter((channel) => channel.id !== channelId); + const updatedRoom = { ...state.currentRoom, channels: updatedChannels }; return { ...state, @@ -458,8 +439,7 @@ export const roomsReducer = createReducer( }), on(RoomsActions.renameChannel, (state, { channelId, name }) => { - if (!state.currentRoom) - return state; + if (!state.currentRoom) return state; const existing = state.currentRoom.channels || defaultChannels(); const normalizedName = normalizeChannelName(name); @@ -469,10 +449,8 @@ export const roomsReducer = createReducer( return state; } - const updatedChannels = existing.map(channel => channel.id === channelId ? { ...channel, - name: normalizedName } : channel); - const updatedRoom = { ...state.currentRoom, - channels: updatedChannels }; + const updatedChannels = existing.map((channel) => (channel.id === channelId ? { ...channel, name: normalizedName } : channel)); + const updatedRoom = { ...state.currentRoom, channels: updatedChannels }; return { ...state, From b8f6d58d99a18edca84c66326f2f9bbf06ec7c8b Mon Sep 17 00:00:00 2001 From: Myx Date: Wed, 29 Apr 2026 19:05:38 +0200 Subject: [PATCH 6/9] test: repair broken tests --- e2e/tests/plugins/plugin-api-two-users.spec.ts | 2 +- e2e/tests/plugins/plugin-manager-ui.spec.ts | 4 ++-- server/src/migrations/1000000000009-ServerIcons.ts | 2 +- .../feature/plugin-page-host/plugin-page-host.component.html | 2 +- .../feature/server-search/server-search.component.ts | 4 +--- .../app/infrastructure/realtime/realtime-session.service.ts | 5 +++++ 6 files changed, 11 insertions(+), 8 deletions(-) diff --git a/e2e/tests/plugins/plugin-api-two-users.spec.ts b/e2e/tests/plugins/plugin-api-two-users.spec.ts index 02ab548..ee26bc7 100644 --- a/e2e/tests/plugins/plugin-api-two-users.spec.ts +++ b/e2e/tests/plugins/plugin-api-two-users.spec.ts @@ -141,7 +141,7 @@ async function registerUser(page: Page, username: string, displayName: string): } async function installGrantAndActivatePlugin(page: Page, installFromStore: boolean): Promise { - await page.getByRole('button', { name: 'Plugins' }).click(); + await page.getByRole('button', { name: 'Plugin Store' }).click(); await expect(page).toHaveURL(/\/plugin-store/, { timeout: 20_000 }); await expect(page.getByTestId('plugin-store-page')).toBeVisible({ timeout: 20_000 }); diff --git a/e2e/tests/plugins/plugin-manager-ui.spec.ts b/e2e/tests/plugins/plugin-manager-ui.spec.ts index 35eb154..5910fd8 100644 --- a/e2e/tests/plugins/plugin-manager-ui.spec.ts +++ b/e2e/tests/plugins/plugin-manager-ui.spec.ts @@ -23,8 +23,8 @@ test.describe('Plugin manager UI', () => { await expect(page).toHaveURL(/\/room\//, { timeout: 30_000 }); }); - await test.step('Open visible Plugins button', async () => { - await page.getByRole('button', { name: 'Plugins' }).click(); + await test.step('Open visible Plugin Store button', async () => { + await page.getByRole('button', { name: 'Plugin Store' }).click(); await expect(page).toHaveURL(/\/plugin-store/, { timeout: 10_000 }); await expect(page.getByTestId('plugin-store-page')).toBeVisible({ timeout: 10_000 }); }); diff --git a/server/src/migrations/1000000000009-ServerIcons.ts b/server/src/migrations/1000000000009-ServerIcons.ts index 79bbb19..8d24ed1 100644 --- a/server/src/migrations/1000000000009-ServerIcons.ts +++ b/server/src/migrations/1000000000009-ServerIcons.ts @@ -28,4 +28,4 @@ export class ServerIcons1000000000009 implements MigrationInterface { await queryRunner.query(`DROP TABLE "servers"`); await queryRunner.query(`ALTER TABLE "servers_without_icons" RENAME TO "servers"`); } -} \ No newline at end of file +} diff --git a/toju-app/src/app/domains/plugins/feature/plugin-page-host/plugin-page-host.component.html b/toju-app/src/app/domains/plugins/feature/plugin-page-host/plugin-page-host.component.html index 13b6933..c25d82c 100644 --- a/toju-app/src/app/domains/plugins/feature/plugin-page-host/plugin-page-host.component.html +++ b/toju-app/src/app/domains/plugins/feature/plugin-page-host/plugin-page-host.component.html @@ -14,4 +14,4 @@

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

} -
\ No newline at end of file + diff --git a/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.ts b/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.ts index 3308c23..0b0b14c 100644 --- a/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.ts +++ b/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.ts @@ -415,13 +415,11 @@ export class ServerSearchComponent implements OnInit { description: currentUser.description, profileUpdatedAt: currentUser.profileUpdatedAt }); - this.webrtc.joinRoom(server.id, currentUser.oderId || currentUser.id, wsUrl); - this.webrtc.sendRawMessage({ + this.webrtc.sendRawMessageToSignalUrl(wsUrl, { type: 'server_icon_sync_request', serverId: server.id, iconUpdatedAt: 0 }); - window.setTimeout(() => this.webrtc.leaveRoom(server.id), 15_000); } catch { /* discovery icons are best-effort */ } diff --git a/toju-app/src/app/infrastructure/realtime/realtime-session.service.ts b/toju-app/src/app/infrastructure/realtime/realtime-session.service.ts index a1db6ec..0a4eea1 100644 --- a/toju-app/src/app/infrastructure/realtime/realtime-session.service.ts +++ b/toju-app/src/app/infrastructure/realtime/realtime-session.service.ts @@ -304,6 +304,11 @@ export class WebRTCService implements OnDestroy { this.signalingTransportHandler.sendRawMessage(message); } + /** Send a raw JSON payload through a specific signaling WebSocket. */ + sendRawMessageToSignalUrl(signalUrl: string, message: Record): boolean { + return this.signalingTransportHandler.sendRawMessageToSignalUrl(signalUrl, message); + } + /** * Track the currently-active server ID (for server-scoped operations). * From fa2cca6fa45be54ada1ee5a0205f8c208f36f688 Mon Sep 17 00:00:00 2001 From: Myx Date: Wed, 29 Apr 2026 20:33:54 +0200 Subject: [PATCH 7/9] fix: improve plugins functionality with server management --- e2e/helpers/seed-test-endpoint.ts | 1 + e2e/pages/login.page.ts | 4 +- e2e/pages/server-search.page.ts | 9 +- .../auth/user-session-data-isolation.spec.ts | 47 +++-- e2e/tests/chat/server-icon-sync.spec.ts | 88 +++++--- .../plugins/plugin-api-two-users.spec.ts | 9 +- electron/README.md | 2 +- electron/api/auth-store.ts | 1 - electron/api/docs-html.ts | 13 +- electron/api/docusaurus-static.ts | 6 +- electron/api/http-helpers.ts | 1 + electron/api/local-api-server.ts | 4 +- electron/api/openapi.ts | 18 +- electron/api/router.ts | 18 +- .../commands/handlers/clearRoomMessages.ts | 8 +- .../cqrs/commands/handlers/deleteMessage.ts | 8 +- .../commands/handlers/deletePluginData.ts | 7 + electron/cqrs/commands/handlers/deleteRoom.ts | 18 +- .../cqrs/commands/handlers/saveMessage.ts | 3 + .../cqrs/commands/handlers/savePluginData.ts | 7 + electron/cqrs/commands/handlers/saveRoom.ts | 13 +- .../cqrs/commands/handlers/updateMessage.ts | 9 +- electron/cqrs/commands/handlers/updateRoom.ts | 7 + electron/cqrs/current-user-scope.ts | 24 +++ electron/cqrs/queries/handlers/getAllRooms.ts | 21 +- .../cqrs/queries/handlers/getCurrentUserId.ts | 2 +- .../cqrs/queries/handlers/getMessageById.ts | 9 +- electron/cqrs/queries/handlers/getMessages.ts | 9 +- .../cqrs/queries/handlers/getMessagesSince.ts | 8 + .../cqrs/queries/handlers/getPluginData.ts | 8 + electron/cqrs/queries/handlers/getRoom.ts | 7 + electron/data-archive.ts | 10 +- electron/data-management.ts | 15 +- electron/db/database.ts | 16 +- electron/desktop-settings.ts | 1 - electron/entities/MessageEntity.ts | 3 + electron/entities/PluginDataEntity.ts | 3 + electron/entities/RoomOwnerEntity.ts | 19 ++ electron/entities/index.ts | 1 + electron/ipc/system.ts | 6 +- ...000000000009-UserScopedRoomsAndMessages.ts | 50 +++++ .../1000000000010-UserScopedPluginData.ts | 56 ++++++ server/src/websocket/handler.ts | 39 +++- toju-app/src/app/app.ts | 3 +- .../app/core/storage/current-user-storage.ts | 2 +- .../feature/login/login.component.ts | 2 +- .../feature/register/register.component.ts | 2 +- .../services/direct-message.service.spec.ts | 6 +- .../application/services/friend.service.ts | 1 + .../friend-button/friend-button.component.ts | 1 + .../effects/notifications.effects.ts | 4 +- toju-app/src/app/domains/plugins/README.md | 6 +- .../services/plugin-desktop-state.service.ts | 10 +- .../plugin-requirement-state.service.ts | 117 +++++++++++ .../services/plugin-store.service.ts | 159 ++++++++++++++- .../domain/models/plugin-store.models.ts | 5 + .../plugin-page-host.component.html | 6 +- .../app/domains/server-directory/README.md | 4 + .../server-search.component.html | 100 ++++++++- .../server-search/server-search.component.ts | 189 ++++++++++++++++-- .../services/server-icon-image.service.ts | 146 ++++++++++++++ .../theme-style-application.logic.spec.ts | 5 +- .../floating-voice-controls.component.html | 12 +- .../floating-voice-controls.component.ts | 3 +- .../rooms-side-panel.component.html | 10 +- .../servers-rail/servers-rail.component.html | 10 +- .../servers-rail/servers-rail.component.ts | 40 +++- .../data-settings/data-settings.component.ts | 1 + .../local-api-settings.component.ts | 12 +- .../server-settings.component.html | 10 +- .../server-settings.component.ts | 34 ++-- .../shell/title-bar/title-bar.component.html | 135 +++++++++++++ .../shell/title-bar/title-bar.component.ts | 74 ++++++- .../app/infrastructure/persistence/README.md | 2 + .../signaling/signaling-message-handler.ts | 45 +++-- .../messages-incoming.handlers.spec.ts | 12 +- .../app/store/rooms/room-settings.effects.ts | 1 + .../store/rooms/room-state-sync.effects.ts | 153 ++++++++++---- .../rooms/rooms-helpers-snapshot.spec.ts | 27 +-- toju-app/src/app/store/rooms/rooms.helpers.ts | 25 ++- toju-app/src/app/store/rooms/rooms.reducer.ts | 24 ++- toju-app/src/app/store/users/users.effects.ts | 5 +- 82 files changed, 1708 insertions(+), 303 deletions(-) create mode 100644 electron/cqrs/current-user-scope.ts create mode 100644 electron/entities/RoomOwnerEntity.ts create mode 100644 electron/migrations/1000000000009-UserScopedRoomsAndMessages.ts create mode 100644 electron/migrations/1000000000010-UserScopedPluginData.ts create mode 100644 toju-app/src/app/domains/server-directory/infrastructure/services/server-icon-image.service.ts diff --git a/e2e/helpers/seed-test-endpoint.ts b/e2e/helpers/seed-test-endpoint.ts index 9713d6a..81332b6 100644 --- a/e2e/helpers/seed-test-endpoint.ts +++ b/e2e/helpers/seed-test-endpoint.ts @@ -69,6 +69,7 @@ function applySeededEndpointStorageState(storageState: SeededEndpointStorageStat 'toju-primary', 'toju-sweden' ])); + storage.setItem('metoyou_general_settings', generalSettings); if (currentUserId) { diff --git a/e2e/pages/login.page.ts b/e2e/pages/login.page.ts index 6d58d9c..992e753 100644 --- a/e2e/pages/login.page.ts +++ b/e2e/pages/login.page.ts @@ -10,7 +10,9 @@ export class LoginPage { readonly registerLink: Locator; constructor(private page: Page) { - this.form = page.locator('#login-username').locator('xpath=ancestor::div[contains(@class, "space-y-3")]').first(); + this.form = page.locator('#login-username').locator('xpath=ancestor::div[contains(@class, "space-y-3")]') + .first(); + this.usernameInput = page.locator('#login-username'); this.passwordInput = page.locator('#login-password'); this.serverSelect = page.locator('#login-server'); diff --git a/e2e/pages/server-search.page.ts b/e2e/pages/server-search.page.ts index 82fadb1..9c1777a 100644 --- a/e2e/pages/server-search.page.ts +++ b/e2e/pages/server-search.page.ts @@ -79,12 +79,19 @@ export class ServerSearchPage { await this.page.getByRole('button', { name }).click(); } - async joinServerFromSearch(name: string) { + async joinServerFromSearch(name: string, options: { acceptPluginDownloads?: boolean } = {}) { await this.searchInput.fill(name); const serverCard = this.page.locator('div[title]', { hasText: name }).first(); await expect(serverCard).toBeVisible({ timeout: 15_000 }); await serverCard.dblclick(); + + if (options.acceptPluginDownloads) { + const pluginConsentDialog = this.page.getByRole('dialog', { name: /uses plugins/ }); + + await expect(pluginConsentDialog).toBeVisible({ timeout: 20_000 }); + await pluginConsentDialog.getByRole('button', { name: 'Accept and join' }).click(); + } } } diff --git a/e2e/tests/auth/user-session-data-isolation.spec.ts b/e2e/tests/auth/user-session-data-isolation.spec.ts index e017969..1aa6424 100644 --- a/e2e/tests/auth/user-session-data-isolation.spec.ts +++ b/e2e/tests/auth/user-session-data-isolation.spec.ts @@ -25,10 +25,7 @@ interface PersistentClient { userDataDir: string; } -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']; test.describe('User session data isolation', () => { test.describe.configure({ timeout: 240_000 }); @@ -43,6 +40,7 @@ test.describe('User session data isolation', () => { }; const aliceServerName = `Alice Session Server ${suffix}`; const aliceMessage = `Alice persisted message ${suffix}`; + let client: PersistentClient | null = null; try { @@ -82,6 +80,7 @@ test.describe('User session data isolation', () => { const bobServerName = `Bob Private Server ${suffix}`; const aliceMessage = `Alice history ${suffix}`; const bobMessage = `Bob history ${suffix}`; + let client: PersistentClient | null = null; try { @@ -136,7 +135,7 @@ async function launchPersistentClient(userDataDir: string, testServerPort: numbe await installTestServerEndpoint(context, testServerPort); - const page = context.pages()[0] ?? await context.newPage(); + const page = context.pages()[0] ?? (await context.newPage()); return { context, @@ -202,6 +201,7 @@ async function createServerAndSendMessage(page: Page, serverName: string, messag await searchPage.createServer(serverName, { description: `User session isolation coverage for ${serverName}` }); + await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 }); await messagesPage.sendMessage(messageText); @@ -209,11 +209,15 @@ async function createServerAndSendMessage(page: Page, serverName: string, messag } async function expectSavedRoomAndHistory(page: Page, roomName: string, messageText: string): Promise { - const roomButton = getSavedRoomButton(page, roomName); + const railRoomButton = getRailSavedRoomButton(page, roomName); const messagesPage = new ChatMessagesPage(page); - await expect(roomButton).toBeVisible({ timeout: 20_000 }); - await roomButton.click(); + await expect(railRoomButton).toBeVisible({ timeout: 20_000 }); + await page.goto('/search', { waitUntil: 'domcontentloaded' }); + const searchRoomButton = getSearchSavedRoomButton(page, roomName); + + await expect(searchRoomButton).toBeVisible({ timeout: 20_000 }); + await searchRoomButton.click(); await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 }); await expect(messagesPage.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 }); } @@ -230,17 +234,29 @@ async function expectBlankSlate(page: Page, hiddenRoomNames: string[]): Promise< } async function expectSavedRoomVisible(page: Page, roomName: string): Promise { - await expect(getSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 }); + await expect(getRailSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 }); + await page.goto('/search', { waitUntil: 'domcontentloaded' }); + await expect(getSearchSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 }); } async function expectSavedRoomHidden(page: Page, roomName: string): Promise { - await expect(getSavedRoomButton(page, roomName)).toHaveCount(0); + await expect(getRailSavedRoomButton(page, roomName)).toHaveCount(0); + + if (!page.url().includes('/search')) { + await page.goto('/search', { waitUntil: 'domcontentloaded' }); + } + + await expect(getSearchSavedRoomButton(page, roomName)).toHaveCount(0); } -function getSavedRoomButton(page: Page, roomName: string) { +function getRailSavedRoomButton(page: Page, roomName: string) { return page.locator(`button[title="${roomName}"]`).first(); } +function getSearchSavedRoomButton(page: Page, roomName: string) { + return page.locator('app-server-search').getByRole('button', { name: roomName, exact: true }); +} + async function retryTransientNavigation(navigate: () => Promise, attempts = 4): Promise { let lastError: unknown; @@ -259,11 +275,10 @@ async function retryTransientNavigation(navigate: () => Promise, attempts } } - throw lastError instanceof Error - ? lastError - : new Error(`Navigation failed after ${attempts} attempts`); + throw lastError instanceof Error ? lastError : new Error(`Navigation failed after ${attempts} attempts`); } function uniqueName(prefix: string): string { - return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; -} \ No newline at end of file + return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36) + .slice(2, 8)}`; +} diff --git a/e2e/tests/chat/server-icon-sync.spec.ts b/e2e/tests/chat/server-icon-sync.spec.ts index d01376a..752c010 100644 --- a/e2e/tests/chat/server-icon-sync.spec.ts +++ b/e2e/tests/chat/server-icon-sync.spec.ts @@ -1,7 +1,13 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { chromium, type BrowserContext, type Locator, type Page, type Route } from '@playwright/test'; +import { + chromium, + type BrowserContext, + type Locator, + type Page, + type Route +} from '@playwright/test'; import { test, expect } from '../../fixtures/multi-client'; import { installTestServerEndpoint } from '../../helpers/seed-test-endpoint'; import { installWebRTCTracking } from '../../helpers/webrtc-helpers'; @@ -31,7 +37,11 @@ interface PersistentClient { } const STATIC_GIF_BASE64 = 'R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw=='; -const GIF_FRAME_MARKER = Buffer.from([0x21, 0xf9, 0x04]); +const GIF_FRAME_MARKER = Buffer.from([ + 0x21, + 0xf9, + 0x04 +]); const CLIENT_LAUNCH_ARGS = ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream']; const SERVER_ICON_SYNC_TIMEOUT_MS = 45_000; @@ -77,6 +87,7 @@ test.describe('Server icon sync', () => { await new ServerSearchPage(alice.page).createServer(serverName, { description: 'Server icon synchronization E2E coverage' }); + await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 }); await joinServerFromSearch(bob.page, serverName); @@ -263,15 +274,15 @@ async function openServerSettings(page: Page, serverName: string): Promise async function openSettingsModalThroughAngularDevMode(page: Page): Promise { await page.evaluate(() => { - type SettingsModalComponentHandle = { + interface SettingsModalComponentHandle { modal?: { open: (page: string) => void; }; - }; - type AngularDebugApi = { + } + interface AngularDebugApi { getComponent: (element: Element) => SettingsModalComponentHandle; applyChanges?: (component: SettingsModalComponentHandle) => void; - }; + } const host = document.querySelector('app-settings-modal'); const debugApi = (window as Window & { ng?: AngularDebugApi }).ng; @@ -373,33 +384,33 @@ async function retryTransientNavigation(navigate: () => Promise, attempts async function expectServerSettingsIcon(page: Page, serverName: string, expectedDataUrl: string): Promise { const settingsPanel = page.locator('app-server-settings'); - const image = settingsPanel.locator(`img[alt="${serverName} icon"]`).first(); + const image = settingsPanel.locator('[style*="background-image"]').first(); - await expectImageLoadedWithSrc(image, expectedDataUrl, 'settings server icon'); + await expectBackgroundImageLoadedWithUrl(image, expectedDataUrl, 'settings server icon'); } async function expectRoomHeaderIcon(page: Page, serverName: string, expectedDataUrl: string): Promise { const channelsPanel = page.locator('app-rooms-side-panel').first(); - const image = channelsPanel.locator(`img[alt="${serverName} icon"]`).first(); + const image = channelsPanel.locator('[style*="background-image"]').first(); - await expectImageLoadedWithSrc(image, expectedDataUrl, 'room header server icon'); + await expectBackgroundImageLoadedWithUrl(image, expectedDataUrl, 'room header server icon'); } async function expectRailIcon(page: Page, serverName: string, expectedDataUrl: string): Promise { - const image = page.locator(`app-servers-rail img[alt="${serverName} icon"]`).first(); + const image = page.locator(`app-servers-rail button[title="${serverName}"] [style*="background-image"]`).first(); - await expectImageLoadedWithSrc(image, expectedDataUrl, 'servers rail icon'); + await expectBackgroundImageLoadedWithUrl(image, expectedDataUrl, 'servers rail icon'); } async function expectSearchResultIcon(page: Page, serverName: string, expectedDataUrl: string): Promise { const serverCard = page.locator('app-server-search div[title]', { hasText: serverName }).first(); - const image = serverCard.locator(`img[alt="${serverName} icon"]`).first(); + const image = serverCard.locator('[style*="background-image"]').first(); await expect(serverCard).toBeVisible({ timeout: 20_000 }); - await expectImageLoadedWithSrc(image, expectedDataUrl, 'search result server icon'); + await expectBackgroundImageLoadedWithUrl(image, expectedDataUrl, 'search result server icon'); } -async function expectImageLoadedWithSrc(image: Locator, expectedDataUrl: string, label: string): Promise { +async function expectBackgroundImageLoadedWithUrl(image: Locator, expectedDataUrl: string, label: string): Promise { await expect .poll( async () => { @@ -407,14 +418,14 @@ async function expectImageLoadedWithSrc(image: Locator, expectedDataUrl: string, return null; } - return image.getAttribute('src'); + return image.evaluate((element) => getComputedStyle(element).backgroundImage); }, { timeout: SERVER_ICON_SYNC_TIMEOUT_MS, - message: `${label} src should update` + message: `${label} background should update` } ) - .toBe(expectedDataUrl); + .toContain(expectedDataUrl); await expect .poll( @@ -423,11 +434,23 @@ async function expectImageLoadedWithSrc(image: Locator, expectedDataUrl: string, return false; } - return image.evaluate((element) => { - const img = element as HTMLImageElement; + return image.evaluate( + (element) => + new Promise((resolve) => { + const backgroundImage = getComputedStyle(element).backgroundImage; + const match = /^url\("?(.*?)"?\)$/.exec(backgroundImage); + const img = new Image(); - return img.complete && img.naturalWidth > 0 && img.naturalHeight > 0; - }); + if (!match?.[1]) { + resolve(false); + return; + } + + img.onload = () => resolve(img.naturalWidth > 0 && img.naturalHeight > 0); + img.onerror = () => resolve(false); + img.src = match[1]; + }) + ); }, { timeout: SERVER_ICON_SYNC_TIMEOUT_MS, @@ -448,8 +471,22 @@ function buildGifUpload(label: string): ImageUploadPayload { const header = baseGif.subarray(0, frameStart); const frame = baseGif.subarray(frameStart, baseGif.length - 1); const commentData = Buffer.from(label, 'ascii'); - const commentExtension = Buffer.concat([Buffer.from([0x21, 0xfe, commentData.length]), commentData, Buffer.from([0x00])]); - const buffer = Buffer.concat([header, commentExtension, frame, Buffer.from([0x3b])]); + const commentExtension = Buffer.concat([ + Buffer.from([ + 0x21, + 0xfe, + commentData.length + ]), + commentData, + Buffer.from([0x00]) + ]); + const buffer = Buffer.concat([ + header, + commentExtension, + frame, + frame, + Buffer.from([0x3b]) + ]); const base64 = buffer.toString('base64'); return { @@ -461,5 +498,6 @@ function buildGifUpload(label: string): ImageUploadPayload { } function uniqueName(prefix: string): string { - return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + return `${prefix}-${Date.now()}-${Math.random().toString(36) + .slice(2, 8)}`; } diff --git a/e2e/tests/plugins/plugin-api-two-users.spec.ts b/e2e/tests/plugins/plugin-api-two-users.spec.ts index ee26bc7..6a19c96 100644 --- a/e2e/tests/plugins/plugin-api-two-users.spec.ts +++ b/e2e/tests/plugins/plugin-api-two-users.spec.ts @@ -28,9 +28,7 @@ test.describe('Plugin API multi-user runtime', () => { 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 test.step('Alice has the server plugin active', async () => { 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'); @@ -101,10 +99,13 @@ async function createPluginApiScenario(createClient: () => Promise): Pro const aliceRoom = new ChatRoomPage(alice.page); await aliceRoom.ensureVoiceChannelExists(VOICE_CHANNEL); + await installGrantAndActivatePlugin(alice.page, true); + await closeSettingsModal(alice.page); + await expect(soundboardComposerButton(alice.page)).toBeVisible({ timeout: 20_000 }); const bobSearch = new ServerSearchPage(bob.page); - await bobSearch.joinServerFromSearch(serverName); + await bobSearch.joinServerFromSearch(serverName, { acceptPluginDownloads: true }); await expect(bob.page).toHaveURL(/\/room\//, { timeout: 30_000 }); const bobRoom = new ChatRoomPage(bob.page); diff --git a/electron/README.md b/electron/README.md index e292d47..30834ab 100644 --- a/electron/README.md +++ b/electron/README.md @@ -28,6 +28,6 @@ Electron main-process package for MetoYou / Toju. This directory owns desktop bo ## Notes - 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. +- Plugin client data is stored in the local Electron SQLite database in the dedicated user-scoped `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. - See [AGENTS.md](AGENTS.md) for package-level editing rules. diff --git a/electron/api/auth-store.ts b/electron/api/auth-store.ts index 36d6878..792ed47 100644 --- a/electron/api/auth-store.ts +++ b/electron/api/auth-store.ts @@ -11,7 +11,6 @@ export interface IssuedToken { } const TOKEN_TTL_MS = 24 * 60 * 60 * 1000; - const tokens = new Map(); export function issueToken(params: { diff --git a/electron/api/docs-html.ts b/electron/api/docs-html.ts index 6b7bf0d..405aa96 100644 --- a/electron/api/docs-html.ts +++ b/electron/api/docs-html.ts @@ -59,6 +59,17 @@ export function getDocsHtml(specUrl: string): string { disabled: true } }; + const contentSecurityPolicy = [ + "default-src 'none'", + "script-src 'self' 'nonce-metoyou-local-api-docs'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "font-src 'self' data:", + "connect-src 'self'", + "base-uri 'none'", + "form-action 'none'", + "frame-ancestors 'none'" + ].join('; '); return ` @@ -67,7 +78,7 @@ export function getDocsHtml(specUrl: string): string { MetoYou Local API