| 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 | } |