keeweb/app/scripts/comp/updater.js

257 lines
10 KiB
JavaScript

const Backbone = require('backbone');
const RuntimeInfo = require('./runtime-info');
const Links = require('../const/links');
const Launcher = require('../comp/launcher');
const AppSettingsModel = require('../models/app-settings-model');
const UpdateModel = require('../models/update-model');
const Transport = require('../comp/transport');
const Logger = require('../util/logger');
const SemVer = require('../util/semver');
const publicKey = require('raw-loader!../../resources/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: function() {
if (!this.enabled) {
return false;
}
let autoUpdate = AppSettingsModel.instance.get('autoUpdate');
if (autoUpdate && autoUpdate === true) {
autoUpdate = 'install';
}
return autoUpdate;
},
updateInProgress: function() {
return (
UpdateModel.instance.get('status') === 'checking' ||
['downloading', 'extracting'].indexOf(UpdateModel.instance.get('updateStatus')) >= 0
);
},
init: function() {
this.scheduleNextCheck();
if (!Launcher && window.applicationCache) {
window.applicationCache.addEventListener('updateready', this.checkAppCacheUpdateReady.bind(this));
this.checkAppCacheUpdateReady();
}
},
scheduleNextCheck: function() {
if (this.nextCheckTimeout) {
clearTimeout(this.nextCheckTimeout);
this.nextCheckTimeout = null;
}
if (!this.getAutoUpdateType()) {
return;
}
let timeDiff = this.MinUpdateTimeout;
const lastCheckDate = UpdateModel.instance.get('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: function(startedByUser) {
if (!this.enabled || this.updateInProgress()) {
return;
}
UpdateModel.instance.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.instance.set({ status: 'error', lastCheckDate: dt, lastCheckError: errMsg });
UpdateModel.instance.save();
this.scheduleNextCheck();
return;
}
const updateMinVersionMatch = data.match(/#\s*updmin:v([\d+\.\w]+)/);
const prevLastVersion = UpdateModel.instance.get('lastVersion');
UpdateModel.instance.set({
status: 'ok',
lastCheckDate: dt,
lastSuccessCheckDate: dt,
lastVersionReleaseDate: new Date(match[1]),
lastVersion: match[2],
lastCheckError: null,
lastCheckUpdMin: updateMinVersionMatch ? updateMinVersionMatch[1] : null
});
UpdateModel.instance.save();
this.scheduleNextCheck();
if (!this.canAutoUpdate()) {
return;
}
if (
prevLastVersion === UpdateModel.instance.get('lastVersion') &&
UpdateModel.instance.get('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.instance.get('lastVersion'), RuntimeInfo.version) > 0) {
UpdateModel.instance.set('updateStatus', 'found');
}
},
error: e => {
logger.error('Update check error', e);
UpdateModel.instance.set({
status: 'error',
lastCheckDate: new Date(),
lastCheckError: 'Error checking last version'
});
UpdateModel.instance.save();
this.scheduleNextCheck();
}
});
},
canAutoUpdate: function() {
const minLauncherVersion = UpdateModel.instance.get('lastCheckUpdMin');
if (minLauncherVersion) {
const cmp = SemVer.compareVersions(Launcher.version, minLauncherVersion);
if (cmp < 0) {
UpdateModel.instance.set({ updateStatus: 'ready', updateManual: true });
return false;
}
}
return true;
},
update: function(startedByUser, successCallback) {
const ver = UpdateModel.instance.get('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.instance.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.instance.set('updateStatus', 'extracting');
logger.info('Extracting update file', this.UpdateCheckFiles, filePath);
this.extractAppUpdate(filePath, err => {
if (err) {
logger.error('Error extracting update', err);
UpdateModel.instance.set({ updateStatus: 'error', updateError: 'Error extracting update' });
} else {
UpdateModel.instance.set({ updateStatus: 'ready', updateError: null });
if (!startedByUser) {
Backbone.trigger('update-app');
}
if (typeof successCallback === 'function') {
successCallback();
}
}
});
},
error: function(e) {
logger.error('Error downloading update', e);
UpdateModel.instance.set({ updateStatus: 'error', updateError: 'Error downloading update' });
}
});
},
extractAppUpdate: function(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: function(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;
},
checkAppCacheUpdateReady: function() {
if (window.applicationCache.status === window.applicationCache.UPDATEREADY) {
try {
window.applicationCache.swapCache();
} catch (e) {}
UpdateModel.instance.set('updateStatus', 'ready');
}
}
};
module.exports = Updater;