feat: plugins v1

This commit is contained in:
2026-04-29 01:14:14 +02:00
parent ec3802ade6
commit 6920f93b41
86 changed files with 9036 additions and 14 deletions

View File

@@ -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"
}
],

View File

@@ -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.

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="E2E plugin icon">
<rect width="64" height="64" rx="12" fill="#111827" />
<path d="M18 22h28v20H18z" fill="#38bdf8" />
<path d="M24 16h16v6H24zM24 42h16v6H24z" fill="#a7f3d0" />
<path d="M25 30h14v4H25z" fill="#111827" />
</svg>

After

Width:  |  Height:  |  Size: 319 B

View File

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

View File

@@ -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"
}
}

View File

@@ -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"
}
]
}

View File

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

View File

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

View File

@@ -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<ImportUserDataResult>;
eraseUserData: () => Promise<EraseUserDataResult>;
getSavedThemesPath: () => Promise<string>;
getLocalPluginsPath: () => Promise<string>;
listLocalPluginManifests: () => Promise<LocalPluginDiscoveryResult>;
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
readSavedTheme: (fileName: string) => Promise<string>;
writeSavedTheme: (fileName: string, text: string) => Promise<boolean>;

View File

@@ -2,6 +2,7 @@ import { Injectable, signal } from '@angular/core';
export type SettingsPage =
| 'general'
| 'plugins'
| 'theme'
| 'network'
| 'notifications'

View File

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

View File

@@ -141,6 +141,20 @@
(drop)="onDrop($event)"
>
<div class="absolute bottom-3 right-3 z-10 flex items-center gap-2 m-0.5">
@for (record of pluginComposerActions(); track record.id) {
<button
type="button"
(click)="runPluginComposerAction(record.contribution.run)"
class="inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-2xl border border-border/70 bg-secondary/55 px-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground shadow-sm backdrop-blur-md transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/35 hover:bg-secondary/90 hover:text-foreground"
[class.opacity-100]="inputHovered()"
[class.opacity-70]="!inputHovered()"
[attr.aria-label]="record.contribution.label"
[title]="record.contribution.label"
>
<span>{{ record.contribution.icon ?? record.contribution.label }}</span>
</button>
}
@if (klipyEnabled()) {
<button
#klipyTrigger

View File

@@ -23,6 +23,7 @@ import type { ClipboardFilePayload } from '../../../../../../core/platform/elect
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
import { KlipyGif, KlipyService } from '../../../../application/services/klipy.service';
import { Message } from '../../../../../../shared-kernel';
import { PluginUiRegistryService } from '../../../../../plugins';
import { ThemeNodeDirective } from '../../../../../theme';
import type { RoomSignalSourceInput } from '../../../../../server-directory';
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive';
@@ -82,8 +83,10 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
private readonly klipy = inject(KlipyService);
private readonly markdown = inject(ChatMarkdownService);
private readonly electronBridge = inject(ElectronBridgeService);
private readonly pluginUi = inject(PluginUiRegistryService);
readonly pendingKlipyGif = signal<KlipyGif | null>(null);
readonly pluginComposerActions = this.pluginUi.composerActionRecords;
readonly toolbarVisible = signal(false);
readonly dragActive = signal(false);
readonly inputHovered = signal(false);
@@ -219,6 +222,11 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
this.klipyGifPickerToggleRequested.emit();
}
runPluginComposerAction(action: () => Promise<void> | void): void {
void Promise.resolve()
.then(() => action());
}
getKlipyTriggerRect(): DOMRect | null {
return this.klipyTrigger?.nativeElement.getBoundingClientRect() ?? null;
}

View File

