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

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