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]