@@ -115,6 +115,20 @@
}
}
@if (pluginEmbeds().length > 0) {
<div class="mt-2 space-y-2" data-testid="plugin-message-embeds">
@for (embed of pluginEmbeds(); track embed.id) {
<article class="rounded-md border border-border bg-secondary/30 p-3">
<div class="mb-2 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span>{{ embed.contribution.embedType }}</span>
<span>{{ embed.pluginId }}</span>
</div>
<app-plugin-render-host [render]="embed.render" />
</article>
}
</div>
}
@if (attachmentsList.length > 0) {
<div class="mt-2 space-y-2">
@for (att of attachmentsList; track att.id) {

View File

@@ -38,6 +38,8 @@ import {
User
} from '../../../../../../shared-kernel';
import { ThemeNodeDirective } from '../../../../../theme';
import { PluginRenderHostComponent } from '../../../../../plugins/feature/plugin-render-host/plugin-render-host.component';
import { PluginUiRegistryService } from '../../../../../plugins';
import {
ChatAudioPlayerComponent,
@@ -98,6 +100,7 @@ interface ChatMessageAttachmentViewModel extends Attachment {
ChatMessageMarkdownComponent,
ChatLinkEmbedComponent,
UserAvatarComponent,
PluginRenderHostComponent,
ThemeNodeDirective
],
viewProviders: [
@@ -124,6 +127,7 @@ export class ChatMessageItemComponent {
private readonly attachmentsSvc = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService);
private readonly pluginUi = inject(PluginUiRegistryService);
private readonly profileCard = inject(ProfileCardService);
private readonly attachmentVersion = signal(this.attachmentsSvc.updated());
@@ -146,6 +150,7 @@ export class ChatMessageItemComponent {
readonly commonEmojis = COMMON_EMOJIS;
readonly deletedMessageContent = DELETED_MESSAGE_CONTENT;
readonly pluginEmbeds = computed(() => this.findPluginEmbeds(this.message().content));
readonly isEditing = signal(false);
readonly showEmojiPicker = signal(false);
readonly senderUser = computed<User>(() => {
@@ -191,6 +196,28 @@ export class ChatMessageItemComponent {
});
});
private findPluginEmbeds(content: string) {
const match = /^toju:embed:([a-zA-Z0-9._:-]+):([\s\S]*)$/.exec(content.trim());
if (!match) {
return [];
}
const [
,
embedType,
payloadText
] = match;
const payload = parseEmbedPayload(payloadText);
return this.pluginUi.embedRecords()
.filter((record) => record.contribution.embedType === embedType)
.map((record) => ({
...record,
render: () => record.contribution.render(payload)
}));
}
startEdit(): void {
this.editContent = this.message().content;
this.isEditing.set(true);
@@ -507,3 +534,15 @@ export class ChatMessageItemComponent {
return this.attachmentsSvc.getForMessage(this.message().id).find((attachment) => attachment.id === attachmentId);
}
}
function parseEmbedPayload(payloadText: string | undefined): unknown {
if (!payloadText?.trim()) {
return null;
}
try {
return JSON.parse(payloadText) as unknown;
} catch {
return payloadText;
}
}

View File

@@ -0,0 +1,17 @@
# Plugins Domain
Owns the client-only plugin runtime foundation: manifest validation, deterministic load ordering, registry state, local manifest discovery, capability grants, browser-imported client entrypoints, disposable UI extension registries, plugin logs, and typed access to signal-server plugin support metadata.
The signal server can store plugin metadata/data and relay registered plugin events, but it must never execute plugin code. Executable plugin loading belongs to the renderer/Electron boundary and should enter this domain through `PluginHostService`.
Desktop local plugins are discovered from the Electron app data `plugins` folder. Discovery reads `toju-plugin.json` or `plugin.json` from immediate child folders and resolves declared entrypoint/readme paths only when they stay inside that plugin folder.
The standalone plugin store is available from the title bar Plugins button, the title-bar Plugin Store menu item, the legacy Settings page button, and the Plugin Manager header. It owns source manifest management, search, readmes, install/update/uninstall actions, and links back to installed-plugin management.
The plugin manager UI is available from Settings -> Plugins and from the store page Manage Plugins button. It includes installed plugins, capability grant toggles, activate/reload/unload actions, runtime logs, extension-point counts, server requirements, generated settings, and docs.
The Store tab consumes user-managed HTTP(S) source manifests. A source manifest can expose a `plugins` array whose entries include `id`, `title`, `description`, `version`, `author`/`authors`, `image`/`imageUrl`, `github`/`githubUrl`, `install`/`installUrl`/`manifestUrl`, and `readme`/`readmeUrl`. Installing from the store fetches the linked plugin manifest, validates it, registers it with the client registry, and persists the manifest locally; it does not execute plugin code on the signal server.
Runtime activation is explicit. `PluginHostService.activateReadyPlugins()` imports browser-safe plugin entrypoints from URL-resolvable manifests, passes a frozen `TojuClientPluginApi`, runs `activate`, then runs `ready` after the load-order pass. `deactivate` runs during unload/reload, disposables are cleaned in reverse order, and UI contributions are removed by plugin id.
Plugins that need fully custom UI can call `api.ui.mountElement(id, { target, element, position })` with the `ui.dom` capability. The runtime tags mounted elements with plugin ownership metadata, replaces duplicate mounts for the same plugin/id pair, and removes remaining mounted elements when the plugin is unloaded.

View File

@@ -0,0 +1,106 @@
import {
Injectable,
computed,
signal
} from '@angular/core';
import { getUserScopedStorageKey } from '../../../../core/storage/current-user-storage';
import type { PluginCapabilityId, TojuPluginManifest } from '../../../../shared-kernel';
const STORAGE_KEY_PLUGIN_CAPABILITIES = 'metoyou_plugin_capability_grants';
export class PluginCapabilityError extends Error {
constructor(pluginId: string, capability: PluginCapabilityId) {
super(`Plugin ${pluginId} needs capability ${capability}`);
this.name = 'PluginCapabilityError';
}
}
@Injectable({ providedIn: 'root' })
export class PluginCapabilityService {
readonly grants = computed(() => this.grantsSignal());
private readonly grantsSignal = signal<Record<string, PluginCapabilityId[]>>(this.loadGrants());
grant(pluginId: string, capability: PluginCapabilityId): void {
this.grantsSignal.update((grants) => ({
...grants,
[pluginId]: Array.from(new Set([...(grants[pluginId] ?? []), capability])).sort()
}));
this.saveGrants();
}
grantAll(manifest: TojuPluginManifest): void {
this.grantsSignal.update((grants) => ({
...grants,
[manifest.id]: [...(manifest.capabilities ?? [])].sort()
}));
this.saveGrants();
}
revoke(pluginId: string, capability: PluginCapabilityId): void {
this.grantsSignal.update((grants) => ({
...grants,
[pluginId]: (grants[pluginId] ?? []).filter((entry) => entry !== capability)
}));
this.saveGrants();
}
revokeAll(pluginId: string): void {
this.grantsSignal.update((grants) => {
const { [pluginId]: _removed, ...next } = grants;
return next;
});
this.saveGrants();
}
has(pluginId: string, capability: PluginCapabilityId): boolean {
return this.grants()[pluginId]?.includes(capability) ?? false;
}
assert(pluginId: string, capability: PluginCapabilityId): void {
if (!this.has(pluginId, capability)) {
throw new PluginCapabilityError(pluginId, capability);
}
}
missing(manifest: TojuPluginManifest): PluginCapabilityId[] {
return (manifest.capabilities ?? []).filter((capability) => !this.has(manifest.id, capability));
}
private loadGrants(): Record<string, PluginCapabilityId[]> {
try {
const raw = localStorage.getItem(getUserScopedStorageKey(STORAGE_KEY_PLUGIN_CAPABILITIES));
if (!raw) {
return {};
}
const parsed = JSON.parse(raw) as unknown;
return isGrantRecord(parsed) ? parsed : {};
} catch {
return {};
}
}
private saveGrants(): void {
try {
localStorage.setItem(
getUserScopedStorageKey(STORAGE_KEY_PLUGIN_CAPABILITIES),
JSON.stringify(this.grantsSignal())
);
} catch {}
}
}
function isGrantRecord(value: unknown): value is Record<string, PluginCapabilityId[]> {
return !!value
&& typeof value === 'object'
&& !Array.isArray(value)
&& Object.values(value).every((entry) => Array.isArray(entry) && entry.every((item) => typeof item === 'string'));
}

View File

@@ -0,0 +1,555 @@
import { Injectable, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { Subscription } from 'rxjs';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { VoiceConnectionFacade } from '../../../voice-connection/application/facades/voice-connection.facade';
import type {
Channel,
ChatEvent,
Message,
PluginCapabilityId,
PluginEventEnvelope,
TojuPluginManifest,
User
} from '../../../../shared-kernel';
import { MessagesActions } from '../../../../store/messages/messages.actions';
import { selectCurrentRoomMessages } from '../../../../store/messages/messages.selectors';
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import {
selectActiveChannelId,
selectCurrentRoom,
selectCurrentRoomChannels,
selectCurrentRoomId
} from '../../../../store/rooms/rooms.selectors';
import { UsersActions } from '../../../../store/users/users.actions';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
import type {
PluginApiAvatarUpdate,
PluginApiChannelRequest,
PluginApiCustomStreamRequest,
PluginApiMessageAsPluginUserRequest,
PluginApiServerSettingsUpdate,
TojuClientPluginApi
} from '../../domain/models/plugin-api.models';
import { PluginCapabilityService } from './plugin-capability.service';
import { PluginLoggerService } from './plugin-logger.service';
import { PluginStorageService } from './plugin-storage.service';
import { PluginUiRegistryService } from './plugin-ui-registry.service';
@Injectable({ providedIn: 'root' })
export class PluginClientApiService {
private readonly capabilities = inject(PluginCapabilityService);
private readonly logger = inject(PluginLoggerService);
private readonly realtime = inject(RealtimeSessionFacade);
private readonly store = inject(Store);
private readonly storage = inject(PluginStorageService);
private readonly uiRegistry = inject(PluginUiRegistryService);
private readonly voice = inject(VoiceConnectionFacade);
private readonly currentMessages = this.store.selectSignal(selectCurrentRoomMessages);
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
private readonly currentRoomChannels = this.store.selectSignal(selectCurrentRoomChannels);
private readonly currentRoomId = this.store.selectSignal(selectCurrentRoomId);
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
private readonly users = this.store.selectSignal(selectAllUsers);
createApi(manifest: TojuPluginManifest): TojuClientPluginApi {
const pluginId = manifest.id;
const requireCapability = (capability: PluginCapabilityId): void => this.capabilities.assert(pluginId, capability);
const assertEvent = (eventName: string): void => this.assertDeclaredEvent(manifest, eventName);
return deepFreeze<TojuClientPluginApi>({
channels: {
addAudioChannel: (request) => {
requireCapability('channels.manage');
this.store.dispatch(RoomsActions.addChannel({ channel: createChannel(request, 'voice') }));
},
addVideoChannel: (request) => {
requireCapability('channels.manage');
this.uiRegistry.registerChannelSection(pluginId, request.id ?? request.name, {
label: request.name,
order: request.position,
type: 'video'
});
},
list: () => {
requireCapability('channels.read');
return this.currentRoomChannels();
},
remove: (channelId) => {
requireCapability('channels.manage');
this.store.dispatch(RoomsActions.removeChannel({ channelId }));
},
rename: (channelId, name) => {
requireCapability('channels.manage');
this.store.dispatch(RoomsActions.renameChannel({ channelId, name }));
},
select: (channelId) => {
requireCapability('channels.read');
this.store.dispatch(RoomsActions.selectChannel({ channelId }));
}
},
events: {
publishP2p: (eventName, payload) => {
requireCapability('events.p2p.publish');
assertEvent(eventName);
this.broadcastPluginEvent(pluginId, eventName, payload, 'p2p');
},
publishServer: (eventName, payload) => {
requireCapability('events.server.publish');
assertEvent(eventName);
this.publishServerPluginEvent(pluginId, eventName, payload);
},
subscribeP2p: (subscription) => {
requireCapability('events.p2p.subscribe');
assertEvent(subscription.eventName);
return this.rememberSubscription(pluginId, subscription.eventName);
},
subscribeServer: (subscription) => {
requireCapability('events.server.subscribe');
assertEvent(subscription.eventName);
return this.subscribeServerPluginEvent(pluginId, subscription.eventName, subscription.handler);
}
},
logger: {
debug: (message, data) => this.logger.debug(pluginId, message, data),
error: (message, data) => this.logger.error(pluginId, message, data),
info: (message, data) => this.logger.info(pluginId, message, data),
warn: (message, data) => this.logger.warn(pluginId, message, data)
},
media: {
addCustomAudioStream: async (request) => {
requireCapability('media.addAudioStream');
await this.voice.setLocalStream(request.stream);
},
addCustomVideoStream: async (_request: PluginApiCustomStreamRequest) => {
requireCapability('media.addVideoStream');
this.logger.info(pluginId, 'Video stream contribution registered');
},
playAudioClip: async (request) => {
requireCapability('media.playAudio');
await playAudioClip(request.url, request.volume);
},
setInputVolume: (volume) => {
requireCapability('audio.volume');
this.voice.setInputVolume(volume);
},
setOutputVolume: (volume) => {
requireCapability('audio.volume');
this.voice.setOutputVolume(volume);
}
},
messages: {
delete: (messageId) => {
requireCapability('messages.deleteOwn');
this.deletePluginMessage(messageId);
},
edit: (messageId, content) => {
requireCapability('messages.editOwn');
this.editPluginMessage(messageId, content);
},
moderateDelete: (messageId) => {
requireCapability('messages.moderate');
this.store.dispatch(MessagesActions.adminDeleteMessage({ messageId }));
},
readCurrent: () => {
requireCapability('messages.read');
return this.currentMessages();
},
send: (content, channelId) => {
requireCapability('messages.send');
return this.sendPluginMessage(content, channelId);
},
sendAsPluginUser: (request) => {
requireCapability('messages.send');
this.receivePluginUserMessage(pluginId, request);
},
sync: (messages) => {
requireCapability('messages.sync');
this.store.dispatch(MessagesActions.syncMessages({ messages }));
}
},
p2p: {
broadcastData: (eventName, payload) => {
requireCapability('p2p.data');
this.broadcastPluginEvent(pluginId, eventName, payload, 'p2p');
},
connectedPeers: () => {
requireCapability('p2p.data');
return this.voice.getConnectedPeers();
},
sendData: (peerId, eventName, payload) => {
requireCapability('p2p.data');
this.broadcastPluginEvent(pluginId, eventName, { payload, peerId }, 'p2p');
}
},
profile: {
getCurrent: () => {
requireCapability('profile.read');
return this.currentUser() ?? null;
},
update: (profile) => {
requireCapability('profile.write');
this.store.dispatch(UsersActions.updateCurrentUserProfile({
profile: {
...profile,
profileUpdatedAt: Date.now()
}
}));
},
updateAvatar: (avatar: PluginApiAvatarUpdate) => {
requireCapability('profile.write');
this.store.dispatch(UsersActions.updateCurrentUserAvatar({
avatar: {
...avatar,
avatarUpdatedAt: Date.now()
}
}));
}
},
roles: {
list: () => {
requireCapability('roles.read');
return this.currentRoom()?.roles ?? [];
},
setAssignments: (assignments) => {
requireCapability('roles.manage');
this.updateRoomAccessControl({ roleAssignments: assignments });
}
},
server: {
getCurrent: () => {
requireCapability('server.read');
return this.currentRoom();
},
registerPluginUser: (request) => {
requireCapability('users.manage');
const userId = request.id ?? `${pluginId}:${slug(request.displayName)}`;
this.store.dispatch(UsersActions.userJoined({
user: {
avatarUrl: request.avatarUrl,
displayName: request.displayName,
id: userId,
isOnline: true,
joinedAt: Date.now(),
oderId: userId,
role: 'member',
status: 'online',
username: userId
}
}));
return userId;
},
updatePermissions: (permissions) => {
requireCapability('server.manage');
this.store.dispatch(RoomsActions.updateRoomPermissions({ roomId: this.requireRoomId(), permissions }));
},
updateSettings: (settings: PluginApiServerSettingsUpdate) => {
requireCapability('server.manage');
this.store.dispatch(RoomsActions.updateRoomSettings({
roomId: this.requireRoomId(),
settings: {
description: settings.description,
hasPassword: !!settings.password,
isPrivate: settings.isPrivate ?? this.currentRoom()?.isPrivate ?? false,
maxUsers: settings.maxUsers,
name: settings.name ?? this.currentRoom()?.name ?? 'Server',
password: settings.password,
rules: [],
topic: settings.topic
}
}));
}
},
serverData: {
read: async (key) => {
requireCapability('storage.serverData.read');
return await this.storage.readServerData(pluginId, key);
},
remove: async (key) => {
requireCapability('storage.serverData.write');
await this.storage.removeServerData(pluginId, key);
},
write: async (key, value) => {
requireCapability('storage.serverData.write');
await this.storage.writeServerData(pluginId, key, value);
}
},
storage: {
get: (key) => {
requireCapability('storage.local');
return this.storage.getLocal(pluginId, key);
},
remove: (key) => {
requireCapability('storage.local');
this.storage.removeLocal(pluginId, key);
},
set: (key, value) => {
requireCapability('storage.local');
this.storage.setLocal(pluginId, key, value);
}
},
ui: {
registerAppPage: (id, contribution) => {
requireCapability('ui.pages');
return this.uiRegistry.registerAppPage(pluginId, id, contribution);
},
registerChannelSection: (id, contribution) => {
requireCapability('ui.channelsSection');
return this.uiRegistry.registerChannelSection(pluginId, id, contribution);
},
registerComposerAction: (id, contribution) => {
requireCapability('ui.pages');
return this.uiRegistry.registerComposerAction(pluginId, id, contribution);
},
registerEmbedRenderer: (id, contribution) => {
requireCapability('ui.embeds');
return this.uiRegistry.registerEmbedRenderer(pluginId, id, contribution);
},
mountElement: (id, request) => {
requireCapability('ui.dom');
return this.uiRegistry.mountElement(pluginId, id, request);
},
registerProfileAction: (id, contribution) => {
requireCapability('ui.pages');
return this.uiRegistry.registerProfileAction(pluginId, id, contribution);
},
registerSettingsPage: (id, contribution) => {
requireCapability('ui.settings');
return this.uiRegistry.registerSettingsPage(pluginId, id, contribution);
},
registerSidePanel: (id, contribution) => {
requireCapability('ui.sidePanel');
return this.uiRegistry.registerSidePanel(pluginId, id, contribution);
},
registerToolbarAction: (id, contribution) => {
requireCapability('ui.pages');
return this.uiRegistry.registerToolbarAction(pluginId, id, contribution);
}
},
users: {
ban: (userId, reason) => {
requireCapability('users.manage');
this.store.dispatch(UsersActions.banUser({ reason, userId }));
},
getCurrent: () => {
requireCapability('users.read');
return this.currentUser() ?? null;
},
kick: (userId) => {
requireCapability('users.manage');
this.store.dispatch(UsersActions.kickUser({ userId }));
},
list: () => {
requireCapability('users.read');
return this.users();
},
readMembers: () => {
requireCapability('users.read');
return this.currentRoom()?.members ?? [];
},
setRole: (userId, role: User['role']) => {
requireCapability('roles.manage');
this.store.dispatch(UsersActions.updateUserRole({ role, userId }));
}
}
});
}
private assertDeclaredEvent(manifest: TojuPluginManifest, eventName: string): void {
const declared = manifest.events?.some((event) => event.eventName === eventName) ?? false;
if (!declared) {
throw new Error(`Plugin ${manifest.id} did not declare event ${eventName}`);
}
}
private broadcastPluginEvent(pluginId: string, eventName: string, payload: unknown, target: 'p2p' | 'server'): void {
const roomId = this.currentRoomId() ?? 'local';
const event: PluginEventEnvelope = {
emittedAt: Date.now(),
eventId: createId(),
eventName,
payload,
pluginId,
serverId: roomId,
type: 'plugin_event'
};
this.voice.broadcastMessage({
data: JSON.stringify({ event, target }),
roomId,
timestamp: Date.now(),
type: 'plugin-event'
} as unknown as ChatEvent);
}
private publishServerPluginEvent(pluginId: string, eventName: string, payload: unknown): void {
this.realtime.sendRawMessage({
type: 'plugin_event',
eventId: createId(),
eventName,
payload,
pluginId,
serverId: this.requireRoomId()
});
}
private subscribeServerPluginEvent(
pluginId: string,
eventName: string,
handler: (event: PluginEventEnvelope) => void
) {
const subscription = new Subscription();
subscription.add(this.realtime.onSignalingMessage.subscribe((message) => {
const record = message as Record<string, unknown>;
if (record['type'] !== 'plugin_event' || record['pluginId'] !== pluginId || record['eventName'] !== eventName) {
return;
}
handler(message as PluginEventEnvelope);
}));
this.logger.info(pluginId, `Subscribed to server event ${eventName}`);
return {
dispose: () => {
subscription.unsubscribe();
this.logger.info(pluginId, `Unsubscribed from server event ${eventName}`);
}
};
}
private receivePluginUserMessage(pluginId: string, request: PluginApiMessageAsPluginUserRequest): void {
const roomId = this.requireRoomId();
const message: Message = {
channelId: request.channelId ?? this.activeChannelId() ?? undefined,
content: request.content,
id: createId(),
isDeleted: false,
reactions: [],
roomId,
senderId: request.pluginUserId,
senderName: request.pluginUserId,
timestamp: Date.now()
};
this.logger.info(pluginId, 'Plugin user message emitted', { messageId: message.id });
this.store.dispatch(MessagesActions.receiveMessage({ message }));
this.voice.broadcastMessage({ type: 'chat-message', message } as unknown as ChatEvent);
}
private deletePluginMessage(messageId: string): void {
this.store.dispatch(MessagesActions.deleteMessageSuccess({ messageId }));
this.voice.broadcastMessage({
deletedAt: Date.now(),
messageId,
type: 'message-deleted'
} as unknown as ChatEvent);
}
private editPluginMessage(messageId: string, content: string): void {
const editedAt = Date.now();
this.store.dispatch(MessagesActions.editMessageSuccess({
content,
editedAt,
messageId
}));
this.voice.broadcastMessage({
content,
editedAt,
messageId,
type: 'message-edited'
} as unknown as ChatEvent);
}
private sendPluginMessage(content: string, channelId?: string): Message {
const currentUser = this.currentUser();
const roomId = this.requireRoomId();
const message: Message = {
channelId: channelId ?? this.activeChannelId() ?? 'general',
content,
id: createId(),
isDeleted: false,
reactions: [],
roomId,
senderId: currentUser?.id ?? 'plugin',
senderName: currentUser?.displayName || currentUser?.username || 'Plugin',
timestamp: Date.now()
};
this.store.dispatch(MessagesActions.sendMessageSuccess({ message }));
this.voice.broadcastMessage({ type: 'chat-message', message } as unknown as ChatEvent);
return message;
}
private rememberSubscription(pluginId: string, eventName: string) {
this.logger.info(pluginId, `Subscribed to ${eventName}`);
return {
dispose: () => this.logger.info(pluginId, `Unsubscribed from ${eventName}`)
};
}
private requireRoomId(): string {
const roomId = this.currentRoomId();
if (!roomId) {
throw new Error('No active server');
}
return roomId;
}
private updateRoomAccessControl(changes: Parameters<typeof RoomsActions.updateRoomAccessControl>[0]['changes']): void {
this.store.dispatch(RoomsActions.updateRoomAccessControl({
changes,
roomId: this.requireRoomId()
}));
}
}
function createChannel(request: PluginApiChannelRequest, type: Channel['type']): Channel {
return {
id: request.id ?? slug(request.name),
name: request.name,
position: request.position ?? Date.now(),
type
};
}
function createId(): string {
return globalThis.crypto?.randomUUID?.() ?? `plugin-${Date.now()}-${Math.random().toString(36)
.slice(2)}`;
}
function deepFreeze<TValue extends object>(value: TValue): TValue {
for (const propertyValue of Object.values(value)) {
if (propertyValue && typeof propertyValue === 'object') {
deepFreeze(propertyValue as Record<string, unknown>);
}
}
return Object.freeze(value);
}
async function playAudioClip(url: string, volume = 1): Promise<void> {
const audio = new Audio(url);
audio.volume = Math.max(0, Math.min(1, volume));
await audio.play();
}
function slug(value: string): string {
return value.trim().toLowerCase()
.replace(/[^a-z0-9.-]+/g, '-')
.replace(/(^-+|-+$)/g, '') || createId();
}

View File

@@ -0,0 +1,161 @@
import { Injector } from '@angular/core';
import type { TojuPluginManifest } from '../../../../shared-kernel';
import { DEVELOPMENT_PLUGIN_MANIFEST } from '../../development/development-plugin';
import type { LocalPluginDiscoveryResult } from '../../domain/models/plugin-runtime.models';
import { LocalPluginDiscoveryService } from '../../infrastructure/local-plugin-discovery.service';
import { PluginCapabilityService } from './plugin-capability.service';
import { PluginClientApiService } from './plugin-client-api.service';
import { PluginHostService } from './plugin-host.service';
import { PluginLoggerService } from './plugin-logger.service';
import { PluginRegistryService } from './plugin-registry.service';
import { PluginUiRegistryService } from './plugin-ui-registry.service';
const TEST_PLUGIN_MANIFEST = createTestPluginManifest();
describe('PluginHostService', () => {
let discoveryResult: LocalPluginDiscoveryResult;
beforeEach(() => {
discoveryResult = {
errors: [],
plugins: [],
pluginsPath: '/plugins'
};
});
it('registers discovered test plugin manifests', async () => {
discoveryResult = {
errors: [],
plugins: [
{
discoveredAt: 1,
entrypointPath: '/plugins/api-test-plugin/dist/main.js',
manifest: TEST_PLUGIN_MANIFEST,
manifestPath: '/plugins/api-test-plugin/toju-plugin.json',
pluginRoot: '/plugins/api-test-plugin',
readmePath: '/plugins/api-test-plugin/README.md'
}
],
pluginsPath: '/plugins'
};
const host = createHostService(() => discoveryResult);
const result = await host.discoverLocalPlugins();
expect(result.errors).toEqual([]);
expect(result.registered.map((plugin) => plugin.manifest.id)).toEqual([TEST_PLUGIN_MANIFEST.id]);
const readyManifestIds = host.getReadyManifests().map((manifest) => manifest.id);
expect(readyManifestIds.sort()).toEqual([DEVELOPMENT_PLUGIN_MANIFEST.id, TEST_PLUGIN_MANIFEST.id].sort());
});
it('registers the built-in development plugin in development builds', () => {
const host = createHostService(() => discoveryResult);
expect(host.getReadyManifests().map((manifest) => manifest.id)).toEqual([DEVELOPMENT_PLUGIN_MANIFEST.id]);
});
it('keeps discovery and validation failures visible to callers', async () => {
discoveryResult = {
errors: [
{
manifestPath: '/plugins/broken/plugin.json',
message: 'Unexpected end of JSON input',
pluginRoot: '/plugins/broken'
}
],
plugins: [
{
discoveredAt: 1,
manifest: {
...TEST_PLUGIN_MANIFEST,
entrypoint: undefined
},
manifestPath: '/plugins/invalid/toju-plugin.json',
pluginRoot: '/plugins/invalid'
}
],
pluginsPath: '/plugins'
};
const host = createHostService(() => discoveryResult);
const result = await host.discoverLocalPlugins();
expect(result.registered).toEqual([]);
expect(result.errors.map((error) => error.pluginRoot)).toEqual(['/plugins/broken', '/plugins/invalid']);
expect(result.errors[1]?.message).toContain('client plugins require an entrypoint');
});
});
function createHostService(readDiscoveryResult: () => LocalPluginDiscoveryResult): PluginHostService {
const injector = Injector.create({
providers: [
PluginHostService,
PluginRegistryService,
{
provide: PluginCapabilityService,
useValue: {
missing: vi.fn(() => [])
}
},
{
provide: PluginClientApiService,
useValue: {
createApi: vi.fn(() => ({}))
}
},
{
provide: PluginLoggerService,
useValue: {
error: vi.fn(),
info: vi.fn(),
warn: vi.fn()
}
},
{
provide: PluginUiRegistryService,
useValue: {
unregisterPlugin: vi.fn()
}
},
{
provide: LocalPluginDiscoveryService,
useValue: {
discoverManifests: vi.fn(async () => readDiscoveryResult())
}
}
]
});
return injector.get(PluginHostService);
}
function createTestPluginManifest(): TojuPluginManifest {
return {
apiVersion: '1.0.0',
capabilities: [
'storage.serverData.read',
'storage.serverData.write',
'events.server.publish'
],
compatibility: {
minimumTojuVersion: '1.0.0'
},
description: 'Fixture plugin used by automated tests for plugin support APIs.',
entrypoint: './dist/main.js',
events: [
{
direction: 'serverRelay',
eventName: 'e2e:relay',
maxPayloadBytes: 2048,
scope: 'server'
}
],
id: 'e2e.plugin-api',
kind: 'client',
schemaVersion: 1,
title: 'E2E Plugin API Fixture',
version: '1.0.0'
};
}

View File

@@ -0,0 +1,255 @@
import { Injectable, inject } from '@angular/core';
import { environment } from '../../../../../environments/environment';
import type { TojuPluginManifest } from '../../../../shared-kernel';
import {
DEVELOPMENT_PLUGIN_ENTRYPOINT,
DEVELOPMENT_PLUGIN_MANIFEST,
DEVELOPMENT_PLUGIN_MODULE
} from '../../development/development-plugin';
import type {
TojuClientPluginModule,
TojuPluginActivationContext,
TojuPluginDisposable
} from '../../domain/models/plugin-api.models';
import type {
LocalPluginDiscoveryError,
LocalPluginRegistrationResult,
RegisteredPlugin
} from '../../domain/models/plugin-runtime.models';
import { LocalPluginDiscoveryService } from '../../infrastructure/local-plugin-discovery.service';
import { PluginCapabilityService } from './plugin-capability.service';
import { PluginClientApiService } from './plugin-client-api.service';
import { PluginLoggerService } from './plugin-logger.service';
import { PluginRegistryService } from './plugin-registry.service';
import { PluginUiRegistryService } from './plugin-ui-registry.service';
interface ActivePluginRuntime {
context: TojuPluginActivationContext;
module: TojuClientPluginModule;
}
@Injectable({ providedIn: 'root' })
export class PluginHostService {
private readonly apiFactory = inject(PluginClientApiService);
private readonly capabilities = inject(PluginCapabilityService);
private readonly localDiscovery = inject(LocalPluginDiscoveryService);
private readonly logger = inject(PluginLoggerService);
private readonly registry = inject(PluginRegistryService);
private readonly uiRegistry = inject(PluginUiRegistryService);
private readonly activePlugins = new Map<string, ActivePluginRuntime>();
constructor() {
this.registerDevelopmentPlugin();
}
registerLocalManifest(manifestValue: unknown, sourcePath?: string): RegisteredPlugin {
return this.registry.registerManifest(manifestValue, sourcePath);
}
async discoverLocalPlugins(): Promise<LocalPluginRegistrationResult> {
const discovery = await this.localDiscovery.discoverManifests();
const registered: RegisteredPlugin[] = [];
const errors: LocalPluginDiscoveryError[] = [...discovery.errors];
for (const descriptor of discovery.plugins) {
try {
registered.push(this.registerLocalManifest(descriptor.manifest, descriptor.pluginRootUrl ?? descriptor.pluginRoot));
} catch (error) {
errors.push({
manifestPath: descriptor.manifestPath,
message: error instanceof Error ? error.message : 'Plugin manifest validation failed',
pluginRoot: descriptor.pluginRoot
});
}
}
return {
discovery,
errors,
registered
};
}
getReadyManifests(): TojuPluginManifest[] {
return this.registry.loadOrder().ordered;
}
async activateReadyPlugins(): Promise<void> {
const activated: TojuPluginActivationContext[] = [];
for (const manifest of this.registry.loadOrder().ordered) {
const entry = this.registry.find(manifest.id);
if (!entry || !entry.enabled || this.activePlugins.has(manifest.id)) {
continue;
}
await this.activatePlugin(entry);
const active = this.activePlugins.get(manifest.id);
if (active) {
activated.push(active.context);
}
}
for (const context of activated) {
const active = this.activePlugins.get(context.pluginId);
if (!active?.module.ready) {
continue;
}
try {
await active.module.ready(context);
this.registry.setState(context.pluginId, 'ready');
} catch (error) {
this.failPlugin(context.pluginId, error);
}
}
}
async deactivatePlugin(pluginId: string): Promise<void> {
const active = this.activePlugins.get(pluginId);
if (!active) {
this.registry.setState(pluginId, 'unloaded');
this.uiRegistry.unregisterPlugin(pluginId);
return;
}
this.registry.setState(pluginId, 'unloading');
try {
await active.module.deactivate?.(active.context);
} catch (error) {
this.logger.warn(pluginId, 'Plugin deactivate failed', error);
}
for (const disposable of [...active.context.subscriptions].reverse()) {
safeDispose(disposable, pluginId, this.logger);
}
this.uiRegistry.unregisterPlugin(pluginId);
this.activePlugins.delete(pluginId);
this.registry.setState(pluginId, 'unloaded');
}
async deactivateAll(): Promise<void> {
const pluginIds = Array.from(this.activePlugins.keys()).reverse();
for (const pluginId of pluginIds) {
await this.deactivatePlugin(pluginId);
}
}
async reloadPlugin(pluginId: string): Promise<void> {
await this.deactivatePlugin(pluginId);
const entry = this.registry.find(pluginId);
if (entry?.enabled) {
await this.activatePlugin(entry);
}
}
markLoaded(pluginId: string): void {
this.registry.setState(pluginId, 'loaded');
}
markFailed(pluginId: string): void {
this.registry.setState(pluginId, 'failed');
}
private async activatePlugin(entry: RegisteredPlugin): Promise<void> {
const manifest = entry.manifest;
const missingCapabilities = this.capabilities.missing(manifest);
if (missingCapabilities.length > 0) {
this.registry.setFailed(manifest.id, `Missing capabilities: ${missingCapabilities.join(', ')}`);
this.logger.warn(manifest.id, 'Plugin blocked by missing capability grants', missingCapabilities);
return;
}
if (!manifest.entrypoint) {
this.registry.setState(manifest.id, 'ready');
return;
}
this.registry.setState(manifest.id, 'loading');
try {
const module = await this.loadPluginModule(manifest, entry.sourcePath);
const context: TojuPluginActivationContext = {
api: this.apiFactory.createApi(manifest),
manifest,
pluginId: manifest.id,
subscriptions: []
};
await module.activate?.(context);
this.activePlugins.set(manifest.id, { context, module });
this.registry.setState(manifest.id, 'loaded');
this.logger.info(manifest.id, 'Plugin activated');
} catch (error) {
this.failPlugin(manifest.id, error);
}
}
private failPlugin(pluginId: string, error: unknown): void {
const message = error instanceof Error ? error.message : 'Plugin activation failed';
this.registry.setFailed(pluginId, message);
this.logger.error(pluginId, message, error);
this.uiRegistry.unregisterPlugin(pluginId);
this.activePlugins.delete(pluginId);
}
private async loadPluginModule(manifest: TojuPluginManifest, sourcePath?: string): Promise<TojuClientPluginModule> {
if (manifest.entrypoint === DEVELOPMENT_PLUGIN_ENTRYPOINT) {
return DEVELOPMENT_PLUGIN_MODULE;
}
return await import(/* @vite-ignore */ this.resolveEntrypoint(manifest, sourcePath)) as TojuClientPluginModule;
}
private registerDevelopmentPlugin(): void {
if (environment.production) {
return;
}
try {
this.registry.registerManifest(DEVELOPMENT_PLUGIN_MANIFEST, DEVELOPMENT_PLUGIN_ENTRYPOINT);
} catch (error) {
this.logger.warn(DEVELOPMENT_PLUGIN_MANIFEST.id, 'Development plugin registration failed', error);
}
}
private resolveEntrypoint(manifest: TojuPluginManifest, sourcePath?: string): string {
if (!manifest.entrypoint) {
throw new Error('Plugin entrypoint is missing');
}
try {
return new URL(manifest.entrypoint).toString();
} catch {}
if (sourcePath?.startsWith('http://') || sourcePath?.startsWith('https://') || sourcePath?.startsWith('file://')) {
return new URL(manifest.entrypoint, sourcePath).toString();
}
if (manifest.entrypoint.startsWith('/')) {
return manifest.entrypoint;
}
throw new Error(`Plugin ${manifest.id} has no browser-importable entrypoint`);
}
}
function safeDispose(disposable: TojuPluginDisposable, pluginId: string, logger: PluginLoggerService): void {
try {
disposable.dispose();
} catch (error) {
logger.warn(pluginId, 'Plugin disposable failed', error);
}
}

View File

@@ -0,0 +1,64 @@
import {
Injectable,
Signal,
computed,
signal
} from '@angular/core';
export type PluginLogLevel = 'debug' | 'error' | 'info' | 'warn';
export interface PluginLogEntry {
data?: unknown;
level: PluginLogLevel;
message: string;
pluginId: string;
timestamp: number;
}
@Injectable({ providedIn: 'root' })
export class PluginLoggerService {
readonly entries: Signal<PluginLogEntry[]>;
private readonly entriesSignal = signal<PluginLogEntry[]>([]);
constructor() {
this.entries = this.entriesSignal.asReadonly();
}
entriesFor(pluginId: string): Signal<PluginLogEntry[]> {
return computed(() => this.entries().filter((entry) => entry.pluginId === pluginId));
}
debug(pluginId: string, message: string, data?: unknown): void {
this.add(pluginId, 'debug', message, data);
}
error(pluginId: string, message: string, data?: unknown): void {
this.add(pluginId, 'error', message, data);
}
info(pluginId: string, message: string, data?: unknown): void {
this.add(pluginId, 'info', message, data);
}
warn(pluginId: string, message: string, data?: unknown): void {
this.add(pluginId, 'warn', message, data);
}
clear(pluginId?: string): void {
this.entriesSignal.update((entries) => pluginId ? entries.filter((entry) => entry.pluginId !== pluginId) : []);
}
private add(pluginId: string, level: PluginLogLevel, message: string, data?: unknown): void {
this.entriesSignal.update((entries) => [
...entries,
{
data,
level,
message,
pluginId,
timestamp: Date.now()
}
].slice(-500));
}
}

View File

@@ -0,0 +1,117 @@
import {
Injectable,
type Signal,
computed,
signal
} from '@angular/core';
import {
RegisteredPlugin,
type PluginLoadOrderResult,
type PluginRuntimeState
} from '../../domain/models/plugin-runtime.models';
import { resolvePluginLoadOrder } from '../../domain/logic/plugin-dependency-resolver.logic';
import { validateTojuPluginManifest } from '../../domain/logic/plugin-manifest-validation.logic';
@Injectable({ providedIn: 'root' })
export class PluginRegistryService {
readonly entries: Signal<RegisteredPlugin[]>;
readonly enabledEntries: Signal<RegisteredPlugin[]>;
readonly loadOrder: Signal<PluginLoadOrderResult>;
private readonly entriesSignal = signal<RegisteredPlugin[]>([]);
constructor() {
this.entries = this.entriesSignal.asReadonly();
this.enabledEntries = computed(() => this.entries().filter((entry) => entry.enabled));
this.loadOrder = computed<PluginLoadOrderResult>(() =>
resolvePluginLoadOrder(this.entries().map((entry) => ({ enabled: entry.enabled, manifest: entry.manifest })))
);
}
clear(): void {
this.entriesSignal.set([]);
}
registerManifest(manifestValue: unknown, sourcePath?: string): RegisteredPlugin {
const validation = validateTojuPluginManifest(manifestValue);
if (!validation.manifest) {
throw new Error(validation.issues.map((issue) => `${issue.path}: ${issue.message}`).join('\n'));
}
const existingIndex = this.entries().findIndex((entry) => entry.manifest.id === validation.manifest?.id);
const entry: RegisteredPlugin = {
enabled: true,
manifest: validation.manifest,
sourcePath,
state: validation.valid ? 'validated' : 'blocked',
validationIssues: validation.issues
};
if (existingIndex >= 0) {
this.entriesSignal.update((entries) => entries.map((candidate, index) => index === existingIndex ? entry : candidate));
} else {
this.entriesSignal.update((entries) => [...entries, entry]);
}
this.syncLoadState();
return entry;
}
setEnabled(pluginId: string, enabled: boolean): void {
this.entriesSignal.update((entries) => entries.map((entry) => entry.manifest.id === pluginId
? {
...entry,
enabled,
state: enabled ? entry.state === 'disabled' ? 'validated' : entry.state : 'disabled'
}
: entry));
this.syncLoadState();
}
unregister(pluginId: string): void {
this.entriesSignal.update((entries) => entries.filter((entry) => entry.manifest.id !== pluginId));
this.syncLoadState();
}
setState(pluginId: string, state: PluginRuntimeState): void {
this.entriesSignal.update((entries) => entries.map((entry) => entry.manifest.id === pluginId
? { ...entry, error: undefined, state }
: entry));
}
setFailed(pluginId: string, error: string): void {
this.entriesSignal.update((entries) => entries.map((entry) => entry.manifest.id === pluginId
? { ...entry, error, state: 'failed' }
: entry));
}
find(pluginId: string): RegisteredPlugin | undefined {
return this.entries().find((entry) => entry.manifest.id === pluginId);
}
private syncLoadState(): void {
const loadOrder = this.loadOrder();
const blockedIds = new Set(loadOrder.blocked.map((blocker) => blocker.pluginId));
const loadIndexes = new Map(loadOrder.ordered.map((manifest, index) => [manifest.id, index]));
this.entriesSignal.update((entries) => entries.map((entry) => {
const loadIndex = loadIndexes.get(entry.manifest.id);
if (!entry.enabled) {
return { ...entry, loadIndex: undefined, state: 'disabled' };
}
if (blockedIds.has(entry.manifest.id)) {
return { ...entry, loadIndex: undefined, state: 'blocked' };
}
return {
...entry,
loadIndex,
state: loadIndex === undefined ? entry.state : 'ready'
};
}));
}
}

View File

@@ -0,0 +1,202 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
DestroyRef,
Injectable,
computed,
effect,
inject,
signal
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store';
import type {
PluginRequirementSummary,
PluginRequirementsSnapshot,
TojuPluginManifest
} from '../../../../shared-kernel';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { selectCurrentRoomId } from '../../../../store/rooms/rooms.selectors';
import { ServerDirectoryFacade } from '../../../server-directory';
import { PluginRegistryService } from './plugin-registry.service';
import { PluginRequirementService } from './plugin-requirement.service';
export type PluginRequirementComparisonStatus =
| 'blockedByServer'
| 'disabled'
| 'enabled'
| 'incompatible'
| 'missing'
| 'notRequired';
export interface PluginRequirementComparison {
installed?: TojuPluginManifest;
pluginId: string;
requirement?: PluginRequirementSummary;
status: PluginRequirementComparisonStatus;
}
@Injectable({ providedIn: 'root' })
export class PluginRequirementStateService {
private readonly destroyRef = inject(DestroyRef);
private readonly pluginRequirements = inject(PluginRequirementService);
private readonly realtime = inject(RealtimeSessionFacade);
private readonly registry = inject(PluginRegistryService);
private readonly serverDirectory = inject(ServerDirectoryFacade);
private readonly store = inject(Store);
private readonly currentRoomId = this.store.selectSignal(selectCurrentRoomId);
private readonly snapshotsSignal = signal<Record<string, PluginRequirementsSnapshot>>({});
private readonly refreshErrorsSignal = signal<Record<string, string>>({});
readonly currentSnapshot = computed(() => {
const roomId = this.currentRoomId();
return roomId ? this.snapshotsSignal()[roomId] ?? null : null;
});
readonly refreshErrors = this.refreshErrorsSignal.asReadonly();
readonly comparisons = computed<PluginRequirementComparison[]>(() => {
const snapshot = this.currentSnapshot();
const installedEntries = this.registry.entries();
const installedById = new Map(installedEntries.map((entry) => [entry.manifest.id, entry]));
const requirementIds = new Set(snapshot?.requirements.map((requirement) => requirement.pluginId) ?? []);
const comparisons: PluginRequirementComparison[] = [];
for (const requirement of snapshot?.requirements ?? []) {
const entry = installedById.get(requirement.pluginId);
comparisons.push({
installed: entry?.manifest,
pluginId: requirement.pluginId,
requirement,
status: this.resolveStatus(requirement, entry)
});
}
for (const entry of installedEntries) {
if (!requirementIds.has(entry.manifest.id)) {
comparisons.push({
installed: entry.manifest,
pluginId: entry.manifest.id,
status: entry.enabled ? 'enabled' : 'disabled'
});
}
}
return comparisons.sort((left, right) => left.pluginId.localeCompare(right.pluginId));
});
constructor() {
this.realtime.onSignalingMessage
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((message) => {
if ((message.type === 'plugin_requirements' || message.type === 'plugin_requirements_changed') && isSnapshotMessage(message)) {
this.setSnapshot(message.serverId, message.snapshot);
}
});
effect(() => {
const roomId = this.currentRoomId();
if (roomId) {
void this.refreshCurrent();
}
});
}
async refreshCurrent(): Promise<void> {
const roomId = this.currentRoomId();
if (!roomId) {
return;
}
try {
const apiBaseUrl = this.serverDirectory.getApiBaseUrl();
const snapshot = await new Promise<PluginRequirementsSnapshot>((resolve, reject) => {
this.pluginRequirements.getSnapshot(apiBaseUrl, roomId).subscribe({
error: reject,
next: resolve
});
});
this.setSnapshot(roomId, snapshot);
this.refreshErrorsSignal.update((errors) => {
const { [roomId]: _removed, ...next } = errors;
return next;
});
} catch (error) {
this.refreshErrorsSignal.update((errors) => ({
...errors,
[roomId]: error instanceof Error ? error.message : 'Unable to refresh plugin requirements'
}));
}
}
comparisonFor(pluginId: string): PluginRequirementComparison | null {
return this.comparisons().find((comparison) => comparison.pluginId === pluginId) ?? null;
}
private setSnapshot(serverId: string, snapshot: PluginRequirementsSnapshot): void {
this.snapshotsSignal.update((snapshots) => ({
...snapshots,
[serverId]: snapshot
}));
}
private resolveStatus(
requirement: PluginRequirementSummary,
entry: { enabled: boolean; manifest: TojuPluginManifest } | undefined
): PluginRequirementComparisonStatus {
if (requirement.status === 'blocked') {
return 'blockedByServer';
}
if (requirement.status === 'incompatible') {
return 'incompatible';
}
if (!entry) {
return 'missing';
}
if (!entry.enabled) {
return 'disabled';
}
if (requirement.versionRange && !isVersionCompatible(entry.manifest.version, requirement.versionRange)) {
return 'incompatible';
}
return 'enabled';
}
}
function isSnapshotMessage(message: unknown): message is { serverId: string; snapshot: PluginRequirementsSnapshot } {
const record = message as Record<string, unknown>;
return typeof record['serverId'] === 'string'
&& !!record['snapshot']
&& typeof record['snapshot'] === 'object';
}
function isVersionCompatible(version: string, versionRange: string): boolean {
const normalizedRange = versionRange.trim();
if (!normalizedRange || normalizedRange === '*') {
return true;
}
if (normalizedRange.startsWith('^')) {
return version.split('.')[0] === normalizedRange.slice(1).split('.')[0];
}
if (normalizedRange.startsWith('~')) {
const [major, minor] = version.split('.');
const [rangeMajor, rangeMinor] = normalizedRange.slice(1).split('.');
return major === rangeMajor && minor === rangeMinor;
}
return version === normalizedRange;
}

View File

@@ -0,0 +1,66 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import type {
PluginEventDefinitionSummary,
PluginRequirementStatus,
PluginRequirementSummary,
PluginRequirementsSnapshot
} from '../../../../shared-kernel';
export interface UpsertPluginRequirementRequest {
actorUserId: string;
reason?: string;
status: PluginRequirementStatus;
versionRange?: string;
}
export interface UpsertPluginEventDefinitionRequest {
actorUserId: string;
direction: 'clientToServer' | 'serverRelay' | 'p2pHint';
maxPayloadBytes?: number;
rateLimitJson?: string;
schemaJson?: string;
scope: 'server' | 'channel' | 'user' | 'plugin';
}
@Injectable({ providedIn: 'root' })
export class PluginRequirementService {
private readonly http = inject(HttpClient);
getSnapshot(apiBaseUrl: string, serverId: string): Observable<PluginRequirementsSnapshot> {
return this.http.get<PluginRequirementsSnapshot>(`${this.apiBase(apiBaseUrl)}/servers/${encodeURIComponent(serverId)}/plugins`);
}
upsertRequirement(
apiBaseUrl: string,
serverId: string,
pluginId: string,
request: UpsertPluginRequirementRequest
): Observable<{ requirement: PluginRequirementSummary }> {
return this.http.put<{ requirement: PluginRequirementSummary }>(
`${this.apiBase(apiBaseUrl)}/servers/${encodeURIComponent(serverId)}/plugins/${encodeURIComponent(pluginId)}/requirement`,
request
);
}
upsertEventDefinition(
apiBaseUrl: string,
serverId: string,
pluginId: string,
eventName: string,
request: UpsertPluginEventDefinitionRequest
): Observable<{ eventDefinition: PluginEventDefinitionSummary }> {
const eventUrl = `${this.apiBase(apiBaseUrl)}/servers/${encodeURIComponent(serverId)}`
+ `/plugins/${encodeURIComponent(pluginId)}/events/${encodeURIComponent(eventName)}`;
return this.http.put<{ eventDefinition: PluginEventDefinitionSummary }>(
eventUrl,
request
);
}
private apiBase(apiBaseUrl: string): string {
return apiBaseUrl.replace(/\/$/, '');
}
}

View File

@@ -0,0 +1,109 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { firstValueFrom } from 'rxjs';
import { getUserScopedStorageKey } from '../../../../core/storage/current-user-storage';
import { ServerDirectoryFacade } from '../../../server-directory';
import { selectCurrentRoomId } from '../../../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
const STORAGE_PREFIX_PLUGIN_LOCAL = 'metoyou_plugin_local';
interface PluginDataResponse {
record?: {
value: unknown;
};
records?: {
value: unknown;
}[];
}
@Injectable({ providedIn: 'root' })
export class PluginStorageService {
private readonly http = inject(HttpClient);
private readonly serverDirectory = inject(ServerDirectoryFacade);
private readonly store = inject(Store);
private readonly currentRoomId = this.store.selectSignal(selectCurrentRoomId);
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
getLocal(pluginId: string, key: string): unknown {
return this.read(`${STORAGE_PREFIX_PLUGIN_LOCAL}:${pluginId}:${key}`);
}
removeLocal(pluginId: string, key: string): void {
localStorage.removeItem(getUserScopedStorageKey(`${STORAGE_PREFIX_PLUGIN_LOCAL}:${pluginId}:${key}`));
}
setLocal(pluginId: string, key: string, value: unknown): void {
this.write(`${STORAGE_PREFIX_PLUGIN_LOCAL}:${pluginId}:${key}`, value);
}
async readServerData(pluginId: string, key: string): Promise<unknown> {
const response = await firstValueFrom(this.http.get<PluginDataResponse>(`${this.pluginsApi(pluginId)}/data`, {
params: new HttpParams()
.set('key', key)
.set('scope', 'server')
.set('userId', this.requireActorUserId())
}));
return response.records?.[0]?.value ?? null;
}
async removeServerData(pluginId: string, key: string): Promise<void> {
await firstValueFrom(this.http.delete(`${this.pluginsApi(pluginId)}/data/${encodeURIComponent(key)}`, {
body: {
actorUserId: this.requireActorUserId(),
scope: 'server'
}
}));
}
async writeServerData(pluginId: string, key: string, value: unknown): Promise<void> {
await firstValueFrom(this.http.put<PluginDataResponse>(`${this.pluginsApi(pluginId)}/data/${encodeURIComponent(key)}`, {
actorUserId: this.requireActorUserId(),
scope: 'server',
value
}));
}
private pluginsApi(pluginId: string): string {
const roomId = this.currentRoomId();
if (!roomId) {
throw new Error('No active server for plugin server data');
}
const apiBase = this.serverDirectory.getApiBaseUrl();
return `${apiBase}/servers/${encodeURIComponent(roomId)}/plugins/${encodeURIComponent(pluginId)}`;
}
private requireActorUserId(): string {
const user = this.currentUser();
const userId = user?.oderId || user?.id;
if (!userId) {
throw new Error('No current user for plugin server data');
}
return userId;
}
private read(key: string): unknown {
const raw = localStorage.getItem(getUserScopedStorageKey(key));
if (!raw) {
return null;
}
try {
return JSON.parse(raw) as unknown;
} catch {
return null;
}
}
private write(key: string, value: unknown): void {
localStorage.setItem(getUserScopedStorageKey(key), JSON.stringify(value));
}
}

View File

@@ -0,0 +1,198 @@
import { Injector } from '@angular/core';
import type { TojuPluginManifest } from '../../../../shared-kernel';
import { PluginStoreService } from './plugin-store.service';
import { PluginHostService } from './plugin-host.service';
import { PluginRegistryService } from './plugin-registry.service';
import type { PluginStoreEntry } from '../../domain/models/plugin-store.models';
describe('PluginStoreService', () => {
let fetchMock: ReturnType<typeof vi.fn>;
let registerLocalManifest: ReturnType<typeof vi.fn>;
let unregister: ReturnType<typeof vi.fn>;
let storage: Storage;
beforeEach(() => {
storage = createMemoryStorage();
vi.stubGlobal('localStorage', storage);
fetchMock = vi.fn();
registerLocalManifest = vi.fn((manifest: TojuPluginManifest, sourcePath?: string) => ({
enabled: true,
manifest,
sourcePath,
state: 'validated',
validationIssues: []
}));
unregister = vi.fn();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
storage.clear();
vi.unstubAllGlobals();
});
it('loads plugin entries from source manifests and resolves relative links', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({
plugins: [
{
author: 'Ada Example',
description: 'Adds better channel tools.',
github: 'https://github.com/example/better-channels',
id: 'example.better-channels',
image: './images/better.png',
install: './better/toju-plugin.json',
readme: './better/README.md',
title: 'Better Channels',
version: '1.2.0'
}
],
title: 'Example Plugins'
}));
const service = createService(registerLocalManifest, unregister);
await service.addSourceUrl('https://plugins.example.test/index.json#latest');
expect(service.sourceUrls()).toEqual(['https://plugins.example.test/index.json']);
expect(service.sources()[0]?.title).toBe('Example Plugins');
expect(service.availablePlugins()).toEqual([
expect.objectContaining({
author: 'Ada Example',
githubUrl: 'https://github.com/example/better-channels',
id: 'example.better-channels',
imageUrl: 'https://plugins.example.test/images/better.png',
installUrl: 'https://plugins.example.test/better/toju-plugin.json',
readmeUrl: 'https://plugins.example.test/better/README.md',
sourceTitle: 'Example Plugins',
title: 'Better Channels',
version: '1.2.0'
})
]);
});
it('installs, detects updates, and uninstalls store plugins', async () => {
const manifest = createManifest({ version: '1.0.0' });
const plugin = createStoreEntry({ version: '1.0.0' });
fetchMock.mockResolvedValueOnce(jsonResponse(manifest));
const service = createService(registerLocalManifest, unregister);
await service.installPlugin(plugin);
expect(registerLocalManifest).toHaveBeenCalledWith(manifest, plugin.installUrl);
expect(service.installedPlugins()[0]?.manifest.id).toBe(plugin.id);
expect(service.getActionLabel(plugin)).toBe('Uninstall');
expect(service.getActionLabel(createStoreEntry({ version: '1.1.0' }))).toBe('Update');
service.uninstallPlugin(plugin.id);
expect(unregister).toHaveBeenCalledWith(plugin.id);
expect(service.installedPlugins()).toEqual([]);
});
it('loads plugin readmes as markdown text', async () => {
const plugin = createStoreEntry({ readmeUrl: 'https://plugins.example.test/better/README.md' });
fetchMock.mockResolvedValueOnce(textResponse('# Better Channels'));
const service = createService(registerLocalManifest, unregister);
const readme = await service.loadReadme(plugin);
expect(readme).toEqual({
markdown: '# Better Channels',
pluginId: plugin.id,
title: plugin.title,
url: plugin.readmeUrl
});
});
});
function createService(
registerLocalManifest: ReturnType<typeof vi.fn>,
unregister: ReturnType<typeof vi.fn>
): PluginStoreService {
const injector = Injector.create({
providers: [
PluginStoreService,
{
provide: PluginHostService,
useValue: { registerLocalManifest }
},
{
provide: PluginRegistryService,
useValue: { unregister }
}
]
});
return injector.get(PluginStoreService);
}
function createManifest(overrides: Partial<TojuPluginManifest> = {}): TojuPluginManifest {
return {
apiVersion: '1.0.0',
compatibility: {
minimumTojuVersion: '1.0.0'
},
description: 'Adds better channel tools.',
entrypoint: './dist/main.js',
id: 'example.better-channels',
kind: 'client',
schemaVersion: 1,
title: 'Better Channels',
version: '1.0.0',
...overrides
};
}
function createStoreEntry(overrides: Partial<PluginStoreEntry> = {}): PluginStoreEntry {
return {
author: 'Ada Example',
description: 'Adds better channel tools.',
githubUrl: 'https://github.com/example/better-channels',
id: 'example.better-channels',
imageUrl: 'https://plugins.example.test/images/better.png',
installUrl: 'https://plugins.example.test/better/toju-plugin.json',
readmeUrl: 'https://plugins.example.test/better/README.md',
sourceTitle: 'Example Plugins',
sourceUrl: 'https://plugins.example.test/index.json',
title: 'Better Channels',
version: '1.0.0',
...overrides
};
}
function jsonResponse(value: unknown): Response {
return {
json: vi.fn(async () => value),
ok: true,
status: 200,
text: vi.fn(async () => JSON.stringify(value))
} as unknown as Response;
}
function textResponse(value: string): Response {
return {
json: vi.fn(async () => JSON.parse(value) as unknown),
ok: true,
status: 200,
text: vi.fn(async () => value)
} as unknown as Response;
}
function createMemoryStorage(): Storage {
const values = new Map<string, string>();
return {
get length(): number {
return values.size;
},
clear: vi.fn(() => values.clear()),
getItem: vi.fn((key: string) => values.get(key) ?? null),
key: vi.fn((index: number) => Array.from(values.keys())[index] ?? null),
removeItem: vi.fn((key: string) => values.delete(key)),
setItem: vi.fn((key: string, value: string) => values.set(key, value))
};
}

View File

@@ -0,0 +1,453 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Injectable,
computed,
inject,
signal
} from '@angular/core';
import { getUserScopedStorageKey } from '../../../../core/storage/current-user-storage';
import type { TojuPluginManifest } from '../../../../shared-kernel';
import { validateTojuPluginManifest } from '../../domain/logic/plugin-manifest-validation.logic';
import type {
InstalledStorePlugin,
PersistedPluginStoreState,
PluginStoreEntry,
PluginStoreInstallState,
PluginStoreReadme,
PluginStoreSourceResult
} from '../../domain/models/plugin-store.models';
import { PluginHostService } from './plugin-host.service';
import { PluginRegistryService } from './plugin-registry.service';
const STORE_SCHEMA_VERSION = 1;
const STORAGE_KEY_PLUGIN_STORE = 'metoyou_plugin_store';
const DEFAULT_STORE_STATE: PersistedPluginStoreState = {
installedPlugins: [],
sourceUrls: []
};
@Injectable({ providedIn: 'root' })
export class PluginStoreService {
private readonly host = inject(PluginHostService);
private readonly registry = inject(PluginRegistryService);
private readonly sourceUrlsSignal = signal<string[]>([]);
private readonly sourcesSignal = signal<PluginStoreSourceResult[]>([]);
private readonly installedPluginsSignal = signal<InstalledStorePlugin[]>([]);
private readonly loadingSignal = signal(false);
private refreshAbortController: AbortController | null = null;
private refreshVersion = 0;
readonly sourceUrls = this.sourceUrlsSignal.asReadonly();
readonly sources = this.sourcesSignal.asReadonly();
readonly installedPlugins = this.installedPluginsSignal.asReadonly();
readonly isLoading = this.loadingSignal.asReadonly();
readonly availablePlugins = computed(() => this.sources().flatMap((source) => source.plugins));
readonly installedById = computed(() => new Map(this.installedPlugins().map((plugin) => [plugin.manifest.id, plugin])));
constructor() {
const state = this.loadState();
this.sourceUrlsSignal.set(state.sourceUrls);
this.installedPluginsSignal.set(state.installedPlugins);
this.hydrateInstalledPlugins(state.installedPlugins);
}
async addSourceUrl(rawUrl: string): Promise<void> {
const sourceUrl = normalizeRemoteUrl(rawUrl, 'Plugin source URL');
if (this.sourceUrls().includes(sourceUrl)) {
throw new Error('Plugin source already exists');
}
this.sourceUrlsSignal.update((sourceUrls) => [...sourceUrls, sourceUrl]);
this.saveState();
await this.refreshSources();
}
async removeSourceUrl(sourceUrl: string): Promise<void> {
this.sourceUrlsSignal.update((sourceUrls) => sourceUrls.filter((candidate) => candidate !== sourceUrl));
this.sourcesSignal.update((sources) => sources.filter((source) => source.url !== sourceUrl));
this.saveState();
await this.refreshSources();
}
async refreshSources(): Promise<void> {
const currentRefresh = this.refreshVersion + 1;
const abortController = new AbortController();
this.refreshVersion = currentRefresh;
this.refreshAbortController?.abort();
this.refreshAbortController = abortController;
this.loadingSignal.set(true);
try {
const sources = await Promise.all(this.sourceUrls().map((sourceUrl) => this.loadSource(sourceUrl, abortController.signal)));
if (this.refreshVersion === currentRefresh) {
this.sourcesSignal.set(sources);
}
} finally {
if (this.refreshVersion === currentRefresh) {
this.refreshAbortController = null;
this.loadingSignal.set(false);
}
}
}
async installPlugin(plugin: PluginStoreEntry): Promise<InstalledStorePlugin> {
if (!plugin.installUrl) {
throw new Error('Plugin does not provide an install manifest URL');
}
const manifest = await this.fetchPluginManifest(plugin.installUrl);
const registered = this.host.registerLocalManifest(manifest, plugin.installUrl);
const now = Date.now();
const existing = this.installedById().get(registered.manifest.id);
const installedPlugin: InstalledStorePlugin = {
installedAt: existing?.installedAt ?? now,
installUrl: plugin.installUrl,
manifest: registered.manifest,
sourceUrl: plugin.sourceUrl,
updatedAt: now
};
this.installedPluginsSignal.update((installedPlugins) => {
const existingPlugins = installedPlugins.filter((candidate) => candidate.manifest.id !== registered.manifest.id);
return [...existingPlugins, installedPlugin].sort(sortInstalledPlugins);
});
this.saveState();
return installedPlugin;
}
uninstallPlugin(pluginId: string): void {
this.registry.unregister(pluginId);
this.installedPluginsSignal.update((installedPlugins) =>
installedPlugins.filter((installedPlugin) => installedPlugin.manifest.id !== pluginId)
);
this.saveState();
}
async loadReadme(plugin: PluginStoreEntry): Promise<PluginStoreReadme> {
if (!plugin.readmeUrl) {
throw new Error('Plugin does not provide a readme URL');
}
const response = await fetch(plugin.readmeUrl, { headers: { Accept: 'text/markdown,text/plain,*/*' } });
if (!response.ok) {
throw new Error(`Unable to load readme (${response.status})`);
}
return {
markdown: await response.text(),
pluginId: plugin.id,
title: plugin.title,
url: plugin.readmeUrl
};
}
getInstallState(plugin: PluginStoreEntry): PluginStoreInstallState {
const installed = this.installedById().get(plugin.id);
if (!installed) {
return 'notInstalled';
}
return compareVersions(plugin.version, installed.manifest.version) > 0
? 'updateAvailable'
: 'installed';
}
getActionLabel(plugin: PluginStoreEntry): 'Install' | 'Uninstall' | 'Update' {
const state = this.getInstallState(plugin);
if (state === 'updateAvailable') {
return 'Update';
}
return state === 'installed' ? 'Uninstall' : 'Install';
}
private async loadSource(sourceUrl: string, signal: AbortSignal): Promise<PluginStoreSourceResult> {
try {
const response = await fetch(sourceUrl, { headers: { Accept: 'application/json' }, signal });
if (!response.ok) {
throw new Error(`Source returned ${response.status}`);
}
const sourceValue = await response.json() as unknown;
return parsePluginSource(sourceUrl, sourceValue);
} catch (error) {
return {
error: error instanceof Error ? error.message : 'Unable to load plugin source',
plugins: [],
url: sourceUrl
};
}
}
private async fetchPluginManifest(manifestUrl: string): Promise<TojuPluginManifest> {
const response = await fetch(manifestUrl, { headers: { Accept: 'application/json' } });
if (!response.ok) {
throw new Error(`Install manifest returned ${response.status}`);
}
const manifestValue = await response.json() as unknown;
const validation = validateTojuPluginManifest(manifestValue);
if (!validation.manifest) {
throw new Error(validation.issues.map((issue) => `${issue.path}: ${issue.message}`).join('\n'));
}
return validation.manifest;
}
private hydrateInstalledPlugins(installedPlugins: InstalledStorePlugin[]): void {
const usableInstalledPlugins: InstalledStorePlugin[] = [];
for (const installedPlugin of installedPlugins) {
try {
this.host.registerLocalManifest(installedPlugin.manifest, installedPlugin.installUrl);
usableInstalledPlugins.push(installedPlugin);
} catch {
// Corrupt persisted manifests are ignored so the store can recover on next install.
}
}
if (usableInstalledPlugins.length !== installedPlugins.length) {
this.installedPluginsSignal.set(usableInstalledPlugins);
this.saveState();
}
}
private loadState(): PersistedPluginStoreState {
try {
const raw = localStorage.getItem(getUserScopedStorageKey(STORAGE_KEY_PLUGIN_STORE));
if (!raw) {
return { ...DEFAULT_STORE_STATE };
}
return normalizePersistedState(JSON.parse(raw) as unknown);
} catch {
return { ...DEFAULT_STORE_STATE };
}
}
private saveState(): void {
const state = {
installedPlugins: this.installedPlugins(),
schemaVersion: STORE_SCHEMA_VERSION,
sourceUrls: this.sourceUrls()
};
try {
localStorage.setItem(getUserScopedStorageKey(STORAGE_KEY_PLUGIN_STORE), JSON.stringify(state));
} catch {}
}
}
function parsePluginSource(sourceUrl: string, sourceValue: unknown): PluginStoreSourceResult {
const sourceRecord = isRecord(sourceValue) ? sourceValue : {};
const sourceTitle = readString(sourceRecord, 'title', 'name') ?? new URL(sourceUrl).hostname;
const rawPlugins = Array.isArray(sourceValue)
? sourceValue
: Array.isArray(sourceRecord['plugins'])
? sourceRecord['plugins']
: Array.isArray(sourceRecord['items'])
? sourceRecord['items']
: [];
const plugins = rawPlugins
.map((entry) => parsePluginEntry(sourceUrl, sourceTitle, entry))
.filter((entry): entry is PluginStoreEntry => !!entry)
.sort((left, right) => left.title.localeCompare(right.title));
return {
loadedAt: Date.now(),
plugins,
title: sourceTitle,
url: sourceUrl
};
}
function sortInstalledPlugins(left: InstalledStorePlugin, right: InstalledStorePlugin): number {
return left.manifest.title.localeCompare(right.manifest.title);
}
function parsePluginEntry(sourceUrl: string, sourceTitle: string, value: unknown): PluginStoreEntry | null {
if (!isRecord(value)) {
return null;
}
const id = readString(value, 'id', 'pluginId');
const version = readString(value, 'version') ?? '0.0.0';
if (!id) {
return null;
}
return {
author: readAuthor(value),
description: readString(value, 'description', 'summary') ?? '',
githubUrl: resolveOptionalUrl(sourceUrl, readGithubUrl(value)),
homepageUrl: resolveOptionalUrl(sourceUrl, readString(value, 'homepage', 'homepageUrl', 'website')),
id,
imageUrl: resolveOptionalUrl(sourceUrl, readString(value, 'image', 'imageUrl', 'icon', 'iconUrl', 'banner')),
installUrl: resolveOptionalUrl(sourceUrl, readString(value, 'install', 'installUrl', 'manifest', 'manifestUrl')),
readmeUrl: resolveOptionalUrl(sourceUrl, readString(value, 'readme', 'readmeUrl')),
sourceTitle,
sourceUrl,
title: readString(value, 'title', 'name') ?? id,
version
};
}
function normalizePersistedState(value: unknown): PersistedPluginStoreState {
if (!isRecord(value)) {
return { ...DEFAULT_STORE_STATE };
}
return {
installedPlugins: Array.isArray(value['installedPlugins'])
? value['installedPlugins'].filter(isInstalledStorePlugin)
: [],
sourceUrls: Array.isArray(value['sourceUrls'])
? value['sourceUrls']
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => normalizeOptionalRemoteUrl(entry))
.filter((entry): entry is string => !!entry)
: []
};
}
function isInstalledStorePlugin(value: unknown): value is InstalledStorePlugin {
if (!isRecord(value) || !isRecord(value['manifest'])) {
return false;
}
const validation = validateTojuPluginManifest(value['manifest']);
return !!validation.manifest;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function readString(record: Record<string, unknown>, ...keys: string[]): string | undefined {
for (const key of keys) {
const value = record[key];
if (typeof value === 'string' && value.trim()) {
return value.trim();
}
}
return undefined;
}
function readAuthor(record: Record<string, unknown>): string | undefined {
const author = readString(record, 'author');
if (author) {
return author;
}
const authors = record['authors'];
if (!Array.isArray(authors)) {
return undefined;
}
return authors
.map((entry) => isRecord(entry) ? readString(entry, 'name') : typeof entry === 'string' ? entry.trim() : '')
.filter(Boolean)
.join(', ') || undefined;
}
function readGithubUrl(record: Record<string, unknown>): string | undefined {
const directUrl = readString(record, 'github', 'githubUrl');
if (directUrl) {
return directUrl;
}
const repository = record['repository'];
return isRecord(repository) ? readString(repository, 'url') : typeof repository === 'string' ? repository.trim() : undefined;
}
function normalizeRemoteUrl(rawUrl: string, label: string): string {
const url = normalizeOptionalRemoteUrl(rawUrl);
if (!url) {
throw new Error(`${label} must be an http or https URL`);
}
return url;
}
function normalizeOptionalRemoteUrl(rawUrl: string): string | undefined {
try {
const url = new URL(rawUrl.trim());
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return undefined;
}
url.hash = '';
return url.toString();
} catch {
return undefined;
}
}
function resolveOptionalUrl(sourceUrl: string, rawUrl?: string): string | undefined {
if (!rawUrl) {
return undefined;
}
try {
const url = new URL(rawUrl, sourceUrl);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return undefined;
}
url.hash = '';
return url.toString();
} catch {
return undefined;
}
}
function compareVersions(leftVersion: string, rightVersion: string): number {
const leftParts = parseVersion(leftVersion);
const rightParts = parseVersion(rightVersion);
for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) {
const leftPart = leftParts[index] ?? 0;
const rightPart = rightParts[index] ?? 0;
if (leftPart !== rightPart) {
return leftPart - rightPart;
}
}
return leftVersion.localeCompare(rightVersion);
}
function parseVersion(version: string): number[] {
return version
.split(/[.+-]/)
.slice(0, 3)
.map((part) => Number.parseInt(part, 10))
.map((part) => Number.isFinite(part) ? part : 0);
}

