feat: plugins v1.7

This commit is contained in:
2026-04-29 15:24:56 +02:00
parent eabbc08896
commit d261bac0ed
45 changed files with 5621 additions and 867 deletions

View File

@@ -91,16 +91,18 @@
@if (msg.isDeleted) {
<div class="mt-1 text-sm italic text-muted-foreground">{{ deletedMessageContent }}</div>
} @else {
@if (requiresRichMarkdown(msg.content)) {
@defer {
<div class="chat-markdown mt-1 break-words">
<app-chat-message-markdown [content]="msg.content" />
</div>
} @placeholder {
@if (pluginEmbeds().length === 0) {
@if (requiresRichMarkdown(msg.content)) {
@defer {
<div class="chat-markdown mt-1 break-words">
<app-chat-message-markdown [content]="msg.content" />
</div>
} @placeholder {
<div class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">{{ msg.content }}</div>
}
} @else {
<div class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">{{ msg.content }}</div>
}
} @else {
<div class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">{{ msg.content }}</div>
}
@if (msg.linkMetadata?.length) {
@@ -116,7 +118,10 @@
}
@if (pluginEmbeds().length > 0) {
<div class="mt-2 space-y-2" data-testid="plugin-message-embeds">
<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">

View File

@@ -10,7 +10,9 @@ The standalone plugin store is available from the title bar Plugins button, the
The plugin manager UI is split between Settings -> Client plugins for global client plugins and Settings -> Server -> Server plugins for chat-server plugins. The two pages filter by manifest `scope` and include installed plugins, capability grant toggles, per-plugin activate/reload/unload actions, runtime logs, extension-point counts, server requirements, generated settings, and docs.
The Store tab consumes user-managed HTTP(S), `file://`, or absolute local-path source manifests. Local-path sources and entrypoints are read through the Electron desktop file bridge. A source manifest can expose a `plugins` array whose entries include `id`, `title`, `description`, `version`, `scope`, `author`/`authors`, `image`/`imageUrl`, `github`/`githubUrl`, `install`/`installUrl`/`manifestUrl`, and `readme`/`readmeUrl`. Installing a `scope: "server"` plugin fetches the linked plugin manifest, validates it, registers it with the client registry, and persists the basic install metadata as a server plugin requirement. Required server plugins are installed on each member client when that chat server opens; optional server plugins stay listed as server requirements but are not auto-installed. Installing a `scope: "client"` plugin persists it locally for the current desktop/browser client.
The Store tab consumes user-managed HTTP(S), `file://`, or absolute local-path source manifests. Local-path sources and entrypoints are read through the Electron desktop file bridge. A source manifest can expose a `plugins` array whose entries include `id`, `title`, `description`, `version`, `scope`, `author`/`authors`, `image`/`imageUrl`, `github`/`githubUrl`, `install`/`installUrl`/`manifestUrl`, `bundle`/`bundleUrl`, and `readme`/`readmeUrl`. Installing a `scope: "server"` plugin fetches the linked plugin manifest, validates it, registers it with the client registry, and persists the basic install metadata as a server plugin requirement. Required server plugins are installed on each member client when that chat server opens; optional server plugins stay listed as server requirements but are not auto-installed. Installing a `scope: "client"` plugin persists it locally for the current desktop/browser client.
Store plugins can be published as cached browser bundles by adding `bundle` or `bundleUrl` to the source manifest entry. The bundle is a browser-safe ESM JavaScript file. During install, Electron downloads the bundle into app data under `plugin-bundles/<plugin-id>/<version>/main.js`, writes a cached manifest next to it, and registers the plugin from that local cached manifest path. If no bundle URL is provided and the manifest entrypoint is a relative browser module, Electron caches that entrypoint path instead. Browser-only clients still load directly from the source URL. Saved store sources refresh during app bootstrap; when a source advertises a higher version for an installed plugin, the store attempts to update the local cached bundle and persisted install metadata automatically.
The server-side plugin support API is metadata-only. The signal server can keep plugin id, requirement status, version range, install/source URLs, and the validated manifest snapshot needed for member clients to install required plugins. Plugin `serverData` API calls are handled as local per-user/per-server client state; HTTP plugin data persistence on the signal server returns `PLUGIN_DATA_DISABLED`.

View File

@@ -0,0 +1,9 @@
import { Injectable, inject } from '@angular/core';
import { PluginRequirementStateService } from './plugin-requirement-state.service';
import { PluginStoreService } from './plugin-store.service';
@Injectable({ providedIn: 'root' })
export class PluginBootstrapService {
readonly requirementState = inject(PluginRequirementStateService);
readonly store = inject(PluginStoreService);
}

View File

@@ -2,6 +2,7 @@ import { Injectable, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { Subscription } from 'rxjs';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { DatabaseService } from '../../../../infrastructure/persistence';
import { VoiceConnectionFacade } from '../../../voice-connection/application/facades/voice-connection.facade';
import type {
Channel,
@@ -40,6 +41,7 @@ import { PluginUiRegistryService } from './plugin-ui-registry.service';
@Injectable({ providedIn: 'root' })
export class PluginClientApiService {
private readonly capabilities = inject(PluginCapabilityService);
private readonly db = inject(DatabaseService);
private readonly logger = inject(PluginLoggerService);
private readonly messageBus = inject(PluginMessageBusService);
private readonly realtime = inject(RealtimeSessionFacade);
@@ -159,11 +161,11 @@ export class PluginClientApiService {
messages: {
delete: (messageId) => {
requireCapability('messages.deleteOwn');
this.deletePluginMessage(messageId);
this.deletePluginMessage(pluginId, messageId);
},
edit: (messageId, content) => {
requireCapability('messages.editOwn');
this.editPluginMessage(messageId, content);
this.editPluginMessage(pluginId, messageId, content);
},
moderateDelete: (messageId) => {
requireCapability('messages.moderate');
@@ -175,7 +177,7 @@ export class PluginClientApiService {
},
send: (content, channelId) => {
requireCapability('messages.send');
return this.sendPluginMessage(content, channelId);
return this.sendPluginMessage(pluginId, content, channelId);
},
sendAsPluginUser: (request) => {
requireCapability('messages.send');
@@ -481,11 +483,18 @@ export class PluginClientApiService {
};
this.logger.info(pluginId, 'Plugin user message emitted', { messageId: message.id });
this.persistPluginMessage(pluginId, message);
this.store.dispatch(MessagesActions.receiveMessage({ message }));
this.voice.broadcastMessage({ type: 'chat-message', message } as unknown as ChatEvent);
}
private deletePluginMessage(messageId: string): void {
private deletePluginMessage(pluginId: string, messageId: string): void {
this.persistPluginMessageUpdate(pluginId, messageId, {
content: '[Message deleted]',
editedAt: Date.now(),
isDeleted: true
});
this.store.dispatch(MessagesActions.deleteMessageSuccess({ messageId }));
this.voice.broadcastMessage({
deletedAt: Date.now(),
@@ -494,9 +503,11 @@ export class PluginClientApiService {
} as unknown as ChatEvent);
}
private editPluginMessage(messageId: string, content: string): void {
private editPluginMessage(pluginId: string, messageId: string, content: string): void {
const editedAt = Date.now();
this.persistPluginMessageUpdate(pluginId, messageId, { content, editedAt });
this.store.dispatch(MessagesActions.editMessageSuccess({
content,
editedAt,
@@ -511,7 +522,7 @@ export class PluginClientApiService {
} as unknown as ChatEvent);
}
private sendPluginMessage(content: string, channelId?: string): Message {
private sendPluginMessage(pluginId: string, content: string, channelId?: string): Message {
const currentUser = this.currentUser();
const roomId = this.requireRoomId();
const message: Message = {
@@ -526,12 +537,25 @@ export class PluginClientApiService {
timestamp: Date.now()
};
this.persistPluginMessage(pluginId, message);
this.store.dispatch(MessagesActions.sendMessageSuccess({ message }));
this.voice.broadcastMessage({ type: 'chat-message', message } as unknown as ChatEvent);
return message;
}
private persistPluginMessage(pluginId: string, message: Message): void {
void this.db.saveMessage(message).catch((error: unknown) => {
this.logger.warn(pluginId, 'Failed to persist plugin message', error);
});
}
private persistPluginMessageUpdate(pluginId: string, messageId: string, updates: Partial<Message>): void {
void this.db.updateMessage(messageId, updates).catch((error: unknown) => {
this.logger.warn(pluginId, 'Failed to persist plugin message update', error);
});
}
private rememberSubscription(pluginId: string, eventName: string) {
this.logger.info(pluginId, `Subscribed to ${eventName}`);

View File

@@ -44,6 +44,7 @@ export class PluginHostService {
private readonly registry = inject(PluginRegistryService);
private readonly uiRegistry = inject(PluginUiRegistryService);
private readonly activePlugins = new Map<string, ActivePluginRuntime>();
private readonly activationRequests = new Map<string, Promise<boolean>>();
private readonly activationStateReady: Promise<void>;
private activatedPluginIds = new Set<string>();
@@ -96,11 +97,10 @@ export class PluginHostService {
continue;
}
await this.activatePlugin(entry);
const didActivate = await this.activatePlugin(entry);
const active = this.activePlugins.get(manifest.id);
if (active) {
if (didActivate && active) {
activated.push(active.context);
this.activatedPluginIds.add(active.context.pluginId);
}
@@ -126,11 +126,10 @@ export class PluginHostService {
return;
}
await this.activatePlugin(entry);
const didActivate = await this.activatePlugin(entry);
const active = this.activePlugins.get(pluginId);
if (!active) {
if (!didActivate || !active) {
return;
}
@@ -161,11 +160,10 @@ export class PluginHostService {
continue;
}
await this.activatePlugin(entry);
const didActivate = await this.activatePlugin(entry);
const active = this.activePlugins.get(manifest.id);
if (active) {
if (didActivate && active) {
activated.push(active.context);
}
}
@@ -265,19 +263,46 @@ export class PluginHostService {
}
}
private async activatePlugin(entry: RegisteredPlugin): Promise<void> {
private async activatePlugin(entry: RegisteredPlugin): Promise<boolean> {
const pluginId = entry.manifest.id;
if (this.activePlugins.has(pluginId)) {
return false;
}
const pendingActivation = this.activationRequests.get(pluginId);
if (pendingActivation) {
await pendingActivation;
return false;
}
const activation = this.activatePluginInternal(entry);
this.activationRequests.set(pluginId, activation);
try {
return await activation;
} finally {
if (this.activationRequests.get(pluginId) === activation) {
this.activationRequests.delete(pluginId);
}
}
}
private async activatePluginInternal(entry: RegisteredPlugin): Promise<boolean> {
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;
return false;
}
if (!manifest.entrypoint) {
this.registry.setState(manifest.id, 'ready');
return;
return false;
}
this.registry.setState(manifest.id, 'loading');
@@ -291,12 +316,14 @@ export class PluginHostService {
subscriptions: []
};
await module.activate?.(context);
await this.runWithPluginRuntimeGuards(manifest.id, () => module.activate?.(context));
this.activePlugins.set(manifest.id, { context, module, moduleObjectUrl });
this.registry.setState(manifest.id, 'loaded');
this.logger.info(manifest.id, 'Plugin activated');
return true;
} catch (error) {
this.failPlugin(manifest.id, error);
return false;
}
}
@@ -310,6 +337,27 @@ export class PluginHostService {
this.revokeModuleObjectUrl(pluginId);
}
private async runWithPluginRuntimeGuards(pluginId: string, activate: () => Promise<void> | void): Promise<void> {
const originalMutationObserver = globalThis.MutationObserver;
if (!originalMutationObserver) {
await activate();
return;
}
const guardedMutationObserver = createGuardedMutationObserver(originalMutationObserver, pluginId, this.logger);
globalThis.MutationObserver = guardedMutationObserver;
try {
await activate();
} finally {
if (globalThis.MutationObserver === guardedMutationObserver) {
globalThis.MutationObserver = originalMutationObserver;
}
}
}
private async loadPluginModule(
manifest: TojuPluginManifest,
sourcePath?: string
@@ -391,6 +439,10 @@ export class PluginHostService {
return new URL(manifest.entrypoint).toString();
} catch {}
if (manifest.bundle?.url && !sourcePath?.startsWith('file://')) {
return manifest.bundle.url;
}
if (sourcePath?.startsWith('http://') || sourcePath?.startsWith('https://') || sourcePath?.startsWith('file://')) {
return new URL(manifest.entrypoint, sourcePath).toString();
}
@@ -421,3 +473,61 @@ function safeDispose(disposable: TojuPluginDisposable, pluginId: string, logger:
logger.warn(pluginId, 'Plugin disposable failed', error);
}
}
function createGuardedMutationObserver(
NativeMutationObserver: typeof MutationObserver,
pluginId: string,
logger: PluginLoggerService
): typeof MutationObserver {
return class GuardedPluginMutationObserver implements MutationObserver {
private readonly nativeObserver: MutationObserver;
private readonly observations: { options?: MutationObserverInit; target: Node }[] = [];
private isDispatching = false;
constructor(private readonly callback: MutationCallback) {
this.nativeObserver = new NativeMutationObserver((records) => this.dispatch(records));
}
observe(target: Node, options?: MutationObserverInit): void {
const existing = this.observations.find((observation) => observation.target === target);
if (existing) {
existing.options = options;
} else {
this.observations.push({ options, target });
}
this.nativeObserver.observe(target, options);
}
disconnect(): void {
this.observations.length = 0;
this.nativeObserver.disconnect();
}
takeRecords(): MutationRecord[] {
return this.nativeObserver.takeRecords();
}
private dispatch(records: MutationRecord[]): void {
if (this.isDispatching) {
return;
}
this.isDispatching = true;
this.nativeObserver.disconnect();
try {
this.callback(records, this);
} catch (error) {
logger.warn(pluginId, 'Plugin MutationObserver callback failed', error);
} finally {
this.isDispatching = false;
for (const observation of this.observations) {
this.nativeObserver.observe(observation.target, observation.options);
}
}
}
};
}

View File

@@ -100,7 +100,7 @@ export class PluginMessageBusService {
request: PluginApiMessageBusLatestRequest,
includeMessages: boolean
): PluginApiMessageBusEnvelope {
const currentUser = this.currentUser();
const currentUser = this.currentUser() ?? null;
const envelope: PluginApiMessageBusEnvelope = {
eventId: createId(),
pluginId,
@@ -233,4 +233,4 @@ function isMessage(value: unknown): value is Message {
function createId(): string {
return globalThis.crypto?.randomUUID?.() ?? `plugin-bus-${Date.now()}-${Math.random().toString(36)
.slice(2)}`;
}
}

View File

@@ -15,8 +15,8 @@ import type {
TojuPluginManifest
} from '../../../../shared-kernel';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { selectCurrentRoomId } from '../../../../store/rooms/rooms.selectors';
import { ServerDirectoryFacade } from '../../../server-directory';
import { selectCurrentRoom, selectCurrentRoomId } from '../../../../store/rooms/rooms.selectors';
import { ServerDirectoryFacade, type ServerSourceSelector } from '../../../server-directory';
import { PluginRegistryService } from './plugin-registry.service';
import { PluginRequirementService } from './plugin-requirement.service';
@@ -44,6 +44,7 @@ export class PluginRequirementStateService {
private readonly serverDirectory = inject(ServerDirectoryFacade);
private readonly store = inject(Store);
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
private readonly currentRoomId = this.store.selectSignal(selectCurrentRoomId);
private readonly snapshotsSignal = signal<Record<string, PluginRequirementsSnapshot>>({});
private readonly refreshErrorsSignal = signal<Record<string, string>>({});
@@ -111,7 +112,7 @@ export class PluginRequirementStateService {
}
try {
const apiBaseUrl = this.serverDirectory.getApiBaseUrl();
const apiBaseUrl = this.serverDirectory.getApiBaseUrl(this.currentRoomSourceSelector());
const snapshot = await new Promise<PluginRequirementsSnapshot>((resolve, reject) => {
this.pluginRequirements.getSnapshot(apiBaseUrl, roomId).subscribe({
error: reject,
@@ -144,6 +145,19 @@ export class PluginRequirementStateService {
}));
}
private currentRoomSourceSelector(): ServerSourceSelector | undefined {
const room = this.currentRoom();
if (!room?.sourceId && !room?.sourceUrl) {
return undefined;
}
return {
sourceId: room.sourceId,
sourceUrl: room.sourceUrl
};
}
private resolveStatus(
requirement: PluginRequirementSummary,
entry: { enabled: boolean; manifest: TojuPluginManifest } | undefined

View File

@@ -129,6 +129,45 @@ describe('PluginStoreService', () => {
expect(service.installedPlugins()).toEqual([]);
});
it('caches plugin bundle entrypoints locally before registering installed plugins', async () => {
const manifest = createManifest({ entrypoint: './dist/main.js' });
const plugin = createStoreEntry({
bundleUrl: 'https://plugins.example.test/better/bundle.js',
version: '1.0.0'
});
const electronApi = {
ensureDir: vi.fn(async () => true),
getAppDataPath: vi.fn(async () => '/tmp/metoyou-user-data'),
writeFile: vi.fn(async () => true)
};
fetchMock
.mockResolvedValueOnce(jsonResponse(manifest))
.mockResolvedValueOnce(textResponse('export function activate() {}'));
const service = createService(registerLocalManifest, unregister, electronApi);
await service.installPlugin(plugin);
expect(electronApi.ensureDir).toHaveBeenCalledWith('/tmp/metoyou-user-data/plugin-bundles/example.better-channels/1.0.0');
expect(electronApi.writeFile).toHaveBeenCalledWith(
'/tmp/metoyou-user-data/plugin-bundles/example.better-channels/1.0.0/main.js',
expect.any(String)
);
expect(registerLocalManifest).toHaveBeenCalledWith(
expect.objectContaining({
bundle: {
entrypoint: './main.js',
url: plugin.bundleUrl
},
entrypoint: './main.js',
id: manifest.id
}),
'file:///tmp/metoyou-user-data/plugin-bundles/example.better-channels/1.0.0/toju-plugin.json'
);
});
it('loads plugin readmes as markdown text', async () => {
const plugin = createStoreEntry({ readmeUrl: 'https://plugins.example.test/better/README.md' });
@@ -149,7 +188,12 @@ describe('PluginStoreService', () => {
function createService(
registerLocalManifest: ReturnType<typeof vi.fn>,
unregister: ReturnType<typeof vi.fn>,
electronApi: { readFile: (filePath: string) => Promise<string> } | null = null
electronApi: {
ensureDir?: (dirPath: string) => Promise<boolean>;
getAppDataPath?: () => Promise<string>;
readFile?: (filePath: string) => Promise<string>;
writeFile?: (filePath: string, data: string) => Promise<boolean>;
} | null = null
): PluginStoreService {
const injector = Injector.create({
providers: [
@@ -165,6 +209,7 @@ function createService(
useValue: {
activatePersistedPlugins: vi.fn(async () => {}),
deactivatePlugin: vi.fn(async () => {}),
isPluginActive: vi.fn(() => false),
registerLocalManifest
}
},

View File

@@ -18,9 +18,14 @@ import type {
TojuPluginInstallScope,
TojuPluginManifest
} from '../../../../shared-kernel';
import { selectCurrentRoomId, selectCurrentRoomName } from '../../../../store/rooms/rooms.selectors';
import {
selectCurrentRoom,
selectCurrentRoomId,
selectCurrentRoomName,
selectSavedRooms
} from '../../../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { ServerDirectoryFacade } from '../../../server-directory';
import { ServerDirectoryFacade, type ServerSourceSelector } from '../../../server-directory';
import { getPluginInstallScope } from '../../domain/logic/plugin-install-scope.logic';
import { validateTojuPluginManifest } from '../../domain/logic/plugin-manifest-validation.logic';
import type {
@@ -39,6 +44,7 @@ import { PluginRegistryService } from './plugin-registry.service';
const STORE_SCHEMA_VERSION = 1;
const STORAGE_KEY_PLUGIN_STORE = 'metoyou_plugin_store';
const PLUGIN_CACHE_DIR = 'plugin-bundles';
const DEFAULT_STORE_STATE: PersistedPluginStoreState = {
installedPlugins: [],
sourceUrls: []
@@ -62,8 +68,10 @@ export class PluginStoreService {
private readonly registry = inject(PluginRegistryService);
private readonly serverDirectory = inject(ServerDirectoryFacade, { optional: true });
private readonly store = inject(Store, { optional: true });
private readonly currentRoom = this.store?.selectSignal(selectCurrentRoom) ?? null;
private readonly currentRoomId = this.store?.selectSignal(selectCurrentRoomId) ?? null;
private readonly currentRoomName = this.store?.selectSignal(selectCurrentRoomName) ?? null;
private readonly savedRooms = this.store?.selectSignal(selectSavedRooms) ?? null;
private readonly currentUser = this.store?.selectSignal(selectCurrentUser) ?? null;
private readonly sourceUrlsSignal = signal<string[]>([]);
private readonly sourcesSignal = signal<PluginStoreSourceResult[]>([]);
@@ -73,6 +81,7 @@ export class PluginStoreService {
private refreshAbortController: AbortController | null = null;
private refreshVersion = 0;
private installedLoadVersion = 0;
private autoUpdateInProgress = false;
private stateMutated = false;
readonly sourceUrls = this.sourceUrlsSignal.asReadonly();
@@ -94,6 +103,10 @@ export class PluginStoreService {
this.sourceUrlsSignal.set(state.sourceUrls);
void this.applyInstalledPlugins(state.installedPlugins, 'client');
if (state.sourceUrls.length > 0) {
void this.refreshSources();
}
if (this.currentRoomId && this.currentUser && this.serverDirectory) {
effect(() => {
const roomId = this.currentRoomId?.() ?? null;
@@ -147,6 +160,7 @@ export class PluginStoreService {
if (this.refreshVersion === currentRefresh) {
this.sourcesSignal.set(sources);
void this.autoUpdateInstalledPlugins();
}
} finally {
if (this.refreshVersion === currentRefresh) {
@@ -161,54 +175,88 @@ export class PluginStoreService {
throw new Error('Plugin does not provide an install manifest URL');
}
const manifest = options.manifest ?? await this.fetchPluginManifest(plugin.installUrl);
const manifest = this.withStoreBundleMetadata(options.manifest ?? await this.fetchPluginManifest(plugin.installUrl), plugin);
const installScope = getPluginInstallScope(manifest);
const targetServerId = installScope === 'server' ? (options.serverId ?? this.currentRoomId?.() ?? null) : null;
if (installScope === 'server' && !targetServerId) {
throw new Error('Open a chat server before installing server-scoped plugins');
}
const targetServerId = this.resolveInstallTargetServerId(installScope, options.serverId);
const now = Date.now();
const currentScopePlugins = installScope === 'server'
? await this.installedPluginsForServer(targetServerId)
: this.installedPluginsForScope(installScope);
const existing = currentScopePlugins.find((candidate) => candidate.manifest.id === manifest.id);
const installedPlugin: InstalledStorePlugin = {
const installedPlugin = await this.cacheInstalledPlugin({
bundleUrl: manifest.bundle?.url ?? plugin.bundleUrl,
installedAt: existing?.installedAt ?? now,
installUrl: plugin.installUrl,
manifest,
sourceUrl: plugin.sourceUrl,
updatedAt: now
};
});
const nextInstalledPlugins = currentScopePlugins
.filter((candidate) => candidate.manifest.id !== manifest.id)
.concat(installedPlugin)
.sort(sortInstalledPlugins);
if (installScope === 'server') {
await this.saveServerPluginRequirement(installedPlugin, targetServerId, options.optional === true ? 'optional' : 'required');
} else {
await this.persistInstalledPlugins(nextInstalledPlugins, installScope);
}
if (installScope === 'client' || targetServerId === this.currentRoomId?.()) {
this.host.registerLocalManifest(manifest, plugin.installUrl);
if (installScope === 'client' || options.optional !== true) {
this.setInstalledPluginsForScope(installScope, nextInstalledPlugins);
}
if (options.activate) {
await this.host.activatePluginById(manifest.id);
}
} else if (options.activate) {
await this.host.rememberActivation(manifest.id);
}
await this.persistInstallResult(installScope, targetServerId, nextInstalledPlugins, installedPlugin, options);
await this.registerInstallResult(installScope, targetServerId, nextInstalledPlugins, installedPlugin, options);
return installedPlugin;
}
private resolveInstallTargetServerId(installScope: TojuPluginInstallScope, requestedServerId: string | undefined): string | null {
if (installScope !== 'server') {
return null;
}
const targetServerId = requestedServerId ?? this.currentRoomId?.() ?? null;
if (!targetServerId) {
throw new Error('Open a chat server before installing server-scoped plugins');
}
return targetServerId;
}
private async persistInstallResult(
installScope: TojuPluginInstallScope,
targetServerId: string | null,
nextInstalledPlugins: InstalledStorePlugin[],
installedPlugin: InstalledStorePlugin,
options: PluginStoreInstallOptions
): Promise<void> {
if (installScope === 'server') {
await this.saveServerPluginRequirement(installedPlugin, targetServerId, options.optional === true ? 'optional' : 'required');
return;
}
await this.persistInstalledPlugins(nextInstalledPlugins, installScope);
}
private async registerInstallResult(
installScope: TojuPluginInstallScope,
targetServerId: string | null,
nextInstalledPlugins: InstalledStorePlugin[],
installedPlugin: InstalledStorePlugin,
options: PluginStoreInstallOptions
): Promise<void> {
if (installScope !== 'client' && targetServerId !== this.currentRoomId?.()) {
if (options.activate) {
await this.host.rememberActivation(installedPlugin.manifest.id);
}
return;
}
this.host.registerLocalManifest(installedPlugin.manifest, installedPlugin.cachedSourcePath ?? installedPlugin.installUrl);
if (installScope === 'client' || options.optional !== true) {
this.setInstalledPluginsForScope(installScope, nextInstalledPlugins);
}
if (options.activate) {
await this.host.activatePluginById(installedPlugin.manifest.id);
}
}
async loadInstallManifest(plugin: PluginStoreEntry): Promise<TojuPluginManifest> {
if (!plugin.installUrl) {
throw new Error('Plugin does not provide an install manifest URL');
@@ -217,21 +265,32 @@ export class PluginStoreService {
return await this.fetchPluginManifest(plugin.installUrl);
}
async uninstallPlugin(pluginId: string, scope?: TojuPluginInstallScope): Promise<void> {
async uninstallPlugin(pluginId: string, scope?: TojuPluginInstallScope, options: { serverId?: string } = {}): Promise<void> {
const installScope = scope ?? this.findInstalledPluginScope(pluginId) ?? 'client';
const nextInstalledPlugins = this.installedPluginsForScope(installScope).filter((installedPlugin) => installedPlugin.manifest.id !== pluginId);
const currentInstalledPlugins = installScope === 'server'
? await this.installedPluginsForServer(options.serverId ?? this.currentRoomId?.() ?? null)
: this.installedPluginsForScope(installScope);
const nextInstalledPlugins = currentInstalledPlugins.filter((installedPlugin) => installedPlugin.manifest.id !== pluginId);
if (installScope === 'server') {
await this.deleteServerPluginRequirement(pluginId);
await this.deleteServerPluginRequirement(pluginId, options.serverId);
} else {
await this.persistInstalledPlugins(nextInstalledPlugins, installScope);
}
if (installScope === 'server' && options.serverId && options.serverId !== this.currentRoomId?.()) {
return;
}
await this.host.deactivatePlugin(pluginId, { forgetActivation: true });
this.registry.unregister(pluginId);
this.setInstalledPluginsForScope(installScope, nextInstalledPlugins);
}
async loadInstalledPluginsForServer(serverId: string): Promise<InstalledStorePlugin[]> {
return await this.installedPluginsForServer(serverId);
}
async loadReadme(plugin: PluginStoreEntry): Promise<PluginStoreReadme> {
if (!plugin.readmeUrl) {
throw new Error('Plugin does not provide a readme URL');
@@ -332,6 +391,107 @@ export class PluginStoreService {
return new TextDecoder().decode(bytes);
}
private withStoreBundleMetadata(manifest: TojuPluginManifest, plugin: PluginStoreEntry): TojuPluginManifest {
if (!plugin.bundleUrl || manifest.bundle?.url) {
return manifest;
}
return {
...manifest,
bundle: {
entrypoint: './main.js',
url: plugin.bundleUrl
}
};
}
private async cacheInstalledPlugin(installedPlugin: InstalledStorePlugin): Promise<InstalledStorePlugin> {
if (installedPlugin.cachedSourcePath) {
return installedPlugin;
}
const api = this.electronBridge.getApi();
const entrypointSourceUrl = this.resolvePluginBundleSourceUrl(installedPlugin);
const cachedEntrypoint = this.resolveCachedEntrypointPath(installedPlugin.manifest);
if (!api || !entrypointSourceUrl || !cachedEntrypoint) {
return installedPlugin;
}
const cachedManifest = this.toCachedRuntimeManifest(installedPlugin.manifest, cachedEntrypoint);
const appDataPath = await api.getAppDataPath();
const pluginCacheDir = joinLocalPath(
appDataPath,
PLUGIN_CACHE_DIR,
sanitizePathSegment(installedPlugin.manifest.id),
sanitizePathSegment(installedPlugin.manifest.version)
);
const manifestPath = joinLocalPath(pluginCacheDir, 'toju-plugin.json');
const entrypointPath = joinLocalPath(pluginCacheDir, cachedEntrypoint);
const cacheRootUrl = localPathToFileUrl(manifestPath);
if (!cacheRootUrl) {
return installedPlugin;
}
await api.ensureDir(dirnameLocalPath(entrypointPath));
await api.writeFile(entrypointPath, bytesToBase64(new TextEncoder().encode(await this.fetchText(entrypointSourceUrl, 'text/javascript,*/*'))));
await api.writeFile(manifestPath, bytesToBase64(new TextEncoder().encode(JSON.stringify(cachedManifest, null, 2))));
return {
...installedPlugin,
bundleUrl: installedPlugin.bundleUrl ?? installedPlugin.manifest.bundle?.url,
cachedAt: Date.now(),
cachedSourcePath: cacheRootUrl,
manifest: cachedManifest
};
}
private toCachedRuntimeManifest(manifest: TojuPluginManifest, cachedEntrypoint: string): TojuPluginManifest {
if (!manifest.bundle?.url) {
return manifest;
}
return {
...manifest,
entrypoint: cachedEntrypoint.startsWith('./') ? cachedEntrypoint : `./${cachedEntrypoint}`
};
}
private resolvePluginBundleSourceUrl(installedPlugin: InstalledStorePlugin): string | null {
const bundleUrl = installedPlugin.bundleUrl ?? installedPlugin.manifest.bundle?.url;
if (bundleUrl) {
return bundleUrl;
}
const entrypoint = installedPlugin.manifest.entrypoint;
if (!entrypoint || !installedPlugin.installUrl || isAbsolutePluginUrl(entrypoint)) {
return null;
}
return resolveOptionalUrl(installedPlugin.installUrl, entrypoint) ?? null;
}
private resolveCachedEntrypointPath(manifest: TojuPluginManifest): string | null {
const entrypoint = manifest.bundle?.url
? manifest.bundle.entrypoint ?? './main.js'
: manifest.entrypoint;
if (!entrypoint || isAbsolutePluginUrl(entrypoint)) {
return null;
}
const normalized = entrypoint.replace(/^\.\//, '').replace(/\\/g, '/');
if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) {
return null;
}
return normalized;
}
private async applyInstalledPlugins(installedPlugins: InstalledStorePlugin[], scope: TojuPluginInstallScope): Promise<void> {
const usableInstalledPlugins: InstalledStorePlugin[] = [];
const scopedInstalledPlugins = installedPlugins.filter((installedPlugin) => getPluginInstallScope(installedPlugin.manifest) === scope);
@@ -346,8 +506,10 @@ export class PluginStoreService {
for (const installedPlugin of scopedInstalledPlugins) {
try {
this.host.registerLocalManifest(installedPlugin.manifest, installedPlugin.installUrl);
usableInstalledPlugins.push(installedPlugin);
const cachedPlugin = await this.cacheInstalledPlugin(installedPlugin);
this.host.registerLocalManifest(cachedPlugin.manifest, cachedPlugin.cachedSourcePath ?? cachedPlugin.installUrl);
usableInstalledPlugins.push(cachedPlugin);
} catch {
// Corrupt persisted manifests are ignored so the store can recover on next install.
}
@@ -355,15 +517,77 @@ export class PluginStoreService {
this.setInstalledPluginsForScope(scope, usableInstalledPlugins);
await this.host.activatePersistedPlugins();
if (scope === 'server') {
await this.activateServerPlugins(usableInstalledPlugins);
} else {
await this.host.activatePersistedPlugins();
}
if (usableInstalledPlugins.length !== scopedInstalledPlugins.length) {
if (scope === 'client') {
await this.persistInstalledPlugins(usableInstalledPlugins, scope);
}
} else if (
scope === 'client'
&& usableInstalledPlugins.some((plugin, index) => plugin.cachedSourcePath !== scopedInstalledPlugins[index]?.cachedSourcePath)
) {
await this.persistInstalledPlugins(usableInstalledPlugins, scope);
}
}
private async activateServerPlugins(installedPlugins: InstalledStorePlugin[]): Promise<void> {
for (const installedPlugin of installedPlugins) {
await this.host.activatePluginById(installedPlugin.manifest.id);
}
}
private async autoUpdateInstalledPlugins(): Promise<void> {
if (this.autoUpdateInProgress || this.sources().length === 0) {
return;
}
this.autoUpdateInProgress = true;
try {
await this.autoUpdateScope('client');
if (this.currentRoomId?.()) {
await this.autoUpdateScope('server');
}
} finally {
this.autoUpdateInProgress = false;
}
}
private async autoUpdateScope(scope: TojuPluginInstallScope): Promise<void> {
for (const installedPlugin of this.installedPluginsForScope(scope)) {
const update = this.findUpdateCandidate(installedPlugin, scope);
if (!update) {
continue;
}
try {
await this.installPlugin(update, {
activate: this.host.isPluginActive(installedPlugin.manifest.id),
serverId: scope === 'server' ? this.currentRoomId?.() ?? undefined : undefined
});
} catch {}
}
}
private findUpdateCandidate(installedPlugin: InstalledStorePlugin, scope: TojuPluginInstallScope): PluginStoreEntry | null {
const candidates = this.availablePlugins().filter((plugin) => {
return plugin.id === installedPlugin.manifest.id
&& getStoreEntryInstallScope(plugin) === scope
&& (!installedPlugin.sourceUrl || plugin.sourceUrl === installedPlugin.sourceUrl);
});
return candidates
.filter((plugin) => compareVersions(plugin.version, installedPlugin.manifest.version) > 0)
.sort((left, right) => compareVersions(right.version, left.version))[0] ?? null;
}
private async loadInstalledPluginsForScope(roomId: string | null, actorUserId: string | null): Promise<void> {
const currentLoad = this.installedLoadVersion + 1;
@@ -418,7 +642,7 @@ export class PluginStoreService {
return [];
}
const snapshot = await firstValueFrom(this.pluginRequirements.getSnapshot(this.serverDirectory.getApiBaseUrl(), roomId));
const snapshot = await firstValueFrom(this.pluginRequirements.getSnapshot(this.getPluginApiBaseUrl(roomId), roomId));
return snapshot.requirements
.map((requirement) => installedPluginFromRequirement(requirement))
@@ -438,7 +662,7 @@ export class PluginStoreService {
}
await firstValueFrom(this.pluginRequirements.upsertRequirement(
this.serverDirectory.getApiBaseUrl(),
this.getPluginApiBaseUrl(roomId),
roomId,
installedPlugin.manifest.id,
{
@@ -453,15 +677,51 @@ export class PluginStoreService {
));
}
private async deleteServerPluginRequirement(pluginId: string): Promise<void> {
const roomId = this.currentRoomId?.() ?? null;
private async deleteServerPluginRequirement(pluginId: string, serverId?: string): Promise<void> {
const roomId = serverId ?? this.currentRoomId?.() ?? null;
const actorUserId = this.currentActorUserId();
if (!roomId || !actorUserId || !this.serverDirectory) {
throw new Error('Open a chat server before removing server-scoped plugins');
}
await firstValueFrom(this.pluginRequirements.deleteRequirement(this.serverDirectory.getApiBaseUrl(), roomId, pluginId, actorUserId));
await firstValueFrom(this.pluginRequirements.deleteRequirement(this.getPluginApiBaseUrl(roomId), roomId, pluginId, actorUserId));
}
private getPluginApiBaseUrl(serverId: string): string {
const selector = this.serverSourceSelector(serverId);
return this.serverDirectory?.getApiBaseUrl(selector) ?? '';
}
private serverSourceSelector(serverId: string): ServerSourceSelector | undefined {
if (serverId === this.currentRoomId?.()) {
return this.currentRoomSourceSelector();
}
const room = this.savedRooms?.().find((candidate) => candidate.id === serverId) ?? null;
if (!room?.sourceId && !room?.sourceUrl) {
return undefined;
}
return {
sourceId: room.sourceId,
sourceUrl: room.sourceUrl
};
}
private currentRoomSourceSelector(): ServerSourceSelector | undefined {
const room = this.currentRoom?.() ?? null;
if (!room?.sourceId && !room?.sourceUrl) {
return undefined;
}
return {
sourceId: room.sourceId,
sourceUrl: room.sourceUrl
};
}
private currentActorUserId(): string | null {
@@ -591,6 +851,7 @@ function installedPluginFromRequirement(requirement: PluginRequirementSummary):
}
return {
bundleUrl: manifest.bundle?.url,
installedAt: requirement.updatedAt,
installUrl: requirement.installUrl,
manifest,
@@ -640,6 +901,7 @@ function parsePluginEntry(sourceUrl: string, sourceTitle: string, value: unknown
return {
author: readAuthor(value),
bundleUrl: resolveOptionalUrl(sourceUrl, readString(value, 'bundle', 'bundleUrl')),
description: readString(value, 'description', 'summary') ?? '',
githubUrl: resolveOptionalUrl(sourceUrl, readGithubUrl(value)),
homepageUrl: resolveOptionalUrl(sourceUrl, readString(value, 'homepage', 'homepageUrl', 'website')),
@@ -794,6 +1056,44 @@ function isAllowedPluginSourceProtocol(protocol: string): boolean {
return protocol === 'http:' || protocol === 'https:' || protocol === 'file:';
}
function isAbsolutePluginUrl(value: string): boolean {
try {
const url = new URL(value);
return isAllowedPluginSourceProtocol(url.protocol);
} catch {
return false;
}
}
function sanitizePathSegment(value: string): string {
return value.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 128) || 'plugin';
}
function joinLocalPath(...parts: string[]): string {
return parts
.map((part, index) => index === 0 ? part.replace(/[\\/]+$/, '') : part.replace(/^[\\/]+|[\\/]+$/g, ''))
.filter(Boolean)
.join('/');
}
function dirnameLocalPath(filePath: string): string {
const normalized = filePath.replace(/\\/g, '/');
const index = normalized.lastIndexOf('/');
return index > 0 ? normalized.slice(0, index) : normalized;
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = '';
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
function localPathToFileUrl(filePath: string): string | undefined {
if (!isAbsoluteLocalPath(filePath)) {
return undefined;

View File

@@ -5,6 +5,7 @@ export type PluginStoreActionLabel = 'Install' | 'Install to Server' | 'Remove f
export interface PluginStoreEntry {
author?: string;
bundleUrl?: string;
description: string;
githubUrl?: string;
homepageUrl?: string;
@@ -28,6 +29,9 @@ export interface PluginStoreSourceResult {
}
export interface InstalledStorePlugin {
bundleUrl?: string;
cachedAt?: number;
cachedSourcePath?: string;
installedAt: number;
installUrl?: string;
manifest: TojuPluginManifest;

View File

@@ -61,6 +61,7 @@ type PluginManagerTab = 'docs' | 'extensions' | 'installed' | 'logs' | 'requirem
})
export class PluginManagerComponent {
@Output() readonly closed = new EventEmitter<void>();
@Output() readonly storeOpened = new EventEmitter<void>();
readonly scope = input<TojuPluginInstallScope>('client');
@@ -149,7 +150,7 @@ export class PluginManagerComponent {
openStore(): void {
const returnUrl = this.router.url.startsWith('/plugin-store') ? '/search' : this.router.url;
this.closed.emit();
this.storeOpened.emit();
void this.router.navigate(['/plugin-store'], { queryParams: { returnUrl } });
}

View File

@@ -1,255 +1,332 @@
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity, @angular-eslint/template/prefer-ngsrc -->
<main
class="plugin-store"
class="min-h-[calc(100vh-2.5rem)] bg-background px-3 py-4 text-foreground sm:px-6"
data-testid="plugin-store-page"
>
<header class="plugin-store__topbar">
<div class="plugin-store__title-row">
<header class="flex flex-col gap-3 border-b border-border pb-3 lg:flex-row lg:items-center lg:justify-between">
<div class="flex min-w-0 items-center gap-3">
<button
type="button"
(click)="goBack()"
class="plugin-store__icon-button"
class="grid h-8 w-8 shrink-0 place-items-center rounded-lg border border-border text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title="Back to app"
>
<ng-icon name="lucideArrowLeft" />
<ng-icon
name="lucideArrowLeft"
class="h-4 w-4"
/>
</button>
<div class="plugin-store__brand-icon">
<ng-icon name="lucideStore" />
<div class="grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-primary/10 text-primary">
<ng-icon
name="lucideStore"
class="h-5 w-5"
/>
</div>
<div class="plugin-store__title-copy">
<h1>Plugin Store</h1>
<p>
<div class="min-w-0">
<h1 class="truncate text-xl font-semibold leading-7">Plugin Store</h1>
<p class="truncate text-sm text-muted-foreground">
{{ installedCount() }} installed for {{ store.installScopeLabel() }} · {{ totalSourcePlugins() }} available · {{ sourceCount() }} sources
</p>
</div>
</div>
<div class="plugin-store__top-actions">
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
(click)="openManager()"
class="plugin-store__secondary-button"
class="inline-flex min-h-8 items-center justify-center gap-2 rounded-lg border border-border bg-card px-3 py-1.5 text-sm font-semibold transition-colors hover:bg-secondary"
>
<ng-icon name="lucideSettings" />
<ng-icon
name="lucideSettings"
class="h-4 w-4"
/>
Manage Plugins
</button>
<button
type="button"
(click)="refreshSources()"
[disabled]="store.isLoading()"
class="plugin-store__secondary-button"
class="inline-flex min-h-8 items-center justify-center gap-2 rounded-lg border border-border bg-card px-3 py-1.5 text-sm font-semibold transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-55"
>
<ng-icon
name="lucideRefreshCw"
[class.is-spinning]="store.isLoading()"
class="h-4 w-4"
[class.animate-spin]="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">
<section class="grid gap-3 py-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<div class="flex min-w-0 flex-col gap-2 sm:flex-row">
<label class="relative flex min-w-0 flex-1">
<input
type="text"
[(ngModel)]="newSourceUrl"
(keyup.enter)="addSourceUrl()"
placeholder="https://example.com/plugins.json or /home/me/plugins/source.json"
aria-label="Plugin source manifest URL"
class="min-h-9 w-full rounded-lg border border-border bg-secondary px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</label>
<button
type="button"
(click)="addSourceUrl()"
[disabled]="!newSourceUrl.trim() || store.isLoading()"
class="plugin-store__primary-button"
class="inline-flex min-h-9 items-center justify-center gap-2 rounded-lg border border-primary bg-primary px-3 py-2 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-55"
>
<ng-icon name="lucidePlus" />
<ng-icon
name="lucidePlus"
class="h-4 w-4"
/>
Add Source
</button>
</div>
@if (sourceError()) {
<p class="plugin-store__error-text">{{ sourceError() }}</p>
<p class="text-sm text-destructive">{{ sourceError() }}</p>
}
</section>
<div class="plugin-store__layout">
<div
class="grid items-start gap-3"
[ngClass]="readme() ? 'xl:grid-cols-[minmax(13rem,17rem)_minmax(0,1fr)_minmax(24rem,38rem)]' : 'xl:grid-cols-[minmax(13rem,17rem)_minmax(0,1fr)]'"
>
<aside
class="plugin-store__rail"
aria-label="Plugin sources"
class="grid gap-3 xl:sticky xl:top-3"
aria-label="Plugin sources and filters"
>
<section class="plugin-store__panel">
<div class="plugin-store__panel-header">
<h2>Sources</h2>
<span>{{ sourceCount() }}</span>
<section class="grid min-w-0 gap-1 rounded-lg border border-border bg-card p-3">
<div class="mb-1 flex items-center justify-between gap-2">
<h2 class="text-xs font-bold uppercase text-foreground">Sources</h2>
<span class="rounded-full bg-secondary px-2 py-0.5 text-xs text-muted-foreground">{{ sourceCount() }}</span>
</div>
<button
type="button"
class="plugin-store__source-filter"
[class.is-active]="selectedSourceUrl() === null"
class="flex min-w-0 items-center justify-between gap-2 rounded-md px-2 py-2 text-left text-sm text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
[class.bg-secondary]="selectedSourceUrl() === null"
[class.text-foreground]="selectedSourceUrl() === null"
(click)="selectSource(null)"
>
<span>All sources</span>
<strong>{{ totalSourcePlugins() }}</strong>
<span class="truncate">All sources</span>
<strong class="rounded-full bg-background px-2 py-0.5 text-xs text-muted-foreground">{{ totalSourcePlugins() }}</strong>
</button>
@for (source of store.sources(); track source.url) {
<div
class="plugin-store__source-row"
[class.has-error]="!!source.error"
>
<div class="flex min-w-0 items-center gap-1">
<button
type="button"
class="plugin-store__source-filter"
[class.is-active]="selectedSourceUrl() === source.url"
class="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-md px-2 py-2 text-left text-sm text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
[class.bg-secondary]="selectedSourceUrl() === source.url"
[class.text-foreground]="selectedSourceUrl() === source.url"
(click)="selectSource(source.url)"
>
<span>{{ source.title || source.url }}</span>
<strong>{{ source.plugins.length }}</strong>
<span class="truncate">{{ source.title || source.url }}</span>
<strong class="rounded-full bg-background px-2 py-0.5 text-xs text-muted-foreground">{{ source.plugins.length }}</strong>
</button>
<button
type="button"
(click)="removeSourceUrl(source.url)"
class="plugin-store__icon-button plugin-store__icon-button--danger"
class="grid h-8 w-8 shrink-0 place-items-center rounded-lg border border-border text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
title="Remove source"
>
<ng-icon name="lucideTrash2" />
<ng-icon
name="lucideTrash2"
class="h-4 w-4"
/>
</button>
</div>
@if (source.error) {
<p class="plugin-store__source-error">{{ source.error }}</p>
<p class="px-2 text-xs text-destructive">{{ source.error }}</p>
}
}
@for (sourceUrl of pendingSourceUrls(); track sourceUrl) {
<div class="plugin-store__source-row">
<div class="flex min-w-0 items-center gap-1">
<button
type="button"
class="plugin-store__source-filter"
[class.is-active]="selectedSourceUrl() === sourceUrl"
class="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-md px-2 py-2 text-left text-sm text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
[class.bg-secondary]="selectedSourceUrl() === sourceUrl"
[class.text-foreground]="selectedSourceUrl() === sourceUrl"
(click)="selectSource(sourceUrl)"
>
<span>{{ sourceUrl }}</span>
<strong>0</strong>
<span class="truncate">{{ sourceUrl }}</span>
<strong class="rounded-full bg-background px-2 py-0.5 text-xs text-muted-foreground">0</strong>
</button>
<button
type="button"
(click)="removeSourceUrl(sourceUrl)"
class="plugin-store__icon-button plugin-store__icon-button--danger"
class="grid h-8 w-8 shrink-0 place-items-center rounded-lg border border-border text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
title="Remove source"
>
<ng-icon name="lucideTrash2" />
<ng-icon
name="lucideTrash2"
class="h-4 w-4"
/>
</button>
</div>
}
</section>
<section class="plugin-store__panel">
<div class="plugin-store__panel-header">
<h2>Filters</h2>
<section class="grid min-w-0 gap-2 rounded-lg border border-border bg-card p-3">
<div class="flex items-center justify-between gap-2">
<h2 class="text-xs font-bold uppercase text-foreground">Filters</h2>
</div>
<button
type="button"
class="plugin-store__toggle-button"
[class.is-active]="showInstalledOnly()"
class="flex min-w-0 items-center justify-between gap-2 rounded-md px-2 py-2 text-left text-sm text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
[class.bg-secondary]="showInstalledOnly()"
[class.text-foreground]="showInstalledOnly()"
(click)="toggleInstalledOnly()"
>
<span>Installed only</span>
<strong>{{ installedCount() }}</strong>
<strong class="rounded-full bg-background px-2 py-0.5 text-xs text-muted-foreground">{{ installedCount() }}</strong>
</button>
</section>
<section class="grid min-w-0 gap-2 rounded-lg border border-border bg-card p-3">
<div class="flex items-center justify-between gap-2">
<h2 class="text-xs font-bold uppercase text-foreground">Install server</h2>
<span class="rounded-full bg-secondary px-2 py-0.5 text-xs text-muted-foreground">{{ manageableServers().length }}</span>
</div>
@if (manageableServers().length > 0) {
@for (server of manageableServers(); track trackServer($index, server)) {
<button
type="button"
(click)="selectStoreServer(server.id)"
class="group flex min-w-0 items-start gap-2 rounded-md border border-transparent px-2 py-2 text-left transition-colors hover:border-primary/40 hover:bg-secondary"
[class.border-primary]="selectedStoreServerId() === server.id"
[ngClass]="{ 'bg-primary/10': selectedStoreServerId() === server.id }"
>
<span
class="mt-1 h-2 w-2 shrink-0 rounded-full bg-muted-foreground group-hover:bg-primary"
[class.bg-primary]="selectedStoreServerId() === server.id"
></span>
<span class="min-w-0">
<span class="block truncate text-sm font-semibold text-foreground">{{ server.name }}</span>
<span class="block truncate text-xs text-muted-foreground">{{ server.sourceUrl || 'Default endpoint' }}</span>
</span>
</button>
}
} @else {
<p class="rounded-md border border-border bg-secondary/40 px-2 py-2 text-xs leading-5 text-muted-foreground">
No server is available for plugin installs. Owner or Manage Server access is required.
</p>
}
</section>
</aside>
<section
class="plugin-store__catalog"
class="grid min-w-0 gap-3 rounded-lg border border-border bg-card p-3"
aria-label="Available plugins"
>
<div class="plugin-store__toolbar">
<label class="plugin-store__input-shell plugin-store__search">
<ng-icon name="lucideSearch" />
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<label class="relative flex min-w-0 flex-1 sm:max-w-xl">
<ng-icon
name="lucideSearch"
class="pointer-events-none absolute inset-y-0 left-3 my-auto h-4 w-4 text-muted-foreground"
/>
<input
type="search"
[ngModel]="searchTerm()"
(ngModelChange)="searchTerm.set($event)"
placeholder="Search plugins, authors, ids"
aria-label="Search plugins"
class="min-h-9 w-full rounded-lg border border-border bg-secondary py-2 pl-9 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</label>
<div class="plugin-store__count">{{ filteredPlugins().length }} shown</div>
<div class="text-sm text-muted-foreground">{{ filteredPlugins().length }} shown</div>
</div>
@if (actionError()) {
<p class="plugin-store__error-banner">{{ actionError() }}</p>
<p class="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">{{ actionError() }}</p>
}
@if (readmeError()) {
<p class="plugin-store__error-banner">{{ readmeError() }}</p>
<p class="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">{{ readmeError() }}</p>
}
@if (filteredPlugins().length > 0) {
<div class="plugin-store__grid">
<div class="grid gap-3">
@for (plugin of filteredPlugins(); track trackPlugin($index, plugin)) {
<article class="plugin-card">
<div class="plugin-card__media">
<article class="grid min-w-0 overflow-hidden rounded-lg border border-border bg-background sm:grid-cols-[5.5rem_minmax(0,1fr)]">
<div class="grid min-h-24 place-items-center bg-secondary text-muted-foreground sm:min-h-full">
@if (plugin.imageUrl) {
<img
[src]="plugin.imageUrl"
[alt]="plugin.title"
(error)="hideBrokenImage($event)"
class="h-full w-full object-cover"
/>
} @else {
<ng-icon name="lucidePackage" />
<ng-icon
name="lucidePackage"
class="h-6 w-6"
/>
}
</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 class="grid min-w-0 gap-2 p-3">
<div class="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] gap-2">
<div class="min-w-0">
<h2 class="truncate text-base font-semibold leading-6">{{ plugin.title }}</h2>
<p class="truncate text-sm text-muted-foreground">{{ 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>
@if (getPluginInstallState(plugin) === 'updateAvailable') {
<span
class="self-start whitespace-nowrap rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-primary"
>Update</span
>
}
@if (getPluginInstallState(plugin) === 'installed') {
<span
class="self-start whitespace-nowrap rounded-full bg-emerald-600/10 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-emerald-600"
>Installed</span
>
}
</div>
<p class="plugin-card__description">{{ plugin.description }}</p>
<p class="line-clamp-2 min-h-10 text-sm text-muted-foreground">{{ plugin.description }}</p>
<div class="plugin-card__meta">
<span>{{ plugin.id }}</span>
<span>{{ plugin.sourceTitle || plugin.sourceUrl }}</span>
<div class="flex min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span class="max-w-full truncate rounded-full bg-secondary px-2 py-1">{{ plugin.id }}</span>
<span class="max-w-full truncate rounded-full bg-secondary px-2 py-1">{{ plugin.sourceTitle || plugin.sourceUrl }}</span>
</div>
<div class="plugin-card__actions">
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
(click)="runPrimaryAction(plugin)"
[disabled]="isPrimaryActionDisabled(plugin)"
[title]="serverInstallButtonTitle(plugin)"
class="plugin-store__primary-button plugin-card__primary-action"
[class.plugin-card__primary-action--danger]="store.getActionLabel(plugin) === 'Uninstall' || store.getActionLabel(plugin) === 'Remove from Server'"
class="inline-flex min-h-8 items-center justify-center gap-2 rounded-lg border border-primary bg-primary px-3 py-1.5 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-55"
[ngClass]="{
'border-destructive/35':
getPrimaryActionLabel(plugin) === 'Uninstall' || getPrimaryActionLabel(plugin) === 'Remove from Server',
'bg-destructive/10': getPrimaryActionLabel(plugin) === 'Uninstall' || getPrimaryActionLabel(plugin) === 'Remove from Server'
}"
[class.text-destructive]="getPrimaryActionLabel(plugin) === 'Uninstall' || getPrimaryActionLabel(plugin) === 'Remove from Server'"
>
<ng-icon
[name]="primaryActionIcon(plugin)"
[class.is-spinning]="isPluginBusy(plugin)"
class="h-4 w-4"
[class.animate-spin]="isPluginBusy(plugin)"
/>
{{ store.getActionLabel(plugin) }}
{{ getPrimaryActionLabel(plugin) }}
</button>
@if (plugin.readmeUrl) {
<button
type="button"
(click)="loadReadme(plugin)"
class="plugin-store__text-button"
class="inline-flex min-h-8 items-center justify-center rounded-lg px-3 py-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title="Load readme"
>
{{ isReadmeLoading(plugin) ? 'Loading' : 'Readme' }}
@@ -260,10 +337,13 @@
<button
type="button"
(click)="openExternal(plugin.githubUrl)"
class="plugin-store__icon-button"
class="grid h-8 w-8 place-items-center rounded-lg border border-border text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title="Open GitHub"
>
<ng-icon name="lucideExternalLink" />
<ng-icon
name="lucideExternalLink"
class="h-4 w-4"
/>
</button>
}
</div>
@@ -272,43 +352,77 @@
}
</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 class="grid min-h-56 place-items-center rounded-lg border border-dashed border-border bg-background p-8 text-center">
<div class="grid justify-items-center gap-2">
<ng-icon
name="lucidePackage"
class="h-7 w-7 text-muted-foreground"
/>
<h2 class="text-base font-semibold">No plugins found</h2>
<p class="max-w-md text-sm text-muted-foreground">
{{ sourceCount() ? 'Adjust filters or add another source manifest.' : 'Add a plugin source manifest URL to populate the catalog.' }}
</p>
</div>
</section>
}
</section>
@if (readme()) {
<aside
class="plugin-store__readme"
class="grid min-w-0 gap-3 rounded-lg border border-border bg-card p-3 xl:sticky xl:top-3 xl:max-h-[calc(100vh-6rem)]"
aria-label="Plugin readme"
>
<div class="plugin-store__readme-header">
<div>
<p>Readme</p>
<h2>{{ readme()!.title }}</h2>
<div class="grid grid-cols-[minmax(0,1fr)_auto] gap-3">
<div class="min-w-0">
<p class="mb-1 text-xs font-bold uppercase text-primary">Readme</p>
<h2 class="truncate text-base font-semibold">{{ readme()!.title }}</h2>
@if (selectedReadmePlugin(); as plugin) {
<span>{{ plugin.author || 'Unknown author' }} · v{{ plugin.version }}</span>
<span class="block truncate text-sm text-muted-foreground">{{ 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 class="flex items-start gap-2">
<button
type="button"
(click)="toggleReadmeRawMode()"
class="inline-flex min-h-8 items-center justify-center rounded-lg border border-border bg-secondary px-3 py-1.5 text-xs font-semibold text-foreground transition-colors hover:bg-secondary/80"
>
{{ readmeRawMode() ? 'Parsed' : 'Raw' }}
</button>
<button
type="button"
(click)="closeReadme()"
class="grid h-8 w-8 place-items-center rounded-lg border border-border text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title="Close readme"
>
<ng-icon
name="lucideX"
class="h-4 w-4"
/>
</button>
</div>
</div>
<pre>{{ readme()!.markdown }}</pre>
@if (readmeRawMode()) {
<pre class="max-h-[calc(100vh-14rem)] overflow-auto rounded-lg bg-secondary/50 p-3 text-sm whitespace-pre-wrap">{{
readme()!.markdown
}}</pre>
} @else {
<div
class="max-h-[calc(100vh-14rem)] overflow-auto rounded-lg bg-secondary/30 p-3 text-sm leading-6 [&_a]:text-primary [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_code]:rounded [&_code]:bg-background [&_code]:px-1 [&_h1]:mb-2 [&_h1]:text-xl [&_h1]:font-semibold [&_h2]:mb-2 [&_h2]:mt-4 [&_h2]:text-lg [&_h2]:font-semibold [&_h3]:mb-1 [&_h3]:mt-3 [&_h3]:font-semibold [&_li]:ml-5 [&_ol]:list-decimal [&_p]:mb-3 [&_pre]:mb-3 [&_pre]:overflow-auto [&_pre]:rounded-lg [&_pre]:bg-background [&_pre]:p-3 [&_ul]:list-disc"
>
<app-chat-message-markdown [content]="readme()!.markdown" />
</div>
}
<button
type="button"
(click)="openExternal(readme()!.url)"
class="plugin-store__secondary-button plugin-store__readme-link"
class="inline-flex min-h-8 items-center justify-center gap-2 rounded-lg border border-border bg-card px-3 py-1.5 text-sm font-semibold transition-colors hover:bg-secondary"
>
<ng-icon name="lucideExternalLink" />
<ng-icon
name="lucideExternalLink"
class="h-4 w-4"
/>
Open source readme
</button>
</aside>
@@ -317,37 +431,46 @@
@if (serverInstallDialog(); as dialog) {
<div
class="plugin-store__modal-backdrop"
class="fixed inset-0 z-[80] bg-black/60"
role="presentation"
></div>
<section
class="plugin-store__install-modal"
class="fixed left-1/2 top-1/2 z-[81] flex max-h-[min(42rem,calc(100vh-2rem))] w-[min(34rem,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-lg border border-border bg-card text-foreground shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="server-plugin-install-title"
>
<header class="plugin-store__install-header">
<div>
<p>Server plugin install</p>
<h2 id="server-plugin-install-title">{{ dialog.manifest.title }}</h2>
<header class="flex items-start justify-between gap-4 border-b border-border p-4">
<div class="min-w-0">
<p class="text-sm text-muted-foreground">Server plugin install</p>
<h2
id="server-plugin-install-title"
class="mt-1 truncate text-lg font-semibold"
>
{{ dialog.manifest.title }}
</h2>
</div>
<button
type="button"
(click)="closeServerInstallDialog()"
class="plugin-store__icon-button"
class="grid h-8 w-8 shrink-0 place-items-center rounded-lg border border-border text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title="Cancel install"
>
<ng-icon name="lucideX" />
<ng-icon
name="lucideX"
class="h-4 w-4"
/>
</button>
</header>
<div class="plugin-store__install-body">
<label class="plugin-store__field">
<span>Install to server</span>
<div class="grid min-h-0 gap-4 overflow-auto p-4">
<label class="grid gap-2">
<span class="text-sm text-muted-foreground">Install to server</span>
<select
[value]="dialog.selectedServerId"
[disabled]="serverInstallBusy()"
(change)="selectServerInstallTarget($any($event.target).value)"
class="min-h-9 rounded-lg border border-border bg-secondary px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-55"
>
@for (server of manageableServers(); track trackServer($index, server)) {
<option [value]="server.id">{{ server.name }}</option>
@@ -355,7 +478,7 @@
</select>
</label>
<label class="plugin-store__capability-row">
<label class="flex items-center gap-2 rounded-lg border border-border bg-background/50 px-3 py-2 text-sm">
<input
type="checkbox"
[checked]="serverInstallOptional()"
@@ -365,15 +488,15 @@
<span>Optional for server members</span>
</label>
<div class="plugin-store__capability-list">
<div class="plugin-store__capability-list-header">
<h3>Capabilities</h3>
<span>{{ dialog.manifest.capabilities?.length ?? 0 }}</span>
<div class="grid gap-2">
<div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold">Capabilities</h3>
<span class="rounded-full bg-secondary px-2 py-0.5 text-xs text-muted-foreground">{{ dialog.manifest.capabilities?.length ?? 0 }}</span>
</div>
@if ((dialog.manifest.capabilities?.length ?? 0) > 0) {
@for (capability of dialog.manifest.capabilities; track trackInstallCapability($index, capability)) {
<label class="plugin-store__capability-row">
<label class="flex items-center gap-2 rounded-lg border border-border bg-background/50 px-3 py-2 text-sm">
<input
type="checkbox"
[checked]="selectedCapabilityIds().has(capability)"
@@ -384,21 +507,21 @@
</label>
}
} @else {
<p class="plugin-store__muted-text">This plugin requests no capabilities.</p>
<p class="text-sm text-muted-foreground">This plugin requests no capabilities.</p>
}
</div>
@if (serverInstallError()) {
<p class="plugin-store__error-banner">{{ serverInstallError() }}</p>
<p class="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">{{ serverInstallError() }}</p>
}
</div>
<footer class="plugin-store__install-actions">
<footer class="flex justify-end gap-2 border-t border-border p-4">
<button
type="button"
(click)="closeServerInstallDialog()"
[disabled]="serverInstallBusy()"
class="plugin-store__secondary-button"
class="inline-flex min-h-8 items-center justify-center rounded-lg border border-border bg-card px-3 py-1.5 text-sm font-semibold transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-55"
>
Cancel
</button>
@@ -406,11 +529,12 @@
type="button"
(click)="confirmServerInstall()"
[disabled]="serverInstallBusy() || !dialog.selectedServerId"
class="plugin-store__primary-button"
class="inline-flex min-h-8 items-center justify-center gap-2 rounded-lg border border-primary bg-primary px-3 py-1.5 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-55"
>
<ng-icon
name="lucidePlus"
[class.is-spinning]="serverInstallBusy()"
class="h-4 w-4"
[class.animate-spin]="serverInstallBusy()"
/>
Install and Activate
</button>

View File

@@ -1,607 +0,0 @@
: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;
min-width: 0;
}
.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;
}
.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__modal-backdrop {
position: fixed;
inset: 0;
z-index: 80;
background: rgb(0 0 0 / 0.6);
}
.plugin-store__install-modal {
position: fixed;
z-index: 81;
top: 50%;
left: 50%;
display: flex;
width: min(34rem, calc(100vw - 2rem));
max-height: min(42rem, calc(100vh - 2rem));
flex-direction: column;
overflow: hidden;
transform: translate(-50%, -50%);
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
color: hsl(var(--foreground));
background: hsl(var(--card));
box-shadow: 0 1.5rem 4rem rgb(0 0 0 / 0.35);
}
.plugin-store__install-header,
.plugin-store__install-actions,
.plugin-store__capability-list-header,
.plugin-store__capability-row {
display: flex;
align-items: center;
}
.plugin-store__install-header {
justify-content: space-between;
gap: 1rem;
border-bottom: 1px solid hsl(var(--border));
padding: 1rem;
}
.plugin-store__install-header p,
.plugin-store__install-header h2,
.plugin-store__capability-list-header h3,
.plugin-store__muted-text {
margin: 0;
}
.plugin-store__install-header p,
.plugin-store__field span,
.plugin-store__capability-list-header span,
.plugin-store__muted-text {
color: hsl(var(--muted-foreground));
font-size: 0.78rem;
}
.plugin-store__install-header h2 {
margin-top: 0.2rem;
font-size: 1.05rem;
}
.plugin-store__install-body {
display: grid;
min-height: 0;
gap: 1rem;
overflow: auto;
padding: 1rem;
}
.plugin-store__field {
display: grid;
gap: 0.4rem;
}
.plugin-store__field select {
min-height: 2.25rem;
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
padding: 0.45rem 0.65rem;
color: hsl(var(--foreground));
background: hsl(var(--secondary));
}
.plugin-store__capability-list {
display: grid;
gap: 0.4rem;
}
.plugin-store__capability-list-header {
justify-content: space-between;
gap: 0.75rem;
}
.plugin-store__capability-list-header h3 {
font-size: 0.9rem;
}
.plugin-store__capability-row {
gap: 0.55rem;
border: 1px solid hsl(var(--border));
border-radius: 0.45rem;
padding: 0.5rem 0.6rem;
background: hsl(var(--background) / 0.5);
font-size: 0.82rem;
}
.plugin-store__capability-row input {
width: 1rem;
height: 1rem;
}
.plugin-store__install-actions {
justify-content: flex-end;
gap: 0.5rem;
border-top: 1px solid hsl(var(--border));
padding: 0.85rem 1rem;
}
.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

@@ -4,6 +4,7 @@ import {
DestroyRef,
OnInit,
computed,
effect,
inject,
signal
} from '@angular/core';
@@ -25,6 +26,7 @@ import {
} from '@ng-icons/lucide';
import { ExternalLinkService } from '../../../../core/platform';
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
import { ChatMessageMarkdownComponent } from '../../../chat';
import { resolveLegacyRole, resolveRoomPermission } from '../../../access-control';
import type {
PluginCapabilityId,
@@ -36,7 +38,12 @@ import { selectCurrentRoom, selectSavedRooms } from '../../../../store/rooms/roo
import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { PluginCapabilityService } from '../../application/services/plugin-capability.service';
import { PluginStoreService } from '../../application/services/plugin-store.service';
import type { PluginStoreEntry, PluginStoreReadme } from '../../domain/models/plugin-store.models';
import type {
InstalledStorePlugin,
PluginStoreEntry,
PluginStoreInstallState,
PluginStoreReadme
} from '../../domain/models/plugin-store.models';
interface ServerPluginInstallDialog {
manifest: TojuPluginManifest;
@@ -50,6 +57,7 @@ interface ServerPluginInstallDialog {
imports: [
CommonModule,
FormsModule,
ChatMessageMarkdownComponent,
NgIcon
],
viewProviders: [
@@ -66,7 +74,6 @@ interface ServerPluginInstallDialog {
lucideX
})
],
styleUrl: './plugin-store.component.scss',
templateUrl: './plugin-store.component.html'
})
export class PluginStoreComponent implements OnInit {
@@ -124,15 +131,23 @@ export class PluginStoreComponent implements OnInit {
return readme ? this.store.availablePlugins().find((plugin) => plugin.id === readme.pluginId) ?? null : null;
});
readonly selectedStoreServer = computed(() => {
const selectedServerId = this.selectedStoreServerId();
return selectedServerId ? this.manageableServers().find((server) => server.id === selectedServerId) ?? null : null;
});
newSourceUrl = '';
readonly searchTerm = signal('');
readonly selectedSourceUrl = signal<string | null>(null);
readonly selectedStoreServerId = signal<string | null>(null);
readonly selectedServerInstalledPlugins = signal<InstalledStorePlugin[]>([]);
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 readmeRawMode = signal(false);
readonly readmeError = signal<string | null>(null);
readonly readmeLoadingPluginId = signal<string | null>(null);
readonly serverInstallDialog = signal<ServerPluginInstallDialog | null>(null);
@@ -147,8 +162,27 @@ export class PluginStoreComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly settingsModal = inject(SettingsModalService);
private selectedServerLoadVersion = 0;
constructor() {
effect(() => {
const servers = this.manageableServers();
const selectedServerId = this.selectedStoreServerId();
if (servers.length === 0) {
this.selectedStoreServerId.set(null);
this.selectedServerInstalledPlugins.set([]);
return;
}
if (!selectedServerId || !servers.some((server) => server.id === selectedServerId)) {
this.selectedStoreServerId.set(servers[0].id);
return;
}
void this.loadSelectedServerInstalledPlugins(selectedServerId);
});
this.destroyRef.onDestroy(() => {
this.destroyed = true;
});
@@ -219,14 +253,17 @@ export class PluginStoreComponent implements OnInit {
}
async runPrimaryAction(plugin: PluginStoreEntry): Promise<void> {
const action = this.store.getActionLabel(plugin);
const action = this.getPrimaryActionLabel(plugin);
this.actionError.set(null);
this.actionBusyPluginId.set(plugin.id);
try {
if (action === 'Uninstall' || action === 'Remove from Server') {
if (action === 'Uninstall') {
await this.store.uninstallPlugin(plugin.id, plugin.scope);
} else if (action === 'Remove from Server') {
await this.store.uninstallPlugin(plugin.id, plugin.scope, { serverId: this.selectedStoreServerId() ?? undefined });
await this.refreshSelectedServerInstalledPlugins();
} else if (this.isServerScopedPlugin(plugin)) {
await this.openServerInstallDialog(plugin);
} else {
@@ -257,6 +294,7 @@ export class PluginStoreComponent implements OnInit {
}
this.readme.set(readme);
this.readmeRawMode.set(false);
} catch (error) {
if (this.destroyed) {
return;
@@ -272,16 +310,21 @@ export class PluginStoreComponent implements OnInit {
closeReadme(): void {
this.readme.set(null);
this.readmeRawMode.set(false);
this.readmeError.set(null);
}
toggleReadmeRawMode(): void {
this.readmeRawMode.update((value) => !value);
}
async openServerInstallDialog(plugin: PluginStoreEntry): Promise<void> {
this.actionBusyPluginId.set(plugin.id);
this.serverInstallError.set(null);
try {
const manifest = await this.store.loadInstallManifest(plugin);
const selectedServerId = this.defaultServerInstallTargetId();
const selectedServerId = this.selectedStoreServerId();
if (!selectedServerId) {
throw new Error('You need owner or Manage Server access on a chat server before installing server plugins');
@@ -315,9 +358,14 @@ export class PluginStoreComponent implements OnInit {
}
selectServerInstallTarget(serverId: string): void {
this.selectedStoreServerId.set(serverId);
this.serverInstallDialog.update((dialog) => dialog ? { ...dialog, selectedServerId: serverId } : dialog);
}
selectStoreServer(serverId: string): void {
this.selectedStoreServerId.set(serverId);
}
toggleInstallCapability(capability: PluginCapabilityId, checked: boolean): void {
this.selectedCapabilityIds.update((capabilities) => {
const nextCapabilities = new Set(capabilities);
@@ -358,6 +406,8 @@ export class PluginStoreComponent implements OnInit {
serverId: dialog.selectedServerId
});
await this.loadSelectedServerInstalledPlugins(dialog.selectedServerId);
if (this.destroyed) {
return;
}
@@ -414,7 +464,7 @@ export class PluginStoreComponent implements OnInit {
isPrimaryActionDisabled(plugin: PluginStoreEntry): boolean {
return this.isPluginBusy(plugin)
|| !this.canRunPrimaryAction(plugin)
|| (!plugin.installUrl && this.store.getInstallState(plugin) !== 'installed');
|| (!plugin.installUrl && this.getPluginInstallState(plugin) !== 'installed');
}
canRunPrimaryAction(plugin: PluginStoreEntry): boolean {
@@ -425,8 +475,39 @@ export class PluginStoreComponent implements OnInit {
return this.manageableServers().length > 0;
}
getPluginInstallState(plugin: PluginStoreEntry): PluginStoreInstallState {
if (!this.isServerScopedPlugin(plugin)) {
return this.store.getInstallState(plugin);
}
const installedPlugin = this.selectedServerInstalledPlugins()
.find((candidate) => candidate.manifest.id === plugin.id);
if (!installedPlugin) {
return 'notInstalled';
}
return comparePluginVersions(plugin.version, installedPlugin.manifest.version) > 0
? 'updateAvailable'
: 'installed';
}
getPrimaryActionLabel(plugin: PluginStoreEntry): string {
if (!this.isServerScopedPlugin(plugin)) {
return this.store.getActionLabel(plugin);
}
const state = this.getPluginInstallState(plugin);
if (state === 'updateAvailable') {
return 'Update Server';
}
return state === 'installed' ? 'Remove from Server' : 'Install to Server';
}
primaryActionIcon(plugin: PluginStoreEntry): string {
const action = this.store.getActionLabel(plugin);
const action = this.getPrimaryActionLabel(plugin);
if (action === 'Uninstall') {
return 'lucideTrash2';
@@ -466,7 +547,7 @@ export class PluginStoreComponent implements OnInit {
serverInstallButtonTitle(plugin: PluginStoreEntry): string {
return this.isServerScopedPlugin(plugin) && this.manageableServers().length === 0
? 'Requires owner or Manage Server access on a chat server'
: this.store.getActionLabel(plugin);
: this.getPrimaryActionLabel(plugin);
}
private matchesSearch(plugin: PluginStoreEntry, searchTerm: string): boolean {
@@ -490,13 +571,47 @@ export class PluginStoreComponent implements OnInit {
return '/search';
}
private defaultServerInstallTargetId(): string | null {
const currentRoomId = this.currentRoom()?.id ?? null;
return this.manageableServers().find((room) => room.id === currentRoomId)?.id ?? this.manageableServers()[0]?.id ?? null;
}
private canManageServerPlugins(room: Room, user: User): boolean {
return resolveLegacyRole(room, user) === 'host' || resolveRoomPermission(room, user, 'manageServer');
}
private async refreshSelectedServerInstalledPlugins(): Promise<void> {
const selectedServerId = this.selectedStoreServerId();
if (selectedServerId) {
await this.loadSelectedServerInstalledPlugins(selectedServerId);
}
}
private async loadSelectedServerInstalledPlugins(serverId: string): Promise<void> {
const loadVersion = ++this.selectedServerLoadVersion;
try {
const installedPlugins = await this.store.loadInstalledPluginsForServer(serverId);
if (!this.destroyed && loadVersion === this.selectedServerLoadVersion && this.selectedStoreServerId() === serverId) {
this.selectedServerInstalledPlugins.set(installedPlugins);
}
} catch {
if (!this.destroyed && loadVersion === this.selectedServerLoadVersion && this.selectedStoreServerId() === serverId) {
this.selectedServerInstalledPlugins.set([]);
}
}
}
}
function comparePluginVersions(leftVersion: string, rightVersion: string): number {
const leftParts = leftVersion.split(/[.-]/).map((part) => Number.parseInt(part, 10) || 0);
const rightParts = rightVersion.split(/[.-]/).map((part) => Number.parseInt(part, 10) || 0);
const length = Math.max(leftParts.length, rightParts.length);
for (let index = 0; index < length; index += 1) {
const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0);
if (difference !== 0) {
return difference;
}
}
return 0;
}

View File

@@ -1,4 +1,5 @@
export * from './application/services/plugin-capability.service';
export * from './application/services/plugin-bootstrap.service';
export * from './application/services/plugin-client-api.service';
export * from './application/services/plugin-desktop-state.service';
export * from './application/services/plugin-host.service';