From fa2cca6fa45be54ada1ee5a0205f8c208f36f688 Mon Sep 17 00:00:00 2001 From: Myx Date: Wed, 29 Apr 2026 20:33:54 +0200 Subject: [PATCH] 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