-
Notifications
You must be signed in to change notification settings - Fork 13
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[menu-bar][electron] Add auto-updater implementation
- Loading branch information
1 parent
d7f2e24
commit 98a195a
Showing
14 changed files
with
1,065 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
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; | ||
} | ||
} |
135 changes: 135 additions & 0 deletions
135
apps/menu-bar/modules/auto-updater/electron/platform/Linux.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 GitHub Actions / lint
|
||
}); | ||
}); | ||
}); | ||
} |
Oops, something went wrong.