feat: plugins v1.5
This commit is contained in:
@@ -17,8 +17,8 @@
|
||||
/>
|
||||
</button>
|
||||
<div class="min-w-0">
|
||||
<h2 class="truncate text-base font-semibold">Plugins</h2>
|
||||
<p class="truncate text-xs text-muted-foreground">Local runtime, store install, capabilities, logs, extension points.</p>
|
||||
<h2 class="truncate text-base font-semibold">{{ managerTitle() }}</h2>
|
||||
<p class="truncate text-xs text-muted-foreground">{{ managerDescription() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -232,7 +232,7 @@
|
||||
@for (page of selectedSettingsPages(); track page.id) {
|
||||
<article class="rounded-md border border-border bg-background/40 p-3">
|
||||
<h4 class="mb-2 text-sm font-medium">{{ page.contribution.label }}</h4>
|
||||
<app-plugin-render-host [render]="page.contribution.render"></app-plugin-render-host>
|
||||
<app-plugin-render-host [render]="page.contribution.render" />
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
@@ -331,8 +331,8 @@
|
||||
name="lucidePackage"
|
||||
size="28"
|
||||
/>
|
||||
<p class="mt-3 text-sm font-medium">No plugins installed.</p>
|
||||
<p class="mt-1 text-sm text-muted-foreground">Use Store tab or local plugin folder discovery.</p>
|
||||
<p class="mt-3 text-sm font-medium">{{ emptyTitle() }}</p>
|
||||
<p class="mt-1 text-sm text-muted-foreground">{{ emptyBody() }}</p>
|
||||
</div>
|
||||
} @else {
|
||||
@for (entry of entries(); track trackEntry($index, entry)) {
|
||||
@@ -370,6 +370,18 @@
|
||||
/>
|
||||
{{ entry.enabled ? 'Disable' : 'Enable' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted disabled:opacity-50"
|
||||
[disabled]="busyPluginId() === entry.manifest.id || !entry.enabled || isActive(entry)"
|
||||
(click)="activate(entry)"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucidePlay"
|
||||
size="14"
|
||||
/>
|
||||
Activate
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted disabled:opacity-50"
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Output,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
@@ -21,13 +22,14 @@ import {
|
||||
lucideStore,
|
||||
lucideX
|
||||
} from '@ng-icons/lucide';
|
||||
import type { PluginCapabilityId } from '../../../../shared-kernel';
|
||||
import type { PluginCapabilityId, TojuPluginInstallScope } from '../../../../shared-kernel';
|
||||
import { PluginCapabilityService } from '../../application/services/plugin-capability.service';
|
||||
import { PluginHostService } from '../../application/services/plugin-host.service';
|
||||
import { PluginLoggerService } from '../../application/services/plugin-logger.service';
|
||||
import { PluginRegistryService } from '../../application/services/plugin-registry.service';
|
||||
import { PluginRequirementStateService } from '../../application/services/plugin-requirement-state.service';
|
||||
import { PluginUiRegistryService } from '../../application/services/plugin-ui-registry.service';
|
||||
import { getPluginInstallScope } from '../../domain/logic/plugin-install-scope.logic';
|
||||
import type { RegisteredPlugin } from '../../domain/models/plugin-runtime.models';
|
||||
import { PluginRenderHostComponent } from '../plugin-render-host/plugin-render-host.component';
|
||||
|
||||
@@ -60,6 +62,8 @@ type PluginManagerTab = 'docs' | 'extensions' | 'installed' | 'logs' | 'requirem
|
||||
export class PluginManagerComponent {
|
||||
@Output() readonly closed = new EventEmitter<void>();
|
||||
|
||||
readonly scope = input<TojuPluginInstallScope>('client');
|
||||
|
||||
readonly capabilities = inject(PluginCapabilityService);
|
||||
readonly host = inject(PluginHostService);
|
||||
readonly logger = inject(PluginLoggerService);
|
||||
@@ -71,7 +75,12 @@ export class PluginManagerComponent {
|
||||
readonly busyPluginId = signal<string | null>(null);
|
||||
readonly busyAll = signal(false);
|
||||
readonly selectedPluginId = signal<string | null>(null);
|
||||
readonly entries = this.registry.entries;
|
||||
readonly allEntries = this.registry.entries;
|
||||
readonly entries = computed(() => this.allEntries().filter((entry) => this.entryBelongsToScope(entry)));
|
||||
readonly managerTitle = computed(() => this.scope() === 'server' ? 'Server plugins' : 'Client plugins');
|
||||
readonly managerDescription = computed(() => this.scope() === 'server'
|
||||
? 'Plugins installed for the current chat server.'
|
||||
: 'Global client plugins installed on this device.');
|
||||
readonly selectedPlugin = computed(() => {
|
||||
const selectedPluginId = this.selectedPluginId();
|
||||
|
||||
@@ -89,17 +98,18 @@ export class PluginManagerComponent {
|
||||
.slice(-20) : [];
|
||||
});
|
||||
readonly extensionCounts = computed(() => ({
|
||||
appPages: this.uiRegistry.appPages().length,
|
||||
channelSections: this.uiRegistry.channelSections().length,
|
||||
composerActions: this.uiRegistry.composerActions().length,
|
||||
embeds: this.uiRegistry.embeds().length,
|
||||
profileActions: this.uiRegistry.profileActions().length,
|
||||
settingsPages: this.uiRegistry.settingsPages().length,
|
||||
sidePanels: this.uiRegistry.sidePanels().length,
|
||||
toolbarActions: this.uiRegistry.toolbarActions().length
|
||||
appPages: this.uiRegistry.appPageRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
|
||||
channelSections: this.uiRegistry.channelSectionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
|
||||
composerActions: this.uiRegistry.composerActionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
|
||||
embeds: this.uiRegistry.embedRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
|
||||
profileActions: this.uiRegistry.profileActionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
|
||||
settingsPages: this.uiRegistry.settingsPageRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
|
||||
sidePanels: this.uiRegistry.sidePanelRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
|
||||
toolbarActions: this.uiRegistry.toolbarActionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length
|
||||
}));
|
||||
readonly requirementComparisons = this.requirementState.comparisons;
|
||||
readonly uiConflicts = this.uiRegistry.conflicts;
|
||||
readonly requirementComparisons = computed(() => this.scope() === 'server' ? this.requirementState.comparisons() : []);
|
||||
readonly uiConflicts = computed(() => this.uiRegistry.conflicts()
|
||||
.filter((conflict) => conflict.pluginIds.some((pluginId) => this.hasVisiblePlugin(pluginId))));
|
||||
readonly selectedRequirement = computed(() => {
|
||||
const selectedPlugin = this.selectedPlugin();
|
||||
|
||||
@@ -113,6 +123,10 @@ export class PluginManagerComponent {
|
||||
? this.uiRegistry.settingsPageRecords().filter((record) => record.pluginId === selectedPlugin.manifest.id)
|
||||
: [];
|
||||
});
|
||||
readonly emptyTitle = computed(() => this.scope() === 'server' ? 'No server plugins installed.' : 'No client plugins installed.');
|
||||
readonly emptyBody = computed(() => this.scope() === 'server'
|
||||
? 'Server-scoped plugins use scope: server in toju-plugin.json.'
|
||||
: 'Client-scoped plugins use scope: client or omit scope in toju-plugin.json.');
|
||||
readonly selectedDocs = computed(() => {
|
||||
const manifest = this.selectedPlugin()?.manifest;
|
||||
|
||||
@@ -176,11 +190,21 @@ export class PluginManagerComponent {
|
||||
}
|
||||
}
|
||||
|
||||
async activate(entry: RegisteredPlugin): Promise<void> {
|
||||
this.busyPluginId.set(entry.manifest.id);
|
||||
|
||||
try {
|
||||
await this.host.activatePluginById(entry.manifest.id);
|
||||
} finally {
|
||||
this.busyPluginId.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
async unload(entry: RegisteredPlugin): Promise<void> {
|
||||
this.busyPluginId.set(entry.manifest.id);
|
||||
|
||||
try {
|
||||
await this.host.deactivatePlugin(entry.manifest.id);
|
||||
await this.host.deactivatePlugin(entry.manifest.id, { forgetActivation: true });
|
||||
} finally {
|
||||
this.busyPluginId.set(null);
|
||||
}
|
||||
@@ -194,6 +218,10 @@ export class PluginManagerComponent {
|
||||
return this.selectedPlugin()?.manifest.id === entry.manifest.id;
|
||||
}
|
||||
|
||||
isActive(entry: RegisteredPlugin): boolean {
|
||||
return this.host.isPluginActive(entry.manifest.id);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed.emit();
|
||||
}
|
||||
@@ -205,4 +233,12 @@ export class PluginManagerComponent {
|
||||
trackCapability(index: number, capability: PluginCapabilityId): string {
|
||||
return capability;
|
||||
}
|
||||
|
||||
private entryBelongsToScope(entry: RegisteredPlugin): boolean {
|
||||
return getPluginInstallScope(entry.manifest) === this.scope();
|
||||
}
|
||||
|
||||
private hasVisiblePlugin(pluginId: string): boolean {
|
||||
return this.entries().some((entry) => entry.manifest.id === pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
|
||||
<div class="plugin-store__title-copy">
|
||||
<h1>Plugin Store</h1>
|
||||
<p>{{ installedCount() }} installed · {{ totalSourcePlugins() }} available · {{ sourceCount() }} sources</p>
|
||||
<p>
|
||||
{{ installedCount() }} installed for {{ store.installScopeLabel() }} · {{ totalSourcePlugins() }} available · {{ sourceCount() }} sources
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -52,10 +54,10 @@
|
||||
<div class="plugin-store__source-form">
|
||||
<label class="plugin-store__input-shell plugin-store__source-input">
|
||||
<input
|
||||
type="url"
|
||||
type="text"
|
||||
[(ngModel)]="newSourceUrl"
|
||||
(keyup.enter)="addSourceUrl()"
|
||||
placeholder="https://example.com/plugins.json"
|
||||
placeholder="https://example.com/plugins.json or /home/me/plugins/source.json"
|
||||
aria-label="Plugin source manifest URL"
|
||||
/>
|
||||
</label>
|
||||
@@ -232,8 +234,9 @@
|
||||
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'"
|
||||
[class.plugin-card__primary-action--danger]="store.getActionLabel(plugin) === 'Uninstall' || store.getActionLabel(plugin) === 'Remove from Server'"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="primaryActionIcon(plugin)"
|
||||
@@ -311,4 +314,107 @@
|
||||
</aside>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (serverInstallDialog(); as dialog) {
|
||||
<div
|
||||
class="plugin-store__modal-backdrop"
|
||||
role="presentation"
|
||||
></div>
|
||||
<section
|
||||
class="plugin-store__install-modal"
|
||||
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>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
(click)="closeServerInstallDialog()"
|
||||
class="plugin-store__icon-button"
|
||||
title="Cancel install"
|
||||
>
|
||||
<ng-icon name="lucideX" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="plugin-store__install-body">
|
||||
<label class="plugin-store__field">
|
||||
<span>Install to server</span>
|
||||
<select
|
||||
[value]="dialog.selectedServerId"
|
||||
[disabled]="serverInstallBusy()"
|
||||
(change)="selectServerInstallTarget($any($event.target).value)"
|
||||
>
|
||||
@for (server of manageableServers(); track trackServer($index, server)) {
|
||||
<option [value]="server.id">{{ server.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="plugin-store__capability-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="serverInstallOptional()"
|
||||
[disabled]="serverInstallBusy()"
|
||||
(change)="serverInstallOptional.set($any($event.target).checked)"
|
||||
/>
|
||||
<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>
|
||||
|
||||
@if ((dialog.manifest.capabilities?.length ?? 0) > 0) {
|
||||
@for (capability of dialog.manifest.capabilities; track trackInstallCapability($index, capability)) {
|
||||
<label class="plugin-store__capability-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="selectedCapabilityIds().has(capability)"
|
||||
[disabled]="serverInstallBusy()"
|
||||
(change)="toggleInstallCapability(capability, $any($event.target).checked)"
|
||||
/>
|
||||
<span>{{ capability }}</span>
|
||||
</label>
|
||||
}
|
||||
} @else {
|
||||
<p class="plugin-store__muted-text">This plugin requests no capabilities.</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (serverInstallError()) {
|
||||
<p class="plugin-store__error-banner">{{ serverInstallError() }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<footer class="plugin-store__install-actions">
|
||||
<button
|
||||
type="button"
|
||||
(click)="closeServerInstallDialog()"
|
||||
[disabled]="serverInstallBusy()"
|
||||
class="plugin-store__secondary-button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="confirmServerInstall()"
|
||||
[disabled]="serverInstallBusy() || !dialog.selectedServerId"
|
||||
class="plugin-store__primary-button"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucidePlus"
|
||||
[class.is-spinning]="serverInstallBusy()"
|
||||
/>
|
||||
Install and Activate
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
}
|
||||
</main>
|
||||
|
||||
@@ -262,6 +262,7 @@ ng-icon {
|
||||
|
||||
.plugin-store__source-row {
|
||||
gap: 0.375rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.plugin-store__source-filter,
|
||||
@@ -401,7 +402,6 @@ ng-icon {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.plugin-store__readme pre {
|
||||
@@ -414,6 +414,123 @@ ng-icon {
|
||||
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;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Store as NgRxStore } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideArrowLeft,
|
||||
@@ -24,9 +25,25 @@ import {
|
||||
} from '@ng-icons/lucide';
|
||||
import { ExternalLinkService } from '../../../../core/platform';
|
||||
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
||||
import { resolveLegacyRole, resolveRoomPermission } from '../../../access-control';
|
||||
import type {
|
||||
PluginCapabilityId,
|
||||
Room,
|
||||
TojuPluginManifest,
|
||||
User
|
||||
} from '../../../../shared-kernel';
|
||||
import { selectCurrentRoom, selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
|
||||
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';
|
||||
|
||||
interface ServerPluginInstallDialog {
|
||||
manifest: TojuPluginManifest;
|
||||
plugin: PluginStoreEntry;
|
||||
selectedServerId: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-plugin-store',
|
||||
standalone: true,
|
||||
@@ -54,6 +71,28 @@ import type { PluginStoreEntry, PluginStoreReadme } from '../../domain/models/pl
|
||||
})
|
||||
export class PluginStoreComponent implements OnInit {
|
||||
readonly store = inject(PluginStoreService);
|
||||
readonly capabilities = inject(PluginCapabilityService);
|
||||
readonly ngrxStore = inject(NgRxStore);
|
||||
readonly savedRooms = this.ngrxStore.selectSignal(selectSavedRooms);
|
||||
readonly currentRoom = this.ngrxStore.selectSignal(selectCurrentRoom);
|
||||
readonly currentUser = this.ngrxStore.selectSignal(selectCurrentUser);
|
||||
readonly manageableServers = computed(() => {
|
||||
const user = this.currentUser();
|
||||
|
||||
if (!user) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const roomsById = new Map(this.savedRooms().map((room) => [room.id, room]));
|
||||
const currentRoom = this.currentRoom();
|
||||
|
||||
if (currentRoom) {
|
||||
roomsById.set(currentRoom.id, currentRoom);
|
||||
}
|
||||
|
||||
return Array.from(roomsById.values())
|
||||
.filter((room) => this.canManageServerPlugins(room, user));
|
||||
});
|
||||
readonly sourceErrors = computed(() => this.store.sources().filter((source) => !!source.error));
|
||||
readonly installedIds = computed(() => new Set(this.store.installedPlugins().map((plugin) => plugin.manifest.id)));
|
||||
readonly filteredPlugins = computed(() => {
|
||||
@@ -96,6 +135,11 @@ export class PluginStoreComponent implements OnInit {
|
||||
readonly readme = signal<PluginStoreReadme | null>(null);
|
||||
readonly readmeError = signal<string | null>(null);
|
||||
readonly readmeLoadingPluginId = signal<string | null>(null);
|
||||
readonly serverInstallDialog = signal<ServerPluginInstallDialog | null>(null);
|
||||
readonly selectedCapabilityIds = signal<Set<PluginCapabilityId>>(new Set());
|
||||
readonly serverInstallOptional = signal(false);
|
||||
readonly serverInstallError = signal<string | null>(null);
|
||||
readonly serverInstallBusy = signal(false);
|
||||
|
||||
private destroyed = false;
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
@@ -181,8 +225,10 @@ export class PluginStoreComponent implements OnInit {
|
||||
this.actionBusyPluginId.set(plugin.id);
|
||||
|
||||
try {
|
||||
if (action === 'Uninstall') {
|
||||
this.store.uninstallPlugin(plugin.id);
|
||||
if (action === 'Uninstall' || action === 'Remove from Server') {
|
||||
await this.store.uninstallPlugin(plugin.id, plugin.scope);
|
||||
} else if (this.isServerScopedPlugin(plugin)) {
|
||||
await this.openServerInstallDialog(plugin);
|
||||
} else {
|
||||
await this.store.installPlugin(plugin);
|
||||
}
|
||||
@@ -229,13 +275,118 @@ export class PluginStoreComponent implements OnInit {
|
||||
this.readmeError.set(null);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
if (!selectedServerId) {
|
||||
throw new Error('You need owner or Manage Server access on a chat server before installing server plugins');
|
||||
}
|
||||
|
||||
this.selectedCapabilityIds.set(new Set(manifest.capabilities ?? []));
|
||||
this.serverInstallOptional.set(false);
|
||||
this.serverInstallDialog.set({ manifest, plugin, selectedServerId });
|
||||
} catch (error) {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.actionError.set(error instanceof Error ? error.message : 'Unable to prepare server plugin install');
|
||||
} finally {
|
||||
if (!this.destroyed) {
|
||||
this.actionBusyPluginId.set(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
closeServerInstallDialog(): void {
|
||||
if (this.serverInstallBusy()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.serverInstallDialog.set(null);
|
||||
this.serverInstallError.set(null);
|
||||
this.serverInstallOptional.set(false);
|
||||
this.selectedCapabilityIds.set(new Set());
|
||||
}
|
||||
|
||||
selectServerInstallTarget(serverId: string): void {
|
||||
this.serverInstallDialog.update((dialog) => dialog ? { ...dialog, selectedServerId: serverId } : dialog);
|
||||
}
|
||||
|
||||
toggleInstallCapability(capability: PluginCapabilityId, checked: boolean): void {
|
||||
this.selectedCapabilityIds.update((capabilities) => {
|
||||
const nextCapabilities = new Set(capabilities);
|
||||
|
||||
if (checked) {
|
||||
nextCapabilities.add(capability);
|
||||
} else {
|
||||
nextCapabilities.delete(capability);
|
||||
}
|
||||
|
||||
return nextCapabilities;
|
||||
});
|
||||
}
|
||||
|
||||
async confirmServerInstall(): Promise<void> {
|
||||
const dialog = this.serverInstallDialog();
|
||||
|
||||
if (!dialog) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.serverInstallBusy.set(true);
|
||||
this.serverInstallError.set(null);
|
||||
|
||||
try {
|
||||
for (const capability of dialog.manifest.capabilities ?? []) {
|
||||
if (this.selectedCapabilityIds().has(capability)) {
|
||||
this.capabilities.grant(dialog.manifest.id, capability);
|
||||
} else {
|
||||
this.capabilities.revoke(dialog.manifest.id, capability);
|
||||
}
|
||||
}
|
||||
|
||||
await this.store.installPlugin(dialog.plugin, {
|
||||
activate: true,
|
||||
manifest: dialog.manifest,
|
||||
optional: this.serverInstallOptional(),
|
||||
serverId: dialog.selectedServerId
|
||||
});
|
||||
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.serverInstallDialog.set(null);
|
||||
this.serverInstallOptional.set(false);
|
||||
this.selectedCapabilityIds.set(new Set());
|
||||
} catch (error) {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.serverInstallError.set(error instanceof Error ? error.message : 'Unable to install server plugin');
|
||||
} finally {
|
||||
if (!this.destroyed) {
|
||||
this.serverInstallBusy.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
void this.router.navigateByUrl(this.getReturnUrl());
|
||||
}
|
||||
|
||||
async openManager(): Promise<void> {
|
||||
const currentRoomId = this.currentRoom()?.id;
|
||||
|
||||
await this.router.navigateByUrl(this.getReturnUrl());
|
||||
this.settingsModal.open('plugins');
|
||||
this.settingsModal.open(this.store.hasActiveServerInstallScope() ? 'serverPlugins' : 'plugins', currentRoomId);
|
||||
}
|
||||
|
||||
selectSource(sourceUrl: string | null): void {
|
||||
@@ -262,9 +413,18 @@ export class PluginStoreComponent implements OnInit {
|
||||
|
||||
isPrimaryActionDisabled(plugin: PluginStoreEntry): boolean {
|
||||
return this.isPluginBusy(plugin)
|
||||
|| !this.canRunPrimaryAction(plugin)
|
||||
|| (!plugin.installUrl && this.store.getInstallState(plugin) !== 'installed');
|
||||
}
|
||||
|
||||
canRunPrimaryAction(plugin: PluginStoreEntry): boolean {
|
||||
if (!this.isServerScopedPlugin(plugin)) {
|
||||
return this.store.canInstallPlugin(plugin);
|
||||
}
|
||||
|
||||
return this.manageableServers().length > 0;
|
||||
}
|
||||
|
||||
primaryActionIcon(plugin: PluginStoreEntry): string {
|
||||
const action = this.store.getActionLabel(plugin);
|
||||
|
||||
@@ -272,6 +432,10 @@ export class PluginStoreComponent implements OnInit {
|
||||
return 'lucideTrash2';
|
||||
}
|
||||
|
||||
if (action === 'Remove from Server') {
|
||||
return 'lucideTrash2';
|
||||
}
|
||||
|
||||
return 'lucidePlus';
|
||||
}
|
||||
|
||||
@@ -287,6 +451,24 @@ export class PluginStoreComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
trackServer(index: number, server: Room): string {
|
||||
return server.id;
|
||||
}
|
||||
|
||||
trackInstallCapability(index: number, capability: PluginCapabilityId): string {
|
||||
return capability;
|
||||
}
|
||||
|
||||
isServerScopedPlugin(plugin: PluginStoreEntry): boolean {
|
||||
return plugin.scope === 'server';
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private matchesSearch(plugin: PluginStoreEntry, searchTerm: string): boolean {
|
||||
return [
|
||||
plugin.author,
|
||||
@@ -307,4 +489,14 @@ 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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user