@samitouri / QOSami-HFS / commits / f18c9876

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")