Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432import { 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<string, ExternalPluginEntry> = new Map() private sourceMap: Map<string, string> = new Map() // pluginId -> mainJs source private catalogRestore: Promise<void> | null = null private catalogOperations = new Map<string, Promise<void>>()
/** Restore device-local admitted bundles without contacting the catalog. */ restoreCatalog(registry: PluginRegistry): Promise<void> { 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<void> { 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<void> { 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<void> { 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<void>): Promise<void> { 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<void> { 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 <style> element. * Uses getElementById for lookup — no CSS selector injection risk. */ private styleElementId(pluginId: string): string { return `reams-plugin-styles-${pluginId}` }
/** * Inject a plugin's styles.css into the document head as a <style> element. * Uses getElementById for identification so it can be removed on uninstall. */ private injectStyles(pluginId: string, css: string): void { // Remove any existing style element for this plugin (idempotent) this.removeStyles(pluginId)
const style = document.createElement('style') style.id = this.styleElementId(pluginId) style.textContent = css document.head.appendChild(style) }
/** * Remove a plugin's injected <style> element from the document. */ private removeStyles(pluginId: string): void { const existing = document.getElementById(this.styleElementId(pluginId)) if (existing) { existing.remove() } }
/** * Create a blob URL from JavaScript source text. * * Obsidian plugins are built as CommonJS modules (via esbuild) that call * `require("obsidian")` to access the Plugin base class and other APIs. * Since blob URLs are loaded via dynamic `import()` (ESM), we wrap the * CJS source in an ESM module that provides: * - A `require` function that returns the Reams plugin API for 'obsidian' * - `module.exports` / `exports` objects so the CJS assignment works * - A default export that extracts the class from `module.exports` * * The plugin API is exposed at `window.__REAMS_PLUGIN_API__` by * `installPluginApiGlobal()` during app init. * * CodeMirror 6 modules are also supported — Obsidian bundles CM6 and * plugins can require('@codemirror/state'), etc. directly. We expose * the same primitives via __REAMS_CM6_MODULES__. */ private createBlobUrl(source: string): string { // Build CM6 module registry from imports. This is done once at blob creation // and embedded in the wrapper so the registry is available at plugin load time. // NOTE: @codemirror/search and @codemirror/lint are not installed — add to // package.json if plugins need them. // NOTE: @lezer/highlight is NOT part of Obsidian's public API and is not exposed. const cm6Modules: Record<string, Record<string, unknown>> = { '@codemirror/state': codemirrorStateModule, '@codemirror/view': codemirrorViewModule, '@codemirror/language': codemirrorLanguageModule, '@codemirror/commands': codemirrorCommandsModule, '@codemirror/autocomplete': codemirrorAutocompleteModule, '@lezer/common': lezerCommonModule, ...browserNodeModules, }
// Wrap the CJS source in an ESM module const wrappedSource = `const __obsidianApi__ = window.__REAMS_PLUGIN_API__;const __cm6Modules__ = window.__REAMS_CM6_MODULES__;const __utilShim__ = { deprecate: (fn, _msg) => fn, callbackify: (fn) => fn, inherits: () => {}, promisify: (fn) => (...args) => new Promise((resolve, reject) => fn(...args, (err, result) => err ? reject(err) : resolve(result))) };function require(mod) { if (mod === "obsidian") return __obsidianApi__; if (mod === "util") return __utilShim__; if (__cm6Modules__ && __cm6Modules__[mod]) return __cm6Modules__[mod]; throw new Error("Cannot require module: " + mod);}const module = { exports: {} };const exports = module.exports;
${source}
export default module.exports.default || module.exports;` const blob = new Blob([wrappedSource], { type: 'application/javascript' })
// Store CM6 modules on window for plugin access (idempotent) if (typeof window !== 'undefined' && !window.__REAMS_CM6_MODULES__) { window.__REAMS_CM6_MODULES__ = cm6Modules }
return URL.createObjectURL(blob) }
/** * Load persisted entries from localStorage. * Creates blob URLs for each entry's mainJs source. */ private loadFromStorage(): void { try { const stored = localStorage.getItem(STORAGE_KEY) if (!stored) return
const data = JSON.parse(stored) as Record<string, PersistedEntry>
for (const [pluginId, persisted] of Object.entries(data)) { try { validateManifest(persisted.manifest) const blobUrl = this.createBlobUrl(persisted.mainJs)
this.entries.set(pluginId, { manifest: persisted.manifest, blobUrl, sourceUrl: persisted.sourceUrl, installedAt: persisted.installedAt, stylesCss: persisted.stylesCss, }) this.sourceMap.set(pluginId, persisted.mainJs)
// Re-inject styles on restore if (persisted.stylesCss) { this.injectStyles(pluginId, persisted.stylesCss) } } catch (error) { console.error(`Failed to restore external plugin '${pluginId}':`, error) } } } catch { // Corrupted storage — start fresh } }
/** * Save all entries to localStorage. */ private saveToStorage(): void { try { const data: Record<string, PersistedEntry> = {}
for (const [pluginId, entry] of this.entries) { const mainJs = this.sourceMap.get(pluginId) if (entry.catalogAdmitted) continue if (!mainJs) continue
data[pluginId] = { manifest: entry.manifest, mainJs, stylesCss: entry.stylesCss, sourceUrl: entry.sourceUrl, installedAt: entry.installedAt, } }
const serialized = JSON.stringify(data) localStorage.setItem(STORAGE_KEY, serialized)
// Warn if approaching localStorage quota (~5MB typical) const sizeKB = Math.round(serialized.length / 1024) if (sizeKB > 2048) { console.warn( `ExternalPluginStore: localStorage usage is ${sizeKB}KB. ` + `Consider fewer plugins to avoid hitting browser quota limits.` ) } } catch (err) { // localStorage full or unavailable console.error( 'ExternalPluginStore: Failed to persist plugins to localStorage. ' + 'Plugins will work for this session but won\'t survive a page reload.', err ) } }}