@samitouri / QOSami-HFS / commits / 73989fe0

fix: issue with plugin folder not containing plugin.js

Massimo Melina committed Jun 2, 2023 at 11:24 UTC 73989fe0b9582bf72a322592cf589c3db70c466a
3 files changed +84 -65
src/api.plugins.ts
+7 -3
@@ -13,15 +13,16 @@ import {
13 getPluginInfo,
14 setPluginConfig,
15 findPluginByRepo,
16 - isPluginEnabled
16 + isPluginEnabled,
17 } from './plugins'
18 import _ from 'lodash'
19 import assert from 'assert'
20 -import { Callback, newObj, onOff } from './misc'
20 +import { Callback, newObj, onOff, waitFor } from './misc'
21 import { ApiError, ApiHandlers, SendListReadable } from './apiMiddleware'
22 import events from './events'
23 import { rm } from 'fs/promises'
24 import { downloadPlugin, getFolder2repo, getRepoInfo, readOnlinePlugin, searchPlugins } from './github'
25 +import { HTTP_SERVER_ERROR } from './const'
26
27 const apis: ApiHandlers = {
28
@@ -129,7 +130,10 @@ const apis: ApiHandlers = {
130
131 async download_plugin(pl) {
132 const res = await downloadPlugin(pl.id, pl.branch)
132 - return typeof res === 'string' ? getPluginInfo(res) : res
133 + if (typeof res !== 'string')
134 + return res
135 + return (await waitFor(() => getPluginInfo(res), { timeout: 5000 }))
136 + || new ApiError(HTTP_SERVER_ERROR)
137 },
138
139 async update_plugin(pl) {
src/github.ts
+12 -8
@@ -3,17 +3,17 @@
3 import events from './events'
4 import { httpsString, httpsStream, unzip } from './misc'
5 import {
6 + DISABLING_POSTFIX,
7 getAvailablePlugins,
8 mapPlugins,
9 parsePluginSource,
10 PATH as PLUGINS_PATH,
10 - pluginsWatcher,
11 - rescan,
11 } from './plugins'
12 import { ApiError } from './apiMiddleware'
13 import _ from 'lodash'
14 import { DAY, HFS_REPO, HTTP_BAD_REQUEST, HTTP_CONFLICT } from './const'
16 -import { rm } from 'fs/promises'
15 +import { rename, rm } from 'fs/promises'
16 +import { join } from 'path'
17
18 const DIST_ROOT = 'dist'
19
@@ -31,6 +31,7 @@ function downloadProgress(id: string, status: DownloadStatus) {
31 export async function downloadPlugin(repo: string, branch='', overwrite?: boolean) {
32 if (downloading[repo])
33 return new ApiError(HTTP_CONFLICT, "already downloading")
34 + console.log('downloading plugin', repo)
35 downloadProgress(repo, true)
36 try {
37 const rec = await getRepoInfo(repo)
@@ -51,17 +52,20 @@ export async function downloadPlugin(repo: string, branch='', overwrite?: boolea
52 rootWithinZip + '-' + process.platform,
53 rootWithinZip,
54 ].map(x => x + '/')
54 - pluginsWatcher.pause()
55 // this zip doesn't have content-length, so we cannot produce progress event
56 const stream = await httpsStream(`https://github.com/${repo}/archive/refs/heads/${branch}.zip`)
57 + const MAIN = 'plugin.js'
58 await unzip(stream, async path => {
59 const folder = foldersToCopy.find(x => path.startsWith(x))
60 if (!folder || path.endsWith('/')) return false
60 - const dest = installPath + '/' + path.slice(folder.length)
61 - return rm(dest).then(() => dest, () => false)
61 + let dest = path.slice(folder.length)
62 + if (dest === MAIN) // avoid being possibly loaded before the download is complete
63 + dest += DISABLING_POSTFIX
64 + dest = join(installPath, dest)
65 + return rm(dest, { force: true }).then(() => dest, () => false)
66 })
63 - pluginsWatcher.unpause()
64 - await rescan() // workaround: for some reason, operations are not triggering the rescan of the watched folder. Let's invoke it.
67 + const main = join(installPath, MAIN)
68 + await rename(main + DISABLING_POSTFIX, main) // we are good now, restore name
69 return folder
70 }
71 finally {
src/plugins.ts
+65 -54
@@ -27,7 +27,7 @@ import events from './events'
27 import { mkdir, readFile } from 'fs/promises'
28 import { existsSync, mkdirSync } from 'fs'
29 import { getConnections } from './connections'
30 -import { dirname, resolve } from 'path'
30 +import { dirname, join, resolve } from 'path'
31 import { newCustomHtmlState, watchLoadCustomHtml } from './customHtml'
32
33 export const PATH = 'plugins'
@@ -122,7 +122,7 @@ export function pluginsMiddleware(): Koa.Middleware {
122 }
123
124 function printError(id: string, e: any) {
125 - console.log('error middleware plugin', id, String(e))
125 + console.log(`error middleware plugin ${id}: ${e?.message || e}`)
126 console.debug(e)
127 }
128 }
@@ -132,13 +132,10 @@ interface OnDirEntryParams { entry:DirEntry, ctx:Koa.Context, node:VfsNode }
132 type OnDirEntry = (params:OnDirEntryParams) => void | false
133
134 export class Plugin {
135 - started = new Date()
135 + started: Date | null = new Date()
136
137 constructor(readonly id:string, readonly folder:string, private readonly data:any, private unwatch:()=>void){
138 if (!data) throw 'invalid data'
139 - if (plugins[id])
140 - throw "unload first: " + id
141 - plugins[id] = this
139
140 this.data = data = { ...data } // clone to make object modifiable. Objects coming from import are not.
141 // some validation
@@ -170,6 +167,8 @@ export class Plugin {
167 }
168
169 async unload(reloading=false) {
170 + if (!this.started) return
171 + this.started = null
172 const { id } = this
173 try {
174 await this.data?.unload?.()
@@ -179,13 +178,9 @@ export class Plugin {
178 catch(e) {
179 console.log('error unloading plugin', id, String(e))
180 }
182 - delete plugins[id]
183 - if (reloading) return
181 + if (this.data)
182 + this.data.unload = undefined
183 this.unwatch()
185 - if (availablePlugins[id])
186 - events.emit('pluginStopped', availablePlugins[id])
187 - else
188 - events.emit('pluginUninstalled', id)
184 }
185 }
186
@@ -220,53 +215,68 @@ enablePlugins.sub(rescanAsap)
215
216 export const pluginsConfig = defineConfig('plugins_config', {} as Record<string,any>)
217
218 +const pluginWatchers = new Map<string, ReturnType<typeof watchPlugin>>()
219 +
220 export async function rescan() {
221 console.debug('scanning plugins')
225 - const found: string[] = []
226 - const foundDisabled: typeof availablePlugins = {}
227 - const MASK = PATH + '/*/plugin.js' // be sure to not use path.join as fast-glob doesn't work with \
228 - const pluginSources = [MASK]
222 + const patterns = [PATH + '/*']
223 if (APP_PATH !== process.cwd())
230 - pluginSources.push(adjustStaticPathForGlob(APP_PATH) + '/' + MASK)
231 - for (const f of await glob(pluginSources)) {
232 - const id = f.split('/').slice(-2)[0]!
233 - if (id.endsWith(DISABLING_POSTFIX)) continue
234 - if (!enablePlugins.get().includes(id)) {
235 - try {
236 - const source = await readFile(f, 'utf8')
237 - foundDisabled[id] = parsePluginSource(id, source)
238 - }
239 - catch {}
240 - continue
241 - }
242 - if (found.includes(id)) // not twice
243 - continue
244 - found.push(id)
245 - if (!plugins[id]) // already loaded
246 - loadPlugin(id, f)
224 + patterns.push(adjustStaticPathForGlob(APP_PATH) + '/' + patterns[0])
225 + const met = []
226 + for (const { path, dirent } of await glob(patterns, { onlyFiles: false, suppressErrors: true, objectMode: true })) {
227 + if (!dirent.isDirectory() || path.endsWith(DISABLING_POSTFIX)) continue
228 + const id = path.split('/').slice(-1)[0]!
229 + met.push(id)
230 + const w = pluginWatchers.get(id)
231 + if (w) continue
232 + console.debug('plugin watch', id)
233 + pluginWatchers.set(id, watchPlugin(id, join(path, 'plugin.js')))
234 }
248 - for (const [id,p] of Object.entries(foundDisabled)) {
249 - const a = availablePlugins[id]
250 - if (same(a, p)) continue
251 - availablePlugins[id] = p
252 - if (a)
253 - events.emit('pluginUpdated', p)
254 - else if (!plugins[id])
255 - events.emit('pluginInstalled', p)
256 - }
257 - for (const id in availablePlugins)
258 - if (!foundDisabled[id] && !found.includes(id) && !plugins[id]) {
259 - delete availablePlugins[id]
260 - events.emit('pluginUninstalled', id)
235 + for (const [id, cancelWatcher] of pluginWatchers.entries())
236 + if (!met.includes(id)) {
237 + enablePlugin(id, false)
238 + console.debug('plugin unwatch', id)
239 + cancelWatcher()
240 + pluginWatchers.delete(id)
241 }
262 - for (const [id,p] of Object.entries(plugins))
263 - if (!found.includes(id))
264 - await p.unload()
242 }
243
267 -function loadPlugin(id: string, path: string) {
244 +function watchPlugin(id: string, path: string) {
245 const module = resolve(path)
269 - const { unwatch } = watchLoad(path, async () => {
246 + enablePlugins.sub(() => { // we take care of enabled-state after it was loaded
247 + if (!getPluginInfo(id)) return // not loaded yet
248 + const enabled = isPluginEnabled(id)
249 + if (enabled !== isPluginRunning(id))
250 + return enabled ? start() : stop()
251 + })
252 + const { unwatch } = watchLoad(module, async (source) => {
253 + const notRunning = availablePlugins[id]
254 + if (!source) {
255 + await stop()
256 + delete availablePlugins[id]
257 + events.emit('pluginUninstalled', id)
258 + return
259 + }
260 + if (isPluginEnabled(id))
261 + return start()
262 + const p = parsePluginSource(id, source)
263 + if (same(notRunning, p)) return
264 + availablePlugins[id] = p
265 + events.emit(notRunning ? 'pluginUpdated' : 'pluginInstalled', p)
266 + })
267 + return unwatch
268 +
269 + async function stop() {
270 + const p = plugins[id]
271 + if (!p) return
272 + await p.unload()
273 + delete plugins[id]
274 + const source = await readFile(module, 'utf8')
275 + availablePlugins[id] = parsePluginSource(id, source)
276 + events.emit('pluginStopped', p)
277 + }
278 +
279 + async function start() {
280 try {
281 const alreadyRunning = plugins[id]
282 console.log(alreadyRunning ? "reloading plugin" : "loading plugin", id)
@@ -314,7 +324,7 @@ function loadPlugin(id: string, path: string) {
324 customHtml: newCustomHtmlState()
325 })
326 const customHtmlWatcher = watchLoadCustomHtml(data.customHtml, folder)
317 - const plugin = new Plugin(id, folder, data, _.flow(unwatch, customHtmlWatcher.unwatch))
327 + const plugin = plugins[id] = new Plugin(id, folder, data, customHtmlWatcher.unwatch)
328 if (alreadyRunning)
329 events.emit('pluginUpdated', Object.assign(_.pick(plugin, 'started'), getPluginInfo(id)))
330 else {
@@ -324,10 +334,11 @@ function loadPlugin(id: string, path: string) {
334 events.emit(wasInstalled ? 'pluginStarted' : 'pluginInstalled', plugin)
335 }
336
327 - } catch (e) {
337 + } catch (e: any) {
338 console.log("plugin error:", e)
339 }
330 - })
340 +
341 + }
342 }
343
344 function deleteModule(id: string) {