import { 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 /** * 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 = new Map() private enableOperations = new Map>() private uninstallOperations = new Map>() private cancelledImports = new Set() /** IDs of built-in plugins (not in registry but tracked for getPluginIds) */ private builtinIds: Set = 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( 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 { return this.settingsStore.load(pluginId) } /** * Save settings data for a plugin to Automerge. */ async savePluginData(pluginId: string, data: unknown): Promise { 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 { 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 { // 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 { 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 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): Promise { 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 { 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) } }