mirror of
https://github.com/logseq/logseq.git
synced 2026-05-16 17:02:34 +00:00
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
1786 lines
42 KiB
TypeScript
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 }
|