View File

@@ -0,0 +1,237 @@
import {
Injectable,
Signal,
computed,
signal
} from '@angular/core';
import type {
PluginApiActionContribution,
PluginApiChannelSectionContribution,
PluginApiDomMountRequest,
PluginApiEmbedRendererContribution,
PluginApiPageContribution,
PluginApiPanelContribution,
PluginApiSettingsPageContribution,
PluginApiUiContributionMap,
TojuPluginDisposable
} from '../../domain/models/plugin-api.models';
type ContributionKind = keyof PluginApiUiContributionMap;
export interface PluginUiContributionRecord<TContribution> {
contribution: TContribution;
contributionKey: string;
id: string;
pluginId: string;
}
export interface PluginUiConflictDiagnostic {
contributionId: string;
kind: ContributionKind;
pluginIds: string[];
}
interface PluginDomMountRecord {
element: HTMLElement;
id: string;
pluginId: string;
}
@Injectable({ providedIn: 'root' })
export class PluginUiRegistryService {
readonly appPages = this.createContributionSignal('appPages');
readonly appPageRecords = this.createContributionRecordSignal('appPages');
readonly channelSections = this.createContributionSignal('channelSections');
readonly channelSectionRecords = this.createContributionRecordSignal('channelSections');
readonly composerActions = this.createContributionSignal('composerActions');
readonly composerActionRecords = this.createContributionRecordSignal('composerActions');
readonly embeds = this.createContributionSignal('embeds');
readonly embedRecords = this.createContributionRecordSignal('embeds');
readonly profileActions = this.createContributionSignal('profileActions');
readonly profileActionRecords = this.createContributionRecordSignal('profileActions');
readonly settingsPages = this.createContributionSignal('settingsPages');
readonly settingsPageRecords = this.createContributionRecordSignal('settingsPages');
readonly sidePanels = this.createContributionSignal('sidePanels');
readonly sidePanelRecords = this.createContributionRecordSignal('sidePanels');
readonly toolbarActions = this.createContributionSignal('toolbarActions');
readonly toolbarActionRecords = this.createContributionRecordSignal('toolbarActions');
readonly conflicts = computed(() => this.collectConflicts());
private readonly domMounts = new Map<string, PluginDomMountRecord>();
private readonly contributionsSignal = signal<{
appPages: PluginUiContributionRecord<PluginApiPageContribution>[];
channelSections: PluginUiContributionRecord<PluginApiChannelSectionContribution>[];
composerActions: PluginUiContributionRecord<PluginApiActionContribution>[];
embeds: PluginUiContributionRecord<PluginApiEmbedRendererContribution>[];
profileActions: PluginUiContributionRecord<PluginApiActionContribution>[];
settingsPages: PluginUiContributionRecord<PluginApiSettingsPageContribution>[];
sidePanels: PluginUiContributionRecord<PluginApiPanelContribution>[];
toolbarActions: PluginUiContributionRecord<PluginApiActionContribution>[];
}>({
appPages: [],
channelSections: [],
composerActions: [],
embeds: [],
profileActions: [],
settingsPages: [],
sidePanels: [],
toolbarActions: []
});
registerAppPage(pluginId: string, id: string, contribution: PluginApiPageContribution): TojuPluginDisposable {
return this.register('appPages', pluginId, id, contribution);
}
registerChannelSection(pluginId: string, id: string, contribution: PluginApiChannelSectionContribution): TojuPluginDisposable {
return this.register('channelSections', pluginId, id, contribution);
}
registerComposerAction(pluginId: string, id: string, contribution: PluginApiActionContribution): TojuPluginDisposable {
return this.register('composerActions', pluginId, id, contribution);
}
registerEmbedRenderer(pluginId: string, id: string, contribution: PluginApiEmbedRendererContribution): TojuPluginDisposable {
return this.register('embeds', pluginId, id, contribution);
}
mountElement(pluginId: string, id: string, request: PluginApiDomMountRequest): TojuPluginDisposable {
const mountId = `${pluginId}:${id}`;
const target = this.resolveMountTarget(request.target);
if (!target) {
throw new Error(`Plugin mount target not found: ${typeof request.target === 'string' ? request.target : request.target.tagName}`);
}
this.unmountElement(mountId);
request.element.dataset['pluginOwner'] = pluginId;
request.element.dataset['pluginMountId'] = mountId;
target.insertAdjacentElement(request.position ?? 'beforeend', request.element);
this.domMounts.set(mountId, { element: request.element, id: mountId, pluginId });
return {
dispose: () => this.unmountElement(mountId)
};
}
registerProfileAction(pluginId: string, id: string, contribution: PluginApiActionContribution): TojuPluginDisposable {
return this.register('profileActions', pluginId, id, contribution);
}
registerSettingsPage(pluginId: string, id: string, contribution: PluginApiSettingsPageContribution): TojuPluginDisposable {
return this.register('settingsPages', pluginId, id, contribution);
}
registerSidePanel(pluginId: string, id: string, contribution: PluginApiPanelContribution): TojuPluginDisposable {
return this.register('sidePanels', pluginId, id, contribution);
}
registerToolbarAction(pluginId: string, id: string, contribution: PluginApiActionContribution): TojuPluginDisposable {
return this.register('toolbarActions', pluginId, id, contribution);
}
unregisterPlugin(pluginId: string): void {
for (const mount of this.domMounts.values()) {
if (mount.pluginId === pluginId) {
this.unmountElement(mount.id);
}
}
this.contributionsSignal.update((current) => ({
appPages: current.appPages.filter((entry) => entry.pluginId !== pluginId),
channelSections: current.channelSections.filter((entry) => entry.pluginId !== pluginId),
composerActions: current.composerActions.filter((entry) => entry.pluginId !== pluginId),
embeds: current.embeds.filter((entry) => entry.pluginId !== pluginId),
profileActions: current.profileActions.filter((entry) => entry.pluginId !== pluginId),
settingsPages: current.settingsPages.filter((entry) => entry.pluginId !== pluginId),
sidePanels: current.sidePanels.filter((entry) => entry.pluginId !== pluginId),
toolbarActions: current.toolbarActions.filter((entry) => entry.pluginId !== pluginId)
}));
}
private register<TKind extends ContributionKind>(
kind: TKind,
pluginId: string,
id: string,
contribution: PluginApiUiContributionMap[TKind][number]
): TojuPluginDisposable {
const contributionId = `${pluginId}:${id}`;
this.contributionsSignal.update((current) => ({
...current,
[kind]: [
...current[kind].filter((entry) => entry.id !== contributionId),
{
contribution,
contributionKey: id,
id: contributionId,
pluginId
}
]
}));
return {
dispose: () => this.unregister(kind, contributionId)
};
}
private unregister(kind: ContributionKind, contributionId: string): void {
this.contributionsSignal.update((current) => ({
...current,
[kind]: current[kind].filter((entry) => entry.id !== contributionId)
}));
}
private resolveMountTarget(target: Element | string): Element | null {
return typeof target === 'string'
? document.querySelector(target)
: target;
}
private unmountElement(mountId: string): void {
const mount = this.domMounts.get(mountId);
if (!mount) {
return;
}
mount.element.remove();
this.domMounts.delete(mountId);
}
private createContributionSignal<TKind extends ContributionKind>(kind: TKind): Signal<PluginApiUiContributionMap[TKind]> {
return computed(() => this.contributionsSignal()[kind].map((entry) => entry.contribution) as PluginApiUiContributionMap[TKind]);
}
private createContributionRecordSignal<TKind extends ContributionKind>(
kind: TKind
): Signal<PluginUiContributionRecord<PluginApiUiContributionMap[TKind][number]>[]> {
return computed(() => this.contributionsSignal()[kind] as PluginUiContributionRecord<PluginApiUiContributionMap[TKind][number]>[]);
}
private collectConflicts(): PluginUiConflictDiagnostic[] {
const conflicts: PluginUiConflictDiagnostic[] = [];
for (const kind of Object.keys(this.contributionsSignal()) as ContributionKind[]) {
const byKey = new Map<string, Set<string>>();
for (const entry of this.contributionsSignal()[kind]) {
const pluginIds = byKey.get(entry.contributionKey) ?? new Set<string>();
pluginIds.add(entry.pluginId);
byKey.set(entry.contributionKey, pluginIds);
}
for (const [contributionId, pluginIds] of byKey.entries()) {
if (pluginIds.size > 1) {
conflicts.push({
contributionId,
kind,
pluginIds: Array.from(pluginIds).sort()
});
}
}
}
return conflicts;
}
}

