diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-youtube-embed/chat-youtube-embed.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-youtube-embed/chat-youtube-embed.component.ts index a5b71f3..bdc0974 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-youtube-embed/chat-youtube-embed.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-youtube-embed/chat-youtube-embed.component.ts @@ -5,9 +5,10 @@ import { input } from '@angular/core'; import { DomSanitizer } from '@angular/platform-browser'; +import { environment } from '../../../../../../../../environments/environment'; import { extractYoutubeVideoId } from '../../../../../domain/rules/link-embed.rules'; -const YOUTUBE_EMBED_FALLBACK_ORIGIN = 'https://toju.app'; +const YOUTUBE_EMBED_FALLBACK_ORIGIN = environment.publicOrigin; function resolveYoutubeClientOrigin(): string { if (typeof window === 'undefined') { diff --git a/toju-app/src/app/domains/experimental-media/infrastructure/services/experimental-vlc-runtime.service.ts b/toju-app/src/app/domains/experimental-media/infrastructure/services/experimental-vlc-runtime.service.ts index 542dd06..261d2eb 100644 --- a/toju-app/src/app/domains/experimental-media/infrastructure/services/experimental-vlc-runtime.service.ts +++ b/toju-app/src/app/domains/experimental-media/infrastructure/services/experimental-vlc-runtime.service.ts @@ -1,5 +1,6 @@ import { DOCUMENT } from '@angular/common'; import { Injectable, inject } from '@angular/core'; +import { environment } from '../../../../../environments/environment'; export interface ExperimentalVlcPlayerOptions { container: HTMLElement; @@ -23,7 +24,7 @@ declare global { } } -const VLC_RUNTIME_SCRIPT_URL = '/vlcjs/metoyou-vlc-player.js'; +const VLC_RUNTIME_SCRIPT_URL = environment.experimentalMedia.vlcRuntimeScriptUrl; @Injectable({ providedIn: 'root' }) export class ExperimentalVlcRuntimeService { @@ -60,6 +61,7 @@ export class ExperimentalVlcRuntimeService { script.src = VLC_RUNTIME_SCRIPT_URL; script.async = true; + script.onload = () => { const runtime = this.document.defaultView?.MetoYouVlcJs; @@ -70,6 +72,7 @@ export class ExperimentalVlcRuntimeService { resolve(runtime); }; + script.onerror = () => reject(new Error(`The experimental VLC.js runtime was not found at ${VLC_RUNTIME_SCRIPT_URL}.`)); this.document.head.appendChild(script); diff --git a/toju-app/src/app/domains/plugins/README.md b/toju-app/src/app/domains/plugins/README.md index 8e46725..c65ef0d 100644 --- a/toju-app/src/app/domains/plugins/README.md +++ b/toju-app/src/app/domains/plugins/README.md @@ -10,7 +10,7 @@ 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`, `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. When a different user joins that server, required plugins block the join until the user accepts the download; optional and recommended plugins are offered as selectable downloads and can be skipped. Once a server has local server-scoped plugins installed, the title bar shows a compact Server plugins button for that server. 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. New users and legacy source lists are seeded with the official Toju plugin repository at `https://raw.githubusercontent.com/Myxelium/official-toju-plugin-repository/refs/heads/master/plugin-source.json`, while source removal remains persisted after migration. 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. When a different user joins that server, required plugins block the join until the user accepts the download; optional and recommended plugins are offered as selectable downloads and can be skipped. Once a server has local server-scoped plugins installed, the title bar shows a compact Server plugins button for that server. 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; the renderer CSP allows HTTP(S), `file://` via local cache, and blob-backed plugin entrypoints. 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. 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 deae055..d3f7f7a 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 @@ -1,4 +1,5 @@ import { Injector } from '@angular/core'; +import { environment } from '../../../../../environments/environment'; import type { TojuPluginManifest } from '../../../../shared-kernel'; import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service'; import { PluginStoreService } from './plugin-store.service'; @@ -6,8 +7,11 @@ import { PluginHostService } from './plugin-host.service'; import { PluginDesktopStateService } from './plugin-desktop-state.service'; import { PluginRequirementService } from './plugin-requirement.service'; import { PluginRegistryService } from './plugin-registry.service'; +import { PluginCapabilityService } from './plugin-capability.service'; import type { PluginStoreEntry } from '../../domain/models/plugin-store.models'; +const OFFICIAL_PLUGIN_SOURCE_URL = environment.pluginStore.defaultSourceUrls[0]; + describe('PluginStoreService', () => { let fetchMock: ReturnType; let registerLocalManifest: ReturnType; @@ -36,42 +40,79 @@ describe('PluginStoreService', () => { }); it('loads plugin entries from source manifests and resolves relative links', async () => { - fetchMock.mockResolvedValueOnce(jsonResponse({ - plugins: [ - { - author: 'Ada Example', - description: 'Adds better channel tools.', - github: 'https://github.com/example/better-channels', - id: 'example.better-channels', - image: './images/better.png', - install: './better/toju-plugin.json', - readme: './better/README.md', - title: 'Better Channels', - version: '1.2.0' - } - ], - title: 'Example Plugins' - })); + mockFetchResponses(fetchMock, { + 'https://plugins.example.test/index.json': jsonResponse({ + plugins: [ + { + author: 'Ada Example', + description: 'Adds better channel tools.', + github: 'https://github.com/example/better-channels', + id: 'example.better-channels', + image: './images/better.png', + install: './better/toju-plugin.json', + readme: './better/README.md', + title: 'Better Channels', + version: '1.2.0' + } + ], + title: 'Example Plugins' + }) + }); const service = createService(registerLocalManifest, unregister); await service.addSourceUrl('https://plugins.example.test/index.json#latest'); - expect(service.sourceUrls()).toEqual(['https://plugins.example.test/index.json']); - expect(service.sources()[0]?.title).toBe('Example Plugins'); - expect(service.availablePlugins()).toEqual([ + expect(service.sourceUrls()).toEqual([OFFICIAL_PLUGIN_SOURCE_URL, 'https://plugins.example.test/index.json']); + + expect(service.sources().some((source) => source.title === 'Example Plugins')).toBe(true); + expect(service.availablePlugins()).toContainEqual(expect.objectContaining({ + author: 'Ada Example', + githubUrl: 'https://github.com/example/better-channels', + id: 'example.better-channels', + imageUrl: 'https://plugins.example.test/images/better.png', + installUrl: 'https://plugins.example.test/better/toju-plugin.json', + readmeUrl: 'https://plugins.example.test/better/README.md', + sourceTitle: 'Example Plugins', + title: 'Better Channels', + version: '1.2.0' + })); + }); + + it('seeds the official plugin repository for new users', () => { + const service = createService(registerLocalManifest, unregister); + + expect(service.sourceUrls()).toEqual([OFFICIAL_PLUGIN_SOURCE_URL]); + expect(fetchMock).toHaveBeenCalledWith( + OFFICIAL_PLUGIN_SOURCE_URL, expect.objectContaining({ - author: 'Ada Example', - githubUrl: 'https://github.com/example/better-channels', - id: 'example.better-channels', - imageUrl: 'https://plugins.example.test/images/better.png', - installUrl: 'https://plugins.example.test/better/toju-plugin.json', - readmeUrl: 'https://plugins.example.test/better/README.md', - sourceTitle: 'Example Plugins', - title: 'Better Channels', - version: '1.2.0' + headers: { Accept: 'application/json' } }) - ]); + ); + }); + + it('adds the official plugin repository when loading legacy source lists', () => { + storage.setItem('metoyou_plugin_store', JSON.stringify({ + installedPlugins: [], + schemaVersion: 1, + sourceUrls: ['https://plugins.example.test/index.json'] + })); + + const service = createService(registerLocalManifest, unregister); + + expect(service.sourceUrls()).toEqual([OFFICIAL_PLUGIN_SOURCE_URL, 'https://plugins.example.test/index.json']); + }); + + it('keeps user-removed default sources removed after schema migration', () => { + storage.setItem('metoyou_plugin_store', JSON.stringify({ + installedPlugins: [], + schemaVersion: 2, + sourceUrls: ['https://plugins.example.test/index.json'] + })); + + const service = createService(registerLocalManifest, unregister); + + expect(service.sourceUrls()).toEqual(['https://plugins.example.test/index.json']); }); it('accepts local source manifest paths and resolves relative file links', async () => { @@ -94,9 +135,10 @@ describe('PluginStoreService', () => { await service.addSourceUrl('/home/ludde/Desktop/TestPlugin/plugin-source.json'); - expect(fetchMock).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalledWith('/home/ludde/Desktop/TestPlugin/plugin-source.json', expect.anything()); expect(readFile).toHaveBeenCalledWith('/home/ludde/Desktop/TestPlugin/plugin-source.json'); - expect(service.sourceUrls()).toEqual(['file:///home/ludde/Desktop/TestPlugin/plugin-source.json']); + expect(service.sourceUrls()).toEqual([OFFICIAL_PLUGIN_SOURCE_URL, 'file:///home/ludde/Desktop/TestPlugin/plugin-source.json']); + expect(service.availablePlugins()).toEqual([ expect.objectContaining({ id: 'example.local-plugin', @@ -112,7 +154,9 @@ describe('PluginStoreService', () => { const manifest = createManifest({ version: '1.0.0' }); const plugin = createStoreEntry({ version: '1.0.0' }); - fetchMock.mockResolvedValueOnce(jsonResponse(manifest)); + mockFetchResponses(fetchMock, { + [plugin.installUrl ?? '']: jsonResponse(manifest) + }); const service = createService(registerLocalManifest, unregister); @@ -141,9 +185,10 @@ describe('PluginStoreService', () => { writeFile: vi.fn(async () => true) }; - fetchMock - .mockResolvedValueOnce(jsonResponse(manifest)) - .mockResolvedValueOnce(textResponse('export function activate() {}')); + mockFetchResponses(fetchMock, { + [plugin.bundleUrl ?? '']: textResponse('export function activate() {}'), + [plugin.installUrl ?? '']: jsonResponse(manifest) + }); const service = createService(registerLocalManifest, unregister, electronApi); @@ -171,7 +216,9 @@ describe('PluginStoreService', () => { it('loads plugin readmes as markdown text', async () => { const plugin = createStoreEntry({ readmeUrl: 'https://plugins.example.test/better/README.md' }); - fetchMock.mockResolvedValueOnce(textResponse('# Better Channels')); + mockFetchResponses(fetchMock, { + [plugin.readmeUrl ?? '']: textResponse('# Better Channels') + }); const service = createService(registerLocalManifest, unregister); const readme = await service.loadReadme(plugin); @@ -185,6 +232,21 @@ describe('PluginStoreService', () => { }); }); +function mockFetchResponses(fetchMock: ReturnType, responses: Record): void { + fetchMock.mockImplementation(async (url: RequestInfo | URL) => { + const requestUrl = typeof url === 'string' + ? url + : url instanceof URL + ? url.toString() + : url.url; + + return responses[requestUrl] + ?? (requestUrl === OFFICIAL_PLUGIN_SOURCE_URL + ? jsonResponse({ plugins: [], title: 'Official Toju Plugins' }) + : textResponse('')); + }); +} + function createService( registerLocalManifest: ReturnType, unregister: ReturnType, @@ -220,6 +282,12 @@ function createService( writeJson: vi.fn(async () => undefined) } }, + { + provide: PluginCapabilityService, + useValue: { + grantAll: vi.fn() + } + }, { provide: PluginRegistryService, useValue: { unregister } 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 674f604..1ce26be 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 @@ -10,6 +10,7 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Store } from '@ngrx/store'; import { firstValueFrom } from 'rxjs'; +import { environment } from '../../../../../environments/environment'; import { RealtimeSessionFacade } from '../../../../core/realtime'; import { getUserScopedStorageKey } from '../../../../core/storage/current-user-storage'; import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service'; @@ -44,14 +45,11 @@ import { PluginDesktopStateService } from './plugin-desktop-state.service'; import { PluginRequirementService } from './plugin-requirement.service'; import { PluginRegistryService } from './plugin-registry.service'; -const STORE_SCHEMA_VERSION = 1; +const STORE_SCHEMA_VERSION = 2; const STORAGE_KEY_PLUGIN_STORE = 'metoyou_plugin_store'; const STORAGE_KEY_SERVER_PLUGIN_INSTALLS = 'metoyou_server_plugin_installs'; const PLUGIN_CACHE_DIR = 'plugin-bundles'; -const DEFAULT_STORE_STATE: PersistedPluginStoreState = { - installedPlugins: [], - sourceUrls: [] -}; +const DEFAULT_PLUGIN_SOURCE_URLS = [...environment.pluginStore.defaultSourceUrls]; export interface PluginStoreInstallOptions { activate?: boolean; @@ -953,12 +951,12 @@ export class PluginStoreService { const raw = localStorage.getItem(getUserScopedStorageKey(STORAGE_KEY_PLUGIN_STORE)); if (!raw) { - return { ...DEFAULT_STORE_STATE }; + return createDefaultStoreState(); } return normalizePersistedState(JSON.parse(raw) as unknown); } catch { - return { ...DEFAULT_STORE_STATE }; + return createDefaultStoreState(); } } @@ -1132,22 +1130,63 @@ function readPluginInstallScope(record: Record): TojuPluginInst function normalizePersistedState(value: unknown): PersistedPluginStoreState { if (!isRecord(value)) { - return { ...DEFAULT_STORE_STATE }; + return createDefaultStoreState(); } + const schemaVersion = typeof value['schemaVersion'] === 'number' ? value['schemaVersion'] : 0; + const sourceUrls = Array.isArray(value['sourceUrls']) + ? normalizePluginSourceUrls(value['sourceUrls']) + : []; + return { installedPlugins: Array.isArray(value['installedPlugins']) ? value['installedPlugins'].filter(isInstalledStorePlugin) : [], - sourceUrls: Array.isArray(value['sourceUrls']) - ? value['sourceUrls'] - .filter((entry): entry is string => typeof entry === 'string') - .map((entry) => normalizeOptionalSourceUrl(entry)) - .filter((entry): entry is string => !!entry) - : [] + schemaVersion: STORE_SCHEMA_VERSION, + sourceUrls: schemaVersion < STORE_SCHEMA_VERSION + ? mergePluginSourceUrls(DEFAULT_PLUGIN_SOURCE_URLS, sourceUrls) + : sourceUrls }; } +function createDefaultStoreState(): PersistedPluginStoreState { + return { + installedPlugins: [], + schemaVersion: STORE_SCHEMA_VERSION, + sourceUrls: [...DEFAULT_PLUGIN_SOURCE_URLS] + }; +} + +function normalizePluginSourceUrls(sourceUrls: unknown[]): string[] { + const normalizedSourceUrls: string[] = []; + + for (const entry of sourceUrls) { + if (typeof entry !== 'string') { + continue; + } + + const sourceUrl = normalizeOptionalSourceUrl(entry); + + if (sourceUrl && !normalizedSourceUrls.includes(sourceUrl)) { + normalizedSourceUrls.push(sourceUrl); + } + } + + return normalizedSourceUrls; +} + +function mergePluginSourceUrls(defaultSourceUrls: string[], sourceUrls: string[]): string[] { + const mergedSourceUrls: string[] = []; + + for (const sourceUrl of defaultSourceUrls.concat(sourceUrls)) { + if (!mergedSourceUrls.includes(sourceUrl)) { + mergedSourceUrls.push(sourceUrl); + } + } + + return mergedSourceUrls; +} + function normalizePersistedServerPluginInstallState(value: unknown): { servers: Record } { if (!isRecord(value) || !isRecord(value['servers'])) { return { servers: {} }; 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 3edadb1..f9b7cf8 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 @@ -48,6 +48,7 @@ export interface PluginStoreReadme { export interface PersistedPluginStoreState { installedPlugins: InstalledStorePlugin[]; + schemaVersion?: number; sourceUrls: string[]; } diff --git a/toju-app/src/app/infrastructure/realtime/ice-server-settings.service.ts b/toju-app/src/app/infrastructure/realtime/ice-server-settings.service.ts index f7bbd53..4dca1f6 100644 --- a/toju-app/src/app/infrastructure/realtime/ice-server-settings.service.ts +++ b/toju-app/src/app/infrastructure/realtime/ice-server-settings.service.ts @@ -4,8 +4,8 @@ import { computed, type Signal } from '@angular/core'; +import { environment } from '../../../environments/environment'; import { STORAGE_KEY_ICE_SERVERS } from '../../core/constants'; -import { ICE_SERVERS } from './realtime.constants'; export interface IceServerEntry { id: string; @@ -15,7 +15,7 @@ export interface IceServerEntry { credential?: string; } -const DEFAULT_ENTRIES: IceServerEntry[] = ICE_SERVERS.map((server, index) => ({ +const DEFAULT_ENTRIES: IceServerEntry[] = environment.realtime.defaultIceServers.map((server, index) => ({ id: `default-stun-${index}`, type: 'stun' as const, urls: Array.isArray(server.urls) ? server.urls[0] : server.urls diff --git a/toju-app/src/app/infrastructure/realtime/realtime.constants.ts b/toju-app/src/app/infrastructure/realtime/realtime.constants.ts index 56c0a1c..672b1cc 100644 --- a/toju-app/src/app/infrastructure/realtime/realtime.constants.ts +++ b/toju-app/src/app/infrastructure/realtime/realtime.constants.ts @@ -5,14 +5,6 @@ import type { LatencyProfile } from '../../shared-kernel'; * Centralised here so nothing is hard-coded inline. */ -export const ICE_SERVERS: RTCIceServer[] = [ - { urls: 'stun:stun.l.google.com:19302' }, - { urls: 'stun:stun1.l.google.com:19302' }, - { urls: 'stun:stun2.l.google.com:19302' }, - { urls: 'stun:stun3.l.google.com:19302' }, - { urls: 'stun:stun4.l.google.com:19302' } -]; - /** Base delay (ms) for exponential backoff on signaling reconnect */ export const SIGNALING_RECONNECT_BASE_DELAY_MS = 1_000; /** Maximum delay (ms) between signaling reconnect attempts */ diff --git a/toju-app/src/environments/environment.prod.ts b/toju-app/src/environments/environment.prod.ts index 5e0ffcb..65a9eb8 100644 --- a/toju-app/src/environments/environment.prod.ts +++ b/toju-app/src/environments/environment.prod.ts @@ -1,5 +1,6 @@ export const environment = { production: true, + publicOrigin: 'https://toju.app', defaultServers: [ { key: 'toju-primary', @@ -12,5 +13,20 @@ export const environment = { url: 'https://signal-sweden.toju.app' } ], - defaultServerUrl: 'https://signal.toju.app' + defaultServerUrl: 'https://signal.toju.app', + experimentalMedia: { + vlcRuntimeScriptUrl: '/vlcjs/metoyou-vlc-player.js' + }, + pluginStore: { + defaultSourceUrls: ['https://raw.githubusercontent.com/Myxelium/official-toju-plugin-repository/refs/heads/master/plugin-source.json'] + }, + realtime: { + defaultIceServers: [ + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun1.l.google.com:19302' }, + { urls: 'stun:stun2.l.google.com:19302' }, + { urls: 'stun:stun3.l.google.com:19302' }, + { urls: 'stun:stun4.l.google.com:19302' } + ] + } }; diff --git a/toju-app/src/environments/environment.ts b/toju-app/src/environments/environment.ts index 7093d8a..b85f60e 100644 --- a/toju-app/src/environments/environment.ts +++ b/toju-app/src/environments/environment.ts @@ -1,5 +1,6 @@ export const environment = { production: false, + publicOrigin: 'https://toju.app', defaultServers: [ { key: 'default', @@ -17,5 +18,20 @@ export const environment = { url: 'https://signal-sweden.toju.app' } ], - defaultServerUrl: 'https://46.59.68.77:3001' + defaultServerUrl: 'https://46.59.68.77:3001', + experimentalMedia: { + vlcRuntimeScriptUrl: '/vlcjs/metoyou-vlc-player.js' + }, + pluginStore: { + defaultSourceUrls: ['https://raw.githubusercontent.com/Myxelium/official-toju-plugin-repository/refs/heads/master/plugin-source.json'] + }, + realtime: { + defaultIceServers: [ + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun1.l.google.com:19302' }, + { urls: 'stun:stun2.l.google.com:19302' }, + { urls: 'stun:stun3.l.google.com:19302' }, + { urls: 'stun:stun4.l.google.com:19302' } + ] + } };