fix: browser bug with plugins, and improve joining

This commit is contained in:
2026-05-04 23:35:40 +02:00
parent a49e18b9f0
commit 0f6cb3ee77
7 changed files with 264 additions and 27 deletions

View File

@@ -6,13 +6,13 @@ The signal server stores plugin install metadata and event definitions, but it m
Desktop local plugins are discovered from the Electron app data `plugins` folder. Discovery reads `toju-plugin.json` or `plugin.json` from immediate child folders and resolves declared entrypoint/readme paths only when they stay inside that plugin folder.
The standalone plugin store is available from the title bar Plugins button, the title-bar Plugin Store menu item, the legacy Settings page button, and the Plugin Manager header. It owns source manifest management, search, readmes, install/update/uninstall actions, and links back to installed-plugin management. Manifest `kind` describes runtime shape (`client` or `library`), while top-level manifest `scope` describes installation scope: omit it or use `scope: "client"` for global client plugins, and use `scope: "server"` for chat-server plugins. Server-scoped store entries are presented as Install to Server, Update Server, or Remove from Server. Server plugin downloads are user-local and server-specific: a server can publish requirement metadata, but each account must consent before those plugins are downloaded or activated on join. Members who are already in a server see new required plugin requirements as a blocking prompt with Install plugins or Leave server actions; new optional or recommended requirements appear as a title-bar banner that can be installed, rejected for the current session, or hidden for that server/plugin requirement version.
The standalone plugin store is available from the title bar Plugins button, the title-bar Plugin Store menu item, the legacy Settings page button, and the Plugin Manager header. It owns source manifest management, search, readmes, install/update/uninstall actions, and links back to installed-plugin management. Manifest `kind` describes runtime shape (`client` or `library`), while top-level manifest `scope` describes installation scope: omit it or use `scope: "client"` for global client plugins, and use `scope: "server"` for chat-server plugins. Server-scoped store entries are presented as Install to Server, Update Server, or Remove from Server. Server plugin downloads are user-local and server-specific: a server can publish requirement metadata, but each account must consent before those plugins are downloaded or activated on join. The join consent dialog lets users inspect requested capabilities, open the plugin source in the system browser, and view a formatted readme when one is declared. Accepting the join prompt grants every declared capability for the accepted plugins and activates them for the server. Members who are already in a server see new required plugin requirements as a blocking prompt with Install plugins or Leave server actions; new optional or recommended requirements appear as a title-bar banner that can be installed, rejected for the current session, or hidden for that server/plugin requirement version.
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`, `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. When a different user joins that server, required plugins block the join until the user accepts the download; optional and recommended plugins are offered as selectable downloads and can be skipped. Once a server has local server-scoped plugins installed, the title bar shows a compact Server plugins button for that server. 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.
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; the renderer CSP allows HTTP(S), `file://` via local cache, and blob-backed plugin entrypoints. 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`.
@@ -24,6 +24,6 @@ Plugins can inspect the current interaction context through `api.context.getCurr
Desktop plugin preferences that belong to the local user, including capability grants, disabled plugin ids, and previously activated plugin ids, are persisted through Electron's local database meta table with renderer localStorage as the browser fallback.
Runtime activation is explicit. `PluginHostService.activateReadyPlugins()` imports browser-safe plugin entrypoints from URL-resolvable manifests, passes a frozen `TojuClientPluginApi`, runs `activate`, then runs `ready` after the load-order pass. Successfully activated plugin ids are remembered locally, and store-installed plugins are reactivated for the active server when their persisted manifests load again. `deactivate` runs during unload/reload, disposables are cleaned in reverse order, and UI contributions are removed by plugin id.
Runtime activation is explicit. `PluginHostService.activateReadyPlugins()` imports browser-safe plugin entrypoints from URL-resolvable manifests, passes a frozen `TojuClientPluginApi`, runs `activate`, then runs `ready` after the load-order pass. HTTP(S) entrypoints are imported directly when the host serves module-compatible JavaScript; if a source host serves JavaScript with a non-module MIME type, the runtime fetches the source and imports it through a blob URL. Successfully activated plugin ids are remembered locally, and store-installed plugins are reactivated for the active server when their persisted manifests load again. `deactivate` runs during unload/reload, disposables are cleaned in reverse order, and UI contributions are removed by plugin id.
Plugins that need fully custom UI can call `api.ui.mountElement(id, { target, element, position })` with the `ui.dom` capability. The runtime tags mounted elements with plugin ownership metadata, replaces duplicate mounts for the same plugin/id pair, and removes remaining mounted elements when the plugin is unloaded.

