Skip to content

Commit

Permalink
[menu-bar][electron] Add auto-updater implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
gabrieldonadel committed Feb 26, 2024
1 parent d7f2e24 commit 98a195a
Show file tree
Hide file tree
Showing 14 changed files with 1,065 additions and 0 deletions.
251 changes: 251 additions & 0 deletions apps/menu-bar/modules/auto-updater/electron/Updater.ts
Original file line number Diff line number Diff line change
@@ -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<Options> | string} options
* @return {this}
*/
init(options: Partial<Options> = {}): 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<Options>,
value: Options[keyof Options] = undefined
): this {
this.options.setOptions(name, value);
return this;
}

get build() {
if (!this.checkIsInitialized()) return;

Check warning on line 161 in apps/menu-bar/modules/auto-updater/electron/Updater.ts

View workflow job for this annotation

GitHub Actions / lint

Expected to return a value in getter 'build'
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;

Check warning on line 175 in apps/menu-bar/modules/auto-updater/electron/Updater.ts

View workflow job for this annotation

GitHub Actions / lint

Expected to return a value in getter 'version'
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;
}
}
135 changes: 135 additions & 0 deletions apps/menu-bar/modules/auto-updater/electron/platform/Linux.ts
Original file line number Diff line number Diff line change
@@ -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);

Check warning on line 131 in apps/menu-bar/modules/auto-updater/electron/platform/Linux.ts

View workflow job for this annotation

GitHub Actions / lint

Expected an assignment or function call and instead saw an expression

Check warning on line 131 in apps/menu-bar/modules/auto-updater/electron/platform/Linux.ts

View workflow job for this annotation

GitHub Actions / lint

Expected the Promise rejection reason to be an Error
});
});
});
}
Loading

0 comments on commit 98a195a

Please sign in to comment.