fix: improve plugins functionality with server management

This commit is contained in:
2026-04-29 20:33:54 +02:00
parent b8f6d58d99
commit fa2cca6fa4
82 changed files with 1708 additions and 303 deletions

View File

@@ -6,17 +6,17 @@ 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.
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 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. Required server plugins are installed on each member client when that chat server opens; optional server plugins stay listed as server requirements but are not auto-installed. Installing a `scope: "client"` plugin persists it locally for the current desktop/browser client.
The Store tab consumes user-managed HTTP(S), `file://`, or absolute local-path source manifests. Local-path sources and entrypoints are read through the Electron desktop file bridge. A source manifest can expose a `plugins` array whose entries include `id`, `title`, `description`, `version`, `scope`, `author`/`authors`, `image`/`imageUrl`, `github`/`githubUrl`, `install`/`installUrl`/`manifestUrl`, `bundle`/`bundleUrl`, and `readme`/`readmeUrl`. Installing a `scope: "server"` plugin fetches the linked plugin manifest, validates it, registers it with the client registry, and persists the basic install metadata as a server plugin requirement. 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.
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`.
Plugin data that belongs to the current client uses the Electron database when the desktop bridge is available. The plugin runtime writes `api.clientData.*` and `api.serverData.*` records to Electron's dedicated `plugin_data` table, with renderer localStorage as the browser fallback. The legacy synchronous `api.storage.*` surface remains local and mirrors writes to the same Electron table when possible; plugins that need guaranteed database reads should use the async `api.clientData.*` methods.
Plugin data that belongs to the current client uses the Electron database when the desktop bridge is available. The plugin runtime writes `api.clientData.*` and `api.serverData.*` records to Electron's dedicated user-scoped `plugin_data` table, with renderer localStorage as the browser fallback. The legacy synchronous `api.storage.*` surface remains local and mirrors writes to the same Electron table when possible; plugins that need guaranteed database reads should use the async `api.clientData.*` methods.
Plugins can communicate over a plugin-only message bus through `api.messageBus`. It sends `plugin-message-bus` data-channel events that are ignored by the normal chat message reducers/effects, can target a peer or broadcast to connected users, and can include a bounded latest-message snapshot filtered by channel, timestamp, and deletion state.

View File

@@ -25,30 +25,32 @@ export class PluginDesktopStateService {
}
private async readRaw(key: string): Promise<string | null> {
const scopedKey = getUserScopedStorageKey(key);
const api = this.electronBridge.getApi();
if (api) {
return await api.query<string | null>({
type: 'get-meta',
payload: { key }
payload: { key: scopedKey }
});
}
return localStorage.getItem(getUserScopedStorageKey(key));
return localStorage.getItem(scopedKey);
}
private async writeRaw(key: string, value: string): Promise<void> {
const scopedKey = getUserScopedStorageKey(key);
const api = this.electronBridge.getApi();
if (api) {
await api.command({
type: 'save-meta',
payload: { key, value }
payload: { key: scopedKey, value }
});
return;
}
localStorage.setItem(getUserScopedStorageKey(key), value);
localStorage.setItem(scopedKey, value);
}
}

View File

@@ -15,11 +15,16 @@ import type {
TojuPluginManifest
} from '../../../../shared-kernel';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { getUserScopedStorageKey } from '../../../../core/storage/current-user-storage';
import { selectCurrentRoom, selectCurrentRoomId } from '../../../../store/rooms/rooms.selectors';
import { ServerDirectoryFacade, type ServerSourceSelector } from '../../../server-directory';
import { PluginRegistryService } from './plugin-registry.service';
import { PluginRequirementService } from './plugin-requirement.service';
const STORAGE_KEY_OPTIONAL_REQUIREMENT_DISMISSALS = 'metoyou_optional_plugin_requirement_dismissals';
type RequirementDismissalState = Record<string, Record<string, number>>;
export type PluginRequirementComparisonStatus =
| 'blockedByServer'
| 'disabled'
@@ -48,6 +53,8 @@ export class PluginRequirementStateService {
private readonly currentRoomId = this.store.selectSignal(selectCurrentRoomId);
private readonly snapshotsSignal = signal<Record<string, PluginRequirementsSnapshot>>({});
private readonly refreshErrorsSignal = signal<Record<string, string>>({});
private readonly sessionDismissedOptionalSignal = signal<Record<string, string[]>>({});
private readonly hiddenOptionalSignal = signal<RequirementDismissalState>(loadRequirementDismissals());
readonly currentSnapshot = computed(() => {
const roomId = this.currentRoomId();
@@ -55,6 +62,22 @@ export class PluginRequirementStateService {
return roomId ? this.snapshotsSignal()[roomId] ?? null : null;
});
readonly refreshErrors = this.refreshErrorsSignal.asReadonly();
readonly missingInstallableRequirements = computed(() => {
const requirements: PluginRequirementSummary[] = [];
for (const comparison of this.comparisons()) {
if (this.isMissingInstallableRequirement(comparison) && comparison.requirement) {
requirements.push(comparison.requirement);
}
}
return requirements;
});
readonly missingRequiredRequirements = computed(() => this.missingInstallableRequirements()
.filter((requirement) => requirement.status === 'required'));
readonly visibleOptionalRequirements = computed(() => this.missingInstallableRequirements()
.filter((requirement) => requirement.status === 'optional' || requirement.status === 'recommended')
.filter((requirement) => !this.isOptionalRequirementDismissed(requirement)));
readonly comparisons = computed<PluginRequirementComparison[]>(() => {
const snapshot = this.currentSnapshot();
const installedEntries = this.registry.entries();
@@ -138,6 +161,36 @@ export class PluginRequirementStateService {
return this.comparisons().find((comparison) => comparison.pluginId === pluginId) ?? null;
}
dismissOptionalRequirement(requirement: PluginRequirementSummary, options: { persist?: boolean } = {}): void {
const roomId = this.currentRoomId();
if (!roomId) {
return;
}
if (options.persist) {
this.hiddenOptionalSignal.update((dismissals) => {
const nextDismissals = {
...dismissals,
[roomId]: {
...(dismissals[roomId] ?? {}),
[requirement.pluginId]: requirement.updatedAt
}
};
saveRequirementDismissals(nextDismissals);
return nextDismissals;
});
return;
}
this.sessionDismissedOptionalSignal.update((dismissals) => ({
...dismissals,
[roomId]: Array.from(new Set([...(dismissals[roomId] ?? []), requirement.pluginId]))
}));
}
private setSnapshot(serverId: string, snapshot: PluginRequirementsSnapshot): void {
this.snapshotsSignal.update((snapshots) => ({
...snapshots,
@@ -184,6 +237,70 @@ export class PluginRequirementStateService {
return 'enabled';
}
private isMissingInstallableRequirement(comparison: PluginRequirementComparison): boolean {
const requirement = comparison.requirement;
return comparison.status === 'missing'
&& !!requirement
&& (requirement.status === 'required' || requirement.status === 'optional' || requirement.status === 'recommended')
&& (!!requirement.manifest || !!requirement.installUrl);
}
private isOptionalRequirementDismissed(requirement: PluginRequirementSummary): boolean {
const roomId = this.currentRoomId();
if (!roomId) {
return true;
}
if ((this.sessionDismissedOptionalSignal()[roomId] ?? []).includes(requirement.pluginId)) {
return true;
}
const hiddenAt = this.hiddenOptionalSignal()[roomId]?.[requirement.pluginId];
return typeof hiddenAt === 'number' && hiddenAt >= requirement.updatedAt;
}
}
function loadRequirementDismissals(): RequirementDismissalState {
try {
const rawValue = localStorage.getItem(getUserScopedStorageKey(STORAGE_KEY_OPTIONAL_REQUIREMENT_DISMISSALS));
if (!rawValue) {
return {};
}
return normalizeRequirementDismissals(JSON.parse(rawValue) as unknown);
} catch {
return {};
}
}
function saveRequirementDismissals(dismissals: RequirementDismissalState): void {
try {
localStorage.setItem(getUserScopedStorageKey(STORAGE_KEY_OPTIONAL_REQUIREMENT_DISMISSALS), JSON.stringify(dismissals));
} catch {}
}
function normalizeRequirementDismissals(value: unknown): RequirementDismissalState {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
return Object.fromEntries(Object.entries(value as Record<string, unknown>)
.map(([serverId, serverValue]) => [serverId, normalizeServerDismissals(serverValue)])
.filter(([, serverValue]) => Object.keys(serverValue).length > 0));
}
function normalizeServerDismissals(value: unknown): Record<string, number> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
return Object.fromEntries(Object.entries(value as Record<string, unknown>)
.filter((entry): entry is [string, number] => typeof entry[1] === 'number'));
}
function isSnapshotMessage(message: unknown): message is { serverId: string; snapshot: PluginRequirementsSnapshot } {

View File

@@ -30,6 +30,7 @@ import { getPluginInstallScope } from '../../domain/logic/plugin-install-scope.l
import { validateTojuPluginManifest } from '../../domain/logic/plugin-manifest-validation.logic';
import type {
InstalledStorePlugin,
PersistedServerPluginInstallState,
PersistedPluginStoreState,
PluginStoreEntry,
PluginStoreInstallState,
@@ -44,6 +45,7 @@ import { PluginRegistryService } from './plugin-registry.service';
const STORE_SCHEMA_VERSION = 1;
const STORAGE_KEY_PLUGIN_STORE = 'metoyou_plugin_store';
const STORAGE_KEY_SERVER_PLUGIN_INSTALLS = 'metoyou_server_plugin_installs';
const PLUGIN_CACHE_DIR = 'plugin-bundles';
const DEFAULT_STORE_STATE: PersistedPluginStoreState = {
installedPlugins: [],
@@ -238,6 +240,10 @@ export class PluginStoreService {
installedPlugin: InstalledStorePlugin,
options: PluginStoreInstallOptions
): Promise<void> {
if (installScope === 'server' && targetServerId) {
await this.writeLocalServerInstalledPlugins(targetServerId, nextInstalledPlugins);
}
if (installScope !== 'client' && targetServerId !== this.currentRoomId?.()) {
if (options.activate) {
await this.host.rememberActivation(installedPlugin.manifest.id);
@@ -248,9 +254,7 @@ export class PluginStoreService {
this.host.registerLocalManifest(installedPlugin.manifest, installedPlugin.cachedSourcePath ?? installedPlugin.installUrl);
if (installScope === 'client' || options.optional !== true) {
this.setInstalledPluginsForScope(installScope, nextInstalledPlugins);
}
this.setInstalledPluginsForScope(installScope, nextInstalledPlugins);
if (options.activate) {
await this.host.activatePluginById(installedPlugin.manifest.id);
@@ -291,6 +295,63 @@ export class PluginStoreService {
return await this.installedPluginsForServer(serverId);
}
async getLocalServerInstalledPluginIds(serverId: string): Promise<Set<string>> {
const installedPlugins = await this.readLocalServerInstalledPlugins(serverId);
return new Set(installedPlugins.map((installedPlugin) => installedPlugin.manifest.id));
}
async installServerRequirementsLocally(
serverId: string,
requirements: PluginRequirementSummary[],
options: { activate?: boolean } = {}
): Promise<InstalledStorePlugin[]> {
const installedPlugins: InstalledStorePlugin[] = [];
for (const requirement of requirements) {
const installedPlugin = await this.resolveLocalInstallFromRequirement(requirement);
if (installedPlugin) {
installedPlugins.push(installedPlugin);
}
}
if (installedPlugins.length === 0) {
return await this.readLocalServerInstalledPlugins(serverId);
}
const currentInstalledPlugins = await this.readLocalServerInstalledPlugins(serverId);
const currentById = new Map(currentInstalledPlugins.map((installedPlugin) => [installedPlugin.manifest.id, installedPlugin]));
const nextById = new Map(currentById);
for (const installedPlugin of installedPlugins) {
const existing = currentById.get(installedPlugin.manifest.id);
const cachedPlugin = await this.cacheInstalledPlugin({
...installedPlugin,
installedAt: existing?.installedAt ?? installedPlugin.installedAt,
updatedAt: installedPlugin.updatedAt
});
nextById.set(cachedPlugin.manifest.id, cachedPlugin);
}
const nextInstalledPlugins = Array.from(nextById.values()).sort(sortInstalledPlugins);
if (options.activate) {
for (const installedPlugin of installedPlugins) {
await this.host.rememberActivation(installedPlugin.manifest.id);
}
}
await this.writeLocalServerInstalledPlugins(serverId, nextInstalledPlugins);
if (serverId === this.currentRoomId?.()) {
await this.applyInstalledPlugins(nextInstalledPlugins, 'server');
}
return nextInstalledPlugins;
}
async loadReadme(plugin: PluginStoreEntry): Promise<PluginStoreReadme> {
if (!plugin.readmeUrl) {
throw new Error('Plugin does not provide a readme URL');
@@ -604,7 +665,7 @@ export class PluginStoreService {
}
try {
const installedPlugins = await this.readServerInstalledPlugins(roomId);
const installedPlugins = await this.readLocalServerInstalledPlugins(roomId);
if (this.installedLoadVersion === currentLoad && this.currentRoomId?.() === roomId) {
await this.applyInstalledPlugins(installedPlugins, 'server');
@@ -650,6 +711,56 @@ export class PluginStoreService {
.sort(sortInstalledPlugins);
}
private async resolveLocalInstallFromRequirement(requirement: PluginRequirementSummary): Promise<InstalledStorePlugin | null> {
const existingPlugin = installedPluginFromRequirement(requirement, { includeOptional: true });
if (existingPlugin) {
return existingPlugin;
}
if (!requirement.installUrl) {
return null;
}
const manifest = await this.fetchPluginManifest(requirement.installUrl);
if (getPluginInstallScope(manifest) !== 'server') {
return null;
}
return {
bundleUrl: manifest.bundle?.url,
installedAt: requirement.updatedAt,
installUrl: requirement.installUrl,
manifest,
sourceUrl: requirement.sourceUrl,
updatedAt: requirement.updatedAt
};
}
private async readLocalServerInstalledPlugins(serverId: string): Promise<InstalledStorePlugin[]> {
const state = await this.desktopState.readJson<PersistedServerPluginInstallState>(STORAGE_KEY_SERVER_PLUGIN_INSTALLS, {});
const normalized = normalizePersistedServerPluginInstallState(state);
return normalized.servers[serverId] ?? [];
}
private async writeLocalServerInstalledPlugins(serverId: string, installedPlugins: InstalledStorePlugin[]): Promise<void> {
const state = await this.desktopState.readJson<PersistedServerPluginInstallState>(STORAGE_KEY_SERVER_PLUGIN_INSTALLS, {});
const normalized = normalizePersistedServerPluginInstallState(state);
const nextServers = installedPlugins.length === 0
? Object.fromEntries(Object.entries(normalized.servers).filter(([candidateServerId]) => candidateServerId !== serverId))
: {
...normalized.servers,
[serverId]: installedPlugins
};
await this.desktopState.writeJson(STORAGE_KEY_SERVER_PLUGIN_INSTALLS, {
schemaVersion: STORE_SCHEMA_VERSION,
servers: nextServers
});
}
private async saveServerPluginRequirement(
installedPlugin: InstalledStorePlugin,
roomId: string | null,
@@ -735,10 +846,6 @@ export class PluginStoreService {
return [];
}
if (serverId === this.currentRoomId?.()) {
return this.serverInstalledPluginsSignal();
}
const actorUserId = this.currentActorUserId();
if (!actorUserId || !this.serverDirectory) {
@@ -839,8 +946,15 @@ function isPluginRequirementsChangedMessage(message: unknown): message is { serv
&& typeof message['serverId'] === 'string';
}
function installedPluginFromRequirement(requirement: PluginRequirementSummary): InstalledStorePlugin | null {
if (requirement.status === 'optional' || requirement.status === 'blocked' || requirement.status === 'incompatible') {
function installedPluginFromRequirement(
requirement: PluginRequirementSummary,
options: { includeOptional?: boolean } = {}
): InstalledStorePlugin | null {
if (requirement.status === 'blocked' || requirement.status === 'incompatible') {
return null;
}
if (requirement.status === 'optional' && options.includeOptional !== true) {
return null;
}
@@ -945,6 +1059,31 @@ function normalizePersistedState(value: unknown): PersistedPluginStoreState {
};
}
function normalizePersistedServerPluginInstallState(value: unknown): { servers: Record<string, InstalledStorePlugin[]> } {
if (!isRecord(value) || !isRecord(value['servers'])) {
return { servers: {} };
}
const servers: Record<string, InstalledStorePlugin[]> = {};
for (const [serverId, installedPlugins] of Object.entries(value['servers'])) {
if (!Array.isArray(installedPlugins)) {
continue;
}
const normalizedPlugins = installedPlugins
.filter(isInstalledStorePlugin)
.filter((installedPlugin) => getPluginInstallScope(installedPlugin.manifest) === 'server')
.sort(sortInstalledPlugins);
if (normalizedPlugins.length > 0) {
servers[serverId] = normalizedPlugins;
}
}
return { servers };
}
function isInstalledStorePlugin(value: unknown): value is InstalledStorePlugin {
if (!isRecord(value) || !isRecord(value['manifest'])) {
return false;

View File

@@ -50,3 +50,8 @@ export interface PersistedPluginStoreState {
installedPlugins: InstalledStorePlugin[];
sourceUrls: string[];
}
export interface PersistedServerPluginInstallState {
schemaVersion?: number;
servers?: Record<string, InstalledStorePlugin[]>;
}

View File

@@ -1,5 +1,9 @@
<main class="min-h-screen bg-background p-6 text-foreground">
<a routerLink="/search" class="text-sm text-muted-foreground hover:text-foreground">Back</a>
<a
routerLink="/search"
class="text-sm text-muted-foreground hover:text-foreground"
>Back</a
>
@if (page(); as pageRecord) {
<section class="mx-auto mt-6 max-w-5xl">
<p class="text-xs uppercase tracking-[0.18em] text-muted-foreground">{{ pageRecord.pluginId }}</p>