import { Events } from '../events' import type { PluginManifest } from '../types' import type { PluginRegistry } from './plugin-registry' import { validateManifest } from './manifest-validator' import { readCatalogBundles, saveCatalogBundle, deleteCatalogBundle, type CatalogBundle } from './catalog-storage' import { downloadCatalogBundle, type CatalogPlugin } from './plugin-catalog' import { areThirdPartyPluginsEnabled, THIRD_PARTY_PLUGINS_UNAVAILABLE } from '../../app/capabilities' import { codemirrorStateModule, codemirrorViewModule, codemirrorLanguageModule, codemirrorCommandsModule, codemirrorAutocompleteModule, lezerCommonModule, browserNodeModules, } from './plugin-api-global' /** * localStorage key for persisted external plugin data. */ const STORAGE_KEY = 'reams:external-plugins' /** * Persisted entry for an external plugin. */ interface PersistedEntry { manifest: PluginManifest mainJs: string stylesCss?: string sourceUrl?: string installedAt: number } /** * Runtime entry with blob URL for module loading. */ export interface ExternalPluginEntry { catalogAdmitted?: boolean manifest: PluginManifest blobUrl: string sourceUrl?: string installedAt: number stylesCss?: string } /** * Manages installation, persistence, and lifecycle of external (third-party) plugins. * * External plugins are distributed as Obsidian-format manifest.json + main.js pairs. * Catalog bundles are durable in IndexedDB; legacy manual bundles remain in * localStorage, inactive under the curated policy. Runtime code uses Blob URLs. * * Emits 'change' event when plugins are installed or uninstalled. */ export class ExternalPluginStore extends Events { private entries: Map = new Map() private sourceMap: Map = new Map() // pluginId -> mainJs source private catalogRestore: Promise | null = null private catalogOperations = new Map>() /** Restore device-local admitted bundles without contacting the catalog. */ restoreCatalog(registry: PluginRegistry): Promise { if (this.catalogRestore) return this.catalogRestore const restoring = (async () => { for (const bundle of await readCatalogBundles()) { try { validateManifest(bundle.manifest) if (registry.has(bundle.manifest.id)) continue this.addCatalogBundle(bundle, registry) } catch (error) { console.error('Failed to restore catalog bundle:', error) } } })().catch(error => { if (this.catalogRestore === restoring) this.catalogRestore = null throw error }) this.catalogRestore = restoring return restoring } async installCatalog(bundle: CatalogBundle, registry: PluginRegistry): Promise { validateManifest(bundle.manifest) await this.withCatalogOperation(bundle.manifest.id, () => this.admitCatalogBundle(bundle, registry)) } /** Own the download as well as admission, across Settings section lifetimes. */ async installFromCatalog(entry: CatalogPlugin, registry: PluginRegistry): Promise { await this.withCatalogOperation(entry.manifest.id, async () => { await this.restoreCatalog(registry) if (this.entries.get(entry.manifest.id)?.catalogAdmitted) return await this.admitCatalogBundle(await downloadCatalogBundle(entry), registry) }) } private async admitCatalogBundle(bundle: CatalogBundle, registry: PluginRegistry): Promise { await this.restoreCatalog(registry) validateManifest(bundle.manifest) const existing = this.entries.get(bundle.manifest.id) if (existing?.catalogAdmitted && existing.manifest.version === bundle.manifest.version && this.sourceMap.get(bundle.manifest.id) === bundle.mainJs && existing.stylesCss === bundle.stylesCss) return if (existing || registry.has(bundle.manifest.id)) throw new Error(`Plugin '${bundle.manifest.id}' is already installed`) // IndexedDB add (not put) also prevents a competing tab from overwriting // the admitted version after this store's restore snapshot was read. await saveCatalogBundle(bundle) try { this.addCatalogBundle(bundle, registry) } catch (error) { await deleteCatalogBundle(bundle.manifest.id); throw error } } private withCatalogOperation(id: string, action: () => Promise): Promise { const operation = (this.catalogOperations.get(id) ?? Promise.resolve()).then(action) const settled = operation.then(() => {}, () => {}) this.catalogOperations.set(id, settled) void settled.then(() => { if (this.catalogOperations.get(id) === settled) this.catalogOperations.delete(id) }) return operation } private addCatalogBundle(bundle: CatalogBundle, registry: PluginRegistry): void { const blobUrl = this.createBlobUrl(bundle.mainJs) try { registry.register(bundle.manifest, blobUrl, true, bundle.waitForMetadata) } catch (error) { URL.revokeObjectURL(blobUrl); throw error } this.entries.set(bundle.manifest.id, { ...bundle, blobUrl, catalogAdmitted: true }) this.sourceMap.set(bundle.manifest.id, bundle.mainJs) this.triggerIsolated('change') } async uninstallCatalog(pluginId: string): Promise { await this.withCatalogOperation(pluginId, async () => { if (this.catalogRestore) await this.catalogRestore await deleteCatalogBundle(pluginId) this.removeEntry(pluginId) }) } setStylesEnabled(pluginId: string, enabled: boolean): void { const entry = this.entries.get(pluginId) if (!entry?.catalogAdmitted) return if (enabled && entry.stylesCss) this.injectStyles(pluginId, entry.stylesCss) else this.removeStyles(pluginId) } constructor() { super() // Leave saved bundles untouched; do not create module URLs or inject CSS. if (areThirdPartyPluginsEnabled()) this.loadFromStorage() } /** * Install an external plugin. * @param manifest Validated plugin manifest * @param mainJs Raw JavaScript source of main.js * @param sourceUrl Optional original URL (for display) * @param stylesCss Optional CSS source from styles.css * @throws If plugin ID is already installed */ install(manifest: PluginManifest, mainJs: string, sourceUrl?: string, stylesCss?: string): void { if (!areThirdPartyPluginsEnabled()) throw new Error(THIRD_PARTY_PLUGINS_UNAVAILABLE) validateManifest(manifest) if (this.entries.has(manifest.id)) { throw new Error(`External plugin '${manifest.id}' is already installed`) } const blobUrl = this.createBlobUrl(mainJs) const entry: ExternalPluginEntry = { manifest, blobUrl, sourceUrl, installedAt: Date.now(), stylesCss, } this.entries.set(manifest.id, entry) this.sourceMap.set(manifest.id, mainJs) // Inject plugin styles into the document if (stylesCss) { this.injectStyles(manifest.id, stylesCss) } this.saveToStorage() this.trigger('change') } /** * Uninstall an external plugin. * Revokes the blob URL, removes injected styles, and removes from persistence. * No-op if plugin is not installed. */ uninstall(pluginId: string): void { if (!areThirdPartyPluginsEnabled()) throw new Error(THIRD_PARTY_PLUGINS_UNAVAILABLE) this.removeEntry(pluginId) } private removeEntry(pluginId: string): void { const entry = this.entries.get(pluginId) if (!entry) return URL.revokeObjectURL(entry.blobUrl) this.removeStyles(pluginId) this.entries.delete(pluginId) this.sourceMap.delete(pluginId) if (!entry.catalogAdmitted) this.saveToStorage() this.triggerIsolated('change') } /** * Restore all persisted external plugins and register them with a PluginRegistry. * Creates fresh blob URLs for each plugin's main.js source. * Called once during app initialization, before enableStoredPlugins(). */ restoreAll(registry: PluginRegistry): void { if (!areThirdPartyPluginsEnabled()) return for (const [pluginId, entry] of this.entries) { if (entry.catalogAdmitted) continue if (registry.has(pluginId)) continue // already registered (idempotent) try { registry.register(entry.manifest, entry.blobUrl) } catch (error) { console.error(`Failed to restore external plugin '${pluginId}':`, error) } } } /** * Check if a plugin ID belongs to an external plugin. */ isExternal(pluginId: string): boolean { return this.entries.has(pluginId) } /** * Get entry for a specific external plugin. */ get(pluginId: string): ExternalPluginEntry | null { return this.entries.get(pluginId) ?? null } /** * Get all installed external plugin entries. */ getAll(): ExternalPluginEntry[] { return Array.from(this.entries.values()) } /** * Revoke all blob URLs and remove injected styles. Call on app teardown. */ cleanup(): void { for (const [pluginId, entry] of this.entries) { URL.revokeObjectURL(entry.blobUrl) this.removeStyles(pluginId) } } /** * Deterministic DOM ID for a plugin's injected