View File

@@ -375,9 +375,20 @@ export class PluginHostService {
return { module, moduleObjectUrl };
}
return {
module: await import(/* @vite-ignore */ entrypointUrl) as TojuClientPluginModule
};
try {
return {
module: await import(/* @vite-ignore */ entrypointUrl) as TojuClientPluginModule
};
} catch (error) {
if (!entrypointUrl.startsWith('http://') && !entrypointUrl.startsWith('https://')) {
throw error;
}
const moduleObjectUrl = await this.createRemoteModuleObjectUrl(entrypointUrl);
const module = await import(/* @vite-ignore */ moduleObjectUrl) as TojuClientPluginModule;
return { module, moduleObjectUrl };
}
}
private async createLocalModuleObjectUrl(entrypointUrl: string): Promise<string> {
@@ -394,6 +405,18 @@ export class PluginHostService {
return URL.createObjectURL(new Blob([source], { type: 'text/javascript' }));
}
private async createRemoteModuleObjectUrl(entrypointUrl: string): Promise<string> {
const response = await fetch(entrypointUrl, { headers: { Accept: 'text/javascript,*/*' } });
if (!response.ok) {
throw new Error(`Plugin entrypoint returned ${response.status}`);
}
const source = await response.text();
return URL.createObjectURL(new Blob([`${source}\n//# sourceURL=${entrypointUrl}`], { type: 'text/javascript' }));
}
private revokeModuleObjectUrl(pluginId: string): void {
const moduleObjectUrl = this.activePlugins.get(pluginId)?.moduleObjectUrl;

View File

@@ -39,6 +39,7 @@ import type {
PluginStoreSourceResult
} from '../../domain/models/plugin-store.models';
import { PluginHostService } from './plugin-host.service';
import { PluginCapabilityService } from './plugin-capability.service';
import { PluginDesktopStateService } from './plugin-desktop-state.service';
import { PluginRequirementService } from './plugin-requirement.service';
import { PluginRegistryService } from './plugin-registry.service';
@@ -69,6 +70,7 @@ interface ServerInstalledPluginsLoadState {
@Injectable({ providedIn: 'root' })
export class PluginStoreService {
private readonly electronBridge = inject(ElectronBridgeService);
private readonly capabilities = inject(PluginCapabilityService);
private readonly desktopState = inject(PluginDesktopStateService);
private readonly destroyRef = inject(DestroyRef);
private readonly host = inject(PluginHostService);
@@ -368,6 +370,10 @@ export class PluginStoreService {
updatedAt: installedPlugin.updatedAt
});
if (options.activate) {
this.capabilities.grantAll(cachedPlugin.manifest);
}
nextById.set(cachedPlugin.manifest.id, cachedPlugin);
}
@@ -402,6 +408,28 @@ export class PluginStoreService {
};
}
async loadRequirementReadme(requirement: PluginRequirementSummary): Promise<PluginStoreReadme> {
const rawReadmeUrl = requirement.manifest?.readme;
if (!rawReadmeUrl) {
throw new Error('Plugin does not provide a readme URL');
}
const baseUrl = requirement.installUrl ?? requirement.sourceUrl ?? rawReadmeUrl;
const readmeUrl = resolveOptionalUrl(baseUrl, rawReadmeUrl);
if (!readmeUrl) {
throw new Error('Plugin readme URL is not valid');
}
return {
markdown: await this.fetchText(readmeUrl, 'text/markdown,text/plain,*/*'),
pluginId: requirement.pluginId,
title: requirement.manifest?.title ?? requirement.pluginId,
url: readmeUrl
};
}
getInstallState(plugin: PluginStoreEntry): PluginStoreInstallState {
const installed = this.installedPluginForScope(plugin.id, getStoreEntryInstallScope(plugin));

View File

@@ -323,7 +323,7 @@
<section class="grid gap-2">
<h3 class="text-sm font-semibold">Required before joining</h3>
@for (requirement of dialog.required; track requirement.pluginId) {
<div class="rounded-lg border border-border bg-background/50 px-3 py-2">
<div class="grid gap-3 rounded-lg border border-border bg-background/50 px-3 py-2">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="truncate text-sm font-semibold">{{ requirement.manifest?.title || requirement.pluginId }}</p>
@@ -333,6 +333,48 @@
</div>
<span class="shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-semibold text-primary">Required</span>
</div>
@if (requirement.manifest?.capabilities; as capabilities) {
<details class="rounded-md border border-border bg-secondary/40 px-2 py-1.5 text-xs text-muted-foreground">
<summary class="cursor-pointer font-semibold text-foreground">Capabilities</summary>
<div class="mt-2 flex flex-wrap gap-1.5">
@for (capability of capabilities; track capability) {
<span class="rounded-full bg-background px-2 py-0.5 font-mono text-[11px] text-muted-foreground">{{ capability }}</span>
}
</div>
</details>
}
<div class="flex flex-wrap items-center gap-2">
@if (getPluginSourceUrl(requirement)) {
<button
type="button"
(click)="openPluginSource(requirement)"
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-xs font-semibold transition-colors hover:bg-secondary"
>
<ng-icon
name="lucideExternalLink"
class="h-3.5 w-3.5"
/>
Source
</button>
}
@if (hasPluginReadme(requirement)) {
<button
type="button"
(click)="openPluginConsentReadme(requirement)"
[disabled]="pluginConsentReadmeLoadingId() === requirement.pluginId"
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-xs font-semibold transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-55"
>
<ng-icon
name="lucideFileText"
class="h-3.5 w-3.5"
/>
{{ pluginConsentReadmeLoadingId() === requirement.pluginId ? 'Loading' : 'Readme' }}
</button>
}
</div>
</div>
}
</section>
@@ -342,25 +384,73 @@
<section class="grid gap-2">
<h3 class="text-sm font-semibold">Optional plugins</h3>
@for (requirement of dialog.optional; track requirement.pluginId) {
<label class="flex items-start gap-3 rounded-lg border border-border bg-background/50 px-3 py-2">
<input
type="checkbox"
class="mt-1 h-4 w-4 rounded border-border bg-secondary"
[checked]="selectedOptionalPluginIds().has(requirement.pluginId)"
[disabled]="pluginConsentBusy()"
(change)="toggleOptionalPluginInstall(requirement.pluginId, $any($event.target).checked)"
/>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-semibold">{{ requirement.manifest?.title || requirement.pluginId }}</span>
@if (requirement.reason) {
<span class="mt-1 block text-xs text-muted-foreground">{{ requirement.reason }}</span>
<div class="grid gap-3 rounded-lg border border-border bg-background/50 px-3 py-2">
<label class="flex items-start gap-3">
<input
type="checkbox"
class="mt-1 h-4 w-4 rounded border-border bg-secondary"
[checked]="selectedOptionalPluginIds().has(requirement.pluginId)"
[disabled]="pluginConsentBusy()"
(change)="toggleOptionalPluginInstall(requirement.pluginId, $any($event.target).checked)"
/>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-semibold">{{ requirement.manifest?.title || requirement.pluginId }}</span>
@if (requirement.reason) {
<span class="mt-1 block text-xs text-muted-foreground">{{ requirement.reason }}</span>
}
</span>
</label>
@if (requirement.manifest?.capabilities; as capabilities) {
<details class="rounded-md border border-border bg-secondary/40 px-2 py-1.5 text-xs text-muted-foreground">
<summary class="cursor-pointer font-semibold text-foreground">Capabilities</summary>
<div class="mt-2 flex flex-wrap gap-1.5">
@for (capability of capabilities; track capability) {
<span class="rounded-full bg-background px-2 py-0.5 font-mono text-[11px] text-muted-foreground">{{ capability }}</span>
}
</div>
</details>
}
<div class="flex flex-wrap items-center gap-2">
@if (getPluginSourceUrl(requirement)) {
<button
type="button"
(click)="openPluginSource(requirement)"
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-xs font-semibold transition-colors hover:bg-secondary"
>
<ng-icon
name="lucideExternalLink"
class="h-3.5 w-3.5"
/>
Source
</button>
}
</span>
</label>
@if (hasPluginReadme(requirement)) {
<button
type="button"
(click)="openPluginConsentReadme(requirement)"
[disabled]="pluginConsentReadmeLoadingId() === requirement.pluginId"
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-xs font-semibold transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-55"
>
<ng-icon
name="lucideFileText"
class="h-3.5 w-3.5"
/>
{{ pluginConsentReadmeLoadingId() === requirement.pluginId ? 'Loading' : 'Readme' }}
</button>
}
</div>
</div>
}
</section>
}
@if (pluginConsentReadmeError()) {
<p class="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">{{ pluginConsentReadmeError() }}</p>
}
@if (pluginConsentError()) {
<p class="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">{{ pluginConsentError() }}</p>
}
@@ -385,6 +475,46 @@
</button>
</footer>
</section>
@if (pluginConsentReadme(); as readme) {
<div
class="fixed inset-0 z-[52] bg-black/60"
role="presentation"
(click)="closePluginConsentReadme()"
></div>
<section
class="fixed left-1/2 top-1/2 z-[53] flex max-h-[min(44rem,calc(100vh-2rem))] w-[min(44rem,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="join-plugin-readme-title"
>
<header class="flex items-start justify-between gap-3 border-b border-border p-4">
<div class="min-w-0">
<p class="text-sm text-muted-foreground">Plugin readme</p>
<h2
id="join-plugin-readme-title"
class="mt-1 truncate text-lg font-semibold"
>
{{ readme.title }}
</h2>
</div>
<button
type="button"
(click)="closePluginConsentReadme()"
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="Close readme"
>
X
</button>
</header>
<div
class="min-h-0 overflow-auto p-4 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>
</section>
}
}
<!-- Create Server Dialog -->

View File

@@ -18,6 +18,8 @@ import {
} from 'rxjs';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideExternalLink,
lucideFileText,
lucideSearch,
lucideUsers,
lucideLock,
@@ -39,6 +41,7 @@ import {
User,
type PluginRequirementSummary
} from '../../../../shared-kernel';
import { ExternalLinkService } from '../../../../core/platform';
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
import { DatabaseService } from '../../../../infrastructure/persistence';
import { type ServerInfo } from '../../domain/models/server-directory.model';
@@ -49,10 +52,15 @@ import {
LeaveServerDialogComponent,
type LeaveServerDialogResult
} from '../../../../shared';
import { ChatMessageMarkdownComponent } from '../../../chat';
import { hasRoomBanForUser } from '../../../access-control';
import { UserSearchListComponent } from '../../../direct-message/feature/user-search-list/user-search-list.component';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { PluginRequirementService, PluginStoreService } from '../../../plugins';
import {
PluginRequirementService,
PluginStoreService,
type PluginStoreReadme
} from '../../../plugins';
interface JoinPluginConsentDialog {
optional: PluginRequirementSummary[];
@@ -68,12 +76,15 @@ interface JoinPluginConsentDialog {
CommonModule,
FormsModule,
NgIcon,
ChatMessageMarkdownComponent,
ConfirmDialogComponent,
LeaveServerDialogComponent,
UserSearchListComponent
],
viewProviders: [
provideIcons({
lucideExternalLink,
lucideFileText,
lucideSearch,
lucideUsers,
lucideLock,
@@ -94,6 +105,7 @@ export class ServerSearchComponent implements OnInit {
private router = inject(Router);
private settingsModal = inject(SettingsModalService);
private db = inject(DatabaseService);
private externalLinks = inject(ExternalLinkService);
private serverDirectory = inject(ServerDirectoryFacade);
private webrtc = inject(RealtimeSessionFacade);
private pluginRequirements = inject(PluginRequirementService);
@@ -122,6 +134,9 @@ export class ServerSearchComponent implements OnInit {
selectedOptionalPluginIds = signal<Set<string>>(new Set());
pluginConsentBusy = signal(false);
pluginConsentError = signal<string | null>(null);
pluginConsentReadme = signal<PluginStoreReadme | null>(null);
pluginConsentReadmeLoadingId = signal<string | null>(null);
pluginConsentReadmeError = signal<string | null>(null);
// Create dialog state
showCreateDialog = signal(false);
@@ -306,6 +321,7 @@ export class ServerSearchComponent implements OnInit {
this.pluginConsentDialog.set(null);
this.selectedOptionalPluginIds.set(new Set());
this.pluginConsentError.set(null);
this.closePluginConsentReadme();
}
toggleOptionalPluginInstall(pluginId: string, checked: boolean): void {
@@ -345,6 +361,7 @@ export class ServerSearchComponent implements OnInit {
this.pluginConsentDialog.set(null);
this.selectedOptionalPluginIds.set(new Set());
this.closePluginConsentReadme();
} catch (error) {
this.pluginConsentError.set(error instanceof Error ? error.message : 'Unable to install server plugins');
} finally {
@@ -352,6 +369,45 @@ export class ServerSearchComponent implements OnInit {
}
}
async openPluginConsentReadme(requirement: PluginRequirementSummary): Promise<void> {
this.pluginConsentReadmeError.set(null);
this.pluginConsentReadmeLoadingId.set(requirement.pluginId);
try {
const readme = await this.pluginStore.loadRequirementReadme(requirement);
this.pluginConsentReadme.set(readme);
} catch (error) {
this.pluginConsentReadmeError.set(error instanceof Error ? error.message : 'Unable to load plugin readme');
} finally {
this.pluginConsentReadmeLoadingId.set(null);
}
}
closePluginConsentReadme(): void {
this.pluginConsentReadme.set(null);
this.pluginConsentReadmeError.set(null);
this.pluginConsentReadmeLoadingId.set(null);
}
openPluginSource(requirement: PluginRequirementSummary): void {
const sourceUrl = this.getPluginSourceUrl(requirement);
if (sourceUrl) {
this.externalLinks.open(sourceUrl);
}
}
getPluginSourceUrl(requirement: PluginRequirementSummary): string | null {
const candidate = requirement.manifest?.homepage ?? requirement.sourceUrl ?? requirement.installUrl ?? requirement.manifest?.bugs ?? null;
return candidate?.startsWith('http://') || candidate?.startsWith('https://') ? candidate : null;
}
hasPluginReadme(requirement: PluginRequirementSummary): boolean {
return !!requirement.manifest?.readme;
}
async confirmPasswordJoin(): Promise<void> {
const server = this.passwordPromptServer();

View File

@@ -10,7 +10,7 @@
/>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; connect-src 'self' blob: ws: wss: http: https:; media-src 'self' blob:; img-src 'self' data: blob: http: https:; frame-src https://www.youtube-nocookie.com https://open.spotify.com https://w.soundcloud.com;"
content="default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; script-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob: http: https:; script-src-elem 'self' 'unsafe-inline' 'unsafe-eval' data: blob: http: https:; connect-src 'self' blob: ws: wss: http: https:; media-src 'self' blob:; img-src 'self' data: blob: http: https:; frame-src https://www.youtube-nocookie.com https://open.spotify.com https://w.soundcloud.com;"
/>
<link
rel="icon"