diff --git a/src/plugins/PluginManager.js b/src/plugins/PluginManager.js
index 7fe229b7..cb43c0bb 100644
--- a/src/plugins/PluginManager.js
+++ b/src/plugins/PluginManager.js
@@ -1,13 +1,37 @@
import React from "react";
import { getEnvironmentConfig, loadConfigProfiles, getActiveProfileName } from "../lib/config";
-import { useStore } from "../lib/store"; // Assuming useStore can return the store instance
+import { useStore } from "../lib/store";
+import {
+ loadInstalledPlugins,
+ upsertInstalledPlugin,
+ removeInstalledPlugin,
+ loadPermissionGrants,
+ savePermissionGrants,
+} from "./pluginStorage";
+import { fetchMarketplacePlugins, fetchMarketplacePluginById } from "./pluginCatalog";
+import SandboxedPluginFrame from "./pluginSandbox";
const PLUGIN_STATUSES = Object.freeze({
REGISTERED: "registered",
INITIALIZED: "initialized",
FAILED: "failed",
+ BLOCKED: "blocked",
+ PENDING_REVIEW: "pending-review",
+ DISABLED: "disabled",
});
+const ALLOWED_PERMISSION_SCOPES = Object.freeze([
+ "dashboard:read",
+ "dashboard:write",
+ "data:read",
+ "data:write",
+ "notifications:write",
+ "network:request",
+ "storage:read",
+ "storage:write",
+ "window:open",
+]);
+
const SAFE_STATE_KEYS = Object.freeze([
"network",
"theme",
@@ -23,6 +47,9 @@ const SAFE_STATE_KEYS = Object.freeze([
"walletPublicKey",
"streamStatus",
"streamLedgers",
+ "searchFilters",
+ "notificationHistory",
+ "unreadNotificationCount",
]);
const SAFE_ACTION_KEYS = Object.freeze([
@@ -34,6 +61,13 @@ const SAFE_ACTION_KEYS = Object.freeze([
"removeNotification",
]);
+const pluginModules = import.meta.glob("./**/*Plugin.{js,jsx,ts,tsx}", {
+ eager: false,
+});
+
+let registrationPromise = null;
+let registrationComplete = false;
+
function freezePlainObject(value) {
if (!value || typeof value !== "object") return value;
if (Array.isArray(value)) return Object.freeze(value.map(freezePlainObject));
@@ -55,99 +89,202 @@ function pickSafeState(state) {
}
function normalizePlugin(rawPlugin) {
- const plugin = rawPlugin?.default || rawPlugin;
+ const plugin = rawPlugin?.default || rawPlugin?.plugin || rawPlugin?.createPlugin || rawPlugin;
if (typeof plugin === "function") return plugin();
return plugin;
}
-function normalizeWidget(widget, pluginRecord, index) {
- if (!widget || typeof widget !== "object") return null;
+function normalizeManifest(plugin) {
+ if (!plugin || typeof plugin !== "object") return null;
+ const manifest = plugin.manifest || plugin;
+
+ if (!manifest.id || typeof manifest.id !== "string") return null;
+ if (!manifest.name || typeof manifest.name !== "string") return null;
+
+ const permissions = Array.isArray(manifest.permissions)
+ ? manifest.permissions.filter(
+ (permission) =>
+ typeof permission === "string" && ALLOWED_PERMISSION_SCOPES.includes(permission)
+ )
+ : [];
+
+ const dependencyPlugins = Array.isArray(manifest.dependencies?.plugins)
+ ? manifest.dependencies.plugins.filter((pluginId) => typeof pluginId === "string")
+ : [];
+
+ return freezePlainObject({
+ id: manifest.id,
+ name: manifest.name,
+ version: String(manifest.version || "1.0.0"),
+ description: String(manifest.description || ""),
+ author: manifest.author || null,
+ homepageUrl: manifest.homepageUrl || null,
+ permissions,
+ dependencies: { plugins: dependencyPlugins },
+ runtime: {
+ mode: manifest.runtime?.mode || "module",
+ entry: manifest.runtime?.entry || null,
+ source: manifest.runtime?.source || null,
+ srcDoc: manifest.runtime?.srcDoc || null,
+ sandbox: Array.isArray(manifest.runtime?.sandbox)
+ ? manifest.runtime.sandbox.filter(Boolean)
+ : ["allow-scripts"],
+ },
+ widgets: Array.isArray(manifest.widgets) ? manifest.widgets : [],
+ dataSources: Array.isArray(manifest.dataSources) ? manifest.dataSources : [],
+ });
+}
+
+function compareVersions(a, b) {
+ const parse = (value) =>
+ String(value || "0.0.0")
+ .split(".")
+ .map((segment) => Number.parseInt(segment, 10) || 0);
+
+ const left = parse(a);
+ const right = parse(b);
+ const length = Math.max(left.length, right.length);
+
+ for (let index = 0; index < length; index += 1) {
+ const diff = (left[index] || 0) - (right[index] || 0);
+ if (diff !== 0) return diff;
+ }
- const Component = widget.component || widget.Component || widget.render;
- if (!Component) return null;
+ return 0;
+}
+function sanitizePermissions(permissions = []) {
+ return Array.from(
+ new Set(
+ (Array.isArray(permissions) ? permissions : []).filter((permission) =>
+ ALLOWED_PERMISSION_SCOPES.includes(permission)
+ )
+ )
+ );
+}
+
+function hasPermission(record, permission) {
+ return Array.isArray(record.permissionsGranted) && record.permissionsGranted.includes(permission);
+}
+
+function createFailureWidget(pluginId, name, message) {
return {
- id: String(widget.id || `${pluginRecord.id}:widget:${index}`),
- pluginId: pluginRecord.id,
- pluginName: pluginRecord.name,
- title: widget.title || widget.name || pluginRecord.name,
- placement: widget.placement || "settings",
- order: Number.isFinite(widget.order) ? widget.order : 100,
- props: widget.props || {},
- component: Component,
+ id: `${pluginId}:failure`,
+ title: `${name} unavailable`,
+ placement: "settings",
+ order: 999,
+ component: function PluginFailureWidget() {
+ return React.createElement(
+ "div",
+ {
+ style: {
+ color: "var(--red)",
+ fontSize: "12px",
+ lineHeight: 1.5,
+ },
+ },
+ message
+ );
+ },
};
}
-function normalizeDataSource(dataSource, pluginRecord, index) {
- if (!dataSource || typeof dataSource !== "object") return null;
+function createIframeRuntime(manifest) {
return {
- id: String(dataSource.id || `${pluginRecord.id}:data-source:${index}`),
- pluginId: pluginRecord.id,
- pluginName: pluginRecord.name,
- name: dataSource.name || dataSource.id || `Data source ${index + 1}`,
- description: dataSource.description || "",
- fetch: typeof dataSource.fetch === "function" ? dataSource.fetch : null,
- subscribe: typeof dataSource.subscribe === "function" ? dataSource.subscribe : null,
- metadata: dataSource.metadata || {},
+ initialize: async () => undefined,
+ getWidgets: () => manifest.widgets || [],
+ getDataSources: () => manifest.dataSources || [],
};
}
-function createFallbackPlugin(plugin, reason) {
- const id = String(plugin?.id || `invalid-plugin-${Date.now()}`);
- const name = String(plugin?.name || id);
+function createModuleRuntimeLoader(manifest) {
+ if (!manifest.runtime?.entry) return null;
+ return async () => {
+ const imported = await import(/* @vite-ignore */ manifest.runtime.entry);
+ const runtime = normalizePlugin(imported);
+ if (typeof runtime === "function") {
+ return runtime();
+ }
+ return runtime || {};
+ };
+}
+
+function createRecord({
+ manifest,
+ runtime,
+ runtimeLoader,
+ sourceType,
+ permissionsGranted,
+ enabled = true,
+ installedAt = null,
+ initializedAt = null,
+ status = PLUGIN_STATUSES.REGISTERED,
+ error = null,
+ runtimeLoaded = true,
+}) {
return {
- id,
- name,
- initialize: () => undefined,
- getWidgets: () => [
- {
- id: `${id}:fallback`,
- title: `${name} unavailable`,
- placement: "settings",
- order: 1000,
- component: function PluginFallbackWidget() {
- return React.createElement(
- "div",
- {
- style: {
- color: "var(--red)",
- fontSize: "12px",
- lineHeight: 1.5,
- },
- },
- reason
- );
- },
- },
- ],
- getDataSources: () => [],
+ id: manifest.id,
+ name: manifest.name,
+ version: manifest.version,
+ manifest,
+ runtime,
+ runtimeLoader,
+ runtimeLoaded,
+ sourceType,
+ status,
+ error,
+ enabled,
+ installedAt,
+ initializedAt,
+ permissionsGranted: sanitizePermissions(permissionsGranted || manifest.permissions || []),
+ updateAvailable: false,
+ latestVersion: null,
+ dependencyStatus: null,
};
}
export class PluginManager {
- constructor({ store = useStore() } = {}) { // useStore() to get the actual store instance
- this.store = store;
+ constructor({ store = useStore } = {}) {
+ this.store = store && typeof store.getState === "function" ? store : useStore;
this.plugins = new Map();
this.listeners = new Set();
this.initializing = null;
+ this.marketplace = new Map();
+ this.hydrated = false;
}
- createDashboardApi(pluginId) {
- const getCurrentState = () => pickSafeState(this.store.getState());
+ createDashboardApi(pluginId, manifest) {
+ const currentState = this.store.getState();
+ const permissions = Array.isArray(manifest?.permissions) ? manifest.permissions : [];
- const actions = SAFE_ACTION_KEYS.reduce((safeActions, key) => {
- const action = this.store.getState()[key];
- if (typeof action === "function") {
- safeActions[key] = (...args) => action(...args);
+ const actions = {};
+ if (permissions.includes("dashboard:write")) {
+ SAFE_ACTION_KEYS.forEach((key) => {
+ const action = currentState[key];
+ if (typeof action === "function") {
+ actions[key] = (...args) => action(...args);
+ }
+ });
+ }
+
+ if (permissions.includes("notifications:write")) {
+ const addNotification = currentState.addNotification;
+ const removeNotification = currentState.removeNotification;
+ if (typeof addNotification === "function") {
+ actions.addNotification = (...args) => addNotification(...args);
+ }
+ if (typeof removeNotification === "function") {
+ actions.removeNotification = (...args) => removeNotification(...args);
}
- return safeActions;
- }, {});
+ }
return Object.freeze({
pluginId,
+ manifest,
+ permissions: Object.freeze([...permissions]),
version: "1.0.0",
- getState: getCurrentState,
+ getState: () => pickSafeState(this.store.getState()),
getConfig: () =>
freezePlainObject({
environment: getEnvironmentConfig(),
@@ -156,7 +293,9 @@ export class PluginManager {
}),
actions: Object.freeze(actions),
subscribe: (listener) => {
- if (typeof listener !== "function") return () => {};
+ if (typeof listener !== "function" || !permissions.includes("dashboard:read")) {
+ return () => {};
+ }
return this.store.subscribe((state) => listener(pickSafeState(state)));
},
logger: Object.freeze({
@@ -167,38 +306,214 @@ export class PluginManager {
});
}
- register(rawPlugin) {
+ emitChange() {
+ this.listeners.forEach((listener) => listener(this));
+ }
+
+ subscribe(listener) {
+ if (typeof listener !== "function") return () => {};
+ this.listeners.add(listener);
+ return () => this.listeners.delete(listener);
+ }
+
+ validate(plugin) {
+ if (!plugin || typeof plugin !== "object") {
+ return "Plugin export must be an object or factory.";
+ }
+
+ const manifest = normalizeManifest(plugin);
+ if (!manifest) return "Plugin manifest is invalid or incomplete.";
+ if (plugin.initialize && typeof plugin.initialize !== "function") {
+ return "Plugin initialize hook must be a function.";
+ }
+ if (plugin.getWidgets && typeof plugin.getWidgets !== "function") {
+ return "Plugin getWidgets hook must be a function.";
+ }
+ if (plugin.getDataSources && typeof plugin.getDataSources !== "function") {
+ return "Plugin getDataSources hook must be a function.";
+ }
+
+ return null;
+ }
+
+ getRecord(pluginId) {
+ return this.plugins.get(pluginId) || null;
+ }
+
+ getMarketplaceCache() {
+ return Array.from(this.marketplace.values());
+ }
+
+ async hydrateInstalledPlugins() {
+ if (this.hydrated) return;
+ this.hydrated = true;
+
+ const installed = loadInstalledPlugins();
+ for (const installedRecord of installed) {
+ if (!installedRecord || typeof installedRecord !== "object") continue;
+ const manifest = normalizeManifest(installedRecord.manifest);
+ if (!manifest) continue;
+
+ if (this.plugins.has(manifest.id)) {
+ continue;
+ }
+
+ const runtime =
+ manifest.runtime.mode === "iframe"
+ ? createIframeRuntime(manifest)
+ : {
+ initialize: async () => undefined,
+ getWidgets: () => manifest.widgets || [],
+ getDataSources: () => manifest.dataSources || [],
+ };
+
+ const runtimeLoader =
+ manifest.runtime.mode === "module" ? createModuleRuntimeLoader(manifest) : null;
+
+ const record = createRecord({
+ manifest,
+ runtime,
+ runtimeLoader,
+ sourceType: installedRecord.sourceType || "installed",
+ permissionsGranted: installedRecord.permissionsGranted || manifest.permissions || [],
+ enabled: installedRecord.enabled !== false,
+ installedAt: installedRecord.installedAt || new Date().toISOString(),
+ initializedAt: installedRecord.initializedAt || null,
+ status: installedRecord.enabled === false ? PLUGIN_STATUSES.DISABLED : PLUGIN_STATUSES.REGISTERED,
+ error: installedRecord.error || null,
+ runtimeLoaded: manifest.runtime.mode !== "module" || !runtimeLoader,
+ });
+
+ this.plugins.set(record.id, record);
+ }
+ }
+
+ persistRecord(record) {
+ if (record.sourceType === "builtin") return;
+
+ upsertInstalledPlugin({
+ id: record.id,
+ name: record.name,
+ version: record.version,
+ manifest: record.manifest,
+ permissionsGranted: record.permissionsGranted,
+ enabled: record.enabled,
+ installedAt: record.installedAt,
+ initializedAt: record.initializedAt,
+ error: record.error,
+ sourceType: record.sourceType,
+ });
+
+ const grants = loadPermissionGrants();
+ savePermissionGrants({
+ ...grants,
+ [record.id]: record.permissionsGranted,
+ });
+ }
+
+ removePersistedRecord(pluginId) {
+ removeInstalledPlugin(pluginId);
+ const grants = loadPermissionGrants();
+ if (grants && Object.prototype.hasOwnProperty.call(grants, pluginId)) {
+ const next = { ...grants };
+ delete next[pluginId];
+ savePermissionGrants(next);
+ }
+ }
+
+ register(rawPlugin, options = {}) {
const plugin = normalizePlugin(rawPlugin);
const validationError = this.validate(plugin);
- const safePlugin = validationError ? createFallbackPlugin(plugin, validationError) : plugin;
- const id = String(safePlugin.id);
+ const safePlugin = validationError
+ ? {
+ id: String(plugin?.id || `invalid-plugin-${Date.now()}`),
+ name: String(plugin?.name || plugin?.id || "Invalid plugin"),
+ version: "0.0.0",
+ permissions: [],
+ runtime: { mode: "iframe" },
+ widgets: [
+ createFailureWidget(
+ String(plugin?.id || "invalid"),
+ String(plugin?.name || "Invalid"),
+ validationError
+ ),
+ ],
+ dataSources: [],
+ initialize: async () => undefined,
+ getWidgets: () => [
+ createFailureWidget(
+ String(plugin?.id || "invalid"),
+ String(plugin?.name || "Invalid"),
+ validationError
+ ),
+ ],
+ getDataSources: () => [],
+ }
+ : plugin;
- if (this.plugins.has(id)) {
- throw new Error(`Plugin ID conflict: "${id}" is already registered.`);
+ const manifest = normalizeManifest(safePlugin);
+ if (!manifest) {
+ throw new Error("Plugin manifest is invalid.");
}
- const record = {
- id,
- name: String(safePlugin.name || id),
- plugin: safePlugin,
- status: PLUGIN_STATUSES.REGISTERED,
- error: validationError || null,
- initializedAt: null,
- };
+ if (this.plugins.has(manifest.id)) {
+ throw new Error(`Plugin ID conflict: "${manifest.id}" is already registered.`);
+ }
+
+ const runtime =
+ options.runtime ||
+ safePlugin ||
+ (manifest.runtime.mode === "iframe" ? createIframeRuntime(manifest) : null);
+
+ const runtimeLoader =
+ options.runtimeLoader ||
+ (manifest.runtime.mode === "module" ? createModuleRuntimeLoader(manifest) : null);
+
+ const record = createRecord({
+ manifest,
+ runtime,
+ runtimeLoader,
+ sourceType: options.sourceType || "builtin",
+ permissionsGranted: options.permissionsGranted || manifest.permissions || [],
+ enabled: options.enabled !== false,
+ installedAt: options.installedAt || null,
+ initializedAt: options.initializedAt || null,
+ status: options.enabled === false ? PLUGIN_STATUSES.DISABLED : PLUGIN_STATUSES.REGISTERED,
+ error: validationError || options.error || null,
+ runtimeLoaded: options.runtimeLoaded !== undefined ? options.runtimeLoaded : manifest.runtime.mode !== "module" || !runtimeLoader,
+ });
- this.plugins.set(id, record);
+ this.plugins.set(record.id, record);
this.emitChange();
return record;
}
- validate(plugin) {
- if (!plugin || typeof plugin !== "object") return "Plugin export must be an object or factory.";
- if (!plugin.id || typeof plugin.id !== "string") return "Plugin is missing a string id.";
- if (!plugin.name || typeof plugin.name !== "string") return "Plugin is missing a string name.";
- if (plugin.initialize && typeof plugin.initialize !== "function") return "Plugin initialize hook must be a function.";
- if (plugin.getWidgets && typeof plugin.getWidgets !== "function") return "Plugin getWidgets hook must be a function.";
- if (plugin.getDataSources && typeof plugin.getDataSources !== "function") return "Plugin getDataSources hook must be a function.";
- return null;
+ canActivate(record) {
+ if (!record.enabled) {
+ return { ok: false, reason: "Plugin is disabled." };
+ }
+
+ const missingDependencies = (record.manifest.dependencies?.plugins || []).filter(
+ (dependencyId) => !this.plugins.has(dependencyId)
+ );
+ if (missingDependencies.length > 0) {
+ return {
+ ok: false,
+ reason: `Missing plugin dependencies: ${missingDependencies.join(", ")}`,
+ };
+ }
+
+ const missingPermissions = (record.manifest.permissions || []).filter(
+ (permission) => !hasPermission(record, permission)
+ );
+ if (missingPermissions.length > 0) {
+ return {
+ ok: false,
+ reason: `Missing permissions: ${missingPermissions.join(", ")}`,
+ };
+ }
+
+ return { ok: true };
}
async initializeAll() {
@@ -206,29 +521,84 @@ export class PluginManager {
this.initializing = Promise.all(
Array.from(this.plugins.values()).map(async (record) => {
- if (record.status === PLUGIN_STATUSES.INITIALIZED) return record;
+ const activation = this.canActivate(record);
+ if (!activation.ok) {
+ record.status = record.enabled ? PLUGIN_STATUSES.BLOCKED : PLUGIN_STATUSES.DISABLED;
+ record.error = activation.reason;
+ return record;
+ }
+
+ if (record.status === PLUGIN_STATUSES.INITIALIZED && record.runtimeLoaded) {
+ return record;
+ }
try {
- const api = this.createDashboardApi(record.id);
- if (typeof record.plugin.initialize === "function") {
- await record.plugin.initialize(api);
+ if (record.runtimeLoader && !record.runtimeLoaded) {
+ record.status = PLUGIN_STATUSES.REGISTERED;
+ record.runtime = await record.runtimeLoader();
+ record.runtimeLoaded = true;
}
+
+ const api = this.createDashboardApi(record.id, record.manifest);
+ if (typeof record.runtime?.initialize === "function") {
+ await record.runtime.initialize(api);
+ }
+
record.status = PLUGIN_STATUSES.INITIALIZED;
record.initializedAt = new Date().toISOString();
+ record.error = null;
+ this.persistRecord(record);
} catch (error) {
record.status = PLUGIN_STATUSES.FAILED;
record.error = error?.message || String(error);
}
- this.emitChange();
+
return record;
})
).finally(() => {
this.initializing = null;
+ this.refreshMarketplaceSnapshot().catch(() => {});
+ this.emitChange();
});
return this.initializing;
}
+ async refreshMarketplaceSnapshot() {
+ const catalog = await fetchMarketplacePlugins();
+ this.marketplace = new Map(catalog.map((plugin) => [plugin.id, plugin]));
+
+ for (const record of this.plugins.values()) {
+ const marketplacePlugin = this.marketplace.get(record.id);
+ record.latestVersion = marketplacePlugin?.version || record.version;
+ record.updateAvailable =
+ !!marketplacePlugin && compareVersions(marketplacePlugin.version, record.version) > 0;
+ }
+
+ this.emitChange();
+ return this.getMarketplacePlugins();
+ }
+
+ async getMarketplacePlugins() {
+ if (this.marketplace.size === 0) {
+ await this.refreshMarketplaceSnapshot();
+ }
+
+ return Array.from(this.marketplace.values()).map((plugin) => {
+ const installed = this.plugins.get(plugin.id);
+ const updateAvailable = installed
+ ? compareVersions(plugin.version, installed.version) > 0
+ : false;
+
+ return {
+ ...plugin,
+ installed: Boolean(installed),
+ installedVersion: installed?.version || null,
+ updateAvailable,
+ };
+ });
+ }
+
getPluginRecords() {
return Array.from(this.plugins.values()).map((record) => ({
id: record.id,
@@ -236,17 +606,32 @@ export class PluginManager {
status: record.status,
error: record.error,
initializedAt: record.initializedAt,
+ version: record.version,
+ latestVersion: record.latestVersion || record.version,
+ updateAvailable: Boolean(record.updateAvailable),
+ sourceType: record.sourceType,
+ enabled: record.enabled,
+ permissionsGranted: record.permissionsGranted,
+ permissionsRequested: record.manifest.permissions || [],
+ dependencies: record.manifest.dependencies?.plugins || [],
+ installedAt: record.installedAt,
}));
}
getWidgets({ placement } = {}) {
return Array.from(this.plugins.values())
.flatMap((record) => {
+ if (record.status === PLUGIN_STATUSES.FAILED || record.status === PLUGIN_STATUSES.DISABLED) {
+ return [];
+ }
+
try {
const widgets =
- typeof record.plugin.getWidgets === "function" ? record.plugin.getWidgets() : [];
+ typeof record.runtime?.getWidgets === "function"
+ ? record.runtime.getWidgets()
+ : record.manifest.widgets || [];
return widgets
- .map((widget, index) => normalizeWidget(widget, record, index))
+ .map((widget, index) => this.normalizeWidget(widget, record, index))
.filter(Boolean);
} catch (error) {
record.error = error?.message || String(error);
@@ -258,13 +643,52 @@ export class PluginManager {
.sort((a, b) => a.order - b.order || a.title.localeCompare(b.title));
}
+ normalizeWidget(widget, record, index) {
+ if (!widget || typeof widget !== "object") return null;
+
+ const Component = widget.component || widget.Component || widget.render;
+ const isIframeWidget = widget.kind === "iframe" || (!Component && record.manifest.runtime.mode === "iframe");
+
+ if (!Component && !isIframeWidget) {
+ return null;
+ }
+
+ return {
+ id: String(widget.id || `${record.id}:widget:${index}`),
+ pluginId: record.id,
+ pluginName: record.name,
+ title: widget.title || widget.name || record.name,
+ placement: widget.placement || "settings",
+ order: Number.isFinite(widget.order) ? widget.order : 100,
+ props: widget.props || {},
+ component: isIframeWidget
+ ? (iframeProps) =>
+ React.createElement(SandboxedPluginFrame, {
+ title: widget.title || widget.name || record.name,
+ description: widget.description || record.manifest.description,
+ src: widget.src || record.manifest.runtime.entry || undefined,
+ srcDoc: widget.srcDoc || record.manifest.runtime.srcDoc || undefined,
+ sandbox: widget.sandbox || record.manifest.runtime.sandbox,
+ height: widget.height || 220,
+ ...iframeProps,
+ })
+ : Component,
+ };
+ }
+
getDataSources() {
return Array.from(this.plugins.values()).flatMap((record) => {
+ if (record.status === PLUGIN_STATUSES.FAILED || record.status === PLUGIN_STATUSES.DISABLED) {
+ return [];
+ }
+
try {
const dataSources =
- typeof record.plugin.getDataSources === "function" ? record.plugin.getDataSources() : [];
+ typeof record.runtime?.getDataSources === "function"
+ ? record.runtime.getDataSources()
+ : record.manifest.dataSources || [];
return dataSources
- .map((dataSource, index) => normalizeDataSource(dataSource, record, index))
+ .map((dataSource, index) => this.normalizeDataSource(dataSource, record, index))
.filter(Boolean);
} catch (error) {
record.error = error?.message || String(error);
@@ -274,16 +698,179 @@ export class PluginManager {
});
}
- subscribe(listener) {
- if (typeof listener !== "function") return () => {};
- this.listeners.add(listener);
- return () => this.listeners.delete(listener);
+ normalizeDataSource(dataSource, record, index) {
+ if (!dataSource || typeof dataSource !== "object") return null;
+ return {
+ id: String(dataSource.id || `${record.id}:data-source:${index}`),
+ pluginId: record.id,
+ pluginName: record.name,
+ name: dataSource.name || dataSource.id || `Data source ${index + 1}`,
+ description: dataSource.description || "",
+ fetch: typeof dataSource.fetch === "function" ? dataSource.fetch : null,
+ subscribe: typeof dataSource.subscribe === "function" ? dataSource.subscribe : null,
+ metadata: dataSource.metadata || {},
+ };
}
- emitChange() {
- this.listeners.forEach((listener) => listener(this));
+ async installPlugin(manifest, { approvedPermissions } = {}) {
+ const normalizedManifest = normalizeManifest(manifest);
+ if (!normalizedManifest) {
+ throw new Error("Cannot install an invalid plugin manifest.");
+ }
+
+ const existing = this.plugins.get(normalizedManifest.id);
+ if (existing?.sourceType === "builtin") {
+ throw new Error(`"${normalizedManifest.id}" is reserved by a built-in plugin.`);
+ }
+ if (existing?.sourceType === "installed") {
+ await this.uninstallPlugin(normalizedManifest.id);
+ }
+
+ const requested = sanitizePermissions(normalizedManifest.permissions || []);
+ const granted = sanitizePermissions(approvedPermissions || requested);
+
+ if (requested.some((permission) => !granted.includes(permission))) {
+ throw new Error("Permission review required before installing this plugin.");
+ }
+
+ const runtime =
+ normalizedManifest.runtime.mode === "iframe"
+ ? createIframeRuntime(normalizedManifest)
+ : null;
+ const runtimeLoader =
+ normalizedManifest.runtime.mode === "module"
+ ? createModuleRuntimeLoader(normalizedManifest)
+ : null;
+
+ const record = createRecord({
+ manifest: normalizedManifest,
+ runtime,
+ runtimeLoader,
+ sourceType: "installed",
+ permissionsGranted: granted,
+ enabled: true,
+ installedAt: new Date().toISOString(),
+ status: PLUGIN_STATUSES.REGISTERED,
+ runtimeLoaded: normalizedManifest.runtime.mode !== "module" || !runtimeLoader,
+ });
+
+ this.plugins.set(record.id, record);
+ this.persistRecord(record);
+ await this.initializeAll();
+ return record;
+ }
+
+ async updatePlugin(pluginId) {
+ const current = this.plugins.get(pluginId);
+ if (!current) {
+ throw new Error(`Plugin "${pluginId}" is not installed.`);
+ }
+
+ const marketplacePlugin = await fetchMarketplacePluginById(pluginId);
+ if (!marketplacePlugin) {
+ throw new Error(`No marketplace update was found for "${pluginId}".`);
+ }
+
+ const currentPermissions = current.permissionsGranted || [];
+ const requested = sanitizePermissions(marketplacePlugin.permissions || []);
+ const missingPermissions = requested.filter((permission) => !currentPermissions.includes(permission));
+ if (missingPermissions.length > 0) {
+ throw new Error(
+ `Plugin update requires additional permissions: ${missingPermissions.join(", ")}`
+ );
+ }
+
+ return this.installPlugin(marketplacePlugin, { approvedPermissions: currentPermissions });
+ }
+
+ async uninstallPlugin(pluginId) {
+ this.plugins.delete(pluginId);
+ this.removePersistedRecord(pluginId);
+ this.emitChange();
+ }
+
+ async setPluginEnabled(pluginId, enabled) {
+ const record = this.plugins.get(pluginId);
+ if (!record) {
+ throw new Error(`Plugin "${pluginId}" is not installed.`);
+ }
+
+ record.enabled = Boolean(enabled);
+ record.status = record.enabled ? PLUGIN_STATUSES.REGISTERED : PLUGIN_STATUSES.DISABLED;
+ record.error = null;
+ this.persistRecord(record);
+ await this.initializeAll();
+ return record;
+ }
+
+ async bootstrap() {
+ await this.hydrateInstalledPlugins();
+ await this.refreshMarketplaceSnapshot();
+ return this;
}
}
export const pluginManager = new PluginManager();
+
+export async function registerActivePlugins(manager = pluginManager) {
+ if (registrationComplete) return manager;
+ if (registrationPromise) return registrationPromise;
+
+ registrationPromise = (async () => {
+ await manager.hydrateInstalledPlugins();
+
+ await Promise.all(
+ Object.entries(pluginModules).map(async ([path, loadModule]) => {
+ try {
+ const module = await loadModule();
+ const pluginFactory = normalizePlugin(module);
+ if (!pluginFactory) {
+ manager.register(
+ {
+ id: path.replace(/[^a-z0-9]+/gi, "-").toLowerCase(),
+ name: path,
+ version: "0.0.0",
+ runtime: { mode: "iframe" },
+ widgets: [],
+ dataSources: [],
+ },
+ { sourceType: "builtin" }
+ );
+ return;
+ }
+
+ manager.register(pluginFactory, { sourceType: "builtin" });
+ } catch (error) {
+ const id = path.replace(/[^a-z0-9]+/gi, "-").toLowerCase();
+ manager.register(
+ {
+ id,
+ name: path,
+ version: "0.0.0",
+ runtime: { mode: "iframe" },
+ widgets: [
+ createFailureWidget(id, path, error?.message || String(error)),
+ ],
+ dataSources: [],
+ initialize: async () => {
+ throw error;
+ },
+ },
+ { sourceType: "builtin", error: error?.message || String(error) }
+ );
+ }
+ })
+ );
+
+ await manager.bootstrap();
+ await manager.initializeAll();
+ registrationComplete = true;
+ return manager;
+ })().finally(() => {
+ registrationPromise = null;
+ });
+
+ return registrationPromise;
+}
+
export { PLUGIN_STATUSES };
diff --git a/src/plugins/__tests__/PluginManager.test.js b/src/plugins/__tests__/PluginManager.test.js
new file mode 100644
index 00000000..2f52bb0c
--- /dev/null
+++ b/src/plugins/__tests__/PluginManager.test.js
@@ -0,0 +1,85 @@
+import { describe, expect, it, beforeEach, vi } from "vitest";
+import { PluginManager, PLUGIN_STATUSES } from "../PluginManager";
+
+function createMockStore() {
+ const listeners = new Set();
+ let state = {
+ network: "testnet",
+ theme: "dark",
+ activeTab: "overview",
+ connectedAddress: "GTEST",
+ };
+
+ return {
+ getState: () => state,
+ setState: (nextState) => {
+ state = { ...state, ...nextState };
+ listeners.forEach((listener) => listener(state));
+ },
+ subscribe: (listener) => {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ },
+ };
+}
+
+const iframeManifest = {
+ id: "community.test-extension",
+ name: "Test Extension",
+ version: "1.0.0",
+ description: "A sandboxed extension used in unit tests.",
+ permissions: ["dashboard:read"],
+ runtime: {
+ mode: "iframe",
+ sandbox: ["allow-scripts"],
+ srcDoc: "
Test extension
",
+ },
+ widgets: [
+ {
+ id: "community.test-extension.settings",
+ title: "Test Extension",
+ placement: "settings",
+ kind: "iframe",
+ },
+ ],
+ dataSources: [],
+};
+
+describe("PluginManager", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ vi.restoreAllMocks();
+ });
+
+ it("installs sandboxed manifests, persists them, and exposes widgets", async () => {
+ const manager = new PluginManager({ store: createMockStore() });
+
+ await manager.installPlugin(iframeManifest);
+
+ const records = manager.getPluginRecords();
+ expect(records).toHaveLength(1);
+ expect(records[0].id).toBe(iframeManifest.id);
+ expect(records[0].status).toBe(PLUGIN_STATUSES.INITIALIZED);
+
+ const widgets = manager.getWidgets({ placement: "settings" });
+ expect(widgets).toHaveLength(1);
+ expect(widgets[0].pluginId).toBe(iframeManifest.id);
+
+ const installedSnapshot = JSON.parse(localStorage.getItem("stellar-dashboard:plugins:v1"));
+ expect(installedSnapshot.installed).toHaveLength(1);
+ expect(installedSnapshot.installed[0].manifest.id).toBe(iframeManifest.id);
+ });
+
+ it("hydrates installed plugins from storage and removes them cleanly", async () => {
+ const manager = new PluginManager({ store: createMockStore() });
+
+ await manager.installPlugin(iframeManifest);
+ await manager.uninstallPlugin(iframeManifest.id);
+
+ expect(manager.getPluginRecords()).toHaveLength(0);
+
+ const secondManager = new PluginManager({ store: createMockStore() });
+ await secondManager.hydrateInstalledPlugins();
+ expect(secondManager.getPluginRecords()).toHaveLength(0);
+ });
+});
diff --git a/src/plugins/index.js b/src/plugins/index.js
index 1118f3ee..0609b644 100644
--- a/src/plugins/index.js
+++ b/src/plugins/index.js
@@ -1,81 +1,16 @@
-import { PluginManager } from "./PluginManager";
-import React from "react";
+export {
+ PluginManager,
+ pluginManager,
+ registerActivePlugins,
+ PLUGIN_STATUSES,
+} from "./PluginManager";
-const pluginModules = import.meta.glob("./**/*Plugin.{js,jsx,ts,tsx}", {
- eager: false,
-});
+export {
+ fetchMarketplacePlugins,
+ fetchMarketplacePluginById,
+ getMarketplacePluginIndex,
+ listMarketplacePluginSummaries,
+ MARKETPLACE_PLUGINS,
+} from "./pluginCatalog";
-let registrationPromise = null;
-
-function getPluginFactory(module) {
- return module?.default || module?.plugin || module?.createPlugin || null;
-}
-
-function pathToPluginId(prefix, path) {
- return `${prefix}.${path.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "").toLowerCase()}`;
-}
-
-function registerWithFallback(manager, plugin, path) {
- try {
- manager.register(plugin);
- } catch (error) {
- manager.register({
- id: pathToPluginId("conflict", path),
- name: `${path} registration conflict`,
- initialize: () => undefined,
- getWidgets: () => [
- {
- id: `${pathToPluginId("conflict", path)}.widget`,
- title: "Plugin registration conflict",
- placement: "settings",
- order: 1000,
- component: function PluginConflictWidget() {
- return React.createElement(
- "div",
- { style: { color: "var(--red)", fontSize: "12px" } },
- error?.message || String(error)
- );
- },
- },
- ],
- getDataSources: () => [],
- });
- }
-}
-
-export async function registerActivePlugins(manager = pluginManager) {
- if (registrationPromise) return registrationPromise;
- registrationPromise = Promise.all(
- Object.entries(pluginModules).map(async ([path, loadModule]) => {
- try {
- const module = await loadModule();
- const pluginFactory = getPluginFactory(module);
- if (!pluginFactory) {
- registerWithFallback(manager, {
- id: pathToPluginId("invalid", path),
- name: path,
- getWidgets: () => [],
- getDataSources: () => [],
- }, path);
- return;
- }
- registerWithFallback(manager, pluginFactory, path);
- } catch (error) {
- const id = pathToPluginId("failed", path);
- registerWithFallback(manager, {
- id,
- name: path,
- initialize: () => {
- throw error;
- },
- getWidgets: () => [],
- getDataSources: () => [],
- }, path);
- }
- })
- )
- .then(() => manager.initializeAll())
- .then(() => manager);
- return registrationPromise;
-}
-export const pluginManager = new PluginManager();
+export { loadInstalledPlugins, loadPermissionGrants } from "./pluginStorage";
diff --git a/src/plugins/pluginCatalog.js b/src/plugins/pluginCatalog.js
new file mode 100644
index 00000000..9dfd0ea8
--- /dev/null
+++ b/src/plugins/pluginCatalog.js
@@ -0,0 +1,220 @@
+const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+
+function makeIframeSrcDoc({ title, accent, body, footer }) {
+ return `
+
+
+
+
+
+
+
+
+
+
${title}
+
${title}
+
${body}
+
Sandboxed extension preview
+
+
+
+
+`;
+}
+
+const MARKETPLACE_PLUGINS = [
+ {
+ id: "community.activity-radar",
+ name: "Activity Radar",
+ version: "1.2.0",
+ description:
+ "Surfaces recent dashboard activity in a compact, permission-scoped overview card.",
+ author: "Community Labs",
+ homepageUrl: "https://example.com/activity-radar",
+ permissions: ["dashboard:read", "data:read"],
+ runtime: {
+ mode: "iframe",
+ sandbox: ["allow-scripts"],
+ srcDoc: makeIframeSrcDoc({
+ title: "Activity Radar",
+ accent: "#66d9ef",
+ body:
+ "Tracks the current network, active tab, and high-signal events without exposing write access to the host app.",
+ footer: "Permissions: dashboard:read, data:read",
+ }),
+ },
+ widgets: [
+ {
+ id: "community.activity-radar.settings",
+ title: "Activity Radar",
+ placement: "settings",
+ order: 5,
+ kind: "iframe",
+ height: 220,
+ },
+ ],
+ dataSources: [],
+ },
+ {
+ id: "community.security-beacon",
+ name: "Security Beacon",
+ version: "1.0.3",
+ description:
+ "Highlights permission grants, plugin updates, and sensitive dashboard actions.",
+ author: "Shield Collective",
+ homepageUrl: "https://example.com/security-beacon",
+ permissions: ["dashboard:read", "notifications:write"],
+ runtime: {
+ mode: "iframe",
+ sandbox: ["allow-scripts"],
+ srcDoc: makeIframeSrcDoc({
+ title: "Security Beacon",
+ accent: "#f97316",
+ body:
+ "Uses the plugin permission model to show how third-party extensions can request only the access they need.",
+ footer: "Permissions: dashboard:read, notifications:write",
+ }),
+ },
+ widgets: [
+ {
+ id: "community.security-beacon.settings",
+ title: "Security Beacon",
+ placement: "settings",
+ order: 12,
+ kind: "iframe",
+ height: 220,
+ },
+ ],
+ dataSources: [],
+ },
+ {
+ id: "community.network-snapshot",
+ name: "Network Snapshot",
+ version: "0.9.8",
+ description:
+ "A sandboxed summary card that demonstrates dependency metadata and version management.",
+ author: "Open Extensions",
+ homepageUrl: "https://example.com/network-snapshot",
+ permissions: ["dashboard:read"],
+ dependencies: { plugins: ["core.runtime-status"] },
+ runtime: {
+ mode: "iframe",
+ sandbox: ["allow-scripts"],
+ srcDoc: makeIframeSrcDoc({
+ title: "Network Snapshot",
+ accent: "#34d399",
+ body:
+ "Uses the built-in runtime-status plugin as a dependency and shows how plugin manifests can declare load-order constraints.",
+ footer: "Dependency: core.runtime-status",
+ }),
+ },
+ widgets: [
+ {
+ id: "community.network-snapshot.settings",
+ title: "Network Snapshot",
+ placement: "settings",
+ order: 20,
+ kind: "iframe",
+ height: 220,
+ },
+ ],
+ dataSources: [],
+ },
+];
+
+function clonePluginManifest(plugin) {
+ return JSON.parse(JSON.stringify(plugin));
+}
+
+export async function fetchMarketplacePlugins() {
+ await delay(200);
+ return MARKETPLACE_PLUGINS.map(clonePluginManifest);
+}
+
+export async function fetchMarketplacePluginById(pluginId) {
+ const catalog = await fetchMarketplacePlugins();
+ return catalog.find((plugin) => plugin.id === pluginId) || null;
+}
+
+export function getMarketplacePluginIndex(pluginId) {
+ return MARKETPLACE_PLUGINS.findIndex((plugin) => plugin.id === pluginId);
+}
+
+export function listMarketplacePluginSummaries() {
+ return MARKETPLACE_PLUGINS.map(({ runtime, ...plugin }) => ({
+ ...plugin,
+ permissionCount: plugin.permissions.length,
+ widgetCount: Array.isArray(plugin.widgets) ? plugin.widgets.length : 0,
+ executionMode: runtime?.mode || "iframe",
+ }));
+}
+
+export { MARKETPLACE_PLUGINS };
diff --git a/src/plugins/pluginSandbox.jsx b/src/plugins/pluginSandbox.jsx
new file mode 100644
index 00000000..45738dc4
--- /dev/null
+++ b/src/plugins/pluginSandbox.jsx
@@ -0,0 +1,84 @@
+import React from "react";
+
+function buildSandboxAttribute(sandbox) {
+ const tokens = Array.isArray(sandbox) ? sandbox.filter(Boolean) : [];
+ return tokens.length ? tokens.join(" ") : "allow-scripts";
+}
+
+function buildFallbackSrcDoc(title, description) {
+ const safeTitle = String(title || "Plugin").replace(/
+
+
+
+
+
+
+
+
+
${safeTitle}
+
${safeDescription}
+
+
+`;
+}
+
+export default function SandboxedPluginFrame({
+ title,
+ description,
+ src,
+ srcDoc,
+ sandbox,
+ height = 220,
+}) {
+ const iframeSrcDoc = srcDoc || buildFallbackSrcDoc(title, description);
+ const iframeSandbox = buildSandboxAttribute(sandbox);
+
+ return (
+
+ );
+}
diff --git a/src/plugins/pluginStorage.js b/src/plugins/pluginStorage.js
new file mode 100644
index 00000000..e3def937
--- /dev/null
+++ b/src/plugins/pluginStorage.js
@@ -0,0 +1,127 @@
+const STORAGE_KEY = "stellar-dashboard:plugins:v1";
+
+function isBrowser() {
+ return typeof window !== "undefined" && typeof localStorage !== "undefined";
+}
+
+function readJson(raw) {
+ if (!raw) return null;
+ try {
+ return JSON.parse(raw);
+ } catch {
+ return null;
+ }
+}
+
+function readState() {
+ if (!isBrowser()) {
+ return { installed: [], permissionGrants: {}, metadata: {} };
+ }
+
+ const parsed = readJson(localStorage.getItem(STORAGE_KEY));
+ if (!parsed || typeof parsed !== "object") {
+ return { installed: [], permissionGrants: {}, metadata: {} };
+ }
+
+ return {
+ installed: Array.isArray(parsed.installed) ? parsed.installed : [],
+ permissionGrants:
+ parsed.permissionGrants && typeof parsed.permissionGrants === "object"
+ ? parsed.permissionGrants
+ : {},
+ metadata: parsed.metadata && typeof parsed.metadata === "object" ? parsed.metadata : {},
+ };
+}
+
+function writeState(nextState) {
+ if (!isBrowser()) return;
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(nextState));
+ } catch {
+ // Storage can fail in private mode or quota-limited environments.
+ }
+}
+
+export function loadPluginStorage() {
+ return readState();
+}
+
+export function savePluginStorage(nextState) {
+ writeState({
+ installed: Array.isArray(nextState?.installed) ? nextState.installed : [],
+ permissionGrants:
+ nextState?.permissionGrants && typeof nextState.permissionGrants === "object"
+ ? nextState.permissionGrants
+ : {},
+ metadata:
+ nextState?.metadata && typeof nextState.metadata === "object" ? nextState.metadata : {},
+ });
+}
+
+export function loadInstalledPlugins() {
+ return readState().installed;
+}
+
+export function saveInstalledPlugins(installed) {
+ const current = readState();
+ writeState({
+ ...current,
+ installed: Array.isArray(installed) ? installed : [],
+ });
+}
+
+export function loadPermissionGrants() {
+ return readState().permissionGrants;
+}
+
+export function savePermissionGrants(permissionGrants) {
+ const current = readState();
+ writeState({
+ ...current,
+ permissionGrants:
+ permissionGrants && typeof permissionGrants === "object" ? permissionGrants : {},
+ });
+}
+
+export function upsertInstalledPlugin(record) {
+ const current = readState();
+ const installed = current.installed.filter((item) => item.id !== record.id);
+ installed.unshift(record);
+ writeState({
+ ...current,
+ installed,
+ });
+ return installed;
+}
+
+export function removeInstalledPlugin(pluginId) {
+ const current = readState();
+ const installed = current.installed.filter((item) => item.id !== pluginId);
+ const permissionGrants = { ...current.permissionGrants };
+ delete permissionGrants[pluginId];
+ writeState({
+ ...current,
+ installed,
+ permissionGrants,
+ });
+ return installed;
+}
+
+export function setPluginMetadata(pluginId, metadata) {
+ const current = readState();
+ writeState({
+ ...current,
+ metadata: {
+ ...current.metadata,
+ [pluginId]: {
+ ...(current.metadata[pluginId] || {}),
+ ...(metadata && typeof metadata === "object" ? metadata : {}),
+ },
+ },
+ });
+}
+
+export function getPluginMetadata(pluginId) {
+ const current = readState();
+ return current.metadata[pluginId] || null;
+}
diff --git a/src/plugins/runtimeStatusPlugin.jsx b/src/plugins/runtimeStatusPlugin.jsx
index 58669ff0..9f0e77e4 100644
--- a/src/plugins/runtimeStatusPlugin.jsx
+++ b/src/plugins/runtimeStatusPlugin.jsx
@@ -45,6 +45,8 @@ export default function createRuntimeStatusPlugin() {
return {
id: "core.runtime-status",
name: "Runtime Status",
+ version: "1.0.0",
+ permissions: ["dashboard:read"],
initialize(api) {
apiRef = api;
api.logger.info("Runtime status plugin initialized.");
diff --git a/src/plugins/sdk.ts b/src/plugins/sdk.ts
new file mode 100644
index 00000000..49849775
--- /dev/null
+++ b/src/plugins/sdk.ts
@@ -0,0 +1,125 @@
+export const PLUGIN_PERMISSION_SCOPES = [
+ "dashboard:read",
+ "dashboard:write",
+ "data:read",
+ "data:write",
+ "notifications:write",
+ "network:request",
+ "storage:read",
+ "storage:write",
+ "window:open",
+] as const;
+
+export type PluginPermissionScope = (typeof PLUGIN_PERMISSION_SCOPES)[number];
+
+export type PluginExecutionMode = "module" | "iframe";
+
+export interface PluginAuthor {
+ name: string;
+ url?: string;
+}
+
+export interface PluginRuntimeDescriptor {
+ mode: PluginExecutionMode;
+ entry?: string;
+ source?: string;
+ srcDoc?: string;
+ sandbox?: string[];
+}
+
+export interface PluginWidgetDescriptor {
+ id: string;
+ title: string;
+ placement: string;
+ order?: number;
+ kind?: "react" | "iframe";
+ component?: unknown;
+ src?: string;
+ srcDoc?: string;
+ sandbox?: string[];
+ height?: number;
+ props?: Record
;
+}
+
+export interface PluginDataSourceDescriptor {
+ id: string;
+ name: string;
+ description?: string;
+ fetch?: unknown;
+ subscribe?: unknown;
+ metadata?: Record;
+}
+
+export interface PluginManifest {
+ id: string;
+ name: string;
+ version: string;
+ description?: string;
+ author?: PluginAuthor | string;
+ homepageUrl?: string;
+ permissions?: PluginPermissionScope[];
+ dependencies?: {
+ plugins?: string[];
+ };
+ runtime: PluginRuntimeDescriptor;
+ widgets?: PluginWidgetDescriptor[];
+ dataSources?: PluginDataSourceDescriptor[];
+}
+
+export interface PluginDefinition {
+ manifest: PluginManifest;
+ initialize?: (_api: unknown) => unknown | Promise;
+ getWidgets?: () => PluginWidgetDescriptor[];
+ getDataSources?: () => PluginDataSourceDescriptor[];
+}
+
+export function definePlugin(definition: PluginDefinition): PluginDefinition {
+ return definition;
+}
+
+export function createPluginManifest(
+ manifest: PluginManifest
+): PluginManifest {
+ return {
+ ...manifest,
+ permissions: Array.isArray(manifest.permissions) ? manifest.permissions : [],
+ dependencies: {
+ plugins: Array.isArray(manifest.dependencies?.plugins)
+ ? manifest.dependencies?.plugins
+ : [],
+ },
+ widgets: Array.isArray(manifest.widgets) ? manifest.widgets : [],
+ dataSources: Array.isArray(manifest.dataSources) ? manifest.dataSources : [],
+ };
+}
+
+export function createIframeWidget(
+ widget: Omit & { kind?: "iframe" }
+): PluginWidgetDescriptor {
+ return {
+ ...widget,
+ kind: "iframe",
+ };
+}
+
+export function isPluginPermissionScope(value: unknown): value is PluginPermissionScope {
+ return typeof value === "string" && (PLUGIN_PERMISSION_SCOPES as readonly string[]).includes(value);
+}
+
+export function comparePluginVersions(a: string, b: string): number {
+ const parse = (value: string) =>
+ String(value || "0.0.0")
+ .split(".")
+ .map((segment) => Number.parseInt(segment, 10) || 0);
+
+ const left = parse(a);
+ const right = parse(b);
+ const length = Math.max(left.length, right.length);
+
+ for (let index = 0; index < length; index += 1) {
+ const diff = (left[index] || 0) - (right[index] || 0);
+ if (diff !== 0) return diff;
+ }
+
+ return 0;
+}
diff --git a/tests/setup.js b/tests/setup.js
index 9029fd93..ee507308 100644
--- a/tests/setup.js
+++ b/tests/setup.js
@@ -25,6 +25,21 @@ Object.assign(navigator, {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
});
+// ─── window.matchMedia mock ─────────────────────────────────────────────────
+Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query) => ({
+ matches: query.includes('prefers-color-scheme: dark'),
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+});
+
// ─── Stellar SDK mock ─────────────────────────────────────────────────────────
vi.mock('@stellar/stellar-sdk', async () => {
const actual = await vi.importActual('@stellar/stellar-sdk');