feat: plugins v1.7
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user