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