admin/home: "install previous version"
Massimo Melina committed
Jan 23, 2025 at 00:09 UTC
f18c9876b403978dba3df5cf8f4c637e57f69749
5 files changed
+58
-35
admin/src/HomePage.ts
+22
-12
@@ -1,14 +1,16 @@
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 { createElement as h, Fragment, ReactNode, useState } from 'react'
3
+import { createElement as h, ReactNode, useState } from 'react'
4
import { Box, Card, CardContent, LinearProgress, Link } from '@mui/material'
5
import { apiCall, useApiEx, useApiList } from './api'
6
import {
7
dontBotherWithKeys, objSameKeys, onlyTruthy, prefix, REPO_URL, md,
8
- replaceStringToReact, wait, with_, DAY, HOUR
8
+ replaceStringToReact, wait, with_, DAY, HOUR, PREVIOUS_TAG
9
} from './misc'
10
import { Btn, Flex, InLink, LinkBtn, wikiLink, } from './mui'
11
-import { BrowserUpdated as UpdateIcon, CheckCircle, Error, Info, Launch, OpenInNew, Warning } from '@mui/icons-material'
11
+import {
12
+ BrowserUpdated as UpdateIcon, CheckCircle, Colorize, Error, Info, Launch, OpenInNew, Restore, Warning
13
+} from '@mui/icons-material'
14
import { state, useSnapState } from './state'
15
import { alertDialog, confirmDialog, promptDialog, toast } from './dialog'
16
import { isCertError, isKeyError, suggestMakingCert } from './OptionsPage'
@@ -101,7 +103,6 @@ export default function HomePage() {
103
fields: [
104
status.updatePossible === 'local' ? h(Btn, { icon: UpdateIcon, onClick: () => update() }, "Update from local file")
105
: !updates && h(Btn, {
104
- variant: 'outlined',
106
icon: UpdateIcon,
107
onClick() {
108
apiCall('wait_project_info').then(reloadStatus)
@@ -129,14 +130,18 @@ export default function HomePage() {
130
: h(Flex, { vert: true },
131
updates.map((x: any) => h(Update, { info: x, key: x.name })) ),
132
),
132
- !status.updatePossible ? entry('', h(Link, { href: REPO_URL + 'releases/', target: 'repo' }, "All releases"))
133
- : otherVersions ? h(Flex, { vert: true }, otherVersions.map((x: any) => h(Update, { info: x, key: x.name, bodyCollapsed: true })) )
134
- : h(Btn, {
135
- variant: 'outlined',
136
- onClick: () => apiCall<typeof adminApis.get_other_versions>('get_other_versions')
137
- .then(x => setOtherVersions(x.options), alertDialog)
138
- }, "Install other version"),
139
- h(SwitchThemeBtn, { variant: 'outlined' }),
133
+ h(Flex, { flexWrap: 'wrap' },
134
+ !otherVersions && status.updatePossible && status.previousVersionAvailable
135
+ && h(Btn, { icon: Restore, onClick: () => update(PREVIOUS_TAG) }, "Install previous version"),
136
+ !status.updatePossible ? entry('', h(Link, { href: REPO_URL + 'releases/', target: 'repo' }, "All releases"))
137
+ : !otherVersions ? h(Btn, { icon: Colorize, onClick: getOtherVersions }, "Install other version")
138
+ : h(Flex, { vert: true }, otherVersions.map((x: any) => h(Update, {
139
+ info: x,
140
+ key: x.name,
141
+ bodyCollapsed: true
142
+ }))),
143
+ ),
144
+ h(SwitchThemeBtn),
145
Date.now() - Number(new Date(status.started)) > HOUR && h(Link, {
146
title: "Donate",
147
target: 'donate',
@@ -144,6 +149,11 @@ export default function HomePage() {
149
href: 'https://www.paypal.com/donate/?hosted_button_id=HC8MB4GRVU5T2'
150
}, '❤️')
151
)
152
+
153
+ async function getOtherVersions() {
154
+ return apiCall<typeof adminApis.get_other_versions>('get_other_versions')
155
+ .then(x => setOtherVersions(x.options), alertDialog)
156
+ }
157
}
158
159
function Update({ info, title, bodyCollapsed }: { title?: ReactNode, info: Release, bodyCollapsed?: boolean }) {
src/adminApis.ts
+4
-1
@@ -24,7 +24,9 @@ import { execFile } from 'child_process'
24
import { promisify } from 'util'
25
import { customHtmlSections, customHtml, saveCustomHtml } from './customHtml'
26
import _ from 'lodash'
27
-import { autoCheckUpdateResult, getUpdates, getVersions, localUpdateAvailable, update, updateSupported } from './update'
27
+import {
28
+ autoCheckUpdateResult, getUpdates, getVersions, localUpdateAvailable, update, updateSupported, previousAvailable
29
+} from './update'
30
import { resolve } from 'path'
31
import { getErrorSections } from './errorPages'
32
import { ip2country } from './geo'
@@ -128,6 +130,7 @@ export const adminApis = {
130
baseUrl: await getBaseUrlOrDefault(),
131
roots: roots.get(),
132
updatePossible: !await updateSupported() ? false : (await localUpdateAvailable()) ? 'local' : true,
133
+ previousVersionAvailable: await previousAvailable(),
134
autoCheckUpdateResult: autoCheckUpdateResult.get(), // in this form, we get the same type of the serialized json
135
alerts: alerts.get(),
136
proxyDetected: getProxyDetected(),
src/config.ts
+6
-4
@@ -43,11 +43,13 @@ export const versionToScalar = _.memoize((ver: string) => { // memoize so we don
43
})
44
45
class Version extends String {
46
- olderThan(otherVersion: string) {
47
- return versionToScalar(this.valueOf()) < versionToScalar(otherVersion)
46
+ readonly scalar
47
+ constructor(v: string) {
48
+ super(v)
49
+ this.scalar = versionToScalar(v)
50
}
49
- getScalar() {
50
- return versionToScalar(this.valueOf())
51
+ olderThan(otherVersion: string) {
52
+ return this.scalar < versionToScalar(otherVersion)
53
}
54
}
55
src/cross-const.ts
+1
@@ -10,6 +10,7 @@ export const PLUGIN_CUSTOM_REST_PREFIX = '_'
10
export const HFS_REPO = 'rejetto/hfs'
11
export const UPLOAD_RESUMABLE = 'upload.resumable'
12
export const UPLOAD_STATUS = 'upload.status'
13
+export const PREVIOUS_TAG = 'previous'
14
15
export const HTTP_OK = 200
16
export const HTTP_NO_CONTENT = 204
src/update.ts
+25
-18
@@ -1,14 +1,13 @@
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 { apiGithubPaginated, getProjectInfo, getRepoInfo } from './github'
4
-import { ARGS_FILE, HFS_REPO, IS_BINARY, IS_WINDOWS, RUNNING_BETA } from './const'
4
+import { ARGS_FILE, HFS_REPO, IS_BINARY, IS_WINDOWS, PREVIOUS_TAG, RUNNING_BETA } from './const'
5
import { dirname, join } from 'path'
6
import { spawn, spawnSync } from 'child_process'
7
-import { DAY, exists, debounceAsync, httpStream, unzip, prefix, xlate, HOUR } from './misc'
7
+import { DAY, exists, debounceAsync, unzip, prefix, xlate, HOUR, httpWithBody } from './misc'
8
import { createReadStream, existsSync, renameSync, unlinkSync, writeFileSync } from 'fs'
9
import { pluginsWatcher } from './plugins'
10
-import { chmod, stat } from 'fs/promises'
11
-import { Readable } from 'stream'
10
+import { chmod, rename, stat, writeFile } from 'fs/promises'
11
import open from 'open'
12
import { currentVersion, defineConfig, versionToScalar } from './config'
13
import { cmdEscape, RUNNING_AS_SERVICE } from './util-os'
@@ -56,7 +55,7 @@ export type Release = { // not using interface, as it will not work with kvstora
55
const ReleaseKeys = ['prerelease', 'tag_name', 'name', 'body', 'assets', 'isNewer', 'versionScalar'] satisfies (keyof Release)[]
56
const ReleaseAssetKeys = ['name', 'browser_download_url'] satisfies (keyof Release['assets'][0])[]
57
59
-const curV = currentVersion.getScalar()
58
+const curV = currentVersion.scalar
59
function prepareRelease(r: Release) {
60
const v = versionToScalar(r.name)
61
return Object.assign(_.pick(r, ReleaseKeys), { // prune a bit, as it will be serialized and it has a lot of unused data
@@ -83,28 +82,34 @@ export async function getUpdates(strict=false) {
82
getProjectInfo() // check for alerts
83
const stable: Release = prepareRelease(await getRepoInfo(HFS_REPO + '/releases/latest'))
84
const res = await getVersions(r => r.versionScalar < stable.versionScalar) // we don't consider betas before stable
86
- const ret = res.filter(x => x.prerelease && (strict ? x.isNewer : (x.versionScalar !== currentVersion.getScalar())) )
85
+ const ret = res.filter(x => x.prerelease && (strict ? x.isNewer : (x.versionScalar !== curV)) )
86
if (stable.isNewer || RUNNING_BETA)
87
ret.push(stable)
88
return ret
89
}
90
91
const LOCAL_UPDATE = 'hfs-update.zip' // update from file takes precedence over net
92
+const INSTALLED_FN = 'hfs-installed.zip'
93
+const PREVIOUS_FN = 'hfs-previous.zip'
94
95
export function localUpdateAvailable() {
96
return exists(LOCAL_UPDATE)
97
}
98
99
+export function previousAvailable() {
100
+ return exists(PREVIOUS_FN)
101
+}
102
+
103
export async function updateSupported() {
104
return process.env.DISABLE_UPDATE ? false : (argv.forceupdate || IS_BINARY && !await RUNNING_AS_SERVICE)
105
}
106
107
export async function update(tagOrUrl: string='') {
108
if (!await updateSupported()) throw "only binary versions supports automatic update for now"
104
- let doingLocal = ''
105
- let updateSource: Readable | false = tagOrUrl.includes('://') ? await httpStream(tagOrUrl)
106
- : await localUpdateAvailable() && createReadStream(doingLocal=LOCAL_UPDATE)
107
- if (!updateSource) {
109
+ let url = tagOrUrl.includes('://') && tagOrUrl
110
+ if (tagOrUrl === PREVIOUS_TAG)
111
+ await rename(PREVIOUS_FN, LOCAL_UPDATE)
112
+ else if (tagOrUrl && !url) {
113
if (/^\d/.test(tagOrUrl)) // work even if the tag is passed without the initial 'v' (useful for console commands)
114
tagOrUrl = 'v' + tagOrUrl
115
const update = !tagOrUrl ? (await getUpdates(true))[0]
@@ -121,11 +126,15 @@ export async function update(tagOrUrl: string='') {
126
|| update.assets.find((x: any) => x.name.endsWith(legacyAssetSearch))
127
if (!asset)
128
throw "asset not found"
124
- const url = asset.browser_download_url
129
+ url = asset.browser_download_url
130
+ }
131
+ if (url) {
132
console.log("downloading", url)
126
- updateSource = await httpStream(url)
133
+ const { body } = await httpWithBody(url)
134
+ if (!body)
135
+ throw "download failed for " + url
136
+ await writeFile(LOCAL_UPDATE, body)
137
}
128
-
138
const bin = process.execPath
139
const binPath = dirname(bin)
140
const binFile = 'hfs' + (IS_WINDOWS ? '.exe' : '') // currently running bin could have been renamed
@@ -134,24 +143,22 @@ export async function update(tagOrUrl: string='') {
143
while (existsSync(join(binPath, newBinFile)))
144
pluginsWatcher.pause()
145
try {
137
- await unzip(updateSource, path =>
146
+ await unzip(createReadStream(LOCAL_UPDATE), path =>
147
join(binPath, path === binFile ? newBinFile : path))
148
const newBin = join(binPath, newBinFile)
149
if (!IS_WINDOWS) {
150
const { mode } = await stat(bin)
151
await chmod(newBin, mode).catch(console.error)
152
}
153
+ await rename(INSTALLED_FN, PREVIOUS_FN).catch(console.warn)
154
+ await rename(LOCAL_UPDATE, INSTALLED_FN).catch(console.warn)
155
onProcessExit(() => {
156
const oldBinFile = 'old-' + binFile
146
- console.log("old version is kept as", oldBinFile)
157
const oldBin = join(binPath, oldBinFile)
158
try { unlinkSync(oldBin) }
159
catch {}
160
renameSync(bin, oldBin)
161
console.log("launching new version in background", newBinFile)
152
- if (doingLocal)
153
- try { renameSync(doingLocal, 'old-' + doingLocal) }
154
- catch(e) { console.warn(e) }
162
launch(newBin, ['--updating', binFile, '--cwd .'], { sync: true }) // sync necessary to work on Mac by double-click
163
})
164
console.log("quitting")