diff --git a/docs-site/docs/developer/llm-plugin-builder-guide.md b/docs-site/docs/developer/llm-plugin-builder-guide.md index cd0cb25..2adb74a 100644 --- a/docs-site/docs/developer/llm-plugin-builder-guide.md +++ b/docs-site/docs/developer/llm-plugin-builder-guide.md @@ -54,12 +54,12 @@ There are three communication boundaries a plugin author must understand: 1. Signaling plane Angular renderer <-> WebSocket signaling server Used for identity, joining servers, presence, typing, plugin requirements, - server-relayed plugin events, WebRTC offers, answers, and ICE candidates. + server-relayed plugin events, WebRTC offers, answers, and ICE candidates. 2. Peer plane Angular renderer <-> WebRTC peer connections <-> other clients - Used for media and data-channel events: chat messages, message sync, - attachments, voice state, screen/camera state, and plugin message bus data. + Used for media and data-channel events: chat messages, message sync, + attachments, voice state, screen/camera state, and plugin message bus data. 3. Desktop/local plane Angular renderer <-> Electron preload bridge <-> Electron main process @@ -1429,4 +1429,4 @@ export function deactivate(context) { - Local REST API: Developer Guide -> Local REST API. - Plugin manifest: Plugin Development -> Manifest Model. - Capabilities: Plugin Development -> Capabilities. -- Focused plugin API examples: Plugin Development -> API Reference and its API subpages. \ No newline at end of file +- Focused plugin API examples: Plugin Development -> API Reference and its API subpages. diff --git a/toju-app/src/app/domains/plugins/README.md b/toju-app/src/app/domains/plugins/README.md index 5778d21..0265681 100644 --- a/toju-app/src/app/domains/plugins/README.md +++ b/toju-app/src/app/domains/plugins/README.md @@ -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///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///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. diff --git a/toju-app/src/app/domains/plugins/application/services/plugin-host.service.ts b/toju-app/src/app/domains/plugins/application/services/plugin-host.service.ts index 729cee9..ca94260 100644 --- a/toju-app/src/app/domains/plugins/application/services/plugin-host.service.ts +++ b/toju-app/src/app/domains/plugins/application/services/plugin-host.service.ts @@ -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 { @@ -394,6 +405,18 @@ export class PluginHostService { return URL.createObjectURL(new Blob([source], { type: 'text/javascript' })); } + private async createRemoteModuleObjectUrl(entrypointUrl: string): Promise { + 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; diff --git a/toju-app/src/app/domains/plugins/application/services/plugin-store.service.ts b/toju-app/src/app/domains/plugins/application/services/plugin-store.service.ts index 7413812..674f604 100644 --- a/toju-app/src/app/domains/plugins/application/services/plugin-store.service.ts +++ b/toju-app/src/app/domains/plugins/application/services/plugin-store.service.ts @@ -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 { + 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)); diff --git a/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.html b/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.html index f34494b..8b9177e 100644 --- a/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.html +++ b/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.html @@ -323,7 +323,7 @@

Required before joining

@for (requirement of dialog.required; track requirement.pluginId) { -
+

{{ requirement.manifest?.title || requirement.pluginId }}

@@ -333,6 +333,48 @@
Required
+ + @if (requirement.manifest?.capabilities; as capabilities) { +
+ Capabilities +
+ @for (capability of capabilities; track capability) { + {{ capability }} + } +
+
+ } + +
+ @if (getPluginSourceUrl(requirement)) { + + } + + @if (hasPluginReadme(requirement)) { + + } +
}
@@ -342,25 +384,73 @@

Optional plugins

@for (requirement of dialog.optional; track requirement.pluginId) { -
} + @if (pluginConsentReadmeError()) { +

{{ pluginConsentReadmeError() }}

+ } + @if (pluginConsentError()) {

{{ pluginConsentError() }}

} @@ -385,6 +475,46 @@ + + @if (pluginConsentReadme(); as readme) { + + + } } diff --git a/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.ts b/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.ts index 65a20f0..e7138eb 100644 --- a/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.ts +++ b/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.ts @@ -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>(new Set()); pluginConsentBusy = signal(false); pluginConsentError = signal(null); + pluginConsentReadme = signal(null); + pluginConsentReadmeLoadingId = signal(null); + pluginConsentReadmeError = signal(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 { + 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 { const server = this.passwordPromptServer(); diff --git a/toju-app/src/index.html b/toju-app/src/index.html index b2e6d1f..4f9971f 100644 --- a/toju-app/src/index.html +++ b/toju-app/src/index.html @@ -10,7 +10,7 @@ />