fix: browser bug with plugins, and improve joining
This commit is contained in:
@@ -54,12 +54,12 @@ There are three communication boundaries a plugin author must understand:
|
|||||||
1. Signaling plane
|
1. Signaling plane
|
||||||
Angular renderer <-> WebSocket signaling server
|
Angular renderer <-> WebSocket signaling server
|
||||||
Used for identity, joining servers, presence, typing, plugin requirements,
|
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
|
2. Peer plane
|
||||||
Angular renderer <-> WebRTC peer connections <-> other clients
|
Angular renderer <-> WebRTC peer connections <-> other clients
|
||||||
Used for media and data-channel events: chat messages, message sync,
|
Used for media and data-channel events: chat messages, message sync,
|
||||||
attachments, voice state, screen/camera state, and plugin message bus data.
|
attachments, voice state, screen/camera state, and plugin message bus data.
|
||||||
|
|
||||||
3. Desktop/local plane
|
3. Desktop/local plane
|
||||||
Angular renderer <-> Electron preload bridge <-> Electron main process
|
Angular renderer <-> Electron preload bridge <-> Electron main process
|
||||||
@@ -1429,4 +1429,4 @@ export function deactivate(context) {
|
|||||||
- Local REST API: Developer Guide -> Local REST API.
|
- Local REST API: Developer Guide -> Local REST API.
|
||||||
- Plugin manifest: Plugin Development -> Manifest Model.
|
- Plugin manifest: Plugin Development -> Manifest Model.
|
||||||
- Capabilities: Plugin Development -> Capabilities.
|
- Capabilities: Plugin Development -> Capabilities.
|
||||||
- Focused plugin API examples: Plugin Development -> API Reference and its API subpages.
|
- Focused plugin API examples: Plugin Development -> API Reference and its API subpages.
|
||||||
|
|||||||
@@ -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.
|
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 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.
|
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`.
|
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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -375,9 +375,20 @@ export class PluginHostService {
|
|||||||
return { module, moduleObjectUrl };
|
return { module, moduleObjectUrl };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
try {
|
||||||
module: await import(/* @vite-ignore */ entrypointUrl) as TojuClientPluginModule
|
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> {
|
private async createLocalModuleObjectUrl(entrypointUrl: string): Promise<string> {
|
||||||
@@ -394,6 +405,18 @@ export class PluginHostService {
|
|||||||
return URL.createObjectURL(new Blob([source], { type: 'text/javascript' }));
|
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 {
|
private revokeModuleObjectUrl(pluginId: string): void {
|
||||||
const moduleObjectUrl = this.activePlugins.get(pluginId)?.moduleObjectUrl;
|
const moduleObjectUrl = this.activePlugins.get(pluginId)?.moduleObjectUrl;
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import type {
|
|||||||
PluginStoreSourceResult
|
PluginStoreSourceResult
|
||||||
} from '../../domain/models/plugin-store.models';
|
} from '../../domain/models/plugin-store.models';
|
||||||
import { PluginHostService } from './plugin-host.service';
|
import { PluginHostService } from './plugin-host.service';
|
||||||
|
import { PluginCapabilityService } from './plugin-capability.service';
|
||||||
import { PluginDesktopStateService } from './plugin-desktop-state.service';
|
import { PluginDesktopStateService } from './plugin-desktop-state.service';
|
||||||
import { PluginRequirementService } from './plugin-requirement.service';
|
import { PluginRequirementService } from './plugin-requirement.service';
|
||||||
import { PluginRegistryService } from './plugin-registry.service';
|
import { PluginRegistryService } from './plugin-registry.service';
|
||||||
@@ -69,6 +70,7 @@ interface ServerInstalledPluginsLoadState {
|
|||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class PluginStoreService {
|
export class PluginStoreService {
|
||||||
private readonly electronBridge = inject(ElectronBridgeService);
|
private readonly electronBridge = inject(ElectronBridgeService);
|
||||||
|
private readonly capabilities = inject(PluginCapabilityService);
|
||||||
private readonly desktopState = inject(PluginDesktopStateService);
|
private readonly desktopState = inject(PluginDesktopStateService);
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly host = inject(PluginHostService);
|
private readonly host = inject(PluginHostService);
|
||||||
@@ -368,6 +370,10 @@ export class PluginStoreService {
|
|||||||
updatedAt: installedPlugin.updatedAt
|
updatedAt: installedPlugin.updatedAt
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (options.activate) {
|
||||||
|
this.capabilities.grantAll(cachedPlugin.manifest);
|
||||||
|
}
|
||||||
|
|
||||||
nextById.set(cachedPlugin.manifest.id, cachedPlugin);
|
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 {
|
getInstallState(plugin: PluginStoreEntry): PluginStoreInstallState {
|
||||||
const installed = this.installedPluginForScope(plugin.id, getStoreEntryInstallScope(plugin));
|
const installed = this.installedPluginForScope(plugin.id, getStoreEntryInstallScope(plugin));
|
||||||
|
|
||||||
|
|||||||
@@ -323,7 +323,7 @@
|
|||||||
<section class="grid gap-2">
|
<section class="grid gap-2">
|
||||||
<h3 class="text-sm font-semibold">Required before joining</h3>
|
<h3 class="text-sm font-semibold">Required before joining</h3>
|
||||||
@for (requirement of dialog.required; track requirement.pluginId) {
|
@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="flex items-start justify-between gap-3">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="truncate text-sm font-semibold">{{ requirement.manifest?.title || requirement.pluginId }}</p>
|
<p class="truncate text-sm font-semibold">{{ requirement.manifest?.title || requirement.pluginId }}</p>
|
||||||
@@ -333,6 +333,48 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-semibold text-primary">Required</span>
|
<span class="shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-semibold text-primary">Required</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
@@ -342,25 +384,73 @@
|
|||||||
<section class="grid gap-2">
|
<section class="grid gap-2">
|
||||||
<h3 class="text-sm font-semibold">Optional plugins</h3>
|
<h3 class="text-sm font-semibold">Optional plugins</h3>
|
||||||
@for (requirement of dialog.optional; track requirement.pluginId) {
|
@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">
|
<div class="grid gap-3 rounded-lg border border-border bg-background/50 px-3 py-2">
|
||||||
<input
|
<label class="flex items-start gap-3">
|
||||||
type="checkbox"
|
<input
|
||||||
class="mt-1 h-4 w-4 rounded border-border bg-secondary"
|
type="checkbox"
|
||||||
[checked]="selectedOptionalPluginIds().has(requirement.pluginId)"
|
class="mt-1 h-4 w-4 rounded border-border bg-secondary"
|
||||||
[disabled]="pluginConsentBusy()"
|
[checked]="selectedOptionalPluginIds().has(requirement.pluginId)"
|
||||||
(change)="toggleOptionalPluginInstall(requirement.pluginId, $any($event.target).checked)"
|
[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>
|
<span class="min-w-0 flex-1">
|
||||||
@if (requirement.reason) {
|
<span class="block truncate text-sm font-semibold">{{ requirement.manifest?.title || requirement.pluginId }}</span>
|
||||||
<span class="mt-1 block text-xs text-muted-foreground">{{ requirement.reason }}</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>
|
</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()) {
|
@if (pluginConsentError()) {
|
||||||
<p class="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">{{ pluginConsentError() }}</p>
|
<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>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
</section>
|
</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 -->
|
<!-- Create Server Dialog -->
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import {
|
import {
|
||||||
|
lucideExternalLink,
|
||||||
|
lucideFileText,
|
||||||
lucideSearch,
|
lucideSearch,
|
||||||
lucideUsers,
|
lucideUsers,
|
||||||
lucideLock,
|
lucideLock,
|
||||||
@@ -39,6 +41,7 @@ import {
|
|||||||
User,
|
User,
|
||||||
type PluginRequirementSummary
|
type PluginRequirementSummary
|
||||||
} from '../../../../shared-kernel';
|
} from '../../../../shared-kernel';
|
||||||
|
import { ExternalLinkService } from '../../../../core/platform';
|
||||||
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
||||||
import { DatabaseService } from '../../../../infrastructure/persistence';
|
import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||||
import { type ServerInfo } from '../../domain/models/server-directory.model';
|
import { type ServerInfo } from '../../domain/models/server-directory.model';
|
||||||
@@ -49,10 +52,15 @@ import {
|
|||||||
LeaveServerDialogComponent,
|
LeaveServerDialogComponent,
|
||||||
type LeaveServerDialogResult
|
type LeaveServerDialogResult
|
||||||
} from '../../../../shared';
|
} from '../../../../shared';
|
||||||
|
import { ChatMessageMarkdownComponent } from '../../../chat';
|
||||||
import { hasRoomBanForUser } from '../../../access-control';
|
import { hasRoomBanForUser } from '../../../access-control';
|
||||||
import { UserSearchListComponent } from '../../../direct-message/feature/user-search-list/user-search-list.component';
|
import { UserSearchListComponent } from '../../../direct-message/feature/user-search-list/user-search-list.component';
|
||||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||||
import { PluginRequirementService, PluginStoreService } from '../../../plugins';
|
import {
|
||||||
|
PluginRequirementService,
|
||||||
|
PluginStoreService,
|
||||||
|
type PluginStoreReadme
|
||||||
|
} from '../../../plugins';
|
||||||
|
|
||||||
interface JoinPluginConsentDialog {
|
interface JoinPluginConsentDialog {
|
||||||
optional: PluginRequirementSummary[];
|
optional: PluginRequirementSummary[];
|
||||||
@@ -68,12 +76,15 @@ interface JoinPluginConsentDialog {
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
NgIcon,
|
NgIcon,
|
||||||
|
ChatMessageMarkdownComponent,
|
||||||
ConfirmDialogComponent,
|
ConfirmDialogComponent,
|
||||||
LeaveServerDialogComponent,
|
LeaveServerDialogComponent,
|
||||||
UserSearchListComponent
|
UserSearchListComponent
|
||||||
],
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
|
lucideExternalLink,
|
||||||
|
lucideFileText,
|
||||||
lucideSearch,
|
lucideSearch,
|
||||||
lucideUsers,
|
lucideUsers,
|
||||||
lucideLock,
|
lucideLock,
|
||||||
@@ -94,6 +105,7 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private settingsModal = inject(SettingsModalService);
|
private settingsModal = inject(SettingsModalService);
|
||||||
private db = inject(DatabaseService);
|
private db = inject(DatabaseService);
|
||||||
|
private externalLinks = inject(ExternalLinkService);
|
||||||
private serverDirectory = inject(ServerDirectoryFacade);
|
private serverDirectory = inject(ServerDirectoryFacade);
|
||||||
private webrtc = inject(RealtimeSessionFacade);
|
private webrtc = inject(RealtimeSessionFacade);
|
||||||
private pluginRequirements = inject(PluginRequirementService);
|
private pluginRequirements = inject(PluginRequirementService);
|
||||||
@@ -122,6 +134,9 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
selectedOptionalPluginIds = signal<Set<string>>(new Set());
|
selectedOptionalPluginIds = signal<Set<string>>(new Set());
|
||||||
pluginConsentBusy = signal(false);
|
pluginConsentBusy = signal(false);
|
||||||
pluginConsentError = signal<string | null>(null);
|
pluginConsentError = signal<string | null>(null);
|
||||||
|
pluginConsentReadme = signal<PluginStoreReadme | null>(null);
|
||||||
|
pluginConsentReadmeLoadingId = signal<string | null>(null);
|
||||||
|
pluginConsentReadmeError = signal<string | null>(null);
|
||||||
|
|
||||||
// Create dialog state
|
// Create dialog state
|
||||||
showCreateDialog = signal(false);
|
showCreateDialog = signal(false);
|
||||||
@@ -306,6 +321,7 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
this.pluginConsentDialog.set(null);
|
this.pluginConsentDialog.set(null);
|
||||||
this.selectedOptionalPluginIds.set(new Set());
|
this.selectedOptionalPluginIds.set(new Set());
|
||||||
this.pluginConsentError.set(null);
|
this.pluginConsentError.set(null);
|
||||||
|
this.closePluginConsentReadme();
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleOptionalPluginInstall(pluginId: string, checked: boolean): void {
|
toggleOptionalPluginInstall(pluginId: string, checked: boolean): void {
|
||||||
@@ -345,6 +361,7 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
|
|
||||||
this.pluginConsentDialog.set(null);
|
this.pluginConsentDialog.set(null);
|
||||||
this.selectedOptionalPluginIds.set(new Set());
|
this.selectedOptionalPluginIds.set(new Set());
|
||||||
|
this.closePluginConsentReadme();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.pluginConsentError.set(error instanceof Error ? error.message : 'Unable to install server plugins');
|
this.pluginConsentError.set(error instanceof Error ? error.message : 'Unable to install server plugins');
|
||||||
} finally {
|
} 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> {
|
async confirmPasswordJoin(): Promise<void> {
|
||||||
const server = this.passwordPromptServer();
|
const server = this.passwordPromptServer();
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
/>
|
/>
|
||||||
<meta
|
<meta
|
||||||
http-equiv="Content-Security-Policy"
|
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
|
<link
|
||||||
rel="icon"
|
rel="icon"
|
||||||
|
|||||||
Reference in New Issue
Block a user