View File

@@ -0,0 +1,43 @@
import type { TojuPluginManifest } from '../../../shared-kernel';
import type { TojuClientPluginModule } from '../domain/models/plugin-api.models';
export const DEVELOPMENT_PLUGIN_ENTRYPOINT = 'toju:development-plugin';
export const DEVELOPMENT_PLUGIN_MANIFEST: TojuPluginManifest = {
apiVersion: '1.0.0',
capabilities: [],
compatibility: {
minimumTojuVersion: '1.0.0',
verifiedTojuVersion: '1.0.0'
},
description: 'Built-in development-only plugin for validating the local plugin runtime.',
entrypoint: DEVELOPMENT_PLUGIN_ENTRYPOINT,
homepage: 'https://localhost:4200',
id: 'metoyou.development-plugin',
kind: 'client',
readme: 'Only registered when the Angular app is running with environment.production=false.',
schemaVersion: 1,
settings: {
properties: {
enabled: {
default: true,
type: 'boolean'
}
},
type: 'object'
},
title: 'Development Plugin',
version: '0.0.0-dev'
};
export const DEVELOPMENT_PLUGIN_MODULE: TojuClientPluginModule = {
activate: (context) => {
context.api.logger.info('Development plugin activated');
},
deactivate: (context) => {
context.api.logger.info('Development plugin deactivated');
},
ready: (context) => {
context.api.logger.info('Development plugin ready');
}
};

