main
ts 238 lines 11.2 KB
Raw
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, IS_MAC, PREVIOUS_TAG, RUNNING_BETA } from './const'
5 import { dirname, join } from 'path'
6 import { spawn, spawnSync } from 'child_process'
7 import {
8 DAY, exists, unzip, prefix, xlate, HOUR, httpStream, statWithTimeout, repeat, debounceAsync, formatPerc, retrySync
9 } from './misc'
10 import { createReadStream, createWriteStream, existsSync, renameSync, unlinkSync, writeFileSync } from 'fs'
11 import { pluginsWatcher } from './plugins'
12 import { chmod, rename, rm } from 'fs/promises'
13 import open from 'open'
14 import { configReady, currentVersion, defineConfig, versionToScalar } from './config'
15 import { cmdEscape, runningAsWindowsService } from './util-os'
16 import { onProcessExit, quit } from './first'
17 import { storedMap } from './persistence'
18 import _ from 'lodash'
19 import { argv } from './argv'
20 import { pipeline } from 'stream/promises'
21
22 const updateToBeta = defineConfig('update_to_beta', false)
23 const autoCheckUpdate = defineConfig('auto_check_update', true)
24 const lastCheckUpdate = storedMap.singleSync<number>('lastCheckUpdate', 0)
25 const AUTO_CHECK_EVERY = DAY
26
27 export const autoCheckUpdateResult = storedMap.singleSync<Release | undefined>('autoCheckUpdateResult', undefined)
28 autoCheckUpdateResult.ready().then(() => {
29 autoCheckUpdateResult.set(v => {
30 if (!v) return // refresh isNewer, as currentVersion may have changed
31 v.isNewer = currentVersion.olderThan(v.tag_name)
32 return v
33 })
34 })
35 configReady.then(lastCheckUpdate.ready).then(() => repeat(HOUR, () => {
36 if (autoCheckUpdate.get() && Date.now() > lastCheckUpdate.get() + AUTO_CHECK_EVERY)
37 return checkForUpdates()
38 }))
39
40 export const checkForUpdates = debounceAsync(async () => {
41 try {
42 const u = await getBestUpdate()
43 if (u) console.log("New version available", u.name)
44 autoCheckUpdateResult.set(u)
45 lastCheckUpdate.set(Date.now())
46 }
47 catch {}
48 }, { reuseRunning: true })
49
50 export async function getBestUpdate() {
51 return (await getUpdates(true))[0]
52 }
53
54 export type Release = { // not using interface, as it will not work with kvstorage.Jsonable
55 prerelease: boolean,
56 tag_name: string,
57 name: string,
58 body: string,
59 assets: { name: string, browser_download_url: string }[],
60 // fields introduced by us
61 isNewer: boolean
62 versionScalar: number
63 }
64 const ReleaseKeys = ['prerelease', 'tag_name', 'name', 'body', 'assets', 'isNewer', 'versionScalar'] satisfies (keyof Release)[]
65 const ReleaseAssetKeys = ['name', 'browser_download_url'] satisfies (keyof Release['assets'][0])[]
66
67 const curV = currentVersion.scalar
68 function prepareRelease(r: Release) {
69 const v = versionToScalar(r.name)
70 return Object.assign(_.pick(r, ReleaseKeys), { // prune a bit, as it will be serialized, and it has a lot of unused data
71 versionScalar: v,
72 isNewer: v > curV, // make easy to know what's newer
73 assets: r.assets.map((a: any) => _.pick(a, ReleaseAssetKeys))
74 })
75 }
76
77 export async function getVersions(filter?: (r: Release) => boolean, max=30) {
78 const ret: Release[] = []
79 for await (const x of apiGithubPaginated(`repos/${HFS_REPO}/releases`)) {
80 if (x.name.endsWith('-ignore')) continue
81 const rel = prepareRelease(x)
82 if (rel.versionScalar === curV) continue
83 if (!filter || filter(rel))
84 ret.push(rel)
85 if (ret.length >= max) break
86 }
87 return _.sortBy(ret, x => -x.versionScalar)
88 }
89
90 export async function getUpdates(strict=false) {
91 console.log("Checking for updates")
92 void getProjectInfo() // also check for alerts and print them asap in the console
93 const stable: Release = prepareRelease(await getRepoInfo(HFS_REPO + '/releases/latest'))
94 const includeBetas = updateToBeta.get() || RUNNING_BETA
95 // we don't consider betas before stable
96 const betas = !includeBetas ? [] : await getVersions(x => x.prerelease && x.versionScalar > stable.versionScalar && (!strict || x.isNewer))
97 if (stable.isNewer || RUNNING_BETA && !strict)
98 betas.push(stable)
99 return betas
100 }
101
102 const LOCAL_UPDATE = 'hfs-update.zip' // update from file takes precedence over net
103 const INSTALLED_FN = 'hfs-installed.zip'
104 const PREVIOUS_FN = 'hfs-previous.zip'
105
106 export function localUpdateAvailable() {
107 return exists(LOCAL_UPDATE)
108 }
109
110 export function previousAvailable() {
111 return exists(PREVIOUS_FN)
112 }
113
114 export function updateSupported() {
115 return !process.env.DISABLE_UPDATE && (argv.forceupdate || IS_BINARY)
116 }
117
118 export async function update(tagOrUrl: string='') {
119 if (!updateSupported())
120 throw process.env.DISABLE_UPDATE ? "Automatic updates are disabled"
121 : "Only binary versions support automatic updates"
122 let url = tagOrUrl.includes('://') && tagOrUrl
123 if (tagOrUrl === PREVIOUS_TAG)
124 await rename(PREVIOUS_FN, LOCAL_UPDATE)
125 else if (tagOrUrl ? !url : !await localUpdateAvailable()) {
126 if (/^\d/.test(tagOrUrl)) // work even if the tag is passed without the initial 'v' (useful for console commands)
127 tagOrUrl = 'v' + tagOrUrl
128 const update = !tagOrUrl ? await getBestUpdate()
129 : await getRepoInfo(HFS_REPO + '/releases/tags/' + tagOrUrl).catch(e => {
130 if (e.message === '404') console.error("Version not found")
131 else throw e
132 }) as Release | undefined
133 if (!update)
134 throw "No update has been found"
135 const plat = '-' + xlate(process.platform, { win32: 'windows', darwin: 'mac' })
136 const assetSearch = `${plat}-${process.arch}`
137 const asset = update.assets.find((x: any) => x.name.includes(assetSearch) && x.name.endsWith('.zip'))
138 if (!asset)
139 throw `Asset not found: ${assetSearch}`
140 url = asset.browser_download_url
141 }
142 if (url) {
143 console.log("Downloading", url)
144 const temp = LOCAL_UPDATE + '-temp'
145 await rm(temp, { force: true })
146 try {
147 const stream = await httpStream(url)
148 const total = Number(stream.headers['content-length']) || 0
149 let downloadedSize = 0
150 const progress = total && setInterval(() => console.log("Download progress", formatPerc(downloadedSize / total)), 5_000)
151 stream.on('data', chunk => downloadedSize += chunk.length)
152 await pipeline(stream, createWriteStream(temp))
153 .finally(() => clearInterval(progress))
154 }
155 catch(e: any) {
156 await rm(temp).catch(() => {}) // no leftovers
157 throw "Download failed for " + url + prefix('', e?.message)
158 }
159 await rename(temp, LOCAL_UPDATE)
160 console.debug("Download finished")
161 }
162 const bin = process.execPath
163 const binPath = dirname(bin)
164 const binFile = 'hfs' + (IS_WINDOWS ? '.exe' : '') // the bin we are currently running could have been renamed
165 let newBinFile = binFile
166 do { newBinFile = 'new-' + newBinFile }
167 while (existsSync(join(binPath, newBinFile)))
168 pluginsWatcher.pause()
169 try {
170 await unzip(createReadStream(LOCAL_UPDATE), path =>
171 join(binPath, path === binFile ? newBinFile : path))
172 const newBin = join(binPath, newBinFile)
173 if (!existsSync(newBin)) {
174 if (url) // the file was downloaded, and the UI would show the "update from local file" button until we remove it
175 await rm(LOCAL_UPDATE).catch(e => console.warn(String(e)))
176 throw "Missing executable in the archive"
177 }
178 if (!IS_WINDOWS) {
179 const { mode } = await statWithTimeout(bin)
180 await chmod(newBin, mode).catch(console.error)
181 }
182 await rename(INSTALLED_FN, PREVIOUS_FN).catch(e => e?.code !== 'ENOENT' && console.warn(String(e)))
183 await rename(LOCAL_UPDATE, INSTALLED_FN).catch(e => console.warn(String(e)))
184 // the bridge process exists to preserve terminal access; skip it when there's no terminal
185 const preserveTerminal = process.stdin.isTTY && !await runningAsWindowsService // NSSM fakes a TTY, so we need explicit detection on Windows
186 onProcessExit(() => {
187 const oldBinFile = 'old-' + binFile
188 const oldBin = join(binPath, oldBinFile)
189 try { unlinkSync(oldBin) }
190 catch {}
191 renameSync(bin, oldBin)
192 if (!preserveTerminal) {
193 try { retrySync(() => renameSync(newBin, join(binPath, binFile))) }
194 catch (e) {
195 try { renameSync(oldBin, bin) } // restore the service target because hfs.exe was already moved aside
196 catch (rollbackError) { console.error("Couldn't restore original binary after failed update", rollbackError) }
197 throw e
198 }
199 console.log("Updated binary in place, exiting for process supervisor to restart")
200 return
201 }
202 // preserving the terminal requires a longer trip
203 console.log("Launching new version in background", newBinFile)
204 spawnSync(cmdEscape(newBin), ['--updating', binFile, '--cwd .'], { shell: true, stdio: [0,1,2] }) // sync necessary to work on Mac by double-click
205 })
206 console.log("Quitting")
207 setTimeout(() => quit(100), // non-zero, otherwise some service managers (like Shawl) won't restart the process.
208 200) // give time to return (and caller to complete, eg: rest api to reply)
209 }
210 catch (e: any) {
211 pluginsWatcher.unpause()
212 throw e?.message || String(e)
213 }
214 }
215
216 if (argv.updating) { // we were launched with a temporary name, restore original name to avoid breaking references
217 const bin = process.execPath
218 const dest = join(dirname(bin), argv.updating)
219 renameSync(bin, dest)
220 // have to relaunch with the new name, or otherwise the next update will fail with EBUSY on hfs.exe
221 console.log(`Renamed binary file to "${argv.updating}" and now restarting`)
222 // if you change anything, be sure to test launching both double-clicking and in a terminal
223 if (IS_WINDOWS) // windows-only; this method on mac+linux works only once, and without the console
224 onProcessExit(() =>
225 spawn(cmdEscape(dest), ['--updated', '--cwd .'], { detached: true, shell: true, stdio: [0,1,2] }) ) // launch+sync here would cause the old process to stay open, locking ports
226 else if (IS_MAC) {
227 // open() is the only consistent way that I could find working on macos preserving console input/output over relaunching,
228 // and it doesn't let us pass cli arguments, so we pass them through a temp file consumed at the next startup.
229 // For the record, on mac you can: write "./hfs arg1 arg2" to /tmp/tmp.sh with 0o700, and then spawn "open -a Terminal /tmp/tmp.sh"
230 try { writeFileSync(ARGS_FILE, JSON.stringify(['--updated', '--cwd', process.cwd()])) }
231 catch {}
232 console.log('Open-ing')
233 void open(dest)
234 }
235 else // linux and *nix on terminal: in interactive terminals, block this bridge process on the restarted hfs so the terminal session stays attached
236 spawnSync(dest, ['--updated', '--cwd', process.cwd()], { stdio: [0, 1, 2] })
237 process.exit()
238 }