Files
logseq/libs/src/LSPlugin.core.ts
Charlie a95483655b refactor: plugin libs (#12395)
This pull request refactors the plugin library infrastructure and adds new experimental features for hosted/sidebar renderers. The main changes include:

Purpose: Refactor plugin communication library (Postmate) to support MessageChannel for improved performance, add support for hosted/sidebar renderers in plugins, add new debug APIs, and consolidate helper functions.

Changes:

Added MessageChannel support to Postmate for optimized plugin-host communication with backward compatibility
Introduced hosted renderer and sidebar renderer APIs for plugins to register custom UI components
Added new app APIs: get_current_route, export_debug_log_db, reset_debug_log_db
Refactored helper functions from helpers.ts to common.ts and updated all import paths
Extended block property APIs to include class properties with default values
Added comprehensive documentation for experiments APIs and plugin development
Added E2E test for plugin marketplace installation
Version bump from 0.2.12 to 0.3.1
2026-04-14 14:29:22 +08:00

1786 lines
42 KiB
TypeScript

import EventEmitter from 'eventemitter3'
import {
deepMerge,
setupInjectedStyle,
genID,
setupInjectedUI,
deferred,
invokeHostExportedApi,
isObject,
withFileProtocol,
getSDKPathRoot,
PROTOCOL_FILE,
URL_LSP,
safetyPathJoin,
path,
safetyPathNormalize,
mergeSettingsWithSchema,
IS_DEV,
cleanInjectedScripts,
safeSnakeCase,
injectTheme,
cleanInjectedUI,
PluginLogger,
} from './common'
import * as pluginHelpers from './common'
import DOMPurify from 'dompurify'
import Debug from 'debug'
import {
LSPluginCaller,
LSPMSG_READY,
LSPMSG_SYNC,
LSPMSG,
LSPMSG_SETTINGS,
LSPMSG_ERROR_TAG,
LSPMSG_BEFORE_UNLOAD,
AWAIT_LSPMSGFn,
} from './LSPlugin.caller'
import {
ILSPluginThemeManager,
LegacyTheme,
LSPluginPkgConfig,
SettingSchemaDesc,
StyleOptions,
StyleString,
Theme,
ThemeMode,
UIContainerAttrs,
UIOptions,
} from './LSPlugin'
const debug = Debug('LSPlugin:core')
const DIR_PLUGINS = 'plugins'
declare global {
interface Window {
LSPluginCore: LSPluginCore
DOMPurify: any
$$callerPluginID: string | undefined
}
}
type DeferredActor = ReturnType<typeof deferred>
interface LSPluginCoreOptions {
dotConfigRoot: string
}
/**
* User settings
*/
class PluginSettings extends EventEmitter<'change' | 'reset'> {
private _settings: Record<string, any> = {
disabled: false,
}
constructor(
private readonly _userPluginSettings: any,
private _schema?: SettingSchemaDesc[]
) {
super()
Object.assign(this._settings, _userPluginSettings)
}
get<T = any>(k: string): T {
return this._settings[k]
}
set(k: string, v?: any) {
const o = deepMerge({}, this._settings)
if (this._settings[k] === v) {
return
}
this._settings = {
...this._settings,
[k]: v,
}
this.emit('change', { ...this._settings }, o)
}
patch(input: Record<string, any>) {
if (!isObject(input)) {
return
}
const o = deepMerge({}, this._settings)
this._settings = deepMerge(this._settings, input)
this.emit('change', { ...this._settings }, o)
}
replace(value: Record<string, any>) {
const o = deepMerge({}, this._settings)
this._settings = {
disabled: false,
...(value || {}),
}
this.emit('change', { ...this._settings }, o)
}
set settings(value: Record<string, any>) {
this.replace(value)
}
get settings(): Record<string, any> {
return this._settings
}
setSchema(schema: SettingSchemaDesc[], syncSettings?: boolean) {
this._schema = schema
if (syncSettings) {
this.replace(mergeSettingsWithSchema(this._settings, schema))
}
}
reset() {
const o = this.settings
const val = {}
if (this._schema) {
// TODO: generated by schema
}
this.replace(val)
this.emit('reset', val, o)
}
toJSON() {
return this._settings
}
}
interface UserPreferences {
theme: LegacyTheme
themes: {
mode: ThemeMode
light: Theme
dark: Theme
}
externals: string[] // external plugin locations
}
interface PluginLocalOptions {
key?: string // Unique from Logseq Plugin Store
entry: string // Plugin main file
url: string // Plugin package absolute fs location
name: string
version: string
runtime: string
mode: 'shadow' | 'iframe'
webPkg?: any // web plugin package.json data
settingsSchema?: SettingSchemaDesc[]
settings?: PluginSettings
effect?: boolean
theme?: boolean
allow?: string
[key: string]: any
}
interface PluginLocalSDKMetadata {
version: string
runtime: string
[key: string]: any
}
type PluginLocalUrl = Pick<PluginLocalOptions, 'url'> & { [key: string]: any }
type RegisterPluginOpts = PluginLocalOptions | PluginLocalUrl
type PluginLocalIdentity = string
interface MainUILayoutData {
width: number
height: number
left: number
top: number
vw: number
vh: number
}
enum PluginLocalLoadStatus {
LOADING = 'loading',
UNLOADING = 'unloading',
LOADED = 'loaded',
UNLOADED = 'unload',
ERROR = 'error',
}
function initUserSettingsHandlers(pluginLocal: PluginLocal) {
const _ = (label: string): any => `settings:${label}`
// settings:schema
pluginLocal.on(
_('schema'),
({ schema, isSync }: { schema: SettingSchemaDesc[]; isSync?: boolean }) => {
pluginLocal.settingsSchema = schema
pluginLocal.settings?.setSchema(schema, isSync)
}
)
// settings:update
pluginLocal.on(_('update'), (attrs) => {
if (!attrs) return
pluginLocal.settings?.patch(attrs)
})
// settings:visible:changed
pluginLocal.on(_('visible:changed'), (payload) => {
const visible = payload?.visible
invokeHostExportedApi(
'set_focused_settings',
visible ? pluginLocal.id : null
)
})
}
function initMainUIHandlers(pluginLocal: PluginLocal) {
const _ = (label: string): any => `main-ui:${label}`
// main-ui:visible
pluginLocal.on(_('visible'), ({ visible, toggle, cursor, autoFocus }) => {
const el = pluginLocal.getMainUIContainer()
el?.classList[toggle ? 'toggle' : visible ? 'add' : 'remove']('visible')
// pluginLocal.caller!.callUserModel(LSPMSG, { type: _('visible'), payload: visible })
// auto focus frame
if (visible) {
if (!pluginLocal.shadow && el && autoFocus !== false) {
el.querySelector('iframe')?.contentWindow?.focus()
}
} else {
// @ts-expect-error set activeElement back to `body`
el.ownerDocument.activeElement.blur()
}
if (cursor) {
invokeHostExportedApi('restore_editing_cursor')
}
})
// main-ui:attrs
pluginLocal.on(_('attrs'), (attrs: Partial<UIContainerAttrs>) => {
const el = pluginLocal.getMainUIContainer()
Object.entries(attrs).forEach(([k, v]) => {
el?.setAttribute(k, String(v))
if (k === 'draggable' && v) {
pluginLocal._dispose(
pluginLocal._setupDraggableContainer(el, {
title: pluginLocal.options.name,
close: () => {
pluginLocal.caller.call('sys:ui:visible', { toggle: true })
},
})
)
}
if (k === 'resizable' && v) {
pluginLocal._dispose(pluginLocal._setupResizableContainer(el))
}
})
})
// main-ui:style
pluginLocal.on(_('style'), (style: Record<string, any>) => {
const el = pluginLocal.getMainUIContainer()
const isInitedLayout = !!el.dataset.inited_layout
Object.entries(style).forEach(([k, v]) => {
if (
isInitedLayout &&
['left', 'top', 'bottom', 'right', 'width', 'height'].includes(k)
) {
return
}
el.style[k] = v
})
})
}
function initProviderHandlers(pluginLocal: PluginLocal) {
const _ = (label: string): any => `provider:${label}`
let themed = false
// provider:theme
pluginLocal.on(_('theme'), (theme: Theme) => {
pluginLocal.themeMgr.registerTheme(pluginLocal.id, theme)
if (!themed) {
pluginLocal._dispose(() => {
pluginLocal.themeMgr.unregisterTheme(pluginLocal.id)
})
themed = true
}
})
// provider:style
pluginLocal.on(_('style'), (style: StyleString | StyleOptions) => {
let key: string | undefined
if (typeof style !== 'string') {
key = style.key
style = style.style
}
if (!style || !style.trim()) return
pluginLocal._dispose(
setupInjectedStyle(style, {
'data-injected-style': key ? `${key}-${pluginLocal.id}` : '',
'data-ref': pluginLocal.id,
})
)
})
// provider:ui
pluginLocal.on(_('ui'), (ui: UIOptions) => {
pluginLocal._onHostMounted(() => {
const ret = setupInjectedUI.call(
pluginLocal,
ui,
Object.assign(
{
'data-ref': pluginLocal.id,
},
ui.attrs || {}
),
({ el, float }) => {
if (!float) return
const identity = el.dataset.identity
pluginLocal.layoutCore.move_container_to_top(identity)
}
)
if (typeof ret === 'function') {
pluginLocal._dispose(ret)
}
})
})
}
function initApiProxyHandlers(pluginLocal: PluginLocal) {
const _ = (label: string): any => `api:${label}`
pluginLocal.on(_('call'), async (payload) => {
let ret: any
try {
window.$$callerPluginID = pluginLocal.id
ret = await invokeHostExportedApi.apply(pluginLocal, [
payload.method,
...payload.args,
])
} catch (e) {
ret = {
[LSPMSG_ERROR_TAG]: e,
}
} finally {
window.$$callerPluginID = undefined
}
if (pluginLocal.shadow) {
if (payload.actor) {
if (ret?.hasOwnProperty(LSPMSG_ERROR_TAG)) {
payload.actor.reject(ret[LSPMSG_ERROR_TAG])
} else {
payload.actor.resolve(ret)
}
}
return
}
const { _sync } = payload
if (_sync != null) {
const reply = (result: any) => {
pluginLocal.caller?.callUserModel(LSPMSG_SYNC, {
result,
_sync,
})
}
Promise.resolve(ret).then(reply, reply)
}
})
}
function convertToLSPResource(fullUrl: string, dotPluginRoot: string) {
if (dotPluginRoot && fullUrl.startsWith(PROTOCOL_FILE + dotPluginRoot)) {
fullUrl = safetyPathJoin(
URL_LSP,
fullUrl.substr(PROTOCOL_FILE.length + dotPluginRoot.length)
)
}
return fullUrl
}
class IllegalPluginPackageError extends Error {
url?: string
packageJsonPath?: string
constructor(
message: string,
options: Partial<Pick<IllegalPluginPackageError, 'url' | 'packageJsonPath'>> = {}
) {
super(message)
this.name = 'IllegalPluginPackageError'
Object.assign(this, options)
}
}
class ExistedImportedPluginPackageError extends Error {
constructor(message: string) {
super(message)
this.name = 'ExistedImportedPluginPackageError'
}
}
/**
* Host plugin for local
*/
class PluginLocal extends EventEmitter<
'loaded' | 'unloaded' | 'beforeunload' | 'error' | string
> {
private _sdk: Partial<PluginLocalSDKMetadata> = {}
private _runtimeDisposes: Array<() => Promise<any>> = []
private _registrationDisposes: Array<() => Promise<any>> = []
private _id: PluginLocalIdentity
private _status: PluginLocalLoadStatus = PluginLocalLoadStatus.UNLOADED
private _loadErr?: Error
private _localRoot?: string
private _dotSettingsFile?: string
private _caller?: LSPluginCaller
private _logger?: PluginLogger = new PluginLogger('PluginLocal')
private _disposeSettingsObserver?: () => void
/**
* @param _options
* @param _themeMgr
* @param _ctx
*/
constructor(
private _options: PluginLocalOptions,
private readonly _themeMgr: ILSPluginThemeManager,
private readonly _ctx: LSPluginCore
) {
super()
this._id = _options.key || genID()
this._disposeRegistration(async () => {
this._disposeSettingsObserver?.()
this._disposeSettingsObserver = undefined
})
initUserSettingsHandlers(this)
initMainUIHandlers(this)
initProviderHandlers(this)
initApiProxyHandlers(this)
}
async _setupUserSettings(reload?: boolean) {
const { _options } = this
const logger = (this._logger = new PluginLogger(`Loader:${this.debugTag}`))
if (_options.settings && !reload && this._disposeSettingsObserver) {
return
}
try {
const loadFreshSettings = () =>
invokeHostExportedApi('load_plugin_user_settings', this.id)
const [userSettingsFilePath, userSettings] = await loadFreshSettings()
this._dotSettingsFile = userSettingsFilePath
let settings = _options.settings
if (!settings) {
settings = _options.settings = new PluginSettings(userSettings)
}
this._disposeSettingsObserver?.()
this._disposeSettingsObserver = undefined
if (reload) {
settings.replace(userSettings)
}
const handler = async (a) => {
debug('Settings changed', this.debugTag, a)
if (a) {
invokeHostExportedApi('save_plugin_user_settings', this.id, a)
}
}
// observe settings
settings.on('change', handler)
const disposeSettingsObserver = () => {
settings.off('change', handler)
if (this._disposeSettingsObserver === disposeSettingsObserver) {
this._disposeSettingsObserver = undefined
}
}
this._disposeSettingsObserver = disposeSettingsObserver
} catch (e) {
debug('[load plugin user settings Error]', e)
logger?.error(e)
}
}
getMainUIContainer(): HTMLElement | undefined {
if (this.shadow) {
return this.caller?._getSandboxShadowContainer()
}
return this.caller?._getSandboxIframeContainer()
}
_resolveResourceFullUrl(filePath: string, localRoot?: string) {
if (!filePath?.trim()) return
localRoot = localRoot || this._localRoot
if (this.isWebPlugin) {
// TODO: strategy for Logseq plugins center
if (this.installedFromUserWebUrl) {
return `${this.installedFromUserWebUrl}/${filePath}`
}
return `https://pub-80f42b85b62c40219354a834fcf2bbfa.r2.dev/${path.join(
localRoot, filePath)}`
}
const reg = /^(http|file)/
if (!reg.test(filePath)) {
const url = path.join(localRoot, filePath)
filePath = reg.test(url) ? url : PROTOCOL_FILE + url
}
return !this.options.effect && this.isInstalledInLocalDotRoot
? convertToLSPResource(filePath, this.dotPluginsRoot)
: filePath
}
async _preparePackageConfigs() {
const { url, webPkg } = this._options
let pkg: any = webPkg
if (!pkg) {
let packageConfigError: string | undefined
if (!url) {
packageConfigError = 'Can not resolve package config location'
} else {
debug('prepare package root', url)
try {
pkg = await invokeHostExportedApi('load_plugin_config', url)
if (!pkg) {
packageConfigError = `Parse package config error #${url}/package.json`
} else {
pkg = JSON.parse(pkg)
if (!pkg) {
packageConfigError = `Parse package config error #${url}/package.json`
}
}
} catch (e: any) {
packageConfigError = e?.message || String(e)
}
}
if (packageConfigError) {
throw new IllegalPluginPackageError(packageConfigError, {
url,
packageJsonPath: url ? path.join(url, 'package.json') : undefined,
})
}
}
// Pick legal attrs
;[
'name',
'author',
'repository',
'version',
'description',
'repo',
'title',
'effect',
'sponsors',
]
.concat(!this.isInstalledInLocalDotRoot ? ['devEntry'] : [])
.forEach((k) => {
this._options[k] = pkg[k]
})
const { repo, version } = this._options
const localRoot = (this._localRoot = this.isWebPlugin ? `${repo ||
url}/${version}` : safetyPathNormalize(url))
const logseq: Partial<LSPluginPkgConfig> = pkg.logseq || {}
// const validateEntry = (main) => main && /\.(js|html)$/.test(main)
// entry from main
const entry = logseq.entry || logseq.main || pkg.main
if (logseq.devEntry) {
// development mode entry
this._options.devEntry = logseq.devEntry
this._options.entry = logseq.devEntry
} else {
// theme has no main
this._options.entry = this._resolveResourceFullUrl(entry, localRoot)
}
if (logseq.mode) {
this._options.mode = logseq.mode
}
const title = logseq.title || pkg.title
const icon = logseq.icon || pkg.icon
this._options.title = title
this._options.icon = icon && this._resolveResourceFullUrl(icon)
this._options.theme = Boolean(logseq.theme || !!logseq.themes)
if (this.isInstalledInLocalDotRoot) {
this._id = path.basename(localRoot)
} else if (!this.isWebPlugin) {
// development mode
if (logseq.id) {
this._id = logseq.id
} else {
logseq.id = this.id
try {
await invokeHostExportedApi('save_plugin_package_json', url, {
...pkg,
logseq,
})
} catch (e) {
debug('[save plugin ID Error] ', e)
}
}
}
// Validate id
const { registeredPlugins, isRegistering } = this._ctx
if (isRegistering && registeredPlugins.has(this.id)) {
throw new ExistedImportedPluginPackageError(this.id)
}
return async () => {
try {
// 0. Install Themes
const themes = logseq.themes
if (themes) {
await this._loadConfigThemes(
Array.isArray(themes) ? themes : [themes]
)
}
} catch (e) {
debug('[prepare package effect Error]', e)
}
}
}
async _tryToNormalizeEntry() {
let { entry, settings, devEntry } = this.options
devEntry = devEntry || settings?.get('_devEntry')
if (devEntry) {
this._options.entry = devEntry
return
}
if (!entry.endsWith('.js')) return
let dirPathInstalled = null
let tmp_file_method = 'write_user_tmp_file'
if (this.isInstalledInLocalDotRoot) {
tmp_file_method = 'write_dotdir_file'
dirPathInstalled = this._localRoot.replace(this.dotPluginsRoot, '')
dirPathInstalled = path.join(DIR_PLUGINS, dirPathInstalled)
}
const tag = new Date().getDay()
const sdkPathRoot = await getSDKPathRoot()
const entryPath = await invokeHostExportedApi(
tmp_file_method,
`${this._id}_index.html`,
`<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>logseq plugin entry</title>
${
this.isWebPlugin
? `<script src="https://cdn.jsdelivr.net/npm/@logseq/libs/dist/lsplugin.user.min.js?v=${tag}"></script>`
: `<script src="${sdkPathRoot}/lsplugin.user.js?v=${tag}"></script>`
}
</head>
<body>
<div id="app"></div>
<script src="${entry}"></script>
</body>
</html>`,
dirPathInstalled
)
entry = convertToLSPResource(
withFileProtocol(path.normalize(entryPath)),
this.dotPluginsRoot
)
this._options.entry = entry
}
async _loadConfigThemes(themes: Theme[]) {
themes.forEach((options) => {
if (!options.url) return
if (!options.url.startsWith('http') && this._localRoot) {
options.url = this._resolveResourceFullUrl(options.url, this._localRoot)
// file:// for native
if (!this.isWebPlugin &&
!options.url.startsWith('file:') &&
!options.url.startsWith('lsp:')) {
options.url = 'assets://' + options.url
}
}
this.emit('provider:theme', options)
})
}
async _loadLayoutsData(): Promise<Record<string, any>> {
const key = this.id + '_layouts'
const [, layouts] = await invokeHostExportedApi(
'load_plugin_user_settings',
key
)
return layouts || {}
}
async _saveLayoutsData(data: any) {
const key = this.id + '_layouts'
await invokeHostExportedApi('save_plugin_user_settings', key, data)
}
async _persistMainUILayoutData(e: MainUILayoutData) {
const layouts = await this._loadLayoutsData()
layouts.$$0 = e
await this._saveLayoutsData(layouts)
}
_setupDraggableContainer(
el: HTMLElement,
opts: Partial<{ key: string; title: string; close: () => void }> = {}
): () => void {
const ds = el.dataset
if (ds.inited_draggable) return
if (!ds.identity) {
ds.identity = 'dd-' + genID()
}
const isInjectedUI = !!opts.key
const handle = document.createElement('div')
handle.classList.add('draggable-handle')
handle.innerHTML = `
<div class="th">
<div class="l"><h3>${opts.title || ''}</h3></div>
<div class="r">
<a class="button x"><i class="ti ti-x"></i></a>
</div>
</div>
`
handle.querySelector('.x').addEventListener(
'click',
(e) => {
opts?.close?.()
e.stopPropagation()
},
false
)
handle.addEventListener(
'mousedown',
(e) => {
const target = e.target as HTMLElement
if (target?.closest('.r')) {
e.stopPropagation()
e.preventDefault()
}
},
false
)
el.prepend(handle)
// move to top
el.addEventListener(
'mousedown',
(e) => {
this.layoutCore.move_container_to_top(ds.identity)
},
true
)
const setTitle = (title) => {
handle.querySelector('h3').textContent = title
}
const dispose = this.layoutCore.setup_draggable_container_BANG_(
el,
!isInjectedUI ? this._persistMainUILayoutData.bind(this) : () => {}
)
ds.inited_draggable = 'true'
if (opts.title) {
setTitle(opts.title)
}
// click outside
let removeOutsideListener = null
if (ds.close === 'outside') {
const handler = (e) => {
const target = e.target
if (!el.contains(target)) {
opts.close()
}
}
document.addEventListener('click', handler, false)
removeOutsideListener = () => {
document.removeEventListener('click', handler)
}
}
return () => {
dispose()
removeOutsideListener?.()
}
}
_setupResizableContainer(el: HTMLElement, key?: string): () => void {
const ds = el.dataset
if (ds.inited_resizable) return
if (!ds.identity) {
ds.identity = 'dd-' + genID()
}
const handle = document.createElement('div')
handle.classList.add('resizable-handle')
el.prepend(handle)
// @ts-expect-error
const layoutCore = window.frontend.modules.layout.core
const dispose = layoutCore.setup_resizable_container_BANG_(
el,
!key ? this._persistMainUILayoutData.bind(this) : () => {}
)
ds.inited_resizable = 'true'
return dispose
}
async load(
opts?: Partial<{
indicator: DeferredActor
reload: boolean
}>
) {
if (this.pending || this.loaded) {
return
}
this._transitionStatus(PluginLocalLoadStatus.LOADING, [
PluginLocalLoadStatus.UNLOADED,
PluginLocalLoadStatus.ERROR,
])
this._loadErr = undefined
try {
// if (!this.options.entry) { // Themes package no entry field
// }
const installPackageThemes = await this._preparePackageConfigs()
await this._setupUserSettings(opts?.reload)
if (!this.disabled) {
await installPackageThemes.call(null)
}
if (this.disabled || !this.options.entry) {
return
}
this._ctx.emit('beforeload', this)
await this._tryToNormalizeEntry()
this._caller = new LSPluginCaller(this)
await this._caller.connectToChild()
const readyFn = () => {
this._caller?.callUserModel(LSPMSG_READY, { pid: this.id })
}
if (opts?.indicator) {
opts.indicator.promise.then(readyFn)
} else {
readyFn()
}
this._dispose(async () => {
await this._caller?.destroy()
})
this._dispose(cleanInjectedScripts.bind(this))
this._ctx.emit('loadeded', this)
} catch (e) {
this.logger.error('load', e, true)
this.disposeRuntime().catch(null)
this._status = PluginLocalLoadStatus.ERROR
this._loadErr = e
} finally {
if (!this._loadErr) {
this._transitionStatus(
this.disabled
? PluginLocalLoadStatus.UNLOADED
: PluginLocalLoadStatus.LOADED,
[PluginLocalLoadStatus.LOADING]
)
}
}
}
async reload() {
if (this.pending) {
return
}
this._ctx.emit('beforereload', this)
if (this.loaded) {
await this.unload()
}
await this.load({ reload: true })
this._ctx.emit('reloaded', this)
}
/**
* @param unregister If true delete plugin files
*/
async unload(unregister: boolean = false) {
if (this.pending) {
return
}
if (!unregister && !this.loaded) {
this._status = PluginLocalLoadStatus.UNLOADED
return
}
if (unregister) {
await this.unload()
await this.disposeRegistration()
if (this.isWebPlugin || this.isInstalledInLocalDotRoot) {
this._ctx.emit('unlink-plugin', this.id)
}
return
}
try {
const eventBeforeUnload = { unregister }
if (this.loaded) {
this._transitionStatus(PluginLocalLoadStatus.UNLOADING, [
PluginLocalLoadStatus.LOADED,
])
try {
await this._caller?.callUserModel(
AWAIT_LSPMSGFn(LSPMSG_BEFORE_UNLOAD),
eventBeforeUnload
)
this.emit('beforeunload', eventBeforeUnload)
} catch (e) {
this.logger.error('beforeunload', e)
}
await this.disposeRuntime()
}
this.emit('unloaded')
} catch (e) {
this.logger.error('unload', e)
} finally {
this._status = PluginLocalLoadStatus.UNLOADED
}
}
private async _runDisposers(disposers: Array<() => Promise<any>>) {
for (const fn of disposers) {
try {
fn && (await fn())
} catch (e) {
console.error(this.debugTag, 'dispose Error', e)
}
}
}
private async disposeRuntime() {
await this._runDisposers(this._runtimeDisposes)
// clear
this._runtimeDisposes = []
}
private async disposeRegistration() {
await this._runDisposers(this._registrationDisposes)
// clear
this._registrationDisposes = []
}
_dispose(fn: any) {
if (!fn) return
this._runtimeDisposes.push(fn)
}
_disposeRegistration(fn: any) {
if (!fn) return
this._registrationDisposes.push(fn)
}
private _transitionStatus(
next: PluginLocalLoadStatus,
from?: PluginLocalLoadStatus[]
) {
if (from && !from.includes(this._status)) {
throw new Error(
`Invalid plugin status transition: ${this._status} -> ${next}`
)
}
this._status = next
}
_onHostMounted(callback: () => void) {
const actor = this._ctx.hostMountedActor
if (!actor || actor.settled) {
callback()
} else {
actor?.promise.then(callback)
}
}
get isWebPlugin() {
return this._ctx.isWebPlatform || !!this.options.webPkg
}
get installedFromUserWebUrl() {
return this.isWebPlugin && this.options.webPkg?.installedFromUserWebUrl
}
get layoutCore(): any {
// @ts-expect-error
return window.frontend.modules.layout.core
}
get isInstalledInLocalDotRoot() {
if (this.isWebPlugin) return false
const dotRoot = this.dotConfigRoot
const plgRoot = this.localRoot
return dotRoot && plgRoot && plgRoot.startsWith(dotRoot)
}
get loaded() {
return this._status === PluginLocalLoadStatus.LOADED
}
get pending() {
return [
PluginLocalLoadStatus.LOADING,
PluginLocalLoadStatus.UNLOADING,
].includes(this._status)
}
get status(): PluginLocalLoadStatus {
return this._status
}
get settings() {
return this.options.settings
}
set settingsSchema(schema: SettingSchemaDesc[]) {
this._options.settingsSchema = schema
}
get settingsSchema() {
return this.options.settingsSchema
}
get logger() {
return this._logger
}
get disabled() {
return this.settings?.get('disabled')
}
get theme() {
return this.options.theme
}
get caller() {
return this._caller
}
get id(): string {
return this._id
}
get shadow(): boolean {
return this.options.mode === 'shadow'
}
get options(): PluginLocalOptions {
return this._options
}
get themeMgr(): ILSPluginThemeManager {
return this._themeMgr
}
get debugTag() {
const name = this._options?.name
return `#${this._id} - ${name ?? ''}`
}
get localRoot(): string {
return this._localRoot || this._options.url
}
get loadErr(): Error | undefined {
return this._loadErr
}
get dotConfigRoot() {
return path.normalize(this._ctx.options.dotConfigRoot)
}
get dotSettingsFile(): string | undefined {
return this._dotSettingsFile
}
get dotPluginsRoot() {
return path.join(this.dotConfigRoot, DIR_PLUGINS)
}
get sdk(): Partial<PluginLocalSDKMetadata> {
return this._sdk
}
set sdk(value: Partial<PluginLocalSDKMetadata>) {
this._sdk = value
}
toJSON(settings = true) {
const json = { ...this.options } as any
json.id = this.id
json.err = this.loadErr
json.usf = this.dotSettingsFile
json.iir = this.isInstalledInLocalDotRoot
json.webMode = this.isWebPlugin ? (this.installedFromUserWebUrl
? 'user'
: 'github') : false
json.lsr = this._resolveResourceFullUrl('/')
if (settings === false) {
delete json.settings
} else {
json.settings = json.settings?.toJSON()
}
return json
}
}
/**
* Host plugin core
*/
class LSPluginCore
extends EventEmitter<
| 'beforeenable'
| 'enabled'
| 'beforedisable'
| 'disabled'
| 'registered'
| 'error'
| 'unregistered'
| 'ready'
| 'themes-changed'
| 'theme-selected'
| 'reset-custom-theme'
| 'settings-changed'
| 'unlink-plugin'
| 'beforeload'
| 'loadeded'
| 'beforereload'
| 'reloaded'
>
implements ILSPluginThemeManager {
private _isRegistering = false
private _readyIndicator?: DeferredActor
private readonly _hostMountedActor: DeferredActor = deferred()
private readonly _userPreferences: UserPreferences = {
theme: null,
themes: {
mode: 'light',
light: null,
dark: null,
},
externals: [],
}
private readonly _registeredThemes = new Map<PluginLocalIdentity, Theme[]>()
private readonly _registeredPlugins = new Map<
PluginLocalIdentity,
PluginLocal
>()
private _currentTheme: {
pid: PluginLocalIdentity
opt: Theme | LegacyTheme
eject: () => void
}
/**
* @param _options
*/
constructor(private readonly _options: Partial<LSPluginCoreOptions>) {
super()
}
async loadUserPreferences() {
try {
const settings = await invokeHostExportedApi('load_user_preferences')
if (settings) {
Object.assign(this._userPreferences, settings)
}
} catch (e) {
debug('[load user preferences Error]', e)
}
}
async saveUserPreferences(settings: Partial<UserPreferences>) {
try {
if (settings) {
Object.assign(this._userPreferences, settings)
}
await invokeHostExportedApi(
'save_user_preferences',
this._userPreferences
)
} catch (e) {
debug('[save user preferences Error]', e)
}
}
/**
* Activate the user preferences.
*
* Steps:
*
* 1. Load the custom theme.
*
* @memberof LSPluginCore
*/
async activateUserPreferences() {
const { theme: legacyTheme, themes } = this._userPreferences
const currentTheme = themes[themes.mode]
// If there is currently a theme that has been set
if (currentTheme) {
await this.selectTheme(currentTheme, { effect: false, emit: false })
} else if (legacyTheme) {
// Otherwise compatible with older versions
await this.selectTheme(legacyTheme, { effect: false, emit: false })
}
}
/**
* @param plugins
* @param initial
*/
async register(
plugins: RegisterPluginOpts[] | RegisterPluginOpts,
initial = false
) {
if (!Array.isArray(plugins)) {
await this.register([plugins])
return
}
const perfTable = new Map<
string,
{ o: PluginLocal; s: number; e: number }
>()
const debugPerfInfo = () => {
const data: any = Array.from(perfTable.values()).reduce((ac, it) => {
const { id, options, status, disabled } = it.o
if (
disabled !== true &&
(options.entry || (!options.name && !options.entry))
) {
ac[id] = {
name: options.name,
entry: options.entry,
status: status,
enabled:
typeof disabled === 'boolean' ? (!disabled ? '🟢' : '⚫️') : '🔴',
perf: !it.e ? it.o.loadErr : `${(it.e - it.s).toFixed(2)}ms`,
}
}
return ac
}, {})
console.table(data)
}
// @ts-expect-error
window.__debugPluginsPerfInfo = debugPerfInfo
const readyIndicator = (this._readyIndicator = deferred())
try {
this._isRegistering = true
await this.loadUserPreferences()
let externals = new Set(this._userPreferences.externals)
// valid externals
if (externals?.size) {
try {
const validatedExternals: Record<string, boolean> =
await invokeHostExportedApi('validate_external_plugins', [
...externals,
])
externals = new Set(
[...Object.entries(validatedExternals)].reduce((a, [k, v]) => {
if (v) {
a.push(k)
}
return a
}, [])
)
} catch (e) {
console.error('[validatedExternals Error]', e)
}
}
if (initial) {
plugins = plugins.concat(
[...externals]
.filter((url) => {
return (
!plugins.length ||
(plugins as RegisterPluginOpts[]).every(
(p) => !p.entry && p.url !== url
)
)
})
.map((url) => ({ url }))
)
}
for (const pluginOptions of plugins) {
const { url } = pluginOptions as PluginLocalOptions
const pluginLocal = new PluginLocal(
pluginOptions as PluginLocalOptions,
this,
this
)
const perfInfo = { o: pluginLocal, s: performance.now(), e: 0 }
perfTable.set(url, perfInfo)
await pluginLocal.load({ indicator: readyIndicator })
perfInfo.e = performance.now()
const { loadErr } = pluginLocal
if (loadErr) {
debug('[Failed LOAD Plugin] #', pluginOptions)
this.emit('error', loadErr)
if (
loadErr instanceof IllegalPluginPackageError ||
loadErr instanceof ExistedImportedPluginPackageError
) {
// TODO: notify global log system?
continue
}
}
const onSettingsChange = (a) => {
this.emit('settings-changed', pluginLocal.id, a)
pluginLocal.caller?.callUserModel(LSPMSG_SETTINGS, { payload: a })
}
pluginLocal.settings?.on('change', onSettingsChange)
pluginLocal._disposeRegistration(() => {
pluginLocal.settings?.off('change', onSettingsChange)
})
this._registeredPlugins.set(pluginLocal.id, pluginLocal)
this.emit('registered', pluginLocal)
// external plugins
if (!pluginLocal.isWebPlugin &&
!pluginLocal.isInstalledInLocalDotRoot) {
externals.add(url)
}
}
await this.saveUserPreferences({ externals: Array.from(externals) })
await this.activateUserPreferences()
readyIndicator.resolve('ready')
} catch (e) {
console.error(e)
} finally {
if (!readyIndicator.settled) {
readyIndicator.resolve('ready')
}
this._isRegistering = false
this.emit('ready', perfTable)
debugPerfInfo()
}
}
async reload(plugins: PluginLocalIdentity[] | PluginLocalIdentity) {
if (!Array.isArray(plugins)) {
await this.reload([plugins])
return
}
for (const identity of plugins) {
try {
const p = this.ensurePlugin(identity)
await p.reload()
} catch (e) {
debug(e)
}
}
}
async unregister(plugins: PluginLocalIdentity[] | PluginLocalIdentity) {
if (!Array.isArray(plugins)) {
await this.unregister([plugins])
return
}
const unregisteredExternals: string[] = []
for (const identity of plugins) {
const p = this.ensurePlugin(identity)
if (!p.isWebPlugin && !p.isInstalledInLocalDotRoot) {
unregisteredExternals.push(p.options.url)
}
await p.unload(true)
this._registeredPlugins.delete(identity)
this.emit('unregistered', identity)
}
const externals = this._userPreferences.externals
if (externals.length && unregisteredExternals.length) {
await this.saveUserPreferences({
externals: externals.filter((it) => {
return !unregisteredExternals.includes(it)
}),
})
}
}
async enable(plugin: PluginLocalIdentity) {
const p = this.ensurePlugin(plugin)
if (p.pending) return
if (!p.disabled && p.loaded) return
this.emit('beforeenable')
p.settings?.set('disabled', false)
await p.load()
this.emit('enabled', p.id)
}
async disable(plugin: PluginLocalIdentity) {
const p = this.ensurePlugin(plugin)
if (p.pending) return
if (p.disabled && !p.loaded) return
this.emit('beforedisable')
p.settings?.set('disabled', true)
await p.unload()
this.emit('disabled', p.id)
}
async _hook(ns: string, type: string, payload?: any, pid?: string) {
const hook = `${ns}:${safeSnakeCase(type)}`
const isDbChangedHook = hook === 'hook:db:changed'
const isDbBlockChangeHook = hook.startsWith('hook:db:block')
const act = (p: PluginLocal) => {
debug(`[call hook][#${p.id}]`, ns, type)
p.caller?.callUserModel(LSPMSG, {
ns,
type: safeSnakeCase(type),
payload,
})
}
const p = pid && this._registeredPlugins.get(pid)
if (p && !p.disabled && p.options.entry) {
act(p)
return
}
for (const [_, p] of this._registeredPlugins) {
if (!p.options.entry || p.disabled) {
continue
}
if (!pid) {
// compatible for old SDK < 0.0.2
const sdkVersion = p.sdk?.version
// TODO: remove optimization after few releases
if (!sdkVersion) {
if (isDbChangedHook || isDbBlockChangeHook) {
continue
} else {
act(p)
}
}
if (
sdkVersion &&
invokeHostExportedApi('should_exec_plugin_hook', p.id, hook)
) {
act(p)
}
} else if (pid === p.id) {
act(p)
break
}
}
}
async hookApp(type: string, payload?: any, pid?: string) {
return await this._hook('hook:app', type, payload, pid)
}
async hookEditor(type: string, payload?: any, pid?: string) {
return await this._hook('hook:editor', type, payload, pid)
}
async hookDb(type: string, payload?: any, pid?: string) {
return await this._hook('hook:db', type, payload, pid)
}
ensurePlugin(plugin: PluginLocalIdentity | PluginLocal) {
if (plugin instanceof PluginLocal) {
return plugin
}
const p = this._registeredPlugins.get(plugin)
if (!p) {
throw new Error(`plugin #${plugin} not existed.`)
}
return p
}
hostMounted() {
this._hostMountedActor.resolve()
}
_forceCleanInjectedUI(id: string) {
if (!id) return
return cleanInjectedUI(id)
}
get isWebPlatform() {
return this.options.dotConfigRoot?.startsWith('LSPUserDotRoot')
}
get registeredPlugins(): Map<PluginLocalIdentity, PluginLocal> {
return this._registeredPlugins
}
get options() {
return this._options
}
get readyIndicator(): DeferredActor | undefined {
return this._readyIndicator
}
get hostMountedActor(): DeferredActor {
return this._hostMountedActor
}
get isRegistering(): boolean {
return this._isRegistering
}
get themes() {
return this._registeredThemes
}
get enabledPlugins() {
return [...this.registeredPlugins.entries()].reduce((a, b) => {
let p = b?.[1]
if (p?.disabled !== true) {
a.set(b?.[0], p)
}
return a
}, new Map())
}
async registerTheme(id: PluginLocalIdentity, opt: Theme): Promise<void> {
debug('Register theme #', id, opt)
if (!id) return
let themes: Theme[] = this._registeredThemes.get(id)!
if (!themes) {
this._registeredThemes.set(id, (themes = []))
}
themes.push(opt)
this.emit('themes-changed', this.themes, { id, ...opt })
}
async selectTheme(
theme: Theme | LegacyTheme,
options: {
effect?: boolean
emit?: boolean
} = {}
) {
const { effect, emit } = Object.assign(
{ effect: true, emit: true }, options)
// Clear current theme before injecting.
if (this._currentTheme) {
this._currentTheme.eject()
}
// Detect if it is the default theme (no url).
if (!theme.url) {
this._currentTheme = null
} else {
const ejectTheme = injectTheme(theme.url)
this._currentTheme = {
pid: theme.pid,
opt: theme,
eject: ejectTheme,
}
}
if (effect) {
await this.saveUserPreferences(
theme.mode
? {
themes: {
...this._userPreferences.themes,
mode: theme.mode,
[theme.mode]: theme,
},
}
: { theme: theme }
)
}
if (emit) {
this.emit('theme-selected', theme, options)
}
}
async unregisterTheme(id: PluginLocalIdentity, effect = true) {
debug('Unregister theme #', id)
if (!this._registeredThemes.has(id)) {
return
}
this._registeredThemes.delete(id)
this.emit('themes-changed', this.themes, { id })
if (effect && this._currentTheme?.pid === id) {
this._currentTheme.eject()
this._currentTheme = null
const { theme, themes } = this._userPreferences
await this.saveUserPreferences({
theme: theme?.pid === id ? null : theme,
themes: {
...themes,
light: themes.light?.pid === id ? null : themes.light,
dark: themes.dark?.pid === id ? null : themes.dark,
},
})
// Reset current theme if it is unregistered
this.emit('reset-custom-theme', this._userPreferences.themes)
}
}
}
function setupPluginCore(options: any) {
const pluginCore = new LSPluginCore(options)
debug('=== 🔗 Setup Logseq Plugin System 🔗 ===')
window.LSPluginCore = pluginCore
window.DOMPurify = DOMPurify
}
export { PluginLocal, pluginHelpers, setupPluginCore }