fix: improve plugins functionality with server management
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -50,3 +50,8 @@ export interface PersistedPluginStoreState {
|
||||
installedPlugins: InstalledStorePlugin[];
|
||||
sourceUrls: string[];
|
||||
}
|
||||
|
||||
export interface PersistedServerPluginInstallState {
|
||||
schemaVersion?: number;
|
||||
servers?: Record<string, InstalledStorePlugin[]>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user