From 98a195ad182e92e932751e3d72525eb4c2fe5d73 Mon Sep 17 00:00:00 2001 From: Gabriel Donadel Date: Mon, 26 Feb 2024 11:00:38 -0300 Subject: [PATCH] [menu-bar][electron] Add auto-updater implementation --- .../modules/auto-updater/electron/Updater.ts | 251 ++++++++++++++++++ .../auto-updater/electron/platform/Linux.ts | 135 ++++++++++ .../electron/platform/Platform.ts | 33 +++ .../auto-updater/electron/platform/Windows.ts | 70 +++++ .../auto-updater/electron/platform/index.ts | 31 +++ .../auto-updater/electron/screens/global.css | 196 ++++++++++++++ .../auto-updater/electron/screens/preload.js | 10 + .../electron/screens/update_available.html | 64 +++++ .../auto-updater/electron/utils/HttpClient.ts | 40 +++ .../auto-updater/electron/utils/Logger.ts | 35 +++ .../auto-updater/electron/utils/file.ts | 26 ++ .../auto-updater/electron/utils/meta.ts | 54 ++++ .../auto-updater/electron/utils/options.ts | 111 ++++++++ .../auto-updater/electron/utils/quit.ts | 9 + 14 files changed, 1065 insertions(+) create mode 100644 apps/menu-bar/modules/auto-updater/electron/Updater.ts create mode 100644 apps/menu-bar/modules/auto-updater/electron/platform/Linux.ts create mode 100644 apps/menu-bar/modules/auto-updater/electron/platform/Platform.ts create mode 100644 apps/menu-bar/modules/auto-updater/electron/platform/Windows.ts create mode 100644 apps/menu-bar/modules/auto-updater/electron/platform/index.ts create mode 100644 apps/menu-bar/modules/auto-updater/electron/screens/global.css create mode 100644 apps/menu-bar/modules/auto-updater/electron/screens/preload.js create mode 100644 apps/menu-bar/modules/auto-updater/electron/screens/update_available.html create mode 100644 apps/menu-bar/modules/auto-updater/electron/utils/HttpClient.ts create mode 100644 apps/menu-bar/modules/auto-updater/electron/utils/Logger.ts create mode 100644 apps/menu-bar/modules/auto-updater/electron/utils/file.ts create mode 100644 apps/menu-bar/modules/auto-updater/electron/utils/meta.ts create mode 100644 apps/menu-bar/modules/auto-updater/electron/utils/options.ts create mode 100644 apps/menu-bar/modules/auto-updater/electron/utils/quit.ts diff --git a/apps/menu-bar/modules/auto-updater/electron/Updater.ts b/apps/menu-bar/modules/auto-updater/electron/Updater.ts new file mode 100644 index 00000000..d9302b40 --- /dev/null +++ b/apps/menu-bar/modules/auto-updater/electron/Updater.ts @@ -0,0 +1,251 @@ +import { BrowserWindow, ipcMain, app, autoUpdater, dialog } from 'electron'; +import { EventEmitter } from 'events'; +import path from 'path'; + +import { createPlatform } from './platform'; +import Platform from './platform/Platform'; +import HttpClient from './utils/HttpClient'; +import Logger from './utils/Logger'; +import { + BuildInfo, + VersionMeta, + extractBuildInfoFromMeta, + getNewerVersion, + getUpdatesMeta, +} from './utils/meta'; +import { Options, getOptions } from './utils/options'; + +// Based on https://github.com/megahertz/electron-simple-updater +export default class Updater extends EventEmitter { + options: Options; + logger: Logger; + meta?: VersionMeta; + buildInfo?: BuildInfo; + httpClient: HttpClient; + platform: Platform; + + constructor() { + super(); + + this.init = this.init.bind(this); + this.checkForUpdates = this.checkForUpdates.bind(this); + this.downloadUpdate = this.downloadUpdate.bind(this); + this.quitAndInstall = this.quitAndInstall.bind(this); + this.setOptions = this.setOptions.bind(this); + this.getFeedURL = this.getFeedURL.bind(this); + + this.options = getOptions(); + + this.logger = new Logger(this.options); + + this.httpClient = new HttpClient(this.options); + + this.platform = createPlatform( + this.options, + this.logger, + this.emit.bind(this), + this.httpClient + ); + + autoUpdater.on('update-downloaded', () => { + const version = this.meta?.version; + this.logger.info(`New version ${version} has been downloaded`); + this.emit('update-downloaded', this.meta); + }); + + this.on('error', this.logger.warn); + autoUpdater.on('error', (e) => this.emit('error', e)); + } + + /** + * Initialize updater module + * @param {Partial | string} options + * @return {this} + */ + init(options: Partial = {}): this { + if (options.logger) { + this.options.setOptions('logger', options.logger); + } + + if (!app.isPackaged) { + this.logger.info('Update is disabled because the app is not packaged'); + return this; + } + + if (!this.options.initialize(options, this.logger)) { + this.logger.warn('Update is disabled because of wrong configuration'); + } + + this.platform.init(); + + if (this.options.checkUpdateOnStart) { + this.checkForUpdates({ silent: true }); + } + + return this; + } + + /** + * Asks the server whether there is an update. url must be set before + */ + checkForUpdates({ silent }: { silent?: boolean } = {}): this { + const opt = this.options; + if (!opt.url) { + this.emit('error', 'You must set url before calling checkForUpdates()'); + return this; + } + + this.emit('checking-for-update'); + getUpdatesMeta(this.httpClient, opt.url) + .then((updates) => { + const update = getNewerVersion(updates, opt.version); + if (!update) { + if (!silent) { + dialog.showMessageBox({ + message: 'You are up to date!', + detail: `${app.getName()} is already running the newest version available. (You are currently running version ${ + opt.version + }.)`, + }); + } + return; + } + + this.onFoundUpdate(update); + }) + .catch((e) => { + this.emit('update-not-available'); + this.emit('error', e); + }); + + return this; + } + + /** + * Start downloading update manually. + * You can use this method if autoDownload option is set to false + * @return {this} + */ + downloadUpdate(): this { + if (!this.buildInfo?.url) { + this.emit('error', 'No metadata for update. Run checkForUpdates first.'); + return this; + } + + this.emit('update-downloading', this.buildInfo); + this.logger.info(`Downloading updates from ${this.buildInfo.url}`); + + this.platform.downloadUpdate(this.buildInfo); + + return this; + } + + /** + * Restarts the app and installs the update after it has been downloaded. + * It should only be called after update-downloaded has been emitted. + * @return {void} + */ + quitAndInstall(): void { + this.platform.quitAndInstall(); + } + + setOptions( + name: keyof Options | Partial, + value: Options[keyof Options] = undefined + ): this { + this.options.setOptions(name, value); + return this; + } + + get build() { + if (!this.checkIsInitialized()) return; + return this.options.build; + } + + /** + * Return a build name with version + * @return {string} + */ + get buildId(): string { + if (!this.checkIsInitialized()) return ''; + return `${this.build}-v${this.version}`; + } + + get version() { + if (!this.checkIsInitialized()) return; + return this.options.version; + } + + /** + * Return the current updates.json URL + * @return {string} + */ + getFeedURL(): string { + if (!this.checkIsInitialized()) return ''; + return this.options.url; + } + + /** + * Called when updates metadata has been downloaded + */ + onFoundUpdate(meta: VersionMeta) { + this.meta = meta; + + const buildInfo = extractBuildInfoFromMeta(meta, this.options.build); + if (!buildInfo) { + this.logger.debug(`Update ${meta.version} for ${this.buildId} is not available`); + return; + } + this.buildInfo = buildInfo; + + this.logger.debug(`Found version ${meta.version} at ${buildInfo.url}`); + this.emit('update-available', meta); + + // Create window with update information + const updateWindow = new BrowserWindow({ + width: 520, + height: 400, + webPreferences: { + preload: path.join(__dirname, '../../../modules/auto-updater/electron/screens/preload.js'), + }, + }); + updateWindow + .loadFile('../modules/auto-updater/electron/screens/update_available.html') + .then(async () => { + updateWindow.webContents.send('autoUpdater:sendInfo', { + appName: app.getName(), + newVersion: meta.version, + releaseNotes: meta.release_notes, + currentVersion: this.options.version, + }); + updateWindow.show(); + }); + + ipcMain.handle('autoUpdater:skipVersion', () => { + // skip version + updateWindow.close(); + }); + + ipcMain.handle('autoUpdater:installUpdate', () => { + this.downloadUpdate(); + updateWindow.close(); + }); + + if (this.options.autoDownload) { + this.downloadUpdate(); + } + } + + /** + * @return {boolean} + * @private + */ + checkIsInitialized(): boolean { + if (!this.options.isInitialized) { + this.emit('error', new Error('Not initialized')); + return false; + } + + return true; + } +} diff --git a/apps/menu-bar/modules/auto-updater/electron/platform/Linux.ts b/apps/menu-bar/modules/auto-updater/electron/platform/Linux.ts new file mode 100644 index 00000000..6f601f60 --- /dev/null +++ b/apps/menu-bar/modules/auto-updater/electron/platform/Linux.ts @@ -0,0 +1,135 @@ +import { spawn } from 'child_process'; +import { app } from 'electron'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { v4 as uuidv4 } from 'uuid'; + +import Platform from './Platform'; +import HttpClient from '../utils/HttpClient'; +import Logger from '../utils/Logger'; +import { calcSha256Hash } from '../utils/file'; +import { BuildInfo } from '../utils/meta'; +import { Options } from '../utils/options'; +import { quit } from '../utils/quit'; + +export default class Linux extends Platform { + lastUpdatePath: string | null; + meta: any; + + constructor(options: Options, logger: Logger, emit: any, httpClient: HttpClient) { + super(options, logger, emit, httpClient); + + this.quitAndInstall = this.quitAndInstall.bind(this); + this.lastUpdatePath = null; + } + + downloadUpdate(buildInfo: BuildInfo) { + this.downloadUpdateFile(buildInfo) + .then(() => { + this.logger.info(`New version has been downloaded from ${buildInfo.url} `); + this.emit('update-downloaded', this.meta); + }) + .catch((e) => this.emit('error', e)); + } + + /** + * @param {boolean} restartRequired + */ + quitAndInstall(restartRequired = true) { + if (!this.lastUpdatePath) { + return; + } + + app.off('will-quit', this.quitAndInstall); + + const updateScript = ` + if [ "\${RESTART_REQUIRED}" = 'true' ]; then + cp -f "\${UPDATE_FILE}" "\${APP_IMAGE}" + (exec "\${APP_IMAGE}") & disown $! + else + (sleep 2 && cp -f "\${UPDATE_FILE}" "\${APP_IMAGE}") & disown $! + fi + kill "\${OLD_PID}" $(ps -h --ppid "\${OLD_PID}" -o pid) + rm "\${UPDATE_FILE}" + `; + + const proc = spawn('/bin/bash', ['-c', updateScript], { + detached: true, + stdio: 'ignore', + env: { + ...process.env, + APP_IMAGE: this.getAppImagePath(), + // @ts-ignore + OLD_PID: process.pid, + RESTART_REQUIRED: String(restartRequired), + UPDATE_FILE: this.lastUpdatePath, + }, + }); + // @ts-ignore + proc.unref(); + + if (restartRequired === true) { + quit(); + process.exit(); + } + } + + async downloadUpdateFile(buildInfo: BuildInfo) { + this.lastUpdatePath = this.getUpdatePath(buildInfo.sha256 || uuidv4()); + + if (!fs.existsSync(this.lastUpdatePath)) { + await this.httpClient.downloadFile(buildInfo.url, this.lastUpdatePath); + await setExecFlag(this.lastUpdatePath); + } + + if (buildInfo.sha256) { + try { + await this.checkHash(buildInfo.sha256, this.lastUpdatePath); + } catch (e) { + await fs.promises.unlink(this.lastUpdatePath); + throw e; + } + } + + app.on('will-quit', this.quitAndInstall); + + return this.lastUpdatePath; + } + + getAppImagePath() { + const appImagePath = process.env.APPIMAGE; + + if (!appImagePath) { + throw new Error('It seems that the app is not in AppImage format'); + } + + return appImagePath; + } + + getUpdatePath(id: string) { + const fileName = `${app.getName()}-${id}.AppImage`; + return path.join(os.tmpdir(), fileName); + } + + async checkHash(hash: string, filePath: string) { + const fileHash = await calcSha256Hash(filePath); + if (fileHash !== hash) { + throw new Error(`Update is corrupted. Expected hash: ${hash}, actual: ${fileHash}`); + } + } +} + +async function setExecFlag(filePath: string) { + return new Promise((resolve, reject) => { + fs.access(filePath, fs.constants.X_OK, (err) => { + if (!err) { + return resolve(filePath); + } + + fs.chmod(filePath, '0755', (e) => { + e ? reject(`Cannot chmod of ${filePath}`) : resolve(filePath); + }); + }); + }); +} diff --git a/apps/menu-bar/modules/auto-updater/electron/platform/Platform.ts b/apps/menu-bar/modules/auto-updater/electron/platform/Platform.ts new file mode 100644 index 00000000..6300a470 --- /dev/null +++ b/apps/menu-bar/modules/auto-updater/electron/platform/Platform.ts @@ -0,0 +1,33 @@ +import { autoUpdater } from 'electron'; + +import HttpClient from '../utils/HttpClient'; +import Logger from '../utils/Logger'; +import { BuildInfo } from '../utils/meta'; +import { Options } from '../utils/options'; + +export default class Platform { + emit: any; + options: Options; + logger: Logger; + httpClient: HttpClient; + + constructor(options: Options, logger: Logger, emit: Function, httpClient: HttpClient) { + this.emit = emit; + this.options = options; + this.logger = logger; + this.httpClient = httpClient; + } + + init() { + // Empty by default + } + + downloadUpdate(buildInfo: BuildInfo) { + autoUpdater.setFeedURL({ url: buildInfo.url }); + autoUpdater.checkForUpdates(); + } + + quitAndInstall() { + autoUpdater.quitAndInstall(); + } +} diff --git a/apps/menu-bar/modules/auto-updater/electron/platform/Windows.ts b/apps/menu-bar/modules/auto-updater/electron/platform/Windows.ts new file mode 100644 index 00000000..ab9fe1c4 --- /dev/null +++ b/apps/menu-bar/modules/auto-updater/electron/platform/Windows.ts @@ -0,0 +1,70 @@ +import { spawn } from 'child_process'; +import path from 'path'; + +import Platform from './Platform'; +import { quit } from '../utils/quit'; + +const SQUIRREL_INSTALL = 'squirrel-install'; +const SQUIRREL_UPDATED = 'squirrel-updated'; +const SQUIRREL_UNINSTALL = 'squirrel-uninstall'; +const SQUIRREL_OBSOLETE = 'squirrel-obsolete'; + +const SQUIRREL_ACTIONS = [ + SQUIRREL_INSTALL, + SQUIRREL_UPDATED, + SQUIRREL_UNINSTALL, + SQUIRREL_OBSOLETE, +] as const; + +export default class Windows extends Platform { + init() { + const squirrelAction = this.getSquirrelInstallerAction(); + if (!squirrelAction) { + return; + } + + const event = { squirrelAction, preventDefault: false }; + this.emit('squirrel-win-installer', event); + + if (!event.preventDefault) { + processSquirrelInstaller(squirrelAction); + process.exit(); + } + } + + getSquirrelInstallerAction( + argv1 = process.argv[1] + ): (typeof SQUIRREL_ACTIONS)[number] | undefined { + const handledArguments = SQUIRREL_ACTIONS.map((act) => `--${act}`); + const actionIndex = handledArguments.indexOf(argv1); + return actionIndex > -1 ? SQUIRREL_ACTIONS[actionIndex] : undefined; + } +} + +function processSquirrelInstaller(action: string) { + const execPath = path.basename(process.execPath); + + switch (action) { + case SQUIRREL_INSTALL: + case SQUIRREL_UPDATED: { + run([`--createShortcut=${execPath}`], quit); + return true; + } + case SQUIRREL_UNINSTALL: { + run([`--removeShortcut=${execPath}`], quit); + return false; + } + case SQUIRREL_OBSOLETE: { + quit(); + return false; + } + default: { + return false; + } + } +} + +function run(args: string[], done: () => void) { + const updateExe = path.resolve(path.dirname(process.execPath), '../Update.exe'); + spawn(updateExe, args, { detached: true }).on('close', done); +} diff --git a/apps/menu-bar/modules/auto-updater/electron/platform/index.ts b/apps/menu-bar/modules/auto-updater/electron/platform/index.ts new file mode 100644 index 00000000..a92a50b6 --- /dev/null +++ b/apps/menu-bar/modules/auto-updater/electron/platform/index.ts @@ -0,0 +1,31 @@ +import Linux from './Linux'; +import Platform from './Platform'; +import Windows from './Windows'; +import HttpClient from '../utils/HttpClient'; +import Logger from '../utils/Logger'; +import { Options } from '../utils/options'; + +/** + * @param {Options} options + * @param {Logger} logger + * @param {Function} emit + * @param {HttpClient} httpClient + * @param {string} platform + * @return {Platform} + */ +export function createPlatform( + options: Options, + logger: Logger, + emit: Function, + httpClient: HttpClient, + platform: NodeJS.Platform = process.platform +): Platform { + switch (platform) { + case 'darwin': + return new Platform(options, logger, emit, httpClient); + case 'win32': + return new Windows(options, logger, emit, httpClient); + default: + return new Linux(options, logger, emit, httpClient); + } +} diff --git a/apps/menu-bar/modules/auto-updater/electron/screens/global.css b/apps/menu-bar/modules/auto-updater/electron/screens/global.css new file mode 100644 index 00000000..0a683d59 --- /dev/null +++ b/apps/menu-bar/modules/auto-updater/electron/screens/global.css @@ -0,0 +1,196 @@ +body { + background-color: var(--orbit-window-background); + font-family: + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + 'Helvetica Neue', + Arial, + sans-serif; + margin: 0; +} + +#root { + margin: 12px; + height: calc(100vh - 24px); + flex-direction: column; + display: flex; + flex: 1; +} + +#release-notes h3 { + font-size: 1rem; + margin-top: 0.1rem; +} + +#release-notes a { + color: var(--link-color); +} + +#release-notes li { + font-size: 0.8rem; +} + +button { + background-color: var(--control-color); + color: var(--text-color); + border: none; + padding: 0.2rem 0.8rem; + border-radius: 4px; + cursor: pointer; + font-size: 0.75rem; + box-shadow: 0 0 0px 1px #8d8d8d17; +} + +.primary-button { + background-color: var(--control-accent-color); + color: var(--selected-menu-item-text-color); +} + +#release-notes { + overflow: scroll; + flex: 1; + border: 1px solid #000; + padding: 10px; + margin: 10px 0; +} + +#title { + font-size: 0.85rem; + margin-top: 0; +} + +#description { + margin-bottom: 0.7rem; +} + +p, +b { + font-size: 0.75rem; + margin: 0; +} + +input, +h1, +h2, +h3, +p, +b, +ul { + color: var(--text-color); +} + +:not(input):not(textarea), +:not(input):not(textarea)::after, +:not(input):not(textarea)::before { + -webkit-user-select: none; + user-select: none; +} + +@media (prefers-color-scheme: light) { + /* Platform Color based on macOS Semantic Colors*/ + :root { + --label-color: #242424; + --secondary-label-color: #000000; + --tertiary-label-color: #000000; + --quaternary-label-color: #000000; + --link-color: #084fd1; + --placeholder-text-color: #000000; + --window-frame-text-color: #000000; + --selected-menu-item-text-color: #fffefe; + --alternate-selected-control-text-color: #fffefe; + --header-text-color: #000000; + --separator-color: #000000; + --grid-color: #e6e6e6; + --window-background-color: #e7e7e7; + --under-page-background-color: #838383; + --control-background-color: #fffefe; + --selected-content-background: #064ad9; + --unemphasized-selected-content-background-color: #d3d3d3; + --find-highlight-color: #ffff0a; + --alternating-content-background-color: #f1f2f2; + --text-color: #000000; + --text-background-color: #fffefe; + --selected-text-color: #000000; + --selected-text-background-color: #b3d6ff; + --unemphasized-selected-text-background-color: #d3d3d3; + --unemphasized-selected-text-color: #000000; + --control-color: #ffffff; + --control-text-color: #000000; + --selected-control-color: #b3d6ff; + --selected-control-text-color: #000000; + --disabled-control-text-color: #000000; + --keyboard-focus-indicator-color: #83a9f1; + --control-accent-color: #007bff; + --highlight-color: #fffefe; + --shadow-color: #000000; + + --popover-background: #e6e7e7; + --orbit-window-background: #eaecec; + } + + #release-notes { + background-color: var(--highlight-color); + } +} + +@media (prefers-color-scheme: dark) { + /* Platform Color based on macOS Semantic Colors*/ + :root { + --label-color: #dedfdf; + --secondary-label-color: #9d9e9e; + --tertiary-label-color: #5c5d5d; + --quaternary-label-color: #3b3d3e; + --link-color: #3486fe; + --placeholder-text-color: #5c5d5e; + --window-frame-text-color: #fffefe; + --selected-menu-item-text-color: #fffefe; + --alternate-selected-control-text-color: #fffefe; + --header-text-color: #fffefe; + --separator-color: #3b3d3e; + --grid-color: #1a1a1a; + --window-background-color: #252525; + --under-page-background-color: #1d1d1d; + --control-background-color: #161616; + --selected-content-background: #064ad9; + --unemphasized-selected-content-background-color: #363636; + --find-highlight-color: #ffff0a; + --alternating-content-background-color: #fffefe; + --text-color: #ffffff; + --text-background-color: #1e1e1e; + --selected-text-color: #ffffff; + --selected-text-background-color: #3f638a; + --unemphasized-selected-text-background-color: #363636; + --unemphasized-selected-text-color: #ffffff; + --control-color: #5c5d5e; + --control-text-color: #fffefe; + --selected-control-color: #3f638a; + --selected-control-text-color: #fffefe; + --disabled-control-text-color: #fffefe; + --keyboard-focus-indicator-color: #286994; + --control-accent-color: #007aff; + --highlight-color: #a4a4a4; + --shadow-color: #000000; + + --popover-background: #212121; + --orbit-window-background: #252928; + } + + #release-notes { + background-color: var(--popover-background); + } +} + +:root { + --system-red-color: #fb2b2c; + --system-green-color: #30d33a; + --system-blue-color: #106afe; + --system-orange-color: #fc8d0d; + --system-yellow-color: #fecf0e; + --system-brown-color: #9b7b55; + --system-pink-color: #fb194c; + --system-purple-color: #4e45d8; + --system-gray-color: #85858b; +} diff --git a/apps/menu-bar/modules/auto-updater/electron/screens/preload.js b/apps/menu-bar/modules/auto-updater/electron/screens/preload.js new file mode 100644 index 00000000..cc21fc86 --- /dev/null +++ b/apps/menu-bar/modules/auto-updater/electron/screens/preload.js @@ -0,0 +1,10 @@ +// Preload (Isolated World) +const { contextBridge, ipcRenderer } = require('electron'); + +contextBridge.exposeInMainWorld('autoUpdater', { + skipVersion: () => ipcRenderer.invoke('autoUpdater:skipVersion'), + installUpdate: () => ipcRenderer.invoke('autoUpdater:installUpdate'), + receiveInfo: (info) => { + ipcRenderer.on('autoUpdater:sendInfo', info); + }, +}); diff --git a/apps/menu-bar/modules/auto-updater/electron/screens/update_available.html b/apps/menu-bar/modules/auto-updater/electron/screens/update_available.html new file mode 100644 index 00000000..d6d5ae70 --- /dev/null +++ b/apps/menu-bar/modules/auto-updater/electron/screens/update_available.html @@ -0,0 +1,64 @@ + + + + + + + + Software Update + + + + + + +
+

