auto_check_update
Massimo Melina committed
Jun 20, 2024 at 10:03 UTC
525aa683e1170b027aeeb17faf10c17ea170ba83
6 files changed
+83
-50
admin/src/HomePage.ts
+31
-32
@@ -15,24 +15,15 @@ import { Account } from './AccountsPage'
15
import _ from 'lodash'
16
import { subscribeKey } from 'valtio/utils'
17
import { SwitchThemeBtn } from './theme'
18
-import { BoolField } from '@hfs/mui-grid-form'
18
+import { CheckboxField } from '@hfs/mui-grid-form'
19
import { ConfigForm } from './ConfigForm'
20
-
21
-interface ServerStatus { listening: boolean, port: number, error?: string, busy?: string }
22
-
23
-interface Status {
24
- http: ServerStatus
25
- https: ServerStatus
26
- frpDetected: boolean
27
- proxyDetected?: boolean
28
- updatePossible: boolean | string
29
- version: string
30
-}
20
+import { Release } from '../../src/update'
21
+import { adminApis } from '../../src/adminApis'
22
23
export default function HomePage() {
24
const SOLUTION_SEP = " — "
25
const { username } = useSnapState()
35
- const { data: status, reload: reloadStatus, element: statusEl } = useApiEx<Status>('get_status')
26
+ const { data: status, reload: reloadStatus, element: statusEl } = useApiEx<typeof adminApis.get_status>('get_status')
27
const { data: vfs } = useApiEx<{ root?: VfsNode }>('get_vfs')
28
const { data: account } = useApiEx<Account>(username && 'get_account')
29
const cfg = useApiEx('get_config', { only: ['https_port', 'cert', 'private_key', 'proxies'] })
@@ -92,7 +83,16 @@ export default function HomePage() {
83
h('li',{}, `disable "admin access for localhost" in HFS (safe, but you won't see users' IPs)`),
84
)),
85
entry('', wikiLink('', "See the documentation"), " and ", h(Link, { target: 'support', href: REPO_URL + 'discussions' }, "get support")),
86
+ !updates && with_(status.autoCheckUpdateResult, x => x?.isNewer && h(Update, { info: x, bodyCollapsed: true, title: "An update has been found" })),
87
pluginUpdates.length > 0 && entry('success', "Updates available for plugin(s): " + pluginUpdates.map(p => p.id).join(', ')),
88
+ h(ConfigForm, {
89
+ gridProps: { sx: { columns: '13em 2', gap: 0, display: 'block', mt: 0, '&>div.MuiGrid-item': { pt: 0 }, '.MuiCheckbox-root': { pl: '2px' } } },
90
+ saveOnChange: true,
91
+ form: { fields: [
92
+ { k: 'auto_check_update', comp: CheckboxField, label: "Check updates daily" },
93
+ { k: 'update_to_beta', comp: CheckboxField, label: "Include beta versions" },
94
+ ] }
95
+ }),
96
status.updatePossible === 'local' ? h(Btn, {
97
icon: UpdateIcon,
98
onClick: () => update()
@@ -103,7 +103,7 @@ export default function HomePage() {
103
icon: UpdateIcon,
104
onClick() {
105
setCheckPlugins(true)
106
- return apiCall('check_update').then(x => setUpdates(x.options), alertDialog)
106
+ return apiCall<typeof adminApis.check_update>('check_update').then(x => setUpdates(x.options), alertDialog)
107
},
108
async onContextMenu(ev) {
109
ev.preventDefault()
@@ -115,33 +115,32 @@ export default function HomePage() {
115
},
116
title: status.updatePossible && "Right-click if you want to install a zip",
117
}, "Check for updates"),
118
- h(ConfigForm, {
119
- saveOnChange: true,
120
- form: { fields: [
121
- { k: 'update_to_beta', comp: BoolField, label: "Include beta versions" },
122
- ] }
123
- })
118
)
119
: with_(_.find(updates, 'isNewer'), newer =>
120
!updates.length || !status.updatePossible && !newer ? entry('', "No update available")
121
: newer && !status.updatePossible ? entry('success', `Version ${newer.name} available`)
122
: h(Flex, { vert: true },
129
- updates.map((x: any) =>
130
- h(Flex, { key: x.name, alignItems: 'flex-start', flexWrap: 'wrap' },
131
- h(Card, {}, h(CardContent, {},
132
- h(Btn, {
133
- icon: UpdateIcon,
134
- ...!x.isNewer && x.prerelease && { color: 'warning', variant: 'outlined' },
135
- onClick: () => update(x.tag_name)
136
- }, prefix("Install ", x.name, x.isNewer ? '' : " (older)")),
137
- h(Box, { mt: 1 }, renderChangelog(x.body))
138
- )),
139
- )),
140
- )),
123
+ updates.map((x: any) => h(Update, { info: x })) )),
124
h(SwitchThemeBtn, { variant: 'outlined' }),
125
)
126
}
127
128
+function Update({ info, title, bodyCollapsed }: { title?: ReactNode, info: Release, bodyCollapsed?: boolean }) {
129
+ const [collapsed, setCollapsed] = useState(bodyCollapsed)
130
+ return h(Flex, { key: info.name, alignItems: 'flex-start', flexWrap: 'wrap' },
131
+ h(Card, {}, h(CardContent, {},
132
+ title && h(Box, { fontSize: 'larger', mb: 1 }, title),
133
+ h(Btn, {
134
+ icon: UpdateIcon,
135
+ ...!info.isNewer && info.prerelease && { color: 'warning', variant: 'outlined' },
136
+ onClick: () => update(info.tag_name)
137
+ }, prefix("Install ", info.name, info.isNewer ? '' : " (older)")),
138
+ collapsed ? h(LinkBtn, { sx: { display: 'block', mt: 1 }, onClick(){ setCollapsed(false) } }, "See details")
139
+ : h(Box, { mt: 1 }, renderChangelog(info.body))
140
+ )),
141
+ )
142
+}
143
+
144
function renderChangelog(s: string) {
145
return md(s, {
146
onText: s => replaceStringToReact(s, /(?<=^|\W)#(\d+)\b|(https:.*\S+)/g, m => // link issues and urls
mui-grid-form/misc-fields.ts
+6
-2
@@ -52,14 +52,14 @@ export function NumberField({ value, onChange, setApi, required, min, max, step,
52
})
53
}
54
55
-export function BoolField({ label='', value, onChange, setApi, helperText, error,
55
+export function BoolField({ label='', value, onChange, setApi, helperText, error, Control=Switch,
56
type, // avoid passing this by accident, as it disrupts the control
57
...props }: FieldProps<boolean>) {
58
const setter = () => value ?? false
59
const [state, setState] = useState(setter)
60
useEffect(() => setState(setter),
61
[value]) //eslint-disable-line
62
- const control = h(Switch, {
62
+ const control = h(Control, {
63
checked: state,
64
...props,
65
onChange(event) {
@@ -72,6 +72,10 @@ export function BoolField({ label='', value, onChange, setApi, helperText, error
72
)
73
}
74
75
+export function CheckboxField(props: FieldProps<boolean>) {
76
+ return h(BoolField, { Control: Checkbox, ...props })
77
+}
78
+
79
export function CheckboxesField({ label, options, value, onChange, columns, columnWidth }: FieldProps<string[]> & { options: string[] }) {
80
const doCols = columns > 1 || Boolean(columnWidth)
81
return h(FormControl, { fullWidth: doCols },
shared/api.ts
+2
-2
@@ -55,7 +55,7 @@ export function apiCall<T=any>(cmd: string, params?: Dict, options: ApiCallOptio
55
await options.onResponse?.(res, result)
56
if (!res.ok)
57
throw new ApiError(res.status, data === undefined ? body : `Failed API ${cmd}: ${res.statusText}`, data)
58
- return result as T
58
+ return result as Awaited<T extends (...args: any[]) => infer R ? R : T>
59
}, err => {
60
stop?.()
61
if (err?.message?.includes('fetch')) {
@@ -86,7 +86,7 @@ export function useApi<T=any>(cmd: string | Falsy, params?: object, options: Api
86
const [forcer, setForcer] = useStateMounted(0)
87
const loadingRef = useRef<ReturnType<typeof apiCall>>()
88
const reloadingRef = useRef<any>()
89
- const dataRef = useRef<T>()
89
+ const dataRef = useRef<any>()
90
useEffect(() => {
91
loadingRef.current?.abort()
92
setData(undefined)
src/adminApis.ts
+9
-8
@@ -1,6 +1,6 @@
1
// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
-import { ApiError, ApiHandlers } from './apiMiddleware'
3
+import { ApiError, ApiHandler, ApiHandlers } from './apiMiddleware'
4
import { configFile, defineConfig, getWholeConfig, setConfig } from './config'
5
import { getBaseUrlOrDefault, getIps, getServerStatus, getUrls } from './listen'
6
import {
@@ -20,7 +20,7 @@ import langApis from './api.lang'
20
import netApis from './api.net'
21
import logApis from './api.log'
22
import { getConnections } from './connections'
23
-import { apiAssertTypes, debounceAsync, isLocalHost, makeNetMatcher, waitFor } from './misc'
23
+import { apiAssertTypes, debounceAsync, isLocalHost, makeNetMatcher, typedEntries, waitFor } from './misc'
24
import { accountCanLoginAdmin, accountsConfig } from './perm'
25
import Koa from 'koa'
26
import { getProxyDetected } from './middlewares'
@@ -29,7 +29,7 @@ import { execFile } from 'child_process'
29
import { promisify } from 'util'
30
import { customHtmlSections, customHtmlState, saveCustomHtml } from './customHtml'
31
import _ from 'lodash'
32
-import { getUpdates, localUpdateAvailable, update, updateSupported } from './update'
32
+import { autoCheckUpdateResult, getUpdates, localUpdateAvailable, update, updateSupported } from './update'
33
import { resolve } from 'path'
34
import { getErrorSections } from './errorPages'
35
import { ip2country } from './geo'
@@ -37,7 +37,7 @@ import { roots } from './roots'
37
import { SendListReadable } from './SendList'
38
import { get_dynamic_dns_error } from './ddns'
39
40
-export const adminApis: ApiHandlers = {
40
+export const adminApis = {
41
42
...vfsApis,
43
...accountsApis,
@@ -119,6 +119,7 @@ export const adminApis: ApiHandlers = {
119
baseUrl: await getBaseUrlOrDefault(),
120
roots: roots.get(),
121
updatePossible: !await updateSupported() ? false : (await localUpdateAvailable()) ? 'local' : true,
122
+ autoCheckUpdateResult: autoCheckUpdateResult.get(), // in this form, we get the same type of the serialized json
123
proxyDetected: getProxyDetected(),
124
frpDetected: localhostAdmin.get() && !getProxyDetected()
125
&& getConnections().every(isLocalHost)
@@ -135,10 +136,10 @@ export const adminApis: ApiHandlers = {
136
return files
137
},
138
138
-}
139
+} satisfies ApiHandlers
140
140
-for (const [k, was] of Object.entries(adminApis))
141
- adminApis[k] = (params, ctx) => {
141
+for (const [k, was] of typedEntries(adminApis))
142
+ (adminApis[k] as any) = ((params, ctx) => {
143
if (!allowAdmin(ctx))
144
return new ApiError(HTTP_FORBIDDEN)
145
if (ctxAdminAccess(ctx))
@@ -147,7 +148,7 @@ for (const [k, was] of Object.entries(adminApis))
148
return ctx.headers.accept === 'text/event-stream'
149
? new SendListReadable({ doAtStart: x => x.error(HTTP_UNAUTHORIZED, true, props) })
150
: new ApiError(HTTP_UNAUTHORIZED, props)
150
- }
151
+ }) satisfies ApiHandler
152
153
export const localhostAdmin = defineConfig('localhost_admin', true)
154
export const adminNet = defineConfig('admin_net', '', v => makeNetMatcher(v, true) )
src/persistence.ts
+2
-1
@@ -6,7 +6,8 @@ export const storedMap = new KvStorage({
6
defaultPutDelay: 5000,
7
maxPutDelay: MINUTE,
8
maxPutDelayCreate: 1000,
9
- rewriteLater: true
9
+ rewriteLater: true,
10
+ bucketThreshold: 10_000,
11
})
12
storedMap.open('data.kv')
13
onProcessExit(() => storedMap.flush())
src/update.ts
+33
-5
@@ -4,7 +4,7 @@ import { getRepoInfo } from './github'
4
import { argv, HFS_REPO, IS_BINARY, IS_WINDOWS, RUNNING_BETA } from './const'
5
import { dirname, join } from 'path'
6
import { spawn, spawnSync } from 'child_process'
7
-import { exists, httpStream, prefix, unzip, xlate } from './misc'
7
+import { DAY, MINUTE, exists, debounceAsync, httpStream, unzip, prefix, xlate } from './misc'
8
import { createReadStream, renameSync, unlinkSync } from 'fs'
9
import { pluginsWatcher } from './plugins'
10
import { chmod, stat } from 'fs/promises'
@@ -13,16 +13,42 @@ import open from 'open'
13
import { currentVersion, defineConfig, versionToScalar } from './config'
14
import { cmdEscape, RUNNING_AS_SERVICE } from './util-os'
15
import { onProcessExit } from './first'
16
+import { storedMap } from './persistence'
17
+import _ from 'lodash'
18
19
const updateToBeta = defineConfig('update_to_beta', false)
20
+const autoCheckUpdate = defineConfig('auto_check_update', true)
21
+const lastCheckUpdate = storedMap.singleSync<number>('lastCheckUpdate', 0)
22
+const AUTO_CHECK_EVERY = DAY
23
19
-interface Release {
24
+export const autoCheckUpdateResult = storedMap.singleSync<Release | undefined>('autoCheckUpdateResult', undefined)
25
+autoCheckUpdateResult.ready().then(() => {
26
+ autoCheckUpdateResult.set(v => {
27
+ if (!v) return // refresh isNewer, as currentVersion may have changed
28
+ v.isNewer = currentVersion.olderThan(v.tag_name)
29
+ return v
30
+ })
31
+})
32
+setInterval(debounceAsync(async () => {
33
+ if (!autoCheckUpdate.get()) return
34
+ if (Date.now() < lastCheckUpdate.get() + AUTO_CHECK_EVERY) return
35
+ console.log("checking for updates")
36
+ const u = (await getUpdates(true))[0]
37
+ if (u) console.log("new version available", u.name)
38
+ autoCheckUpdateResult.set(u)
39
+ lastCheckUpdate.set(Date.now())
40
+}), MINUTE / 30)
41
+
42
+export type Release = { // not using interface, as it will not work with kvstorage.Jsonable
43
prerelease: boolean,
44
tag_name: string,
45
name: string,
23
- assets: any[],
46
+ body: string,
47
+ assets: { name: string, browser_download_url: string }[],
48
isNewer: boolean // introduced by us
49
}
50
+const ReleaseKeys = ['prerelease', 'tag_name', 'name', 'body', 'assets', 'isNewer'] satisfies (keyof Release)[]
51
+const ReleaseAssetKeys = ['name', 'browser_download_url'] satisfies (keyof Release['assets'][0])[]
52
53
export async function getUpdates(strict=false) {
54
const stable: Release = await getRepoInfo(HFS_REPO + '/releases/latest')
@@ -31,7 +57,9 @@ export async function getUpdates(strict=false) {
57
stable.isNewer = currentVersion.olderThan(stable.tag_name)
58
if (stable.isNewer || RUNNING_BETA)
59
ret.push(stable)
34
- return ret.filter(x => !strict || x.isNewer)
60
+ // prune a bit, as it will be serialized, but it has a lot of unused data
61
+ return ret.filter(x => !strict || x.isNewer).map(x =>
62
+ Object.assign(_.pick(x, ReleaseKeys), { assets: x.assets.map(a => _.pick(a, ReleaseAssetKeys)) }))
63
64
function ver(x: any) {
65
return versionToScalar(x.name)
@@ -117,7 +145,7 @@ export async function update(tagOrUrl: string='') {
145
catch {}
146
renameSync(bin, oldBin)
147
console.log("launching new version in background", newBinFile)
120
- launch(newBin, ['--updating', binFile], { sync: true }) // sync necessary to work on mac by double-click
148
+ launch(newBin, ['--updating', binFile], { sync: true }) // sync necessary to work on Mac by double-click
149
})
150
console.log("quitting")
151
setTimeout(() => process.exit()) // give time to return (and caller to complete, eg: rest api to reply)