import * as kdbxweb from 'kdbxweb'; import { Events } from 'framework/events'; import { RuntimeInfo } from 'const/runtime-info'; import { Transport } from 'comp/browser/transport'; import { Launcher } from 'comp/launcher'; import { Links } from 'const/links'; import { AppSettingsModel } from 'models/app-settings-model'; import { UpdateModel } from 'models/update-model'; import { SemVer } from 'util/data/semver'; import { Logger } from 'util/logger'; import { SignatureVerifier } from 'util/data/signature-verifier'; const logger = new Logger('updater'); const Updater = { UpdateInterval: 1000 * 60 * 60 * 24, MinUpdateTimeout: 500, MinUpdateSize: 10000, nextCheckTimeout: null, updateCheckDate: new Date(0), enabled: Launcher?.updaterEnabled(), getAutoUpdateType() { if (!this.enabled) { return false; } let autoUpdate = AppSettingsModel.autoUpdate; if (autoUpdate && autoUpdate === true) { autoUpdate = 'install'; } return autoUpdate; }, updateInProgress() { return ( UpdateModel.status === 'checking' || ['downloading', 'extracting', 'updating'].indexOf(UpdateModel.updateStatus) >= 0 ); }, init() { this.scheduleNextCheck(); if (!Launcher && navigator.serviceWorker && !RuntimeInfo.beta && !RuntimeInfo.devMode) { navigator.serviceWorker .register('service-worker.js') .then((reg) => { logger.info('Service worker registered'); reg.addEventListener('updatefound', () => { if (reg.active) { logger.info('Service worker found an update'); UpdateModel.set({ updateStatus: 'ready' }); } }); }) .catch((e) => { logger.error('Failed to register a service worker', e); }); } }, scheduleNextCheck() { if (this.nextCheckTimeout) { clearTimeout(this.nextCheckTimeout); this.nextCheckTimeout = null; } if (!this.getAutoUpdateType()) { return; } let timeDiff = this.MinUpdateTimeout; const lastCheckDate = UpdateModel.lastCheckDate; if (lastCheckDate) { timeDiff = Math.min( Math.max(this.UpdateInterval + (lastCheckDate - new Date()), this.MinUpdateTimeout), this.UpdateInterval ); } this.nextCheckTimeout = setTimeout(this.check.bind(this), timeDiff); logger.info('Next update check will happen in ' + Math.round(timeDiff / 1000) + 's'); }, check(startedByUser) { if (!this.enabled || this.updateInProgress()) { return; } UpdateModel.set({ status: 'checking' }); if (!startedByUser) { // additional protection from broken program logic, to ensure that auto-checks are not performed more than once an hour const diffMs = new Date() - this.updateCheckDate; if (isNaN(diffMs) || diffMs < 1000 * 60 * 60) { logger.error( 'Prevented update check; last check was performed at ' + this.updateCheckDate ); this.scheduleNextCheck(); return; } this.updateCheckDate = new Date(); } logger.info('Checking for update...'); Transport.httpGet({ url: Links.UpdateJson, json: true, success: (updateJson) => { const dt = new Date(); logger.info('Update check: ' + (updateJson.version || 'unknown')); if (!updateJson.version) { const errMsg = 'No version info found'; UpdateModel.set({ status: 'error', lastCheckDate: dt, lastCheckError: errMsg }); UpdateModel.save(); this.scheduleNextCheck(); return; } const prevLastVersion = UpdateModel.lastVersion; UpdateModel.set({ status: 'ok', lastCheckDate: dt, lastSuccessCheckDate: dt, lastVersionReleaseDate: new Date(updateJson.date), lastVersion: updateJson.version, lastCheckError: null, lastCheckUpdMin: updateJson.minVersion || null }); UpdateModel.save(); this.scheduleNextCheck(); if (!this.canAutoUpdate()) { return; } if ( prevLastVersion === UpdateModel.lastVersion && UpdateModel.updateStatus === 'ready' ) { logger.info('Waiting for the user to apply downloaded update'); return; } if (!startedByUser && this.getAutoUpdateType() === 'install') { this.update(startedByUser); } else if ( SemVer.compareVersions(UpdateModel.lastVersion, RuntimeInfo.version) > 0 ) { UpdateModel.set({ updateStatus: 'found' }); } }, error: (e) => { logger.error('Update check error', e); UpdateModel.set({ status: 'error', lastCheckDate: new Date(), lastCheckError: 'Error checking last version' }); UpdateModel.save(); this.scheduleNextCheck(); } }); }, canAutoUpdate() { const minLauncherVersion = UpdateModel.lastCheckUpdMin; if (minLauncherVersion) { const cmp = SemVer.compareVersions(RuntimeInfo.version, minLauncherVersion); if (cmp < 0) { UpdateModel.set({ updateStatus: 'ready', updateManual: true }); return false; } } return true; }, update(startedByUser, successCallback) { const ver = UpdateModel.lastVersion; if (!this.enabled) { logger.info('Updater is disabled'); return; } if (SemVer.compareVersions(RuntimeInfo.version, ver) >= 0) { logger.info('You are using the latest version'); return; } UpdateModel.set({ updateStatus: 'downloading', updateError: null }); logger.info('Downloading update', ver); const updateAssetName = this.getUpdateAssetName(ver); if (!updateAssetName) { logger.error('Empty updater asset name for', Launcher.platform(), Launcher.arch()); return; } const updateUrlBasePath = Links.UpdateBasePath.replace('{ver}', ver); const updateAssetUrl = updateUrlBasePath + updateAssetName; Transport.httpGet({ url: updateAssetUrl, file: updateAssetName, cleanupOldFiles: true, cache: true, success: (assetFilePath) => { logger.info('Downloading update signatures'); Transport.httpGet({ url: updateUrlBasePath + 'Verify.sign.sha256', text: true, file: updateAssetName + '.sign', cleanupOldFiles: true, cache: true, success: (assetFileSignaturePath) => { this.verifySignature(assetFilePath, updateAssetName, (err, valid) => { if (err || !valid) { UpdateModel.set({ updateStatus: 'error', updateError: err ? 'Error verifying update signature' : 'Invalid update signature' }); Launcher.deleteFile(assetFilePath); Launcher.deleteFile(assetFileSignaturePath); return; } logger.info('Update is ready', assetFilePath); UpdateModel.set({ updateStatus: 'ready', updateError: null }); if (!startedByUser) { Events.emit('update-app'); } if (typeof successCallback === 'function') { successCallback(); } }); }, error(e) { logger.error('Error downloading update signatures', e); UpdateModel.set({ updateStatus: 'error', updateError: 'Error downloading update signatures' }); } }); }, error(e) { logger.error('Error downloading update', e); UpdateModel.set({ updateStatus: 'error', updateError: 'Error downloading update' }); } }); }, verifySignature(assetFilePath, assetName, callback) { logger.info('Verifying update signature', assetName); const fs = Launcher.req('fs'); const signaturesTxt = fs.readFileSync(assetFilePath + '.sign', 'utf8'); const assetSignatureLine = signaturesTxt .split('\n') .find((line) => line.endsWith(assetName)); if (!assetSignatureLine) { logger.error('Signature not found for asset', assetName); callback('Asset signature not found'); return; } const signature = kdbxweb.ByteUtils.hexToBytes(assetSignatureLine.split(' ')[0]); const fileBytes = fs.readFileSync(assetFilePath); SignatureVerifier.verify(fileBytes, signature) .catch((e) => { logger.error('Error verifying signature', e); callback('Error verifying signature'); }) .then((valid) => { logger.info(`Update asset signature is ${valid ? 'valid' : 'invalid'}`); callback(undefined, valid); }); }, getUpdateAssetName(ver) { const platform = Launcher.platform(); const arch = Launcher.arch(); switch (platform) { case 'win32': switch (arch) { case 'x64': return `KeeWeb-${ver}.win.x64.exe`; case 'ia32': return `KeeWeb-${ver}.win.ia32.exe`; case 'arm64': return `KeeWeb-${ver}.win.arm64.exe`; } break; case 'darwin': switch (arch) { case 'x64': return `KeeWeb-${ver}.mac.x64.dmg`; case 'arm64': return `KeeWeb-${ver}.mac.arm64.dmg`; } break; } return undefined; }, installAndRestart() { if (!Launcher) { return; } const updateAssetName = this.getUpdateAssetName(UpdateModel.lastVersion); const updateFilePath = Transport.cacheFilePath(updateAssetName); Launcher.requestRestartAndUpdate(updateFilePath); } }; export { Updater };