A new version of app is available!

+

app 1.0.0 is now available!--you + have + 1.0.0. Would you like to + download it now? +

+ Release Notes: +
+
+
+ + + +
+
+ + + + diff --git a/apps/menu-bar/modules/auto-updater/electron/utils/HttpClient.ts b/apps/menu-bar/modules/auto-updater/electron/utils/HttpClient.ts new file mode 100644 index 00000000..b1e4a0a5 --- /dev/null +++ b/apps/menu-bar/modules/auto-updater/electron/utils/HttpClient.ts @@ -0,0 +1,40 @@ +import axios, { AxiosRequestConfig } from 'axios'; +import fs from 'fs'; +import stream from 'stream'; +import util from 'util'; + +import { Options } from './options'; + +const pipeline = util.promisify(stream.pipeline); + +export default class HttpClient { + options: Options; + + constructor(options: Options) { + this.options = options; + } + + async getJson(url: string) { + const { data } = await axios.get(url, this.getHttpOptions()); + return data; + } + + async downloadFile(url: string, savePath: fs.PathLike) { + const { data: httpRequest } = await axios.get(url, { + ...this.getHttpOptions(), + responseType: 'stream', + }); + return pipeline(httpRequest, fs.createWriteStream(savePath)); + } + + getHttpOptions(): AxiosRequestConfig { + const options = this.options.http || {}; + return { + ...options, + headers: { + 'User-Agent': 'auto-updater 1.0', + ...options.headers, + }, + }; + } +} diff --git a/apps/menu-bar/modules/auto-updater/electron/utils/Logger.ts b/apps/menu-bar/modules/auto-updater/electron/utils/Logger.ts new file mode 100644 index 00000000..66725b20 --- /dev/null +++ b/apps/menu-bar/modules/auto-updater/electron/utils/Logger.ts @@ -0,0 +1,35 @@ +import { Options } from './options'; + +const PREFIX = '[Updater]'; + +export default class Logger { + options: Partial; + error: (...args: any[]) => void; + warn: (...args: any[]) => void; + info: (...args: any[]) => void; + debug: (...args: any[]) => void; + + constructor(options: Partial) { + this.options = options; + + this.error = this.log.bind(this, 'error'); + this.warn = this.log.bind(this, 'warn'); + this.info = this.log.bind(this, 'info'); + this.debug = this.log.bind(this, 'debug'); + } + + log(level: Exclude, ...args: any[]) { + const customLogger = this.options.logger; + const logger = customLogger?.[level]; + + if (!logger || typeof logger !== 'function') { + return; + } + + logger(PREFIX, ...args); + } + + static createEmpty() { + return new Logger({}); + } +} diff --git a/apps/menu-bar/modules/auto-updater/electron/utils/file.ts b/apps/menu-bar/modules/auto-updater/electron/utils/file.ts new file mode 100644 index 00000000..d250cd72 --- /dev/null +++ b/apps/menu-bar/modules/auto-updater/electron/utils/file.ts @@ -0,0 +1,26 @@ +import crypto from 'crypto'; +import { app } from 'electron'; +import fs from 'fs'; +import path from 'path'; + +export function calcSha256Hash(filePath: string): Promise { + const stream = fs.createReadStream(filePath); + const shaSum = crypto.createHash('sha256'); + + return new Promise((resolve, reject) => { + stream + .on('data', (data) => shaSum.update(data)) + .on('end', () => resolve(shaSum.digest('hex'))) + .on('error', reject); + }); +} + +export function readPackageJson(appPath: string | undefined) { + try { + const packageFile = path.join(appPath || app.getAppPath(), 'package.json'); + const content = fs.readFileSync(packageFile, 'utf-8'); + return JSON.parse(content); + } catch (e) { + return {}; + } +} diff --git a/apps/menu-bar/modules/auto-updater/electron/utils/meta.ts b/apps/menu-bar/modules/auto-updater/electron/utils/meta.ts new file mode 100644 index 00000000..22e49b08 --- /dev/null +++ b/apps/menu-bar/modules/auto-updater/electron/utils/meta.ts @@ -0,0 +1,54 @@ +import semver from 'semver'; + +import HttpClient from './HttpClient'; + +type UpdatesJSON = { + title: string; + link: string; + versions: VersionMeta[]; +}; + +export type BuildInfo = { + url: string; + sha256?: string; +}; + +export type VersionMeta = { + version: string; + release_notes: string; + pub_date: string; + builds: { + [platform: string]: BuildInfo; + }; +}; + +/** + * Return promise containing a JSON with information regarding + * all available updates + * + * @param {HttpClient} httpClient + * @param {string} updatesUrl + * @returns {Promise} + */ +export async function getUpdatesMeta(httpClient: HttpClient, updatesUrl: string) { + const json: UpdatesJSON = await httpClient.getJson(updatesUrl); + + return json; +} + +export function getNewerVersion( + updatesJSON: UpdatesJSON, + currentVersion: string +): VersionMeta | undefined { + const latestVersion = updatesJSON.versions.sort((a, b) => + semver.compareLoose(b.version, a.version) + )[0]; + + if (semver.gt(latestVersion.version, currentVersion)) { + return latestVersion; + } +} + +export function extractBuildInfoFromMeta(updateMeta: VersionMeta, build: string) { + return updateMeta.builds[build]; +} diff --git a/apps/menu-bar/modules/auto-updater/electron/utils/options.ts b/apps/menu-bar/modules/auto-updater/electron/utils/options.ts new file mode 100644 index 00000000..cbb4ba80 --- /dev/null +++ b/apps/menu-bar/modules/auto-updater/electron/utils/options.ts @@ -0,0 +1,111 @@ +import { app } from 'electron'; + +import Logger from './Logger'; +import { readPackageJson } from './file'; + +export function getOptions() { + return new Options(); +} + +export class Options { + autoDownload: boolean; + build: string; + http: { headers?: Record }; + version: string; + url: string; + checkUpdateOnStart: boolean; + logger: Partial; + isInitialized: boolean; + appPath: string | undefined; + + constructor() { + this.autoDownload = false; + this.build = this.makeBuildString(process); + this.http = {}; + this.version = ''; + this.url = ''; + this.checkUpdateOnStart = true; + this.logger = console; + this.isInitialized = false; + this.appPath = undefined; + } + + setOptions( + nameOrOptions: keyof Options | Partial, + value: Options[keyof Options] = undefined + ) { + if (typeof nameOrOptions === 'object') { + Object.entries(nameOrOptions).forEach((entry) => { + const [optName, optValue] = entry as [keyof Options, Options[keyof Options]]; + this.setOptions(optName, optValue); + }); + return; + } + + const name = nameOrOptions; + + if (value === undefined) { + return; + } + + // @ts-ignore + this[name] = value; + } + + initialize(options: Partial, logger: Logger): boolean { + if (this.isInitialized) { + logger.error('It has been initialized before'); + return false; + } + + this.version = app.getVersion(); + this.loadOptionsFromPackage(options.appPath); + + this.setOptions(options); + + if (!this.validate(logger)) { + return false; + } + + this.isInitialized = true; + + return true; + } + + loadOptionsFromPackage(appPath?: string) { + const packageJson = readPackageJson(appPath); + const options = packageJson.updater || {}; + + options.version = packageJson.version; + this.setOptions(options); + } + + makeBuildString(process: NodeJS.Process): string { + let build: NodeJS.Platform | 'mas' | 'winstore' = process.platform; + + if (process.mas) { + build = 'mas'; + } else if (process.windowsStore) { + build = 'winstore'; + } + + return `${build}-${process.arch}`; + } + + validate(logger: Logger): boolean { + if (!this.url) { + logger.warn( + 'You must set an url parameter in package.json (updater.url) or ' + + 'when calling init({ url })' + ); + return false; + } + + if (!this.version) { + logger.warn('Set version in a package.json or when calling init()'); + return false; + } + + return true; + } +} diff --git a/apps/menu-bar/modules/auto-updater/electron/utils/quit.ts b/apps/menu-bar/modules/auto-updater/electron/utils/quit.ts new file mode 100644 index 00000000..5d6eaa7d --- /dev/null +++ b/apps/menu-bar/modules/auto-updater/electron/utils/quit.ts @@ -0,0 +1,9 @@ +import { app } from 'electron'; + +export function quit() { + if (app) { + app.quit(); + } else { + process.exit(); + } +}