main
ts 724 lines 28.1 KB
Raw
1 // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3 import glob from 'fast-glob'
4 import { watchLoad } from './watchLoad'
5 import _ from 'lodash'
6 import {
7 API_VERSION, APP_PATH, COMPATIBLE_API_VERSION, IS_WINDOWS, MIME_AUTO, PLUGINS_PUB_URI, EMBEDDED_LANGUAGE,
8 HTTP_NOT_FOUND,
9 } from './const'
10 import * as Const from './const'
11 import Koa from 'koa'
12 import {
13 escapeGlobPath, callable, Callback, CFG, debounceAsync, Dict, onlyTruthy, prefix,
14 PendingPromise, pendingPromise, Promisable, same, tryJson, wait, waitFor, wantArray, watchDir, objFromKeys, patchKey
15 } from './misc'
16 import * as misc from './misc'
17 import { defineConfig, getConfig, subMultipleConfigs } from './config'
18 import { DirEntry } from './api.get_file_list'
19 import { normalizeFilename, VfsNode } from './vfs'
20 import { serveFile } from './serveFile'
21 import events from './events'
22 import { mkdir, readdir, readFile, rm } from 'fs/promises'
23 import { existsSync, mkdirSync } from 'fs'
24 import { getConnections } from './connections'
25 import { dirname, join, resolve } from 'path'
26 import { watchLoadCustomHtml } from './customHtml'
27 import { KvStorage, KvStorageOptions } from '@rejetto/kvstorage'
28 import { onProcessExit } from './first'
29 import { notifyClient } from './frontEndApis'
30 import { app } from './index'
31 import { addBlock } from './block'
32 import { getLangData } from './lang'
33 import { i18nFromTranslations } from './i18n'
34 import { addAccount, ctxBelongsTo, delAccount, getAccount, getUsernames, renameAccount, updateAccount } from './perm'
35 import { getCurrentUsername } from './auth'
36 import { CustomizedIcons, watchIconsFolder } from './icons'
37 import { getServerStatus } from './listen'
38
39 export const PATH = 'plugins'
40 export const DISABLING_SUFFIX = '-disabled'
41 export const DELETE_ME_SUFFIX = '-delete_me' + DISABLING_SUFFIX
42 export const STORAGE_FOLDER = 'storage'
43 export const pluginsScanned = pendingPromise()
44
45 setTimeout(async () => { // delete leftovers, if any
46 for (const x of await readdir(PATH))
47 if (x.endsWith(DELETE_ME_SUFFIX))
48 await rm(join(PATH, x), { recursive: true, force: true }).catch(() => {})
49 }, 1000)
50
51 const plugins = new Map<string, Plugin>() // now that we care about the order, a simple object wouldn't do, because numbers are always at the beginning
52
53 export function isPluginRunning(id: string) {
54 return Boolean(plugins.get(id)?.started)
55 }
56
57 export function isPluginEnabled(id: string, considerSuspension=false) {
58 return (!considerSuspension || !suspendPlugins.get()) && enablePlugins.get().includes(id)
59 }
60
61 export function enablePlugin(id: string, state=true) {
62 if (state && !getPluginInfo(id))
63 throw Error('miss')
64 enablePlugins.set(arr => {
65 if (arr.includes(id) === state)
66 return arr
67 console.log("Switching plugin", id, state ? "on" : "off")
68 return arr.includes(id) === state ? arr
69 : state ? [...arr, id]
70 : arr.filter((x: string) => x !== id)
71 })
72 }
73
74 export async function stopPlugin(id: string) {
75 enablePlugin(id, false)
76 await waitRunning(id, false)
77 }
78
79 export async function startPlugin(id: string) {
80 if (getPluginInfo(id)?.isTheme)
81 await Promise.all(mapPlugins((pl, id) => pl.isTheme && stopPlugin(id)))
82 enablePlugin(id)
83 await waitRunning(id)
84 }
85
86 async function waitRunning(id: string, state=true) {
87 while (isPluginRunning(id) !== state) {
88 await wait(500)
89 const error = getError(id)
90 if (error)
91 throw Error(error)
92 }
93 }
94
95 // nullish values are equivalent to defaultValues
96 export function setPluginConfig(id: string, changes: Dict | null) {
97 pluginsConfig.set(allConfigs => {
98 const fields = getPluginConfigFields(id)
99 const oldConfig = allConfigs[id]
100 const newConfig = changes && _.pickBy({ ...oldConfig, ...changes },
101 (v, k) => v != null && !same(v, fields?.[k]?.defaultValue))
102 return { ...allConfigs, [id]: _.isEmpty(newConfig) ? undefined : newConfig }
103 })
104 }
105
106 export function getPluginInfo(id: string) {
107 const running = plugins.get(id)
108 return running && { ...running.getData(), ...running } || inactivePlugins[id]
109 }
110
111 export function findPluginByRepo<T>(repo: string) {
112 for (const pl of plugins.values())
113 if (match(pl.getData()))
114 return pl
115 return _.find(inactivePlugins, match)
116
117 function match(rec: any) {
118 return repo === (rec?.repo?.main ?? rec?.repo)
119 }
120 }
121
122 export function getPluginConfigFields(id: string) {
123 return plugins.get(id)?.getData().config
124 }
125
126 async function initPlugin(pl: any, morePassedToInit?: { id: string } & Dict) {
127 const undoEvents: any[] = []
128 const timeouts: NodeJS.Timeout[] = []
129 const controlledEvents = Object.create(events, objFromKeys(['on', 'once', 'multi'], k => ({
130 value() {
131 if (k === 'multi')
132 arguments[0] = _.mapValues(arguments[0], trap)
133 else
134 arguments[1] = trap(arguments[1])
135 const ret = (events[k] as any)(...arguments)
136 undoEvents.push(ret)
137 return ret
138
139 function trap(cb: unknown) {
140 return (...args: any[]) => {
141 try {
142 if (!_.isFunction(cb)) return
143 const ret = cb(...args)
144 return ret instanceof Promise ? ret.catch(printError) : ret
145 }
146 catch(e) {
147 printError(e)
148 }
149
150 function printError(e: any) {
151 const {event} = args.at(-1)
152 console.error(`plugin ${morePassedToInit?.id || '?'} on event ${event}:`, e)
153 }
154 }
155 }
156
157 }
158 })))
159 const res = await pl.init?.({
160 Const, require,
161 // intercept all subscriptions, so to be able to undo them on unload
162 events: controlledEvents,
163 log: console.log,
164 setError(msg: string) { setError(morePassedToInit?.id || 'server_code', msg) },
165 getHfsConfig: getConfig,
166 setInterval() { // @ts-ignore
167 const ret = setInterval(...arguments)
168 timeouts.push(ret) // intervals can be canceled by clearTimeout (source: MDN)
169 return ret
170 },
171 setTimeout(cb: (...args: any[]) => any, delay=0, ...args: any[]) { // @ts-ignore
172 let ret: NodeJS.Timeout
173 if (_.isFunction(cb)) {
174 const original = cb
175 cb = function(this: NodeJS.Timeout) { // remove fired one-shot timers so plugin unload only tracks pending work
176 _.pull(timeouts, ret)
177 return original.apply(this, args)
178 }
179 }
180 ret = setTimeout(cb, delay, ...args) as any // 'any' to allow build of frontend and admin
181 timeouts.push(ret)
182 return ret
183 },
184 async onServer(cb: Callback<object>) {
185 const res = await getServerStatus()
186 if (res.http.srv)
187 cb(res.http.srv)
188 if (res.https.srv)
189 cb(res.https.srv)
190 controlledEvents.on('listening', ({ server }: any) => cb(server))
191 },
192 misc, _,
193 customApiCall, notifyClient, addBlock, ctxBelongsTo, getConnections, normalizeFilename,
194 getCurrentUsername, getAccount, getUsernames, addAccount, delAccount, updateAccount, renameAccount,
195 ...morePassedToInit
196 })
197 Object.assign(pl, typeof res === 'function' ? { unload: res } : res)
198 patchKey(pl, 'unload', was => () => {
199 for (const x of timeouts) clearTimeout(x)
200 for (const cb of undoEvents) cb()
201 if (typeof was === 'function')
202 return was(...arguments)
203 })
204 events.emit('pluginInitialized', pl)
205 return pl
206 }
207
208 export const pluginsMiddleware: Koa.Middleware = async (ctx, next) => {
209 const after: Dict<CallMeAfter> = {}
210 // run middleware plugins
211 let lastStatus = ctx.status
212 let lastBody = ctx.body
213 const res = await events.emitAsync('request', { ctx })
214 if (ctx.isAborted() || res?.isDefaultPrevented())
215 return
216 if (res?.length)
217 after['/event'] = () => res.forEach(callable)
218 await Promise.all(mapPlugins(async (pl, id) => {
219 try {
220 const res = await pl.middleware?.(ctx)
221 printChange(id)
222 if (ctx.isAborted())
223 ctx.stop()
224 // don't just check ctx.isStopped, as the async plugin that called ctx.stop will reach here after sync ones
225 if (ctx.isStopped && !ctx.pluginBlockedRequest)
226 console.debug("Plugin blocked request", ctx.pluginBlockedRequest = id)
227 if (typeof res === 'function')
228 after[id] = res
229 }
230 catch(e){
231 printError(id, e)
232 }
233 }))
234 // expose public plugins' files
235 if (!ctx.isStopped) {
236 const { path } = ctx
237 if (path.startsWith(PLUGINS_PUB_URI)) {
238 const a = path.substring(PLUGINS_PUB_URI.length).split('/')
239 const name = a.shift()!
240 if (plugins.has(name)) { // do it only if the plugin is loaded
241 if (ctx.get('referer')?.endsWith('/'))
242 ctx.state.considerAsGui = true
243 await serveFile(ctx, plugins.get(name)!.folder + '/public/' + a.join('/'), MIME_AUTO)
244 }
245 return
246 }
247 if (ctx.body === undefined && ctx.status === HTTP_NOT_FOUND) // no response was provided by plugins, so we'll do
248 await next()
249 }
250 lastStatus = ctx.status
251 lastBody = ctx.body
252 for (const [id, f] of Object.entries(after))
253 try {
254 await f()
255 printChange(id)
256 }
257 catch (e) { printError(id, e) }
258
259
260 function printChange(id: string) {
261 if (id === SERVER_CODE_ID || (lastStatus === ctx.status && lastBody === ctx.body)) return
262 console.debug("Plugin changed response:", id)
263 lastStatus = ctx.status
264 lastBody = ctx.body
265 }
266
267 function printError(id: string, e: any) {
268 console.log(`Error middleware plugin ${id}: ${e?.message || e}`)
269 console.debug(e)
270 }
271 }
272
273 declare module "koa" {
274 interface BaseContext {
275 stop(): void
276 }
277 }
278 events.once('app', () => Object.assign(app.context, {
279 isStopped: false,
280 stop() { return this.isStopped = true }
281 }))
282
283 // return false to ask to exclude this entry from results
284 interface OnDirEntryParams { entry:DirEntry, ctx:Koa.Context, node:VfsNode }
285 type OnDirEntry = (params:OnDirEntryParams) => Promisable<unknown | false>
286
287 export class Plugin implements CommonPluginInterface {
288 started: Date | null = new Date()
289 icons: CustomizedIcons
290 log: { ts: Date, msg: string }[]
291
292 constructor(readonly id:string, readonly folder:string, private readonly data:any, private onUnload:()=>unknown){
293 if (!data) throw 'invalid data'
294
295 this.log = []
296 this.data = data = { ...data } // clone to make object modifiable. Objects coming from import are not.
297 // some validation
298 for (const k of ['frontend_css', 'frontend_js']) {
299 const v = data[k]
300 if (typeof v === 'string')
301 data[k] = [v]
302 else if (v && !Array.isArray(v)) {
303 delete data[k]
304 console.warn('Invalid', k)
305 }
306 }
307 plugins.set(id, this)
308
309 const keys = Array.from(plugins.keys())
310 const idx = keys.indexOf(id)
311 // initialize moveDown with existing plugins that want to be after this
312 const moveDown = onlyTruthy(mapPlugins(((pl, plId, plIdx) => pl.afterPlugin === id && plIdx < idx && plId)))
313 const {beforePlugin, afterPlugin} = data // then consider what this plugin wants
314 if (afterPlugin && keys.indexOf(afterPlugin) > idx)
315 moveDown.push(id) // move down this plugin
316 if (beforePlugin && keys.indexOf(beforePlugin) < idx)
317 moveDown.push(beforePlugin) // move down the other plugin
318 for (const k of moveDown) {
319 const temp = plugins.get(k)
320 if (!temp) continue
321 plugins.delete(k)
322 plugins.set(k, temp)
323 }
324 }
325 get version(): undefined | number { return this.data?.version }
326 get description(): undefined | string { return this.data?.description }
327 get apiRequired(): undefined | number | [number,number] { return this.data?.apiRequired }
328 get isTheme(): undefined | boolean { return this.data?.isTheme }
329 get repo(): undefined | Repo { return this.data?.repo }
330 get depend(): undefined | Depend { return this.data?.depend }
331 get afterPlugin(): undefined | string { return this.data?.afterPlugin }
332 get beforePlugin(): undefined | string { return this.data?.beforePlugin }
333
334 get middleware(): undefined | PluginMiddleware {
335 return this.data?.middleware
336 }
337 get frontend_css(): undefined | string[] {
338 return this.data?.frontend_css
339 }
340 get frontend_js(): undefined | string[] {
341 return this.data?.frontend_js
342 }
343 get onDirEntry(): undefined | OnDirEntry {
344 return this.data?.onDirEntry
345 }
346
347 getData(): any {
348 return this.data
349 }
350
351 async unload(reloading=false) {
352 if (!this.started) return
353 this.started = null
354 const { id } = this
355 try { await this.data?.unload?.() }
356 catch(e) {
357 console.log('Error unloading plugin', id, String(e))
358 }
359 await this.onUnload()
360 if (!reloading && id !== SERVER_CODE_ID) // we already printed 'reloading'
361 console.log('Unloaded plugin', id)
362 if (this.data)
363 this.data.unload = undefined
364 }
365 }
366
367 export const SERVER_CODE_ID = '.' // a name that will surely be not found among plugin folders
368 const serverCode = defineConfig('server_code', '', async (script, { k }) => {
369 try { (await serverCode.compiled())?.unload() }
370 catch {}
371 const res: any = {}
372 try {
373 new Function('exports,require', script)(res, require) // parse
374 await initPlugin(res)
375 res.getCustomHtml = () => callable(res.customHtml) || {}
376 return new Plugin(SERVER_CODE_ID, '', res, _.noop)
377 }
378 catch (e: any) {
379 return console.error(k + ':', e.message || String(e))
380 }
381 })
382
383 export function mapPlugins<T>(cb:(plugin:Readonly<Plugin>, pluginName:string, idx:number)=> T, includeServerCode=true) {
384 let i = 0
385 return Array.from(plugins).map(([plName,pl]) => {
386 if (!includeServerCode && plName === SERVER_CODE_ID) return
387 try { return cb(pl,plName,i++) }
388 catch(e) {
389 console.log('Plugin error', plName, String(e))
390 }
391 }).filter(x => x !== undefined) as Exclude<T,undefined>[]
392 }
393
394 export function firstPlugin<T>(cb:(plugin:Readonly<Plugin>, pluginName:string)=> T, includeServerCode=true) {
395 for (const [plName, pl] of plugins.entries()) {
396 if (!includeServerCode && plName === SERVER_CODE_ID) continue
397 try {
398 const ret = cb(pl,plName)
399 if (ret !== undefined)
400 return ret
401 }
402 catch(e) {
403 console.log('Plugin error', plName, String(e))
404 }
405 }
406 }
407
408 type PluginMiddleware = (ctx:Koa.Context) => Promisable<void | Stop | CallMeAfter>
409 type Stop = true
410 type CallMeAfter = ()=>any
411
412 export type Repo = string | { web?: string, main: string, zip?: string, zipRoot?: string } // string is github, object is custom
413 type Depend = { repo: string, version?: number }[]
414 export interface CommonPluginInterface {
415 id: string
416 description?: string
417 version?: number
418 apiRequired?: number | [number,number]
419 repo?: Repo
420 depend?: Depend
421 isTheme?: boolean | 'light' | 'dark'
422 preview?: string | string[]
423 changelog?: unknown
424 }
425 export interface InactivePlugin extends CommonPluginInterface {
426 branch?: string
427 badApi?: string
428 error?: string
429 }
430
431 let inactivePlugins: Record<string, InactivePlugin> = {}
432
433 export function getInactivePlugins() {
434 return Object.values(inactivePlugins)
435 }
436
437 const rescanAsap = debounceAsync(rescan, { wait: 1000 })
438 if (!existsSync(PATH))
439 try { mkdirSync(PATH) }
440 catch {}
441 export const pluginsWatcher = watchDir(PATH, rescanAsap)
442
443 export const enablePlugins = defineConfig('enable_plugins', ['antibrute'])
444 enablePlugins.sub(rescanAsap)
445
446 export const suspendPlugins = defineConfig(CFG.suspend_plugins, false)
447
448 export const pluginsConfig = defineConfig('plugins_config', {} as Record<string,any>)
449 export const PLUGIN_MAIN_FILE = 'plugin.js'
450
451 const pluginWatchers = new Map<string, ReturnType<typeof watchPlugin>>()
452
453 export async function rescan() {
454 console.debug('Scanning plugins')
455 const patterns = [PATH + '/*']
456 if (APP_PATH !== process.cwd())
457 patterns.unshift(escapeGlobPath(APP_PATH) + '/' + patterns[0]) // first search bundled plugins, because otherwise they won't be loaded because of the folders with same name in .hfs/plugins (used for storage)
458 const existing = new Set<string>()
459 for (const { path, dirent } of await glob(patterns, { onlyFiles: false, suppressErrors: true, objectMode: true })) {
460 if (!dirent.isDirectory() || path.endsWith(DISABLING_SUFFIX)) continue
461 const id = path.split('/').slice(-1)[0]!
462 existing.add(id)
463 if (!pluginWatchers.has(id))
464 pluginWatchers.set(id, watchPlugin(id, join(path, PLUGIN_MAIN_FILE)))
465 }
466 for (const [id, cancelWatcher] of pluginWatchers.entries())
467 if (!existing.has(id)) {
468 enablePlugin(id, false)
469 cancelWatcher()
470 pluginWatchers.delete(id)
471 }
472 pluginsScanned.resolve()
473 }
474
475 function watchPlugin(id: string, path: string) {
476 console.debug('Plugin watch', id)
477 const module = resolve(path)
478 let starting: PendingPromise | undefined
479 const unsub = subMultipleConfigs(() => {
480 if (!getPluginInfo(id)) return // not loaded yet
481 const should = isPluginEnabled(id, true)
482 if (should === isPluginRunning(id)) return
483 if (should)
484 return start()
485 stop()
486 }, [enablePlugins, suspendPlugins])
487 const { unwatch } = watchLoad(module, async source => {
488 const notRunning = inactivePlugins[id]
489 if (!source)
490 return onUninstalled()
491 if (isPluginEnabled(id, true))
492 return start()
493 const p = parsePluginSource(id, source) // plugin not running = json parsing
494 if (same(notRunning, p)) return
495 inactivePlugins[id] = p
496 events.emit(notRunning ? 'pluginUpdated' : 'pluginInstalled', p)
497 })
498 return () => {
499 console.debug('Plugin unwatch', id)
500 unsub()
501 unwatch()
502 return onUninstalled()
503 }
504
505 async function onUninstalled() {
506 await stop()
507 const info = getPluginInfo(id)
508 if (!info) return // already missing
509 delete inactivePlugins[id]
510 events.emit('pluginUninstalled', id, info.repo)
511 }
512
513 async function markItInactive() {
514 plugins.delete(id)
515 inactivePlugins[id] = await parsePlugin()
516 }
517
518 async function parsePlugin() {
519 return parsePluginSource(id, await readFile(module, 'utf8'))
520 }
521
522 async function stop() {
523 await starting
524 const p = plugins.get(id)
525 if (!p) return
526 await p.unload()
527 await markItInactive().catch(() =>
528 events.emit('pluginUninstalled', id, p.repo)) // when a running plugin is deleted, avoid error and report
529 events.emit('pluginStopped', p)
530 }
531
532 async function start() {
533 if (starting) return
534 try {
535 starting = pendingPromise()
536 // if dependencies are not ready right now, we give some time. Not super-solid but good enough for now.
537 const info = await parsePlugin()
538 if (!await waitFor(() => _.isEmpty(getMissingDependencies(info)), { timeout: 5_000 }))
539 throw Error("plugin missing dependencies: " + _.map(getMissingDependencies(info), x => x.repo).join(', '))
540 if (getPluginInfo(id))
541 setError(id, '')
542 const alreadyRunning = plugins.get(id)
543 console.log(alreadyRunning ? "Reloading plugin" : "Loading plugin", id)
544 const pluginData = require(module)
545 deleteModule(require.resolve(module)) // avoid caching at next import
546 calculateBadApi(pluginData)
547 if (pluginData.badApi)
548 throw Error(pluginData.badApi)
549
550 await alreadyRunning?.unload(true)
551 console.debug("Starting plugin", id)
552 const storageDir = resolve(PATH, id, STORAGE_FOLDER) + (IS_WINDOWS ? '\\' : '/')
553 await mkdir(storageDir, { recursive: true })
554 const openDbs: KvStorage[] = []
555 const subbedConfigs: Callback[] = []
556 const pluginReady = pendingPromise()
557 const MAX_LOG = 100
558 await initPlugin(pluginData, { // following properties are not available in server_code
559 id,
560 srcDir: __dirname,
561 storageDir,
562 async openDb(filename: string, options?: KvStorageOptions){
563 if (!filename) throw Error("missing filename")
564 const db = new KvStorage(options)
565 await db.open(join(storageDir, filename))
566 openDbs.push(db)
567 return db
568 },
569 log(...args: any[]) {
570 console.log(`Plugin "${id}":`, ...args)
571 pluginReady.then(() => { // log() maybe invoked during init(), while plugin is undefined
572 if (!plugin) return
573 const msg = { ts: new Date, msg: args.map(x => x && typeof x === 'object' ? JSON.stringify(x) : String(x)).join(' ') }
574 plugin.log.push(msg)
575 if (plugin.log.length > MAX_LOG)
576 plugin.log.splice(0, 10) // truncate
577 events.emit('pluginLog:' + id, msg)
578 events.emit('pluginLog', id, msg)
579 })
580 },
581 getConfig(cfgKey?: string) {
582 const cur = pluginsConfig.get()?.[id]
583 return cfgKey ? cur?.[cfgKey] ?? pluginData.config?.[cfgKey]?.defaultValue
584 : _.defaults(cur, _.mapValues(pluginData.config, x => x.defaultValue))
585 },
586 setConfig: (cfgKey: string, value: any) =>
587 setPluginConfig(id, { [cfgKey]: value }),
588 subscribeConfig(cfgKey: string | string[], cb: Callback<any>) {
589 const get = () => Array.isArray(cfgKey) ? objFromKeys(cfgKey, k => this.getConfig(k))
590 : this.getConfig(cfgKey)
591 let last = get()
592 cb(last)
593 const ret = pluginsConfig.sub(() => {
594 const now = get()
595 if (same(now, last)) return
596 try { cb(last = now) }
597 catch(e){ this.log(String(e)) }
598 })
599 subbedConfigs.push(ret)
600 return ret
601 },
602 async i18n(ctx: any) {
603 return i18nFromTranslations(await getLangData(ctx), EMBEDDED_LANGUAGE)
604 },
605 })
606 const folder = dirname(module)
607 const { sections, unwatch } = watchLoadCustomHtml(folder)
608 pluginData.getCustomHtml = () =>
609 Object.assign(Object.fromEntries(sections), callable(pluginData.customHtml) || {})
610
611 const unwatchIcons = watchIconsFolder(folder, v => plugin.icons = v)
612 const plugin = new Plugin(id, folder, pluginData, async () => {
613 unwatchIcons()
614 unwatch()
615 for (const x of subbedConfigs) x()
616 await Promise.allSettled(openDbs.map(x => x.close()))
617 openDbs.length = 0
618 })
619 pluginReady.resolve()
620 if (alreadyRunning)
621 events.emit('pluginUpdated', Object.assign(_.pick(plugin, 'started'), getPluginInfo(id)))
622 else {
623 const wasInstalled = inactivePlugins[id]
624 if (wasInstalled)
625 delete inactivePlugins[id]
626 events.emit(wasInstalled ? 'pluginStarted' : 'pluginInstalled', plugin)
627 }
628 events.emit('pluginStarted:'+id)
629 } catch (e: any) {
630 await markItInactive()
631 const parsed = e.stack?.split('\n\n') // this form is used by syntax-errors inside the plugin, which is useful to show
632 const where = parsed?.length > 1 ? `\n${parsed[0]}` : ''
633 e = prefix('', e.message, where) || String(e)
634 setError(id, e)
635 }
636 finally {
637 starting?.resolve()
638 starting = undefined
639 }
640
641 }
642 }
643
644 function customApiCall(method: string, ...params: any[]) {
645 return mapPlugins(pl => pl.getData().customApi?.[method]?.(...params))
646 }
647
648 function getError(id: string) {
649 return getPluginInfo(id)?.error as undefined | string
650 }
651
652 // returns true if there's an error, and it has changed
653 function setError(id: string, error: string) {
654 const info = getPluginInfo(id)
655 if (!info) return
656 if (info.error === error) return
657 info.error = error
658 events.emit('pluginUpdated', info)
659 if (!error) return
660 console.warn(`Plugin error: ${id}:`, error)
661 return true
662 }
663
664 function deleteModule(id: string) {
665 const { cache } = require
666 // build reversed map of dependencies
667 const requiredBy: Record<string,string[]> = { '.':['.'] } // don't touch main entry
668 for (const k in cache)
669 if (k !== id)
670 for (const child of wantArray(cache[k]?.children))
671 (requiredBy[child.id] ||= []).push(k)
672 const deleted: string[] = []
673 ;(function deleteCache(id: string) {
674 const mod = cache[id]
675 if (!mod) return
676 delete cache[id]
677 deleted.push(id)
678 for (const child of mod.children)
679 if (! _.difference(requiredBy[child.id], deleted).length)
680 deleteCache(child.id)
681 })(id)
682 }
683
684 onProcessExit(() =>
685 Promise.allSettled(mapPlugins(pl => pl.unload())))
686
687 export function parsePluginSource(id: string, source: string) {
688 const pl: InactivePlugin = { id }
689 pl.description = tryJson(/exports.description\s*=\s*(".*")/.exec(source)?.[1])
690 pl.repo = tryJson(/exports.repo\s*=\s*([^\s;]+)/.exec(source)?.[1])
691 pl.version = Number(/exports.version\s*=\s*(\d*\.?\d+)/.exec(source)?.[1]) ?? undefined
692 pl.apiRequired = tryJson(/exports.apiRequired\s*=\s*([ \d.,[\]]+)/.exec(source)?.[1]) ?? undefined
693 pl.isTheme = tryJson(/exports.isTheme\s*=\s*(true|false|"light"|"dark")/.exec(source)?.[1]) ?? (id.endsWith('-theme') || undefined)
694 pl.preview = tryJson(/exports.preview\s*=\s*("(?:[^"\\]|\\.)*"|\[[\s\S]*?\])/.exec(source)?.[1]) ?? undefined
695 pl.depend = tryJson(/exports.depend\s*=\s*(\[[\s\S]*?])/m.exec(source)?.[1])?.filter((x: any) =>
696 typeof x.repo === 'string' && x.version === undefined || typeof x.version === 'number'
697 || console.warn("Plugin dependency discarded", x) )
698 pl.changelog = tryJson(/exports.changelog\s*=\s*(\[[\s\S]*?])/m.exec(source)?.[1])
699 if (Array.isArray(pl.apiRequired) && (pl.apiRequired.length !== 2 || !pl.apiRequired.every(_.isFinite))) // validate [from,to] form
700 pl.apiRequired = undefined
701 calculateBadApi(pl)
702 return pl
703 }
704
705 function calculateBadApi(data: InactivePlugin) {
706 const r = data.apiRequired
707 const [min=0, max=Infinity] = Array.isArray(r) ? r : [r] // normalize data type
708 data.badApi = !r ? "missing mandatory property apiRequired"
709 : min > API_VERSION ? "may not work correctly as it is designed for a newer version of HFS - check for updates"
710 : min < COMPATIBLE_API_VERSION || max < API_VERSION ? "may not work correctly as it is designed for an older version of HFS - check for updates"
711 : undefined
712 }
713
714 export function getMissingDependencies(plugin: CommonPluginInterface) {
715 return onlyTruthy((plugin?.depend || []).map((dep: any) => {
716 const res = findPluginByRepo(dep.repo)
717 const error = !res ? 'missing'
718 : (res.version || 0) < dep.version ? 'version'
719 : !isPluginEnabled(res.id) ? 'disabled'
720 : !isPluginRunning(res.id) ? 'stopped'
721 : ''
722 return error && { repo: dep.repo, error, id: res?.id }
723 }))
724 }