import { Events } from 'framework/events'; import { RuntimeInfo } from 'comp/app/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 publicKey from 'public-key.pem'; const logger = new Logger('updater'); const Updater = { UpdateInterval: 1000 * 60 * 60 * 24, MinUpdateTimeout: 500, MinUpdateSize: 10000, UpdateCheckFiles: ['app.asar'], nextCheckTimeout: null, updateCheckDate: new Date(0), enabled: Launcher && 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'].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.Manifest, utf8: true, success: data => { const dt = new Date(); const match = data.match(/#\s*(\d+\-\d+\-\d+):v([\d+\.\w]+)/); logger.info('Update check: ' + (match ? match[0] : 'unknown')); if (!match) { const errMsg = 'No version info found'; UpdateModel.set({ status: 'error', lastCheckDate: dt, lastCheckError: errMsg }); UpdateModel.save(); this.scheduleNextCheck(); return; } const updateMinVersionMatch = data.match(/#\s*updmin:v([\d+\.\w]+)/); const prevLastVersion = UpdateModel.lastVersion; UpdateModel.set({ status: 'ok', lastCheckDate: dt, lastSuccessCheckDate: dt, lastVersionReleaseDate: new Date(match[1]), lastVersion: match[2], lastCheckError: null, lastCheckUpdMin: updateMinVersionMatch ? updateMinVersionMatch[1] : 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(Launcher.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); Transport.httpGet({ url: Links.UpdateDesktop.replace('{ver}', ver), file: 'KeeWeb-' + ver + '.zip', cache: !startedByUser, success: filePath => { UpdateModel.set({ updateStatus: 'extracting' }); logger.info('Extracting update file', this.UpdateCheckFiles, filePath); this.extractAppUpdate(filePath, err => { if (err) { logger.error('Error extracting update', err); UpdateModel.set({ updateStatus: 'error', updateError: 'Error extracting update' }); } else { UpdateModel.set({ updateStatus: 'ready', updateError: null }); if (!startedByUser) { Events.emit('update-app'); } if (typeof successCallback === 'function') { successCallback(); } } }); }, error(e) { logger.error('Error downloading update', e); UpdateModel.set({ updateStatus: 'error', updateError: 'Error downloading update' }); } }); }, extractAppUpdate(updateFile, cb) { const expectedFiles = this.UpdateCheckFiles; const appPath = Launcher.getUserDataPath(); const StreamZip = Launcher.req('node-stream-zip'); StreamZip.setFs(Launcher.req('original-fs')); const zip = new StreamZip({ file: updateFile, storeEntries: true }); zip.on('error', cb); zip.on('ready', () => { const containsAll = expectedFiles.every(expFile => { const entry = zip.entry(expFile); return entry && entry.isFile; }); if (!containsAll) { return cb('Bad archive'); } const validationError = this.validateArchiveSignature(updateFile, zip); if (validationError) { return cb('Invalid archive: ' + validationError); } zip.extract(null, appPath, err => { zip.close(); if (err) { return cb(err); } Launcher.deleteFile(updateFile); cb(); }); }); }, validateArchiveSignature(archivePath, zip) { if (!zip.comment) { return 'No comment in ZIP'; } if (zip.comment.length !== 512) { return 'Bad comment length in ZIP: ' + zip.comment.length; } try { const zipFileData = Launcher.req('fs').readFileSync(archivePath); const verify = Launcher.req('crypto').createVerify('RSA-SHA256'); verify.write(zipFileData.slice(0, zip.centralDirectory.headerOffset + 22)); verify.end(); const signature = window.Buffer.from(zip.comment, 'hex'); if (!verify.verify(publicKey, signature)) { return 'Invalid signature'; } } catch (err) { return err.toString(); } return null; } }; export { Updater };