Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613import { Events } from '../events'import type { App } from '../app'import { Plugin } from '../plugin'import type { PluginManifest } from '../types'import type { PluginRegistry } from './plugin-registry'import type { PluginSettingsStore } from './plugin-settings-store'import type { EnabledStateStore } from './enabled-state-store'import type { PluginState, PluginRecord, PluginModule, PluginLoaderEvents, ModuleImporter,} from './types'import { isVersionCompatible } from './version'import { areThirdPartyPluginsEnabled, THIRD_PARTY_PLUGINS_UNAVAILABLE } from '../../app/capabilities'
/** * Default module importer using dynamic import(). */const defaultImporter: ModuleImporter = (url: string) => import(/* @vite-ignore */ url) as Promise<PluginModule>
/** * Plugin lifecycle manager. * Handles loading, enabling, disabling, and error isolation for plugins. */export class PluginLoader extends Events { private app: App private registry: PluginRegistry private settingsStore: PluginSettingsStore private enabledStore: EnabledStateStore private plugins: Map<string, PluginRecord> = new Map() private enableOperations = new Map<string, Promise<void>>() private uninstallOperations = new Map<string, Promise<void>>() private cancelledImports = new Set<string>() /** IDs of built-in plugins (not in registry but tracked for getPluginIds) */ private builtinIds: Set<string> = new Set() private importModule: ModuleImporter
constructor( app: App, registry: PluginRegistry, settingsStore: PluginSettingsStore, enabledStore: EnabledStateStore, importModule: ModuleImporter = defaultImporter ) { super() this.app = app this.registry = registry this.settingsStore = settingsStore this.enabledStore = enabledStore this.importModule = importModule }
// Typed event overloads on<K extends keyof PluginLoaderEvents>( name: K, callback: (...args: PluginLoaderEvents[K]) => unknown, ctx?: unknown ): import('../events').EventRef on(name: string, callback: (...data: unknown[]) => unknown, ctx?: unknown): import('../events').EventRef on(name: string, callback: (...data: unknown[]) => unknown, ctx?: unknown): import('../events').EventRef { return super.on(name, callback, ctx) }
/** * Get current state of a plugin. * Returns null if plugin is not registered. */ getPluginState(pluginId: string): PluginState | null { const record = this.plugins.get(pluginId) if (record) return record.state // Check if registered but not yet tracked if (this.registry.has(pluginId)) return 'unloaded' return null }
/** Stop an enable requested by a departing host while its module imports. */ cancelPendingEnable(pluginId: string): void { if (this.plugins.get(pluginId)?.state === 'loading' && this.enableOperations.has(pluginId)) { this.cancelledImports.add(pluginId) } }
/** * Get manifest for a plugin. * Returns null if plugin is not registered. */ getPluginManifest(pluginId: string): PluginManifest | null { const record = this.plugins.get(pluginId) if (record) return record.manifest const source = this.registry.get(pluginId) return source?.manifest ?? null }
/** * Get all registered plugin IDs. * Includes both registry-based plugins and built-in instances. */ getPluginIds(): string[] { // Combine registry IDs with built-in IDs, avoiding duplicates const ids = new Set(this.registry.getIds()) for (const id of this.builtinIds) { ids.add(id) } return Array.from(ids) }
/** * Get plugin instance. * Returns null if plugin is not active. */ getPlugin(pluginId: string): Plugin | null { const record = this.plugins.get(pluginId) if (!record || record.state !== 'active') return null return record.instance }
/** * Get error for a plugin in error state. * Returns null if plugin is not in error state. */ getPluginError(pluginId: string): Error | null { const record = this.plugins.get(pluginId) return record?.error ?? null }
/** * Check if a plugin is enabled (active). */ isPluginEnabled(pluginId: string): boolean { return this.getPluginState(pluginId) === 'active' }
/** * Get the App instance. */ getApp(): App { return this.app }
/** * Get the settings store. */ getSettingsStore(): PluginSettingsStore { return this.settingsStore }
/** * Load settings data for a plugin from Automerge. */ async loadPluginData(pluginId: string): Promise<unknown> { return this.settingsStore.load(pluginId) }
/** * Save settings data for a plugin to Automerge. */ async savePluginData(pluginId: string, data: unknown): Promise<void> { return this.settingsStore.save(pluginId, data) }
/** * Initialize a plugin record for a registered plugin. */ private initPluginRecord(pluginId: string): PluginRecord | undefined { const source = this.registry.get(pluginId) if (!source) return undefined
const record: PluginRecord = { manifest: source.manifest, state: 'unloaded', instance: null, entryUrl: source.entryUrl, module: null, error: null, } this.plugins.set(pluginId, record) return record }
/** * Transition plugin to a new state and emit events. */ private transitionState(record: PluginRecord, newState: PluginState): void { const oldState = record.state record.state = newState this.triggerIsolated('plugin-state-change', record.manifest.id, newState, oldState) }
/** * Load a plugin module via dynamic import. * Transitions: unloaded -> loading -> loaded * On error: -> error */ async loadPlugin(pluginId: string, signal?: AbortSignal): Promise<void> { if (!this.isBuiltinPlugin(pluginId) && !areThirdPartyPluginsEnabled(this.registry.get(pluginId)?.catalogAdmitted)) { throw new Error(THIRD_PARTY_PLUGINS_UNAVAILABLE) } let record = this.plugins.get(pluginId) if (!record) { record = this.initPluginRecord(pluginId) if (!record) { throw new Error(`Plugin '${pluginId}': not registered`) } }
// Idempotent: if already loading, loaded, or active, no-op if (record.state !== 'unloaded' && record.state !== 'error') { return }
// Check version compatibility before loading const compatResult = isVersionCompatible(record.manifest.minAppVersion) if (!compatResult.compatible) { const error = new Error( `Plugin '${pluginId}': requires Obsidian >= ${record.manifest.minAppVersion}, ` + `but Reams only implements APIs up to ${compatResult.targetVersion}. ` + `Consult docs/PLUGIN-AUTHOR-GUIDE.md for guidance.` ) record.error = error this.transitionState(record, 'error') this.triggerIsolated('plugin-error', pluginId, error) throw error }
this.transitionState(record, 'loading')
try { // Some upstream indexers require cached metadata for the complete initial // file set. Wait for its owning service rather than fabricating empty caches. if (this.registry.get(pluginId)?.waitForMetadata && this.app.hasBackgroundIndexer()) { await this.app.reamsBackgroundIndexer.whenReady() if (this.cancelledImports.has(pluginId) || signal?.aborted) { this.transitionState(record, 'unloaded') return } } const module = await this.importModule(record.entryUrl) if (!module.default) { throw new Error(`Plugin '${pluginId}': module has no default export`) } if (typeof module.default !== 'function') { throw new Error(`Plugin '${pluginId}': default export is not a class`) }
record.module = module record.error = null this.transitionState(record, 'loaded') } catch (error) { record.error = error as Error this.transitionState(record, 'error') this.triggerIsolated('plugin-error', pluginId, error as Error) throw error } }
/** * Enable a plugin (instantiate and call onload). * Auto-loads the module if not already loaded (registry-based plugins). * For built-in instances, reuses the existing instance. * Transitions: loaded/disabled -> enabling -> active * On error: -> error */ enablePlugin(pluginId: string, options?: { userInitiated?: boolean; signal?: AbortSignal }): Promise<void> { // A replacement host can disappear while queued behind a cancelled enable. // Preserve its signal through recursive retries before any new code executes. if (options?.signal?.aborted) return Promise.resolve() if (this.uninstallOperations.has(pluginId)) return Promise.reject(new Error(`Plugin '${pluginId}' is being uninstalled`)) const pending = this.enableOperations.get(pluginId) if (pending) { // A cleanup may disable an enabling plugin while its async onload is // still running. Wait for its post-disable cleanup before reusing the // instance, instead of starting a second onload on the same object. if (this.plugins.get(pluginId)?.state === 'disabled' || this.cancelledImports.has(pluginId)) { return pending.catch(() => {}).then(() => this.enablePlugin(pluginId, options)) } return pending } const cancel = () => { this.cancelPendingEnable(pluginId) this.disablePlugin(pluginId, { persist: false }) } options?.signal?.addEventListener('abort', cancel, { once: true }) const operation = this.enablePluginOnce(pluginId, options) const tracked = operation.finally(() => { options?.signal?.removeEventListener('abort', cancel) if (this.enableOperations.get(pluginId) === tracked) { this.enableOperations.delete(pluginId) this.cancelledImports.delete(pluginId) } }) this.enableOperations.set(pluginId, tracked) return tracked }
private async enablePluginOnce(pluginId: string, options?: { userInitiated?: boolean; signal?: AbortSignal }): Promise<void> { if (!this.isBuiltinPlugin(pluginId) && !areThirdPartyPluginsEnabled(this.registry.get(pluginId)?.catalogAdmitted)) { throw new Error(THIRD_PARTY_PLUGINS_UNAVAILABLE) } const { userInitiated = false } = options ?? {} let record = this.plugins.get(pluginId) // Auto-load if needed (only for registry-based plugins, not built-ins) if (!record || record.state === 'unloaded' || record.state === 'error') { await this.loadPlugin(pluginId, options?.signal) if (this.cancelledImports.delete(pluginId) || options?.signal?.aborted) return record = this.plugins.get(pluginId)! }
// Idempotent: if already active or enabling, no-op if (record.state === 'active' || record.state === 'enabling') { return }
// Can only enable from loaded or disabled state if (record.state !== 'loaded' && record.state !== 'disabled') { throw new Error( `Plugin '${pluginId}': cannot enable from state '${record.state}'` ) }
this.transitionState(record, 'enabling')
try { // For built-in instances, reuse the existing instance // For module-based plugins, create new instance from the loaded class let instance = record.instance if (!instance) { const PluginClass = record.module!.default instance = new PluginClass(this.app, record.manifest) // Verify the instance is actually a Plugin if (!(instance instanceof Plugin)) { throw new Error(`Plugin '${pluginId}': default export must be a class extending Plugin`) } record.instance = instance }
// Use Component.load() which: // 1. Sets _loaded = true before calling onload() (enables lifecycle guards) // 2. Returns the promise from async onload() so we can await it // 3. Installs a catch fork to prevent unhandled rejection // Note: Component.load() is typed as void for Obsidian API compatibility, // but at runtime returns Promise<void> when onload() is async. const loadResult = instance.load() as unknown if (loadResult instanceof Promise) { await loadResult }
// After the async onload() completes, check if the plugin was disabled // while we were awaiting. This happens during React StrictMode cleanup: // the effect cleanup calls disablePlugin() while onload() is still running. // If the state is no longer 'enabling', clean up and bail out. // // The cast widens away an earlier narrow (`'loaded' | 'disabled'` from // the throw-on-other-states guard above). disablePlugin() can have // mutated record.state to 'disabling' or 'disabled' during the await; // TS doesn't track that mutation and would otherwise reject this // comparison as "no overlap". if ((record.state as PluginState) !== 'enabling') { // The plugin's onload() completed but the plugin was disabled during the await. // Clean up any resources registered during onload(). if (instance) { try { instance.unload() } catch (cleanupErr) { console.error(`Plugin '${pluginId}': error during post-disable cleanup:`, cleanupErr) } } if (!this.builtinIds.has(pluginId)) { record.instance = null } return }
// Persist enabled state this.enabledStore.setEnabled(pluginId, true)
this.transitionState(record, 'active')
// Call onUserEnable() for user-initiated enables (not auto-re-enable on restart). // onUserEnable() is where plugins open sidebar views, show welcome notices, etc. // Errors are caught to avoid blocking the enable flow. if (userInitiated && typeof instance.onUserEnable === 'function') { try { instance.onUserEnable() } catch (err) { console.error(`Plugin '${pluginId}': onUserEnable() threw:`, err) } }
this.triggerIsolated('plugin-enabled', pluginId, instance) } catch (error) { // Clean up any resources the plugin registered before the error. if (record.instance) { try { record.instance.unload() } catch (cleanupError) { console.error(`Plugin '${pluginId}': error during cleanup after failed onload:`, cleanupError) } } record.error = error as Error // An old onload can reject after host cleanup disabled it. Keep the // disabled state so a new host may retry this built-in instance. if (record.state === 'disabled') { this.triggerIsolated('plugin-error', pluginId, error as Error) return } // Don't null out instance for built-in plugins (they can be re-enabled) if (!this.builtinIds.has(pluginId)) { record.instance = null } this.transitionState(record, 'error') this.triggerIsolated('plugin-error', pluginId, error as Error) throw error } }
/** * Disable a plugin (call onunload and destroy instance). * Transitions: active -> disabling -> disabled * * Note: This method is synchronous. If a plugin's onunload() is async, * the cleanup may not complete before this method returns. * * For built-in plugins, the instance is preserved so it can be re-enabled. * For module-based plugins, the instance is nulled out. * * @param pluginId The plugin ID to disable * @param options.persist Whether to persist the disabled state to vault state. * Set to false during React cleanup to avoid marking * plugins as permanently disabled. Defaults to true. */ disablePlugin(pluginId: string, options?: { persist?: boolean }): void { const { persist = true } = options ?? {} const record = this.plugins.get(pluginId) if (!record) return
// Idempotent: if already disabled or unloaded, no-op if (record.state === 'disabled' || record.state === 'unloaded') { return }
// Can only disable from active or enabling state. // Enabling state can occur during React StrictMode cleanup — // the effect cleanup runs while the previous enable's async onload() // is still in progress. We must allow cleanup in this case. if (record.state !== 'active' && record.state !== 'enabling') { return }
const wasEnabling = record.state === 'enabling' this.transitionState(record, 'disabling')
// If the plugin was in 'enabling' state, its async onload() is still running. // DON'T call unload() — that would run onunload() while onload() is in progress, // leaving the instance in an inconsistent state. Instead, just transition to // 'disabled'. The post-await state check in enablePlugin() will detect the // state change and bail out without marking the plugin as active. if (!wasEnabling) { try { if (record.instance) { record.instance.unload() } } catch (error) { console.error(`Plugin '${pluginId}': error in onunload:`, error) }
// Preserve instance for built-in plugins (they can be re-enabled) // Null out instance for module-based plugins if (!this.builtinIds.has(pluginId)) { record.instance = null } } // Only persist the disabled state if this is an intentional user action, // not a cleanup during React unmount/StrictMode re-render. if (persist) { this.enabledStore.setEnabled(pluginId, false) } this.transitionState(record, 'disabled') this.triggerIsolated('plugin-disabled', pluginId) }
/** * Completely remove a plugin from the loader's internal tracking. * Disables the plugin first if it's active. * Used when uninstalling external plugins. * * Note: This does NOT unregister from PluginRegistry — caller must * handle that separately if needed. */ unloadPlugin(pluginId: string): void { const record = this.plugins.get(pluginId) const previousState = record?.state ?? 'unloaded' if (this.enableOperations.has(pluginId)) this.cancelledImports.add(pluginId) // Clear intent even when registration is known but no module was loaded. this.enabledStore.setEnabled(pluginId, false) if (!record) return
// Invalidate an enabling record too. Its onload continuation owns cleanup // after settlement and must never activate a record removed from this map. if (record.state === 'active' || record.state === 'enabling') { this.disablePlugin(pluginId, { persist: false }) }
// Remove the record completely this.plugins.delete(pluginId) this.triggerIsolated('plugin-state-change', pluginId, 'unloaded', previousState) }
isPluginUninstalling(pluginId: string): boolean { return this.uninstallOperations.has(pluginId) }
/** Own uninstall exclusion across Settings remounts and drain pending enables. */ uninstallPlugin(pluginId: string, removeBundle: () => Promise<void>): Promise<void> { const pending = this.uninstallOperations.get(pluginId) if (pending) return pending const operation = Promise.resolve().then(async () => { const enabling = this.enableOperations.get(pluginId) this.unloadPlugin(pluginId) await enabling?.catch(() => {}) await removeBundle() this.registry.unregister(pluginId) }) const tracked = operation.finally(() => { if (this.uninstallOperations.get(pluginId) === tracked) { this.uninstallOperations.delete(pluginId) this.triggerIsolated('plugin-uninstall-change', pluginId, false) } }) this.uninstallOperations.set(pluginId, tracked) this.triggerIsolated('plugin-uninstall-change', pluginId, true) return tracked }
/** * Enable all plugins that were enabled on last session. */ async enableStoredPlugins(signal?: AbortSignal): Promise<void> { const enabledIds = this.enabledStore.getEnabledPluginIds() for (const pluginId of enabledIds) { if (signal?.aborted) return if (!this.enabledStore.isEnabled(pluginId)) continue // Preserve enable intent without importing or executing unavailable modules. if (!this.isBuiltinPlugin(pluginId) && !areThirdPartyPluginsEnabled(this.registry.get(pluginId)?.catalogAdmitted)) continue if (this.registry.has(pluginId)) { try { await this.enablePlugin(pluginId, { signal }) } catch (error) { if (!signal?.aborted) console.error(`Plugin '${pluginId}': failed to enable:`, error) } } } }
/** * Register a pre-constructed plugin instance (for built-in plugins). * * This method registers the instance as "known" to the loader without * immediately enabling it. The plugin starts in 'disabled' state. * Use enablePlugin() to enable it after registration. * * Use this for built-in plugins that: * 1. Have inline manifests (no external manifest.json) * 2. Need custom constructor options (callbacks, services) * 3. Are bundled with the app (no dynamic import needed) * * @param instance Pre-constructed Plugin instance * @throws If a plugin with the same ID is already registered */ registerBuiltinInstance(instance: Plugin): void { const pluginId = instance.manifest.id
// Check for duplicate registration if (this.plugins.has(pluginId) || this.builtinIds.has(pluginId)) { throw new Error(`Plugin '${pluginId}': already registered`) }
// Create record for the pre-constructed instance in disabled state const record: PluginRecord = { manifest: instance.manifest, state: 'disabled', // Start disabled, enable via enablePlugin() instance, // Keep instance for re-enabling entryUrl: '', // No URL for built-ins module: null, // No module for built-ins error: null, }
this.plugins.set(pluginId, record) this.builtinIds.add(pluginId) }
/** * Check if a plugin is a built-in (registered via registerBuiltinInstance). */ isBuiltinPlugin(pluginId: string): boolean { return this.builtinIds.has(pluginId) }}