@samitouri / QOSami-HFS / commits / 5192e356

fix: (regression 0.48.0) couldn't update non-github plugins

Massimo Melina committed Sep 9, 2023 at 13:16 UTC 5192e356745457922b0f7fa68939f67eca443917
5 files changed +66 -66
admin/src/InstalledPlugins.ts
+16 -17
@@ -4,8 +4,8 @@ import { apiCall, useApiList } from './api'
4 import { createElement as h, Fragment, ReactNode } from 'react'
5 import { Alert, Box, Link, Tooltip } from '@mui/material'
6 import { DataTable } from './DataTable'
7 -import { Delete, Error, PlayCircle, Settings, StopCircle, Upgrade } from '@mui/icons-material'
8 -import { IconBtn, with_, xlate } from './misc'
7 +import { Delete, Error as ErrorIcon, PlayCircle, Settings, StopCircle, Upgrade } from '@mui/icons-material'
8 +import { IconBtn, prefix, with_, xlate } from './misc'
9 import { formDialog, toast } from './dialog'
10 import _ from 'lodash'
11 import { BoolField, Field, MultiSelectField, NumberField, SelectField, StringField } from '@hfs/mui-grid-form'
@@ -44,7 +44,19 @@ export default function InstalledPlugins({ updates }: { updates?: true }) {
44 },
45 ],
46 actions: ({ row, id }) => updates ? [
47 - h(UpdateButton, { id, updated: row.updated, then: () => updateEntry({ id }, { updated: true }) })
47 + h(IconBtn, {
48 + icon: Upgrade,
49 + title: row.updated ? "Already updated" : "Update",
50 + disabled: row.updated,
51 + async onClick() {
52 + await apiCall('update_plugin', { id }, { timeout: false }).catch(e => {
53 + throw e.code !== 424 ? e
54 + : Error("Failed dependencies: " + e.cause?.map((x: any) => prefix(`plugin "`, x.id || x.repo, `" `) + x.error).join('; '))
55 + })
56 + updateEntry({ id }, { updated: true })
57 + toast("Plugin updated")
58 + }
59 + })
60 ] : [
61 h(IconBtn, row.started ? {
62 icon: StopCircle,
@@ -115,7 +127,7 @@ export function renderName({ row, value }: any) {
127 function errorIcon(msg: ReactNode, warning=false) {
128 return msg && h(Tooltip, {
129 title: msg,
118 - children: h(Error, { fontSize: 'small', color: warning ? 'warning' : 'error', sx: { ml: -.5, mr: .5 } })
130 + children: h(ErrorIcon, { fontSize: 'small', color: warning ? 'warning' : 'error', sx: { ml: -.5, mr: .5 } })
131 })
132 }
133 }
@@ -151,19 +163,6 @@ export function showError(error: any) {
163 }))
164 }
165
154 -export function UpdateButton({ id, updated, then }: { id: string, updated?: boolean, then: (id:string)=>void }) {
155 - return h(IconBtn, {
156 - icon: Upgrade,
157 - title: updated ? "Already updated" : "Update",
158 - disabled: updated,
159 - async onClick() {
160 - await apiCall('update_plugin', { id }, { timeout: false })
161 - then?.(id)
162 - toast("Plugin updated")
163 - }
164 - })
165 -}
166 -
166 export async function startPlugin(id: string) {
167 await apiCall('start_plugin', { id })
168 toast("Plugin started", h(PlayCircle, { color: 'success' }))
admin/src/OnlinePlugins.ts
+2 -9
@@ -7,9 +7,8 @@ import { IconBtn } from './misc'
7 import { Download, Search } from '@mui/icons-material'
8 import { StringField } from '@hfs/mui-grid-form'
9 import { useDebounce } from 'usehooks-ts'
10 -import { renderName, showError, startPlugin, UpdateButton } from './InstalledPlugins'
10 +import { renderName, showError, startPlugin } from './InstalledPlugins'
11 import { state, useSnapState } from './state'
12 -import _ from 'lodash'
12 import { alertDialog } from './dialog'
13
14 export default function OnlinePlugins() {
@@ -68,13 +67,7 @@ export default function OnlinePlugins() {
67 },
68 ],
69 actions: ({ row, id }) => [
71 - row.update ? h(UpdateButton, {
72 - id,
73 - then() {
74 - updateList(list =>
75 - _.find(list, { id }).update = false )
76 - }
77 - }) : h(IconBtn, {
70 + h(IconBtn, {
71 icon: Download,
72 title: "Install",
73 progress: row.downloading,
dev-plugins.md
+3 -1
@@ -59,10 +59,12 @@ All the following properties are optional unless otherwise specified.
59 {
60 "web": "https://github.com/rejetto/file-icons",
61 "zip": "/archive/refs/heads/main.zip",
62 - "zipRoot: "file-icons/main",
62 + "zipRoot: "file-icons-main/dist",
63 "main": "https://raw.githubusercontent.com/rejetto/file-icons/main/dist/plugin.js"
64 }
65 ```
66 + Note that in this example we are pointing to a github repo just for clarity. You are not supposed to use this
67 + complicated object form to link github, use the string form.
68 Plugins with custom repos are not included in search results, but the update feature will still work.
69
70 WARNING: All the properties above are a bit special and must go in `exports` only (thus, not returned in `init`) and the syntax
src/api.plugins.ts
+26 -22
@@ -14,7 +14,7 @@ import {
14 setPluginConfig,
15 findPluginByRepo,
16 isPluginEnabled,
17 - isPluginRunning, stopPlugin, startPlugin,
17 + isPluginRunning, stopPlugin, startPlugin, Repo,
18 } from './plugins'
19 import _ from 'lodash'
20 import assert from 'assert'
@@ -37,14 +37,6 @@ const apis: ApiHandlers = {
37 },
38 pluginUninstalled: id => list.remove({ id }),
39 })
40 -
41 - function serialize(p: Readonly<Plugin> | AvailablePlugin) {
42 - const o = 'getData' in p ? Object.assign(_.pick(p, ['id','started']), p.getData())
43 - : { ...p } // _.defaults mutates object, and we don't want that
44 - if (typeof o.repo === 'object') // custom repo
45 - o.repo = o.repo.web
46 - return _.defaults(o, { started: null, badApi: null }) // nulls should be used to be sure to overwrite previous values,
47 - }
40 },
41
42 async get_plugin_updates() {
@@ -57,8 +49,12 @@ const apis: ApiHandlers = {
49 const online = await readOnlinePlugin(repo)
50 if (!online?.apiRequired || online.badApi) return
51 const disk = getPluginInfo(folder)
60 - if (online.version! > disk.version)
52 + if (!disk) return // plugin removed in the meantime?
53 + if (online.version! > disk.version) { // it IS newer
54 + online.id = disk.id // id is installation-dependant, and online cannot know
55 + online.repo = serialize(disk).repo // show the user the current repo we are getting this update from, not a possibly-changed future one
56 list.add(online)
57 + }
58 } catch (err: any) {
59 if (err.message === '404') // the plugin is declaring a wrong repo
60 return
@@ -116,7 +112,7 @@ const apis: ApiHandlers = {
112
113 const folder2repo = getFolder2repo()
114 for await (const pl of searchPlugins(text)) {
119 - const repo = pl.id
115 + const repo = pl.repo || pl.id // .repo property can be more trustworthy in case github user renamed and left the previous link in 'repo'
116 if (_.includes(folder2repo, repo)) continue // don't include installed plugins
117 list.add(pl)
118 // watch for events about this plugin, until this request is closed
@@ -142,25 +138,25 @@ const apis: ApiHandlers = {
138 })
139 },
140
145 - async download_plugin(pl) {
146 - await checkDependencies(pl.id, pl.branch)
147 - const res = await downloadPlugin(pl.id, pl.branch)
141 + async download_plugin({ id, branch }) {
142 + await checkDependencies(id, branch)
143 + const res = await downloadPlugin(id, { branch })
144 if (typeof res !== 'string')
145 return res
146 return (await waitFor(() => getPluginInfo(res), { timeout: 5000 }))
147 || new ApiError(HTTP_SERVER_ERROR)
148 },
149
154 - async update_plugin(pl) {
155 - await checkDependencies(pl.id, pl.branch)
156 - const found = findPluginByRepo(pl.id) // github id !== local id
150 + async update_plugin({ id }) {
151 + const found = getPluginInfo(id)
152 if (!found)
153 return new ApiError(HTTP_NOT_FOUND)
159 - const enabled = isPluginEnabled(found.id)
160 - await stopPlugin(found.id)
161 - await downloadPlugin(pl.id, pl.branch, true)
154 + await checkDependencies(found.repo, )
155 + const enabled = isPluginEnabled(id)
156 + await stopPlugin(id)
157 + await downloadPlugin(found.repo, { overwrite: true })
158 if (enabled)
163 - startPlugin(found.id).then() // don't wait, in case it fails to start
159 + startPlugin(id).then() // don't wait, in case it fails to start
160 return {}
161 },
162
@@ -174,7 +170,15 @@ const apis: ApiHandlers = {
170
171 export default apis
172
177 -async function checkDependencies(repo: string, branch: string) {
173 +function serialize(p: Readonly<Plugin> | AvailablePlugin) {
174 + const o = 'getData' in p ? Object.assign(_.pick(p, ['id','started']), p.getData())
175 + : { ...p } // _.defaults mutates object, and we don't want that
176 + if (typeof o.repo === 'object') // custom repo
177 + o.repo = o.repo.web
178 + return _.defaults(o, { started: null, badApi: null }) // nulls should be used to be sure to overwrite previous values,
179 +}
180 +
181 +async function checkDependencies(repo: Repo, branch?: string) {
182 const rec = await readOnlinePlugin(repo, branch)
183 if (!rec) return
184 const miss = rec.depend && rec.depend.map((dep: any) => {
src/github.ts
+19 -17
@@ -12,7 +12,7 @@ import {
12 } from './plugins'
13 import { ApiError } from './apiMiddleware'
14 import _ from 'lodash'
15 -import { DAY, HFS_REPO, HTTP_BAD_REQUEST, HTTP_CONFLICT, HTTP_FAILED_DEPENDENCY, HTTP_SERVER_ERROR } from './const'
15 +import { DAY, HFS_REPO, HTTP_BAD_REQUEST, HTTP_CONFLICT, HTTP_NOT_ACCEPTABLE, HTTP_SERVER_ERROR } from './const'
16 import { rename, rm } from 'fs/promises'
17 import { join } from 'path'
18 import { readFileSync } from 'fs'
@@ -30,7 +30,16 @@ function downloadProgress(id: string, status: DownloadStatus) {
30 events.emit('pluginDownload_'+id, status)
31 }
32
33 -export async function downloadPlugin(repo: string, branch='', overwrite?: boolean) {
33 +// determine default branch, possibly without consuming api quota
34 +async function getGithubDefaultBranch(repo: string) {
35 + const res = await httpString(`https://github.com/${repo}/archive/refs/heads/main.zip`, { method: 'HEAD' })
36 + return res.ok ? 'main'
37 + : (await getRepoInfo(repo))?.default_branch as string
38 +}
39 +
40 +export async function downloadPlugin(repo: Repo, { branch='', overwrite=false }={}) {
41 + if (typeof repo !== 'string')
42 + repo = repo.main
43 if (downloading[repo])
44 return new ApiError(HTTP_CONFLICT, "already downloading")
45 console.log('downloading plugin', repo)
@@ -48,9 +57,7 @@ export async function downloadPlugin(repo: string, branch='', overwrite?: boolea
57 url = customRepo.web + url
58 return await go(url, pl?.id, customRepo.zipRoot ?? DIST_ROOT)
59 }
51 - const rec = await getRepoInfo(repo)
52 - if (!branch)
53 - branch = rec.default_branch
60 + branch ||= await getGithubDefaultBranch(repo)
61 const short = repo.split('/')[1] // second part, repo without the owner
62 if (!short)
63 return new ApiError(HTTP_BAD_REQUEST, "bad repo")
@@ -81,7 +88,7 @@ export async function downloadPlugin(repo: string, branch='', overwrite?: boolea
88 })
89 const main = join(installPath, MAIN)
90 await rename(main + DISABLING_POSTFIX, main) // we are good now, restore name
84 - .catch(e => { throw e.code !== 'ENOENT' ? e : new ApiError(HTTP_FAILED_DEPENDENCY, "missing main file") })
91 + .catch(e => { throw e.code !== 'ENOENT' ? e : new ApiError(HTTP_NOT_ACCEPTABLE, "missing main file") })
92 return folder
93 }
94 }
@@ -112,17 +119,12 @@ export async function readOnlinePlugin(repo: Repo, branch='') {
119 if (!res.ok) throw Error("bad repo.main")
120 return parsePluginSource(main, res.body) // use 'repo' as 'id' client-side
121 }
115 - const branches = branch ? [branch] : (async function*() {
116 - yield 'main' // getRepoInfo consumes github-api-quota, so give 'main' a shot first, and if it fails we'll ask
117 - yield (await getRepoInfo(repo))?.default_branch
118 - })()
119 - for await (const b of branches) {
120 - const res = await readGithubFile(`${repo}/${b}/${DIST_ROOT}/plugin.js`)
121 - if (!res) continue
122 - const pl = parsePluginSource(repo, res) // use 'repo' as 'id' client-side
123 - pl.branch = b || undefined
124 - return pl
125 - }
122 + branch ||= await getGithubDefaultBranch(repo)
123 + const res = await readGithubFile(`${repo}/${branch}/${DIST_ROOT}/plugin.js`)
124 + if (!res) throw Error("missing plugin.js")
125 + const pl = parsePluginSource(repo, res) // use 'repo' as 'id' client-side
126 + pl.branch = branch
127 + return pl
128 }
129
130 export function getFolder2repo() {