View File

@@ -0,0 +1,82 @@
import type { TojuPluginManifest } from '../../../../shared-kernel';
import { resolvePluginLoadOrder } from './plugin-dependency-resolver.logic';
function manifest(id: string, overrides: Partial<TojuPluginManifest> = {}): TojuPluginManifest {
return {
apiVersion: '1.0.0',
compatibility: {
minimumTojuVersion: '1.0.0'
},
description: `${id} plugin`,
entrypoint: './main.js',
id,
kind: 'client',
schemaVersion: 1,
title: id,
version: '1.0.0',
...overrides
};
}
describe('plugin dependency resolver', () => {
it('orders required dependencies before dependants', () => {
const featurePlugin = manifest('feature.chat', { relationships: { requires: [{ id: 'library.base' }] } });
const result = resolvePluginLoadOrder([{ manifest: featurePlugin }, { manifest: manifest('library.base') }]);
expect(result.blocked).toEqual([]);
expect(result.ordered.map((entry) => entry.id)).toEqual(['library.base', 'feature.chat']);
});
it('uses priority then plugin id for otherwise independent plugins', () => {
const result = resolvePluginLoadOrder([
{ manifest: manifest('plugin.zed') },
{ manifest: manifest('plugin.bootstrap', { load: { priority: 'bootstrap' } }) },
{ manifest: manifest('plugin.alpha') }
]);
expect(result.ordered.map((entry) => entry.id)).toEqual([
'plugin.bootstrap',
'plugin.alpha',
'plugin.zed'
]);
});
it('blocks missing dependencies and leaves valid plugins loadable', () => {
const blockedPlugin = manifest('plugin.blocked', { relationships: { requires: [{ id: 'missing.library' }] } });
const result = resolvePluginLoadOrder([{ manifest: manifest('plugin.valid') }, { manifest: blockedPlugin }]);
expect(result.ordered.map((entry) => entry.id)).toEqual(['plugin.valid']);
expect(result.blocked).toContainEqual({
message: 'Missing required plugin missing.library',
pluginId: 'plugin.blocked',
reason: 'missingDependency'
});
});
it('detects duplicate ids and cycles', () => {
const result = resolvePluginLoadOrder([
{ manifest: manifest('plugin.duplicate') },
{ manifest: manifest('plugin.duplicate') },
{ manifest: manifest('plugin.a', { relationships: { after: ['plugin.b'] } }) },
{ manifest: manifest('plugin.b', { relationships: { after: ['plugin.a'] } }) }
]);
expect(result.blocked).toEqual(expect.arrayContaining([
{
message: 'Duplicate plugin id',
pluginId: 'plugin.duplicate',
reason: 'duplicate'
},
{
message: 'Plugin load order contains a cycle',
pluginId: 'plugin.a',
reason: 'cycle'
},
{
message: 'Plugin load order contains a cycle',
pluginId: 'plugin.b',
reason: 'cycle'
}
]));
});
});

View File

@@ -0,0 +1,251 @@
import type { TojuPluginManifest } from '../../../../shared-kernel';
import type {
PluginLoadBlocker,
PluginLoadCandidate,
PluginLoadOrderResult
} from '../models/plugin-runtime.models';
const PRIORITY_WEIGHT: Record<string, number> = {
bootstrap: 0,
high: 1,
default: 2,
low: 3
};
interface PluginLoadGraph {
edges: Map<string, Set<string>>;
inboundCounts: Map<string, number>;
}
function priorityWeight(manifest: TojuPluginManifest): number {
return PRIORITY_WEIGHT[manifest.load?.priority ?? 'default'] ?? PRIORITY_WEIGHT['default'];
}
function sortManifests(firstManifest: TojuPluginManifest, secondManifest: TojuPluginManifest): number {
const firstPriority = priorityWeight(firstManifest);
const secondPriority = priorityWeight(secondManifest);
if (firstPriority !== secondPriority) {
return firstPriority - secondPriority;
}
return firstManifest.id.localeCompare(secondManifest.id);
}
function addEdge(edges: Map<string, Set<string>>, fromPluginId: string, toPluginId: string): void {
const targets = edges.get(fromPluginId) ?? new Set<string>();
targets.add(toPluginId);
edges.set(fromPluginId, targets);
}
function addBlocker(blocked: PluginLoadBlocker[], pluginId: string, reason: PluginLoadBlocker['reason'], message: string): void {
blocked.push({ pluginId, reason, message });
}
function collectManifests(
candidates: readonly PluginLoadCandidate[],
blocked: PluginLoadBlocker[]
): Map<string, TojuPluginManifest> {
const manifestsById = new Map<string, TojuPluginManifest>();
for (const candidate of candidates) {
if (candidate.enabled === false) {
addBlocker(blocked, candidate.manifest.id, 'disabled', 'Plugin is disabled');
continue;
}
if (manifestsById.has(candidate.manifest.id)) {
addBlocker(blocked, candidate.manifest.id, 'duplicate', 'Duplicate plugin id');
continue;
}
manifestsById.set(candidate.manifest.id, candidate.manifest);
}
return manifestsById;
}
function createLoadGraph(manifestsById: Map<string, TojuPluginManifest>): PluginLoadGraph {
const graph: PluginLoadGraph = {
edges: new Map<string, Set<string>>(),
inboundCounts: new Map<string, number>()
};
for (const pluginId of manifestsById.keys()) {
graph.edges.set(pluginId, new Set<string>());
graph.inboundCounts.set(pluginId, 0);
}
return graph;
}
function addRequiredEdges(
manifest: TojuPluginManifest,
manifestsById: Map<string, TojuPluginManifest>,
edges: Map<string, Set<string>>,
blocked: PluginLoadBlocker[]
): void {
for (const required of manifest.relationships?.requires ?? []) {
if (!manifestsById.has(required.id)) {
addBlocker(blocked, manifest.id, 'missingDependency', `Missing required plugin ${required.id}`);
continue;
}
addEdge(edges, required.id, manifest.id);
}
}
function addOrderingEdges(
manifest: TojuPluginManifest,
manifestsById: Map<string, TojuPluginManifest>,
edges: Map<string, Set<string>>
): void {
for (const afterPluginId of manifest.relationships?.after ?? []) {
if (manifestsById.has(afterPluginId)) {
addEdge(edges, afterPluginId, manifest.id);
}
}
for (const beforePluginId of manifest.relationships?.before ?? []) {
if (manifestsById.has(beforePluginId)) {
addEdge(edges, manifest.id, beforePluginId);
}
}
}
function addConflictBlockers(
manifest: TojuPluginManifest,
manifestsById: Map<string, TojuPluginManifest>,
blocked: PluginLoadBlocker[]
): void {
for (const conflictPluginId of manifest.relationships?.conflicts ?? []) {
if (manifestsById.has(conflictPluginId)) {
addBlocker(blocked, manifest.id, 'conflict', `Conflicts with plugin ${conflictPluginId}`);
}
}
}
function applyRelationships(
manifestsById: Map<string, TojuPluginManifest>,
edges: Map<string, Set<string>>,
blocked: PluginLoadBlocker[]
): void {
for (const manifest of manifestsById.values()) {
addRequiredEdges(manifest, manifestsById, edges, blocked);
addOrderingEdges(manifest, manifestsById, edges);
addConflictBlockers(manifest, manifestsById, blocked);
}
}
function countInboundEdges(graph: PluginLoadGraph, blockedIds: Set<string>): void {
for (const [fromPluginId, targets] of graph.edges.entries()) {
if (blockedIds.has(fromPluginId)) {
continue;
}
for (const targetPluginId of targets) {
if (!blockedIds.has(targetPluginId)) {
graph.inboundCounts.set(targetPluginId, (graph.inboundCounts.get(targetPluginId) ?? 0) + 1);
}
}
}
}
function getInitialReadyManifests(
manifestsById: Map<string, TojuPluginManifest>,
inboundCounts: Map<string, number>,
blockedIds: Set<string>
): TojuPluginManifest[] {
return Array.from(manifestsById.values())
.filter((manifest) => !blockedIds.has(manifest.id) && (inboundCounts.get(manifest.id) ?? 0) === 0)
.sort(sortManifests);
}
function pushReadyManifest(
ready: TojuPluginManifest[],
manifestsById: Map<string, TojuPluginManifest>,
pluginId: string
): void {
const targetManifest = manifestsById.get(pluginId);
if (targetManifest) {
ready.push(targetManifest);
ready.sort(sortManifests);
}
}
function consumeReadyManifest(
manifest: TojuPluginManifest,
graph: PluginLoadGraph,
manifestsById: Map<string, TojuPluginManifest>,
ready: TojuPluginManifest[],
blockedIds: Set<string>
): void {
for (const targetPluginId of graph.edges.get(manifest.id) ?? []) {
if (blockedIds.has(targetPluginId)) {
continue;
}
const nextInboundCount = Math.max(0, (graph.inboundCounts.get(targetPluginId) ?? 0) - 1);
graph.inboundCounts.set(targetPluginId, nextInboundCount);
if (nextInboundCount === 0) {
pushReadyManifest(ready, manifestsById, targetPluginId);
}
}
}
function buildOrderedManifests(
graph: PluginLoadGraph,
manifestsById: Map<string, TojuPluginManifest>,
blockedIds: Set<string>
): TojuPluginManifest[] {
const ready = getInitialReadyManifests(manifestsById, graph.inboundCounts, blockedIds);
const ordered: TojuPluginManifest[] = [];
while (ready.length > 0) {
const nextManifest = ready.shift();
if (!nextManifest) {
break;
}
ordered.push(nextManifest);
consumeReadyManifest(nextManifest, graph, manifestsById, ready, blockedIds);
}
return ordered;
}
function addCycleBlockers(
manifestsById: Map<string, TojuPluginManifest>,
ordered: TojuPluginManifest[],
blockedIds: Set<string>,
blocked: PluginLoadBlocker[]
): void {
const orderedIds = new Set(ordered.map((manifest) => manifest.id));
for (const manifest of manifestsById.values()) {
if (!blockedIds.has(manifest.id) && !orderedIds.has(manifest.id)) {
addBlocker(blocked, manifest.id, 'cycle', 'Plugin load order contains a cycle');
}
}
}
export function resolvePluginLoadOrder(candidates: readonly PluginLoadCandidate[]): PluginLoadOrderResult {
const blocked: PluginLoadBlocker[] = [];
const manifestsById = collectManifests(candidates, blocked);
const graph = createLoadGraph(manifestsById);
applyRelationships(manifestsById, graph.edges, blocked);
const blockedIds = new Set(blocked.map((blocker) => blocker.pluginId));
countInboundEdges(graph, blockedIds);
const ordered = buildOrderedManifests(graph, manifestsById, blockedIds);
addCycleBlockers(manifestsById, ordered, blockedIds, blocked);
return { blocked, ordered };
}

