admin/plugins: updates

Massimo Melina committed May 11, 2022 at 23:45 UTC 5e4c3228d02f68240c27e32007407f3afe2ac04d
5 files changed +119 -51
admin/src/InstalledPlugins.ts
+18 -4
@@ -2,22 +2,26 @@ import { apiCall, useApiList } from './api'
2 import { createElement as h, Fragment } from 'react'
3 import { Alert, Box, Tooltip } from '@mui/material'
4 import { DataGrid } from '@mui/x-data-grid'
5 -import { Delete, Error, PlayCircle, Settings, StopCircle } from '@mui/icons-material'
5 +import { Delete, Error, PlayCircle, Settings, StopCircle, SystemUpdateAlt } from '@mui/icons-material'
6 import { IconBtn } from './misc'
7 import { formDialog, toast } from './dialog'
8 import _ from 'lodash'
9 import { BoolField, Field, MultiSelectField, NumberField, SelectField, StringField } from './Form'
10 import { ArrayField } from './ArrayField'
11
12 -export default function InstalledPlugins() {
13 - const { list, error, initializing } = useApiList('get_plugins')
12 +export default function InstalledPlugins({ updates }: { updates?: true }) {
13 + const { list, setList, error, initializing } = useApiList(updates ? 'get_plugin_updates' : 'get_plugins')
14 if (error)
15 return h(Alert, { severity: 'error' }, error)
16 return h(DataGrid, {
17 - rows: list,
17 + rows: list.length ? list : [], // workaround for DataGrid bug causing 'no rows' message to be not displayed after 'loading' was also used
18 loading: initializing,
19 disableColumnSelector: true,
20 disableColumnMenu: true,
21 + columnVisibilityModel: {
22 + started: !updates,
23 + },
24 + localeText: updates && { noRowsLabel: "No updates available. Only online plugins are checked." },
25 columns: [
26 {
27 field: 'id',
@@ -48,10 +52,20 @@ export default function InstalledPlugins() {
52 field: "actions",
53 width: 120,
54 align: 'center',
55 + headerAlign: 'center',
56 hideSortIcons: true,
57 disableColumnMenu: true,
58 renderCell({ row }) {
59 const { config, id } = row
60 + if (updates)
61 + return h(IconBtn, {
62 + icon: SystemUpdateAlt,
63 + title: "Update",
64 + async onClick() {
65 + await apiCall('update_plugin', { id })
66 + setList(list.filter(x => x.id !== id))
67 + }
68 + })
69 return h('div', {},
70 h(IconBtn, row.started ? {
71 icon: StopCircle,
admin/src/PluginsPage.ts
+15 -4
@@ -5,12 +5,23 @@ import { Tab, Tabs } from '@mui/material'
5 import InstalledPlugins from "./InstalledPlugins"
6 import OnlinePlugins from "./OnlinePlugins"
7
8 +const TABS = {
9 + "Installed": InstalledPlugins,
10 + "Search online": OnlinePlugins,
11 + "Check updates": () => h(InstalledPlugins, { updates: true }),
12 +}
13 +const LABELS = Object.keys(TABS)
14 +const PANES = Object.values(TABS)
15 +
16 export default function PluginsPage() {
17 const [tab, setTab] = useState(0)
10 - const tabs = ["Installed", "Search online"]
18 return h(Fragment, {},
12 - h(Tabs, { value: tab, onChange(ev,i){ setTab(i) } },
13 - tabs.map(f => h(Tab, { label: f, key: f })) ),
14 - h(tab ? OnlinePlugins : InstalledPlugins)
19 + h(Tabs, {
20 + value: tab,
21 + onChange(ev, i) {
22 + setTab(i)
23 + }
24 + }, LABELS.map(label => h(Tab, { label, key: label })) ),
25 + h(PANES[tab])
26 )
27 }
admin/src/api.ts
+1 -1
@@ -189,5 +189,5 @@ export function useApiList<T=any>(cmd:string|Falsy, params: Dict={}, { addId=fal
189 clearInterval(timer)
190 }
191 }, [cmd, JSON.stringify(params)]) //eslint-disable-line
192 - return { list, loading, error, initializing }
192 + return { list, loading, error, initializing, setList }
193 }
server/src/api.plugins.ts
+69 -34
@@ -6,7 +6,7 @@ import {
6 mapPlugins,
7 parsePluginSource,
8 Plugin, pluginsConfig,
9 - PATH as PLUGINS_PATH, isPluginRunning, enablePlugin
9 + PATH as PLUGINS_PATH, isPluginRunning, enablePlugin, getPluginInfo, rescan
10 } from './plugins'
11 import _ from 'lodash'
12 import assert from 'assert'
@@ -20,11 +20,12 @@ import { rm } from 'fs/promises'
20 const DIST_ROOT = 'dist/'
21
22 const apis: ApiHandlers = {
23 +
24 get_plugins({}, ctx) {
25 const list = sendList([ ...mapPlugins(serialize), ...getAvailablePlugins() ])
26 return list.events(ctx, {
27 pluginInstalled: p => list.add(serialize(p)),
27 - 'pluginStarted pluginStopped': p => {
28 + 'pluginStarted pluginStopped pluginUpdated': p => {
29 const { id, ...rest } = serialize(p)
30 list.update({ id }, rest)
31 },
@@ -36,6 +37,23 @@ const apis: ApiHandlers = {
37 }
38 },
39
40 + async get_plugin_updates() {
41 + const list = sendList()
42 + setTimeout(async () => {
43 + const repo2id = getRepo2id()
44 + for (const repo in repo2id) {
45 + const online = await readOnlinePlugin(repo)
46 + if (!online.apiRequired || online.badApi) continue
47 + const id = repo2id[repo]
48 + const disk = getPluginInfo(id)
49 + if (online.version! > disk.version)
50 + list.add(online)
51 + }
52 + list.end()
53 + })
54 + return list.return
55 + },
56 +
57 async set_plugin({ id, enabled, config }) {
58 assert(id, 'id')
59 if (enabled !== undefined)
@@ -63,40 +81,33 @@ const apis: ApiHandlers = {
81
82 search_online_plugins({ text }, ctx) {
83 const list = sendList()
66 - const repo2id = Object.fromEntries(getAvailablePlugins().map(x => [x.repo, x.id]))
67 - Object.assign(repo2id, Object.fromEntries(mapPlugins(x => [x.getData().repo, x.id]))) // started ones
68 - apiGithub('search/repositories?q=topic:hfs-plugin+' + encodeURI(text)).then(res => {
69 - const jobs = []
84 + const repo2id = getRepo2id()
85 + apiGithub('search/repositories?q=topic:hfs-plugin+' + encodeURI(text)).then(async res => {
86 for (const it of res.items) {
87 const repo = it.full_name
72 - const job = httpsString(`https://raw.githubusercontent.com/${repo}/master/${DIST_ROOT}plugin.js`).then(res => {
73 - if (!res.ok)
74 - throw res.statusCode
75 - const pl = parsePluginSource(repo, res.body) // use 'repo' as 'id' client-side
76 - if (!pl.apiRequired || pl.badApi) return
77 - Object.assign(pl, { // inject some extra useful fields
78 - downloading: downloading[repo],
79 - installed: repo2id[repo]
80 - })
81 - list.add(pl)
82 - // watch for events about this plugin, until this request is closed
83 - ctx.req.on('close', onOff(events, {
84 - pluginInstalled: p => {
85 - if (p.repo === repo)
86 - list.update({ id: repo }, { installed: true })
87 - },
88 - pluginUninstalled: id => {
89 - if (repo === _.findKey(repo2id, x => x === id))
90 - list.update({ id: repo }, { installed: false })
91 - },
92 - ['pluginDownload_'+repo](status) {
93 - list.update({ id: repo }, { downloading: status ?? null })
94 - }
95 - }) )
88 + const pl = await readOnlinePlugin(repo)
89 + if (!pl.apiRequired || pl.badApi) continue
90 + Object.assign(pl, { // inject some extra useful fields
91 + downloading: downloading[repo],
92 + installed: repo2id[repo]
93 })
97 - jobs.push(job)
94 + list.add(pl)
95 + // watch for events about this plugin, until this request is closed
96 + ctx.req.on('close', onOff(events, {
97 + pluginInstalled: p => {
98 + if (p.repo === repo)
99 + list.update({ id: repo }, { installed: true })
100 + },
101 + pluginUninstalled: id => {
102 + if (repo === _.findKey(repo2id, x => x === id))
103 + list.update({ id: repo }, { installed: false })
104 + },
105 + ['pluginDownload_'+repo](status) {
106 + list.update({ id: repo }, { downloading: status ?? null })
107 + }
108 + }) )
109 }
99 - Promise.allSettled(jobs).then(() => list.end())
110 + list.end()
111 })
112 return list.return
113 },
@@ -108,6 +119,13 @@ const apis: ApiHandlers = {
119 return {}
120 },
121
122 + async update_plugin({ id }) {
123 + if (downloading[id])
124 + return new ApiError(409, "already downloading")
125 + await downloadPlugin(id, true)
126 + return {}
127 + },
128 +
129 async uninstall_plugin({ id }) {
130 while (isPluginRunning(id)) {
131 enablePlugin(id, false)
@@ -130,13 +148,14 @@ function downloadProgress(id: string, status: DownloadStatus) {
148 events.emit('pluginDownload_'+id, status)
149 }
150
133 -async function downloadPlugin(repo: string) {
151 +async function downloadPlugin(repo: string, overwrite?: boolean) {
152 downloadProgress(repo, true)
153 const rec = await apiGithub('repos/'+repo)
154 const url = `https://github.com/${repo}/archive/${rec.default_branch}.zip`
155 const res = await httpsStream(url)
156 const repo2 = repo.split('/')[1] // second part, repo without the owner
139 - const repo2clash = getAvailablePlugins().find(x => x.id === repo2) || mapPlugins(x => x.id === repo2).some(Boolean)
157 + const repo2clash = !overwrite
158 + && (getAvailablePlugins().find(x => x.id === repo2) || mapPlugins(x => x.id === repo2).some(Boolean))
159 const pluginFolder = repo2clash ? repo.replace('/','-') : repo2 // longer form only if necessary
160 const installFolder = PLUGINS_PATH + '/' + pluginFolder
161 const GITHUB_ZIP_ROOT = repo2 + '-' + rec.default_branch // github puts everything within this folder
@@ -153,6 +172,7 @@ async function downloadPlugin(repo: string) {
172 mkdirSync(dest, { recursive: true }) // easy way be sure to have the folder ready before proceeding
173 })
174 .on('close', () => {
175 + rescan() // workaround: for some reason, operations above are not triggering the rescan of the watched folder. Let's invoke it.
176 resolve(undefined)
177 downloadProgress(repo, undefined)
178 }))
@@ -172,3 +192,18 @@ function apiGithub(uri: string) {
192 return JSON.parse(res.body)
193 })
194 }
195 +
196 +function readOnlinePlugin(repo: string) {
197 + return httpsString(`https://raw.githubusercontent.com/${repo}/master/${DIST_ROOT}plugin.js`).then(res => {
198 + if (!res.ok)
199 + throw res.statusCode
200 + return parsePluginSource(repo, res.body) // use 'repo' as 'id' client-side
201 + })
202 +}
203 +
204 +function getRepo2id() {
205 + const ret = Object.fromEntries(getAvailablePlugins().map(x => [x.repo, x.id]))
206 + Object.assign(ret, Object.fromEntries(mapPlugins(x => [x.getData().repo, x.id]))) // started ones
207 + delete ret.undefined
208 + return ret
209 +}
server/src/plugins.ts
+16 -8
@@ -7,7 +7,7 @@ import pathLib from 'path'
7 import { API_VERSION, COMPATIBLE_API_VERSION, PLUGINS_PUB_URI } from './const'
8 import * as Const from './const'
9 import Koa from 'koa'
10 -import { debounceAsync, getOrSet, onProcessExit, wantArray, watchDir } from './misc'
10 +import { debounceAsync, getOrSet, onProcessExit, same, wantArray, watchDir } from './misc'
11 import { defineConfig } from './config'
12 import { DirEntry } from './api.file_list'
13 import { VfsNode } from './vfs'
@@ -34,6 +34,10 @@ export function enablePlugin(id: string, state=true) {
34 )
35 }
36
37 +export function getPluginInfo(id: string) {
38 + return plugins[id]?.getData() ?? availablePlugins[id]
39 +}
40 +
41 export function mapPlugins<T>(cb:(plugin:Readonly<Plugin>, pluginName:string)=> T) {
42 return _.map(plugins, (pl,plName) => {
43 try { return cb(pl,plName) }
@@ -178,7 +182,7 @@ enablePlugins.sub(rescanAsap)
182
183 export const pluginsConfig = defineConfig('plugins_config', {} as Record<string,any>)
184
181 -async function rescan() {
185 +export async function rescan() {
186 console.debug('scanning plugins')
187 const found = []
188 const foundDisabled: typeof availablePlugins = {}
@@ -226,12 +230,16 @@ async function rescan() {
230 }
231 })
232 }
229 - for (const id in foundDisabled)
230 - if (!availablePlugins[id]) {
231 - availablePlugins[id] = foundDisabled[id]
232 - if (!plugins[id])
233 - events.emit('pluginInstalled', foundDisabled[id])
234 - }
233 + for (const id in foundDisabled) {
234 + const p = foundDisabled[id]
235 + const a = availablePlugins[id]
236 + if (same(a, p)) continue
237 + availablePlugins[id] = p
238 + if (a)
239 + events.emit('pluginUpdated', p)
240 + else if (!plugins[id])
241 + events.emit('pluginInstalled', p)
242 + }
243 for (const id in availablePlugins)
244 if (!foundDisabled[id] && !found.includes(id) && !plugins[id]) {
245 delete availablePlugins[id]