@samitouri / QOSami-HFS / commits / 525aa683

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)