View File

@@ -0,0 +1,86 @@
import type { TojuPluginManifest } from '../../../../shared-kernel';
import { isKnownPluginCapability, validateTojuPluginManifest } from './plugin-manifest-validation.logic';
function createManifest(overrides: Partial<TojuPluginManifest> = {}): TojuPluginManifest {
return {
apiVersion: '1.0.0',
compatibility: {
minimumTojuVersion: '1.0.0'
},
description: 'Adds test behavior.',
entrypoint: './main.js',
id: 'test.plugin',
kind: 'client',
schemaVersion: 1,
title: 'Test Plugin',
version: '1.2.3',
...overrides
};
}
describe('plugin manifest validation', () => {
it('accepts a valid client plugin manifest', () => {
const result = validateTojuPluginManifest(createManifest({
capabilities: ['messages.send', 'ui.settings'],
events: [
{
direction: 'serverRelay',
eventName: 'test:ping',
scope: 'server'
}
]
}));
expect(result.valid).toBe(true);
expect(result.manifest?.id).toBe('test.plugin');
expect(result.issues).toEqual([]);
});
it('rejects executable client manifests without an entrypoint', () => {
const manifest = createManifest({ entrypoint: undefined });
const result = validateTojuPluginManifest(manifest);
expect(result.valid).toBe(false);
expect(result.manifest).toBeUndefined();
expect(result.issues).toContainEqual({
message: 'client plugins require an entrypoint',
path: 'entrypoint',
severity: 'error'
});
});
it('allows library manifests without an entrypoint', () => {
const result = validateTojuPluginManifest(createManifest({
entrypoint: undefined,
kind: 'library'
}));
expect(result.valid).toBe(true);
});
it('rejects unknown capabilities and event dimensions', () => {
const result = validateTojuPluginManifest({
...createManifest(),
capabilities: ['messages.send', 'unknown.power'],
events: [
{
direction: 'serverMagic',
eventName: 'bad-event',
scope: 'cosmos'
}
]
});
expect(result.valid).toBe(false);
expect(result.issues.map((issue) => issue.path)).toEqual(expect.arrayContaining([
'capabilities.1',
'events.0.direction',
'events.0.scope'
]));
});
it('narrows known plugin capabilities', () => {
expect(isKnownPluginCapability('messages.send')).toBe(true);
expect(isKnownPluginCapability('messages.destroyEverything')).toBe(false);
});
});

View File

@@ -0,0 +1,204 @@
import {
PLUGIN_CAPABILITIES,
PLUGIN_EVENT_DIRECTIONS,
PLUGIN_EVENT_SCOPES,
type PluginCapabilityId,
type TojuPluginManifest
} from '../../../../shared-kernel';
import type { PluginManifestValidationResult, PluginValidationIssue } from '../models/plugin-runtime.models';
const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9.-]{1,126}[a-z0-9]$/;
const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
const capabilitySet = new Set<string>(PLUGIN_CAPABILITIES);
const eventDirectionSet = new Set<string>(PLUGIN_EVENT_DIRECTIONS);
const eventScopeSet = new Set<string>(PLUGIN_EVENT_SCOPES);
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function readString(record: Record<string, unknown>, key: string): string | null {
const value = record[key];
return typeof value === 'string' ? value.trim() : null;
}
function pushIssue(
issues: PluginValidationIssue[],
path: string,
message: string,
severity: PluginValidationIssue['severity'] = 'error'
): void {
issues.push({ path, message, severity });
}
function validateStringField(
issues: PluginValidationIssue[],
record: Record<string, unknown>,
key: string,
options?: { pattern?: RegExp; required?: boolean }
): void {
const value = readString(record, key);
if (!value) {
if (options?.required !== false) {
pushIssue(issues, key, `${key} is required`);
}
return;
}
if (options?.pattern && !options.pattern.test(value)) {
pushIssue(issues, key, `${key} has an invalid format`);
}
}
function validateStringArray(
issues: PluginValidationIssue[],
value: unknown,
path: string
): void {
if (value === undefined) {
return;
}
if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string' || !entry.trim())) {
pushIssue(issues, path, `${path} must be an array of non-empty strings`);
}
}
function validateRelationships(issues: PluginValidationIssue[], manifestRecord: Record<string, unknown>): void {
const relationships = manifestRecord['relationships'];
if (relationships === undefined) {
return;
}
if (!isRecord(relationships)) {
pushIssue(issues, 'relationships', 'relationships must be an object');
return;
}
validateStringArray(issues, relationships['after'], 'relationships.after');
validateStringArray(issues, relationships['before'], 'relationships.before');
validateStringArray(issues, relationships['conflicts'], 'relationships.conflicts');
for (const key of ['requires', 'optional'] as const) {
const entries = relationships[key];
if (entries === undefined) {
continue;
}
if (!Array.isArray(entries)) {
pushIssue(issues, `relationships.${key}`, `relationships.${key} must be an array`);
continue;
}
entries.forEach((entry, index) => {
if (!isRecord(entry) || typeof entry['id'] !== 'string' || !entry['id'].trim()) {
pushIssue(issues, `relationships.${key}.${index}`, 'dependency id is required');
}
});
}
}
function validateCapabilities(issues: PluginValidationIssue[], manifestRecord: Record<string, unknown>): void {
const capabilities = manifestRecord['capabilities'];
if (capabilities === undefined) {
return;
}
if (!Array.isArray(capabilities)) {
pushIssue(issues, 'capabilities', 'capabilities must be an array');
return;
}
capabilities.forEach((capability, index) => {
if (typeof capability !== 'string' || !capabilitySet.has(capability)) {
pushIssue(issues, `capabilities.${index}`, `Unknown capability ${String(capability)}`);
}
});
}
function validateEvents(issues: PluginValidationIssue[], manifestRecord: Record<string, unknown>): void {
const events = manifestRecord['events'];
if (events === undefined) {
return;
}
if (!Array.isArray(events)) {
pushIssue(issues, 'events', 'events must be an array');
return;
}
events.forEach((event, index) => {
if (!isRecord(event)) {
pushIssue(issues, `events.${index}`, 'event must be an object');
return;
}
if (typeof event['eventName'] !== 'string' || !event['eventName'].trim()) {
pushIssue(issues, `events.${index}.eventName`, 'eventName is required');
}
if (typeof event['direction'] !== 'string' || !eventDirectionSet.has(event['direction'])) {
pushIssue(issues, `events.${index}.direction`, 'direction is invalid');
}
if (typeof event['scope'] !== 'string' || !eventScopeSet.has(event['scope'])) {
pushIssue(issues, `events.${index}.scope`, 'scope is invalid');
}
});
}
export function validateTojuPluginManifest(value: unknown): PluginManifestValidationResult {
const issues: PluginValidationIssue[] = [];
if (!isRecord(value)) {
return {
issues: [{ path: '', message: 'Manifest must be an object', severity: 'error' }],
valid: false
};
}
validateStringField(issues, value, 'id', { pattern: PLUGIN_ID_PATTERN });
validateStringField(issues, value, 'title');
validateStringField(issues, value, 'description');
validateStringField(issues, value, 'version', { pattern: VERSION_PATTERN });
validateStringField(issues, value, 'apiVersion');
if (value['schemaVersion'] !== 1) {
pushIssue(issues, 'schemaVersion', 'schemaVersion must be 1');
}
if (value['kind'] !== 'client' && value['kind'] !== 'library') {
pushIssue(issues, 'kind', 'kind must be client or library');
}
if (!isRecord(value['compatibility'])) {
pushIssue(issues, 'compatibility', 'compatibility is required');
} else {
validateStringField(issues, value['compatibility'], 'minimumTojuVersion');
}
if (typeof value['entrypoint'] !== 'string' && value['kind'] === 'client') {
pushIssue(issues, 'entrypoint', 'client plugins require an entrypoint');
}
validateCapabilities(issues, value);
validateRelationships(issues, value);
validateEvents(issues, value);
return {
issues,
manifest: issues.some((issue) => issue.severity === 'error') ? undefined : value as unknown as TojuPluginManifest,
valid: !issues.some((issue) => issue.severity === 'error')
};
}
export function isKnownPluginCapability(value: string): value is PluginCapabilityId {
return capabilitySet.has(value);
}

View File

@@ -0,0 +1,226 @@
import type {
Channel,
Message,
PluginEventEnvelope,
PluginRequirementsSnapshot,
Room,
RoomMember,
RoomPermissions,
RoomRole,
RoomRoleAssignment,
TojuPluginManifest,
User
} from '../../../../shared-kernel';
export interface TojuPluginDisposable {
dispose: () => void;
}
export interface TojuPluginActivationContext {
api: TojuClientPluginApi;
manifest: TojuPluginManifest;
pluginId: string;
subscriptions: TojuPluginDisposable[];
}
export interface TojuClientPluginModule {
activate?: (context: TojuPluginActivationContext) => Promise<void> | void;
deactivate?: (context: TojuPluginActivationContext) => Promise<void> | void;
onPluginDataChanged?: (context: TojuPluginActivationContext, event: unknown) => Promise<void> | void;
onServerRequirementsChanged?: (context: TojuPluginActivationContext, snapshot: PluginRequirementsSnapshot) => Promise<void> | void;
ready?: (context: TojuPluginActivationContext) => Promise<void> | void;
}
export interface PluginApiProfileUpdate {
description?: string;
displayName: string;
}
export interface PluginApiAvatarUpdate {
avatarHash: string;
avatarMime: string;
avatarUrl: string;
}
export interface PluginApiChannelRequest {
id?: string;
name: string;
position?: number;
}
export interface PluginApiServerSettingsUpdate {
description?: string;
isPrivate?: boolean;
maxUsers?: number;
name?: string;
password?: string;
topic?: string;
}
export interface PluginApiPluginUserRequest {
avatarUrl?: string;
displayName: string;
id?: string;
}
export interface PluginApiMessageAsPluginUserRequest {
channelId?: string;
content: string;
pluginUserId: string;
}
export interface PluginApiAudioClipRequest {
volume?: number;
url: string;
}
export interface PluginApiCustomStreamRequest {
label?: string;
stream: MediaStream;
}
export interface PluginApiEventSubscription {
eventName: string;
handler: (event: PluginEventEnvelope) => void;
}
export interface PluginApiSettingsPageContribution {
label: string;
order?: number;
render: () => HTMLElement | string;
settingsKey?: string;
}
export interface PluginApiPageContribution {
label: string;
path: string;
render: () => HTMLElement | string;
}
export interface PluginApiPanelContribution {
label: string;
order?: number;
render: () => HTMLElement | string;
}
export interface PluginApiChannelSectionContribution {
label: string;
order?: number;
type?: 'audio' | 'custom' | 'video';
}
export interface PluginApiActionContribution {
icon?: string;
label: string;
run: () => Promise<void> | void;
}
export interface PluginApiEmbedRendererContribution {
embedType: string;
render: (payload: unknown) => HTMLElement | string;
}
export interface PluginApiDomMountRequest {
element: HTMLElement;
position?: InsertPosition;
target: Element | string;
}
export interface PluginApiUiContributionMap {
appPages: PluginApiPageContribution[];
channelSections: PluginApiChannelSectionContribution[];
composerActions: PluginApiActionContribution[];
embeds: PluginApiEmbedRendererContribution[];
profileActions: PluginApiActionContribution[];
settingsPages: PluginApiSettingsPageContribution[];
sidePanels: PluginApiPanelContribution[];
toolbarActions: PluginApiActionContribution[];
}
export interface TojuClientPluginApi {
readonly channels: {
addAudioChannel: (request: PluginApiChannelRequest) => void;
addVideoChannel: (request: PluginApiChannelRequest) => void;
list: () => Channel[];
remove: (channelId: string) => void;
rename: (channelId: string, name: string) => void;
select: (channelId: string) => void;
};
readonly events: {
publishP2p: (eventName: string, payload: unknown) => void;
publishServer: (eventName: string, payload: unknown) => void;
subscribeP2p: (subscription: PluginApiEventSubscription) => TojuPluginDisposable;
subscribeServer: (subscription: PluginApiEventSubscription) => TojuPluginDisposable;
};
readonly logger: {
debug: (message: string, data?: unknown) => void;
error: (message: string, data?: unknown) => void;
info: (message: string, data?: unknown) => void;
warn: (message: string, data?: unknown) => void;
};
readonly media: {
addCustomAudioStream: (request: PluginApiCustomStreamRequest) => Promise<void>;
addCustomVideoStream: (request: PluginApiCustomStreamRequest) => Promise<void>;
playAudioClip: (request: PluginApiAudioClipRequest) => Promise<void>;
setInputVolume: (volume: number) => void;
setOutputVolume: (volume: number) => void;
};
readonly messages: {
delete: (messageId: string) => void;
edit: (messageId: string, content: string) => void;
moderateDelete: (messageId: string) => void;
readCurrent: () => Message[];
send: (content: string, channelId?: string) => Message;
sendAsPluginUser: (request: PluginApiMessageAsPluginUserRequest) => void;
sync: (messages: Message[]) => void;
};
readonly p2p: {
broadcastData: (eventName: string, payload: unknown) => void;
connectedPeers: () => string[];
sendData: (peerId: string, eventName: string, payload: unknown) => void;
};
readonly profile: {
getCurrent: () => User | null;
update: (profile: PluginApiProfileUpdate) => void;
updateAvatar: (avatar: PluginApiAvatarUpdate) => void;
};
readonly roles: {
list: () => RoomRole[];
setAssignments: (assignments: RoomRoleAssignment[]) => void;
};
readonly server: {
getCurrent: () => Room | null;
registerPluginUser: (request: PluginApiPluginUserRequest) => string;
updatePermissions: (permissions: Partial<RoomPermissions>) => void;
updateSettings: (settings: PluginApiServerSettingsUpdate) => void;
};
readonly serverData: {
read: (key: string) => Promise<unknown>;
remove: (key: string) => Promise<void>;
write: (key: string, value: unknown) => Promise<void>;
};
readonly storage: {
get: (key: string) => unknown;
remove: (key: string) => void;
set: (key: string, value: unknown) => void;
};
readonly ui: {
registerAppPage: (id: string, contribution: PluginApiPageContribution) => TojuPluginDisposable;
registerChannelSection: (id: string, contribution: PluginApiChannelSectionContribution) => TojuPluginDisposable;
registerComposerAction: (id: string, contribution: PluginApiActionContribution) => TojuPluginDisposable;
registerEmbedRenderer: (id: string, contribution: PluginApiEmbedRendererContribution) => TojuPluginDisposable;
mountElement: (id: string, request: PluginApiDomMountRequest) => TojuPluginDisposable;
registerProfileAction: (id: string, contribution: PluginApiActionContribution) => TojuPluginDisposable;
registerSettingsPage: (id: string, contribution: PluginApiSettingsPageContribution) => TojuPluginDisposable;
registerSidePanel: (id: string, contribution: PluginApiPanelContribution) => TojuPluginDisposable;
registerToolbarAction: (id: string, contribution: PluginApiActionContribution) => TojuPluginDisposable;
};
readonly users: {
ban: (userId: string, reason?: string) => void;
getCurrent: () => User | null;
kick: (userId: string) => void;
list: () => User[];
readMembers: () => RoomMember[];
setRole: (userId: string, role: User['role']) => void;
};
}

View File

@@ -0,0 +1,81 @@
import type { TojuPluginManifest } from '../../../../shared-kernel';
export type PluginRuntimeState =
| 'discovered'
| 'validated'
| 'blocked'
| 'loading'
| 'ready'
| 'loaded'
| 'failed'
| 'unloading'
| 'unloaded'
| 'disabled';
export type PluginValidationSeverity = 'error' | 'warning';
export interface PluginValidationIssue {
message: string;
path: string;
severity: PluginValidationSeverity;
}
export interface PluginManifestValidationResult {
issues: PluginValidationIssue[];
manifest?: TojuPluginManifest;
valid: boolean;
}
export interface RegisteredPlugin {
enabled: boolean;
error?: string;
loadIndex?: number;
manifest: TojuPluginManifest;
sourcePath?: string;
state: PluginRuntimeState;
validationIssues: PluginValidationIssue[];
}
export interface PluginLoadCandidate {
enabled?: boolean;
manifest: TojuPluginManifest;
}
export interface PluginLoadBlocker {
message: string;
pluginId: string;
reason: 'conflict' | 'cycle' | 'disabled' | 'duplicate' | 'missingDependency' | 'validation';
}
export interface PluginLoadOrderResult {
blocked: PluginLoadBlocker[];
ordered: TojuPluginManifest[];
}
export interface LocalPluginManifestDescriptor {
discoveredAt: number;
entrypointPath?: string;
pluginRootUrl?: string;
manifest: unknown;
manifestPath: string;
pluginRoot: string;
readmePath?: string;
}
export interface LocalPluginDiscoveryError {
manifestPath?: string;
message: string;
pluginRoot?: string;
}
export interface LocalPluginDiscoveryResult {
errors: LocalPluginDiscoveryError[];
plugins: LocalPluginManifestDescriptor[];
pluginsPath: string;
}
export interface LocalPluginRegistrationResult {
discovery: LocalPluginDiscoveryResult;
errors: LocalPluginDiscoveryError[];
registered: RegisteredPlugin[];
}

