refactor: Remove hardcoded values
All checks were successful
Queue Release Build / prepare (push) Successful in 2m28s
Deploy Web Apps / deploy (push) Successful in 7m58s
Queue Release Build / build-linux (push) Successful in 46m59s
Queue Release Build / build-windows (push) Successful in 26m2s
Queue Release Build / finalize (push) Successful in 23s

This commit is contained in:
2026-05-17 18:18:14 +02:00
parent a173299ad3
commit ecb1a4b3a0
10 changed files with 201 additions and 65 deletions

View File

@@ -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') {

View File

@@ -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);

View File

@@ -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/<plugin-id>/<version>/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.

View File

@@ -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<typeof vi.fn>;
let registerLocalManifest: ReturnType<typeof vi.fn>;
@@ -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<typeof vi.fn>, responses: Record<string, Response>): 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<typeof vi.fn>,
unregister: ReturnType<typeof vi.fn>,
@@ -220,6 +282,12 @@ function createService(
writeJson: vi.fn(async () => undefined)
}
},
{
provide: PluginCapabilityService,
useValue: {
grantAll: vi.fn()
}
},
{
provide: PluginRegistryService,
useValue: { unregister }

View File

@@ -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<string, unknown>): 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<string, InstalledStorePlugin[]> } {
if (!isRecord(value) || !isRecord(value['servers'])) {
return { servers: {} };

View File

@@ -48,6 +48,7 @@ export interface PluginStoreReadme {
export interface PersistedPluginStoreState {
installedPlugins: InstalledStorePlugin[];
schemaVersion?: number;
sourceUrls: string[];
}