Files
logseq/libs/src/LSPlugin.user.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

919 lines
21 KiB
TypeScript

import {
isValidUUID,
deepMerge,
mergeSettingsWithSchema,
PluginLogger,
safeSnakeCase,
safetyPathJoin, normalizeKeyStr,
} from './common'
import { LSPluginCaller } from './LSPlugin.caller'
import * as callableAPIs from './callable.apis'
import {
IAppProxy,
IDBProxy,
IEditorProxy,
ILSPluginUser,
LSPluginBaseInfo,
LSPluginUserEvents,
SlashCommandAction,
BlockCommandCallback,
StyleString,
Theme,
UIOptions,
IHookEvent,
BlockIdentity,
BlockPageName,
UIContainerAttrs,
SimpleCommandCallback,
SimpleCommandKeybinding,
SettingSchemaDesc,
IUserOffHook,
IGitProxy,
IUIProxy,
UserProxyNSTags,
BlockUUID,
BlockEntity,
IDatom,
IAssetsProxy,
AppInfo,
IPluginSearchServiceHooks,
PageEntity, IUtilsProxy,
} from './LSPlugin'
import Debug from 'debug'
import * as CSS from 'csstype'
import EventEmitter from 'eventemitter3'
import { IAsyncStorage, LSPluginFileStorage } from './modules/LSPlugin.Storage'
import { LSPluginExperiments } from './modules/LSPlugin.Experiments'
import { LSPluginRequest } from './modules/LSPlugin.Request'
import { LSPluginSearchService } from './modules/LSPlugin.Search'
declare global {
interface Window {
__LSP__HOST__: boolean
logseq: LSPluginUser
}
}
type callableMethods = keyof typeof callableAPIs | string // host exported SDK apis & host platform related apis
const PROXY_CONTINUE = Symbol.for('proxy-continue')
const debug = Debug('LSPlugin:user')
const logger = new PluginLogger('', { console: true })
/**
* @param type (key of group commands)
* @param opts
* @param action
*/
function registerSimpleCommand(
this: LSPluginUser,
type: string,
opts: {
key: string
label: string
desc?: string
palette?: boolean
keybinding?: SimpleCommandKeybinding
extras?: Record<string, any>
},
action: SimpleCommandCallback
) {
const { key, label, desc, palette, keybinding, extras } = opts
if (typeof action !== 'function') {
this.logger.error(`${key || label}: command action should be function.`)
return false
}
const normalizedKey = normalizeKeyStr(key)
if (!normalizedKey) {
this.logger.error(`${label}: command key is required.`)
return false
}
const eventKey = `SimpleCommandHook${normalizedKey}${++registeredCmdUid}`
this.Editor['on' + eventKey](action)
this.caller?.call(`api:call`, {
method: 'register-plugin-simple-command',
args: [
this.baseInfo.id,
// [cmd, action]
[
{ key: normalizedKey, label, type, desc, keybinding, extras },
['editor/hook', eventKey],
],
palette,
],
})
}
function shouldValidUUID(uuid: string) {
if (!isValidUUID(uuid)) {
logger.error(`#${uuid} is not a valid UUID string.`)
return false
}
return true
}
function checkEffect(p: LSPluginUser) {
return p && (p.baseInfo?.effect || !p.baseInfo?.iir)
}
let _appBaseInfo: AppInfo = null
let _searchServices: Map<string, LSPluginSearchService> = new Map()
const app: Partial<IAppProxy> = {
async getInfo(this: LSPluginUser, key) {
if (!_appBaseInfo) {
_appBaseInfo = await this._execCallableAPIAsync('get-app-info')
}
return typeof key === 'string' ? _appBaseInfo[key] : _appBaseInfo
},
registerCommand: registerSimpleCommand,
registerSearchService<T extends IPluginSearchServiceHooks>(
this: LSPluginUser,
s: T
) {
if (_searchServices.has(s.name)) {
throw new Error(`SearchService: #${s.name} has registered!`)
}
_searchServices.set(s.name, new LSPluginSearchService(this, s))
},
registerCommandPalette(
opts: { key: string; label: string; keybinding?: SimpleCommandKeybinding },
action: SimpleCommandCallback
) {
const { key, label, keybinding } = opts
const group = '$palette$'
return registerSimpleCommand.call(
this,
group,
{ key, label, palette: true, keybinding },
action
)
},
registerCommandShortcut(
keybinding: SimpleCommandKeybinding | string,
action: SimpleCommandCallback,
opts: Partial<{
key: string
label: string
desc: string
extras: Record<string, any>
}> = {}
) {
if (typeof keybinding == 'string') {
keybinding = {
mode: 'global',
binding: keybinding,
}
}
const { binding } = keybinding
const group = '$shortcut$'
const key = opts.key || (group + safeSnakeCase(binding?.toString()))
return registerSimpleCommand.call(
this,
group,
{ ...opts, key, palette: false, keybinding },
action
)
},
registerUIItem(
type: 'toolbar' | 'pagebar',
opts: { key: string; template: string }
) {
const pid = this.baseInfo.id
// opts.key = `${pid}_${opts.key}`
this.caller?.call(`api:call`, {
method: 'register-plugin-ui-item',
args: [pid, type, opts],
})
},
registerPageMenuItem(
this: LSPluginUser,
tag: string,
action: (e: IHookEvent & { page: string }) => void
) {
if (typeof action !== 'function') {
return false
}
const key = tag + '_' + this.baseInfo.id
const label = tag
const type = 'page-menu-item'
registerSimpleCommand.call(
this,
type,
{
key,
label,
},
action
)
},
onBlockRendererSlotted(uuid, callback: (payload: any) => void) {
if (!shouldValidUUID(uuid)) return
const pid = this.baseInfo.id
const hook = `hook:editor:${safeSnakeCase(`slot:${uuid}`)}`
this.caller.on(hook, callback)
this.App._installPluginHook(pid, hook)
return () => {
this.caller.off(hook, callback)
this.App._uninstallPluginHook(pid, hook)
}
},
invokeExternalPlugin(this: LSPluginUser, type: string, ...args: Array<any>) {
type = type?.trim()
if (!type) return
let [pid, group] = type.split('.')
if (!['models', 'commands'].includes(group?.toLowerCase())) {
throw new Error(`Type only support '.models' or '.commands' currently.`)
}
const key = type.replace(`${pid}.${group}.`, '')
if (!pid || !group || !key) {
throw new Error(`Illegal type of #${type} to invoke external plugin.`)
}
return this._execCallableAPIAsync(
'invoke_external_plugin_cmd',
pid,
group.toLowerCase(),
key,
args
)
},
setFullScreen(flag) {
const sf = (...args) => this._callWin('setFullScreen', ...args)
if (flag === 'toggle') {
this._callWin('isFullScreen').then((r) => {
r ? sf() : sf(true)
})
} else {
flag ? sf(true) : sf()
}
},
}
let registeredCmdUid = 0
const editor: Partial<IEditorProxy> = {
newBlockUUID(this: LSPluginUser): Promise<string> {
return this._execCallableAPIAsync('new_block_uuid')
},
isPageBlock(
this: LSPluginUser,
block: BlockEntity | PageEntity
): Boolean {
return block.uuid && block.hasOwnProperty('name')
},
registerSlashCommand(
this: LSPluginUser,
tag: string,
actions: BlockCommandCallback | Array<SlashCommandAction>
) {
debug('Register slash command #', this.baseInfo.id, tag, actions)
if (typeof actions === 'function') {
actions = [
['editor/clear-current-slash', false],
['editor/restore-saved-cursor'],
['editor/hook', actions],
]
}
actions = actions.map((it) => {
const [tag, ...args] = it
switch (tag) {
case 'editor/hook':
let key = args[0]
let fn = () => {
this.caller?.callUserModel(key)
}
if (typeof key === 'function') {
fn = key
}
const eventKey = `SlashCommandHook${tag}${++registeredCmdUid}`
it[1] = eventKey
// register command listener
this.Editor['on' + eventKey](fn)
break
default:
}
return it
})
this.caller?.call(`api:call`, {
method: 'register-plugin-slash-command',
args: [this.baseInfo.id, [tag, actions]],
})
},
registerBlockContextMenuItem(
this: LSPluginUser,
label: string,
action: BlockCommandCallback
) {
if (typeof action !== 'function') {
return false
}
const key = label + '_' + this.baseInfo.id
const type = 'block-context-menu-item'
registerSimpleCommand.call(
this,
type,
{
key,
label,
},
action
)
},
registerHighlightContextMenuItem(
this: LSPluginUser,
label: string,
action: SimpleCommandCallback,
opts?: { clearSelection: boolean }
) {
if (typeof action !== 'function') {
return false
}
const key = label + '_' + this.baseInfo.id
const type = 'highlight-context-menu-item'
registerSimpleCommand.call(
this,
type,
{
key,
label,
extras: opts,
},
action
)
},
scrollToBlockInPage(
this: LSPluginUser,
pageName: BlockPageName,
blockId: BlockIdentity,
opts?: { replaceState: boolean }
) {
const anchor = `block-content-` + blockId
if (opts?.replaceState) {
this.App.replaceState('page', { name: pageName }, { anchor })
} else {
this.App.pushState('page', { name: pageName }, { anchor })
}
},
}
const db: Partial<IDBProxy> = {
onBlockChanged(
this: LSPluginUser,
uuid: BlockUUID,
callback: (
block: BlockEntity,
txData: Array<IDatom>,
txMeta?: { outlinerOp: string; [p: string]: any }
) => void
): IUserOffHook {
if (!shouldValidUUID(uuid)) return
const pid = this.baseInfo.id
const hook = `hook:db:${safeSnakeCase(`block:${uuid}`)}`
const aBlockChange = ({ block, txData, txMeta }) => {
if (block.uuid !== uuid) {
return
}
callback(block, txData, txMeta)
}
this.caller.on(hook, aBlockChange)
this.App._installPluginHook(pid, hook)
return () => {
this.caller.off(hook, aBlockChange)
this.App._uninstallPluginHook(pid, hook)
}
},
datascriptQuery<T = any>(
this: LSPluginUser,
query: string,
...inputs: Array<any>
): Promise<T> {
// force remove proxy ns flag `db`
inputs.pop()
if (inputs?.some((it) => typeof it === 'function')) {
const host = this.Experiments.ensureHostScope()
return host.logseq.api.datascript_query(query, ...inputs)
}
return this._execCallableAPIAsync(`datascript_query`, ...[query, ...inputs])
},
}
const git: Partial<IGitProxy> = {}
const ui: Partial<IUIProxy> = {}
const utils: Partial<IUtilsProxy> = {}
const assets: Partial<IAssetsProxy> = {
makeSandboxStorage(this: LSPluginUser): IAsyncStorage {
return new LSPluginFileStorage(this, { assets: true })
},
}
type uiState = {
key?: number
visible: boolean
}
const KEY_MAIN_UI = 0
/**
* User plugin instance from global namespace `logseq`.
* @example
* ```ts
* logseq.UI.showMsg('Hello, Logseq')
* ```
* @public
*/
export class LSPluginUser
extends EventEmitter<LSPluginUserEvents>
implements ILSPluginUser {
// @ts-ignore
private _version: string = LIB_VERSION
private _debugTag: string = ''
private _settingsSchema?: Array<SettingSchemaDesc>
private _connected: boolean = false
/**
* ui frame identities
* @private
*/
private _ui = new Map<number, uiState>()
private _mFileStorage: LSPluginFileStorage
private _mRequest: LSPluginRequest
private _mExperiments: LSPluginExperiments
/**
* handler of before unload plugin
* @private
*/
private _beforeunloadCallback?: (e: any) => Promise<void>
/**
* @param _baseInfo
* @param _caller
*/
constructor(
private _baseInfo: LSPluginBaseInfo,
private _caller: LSPluginCaller
) {
super()
_caller.on('sys:ui:visible', (payload) => {
if (payload?.toggle) {
this.toggleMainUI()
}
})
_caller.on('settings:changed', (payload) => {
const prevSettings = { ...(this.settings || {}) }
const nextSettings = { ...(payload || {}) }
this._baseInfo = {
...this._baseInfo,
settings: nextSettings,
}
this.emit('settings:changed', nextSettings, prevSettings)
})
_caller.on('beforeunload', async (payload) => {
const { actor, ...rest } = payload
const cb = this._beforeunloadCallback
try {
cb && (await cb(rest))
actor?.resolve(null)
} catch (e) {
this.logger.error(`[beforeunload] `, e)
actor?.reject(e)
}
})
}
// Life related
async ready(model?: any, callback?: any) {
if (this._connected) return
try {
if (typeof model === 'function') {
callback = model
model = {}
}
let baseInfo = await this._caller.connectToParent(model)
const hostSettings = baseInfo?.settings
this._connected = true
baseInfo = deepMerge(this._baseInfo, baseInfo)
if (hostSettings !== undefined) {
baseInfo.settings = hostSettings
}
this._baseInfo = baseInfo
if (baseInfo?.id) {
this._debugTag =
this._caller.debugTag = `#${baseInfo.id} [${baseInfo.name}]`
this.logger.setTag(this._debugTag)
}
if (this._settingsSchema) {
baseInfo.settings = mergeSettingsWithSchema(
baseInfo.settings,
this._settingsSchema
)
// TODO: sync host settings schema
await this.useSettingsSchema(this._settingsSchema)
}
try {
await this._execCallableAPIAsync('setSDKMetadata', {
version: this._version,
runtime: 'js',
})
} catch (e) {
console.warn(e)
}
callback && callback.call(this, baseInfo)
} catch (e) {
console.error(`${this._debugTag} [Ready Error]`, e)
}
}
ensureConnected() {
if (!this._connected) {
throw new Error('not connected')
}
}
beforeunload(callback: (e: any) => Promise<void>): void {
if (typeof callback !== 'function') return
this._beforeunloadCallback = callback
}
provideModel(model: Record<string, any>) {
this.caller._extendUserModel(model)
return this
}
provideTheme(theme: Theme) {
this.caller.call('provider:theme', theme)
return this
}
provideStyle(style: StyleString) {
this.caller.call('provider:style', style)
return this
}
provideUI(ui: UIOptions) {
this.caller.call('provider:ui', ui)
return this
}
// Settings related
useSettingsSchema(schema: Array<SettingSchemaDesc>) {
if (this.connected) {
this.caller.call('settings:schema', {
schema,
isSync: true,
})
}
this._settingsSchema = schema
return this
}
updateSettings(attrs: Record<string, any>) {
this.caller.call('settings:update', attrs)
// TODO: update associated baseInfo settings
}
onSettingsChanged<T = any>(cb: (a: T, b: T) => void): IUserOffHook {
const type = 'settings:changed'
this.on(type, cb)
return () => this.off(type, cb)
}
showSettingsUI() {
this.caller.call('settings:visible:changed', { visible: true })
}
hideSettingsUI() {
this.caller.call('settings:visible:changed', { visible: false })
}
// UI related
setMainUIAttrs(attrs: Partial<UIContainerAttrs>): void {
this.caller.call('main-ui:attrs', attrs)
}
setMainUIInlineStyle(style: CSS.Properties): void {
this.caller.call('main-ui:style', style)
}
hideMainUI(opts?: { restoreEditingCursor: boolean }): void {
const payload = {
key: KEY_MAIN_UI,
visible: false,
cursor: opts?.restoreEditingCursor,
}
this.caller.call('main-ui:visible', payload)
this.emit('ui:visible:changed', payload)
this._ui.set(payload.key, payload)
}
showMainUI(opts?: { autoFocus: boolean }): void {
const payload = {
key: KEY_MAIN_UI,
visible: true,
autoFocus: opts?.autoFocus,
}
this.caller.call('main-ui:visible', payload)
this.emit('ui:visible:changed', payload)
this._ui.set(payload.key, payload)
}
toggleMainUI(): void {
const payload = { key: KEY_MAIN_UI, toggle: true }
const state = this._ui.get(payload.key)
if (state && state.visible) {
this.hideMainUI()
} else {
this.showMainUI()
}
}
// Getters
get version(): string {
return this._version
}
get isMainUIVisible(): boolean {
const state = this._ui.get(KEY_MAIN_UI)
return Boolean(state && state.visible)
}
get connected(): boolean {
return this._connected
}
get baseInfo(): LSPluginBaseInfo {
return this._baseInfo
}
get effect(): Boolean {
return checkEffect(this)
}
get logger() {
return logger
}
get settings() {
return this.baseInfo?.settings
}
get caller(): LSPluginCaller {
return this._caller
}
resolveResourceFullUrl(filePath: string) {
this.ensureConnected()
if (!filePath) return
filePath = filePath.replace(/^[.\\/]+/, '')
return safetyPathJoin(this._baseInfo.lsr, filePath)
}
/**
* @internal
*/
_makeUserProxy(target: any, nstag?: UserProxyNSTags) {
const that = this
const caller = this.caller
return new Proxy(target, {
get(target: any, propKey, _receiver) {
const origMethod = target[propKey]
return function (this: any, ...args: any) {
if (origMethod) {
if (args?.length !== 0) args.push(nstag)
const ret = origMethod.apply(that, args)
if (ret !== PROXY_CONTINUE) return ret
}
// Handle hook
if (nstag) {
const hookMatcher = propKey.toString().match(/^(once|off|on)/i)
if (hookMatcher != null) {
const f = hookMatcher[0].toLowerCase()
const s = hookMatcher.input!
const isOff = f === 'off'
const pid = that.baseInfo.id
let type = s.slice(f.length)
let handler = args[0]
let opts = args[1]
// condition mode
if (typeof handler === 'string' && typeof opts === 'function') {
handler = handler.replace(/^logseq./, ':')
type = `${type}${handler}`
handler = opts
opts = args[2]
}
type = `hook:${nstag}:${safeSnakeCase(type)}`
caller[f](type, handler)
const unlisten = () => {
caller.off(type, handler)
if (!caller.listenerCount(type)) {
that.App._uninstallPluginHook(pid, type)
}
}
if (!isOff) {
that.App._installPluginHook(pid, type, opts)
} else {
unlisten()
return
}
return unlisten
}
}
let method = propKey as string
// TODO: refactor api call with the explicit tag
if ((['git', 'ui', 'assets', 'utils'] as UserProxyNSTags[]).includes(nstag)) {
method = nstag + '_' + method
}
// Call host
return caller.callAsync(`api:call`, {
tag: nstag,
method,
args: args,
})
}
},
})
}
_execCallableAPIAsync(method: callableMethods, ...args) {
return this._caller.callAsync(`api:call`, {
method,
args,
})
}
_execCallableAPI(method: callableMethods, ...args) {
this._caller.call(`api:call`, {
method,
args,
})
}
_callWin(...args) {
return this._execCallableAPIAsync(`_callMainWin`, ...args)
}
// User Proxies
#appProxy: IAppProxy
#editorProxy: IEditorProxy
#dbProxy: IDBProxy
#uiProxy: IUIProxy
#utilsProxy: IUtilsProxy
get App(): IAppProxy {
if (this.#appProxy) return this.#appProxy
return (this.#appProxy = this._makeUserProxy(app, 'app'))
}
get Editor(): IEditorProxy {
if (this.#editorProxy) return this.#editorProxy
return (this.#editorProxy = this._makeUserProxy(editor, 'editor'))
}
get DB(): IDBProxy {
if (this.#dbProxy) return this.#dbProxy
return (this.#dbProxy = this._makeUserProxy(db, 'db'))
}
get UI(): IUIProxy {
if (this.#uiProxy) return this.#uiProxy
return (this.#uiProxy = this._makeUserProxy(ui, 'ui'))
}
get Utils(): IUtilsProxy {
if (this.#utilsProxy) return this.#utilsProxy
return (this.#utilsProxy = this._makeUserProxy(utils, 'utils'))
}
get Git(): IGitProxy {
return this._makeUserProxy(git, 'git')
}
get Assets(): IAssetsProxy {
return this._makeUserProxy(assets, 'assets')
}
get FileStorage(): LSPluginFileStorage {
let m = this._mFileStorage
if (!m) m = this._mFileStorage = new LSPluginFileStorage(this)
return m
}
get Request(): LSPluginRequest {
let m = this._mRequest
if (!m) m = this._mRequest = new LSPluginRequest(this)
return m
}
get Experiments(): LSPluginExperiments {
let m = this._mExperiments
if (!m) m = this._mExperiments = new LSPluginExperiments(this)
return m
}
}
export * from './LSPlugin'
/**
* @internal
*/
export function setupPluginUserInstance(
pluginBaseInfo: LSPluginBaseInfo,
pluginCaller: LSPluginCaller
) {
return new LSPluginUser(pluginBaseInfo, pluginCaller)
}
// entry of iframe mode
if (window.__LSP__HOST__ == null) {
const caller = new LSPluginCaller(null)
window.logseq = setupPluginUserInstance({} as any, caller)
}