View File

@@ -0,0 +1,46 @@
import type { TojuPluginManifest } from '../../../../shared-kernel';
export type PluginStoreInstallState = 'installed' | 'notInstalled' | 'updateAvailable';
export interface PluginStoreEntry {
author?: string;
description: string;
githubUrl?: string;
homepageUrl?: string;
id: string;
imageUrl?: string;
installUrl?: string;
readmeUrl?: string;
sourceTitle?: string;
sourceUrl: string;
title: string;
version: string;
}
export interface PluginStoreSourceResult {
error?: string;
loadedAt?: number;
plugins: PluginStoreEntry[];
title?: string;
url: string;
}
export interface InstalledStorePlugin {
installedAt: number;
installUrl?: string;
manifest: TojuPluginManifest;
sourceUrl?: string;
updatedAt: number;
}
export interface PluginStoreReadme {
pluginId: string;
title: string;
url: string;
markdown: string;
}
export interface PersistedPluginStoreState {
installedPlugins: InstalledStorePlugin[];
sourceUrls: string[];
}

View File

@@ -0,0 +1,449 @@
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
<section
class="flex h-full min-h-0 flex-col bg-background text-foreground"
data-testid="plugin-manager"
>
<header class="flex items-center justify-between border-b border-border px-4 py-3">
<div class="flex min-w-0 items-center gap-3">
<button
type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Back to settings"
(click)="close()"
>
<ng-icon
name="lucideArrowLeft"
size="18"
/>
</button>
<div class="min-w-0">
<h2 class="truncate text-base font-semibold">Plugins</h2>
<p class="truncate text-xs text-muted-foreground">Local runtime, store install, capabilities, logs, extension points.</p>
</div>
</div>
<button
type="button"
class="inline-flex h-8 items-center gap-2 rounded-md border border-border px-3 text-sm hover:bg-muted disabled:opacity-50"
[disabled]="busyAll()"
(click)="activateAll()"
>
<ng-icon
name="lucidePlay"
size="16"
/>
Activate ready plugins
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-2 rounded-md border border-border px-3 text-sm hover:bg-muted"
(click)="openStore()"
>
<ng-icon
name="lucideStore"
size="16"
/>
Open Plugin Store
</button>
</header>
<nav
class="flex gap-2 border-b border-border px-4 py-2"
aria-label="Plugin manager sections"
>
<button
type="button"
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm"
[class.bg-muted]="activeTab() === 'installed'"
(click)="setTab('installed')"
>
<ng-icon
name="lucidePackage"
size="16"
/>
Installed
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm"
[class.bg-muted]="activeTab() === 'extensions'"
(click)="setTab('extensions')"
>
<ng-icon
name="lucideSettings"
size="16"
/>
Extension points
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm"
[class.bg-muted]="activeTab() === 'requirements'"
(click)="setTab('requirements')"
>
<ng-icon
name="lucideShield"
size="16"
/>
Requirements
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm"
[class.bg-muted]="activeTab() === 'settings'"
(click)="setTab('settings')"
>
<ng-icon
name="lucideSettings"
size="16"
/>
Settings
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm"
[class.bg-muted]="activeTab() === 'docs'"
(click)="setTab('docs')"
>
<ng-icon
name="lucidePackage"
size="16"
/>
Docs
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm"
[class.bg-muted]="activeTab() === 'logs'"
(click)="setTab('logs')"
>
<ng-icon
name="lucideBug"
size="16"
/>
Logs
</button>
</nav>
<div class="min-h-0 flex-1 overflow-auto p-4">
@switch (activeTab()) {
@case ('extensions') {
<div class="space-y-4">
<div
class="grid gap-3 md:grid-cols-2 xl:grid-cols-4"
data-testid="plugin-extension-counts"
>
@for (
item of [
{ label: 'Settings pages', value: extensionCounts().settingsPages },
{ label: 'App pages', value: extensionCounts().appPages },
{ label: 'Side panels', value: extensionCounts().sidePanels },
{ label: 'Channel sections', value: extensionCounts().channelSections },
{ label: 'Composer actions', value: extensionCounts().composerActions },
{ label: 'Profile actions', value: extensionCounts().profileActions },
{ label: 'Toolbar actions', value: extensionCounts().toolbarActions },
{ label: 'Embed renderers', value: extensionCounts().embeds }
];
track item.label
) {
<article class="rounded-lg border border-border bg-card p-3">
<p class="text-sm text-muted-foreground">{{ item.label }}</p>
<p class="mt-2 text-2xl font-semibold">{{ item.value }}</p>
</article>
}
</div>
<section
class="rounded-lg border border-border bg-card p-4"
data-testid="plugin-conflict-diagnostics"
>
<h3 class="text-sm font-semibold">Conflict diagnostics</h3>
@if (uiConflicts().length === 0) {
<p class="mt-2 text-sm text-muted-foreground">
No duplicate route, action, embed, channel, panel, or settings contribution ids detected.
</p>
} @else {
<div class="mt-3 space-y-2">
@for (conflict of uiConflicts(); track conflict.kind + conflict.contributionId) {
<div class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm">
<span class="font-medium">{{ conflict.kind }} / {{ conflict.contributionId }}</span>
<span class="text-muted-foreground"> conflicts in {{ conflict.pluginIds.join(', ') }}</span>
</div>
}
</div>
}
</section>
</div>
}
@case ('requirements') {
<div
class="space-y-3"
data-testid="plugin-server-requirements"
>
@if (requirementComparisons().length === 0) {
<p class="rounded-lg border border-border bg-card p-4 text-sm text-muted-foreground">
No server plugin requirements for the current room.
</p>
} @else {
@for (comparison of requirementComparisons(); track comparison.pluginId) {
<article class="rounded-lg border border-border bg-card p-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<h3 class="text-sm font-semibold">{{ comparison.installed?.title ?? comparison.pluginId }}</h3>
<p class="mt-1 text-xs text-muted-foreground">{{ comparison.pluginId }}</p>
</div>
<span class="rounded bg-muted px-2 py-1 text-xs text-muted-foreground">{{ comparison.status }}</span>
</div>
@if (comparison.requirement) {
<p class="mt-3 text-sm text-muted-foreground">Server status: {{ comparison.requirement.status }}</p>
@if (comparison.requirement.versionRange) {
<p class="mt-1 text-sm text-muted-foreground">Version range: {{ comparison.requirement.versionRange }}</p>
}
@if (comparison.requirement.reason) {
<p class="mt-1 text-sm text-muted-foreground">{{ comparison.requirement.reason }}</p>
}
}
</article>
}
}
</div>
}
@case ('settings') {
<div
class="grid gap-4 xl:grid-cols-[260px_minmax(0,1fr)]"
data-testid="plugin-generated-settings"
>
<div class="space-y-2">
@for (entry of entries(); track trackEntry($index, entry)) {
<button
type="button"
class="w-full rounded-md border border-border px-3 py-2 text-left text-sm hover:bg-muted"
[class.bg-muted]="isSelected(entry)"
(click)="selectPlugin(entry.manifest.id)"
>
{{ entry.manifest.title }}
</button>
}
</div>
<section class="rounded-lg border border-border bg-card p-4">
@if (selectedPlugin(); as plugin) {
<h3 class="text-sm font-semibold">{{ plugin.manifest.title }} settings</h3>
@if (selectedSettingsPages().length > 0) {
<div class="mt-4 space-y-3">
@for (page of selectedSettingsPages(); track page.id) {
<article class="rounded-md border border-border bg-background/40 p-3">
<h4 class="mb-2 text-sm font-medium">{{ page.contribution.label }}</h4>
<app-plugin-render-host [render]="page.contribution.render"></app-plugin-render-host>
</article>
}
</div>
}
@if (selectedSettingsSchema()) {
<pre class="mt-3 max-h-[420px] overflow-auto rounded-md bg-muted p-3 text-xs">{{ selectedSettingsSchema() | json }}</pre>
} @else {
<p class="mt-2 text-sm text-muted-foreground">This plugin does not declare a settings schema.</p>
}
}
</section>
</div>
}
@case ('docs') {
<div
class="grid gap-4 xl:grid-cols-[260px_minmax(0,1fr)]"
data-testid="plugin-installed-docs"
>
<div class="space-y-2">
@for (entry of entries(); track trackEntry($index, entry)) {
<button
type="button"
class="w-full rounded-md border border-border px-3 py-2 text-left text-sm hover:bg-muted"
[class.bg-muted]="isSelected(entry)"
(click)="selectPlugin(entry.manifest.id)"
>
{{ entry.manifest.title }}
</button>
}
</div>
<section class="rounded-lg border border-border bg-card p-4">
@if (selectedPlugin(); as plugin) {
<h3 class="text-sm font-semibold">{{ plugin.manifest.title }}</h3>
<p class="mt-2 text-sm text-muted-foreground">{{ plugin.manifest.description }}</p>
<div class="mt-4 flex flex-wrap gap-2">
@for (doc of selectedDocs(); track doc.label) {
<a
class="rounded-md border border-border px-3 py-1.5 text-sm hover:bg-muted"
[href]="doc.url"
target="_blank"
rel="noreferrer"
>{{ doc.label }}</a
>
}
</div>
<pre class="mt-4 max-h-[420px] overflow-auto rounded-md bg-muted p-3 text-xs">{{ plugin.manifest | json }}</pre>
}
</section>
</div>
}
@case ('logs') {
<div class="space-y-3">
@if (!selectedPlugin()) {
<p class="text-sm text-muted-foreground">No plugins installed.</p>
} @else {
<div class="flex flex-wrap gap-2">
@for (entry of entries(); track trackEntry($index, entry)) {
<button
type="button"
class="rounded-md border border-border px-3 py-1 text-sm hover:bg-muted"
[class.bg-muted]="isSelected(entry)"
(click)="selectPlugin(entry.manifest.id)"
>
{{ entry.manifest.title }}
</button>
}
</div>
<div class="rounded-lg border border-border bg-card">
@if (selectedLogs().length === 0) {
<p class="p-4 text-sm text-muted-foreground">No logs for selected plugin.</p>
} @else {
@for (log of selectedLogs(); track log.timestamp) {
<div class="border-b border-border px-4 py-3 last:border-b-0">
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<span class="uppercase">{{ log.level }}</span>
<span>{{ log.timestamp | date: 'short' }}</span>
</div>
<p class="mt-1 text-sm">{{ log.message }}</p>
</div>
}
}
</div>
}
</div>
}
@default {
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_360px]">
<div class="space-y-3">
@if (entries().length === 0) {
<div
class="rounded-lg border border-dashed border-border p-8 text-center"
data-testid="plugin-empty-state"
>
<ng-icon
class="mx-auto text-muted-foreground"
name="lucidePackage"
size="28"
/>
<p class="mt-3 text-sm font-medium">No plugins installed.</p>
<p class="mt-1 text-sm text-muted-foreground">Use Store tab or local plugin folder discovery.</p>
</div>
} @else {
@for (entry of entries(); track trackEntry($index, entry)) {
<article
class="rounded-lg border border-border bg-card p-4"
[class.ring-2]="isSelected(entry)"
[class.ring-primary]="isSelected(entry)"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h3 class="truncate text-sm font-semibold">{{ entry.manifest.title }}</h3>
<span class="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground">{{ entry.state }}</span>
<span class="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground">v{{ entry.manifest.version }}</span>
</div>
<p class="mt-1 text-sm text-muted-foreground">{{ entry.manifest.description }}</p>
<p class="mt-2 text-xs text-muted-foreground">{{ entry.manifest.id }}</p>
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted"
(click)="selectPlugin(entry.manifest.id)"
>
Select
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted"
(click)="setEnabled(entry, !entry.enabled)"
>
<ng-icon
[name]="entry.enabled ? 'lucideX' : 'lucideCheck'"
size="14"
/>
{{ entry.enabled ? 'Disable' : 'Enable' }}
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted disabled:opacity-50"
[disabled]="busyPluginId() === entry.manifest.id"
(click)="reload(entry)"
>
<ng-icon
name="lucideRefreshCw"
size="14"
/>
Reload
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted disabled:opacity-50"
[disabled]="busyPluginId() === entry.manifest.id"
(click)="unload(entry)"
>
<ng-icon
name="lucideX"
size="14"
/>
Unload
</button>
</div>
</div>
@if (entry.error) {
<p class="mt-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">{{ entry.error }}</p>
}
</article>
}
}
</div>
<aside class="rounded-lg border border-border bg-card p-4">
@if (selectedPlugin(); as plugin) {
<div class="flex items-center gap-2">
<ng-icon
name="lucideShield"
size="18"
/>
<h3 class="text-sm font-semibold">Capabilities</h3>
</div>
@if ((plugin.manifest.capabilities?.length ?? 0) === 0) {
<p class="mt-3 text-sm text-muted-foreground">Plugin requests no capabilities.</p>
} @else {
<button
type="button"
class="mt-3 h-8 rounded-md border border-border px-3 text-sm hover:bg-muted"
(click)="grantAll(plugin)"
>
Grant all requested
</button>
<div class="mt-3 space-y-2">
@for (capability of plugin.manifest.capabilities; track trackCapability($index, capability)) {
<label class="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm">
<input
type="checkbox"
class="h-4 w-4"
[checked]="capabilities.has(plugin.manifest.id, capability)"
(change)="toggleCapability(plugin, capability)"
/>
<span>{{ capability }}</span>
</label>
}
</div>
}
@if (missingCapabilities().length > 0) {
<p class="mt-3 text-xs text-muted-foreground">Missing: {{ missingCapabilities().join(', ') }}</p>
}
}
</aside>
</div>
}
}
</div>
</section>

View File

@@ -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<void>();
readonly capabilities = inject(PluginCapabilityService);
readonly host = inject(PluginHostService);
readonly logger = inject(PluginLoggerService);
readonly registry = inject(PluginRegistryService);
readonly requirementState = inject(PluginRequirementStateService);
readonly router = inject(Router);
readonly uiRegistry = inject(PluginUiRegistryService);
readonly activeTab = signal<PluginManagerTab>('installed');
readonly busyPluginId = signal<string | null>(null);
readonly busyAll = signal(false);
readonly selectedPluginId = signal<string | null>(null);
readonly 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<void> {
this.busyAll.set(true);
try {
await this.host.activateReadyPlugins();
} finally {
this.busyAll.set(false);
}
}
async reload(entry: RegisteredPlugin): Promise<void> {
this.busyPluginId.set(entry.manifest.id);
try {
await this.host.reloadPlugin(entry.manifest.id);
} finally {
this.busyPluginId.set(null);
}
}
async unload(entry: RegisteredPlugin): Promise<void> {
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;
}
}

View File

@@ -0,0 +1,17 @@
<main class="min-h-screen bg-background p-6 text-foreground">
<a routerLink="/search" class="text-sm text-muted-foreground hover:text-foreground">Back</a>
@if (page(); as pageRecord) {
<section class="mx-auto mt-6 max-w-5xl">
<p class="text-xs uppercase tracking-[0.18em] text-muted-foreground">{{ pageRecord.pluginId }}</p>
<h1 class="mt-1 text-2xl font-semibold">{{ pageRecord.contribution.label }}</h1>
<div class="mt-6 rounded-lg border border-border bg-card p-4">
<app-plugin-render-host [render]="pageRecord.contribution.render" />
</div>
</section>
} @else {
<section class="mx-auto mt-6 max-w-2xl rounded-lg border border-border bg-card p-8 text-center">
<h1 class="text-xl font-semibold">Plugin page unavailable</h1>
<p class="mt-2 text-sm text-muted-foreground">The plugin page is not registered or the plugin is not loaded.</p>
</section>
}
</main>

View File

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

View File

@@ -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: '<div #host></div>'
})
export class PluginRenderHostComponent {
readonly render = input.required<PluginRenderable>();
private readonly host = viewChild.required<ElementRef<HTMLElement>>('host');
constructor() {
effect(() => {
this.renderContribution(this.render());
});
}
private renderContribution(render: PluginRenderable): void {
const hostElement = this.host().nativeElement;
hostElement.replaceChildren();
try {
const rendered = render();
if (typeof rendered === 'string') {
hostElement.textContent = rendered;
return;
}
hostElement.appendChild(rendered);
} catch (error) {
hostElement.textContent = error instanceof Error ? error.message : 'Plugin contribution failed to render';
}
}
}

View File

