automatic update to beta
Massimo Melina committed
Jul 18, 2023 at 22:59 UTC
c09ff9e5e92b5ab3aa493ea2d1b7eab66de1f7f4
6 files changed
+98
-38
admin/src/HomePage.ts
+35
-18
@@ -3,7 +3,18 @@
3
import { createElement as h, ReactNode, useState } from 'react'
4
import { Box, Button, LinearProgress, Link } from '@mui/material'
5
import { apiCall, useApi, useApiEx, useApiList } from './api'
6
-import { Btn, dontBotherWithKeys, InLink, objSameKeys, onlyTruthy, prefix, REPO_URL, wait, wikiLink } from './misc'
6
+import {
7
+ Btn,
8
+ dontBotherWithKeys,
9
+ Flex,
10
+ InLink,
11
+ objSameKeys,
12
+ onlyTruthy,
13
+ prefix,
14
+ REPO_URL,
15
+ wait,
16
+ wikiLink
17
+} from './misc'
18
import { BrowserUpdated as UpdateIcon, CheckCircle, Error, Info, Launch, Warning } from '@mui/icons-material'
19
import md from './md'
20
import { state, useSnapState } from './state'
@@ -21,7 +32,7 @@ interface Status {
32
https: ServerStatus
33
frpDetected: boolean
34
proxyDetected?: boolean
24
- update: boolean | string
35
+ updatePossible: boolean | string
36
version: string
37
}
38
@@ -31,9 +42,9 @@ export default function HomePage() {
42
const { data: status, reload: reloadStatus, element: statusEl } = useApiEx<Status>('get_status')
43
const { data: vfs } = useApiEx<{ root?: VfsNode }>('get_vfs')
44
const [account] = useApi<Account>(username && 'get_account')
34
- const { data: cfg, reload: reloadCfg } = useApiEx('get_config', { only: ['https_port', 'cert', 'private_key', 'proxies'] })
45
+ const { data: cfg, reload: reloadCfg } = useApiEx('get_config', { only: ['https_port', 'cert', 'private_key', 'proxies', 'update_to_beta'] })
46
const { list: plugins } = useApiList('get_plugins')
36
- const [onlineVersion, setOnlineVersion] = useState('')
47
+ const [updateInfo, setUpdateInfo] = useState<any>()
48
if (statusEl || !status)
49
return statusEl
50
const { http, https } = status
@@ -47,10 +58,13 @@ export default function HomePage() {
58
const errors = serverErrors && onlyTruthy(Object.entries(serverErrors).map(([k,v]) =>
59
v && [md(`Protocol _${k}_ cannot work: `), v,
60
(isCertError(v) || isKeyError(v)) && [
50
- SOLUTION_SEP, h(Link, { sx: { cursor: 'pointer' }, onClick() { makeCertAndSave().then(reloadCfg).then(reloadStatus) } }, "make one"),
51
- " or ", SOLUTION_SEP, cfgLink("provide adequate files")
61
+ SOLUTION_SEP, h(Link, {
62
+ sx: { cursor: 'pointer' },
63
+ onClick() { makeCertAndSave().then(reloadCfg).then(reloadStatus) } },
64
+ "make one"
65
+ ), " or ", SOLUTION_SEP, cfgLink("provide adequate files")
66
]]))
53
- return h(Box, { display:'flex', gap: 2, flexDirection:'column' },
67
+ return h(Box, { display:'flex', gap: 2, flexDirection:'column', height: '100%' },
68
username && entry('', "Welcome "+username),
69
errors.length ? dontBotherWithKeys(errors.map(msg => entry('error', dontBotherWithKeys(msg))))
70
: entry('success', "Server is working"),
@@ -86,26 +100,29 @@ export default function HomePage() {
100
)),
101
entry('', h(Link, { target: 'support', href: REPO_URL + 'discussions' }, "Get support")),
102
h(Box, { mt: 4 },
89
- status.update === 'local' ? h(Btn, { icon: UpdateIcon, onClick: update }, "Update from local file")
90
- : !onlineVersion ? h(Btn, {
103
+ status.updatePossible === 'local' ? h(Btn, {
104
+ icon: UpdateIcon,
105
+ onClick: () => update()
106
+ }, "Update from local file")
107
+ : !updateInfo ? h(Btn, {
108
variant: 'outlined',
109
icon: UpdateIcon,
93
- onClick: () => apiCall('check_update').then(x => setOnlineVersion(x.name), x => {
94
- setOnlineVersion('')
95
- alertDialog(x).then()
96
- })
110
+ onClick: () => apiCall('check_update').then(x => setUpdateInfo(x.options), alertDialog)
111
}, "Check for updates")
98
- : status?.version === onlineVersion ? entry('', "You got the latest version")
99
- : !status.update ? entry('', `Version ${onlineVersion} available`)
100
- : h(Btn, { icon: UpdateIcon, onClick: update }, prefix("Update to ", onlineVersion))
112
+ : !updateInfo.length ? entry('', "No update available")
113
+ : !status.updatePossible ? entry('', `Version ${updateInfo[0].name} available`)
114
+ : h(Flex, { vert: true, alignItems: 'flex-start' },
115
+ updateInfo.map((x: any) =>
116
+ h(Btn, { icon: UpdateIcon, onClick: () => update(x.tag_name) }, prefix("Install ", x.name)) )
117
+ )
118
),
119
)
120
}
121
105
-async function update() {
122
+async function update(tag?: string) {
123
if (!await confirmDialog("Update may take less than a minute, depending on the speed of your server")) return
124
toast('Downloading')
108
- await apiCall('update')
125
+ await apiCall('update', { tag })
126
toast("Restarting")
127
const restarting = Date.now()
128
while (await apiCall('NONE').then(() => 0, e => !e.code)) { // while we get no response
admin/src/OptionsPage.ts
+2
-1
@@ -150,7 +150,8 @@ export default function OptionsPage() {
150
{ k: 'session_duration', comp: NumberField, sm: 3, min: 5, unit: "seconds", required: true },
151
{ k: 'zip_calculate_size_for_seconds', comp: NumberField, sm: 3, label: "Calculate ZIP size for", unit: "seconds",
152
helperText: "If time is not enough, the browser will not show download percentage" },
153
- { k: 'admin_net', comp: NetmaskField, label: "Admin-panel accessible from", placeholder: "any address", md: 12,
153
+ { k: 'update_to_beta', comp: BoolField, helperText: "Include betas in automatic updates" },
154
+ { k: 'admin_net', comp: NetmaskField, label: "Admin-panel accessible from", placeholder: "any address",
155
helperText: h(Fragment, {}, "IP address of browser machine. ", h(WildcardsSupported))
156
},
157
{ k: 'mime', comp: StringStringField,
src/adminApis.ts
+8
-4
@@ -31,7 +31,7 @@ import { execFile } from 'child_process'
31
import { promisify } from 'util'
32
import { customHtmlSections, customHtmlState, saveCustomHtml } from './customHtml'
33
import _ from 'lodash'
34
-import { getUpdate, localUpdateAvailable, update, updateSupported } from './update'
34
+import { getUpdates, localUpdateAvailable, update, updateSupported } from './update'
35
import { consoleLog } from './consoleLog'
36
37
export const adminApis: ApiHandlers = {
@@ -59,8 +59,12 @@ export const adminApis: ApiHandlers = {
59
},
60
61
get_config: getWholeConfig,
62
- update,
63
- check_update: () => getUpdate().then(x => _.pick(x, 'name')),
62
+ update({ tag }) {
63
+ return update(tag)
64
+ },
65
+ async check_update() {
66
+ return { options: await getUpdates() }
67
+ },
68
69
get_custom_html() {
70
return {
@@ -91,7 +95,7 @@ export const adminApis: ApiHandlers = {
95
...await getServerStatus(),
96
urls: getUrls(),
97
baseUrl: baseUrl.get(), // can be retrieved with get_config, but it's very handy with urls and low overhead. Case is different because the context is
94
- update: !updateSupported() ? false : await localUpdateAvailable() ? 'local' : true,
98
+ updatePossible: !updateSupported() ? false : await localUpdateAvailable() ? 'local' : true,
99
proxyDetected: getProxyDetected(),
100
frpDetected: localhostAdmin.get() && !getProxyDetected()
101
&& getConnections().every(isLocalHost)
src/commands.ts
+3
-3
@@ -3,7 +3,7 @@
3
import { addAccount, getAccount, updateAccount } from './perm'
4
import { getConfig, configKeyExists, setConfig } from './config'
5
import _ from 'lodash'
6
-import { getUpdate, update } from './update'
6
+import { getUpdates, update } from './update'
7
import { openAdmin } from './listen'
8
import yaml from 'yaml'
9
import { argv, BUILD_TIMESTAMP, VERSION } from './const'
@@ -110,8 +110,8 @@ const commands = {
110
'check-update': {
111
params: '',
112
async cb() {
113
- const update = await getUpdate()
114
- if (update.name === VERSION)
113
+ const [update] = await getUpdates()
114
+ if (!update)
115
throw "you already have the latest version: " + VERSION
116
console.log("new version available", update.name)
117
}
src/config.ts
+11
-8
@@ -46,13 +46,13 @@ if (!existsSync(filePath) && existsSync(legacyPosition))
46
catch {}
47
}
48
// takes a semver like 1.2.3-alpha1, but alpha and beta numbers must share the number progression
49
-const versionToScalar = _.memoize((ver: string) => { // memoize so we don't have to care about converting same value twice
50
- const [official, beta] = ver.split('-')
51
- const numbers = official!.split('.').map(Number)
52
- if (numbers.length !== 3)
53
- return NaN
54
- const officialScalar = numbers.reduce((acc,x,i) => acc + x * 1000 ** (2-i), 0) // 1000 gives 3 digits for each number
55
- const betaScalar = 1 / (beta && Number(/\d+/.exec(beta)?.[0]) || Infinity) // beta tends to 0, while non-beta is 0
49
+export const versionToScalar = _.memoize((ver: string) => { // memoize so we don't have to care about converting same value twice
50
+ // this regexp is supposed to be resistant to optional leading "v" and an optional custom name after a space
51
+ const res = /^v?(\d+)\.(\d+)\.(\d+)(?:-\w+(\d+))?/.exec(ver)
52
+ if (!res) return NaN
53
+ const [,a,b,c,beta] = res.map(Number)
54
+ const officialScalar = c! + b! * 1E3 + a! * 1E6 // gives 3 digits for each number
55
+ const betaScalar = 1 / (1 + beta! || Infinity) // beta tends to 0, while non-beta is 0. +1 to make it work even in case of alpha0
56
return officialScalar - betaScalar
57
})
58
@@ -60,10 +60,13 @@ class Version extends String {
60
olderThan(otherVersion: string) {
61
return versionToScalar(this.valueOf()) < versionToScalar(otherVersion)
62
}
63
+ getScalar() {
64
+ return versionToScalar(this.valueOf())
65
+ }
66
}
67
68
const CONFIG_CHANGE_EVENT_PREFIX = 'new.'
66
-const currentVersion = new Version(VERSION)
69
+export const currentVersion = new Version(VERSION)
70
const configVersion = defineConfig('version', VERSION, v => new Version(v))
71
72
type Subscriber<T,R=void> = (v:T, more: { was?: T, version?: Version, defaultValue: T }) => R
src/update.ts
+39
-4
@@ -10,9 +10,41 @@ import { pluginsWatcher } from './plugins'
10
import { access, chmod, stat } from 'fs/promises'
11
import { Readable } from 'stream'
12
import open from 'open'
13
+import { currentVersion, defineConfig, versionToScalar } from './config'
14
14
-export async function getUpdate() {
15
- return (await getRepoInfo(HFS_REPO + '/releases?per_page=1'))[0]
15
+const updateToBeta = defineConfig('update_to_beta', false)
16
+
17
+interface Release { prerelease: boolean, tag_name: string, name: string, assets: any[] }
18
+
19
+export async function getUpdates() {
20
+ const stable: Release = await getRepoInfo(HFS_REPO + '/releases/latest')
21
+ const minVer = Math.max(ver(stable), currentVersion.getScalar())
22
+ const ret = await getBetas()
23
+ if (stable && currentVersion.olderThan(stable.tag_name))
24
+ ret.unshift(stable)
25
+ return ret
26
+
27
+ function ver(x: any) {
28
+ return versionToScalar(x.name)
29
+ }
30
+
31
+ async function getBetas() {
32
+ if (!updateToBeta.get() && !currentVersion.includes('-')) return [] // '-' means using beta
33
+ let page = 1
34
+ const ret = []
35
+ while (1) {
36
+ const per = 100
37
+ const res: Release[] = await getRepoInfo(HFS_REPO + `/releases?per_page=${per}&page=${page++}`)
38
+ if (!res.length) break
39
+ for (const x of res) {
40
+ if (!x.prerelease) continue // prerelease are all the end
41
+ if (ver(x) <= minVer) // prerelease-s are locally ordered, so as soon as we reach verStable we are done
42
+ return ret
43
+ ret.push(x)
44
+ }
45
+ }
46
+ return ret
47
+ }
48
}
49
50
const LOCAL_UPDATE = 'hfs-update.zip' // update from file takes precedence over net
@@ -25,12 +57,15 @@ export function updateSupported() {
57
return IS_BINARY
58
}
59
28
-export async function update() {
60
+export async function update(tag?: string) {
61
if (!updateSupported())
62
throw "only binary versions are supported for now"
63
let updateSource: Readable | false = await localUpdateAvailable() && createReadStream(LOCAL_UPDATE)
64
if (!updateSource) {
33
- const update = await getUpdate()
65
+ const update = !tag ? (await getUpdates())[0]
66
+ : await getRepoInfo(HFS_REPO + '/releases/tags/' + tag) as Release
67
+ if (!update)
68
+ throw "no update found"
69
const assetSearch = ({ win32: 'windows', darwin: 'mac', linux: 'linux' } as any)[process.platform]
70
if (!assetSearch)
71
throw "this feature doesn't support your platform: " + process.platform