@@ -0,0 +1,314 @@
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity, @angular-eslint/template/prefer-ngsrc -->
<main
class="plugin-store"
data-testid="plugin-store-page"
>
<header class="plugin-store__topbar">
<div class="plugin-store__title-row">
<button
type="button"
(click)="goBack()"
class="plugin-store__icon-button"
title="Back to app"
>
<ng-icon name="lucideArrowLeft" />
</button>
<div class="plugin-store__brand-icon">
<ng-icon name="lucideStore" />
</div>
<div class="plugin-store__title-copy">
<h1>Plugin Store</h1>
<p>{{ installedCount() }} installed · {{ totalSourcePlugins() }} available · {{ sourceCount() }} sources</p>
</div>
</div>
<div class="plugin-store__top-actions">
<button
type="button"
(click)="openManager()"
class="plugin-store__secondary-button"
>
<ng-icon name="lucideSettings" />
Manage Plugins
</button>
<button
type="button"
(click)="refreshSources()"
[disabled]="store.isLoading()"
class="plugin-store__secondary-button"
>
<ng-icon
name="lucideRefreshCw"
[class.is-spinning]="store.isLoading()"
/>
Refresh
</button>
</div>
</header>
<section class="plugin-store__source-strip">
<div class="plugin-store__source-form">
<label class="plugin-store__input-shell plugin-store__source-input">
<input
type="url"
[(ngModel)]="newSourceUrl"
(keyup.enter)="addSourceUrl()"
placeholder="https://example.com/plugins.json"
aria-label="Plugin source manifest URL"
/>
</label>
<button
type="button"
(click)="addSourceUrl()"
[disabled]="!newSourceUrl.trim() || store.isLoading()"
class="plugin-store__primary-button"
>
<ng-icon name="lucidePlus" />
Add Source
</button>
</div>
@if (sourceError()) {
<p class="plugin-store__error-text">{{ sourceError() }}</p>
}
</section>
<div class="plugin-store__layout">
<aside
class="plugin-store__rail"
aria-label="Plugin sources"
>
<section class="plugin-store__panel">
<div class="plugin-store__panel-header">
<h2>Sources</h2>
<span>{{ sourceCount() }}</span>
</div>
<button
type="button"
class="plugin-store__source-filter"
[class.is-active]="selectedSourceUrl() === null"
(click)="selectSource(null)"
>
<span>All sources</span>
<strong>{{ totalSourcePlugins() }}</strong>
</button>
@for (source of store.sources(); track source.url) {
<div
class="plugin-store__source-row"
[class.has-error]="!!source.error"
>
<button
type="button"
class="plugin-store__source-filter"
[class.is-active]="selectedSourceUrl() === source.url"
(click)="selectSource(source.url)"
>
<span>{{ source.title || source.url }}</span>
<strong>{{ source.plugins.length }}</strong>
</button>
<button
type="button"
(click)="removeSourceUrl(source.url)"
class="plugin-store__icon-button plugin-store__icon-button--danger"
title="Remove source"
>
<ng-icon name="lucideTrash2" />
</button>
</div>
@if (source.error) {
<p class="plugin-store__source-error">{{ source.error }}</p>
}
}
@for (sourceUrl of pendingSourceUrls(); track sourceUrl) {
<div class="plugin-store__source-row">
<button
type="button"
class="plugin-store__source-filter"
[class.is-active]="selectedSourceUrl() === sourceUrl"
(click)="selectSource(sourceUrl)"
>
<span>{{ sourceUrl }}</span>
<strong>0</strong>
</button>
<button
type="button"
(click)="removeSourceUrl(sourceUrl)"
class="plugin-store__icon-button plugin-store__icon-button--danger"
title="Remove source"
>
<ng-icon name="lucideTrash2" />
</button>
</div>
}
</section>
<section class="plugin-store__panel">
<div class="plugin-store__panel-header">
<h2>Filters</h2>
</div>
<button
type="button"
class="plugin-store__toggle-button"
[class.is-active]="showInstalledOnly()"
(click)="toggleInstalledOnly()"
>
<span>Installed only</span>
<strong>{{ installedCount() }}</strong>
</button>
</section>
</aside>
<section
class="plugin-store__catalog"
aria-label="Available plugins"
>
<div class="plugin-store__toolbar">
<label class="plugin-store__input-shell plugin-store__search">
<ng-icon name="lucideSearch" />
<input
type="search"
[ngModel]="searchTerm()"
(ngModelChange)="searchTerm.set($event)"
placeholder="Search plugins, authors, ids"
aria-label="Search plugins"
/>
</label>
<div class="plugin-store__count">{{ filteredPlugins().length }} shown</div>
</div>
@if (actionError()) {
<p class="plugin-store__error-banner">{{ actionError() }}</p>
}
@if (readmeError()) {
<p class="plugin-store__error-banner">{{ readmeError() }}</p>
}
@if (filteredPlugins().length > 0) {
<div class="plugin-store__grid">
@for (plugin of filteredPlugins(); track trackPlugin($index, plugin)) {
<article class="plugin-card">
<div class="plugin-card__media">
@if (plugin.imageUrl) {
<img
[src]="plugin.imageUrl"
[alt]="plugin.title"
(error)="hideBrokenImage($event)"
/>
} @else {
<ng-icon name="lucidePackage" />
}
</div>
<div class="plugin-card__body">
<div class="plugin-card__header">
<div>
<h2>{{ plugin.title }}</h2>
<p>{{ plugin.author || 'Unknown author' }} · v{{ plugin.version }}</p>
</div>
@if (store.getInstallState(plugin) === 'updateAvailable') {
<span class="plugin-card__badge">Update</span>
} @else if (store.getInstallState(plugin) === 'installed') {
<span class="plugin-card__badge plugin-card__badge--installed">Installed</span>
}
</div>
<p class="plugin-card__description">{{ plugin.description }}</p>
<div class="plugin-card__meta">
<span>{{ plugin.id }}</span>
<span>{{ plugin.sourceTitle || plugin.sourceUrl }}</span>
</div>
<div class="plugin-card__actions">
<button
type="button"
(click)="runPrimaryAction(plugin)"
[disabled]="isPrimaryActionDisabled(plugin)"
class="plugin-store__primary-button plugin-card__primary-action"
[class.plugin-card__primary-action--danger]="store.getActionLabel(plugin) === 'Uninstall'"
>
<ng-icon
[name]="primaryActionIcon(plugin)"
[class.is-spinning]="isPluginBusy(plugin)"
/>
{{ store.getActionLabel(plugin) }}
</button>
@if (plugin.readmeUrl) {
<button
type="button"
(click)="loadReadme(plugin)"
class="plugin-store__text-button"
title="Load readme"
>
{{ isReadmeLoading(plugin) ? 'Loading' : 'Readme' }}
</button>
}
@if (plugin.githubUrl) {
<button
type="button"
(click)="openExternal(plugin.githubUrl)"
class="plugin-store__icon-button"
title="Open GitHub"
>
<ng-icon name="lucideExternalLink" />
</button>
}
</div>
</div>
</article>
}
</div>
} @else {
<section class="plugin-store__empty">
<ng-icon name="lucidePackage" />
<h2>No plugins found</h2>
<p>{{ sourceCount() ? 'Adjust filters or add another source manifest.' : 'Add a plugin source manifest URL to populate the catalog.' }}</p>
</section>
}
</section>
@if (readme()) {
<aside
class="plugin-store__readme"
aria-label="Plugin readme"
>
<div class="plugin-store__readme-header">
<div>
<p>Readme</p>
<h2>{{ readme()!.title }}</h2>
@if (selectedReadmePlugin(); as plugin) {
<span>{{ plugin.author || 'Unknown author' }} · v{{ plugin.version }}</span>
}
</div>
<button
type="button"
(click)="closeReadme()"
class="plugin-store__icon-button"
title="Close readme"
>
<ng-icon name="lucideX" />
</button>
</div>
<pre>{{ readme()!.markdown }}</pre>
<button
type="button"
(click)="openExternal(readme()!.url)"
class="plugin-store__secondary-button plugin-store__readme-link"
>
<ng-icon name="lucideExternalLink" />
Open source readme
</button>
</aside>
}
</div>
</main>

View File

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

View File

@@ -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<string | null>(null);
readonly showInstalledOnly = signal(false);
readonly sourceError = signal<string | null>(null);
readonly actionError = signal<string | null>(null);
readonly actionBusyPluginId = signal<string | null>(null);
readonly readme = signal<PluginStoreReadme | null>(null);
readonly readmeError = signal<string | null>(null);
readonly readmeLoadingPluginId = signal<string | null>(null);
private destroyed = false;
private readonly destroyRef = inject(DestroyRef);
private readonly externalLinks = inject(ExternalLinkService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly settingsModal = inject(SettingsModalService);
constructor() {
this.destroyRef.onDestroy(() => {
this.destroyed = true;
});
}
ngOnInit(): void {
if (this.store.sourceUrls().length > 0 && this.store.sources().length === 0) {
void this.refreshSources();
}
}
async addSourceUrl(): Promise<void> {
const sourceUrl = this.newSourceUrl.trim();
if (!sourceUrl) {
return;
}
this.sourceError.set(null);
try {
await this.store.addSourceUrl(sourceUrl);
if (this.destroyed) {
return;
}
this.newSourceUrl = '';
} catch (error) {
if (this.destroyed) {
return;
}
this.sourceError.set(error instanceof Error ? error.message : 'Unable to add plugin source');
}
}
async removeSourceUrl(sourceUrl: string): Promise<void> {
this.sourceError.set(null);
try {
await this.store.removeSourceUrl(sourceUrl);
if (this.selectedSourceUrl() === sourceUrl) {
this.selectedSourceUrl.set(null);
}
} catch (error) {
if (this.destroyed) {
return;
}
this.sourceError.set(error instanceof Error ? error.message : 'Unable to remove plugin source');
}
}
async refreshSources(): Promise<void> {
this.sourceError.set(null);
try {
await this.store.refreshSources();
} catch (error) {
if (this.destroyed) {
return;
}
this.sourceError.set(error instanceof Error ? error.message : 'Unable to refresh plugin sources');
}
}
async runPrimaryAction(plugin: PluginStoreEntry): Promise<void> {
const action = this.store.getActionLabel(plugin);
this.actionError.set(null);
this.actionBusyPluginId.set(plugin.id);
try {
if (action === 'Uninstall') {
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<void> {
this.readmeError.set(null);
this.readmeLoadingPluginId.set(plugin.id);
try {
const readme = await this.store.loadReadme(plugin);
if (this.destroyed) {
return;
}
this.readme.set(readme);
} catch (error) {
if (this.destroyed) {
return;
}
this.readmeError.set(error instanceof Error ? error.message : 'Unable to load readme');
} finally {
if (!this.destroyed) {
this.readmeLoadingPluginId.set(null);
}
}
}
closeReadme(): void {
this.readme.set(null);
this.readmeError.set(null);
}
goBack(): void {
void this.router.navigateByUrl(this.getReturnUrl());
}
async openManager(): Promise<void> {
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';
}
}

View File

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

View File

@@ -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<ElectronApi> as ElectronApi;
const service = createDiscoveryService(() => electronApi);
expect(service.isAvailable).toBe(true);
await expect(service.getPluginsPath()).resolves.toBe('/plugins');
await expect(service.discoverManifests()).resolves.toEqual({
errors: [],
plugins: [
{
discoveredAt: 1,
entrypointPath: '/plugins/api-test-plugin/dist/main.js',
manifest: TEST_PLUGIN_MANIFEST,
manifestPath: '/plugins/api-test-plugin/toju-plugin.json',
pluginRoot: '/plugins/api-test-plugin',
readmePath: '/plugins/api-test-plugin/README.md'
}
],
pluginsPath: '/plugins'
});
});
});
function createDiscoveryService(readElectronApi: () => ElectronApi | null): LocalPluginDiscoveryService {
const injector = Injector.create({
providers: [
LocalPluginDiscoveryService,
{
provide: ElectronBridgeService,
useValue: {
get isAvailable(): boolean {
return readElectronApi() !== null;
},
getApi: vi.fn(() => readElectronApi())
}
}
]
});
return injector.get(LocalPluginDiscoveryService);
}
function createTestPluginManifest(): TojuPluginManifest {
return {
apiVersion: '1.0.0',
capabilities: [
'storage.serverData.read',
'storage.serverData.write',
'events.server.publish'
],
compatibility: {
minimumTojuVersion: '1.0.0'
},
description: 'Fixture plugin used by automated tests for plugin support APIs.',
entrypoint: './dist/main.js',
events: [
{
direction: 'serverRelay',
eventName: 'e2e:relay',
maxPayloadBytes: 2048,
scope: 'server'
}
],
id: 'e2e.plugin-api',
kind: 'client',
schemaVersion: 1,
title: 'E2E Plugin API Fixture',
version: '1.0.0'
};
}

View File

@@ -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<string | null> {
const api = this.electronBridge.getApi();
return api ? await api.getLocalPluginsPath() : null;
}
async discoverManifests(): Promise<LocalPluginDiscoveryResult> {
const api = this.electronBridge.getApi();
if (!api) {
return {
errors: [],
plugins: [],
pluginsPath: ''
};
}
const result = await api.listLocalPluginManifests();
return {
errors: result.errors,
plugins: result.plugins.map((plugin): LocalPluginManifestDescriptor => ({
discoveredAt: plugin.discoveredAt,
entrypointPath: plugin.entrypointPath,
pluginRootUrl: plugin.pluginRootUrl,
manifest: plugin.manifest,
manifestPath: plugin.manifestPath,
pluginRoot: plugin.pluginRoot,
readmePath: plugin.readmePath
})),
pluginsPath: result.pluginsPath
};
}
}

View File

@@ -250,6 +250,41 @@
}
</div>
</section>
@if (pluginChannelSections().length > 0 || pluginSidePanels().length > 0) {
<section class="border-t border-border px-2 py-3" data-testid="plugin-room-side-panel">
<div class="mb-2 px-1">
<h4 class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">Plugins</h4>
</div>
@if (pluginChannelSections().length > 0) {
<div class="space-y-1">
@for (record of pluginChannelSections(); track record.id) {
<button
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-foreground/70 transition-colors hover:bg-secondary/60 hover:text-foreground"
[title]="record.pluginId">
<ng-icon
[name]="record.contribution.type === 'video' ? 'lucideVideo' : 'lucideHash'"
class="h-4 w-4 text-muted-foreground"
/>
<span class="min-w-0 flex-1 truncate">{{ record.contribution.label }}</span>
</button>
}
</div>
}
@if (pluginSidePanels().length > 0) {
<div class="mt-3 space-y-2">
@for (record of pluginSidePanels(); track record.id) {
<article class="rounded-md border border-border bg-background/40 p-2">
<p class="mb-2 truncate text-xs font-medium text-muted-foreground">{{ record.contribution.label }}</p>
<app-plugin-render-host [render]="record.contribution.render" />
</article>
}
</div>
}
</section>
}
</div>
}

View File

@@ -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<typeof setTimeout> | 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(() => {

View File

@@ -135,6 +135,9 @@
@case ('general') {
General
}
@case ('plugins') {
Plugins
}
@case ('network') {
Network
}
@@ -193,6 +196,9 @@
@case ('general') {
<app-general-settings />
}
@case ('plugins') {
<app-plugin-manager (closed)="navigate('general')" />
}
@case ('network') {
<app-network-settings />
}

View File

@@ -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' },

View File

@@ -18,6 +18,18 @@
<h1 class="text-2xl font-bold text-foreground">Settings</h1>
</div>
<button
type="button"
(click)="openPluginStore()"
class="mb-6 flex items-center gap-2 rounded-lg bg-secondary px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-secondary/80"
>
<ng-icon
name="lucidePackage"
class="h-4 w-4"
/>
Plugin Store
</button>
<!-- Server Endpoints Section -->
<div class="bg-card border border-border rounded-lg p-6 mb-6">
<div class="flex items-center justify-between mb-4">

View File

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

View File

@@ -85,6 +85,20 @@
Login
</button>
<button
type="button"
class="flex h-8 items-center gap-1.5 rounded-md px-2 text-sm text-foreground transition-colors hover:bg-secondary"
[class.hidden]="!isAuthed()"
(click)="openPluginStore()"
title="Plugin Store"
>
<ng-icon
name="lucidePackage"
class="h-4 w-4 text-muted-foreground"
/>
Plugins
</button>
<div class="relative">
<button
type="button"
@@ -131,6 +145,14 @@
{{ inviteStatus() }}
</div>
<div class="mx-2 my-1 h-px bg-border"></div>
<button
type="button"
(click)="openPluginStore()"
class="w-full rounded-md px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-secondary"
>
Plugin Store
</button>
<div class="mx-2 my-1 h-px bg-border"></div>
<button
type="button"
(click)="logout()"

View File

@@ -16,6 +16,7 @@ import {
lucideChevronLeft,
lucideHash,
lucideMenu,
lucidePackage,
lucideRefreshCw
} from '@ng-icons/lucide';
import { NavigationEnd, Router } from '@angular/router';
@@ -59,6 +60,7 @@ import { ThemeNodeDirective } from '../../../domains/theme';
lucideChevronLeft,
lucideHash,
lucideMenu,
lucidePackage,
lucideRefreshCw })
],
templateUrl: './title-bar.component.html'
@@ -179,6 +181,13 @@ export class TitleBarComponent {
this.router.navigate(['/login']);
}
openPluginStore(): void {
const returnUrl = this.router.url.startsWith('/plugin-store') ? '/search' : this.router.url;
this._showMenu.set(false);
void this.router.navigate(['/plugin-store'], { queryParams: { returnUrl } });
}
/** Open the unified leave-server confirmation dialog. */
private openLeaveConfirm() {
this._showMenu.set(false);

View File

@@ -10,5 +10,6 @@ export * from './chat-events';
export * from './media-preferences';
export * from './signaling-contracts';
export * from './attachment-contracts';
export * from './plugin-system.contracts';
export * from './p2p-transfer.constants';
export * from './p2p-transfer.utils';

View File

@@ -0,0 +1,193 @@
export const PLUGIN_REQUIREMENT_STATUSES = [
'required',
'optional',
'recommended',
'blocked',
'incompatible'
] as const;
export type PluginRequirementStatus = typeof PLUGIN_REQUIREMENT_STATUSES[number];
export const PLUGIN_EVENT_DIRECTIONS = [
'clientToServer',
'serverRelay',
'p2pHint'
] as const;
export type PluginEventDirection = typeof PLUGIN_EVENT_DIRECTIONS[number];
export const PLUGIN_EVENT_SCOPES = [
'server',
'channel',
'user',
'plugin'
] as const;
export type PluginEventScope = typeof PLUGIN_EVENT_SCOPES[number];
export const PLUGIN_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'
] as const;
export type PluginCapabilityId = typeof PLUGIN_CAPABILITIES[number];
export interface PluginRequirementSummary {
pluginId: string;
reason?: string;
status: PluginRequirementStatus;
updatedAt: number;
versionRange?: string;
}
export interface PluginEventDefinitionSummary {
direction: PluginEventDirection;
eventName: string;
maxPayloadBytes: number;
pluginId: string;
scope: PluginEventScope;
schemaJson?: string;
updatedAt: number;
}
export interface PluginRequirementsSnapshot {
eventDefinitions: PluginEventDefinitionSummary[];
requirements: PluginRequirementSummary[];
serverId: string;
updatedAt: number;
}
export interface PluginEventEnvelope<TPayload = unknown> {
emittedAt?: number;
eventId?: string;
eventName: string;
payload: TPayload;
pluginId: string;
serverId: string;
sourcePluginUserId?: string;
sourceUserId?: string;
type: 'plugin_event';
}
export interface PluginRequirementsMessage {
snapshot: PluginRequirementsSnapshot;
serverId: string;
type: 'plugin_requirements';
}
export interface PluginRequirementsChangedMessage {
snapshot: PluginRequirementsSnapshot;
serverId: string;
type: 'plugin_requirements_changed';
}
export interface PluginDataChangedMessage {
key: string;
ownerId?: string;
pluginId: string;
scope: string;
serverId: string;
type: 'plugin_data_changed';
updatedAt: number;
}
export interface PluginErrorMessage {
code: string;
eventId?: string;
eventName?: string;
message: string;
pluginId?: string;
serverId?: string;
type: 'plugin_error';
}
export interface TojuPluginManifest {
apiVersion: string;
authors?: {
email?: string;
name: string;
url?: string;
}[];
bugs?: string;
capabilities?: PluginCapabilityId[];
changelog?: string;
compatibility: {
maximumTojuVersion?: string;
minimumTojuVersion: string;
verifiedTojuVersion?: string;
};
data?: {
key: string;
schema?: string;
scope: string;
storage: 'local' | 'serverData';
}[];
description: string;
entrypoint?: string;
events?: {
direction: PluginEventDirection;
eventName: string;
maxPayloadBytes?: number;
schema?: string;
scope: PluginEventScope;
}[];
homepage?: string;
id: string;
kind: 'client' | 'library';
license?: string;
load?: {
priority?: 'bootstrap' | 'high' | 'default' | 'low';
};
pluginUser?: {
avatar?: string;
displayName: string;
label?: string;
};
readme?: string;
relationships?: {
after?: string[];
before?: string[];
conflicts?: string[];
optional?: { id: string; versionRange?: string }[];
requires?: { id: string; versionRange?: string }[];
};
schemaVersion: 1;
settings?: Record<string, unknown>;
title: string;
ui?: Record<string, unknown>;
version: string;
}