diff --git a/Gruntfile.js b/Gruntfile.js index e5fa9e0c..c156a67a 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -3,6 +3,9 @@ var fs = require('fs'), path = require('path'); +/* jshint node:true */ +/* jshint browser:false */ + var StringReplacePlugin = require('string-replace-webpack-plugin'); module.exports = function(grunt) { @@ -55,8 +58,8 @@ module.exports = function(grunt) { }, clean: { dist: ['dist', 'tmp'], - desktop_dist: ['dist/desktop'], - desktop_tmp: ['tmp/desktop'] + 'desktop_dist': ['dist/desktop'], + 'desktop_tmp': ['tmp/desktop'] }, copy: { html: { @@ -76,6 +79,13 @@ module.exports = function(grunt) { expand: true, flatten: true }, + 'desktop_app_content': { + cwd: 'electron/', + src: '**', + dest: 'tmp/desktop/app/', + expand: true, + nonull: true + }, 'desktop_osx': { src: 'tmp/desktop/KeeWeb.dmg', dest: 'dist/desktop/KeeWeb.mac.dmg', @@ -141,12 +151,16 @@ module.exports = function(grunt) { 'string-replace': { manifest: { options: { - replacements: [{ - pattern: '# YYYY-MM-DD:v0.0.0', - replacement: '# ' + dt + ':v' + pkg.version - }] + replacements: [ + { pattern: '# YYYY-MM-DD:v0.0.0', replacement: '# ' + dt + ':v' + pkg.version }, + { pattern: 'vElectron', replacement: electronVersion } + ] }, files: { 'dist/manifest.appcache': 'app/manifest.appcache' } + }, + 'desktop_html': { + options: { replacements: [{ pattern: ' manifest="manifest.appcache"', replacement: '' }] }, + files: { 'tmp/desktop/app/index.html': 'dist/index.html' } } }, webpack: { @@ -190,7 +204,8 @@ module.exports = function(grunt) { }]})}, { test: /runtime\-info\.js$/, loader: StringReplacePlugin.replace({ replacements: [ { pattern: /@@VERSION/g, replacement: function() { return pkg.version; } }, - { pattern: /@@DATE/g, replacement: function() { return dt; } } + { pattern: /@@DATE/g, replacement: function() { return dt; } }, + { pattern: /@@COMMIT/g, replacement: function() { return grunt.config.get('gitinfo.local.branch.current.shortSHA'); } } ]})}, { test: /zepto(\.min)?\.js$/, loader: 'exports?Zepto; delete window.$; delete window.Zepto;' }, { test: /baron(\.min)?\.js$/, loader: 'exports?baron; delete window.baron;' }, @@ -251,7 +266,7 @@ module.exports = function(grunt) { electron: { options: { name: 'KeeWeb', - dir: 'electron', + dir: 'tmp/desktop/app', out: 'tmp/desktop', version: electronVersion, overwrite: true, @@ -326,15 +341,26 @@ module.exports = function(grunt) { }, compress: { linux: { - options: { - archive: 'tmp/desktop/KeeWeb.linux.x64.zip' - }, + options: { archive: 'tmp/desktop/KeeWeb.linux.x64.zip' }, files: [{ cwd: 'tmp/desktop/KeeWeb-linux-x64', src: '**', expand: true }] + }, + 'desktop_update': { + options: { archive: 'dist/desktop/UpdateDesktop.zip' }, + files: [{ cwd: 'tmp/desktop/app', src: '**', expand: true }] + } + }, + 'validate-desktop-update': { + desktop: { + options: { + file: 'dist/desktop/UpdateDesktop.zip', + expected: ['main.js', 'app.js', 'index.html', 'package.json', 'node_modules/node-stream-zip/node_stream_zip.js'] + } } } }); grunt.registerTask('default', [ + 'gitinfo', 'bower-install-simple', 'clean', 'jshint', @@ -347,13 +373,18 @@ module.exports = function(grunt) { 'postcss', 'inline', 'htmlmin', - 'string-replace' + 'string-replace:manifest' ]); grunt.registerTask('desktop', [ + 'default', 'gitinfo', 'clean:desktop_tmp', 'clean:desktop_dist', + 'copy:desktop_app_content', + 'string-replace:desktop_html', + 'compress:desktop_update', + 'validate-desktop-update', 'electron', 'electron_builder', 'compress:linux', diff --git a/README.md b/README.md index e4d548e4..60dc3abf 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,6 @@ Reading and display is mostly complete; modification and sync is under construct These major issues are in progress, or will be fixed in next releases, before v1.0: -- auto-update is not implemented - dropbox sync is one-way: changes are not loaded from dropbox, only saved # Self-hosting @@ -34,10 +33,20 @@ You can download the latest distribution files from [gh-pages](https://github.co # Building -The app can be built with grunt: `grunt` (html file will be in `dist/`) or `grunt watch` (result will be in `tmp/`). -Electron app is built with `grunt electron` (works only under mac osx as it builds dmg; requires wine). -To run Electron app without building, install electron package (`npm install electron-prebuilt -g`) and start with `electron ./electron/`. -For debug build: 1. run `grunt`, 2. run `grunt watch`, 3. open `tmp/index.html`. +The app can be built with grunt: `grunt` (html file will be in `dist/`). +Desktop apps are built with `grunt desktop`. This works only in mac osx as it builds dmg; requires wine. +To run Electron app without building, install electron package (`npm install electron-prebuilt -g`) and start in this way: +```bash +$ cd electron +$ electron . --htmlpath=../tmp +``` + +For debug build: + +1. run `grunt` +2. run `grunt watch` +3. open `tmp/index.html` + # Contributing diff --git a/app/manifest.appcache b/app/manifest.appcache index 0f8b8573..2d741eb0 100644 --- a/app/manifest.appcache +++ b/app/manifest.appcache @@ -1,6 +1,6 @@ CACHE MANIFEST -# YYYY-MM-DD:v0.0.0 +# YYYY-MM-DD:v0.0.0 evElectron CACHE: index.html diff --git a/app/scripts/app.js b/app/scripts/app.js index 41377774..53b29c8f 100644 --- a/app/scripts/app.js +++ b/app/scripts/app.js @@ -5,6 +5,7 @@ var AppModel = require('./models/app-model'), KeyHandler = require('./comp/key-handler'), Alerts = require('./comp/alerts'), DropboxLink = require('./comp/dropbox-link'), + Updater = require('./comp/updater'), LastOpenFiles = require('./comp/last-open-files'), ThemeChanger = require('./util/theme-changer'); @@ -47,6 +48,6 @@ $(function() { } else { appView.showOpenFile(); } + Updater.init(); } }); - diff --git a/app/scripts/comp/launcher.js b/app/scripts/comp/launcher.js index e424835f..239eed6e 100644 --- a/app/scripts/comp/launcher.js +++ b/app/scripts/comp/launcher.js @@ -4,6 +4,7 @@ var Backbone = require('backbone'); var Launcher; if (window.process && window.process.versions && window.process.versions.electron) { + /* jshint node:true */ Launcher = { name: 'electron', version: window.process.versions.electron, @@ -18,6 +19,9 @@ if (window.process && window.process.versions && window.process.versions.electro openDevTools: function() { this.req('remote').getCurrentWindow().openDevTools(); }, + getAppVersion: function() { + return this.remReq('app').getVersion(); + }, getSaveFileName: function(defaultPath, cb) { if (defaultPath) { var homePath = this.remReq('app').getPath('userDesktop'); @@ -32,6 +36,9 @@ if (window.process && window.process.versions && window.process.versions.electro getUserDataPath: function(fileName) { return this.req('path').join(this.remReq('app').getPath('userData'), fileName || ''); }, + getTempPath: function(fileName) { + return this.req('path').join(this.remReq('app').getPath('temp'), fileName || ''); + }, writeFile: function(path, data) { this.req('fs').writeFileSync(path, new window.Buffer(data)); }, @@ -42,9 +49,28 @@ if (window.process && window.process.versions && window.process.versions.electro fileExists: function(path) { return this.req('fs').existsSync(path); }, + preventExit: function(e) { + e.returnValue = false; + return false; + }, exit: function() { Launcher.exitRequested = true; - this.remReq('app').quit(); + this.requestExit(); + }, + requestExit: function() { + var app = this.remReq('app'); + if (this.restartPending) { + app.quitAndRestart(); + } else { + app.quit(); + } + }, + requestRestart: function() { + this.restartPending = true; + this.requestExit(); + }, + cancelRestart: function() { + this.restartPending = false; } }; window.launcherOpen = function(path) { diff --git a/app/scripts/comp/runtime-info.js b/app/scripts/comp/runtime-info.js index 880dc5dd..1255d5bd 100644 --- a/app/scripts/comp/runtime-info.js +++ b/app/scripts/comp/runtime-info.js @@ -5,6 +5,7 @@ var Launcher = require('../comp/launcher'); var RuntimeInfo = { version: '@@VERSION', buildDate: '@@DATE', + commit: '@@COMMIT', userAgent: navigator.userAgent, launcher: Launcher ? Launcher.name + ' v' + Launcher.version : '' }; diff --git a/app/scripts/comp/transport.js b/app/scripts/comp/transport.js new file mode 100644 index 00000000..21e6928b --- /dev/null +++ b/app/scripts/comp/transport.js @@ -0,0 +1,62 @@ +'use strict'; + +var Launcher = require('./launcher'); + +var Transport = { + httpGet: function(config) { + var tmpFile; + var fs = Launcher.req('fs'); + if (config.file) { + tmpFile = Launcher.getTempPath(config.file); + if (fs.existsSync(tmpFile)) { + try { + if (config.cache && fs.statSync(tmpFile).size > 0) { + console.log('File already downloaded ' + config.url); + return config.success(tmpFile); + } else { + fs.unlinkSync(tmpFile); + } + } catch (e) { + fs.unlink(tmpFile); + } + } + } + var proto = config.url.split(':')[0]; + console.log('GET ' + config.url); + var opts = Launcher.req('url').parse(config.url); + opts.headers = { 'User-Agent': navigator.userAgent }; + Launcher.req(proto).get(opts, function(res) { + console.log('Response from ' + config.url + ': ' + res.statusCode); + if (res.statusCode === 200) { + if (config.file) { + var file = fs.createWriteStream(tmpFile); + res.pipe(file); + file.on('finish', function() { + file.close(function() { config.success(tmpFile); }); + }); + file.on('error', function(err) { config.error(err); }); + } else { + var data = []; + res.on('data', function(chunk) { data.push(chunk); }); + res.on('end', function() { + data = window.Buffer.concat(data); + if (config.utf8) { + data = data.toString('utf8'); + } + config.success(data); + }); + } + } else { + config.error('HTTP status ' + res.statusCode); + } + }).on('error', function(e) { + console.error('Cannot GET ' + config.url, e); + if (tmpFile) { + fs.unlink(tmpFile); + } + config.error(e); + }); + } +}; + +module.exports = Transport; diff --git a/app/scripts/comp/updater.js b/app/scripts/comp/updater.js new file mode 100644 index 00000000..8ed88b17 --- /dev/null +++ b/app/scripts/comp/updater.js @@ -0,0 +1,198 @@ +'use strict'; + +var Backbone = require('backbone'), + RuntimeInfo = require('./runtime-info'), + Links = require('../const/links'), + Launcher = require('../comp/launcher'), + AppSettingsModel = require('../models/app-settings-model'), + UpdateModel = require('../models/update-model'), + Transport = require('../comp/transport'); + +var Updater = { + UpdateInterval: 1000*60*60*24, + MinUpdateTimeout: 500, + MinUpdateSize: 10000, + UpdateCheckFiles: ['index.html', 'app.js'], + nextCheckTimeout: null, + updateCheckDate: new Date(0), + + enabledAutoUpdate: function() { + return Launcher && AppSettingsModel.instance.get('autoUpdate'); + }, + + updateInProgress: function() { + return UpdateModel.instance.get('status') === 'checking' || + ['downloading', 'extracting'].indexOf(UpdateModel.instance.get('updateStatus')) >= 0; + }, + + init: function() { + var willCheckNow = this.scheduleNextCheck(); + if (!willCheckNow && this.enabledAutoUpdate()) { + this.check(); + } + 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.enabledAutoUpdate()) { + return; + } + var timeDiff = this.MinUpdateTimeout; + var 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); + console.log('Update check will happen in ' + Math.round(timeDiff / 1000) + 's'); + return timeDiff === this.MinUpdateTimeout; + }, + + check: function(startedByUser) { + if (!Launcher || this.updateInProgress()) { + return; + } + if (this.checkManualDownload()) { + return; + } + UpdateModel.instance.set('status', 'checking'); + var that = this; + if (!startedByUser) { + // additional protection from broken program logic, to ensure that auto-checks are not performed more than once an hour + var diffMs = new Date() - this.updateCheckDate; + if (isNaN(diffMs) || diffMs < 1000 * 60 * 60) { + console.error('Prevented update check; last check was performed at ' + this.updateCheckDate); + that.scheduleNextCheck(); + return; + } + this.updateCheckDate = new Date(); + } + console.log('Checking for update...'); + Transport.httpGet({ + url: Links.WebApp + 'manifest.appcache', + utf8: true, + success: function(data) { + var dt = new Date(); + var match = data.match(/#\s*(\d+\-\d+\-\d+):v([\d+\.\w]+)/); + console.log('Update check: ' + (match ? match[0] : 'unknown')); + if (!match) { + var errMsg = 'No version info found'; + UpdateModel.instance.set({ status: 'error', lastCheckDate: dt, lastCheckError: errMsg }); + UpdateModel.instance.save(); + that.scheduleNextCheck(); + return; + } + var prevLastVersion = UpdateModel.instance.get('lastVersion'); + UpdateModel.instance.set({ + status: 'ok', + lastCheckDate: dt, + lastSuccessCheckDate: dt, + lastVersionReleaseDate: new Date(match[1]), + lastVersion: match[2], + lastcheckError: null + }); + UpdateModel.instance.save(); + that.scheduleNextCheck(); + if (prevLastVersion === UpdateModel.instance.get('lastVersion') && + UpdateModel.instance.get('updateStatus') === 'ready') { + console.log('Waiting for the user to apply downloaded update'); + return; + } + that.update(startedByUser); + }, + error: function(e) { + console.error('Update check error', e); + UpdateModel.instance.set({ + status: 'error', + lastCheckDate: new Date(), + lastCheckError: 'Error checking last version' + }); + UpdateModel.instance.save(); + that.scheduleNextCheck(); + } + }); + }, + + checkManualDownload: function() { + if (+Launcher.getAppVersion().split('.')[1] <= 2) { + UpdateModel.instance.set({ updateStatus: 'ready', updateManual: true }); + return true; + } + }, + + update: function(startedByUser) { + var ver = UpdateModel.instance.get('lastVersion'); + if (!Launcher || ver === RuntimeInfo.version) { + console.log('You are using the latest version'); + return; + } + UpdateModel.instance.set({ updateStatus: 'downloading', updateError: null }); + var that = this; + console.log('Downloading update', ver); + Transport.httpGet({ + url: Links.UpdateDesktop.replace('{ver}', ver), + file: 'KeeWeb-' + ver + '.zip', + cache: !startedByUser, + success: function(filePath) { + UpdateModel.instance.set('updateStatus', 'extracting'); + console.log('Extracting update file', that.UpdateCheckFiles, filePath); + that.extractAppUpdate(filePath, function(err) { + if (err) { + console.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'); + } + } + }); + }, + error: function(e) { + console.error('Error downloading update', e); + UpdateModel.instance.set({ updateStatus: 'error', updateError: 'Error downloading update' }); + } + }); + }, + + extractAppUpdate: function(updateFile, cb) { + var expectedFiles = this.UpdateCheckFiles; + var appPath = Launcher.getUserDataPath(); + var StreamZip = Launcher.req('node-stream-zip'); + var zip = new StreamZip({ file: updateFile, storeEntries: true }); + zip.on('error', cb); + zip.on('ready', function() { + var containsAll = expectedFiles.every(function(expFile) { + var entry = zip.entry(expFile); + return entry && entry.isFile; + }); + if (!containsAll) { + return cb('Bad archive'); + } + zip.extract(null, appPath, function(err) { + zip.close(); + if (err) { + return cb(err); + } + Launcher.req('fs').unlink(updateFile); + cb(); + }); + }); + }, + + checkAppCacheUpdateReady: function() { + if (window.applicationCache.status === window.applicationCache.UPDATEREADY) { + try { window.applicationCache.swapCache(); } catch (e) { } + UpdateModel.instance.set('updateStatus', 'ready'); + Backbone.trigger('update-app'); + } + } +}; + +module.exports = Updater; diff --git a/app/scripts/const/links.js b/app/scripts/const/links.js index 111ecb48..5d329ed6 100644 --- a/app/scripts/const/links.js +++ b/app/scripts/const/links.js @@ -4,7 +4,9 @@ var Links = { Repo: 'https://github.com/antelle/keeweb', Desktop: 'https://github.com/antelle/keeweb/releases/latest', WebApp: 'https://antelle.github.io/keeweb/', - License: 'https://github.com/antelle/keeweb/blob/master/MIT-LICENSE.txt' + License: 'https://github.com/antelle/keeweb/blob/master/MIT-LICENSE.txt', + UpdateDesktop: 'https://github.com/antelle/keeweb/releases/download/{ver}/UpdateDesktop.zip', + ReleaseNotes: 'https://github.com/antelle/keeweb/blob/master/release-notes.md#release-notes' }; module.exports = Links; diff --git a/app/scripts/models/app-settings-model.js b/app/scripts/models/app-settings-model.js index 847181c4..820c6497 100644 --- a/app/scripts/models/app-settings-model.js +++ b/app/scripts/models/app-settings-model.js @@ -10,7 +10,8 @@ var AppSettingsModel = Backbone.Model.extend({ theme: 'd', expandGroups: true, listViewWidth: null, - menuViewWidth: null + menuViewWidth: null, + autoUpdate: true }, initialize: function() { @@ -21,7 +22,10 @@ var AppSettingsModel = Backbone.Model.extend({ try { var data; if (Launcher) { - data = JSON.parse(Launcher.readFile(Launcher.getUserDataPath(FileName), 'utf8')); + var settingsFile = Launcher.getUserDataPath(FileName); + if (Launcher.fileExists(settingsFile)) { + data = JSON.parse(Launcher.readFile(settingsFile, 'utf8')); + } } else if (typeof localStorage !== 'undefined' && localStorage.appSettings) { data = JSON.parse(localStorage.appSettings); } diff --git a/app/scripts/models/update-model.js b/app/scripts/models/update-model.js new file mode 100644 index 00000000..00567524 --- /dev/null +++ b/app/scripts/models/update-model.js @@ -0,0 +1,49 @@ +'use strict'; + +var Backbone = require('backbone'); + +var UpdateModel = Backbone.Model.extend({ + defaults: { + lastSuccessCheckDate: null, + lastCheckDate: null, + lastVersion: null, + lastVersionReleaseDate: null, + lastCheckError: null, + status: null, + updateStatus: null, + updateError: null, + updateManual: false + }, + + initialize: function() { + }, + + load: function() { + if (localStorage.updateInfo) { + try { + var data = JSON.parse(localStorage.updateInfo); + _.each(data, function(val, key) { + if (/Date$/.test(key)) { + data[key] = val ? new Date(val) : null; + } + }); + this.set(data, { silent: true }); + } catch (e) { /* failed to load model */ } + } + }, + + save: function() { + var attr = _.clone(this.attributes); + Object.keys(attr).forEach(function(key) { + if (key.lastIndexOf('update', 0) === 0) { + delete attr[key]; + } + }); + localStorage.updateInfo = JSON.stringify(attr); + } +}); + +UpdateModel.instance = new UpdateModel(); +UpdateModel.instance.load(); + +module.exports = UpdateModel; diff --git a/app/scripts/views/app-view.js b/app/scripts/views/app-view.js index c4191c33..d40cb2a0 100644 --- a/app/scripts/views/app-view.js +++ b/app/scripts/views/app-view.js @@ -58,6 +58,7 @@ var AppView = Backbone.View.extend({ this.listenTo(Backbone, 'toggle-details', this.toggleDetails); this.listenTo(Backbone, 'edit-group', this.editGroup); this.listenTo(Backbone, 'launcher-open-file', this.launcherOpenFile); + this.listenTo(Backbone, 'update-app', this.updateApp); window.onbeforeunload = this.beforeUnload.bind(this); window.onresize = this.windowResize.bind(this); @@ -103,6 +104,12 @@ var AppView = Backbone.View.extend({ } }, + updateApp: function() { + if (!Launcher && !this.model.files.hasOpenFiles()) { + window.location.reload(); + } + }, + showEntries: function() { this.views.menu.show(); this.views.menuDrag.show(); @@ -189,16 +196,25 @@ var AppView = Backbone.View.extend({ beforeUnload: function(e) { if (this.model.files.hasUnsavedFiles()) { if (Launcher && !Launcher.exitRequested) { - Alerts.yesno({ - header: 'Unsaved changes!', - body: 'You have unsaved files, all changes will be lost.', - buttons: [{result: 'yes', title: 'Exit and discard unsaved changes'}, {result: '', title: 'Don\'t exit'}], - success: function() { - Launcher.exit(); - } - }); - e.returnValue = false; - return false; + if (!this.exitAlertShown) { + var that = this; + that.exitAlertShown = true; + Alerts.yesno({ + header: 'Unsaved changes!', + body: 'You have unsaved files, all changes will be lost.', + buttons: [{result: 'yes', title: 'Exit and discard unsaved changes'}, {result: '', title: 'Don\'t exit'}], + success: function () { + Launcher.exit(); + }, + cancel: function() { + Launcher.cancelRestart(false); + }, + complete: function () { + that.exitAlertShown = false; + } + }); + } + return Launcher.preventExit(e); } return 'You have unsaved files, all changes will be lost.'; } diff --git a/app/scripts/views/footer-view.js b/app/scripts/views/footer-view.js index 495a531b..d2a1c01e 100644 --- a/app/scripts/views/footer-view.js +++ b/app/scripts/views/footer-view.js @@ -3,7 +3,8 @@ var Backbone = require('backbone'), Keys = require('../const/keys'), KeyHandler = require('../comp/key-handler'), - GeneratorView = require('./generator-view'); + GeneratorView = require('./generator-view'), + UpdateModel = require('../models/update-model'); var FooterView = Backbone.View.extend({ template: require('templates/footer.html'), @@ -28,10 +29,15 @@ var FooterView = Backbone.View.extend({ KeyHandler.onKey(Keys.DOM_VK_COMMA, this.toggleSettings, this, KeyHandler.SHORTCUT_ACTION); this.listenTo(this.model.files, 'update reset change', this.render); + this.listenTo(Backbone, 'update-app', this.render); }, render: function () { - this.$el.html(this.template(this.model)); + this.listenTo(Backbone, 'update-app', this.updateApp); + this.$el.html(this.template({ + files: this.model.files, + updateAvailable: UpdateModel.instance.get('updateStatus') === 'ready' + })); return this; }, diff --git a/app/scripts/views/settings/settings-general-view.js b/app/scripts/views/settings/settings-general-view.js index 93a1a770..fa0361b7 100644 --- a/app/scripts/views/settings/settings-general-view.js +++ b/app/scripts/views/settings/settings-general-view.js @@ -2,7 +2,12 @@ var Backbone = require('backbone'), Launcher = require('../../comp/launcher'), - AppSettingsModel = require('../../models/app-settings-model'); + Updater = require('../../comp/updater'), + Format = require('../../util/format'), + AppSettingsModel = require('../../models/app-settings-model'), + UpdateModel = require('../../models/update-model'), + RuntimeInfo = require('../../comp/runtime-info'), + Links = require('../../const/links'); var SettingsGeneralView = Backbone.View.extend({ template: require('templates/settings/settings-general.html'), @@ -10,6 +15,10 @@ var SettingsGeneralView = Backbone.View.extend({ events: { 'change .settings__general-theme': 'changeTheme', 'change .settings__general-expand': 'changeExpandGroups', + 'change .settings__general-auto-update': 'changeAutoUpdate', + 'click .settings__general-update-btn': 'checkUpdate', + 'click .settings__general-restart-btn': 'restartApp', + 'click .settings__general-download-update-btn': 'downloadUpdate', 'click .settings__general-dev-tools-link': 'openDevTools' }, @@ -19,20 +28,92 @@ var SettingsGeneralView = Backbone.View.extend({ wh: 'white' }, + initialize: function() { + this.listenTo(UpdateModel.instance, 'change:status', this.render, this); + this.listenTo(UpdateModel.instance, 'change:updateStatus', this.render, this); + }, + render: function() { this.renderTemplate({ themes: this.allThemes, activeTheme: AppSettingsModel.instance.get('theme'), expandGroups: AppSettingsModel.instance.get('expandGroups'), - devTools: Launcher && Launcher.devTools + devTools: Launcher && Launcher.devTools, + canAutoUpdate: !!Launcher, + autoUpdate: Updater.enabledAutoUpdate(), + updateInProgress: Updater.updateInProgress(), + updateInfo: this.getUpdateInfo(), + updateReady: UpdateModel.instance.get('updateStatus') === 'ready', + updateManual: UpdateModel.instance.get('updateManual'), + releaseNotesLink: Links.ReleaseNotes }); }, + getUpdateInfo: function() { + switch (UpdateModel.instance.get('status')) { + case 'checking': + return 'Checking for updates...'; + case 'error': + var errMsg = 'Error checking for updates'; + if (UpdateModel.instance.get('lastError')) { + errMsg += ': ' + UpdateModel.instance.get('lastError'); + } + if (UpdateModel.instance.get('lastSuccessCheckDate')) { + errMsg += '. Last successful check was at ' + Format.dtStr(UpdateModel.instance.get('lastSuccessCheckDate')) + + ': the latest version was ' + UpdateModel.instance.get('lastVersion'); + } + return errMsg; + case 'ok': + var msg = 'Checked at ' + Format.dtStr(UpdateModel.instance.get('lastCheckDate')) + ': '; + if (RuntimeInfo.version === UpdateModel.instance.get('lastVersion')) { + msg += 'you are using the latest version'; + } else { + msg += 'new version ' + UpdateModel.instance.get('lastVersion') + ' available, released at ' + + Format.dStr(UpdateModel.instance.get('lastVersionReleaseDate')); + } + switch (UpdateModel.instance.get('updateStatus')) { + case 'downloading': + return msg + '. Downloading update...'; + case 'extracting': + return msg + '. Extracting update...'; + case 'error': + return msg + '. There was an error downloading new version'; + } + return msg; + default: + return 'Never checked for updates'; + } + }, + changeTheme: function(e) { var theme = e.target.value; AppSettingsModel.instance.set('theme', theme); }, + changeAutoUpdate: function(e) { + var autoUpdate = e.target.checked; + AppSettingsModel.instance.set('autoUpdate', autoUpdate); + if (autoUpdate) { + Updater.scheduleNextCheck(); + } + }, + + checkUpdate: function() { + Updater.check(true); + }, + + restartApp: function() { + if (Launcher) { + Launcher.requestRestart(); + } else { + window.location.reload(); + } + }, + + downloadUpdate: function() { + Launcher.openLink(Links.Desktop); + }, + changeExpandGroups: function(e) { var expand = e.target.checked; AppSettingsModel.instance.set('expandGroups', expand); diff --git a/app/scripts/views/settings/settings-help-view.js b/app/scripts/views/settings/settings-help-view.js index 04d00245..dac1efe0 100644 --- a/app/scripts/views/settings/settings-help-view.js +++ b/app/scripts/views/settings/settings-help-view.js @@ -8,7 +8,7 @@ var SettingsHelpView = Backbone.View.extend({ template: require('templates/settings/settings-help.html'), render: function() { - var appInfo = 'KeeWeb v' + RuntimeInfo.version + ' (built at ' + RuntimeInfo.buildDate + ')\n' + + var appInfo = 'KeeWeb v' + RuntimeInfo.version + ' (' + RuntimeInfo.commit + ', ' + RuntimeInfo.buildDate + ')\n' + 'Environment: ' + (RuntimeInfo.launcher ? RuntimeInfo.launcher : 'web') + '\n' + 'User-Agent: ' + RuntimeInfo.userAgent; this.renderTemplate({ diff --git a/app/styles/areas/_footer.scss b/app/styles/areas/_footer.scss index 9725a84e..3521075d 100644 --- a/app/styles/areas/_footer.scss +++ b/app/styles/areas/_footer.scss @@ -41,4 +41,9 @@ padding: $base-padding; font-size: 1.4em; } + + &__update-icon { + @include th { color: action-color(); } + @include animation(shake 50s cubic-bezier(.36,.07,.19,.97) 0s infinite); + } } diff --git a/app/styles/areas/_settings.scss b/app/styles/areas/_settings.scss index ea15d689..9413f6d5 100644 --- a/app/styles/areas/_settings.scss +++ b/app/styles/areas/_settings.scss @@ -71,4 +71,12 @@ float: right; display: none; } + + &__general-update-buttons { + margin-top: $base-spacing; + } + &__general-update-btn { + width: 15em; + margin-right: $small-spacing; + } } diff --git a/app/styles/base/_colors.scss b/app/styles/base/_colors.scss index b0bb17b4..6f31bd77 100644 --- a/app/styles/base/_colors.scss +++ b/app/styles/base/_colors.scss @@ -57,6 +57,5 @@ $all-colors: ( @each $col, $val in $all-colors { .#{$col}-color { color: #{$val}; } } -.muted-color { - @include th { color: muted-color(); }; -} +.muted-color { @include th { color: muted-color(); }; } +.action-color { @include th { color: action-color(); }; } diff --git a/app/styles/common/_fx.scss b/app/styles/common/_fx.scss index 5c994893..6c9ab9ad 100644 --- a/app/styles/common/_fx.scss +++ b/app/styles/common/_fx.scss @@ -25,3 +25,11 @@ @include transform(rotateY(360deg)); } } + +@include keyframes(shake) { + 0%, 1%, 100% { @include transform(translate3d(0, 0, 0)); } + .1%, .9% { @include transform(translate3d(-1px, 0, 0)); } + .2%, .8% { @include transform(translate3d(2px, 0, 0)); } + .3%, .5%, .7% { @include transform(translate3d(-3px, 0, 0)); } + .4%, .6% { @include transform(translate3d(3px, 0, 0)); } +} diff --git a/app/templates/footer.html b/app/templates/footer.html index c548e899..62617653 100644 --- a/app/templates/footer.html +++ b/app/templates/footer.html @@ -9,7 +9,13 @@ - + diff --git a/app/templates/settings/settings-general.html b/app/templates/settings/settings-general.html index 78520dba..d694394d 100644 --- a/app/templates/settings/settings-general.html +++ b/app/templates/settings/settings-general.html @@ -1,5 +1,19 @@

General Settings

+ <% if (updateReady && !canAutoUpdate) { %> +

Update

+
New app version was released and downloaded. View release notes
+
+ +
+ <% } else if (updateManual) { %> +

Update

+
New version has been released. It will check for updates and install them automatically + but auto-upgrading from your version is impossible.
+
+ +
+ <% } %>

Appearance

@@ -13,6 +27,25 @@ />
+ <% if (canAutoUpdate && !updateManual) { %> +

Function

+
+ /> + +
<%- updateInfo %>
+ View release notes +
+
+ <% if (updateInProgress) { %> + + <% } else { %> + + <% } %> + <% if (updateReady) { %> + + <% } %> +
+ <% } %> <% if (devTools) { %>

Advanced

Show dev tools diff --git a/electron/app.js b/electron/app.js new file mode 100644 index 00000000..38be4f8b --- /dev/null +++ b/electron/app.js @@ -0,0 +1,104 @@ +'use strict'; + +/* jshint node:true */ +/* jshint browser:false */ + +var app = require('app'), + BrowserWindow = require('browser-window'), + path = require('path'), + Menu = require('menu'); + +var mainWindow = null, + openFile = process.argv.filter(function(arg) { return /\.kdbx$/i.test(arg); })[0], + ready = false, + restartPending = false, + htmlPath = path.join(__dirname, 'index.html'); + +process.argv.forEach(function(arg) { + if (arg.lastIndexOf('--htmlpath=', 0) === 0) { + htmlPath = path.resolve(arg.replace('--htmlpath=', ''), 'index.html'); + } +}); + +app.on('window-all-closed', function() { + app.quit(); +}); +app.on('ready', function() { + mainWindow = new BrowserWindow({ + show: false, + width: 1000, height: 700, 'min-width': 600, 'min-height': 300, + icon: path.join(__dirname, 'icon.png') + }); + setMenu(); + mainWindow.loadUrl('file://' + htmlPath); + mainWindow.webContents.on('dom-ready', function() { + setTimeout(function() { + mainWindow.show(); + ready = true; + notifyOpenFile(); + }, 50); + }); + mainWindow.on('closed', function() { + mainWindow = null; + }); +}); +app.on('open-file', function(e, path) { + e.preventDefault(); + openFile = path; + notifyOpenFile(); +}); +app.on('quit', function() { + if (restartPending) { + require('child_process').exec(process.execPath); + } +}); +app.quitAndRestart = function() { + restartPending = true; + app.quit(); + setTimeout(function() { restartPending = false; }, 1000); +}; + +function setMenu() { + if (process.platform === 'darwin') { + var name = require('app').getName(); + var template = [ + { + label: name, + submenu: [ + { label: 'About ' + name, role: 'about' }, + { type: 'separator' }, + { label: 'Services', role: 'services', submenu: [] }, + { type: 'separator' }, + { label: 'Hide ' + name, accelerator: 'Command+H', role: 'hide' }, + { label: 'Hide Others', accelerator: 'Command+Shift+H', role: 'hideothers' }, + { label: 'Show All', role: 'unhide' }, + { type: 'separator' }, + { label: 'Quit', accelerator: 'Command+Q', click: function() { app.quit(); } } + ] + }, + { + label: 'Edit', + submenu: [ + { label: 'Undo', accelerator: 'CmdOrCtrl+Z', role: 'undo' }, + { label: 'Redo', accelerator: 'Shift+CmdOrCtrl+Z', role: 'redo' }, + { type: 'separator' }, + { label: 'Cut', accelerator: 'CmdOrCtrl+X', role: 'cut' }, + { label: 'Copy', accelerator: 'CmdOrCtrl+C', role: 'copy' }, + { label: 'Paste', accelerator: 'CmdOrCtrl+V', role: 'paste' }, + { label: 'Select All', accelerator: 'CmdOrCtrl+A', role: 'selectall' } + ] + } + ]; + var menu = Menu.buildFromTemplate(template); + Menu.setApplicationMenu(menu); + } +} + +function notifyOpenFile() { + if (ready && openFile && mainWindow) { + openFile = openFile.replace(/"/g, '\\"').replace(/\\/g, '\\\\'); + mainWindow.webContents.executeJavaScript('if (window.launcherOpen) { window.launcherOpen("' + openFile + '"); } ' + + ' else { window.launcherOpenedFile="' + openFile + '"; }'); + openFile = null; + } +} diff --git a/electron/main.js b/electron/main.js index c5151a1c..84015d2d 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1,87 +1,43 @@ +// KeeWeb launcher script + +// This script is distributed with the app and is its entry point +// It checks whether the app is available in userData folder and if its version is higher than local, launches it +// This script is the only part which will be updated only with the app itself, auto-update will not change it + +// (C) Antelle 2015, MIT license https://github.com/antelle/keeweb + 'use strict'; /* jshint node:true */ /* jshint browser:false */ var app = require('app'), - BrowserWindow = require('browser-window'), path = require('path'), - fs = require('fs'), - Menu = require('menu'); + fs = require('fs'); -var mainWindow = null, - openFile = process.argv.filter(function(arg) { return /\.kdbx$/i.test(arg); })[0], - ready = false; +var userDataDir = app.getPath('userData'), + appPathUserData = path.join(userDataDir, 'app.js'), + appPath = path.join(__dirname, 'app.js'); -app.on('window-all-closed', function() { app.quit(); }); -app.on('ready', function() { - var htmlPath = path.join(app.getPath('userData'), 'index.html'); - - mainWindow = new BrowserWindow({ - show: false, - width: 1000, height: 700, 'min-width': 600, 'min-height': 300, - icon: path.join(__dirname, 'icon.png') - }); - setMenu(); - if (fs.existsSync(htmlPath)) { - mainWindow.loadUrl('file://' + htmlPath); - } else { - mainWindow.loadUrl('https://antelle.github.io/keeweb/index.html'); - } - mainWindow.webContents.on('dom-ready', function() { - mainWindow.show(); - ready = true; - notifyOpenFile(); - }); - mainWindow.on('closed', function() { mainWindow = null; }); -}); -app.on('open-file', function(e, path) { - e.preventDefault(); - openFile = path; - notifyOpenFile(); -}); - -function setMenu() { - if (process.platform === 'darwin') { - var name = require('app').getName(); - var template = [ - { - label: name, - submenu: [ - { label: 'About ' + name, role: 'about' }, - { type: 'separator' }, - { label: 'Services', role: 'services', submenu: [] }, - { type: 'separator' }, - { label: 'Hide ' + name, accelerator: 'Command+H', role: 'hide' }, - { label: 'Hide Others', accelerator: 'Command+Shift+H', role: 'hideothers' }, - { label: 'Show All', role: 'unhide' }, - { type: 'separator' }, - { label: 'Quit', accelerator: 'Command+Q', click: function() { app.quit(); } } - ] - }, - { - label: 'Edit', - submenu: [ - { label: 'Undo', accelerator: 'CmdOrCtrl+Z', role: 'undo' }, - { label: 'Redo', accelerator: 'Shift+CmdOrCtrl+Z', role: 'redo' }, - { type: 'separator' }, - { label: 'Cut', accelerator: 'CmdOrCtrl+X', role: 'cut' }, - { label: 'Copy', accelerator: 'CmdOrCtrl+C', role: 'copy' }, - { label: 'Paste', accelerator: 'CmdOrCtrl+V', role: 'paste' }, - { label: 'Select All', accelerator: 'CmdOrCtrl+A', role: 'selectall' } - ] +if (fs.existsSync(appPathUserData)) { + var versionLocal = require('./package.json').version; + try { + var versionUserData = require(path.join(userDataDir, 'package.json')).version; + versionLocal = versionLocal.split('.'); + versionUserData = versionUserData.split('.'); + for (var i = 0; i < versionLocal.length; i++) { + if (+versionUserData[i] > +versionLocal[i]) { + appPath = appPathUserData; + break; } - ]; - var menu = Menu.buildFromTemplate(template); - Menu.setApplicationMenu(menu); + if (+versionUserData[i] < +versionLocal[i]) { + break; + } + } + } + catch (e) { + console.error('Error reading user file version', e); } } -function notifyOpenFile() { - if (ready && openFile && mainWindow) { - openFile = openFile.replace(/"/g, '\\"').replace(/\\/g, '\\\\'); - mainWindow.webContents.executeJavaScript('if (window.launcherOpen) { window.launcherOpen("' + openFile + '"); } ' + - ' else { window.launcherOpenedFile="' + openFile + '"; }'); - openFile = null; - } -} +require(appPath); diff --git a/electron/package.json b/electron/package.json index 32c4c597..e5e7b1e9 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,5 +1,13 @@ { "name": "KeeWeb", "version": "0.2.1", - "main": "main.js" + "description": "KeePass web app", + "main": "main.js", + "repository": "https://github.com/antelle/keeweb", + "author": "Antelle", + "license": "MIT", + "readme": "../README.md", + "dependencies": { + "node-stream-zip": "^1.2.1" + } } diff --git a/grunt/tasks/grunt-validate-desktop-update.js b/grunt/tasks/grunt-validate-desktop-update.js new file mode 100644 index 00000000..17246313 --- /dev/null +++ b/grunt/tasks/grunt-validate-desktop-update.js @@ -0,0 +1,31 @@ +'use strict'; + +module.exports = function (grunt) { + grunt.registerMultiTask('validate-desktop-update', 'Validates desktop update package', function () { + var path = require('path'); + var done = this.async(); + var StreamZip = require(path.resolve(__dirname, '../../electron/node_modules/node-stream-zip')); + var zip = new StreamZip({ file: this.options().file, storeEntries: true }); + var expFiles = this.options().expected; + zip.on('error', function(err) { + grunt.warn(err); + }); + zip.on('ready', function() { + var valid = true; + expFiles.forEach(function(entry) { + try { + if (!zip.entryDataSync(entry)) { + grunt.warn('Corrupted entry in desktop update archive: ' + entry); + valid = false; + } + } catch (e) { + grunt.warn('Entry not found in desktop update archive: ' + entry); + valid = false; + } + }); + if (valid) { + done(); + } + }); + }); +}; diff --git a/package.json b/package.json index 8fd3fcef..0b9b8bf7 100644 --- a/package.json +++ b/package.json @@ -32,12 +32,14 @@ "time-grunt": "^1.2.1", "uglify-loader": "^1.2.0", "webpack": "^1.11.0", - "webpack-dev-server": "^1.10.1" + "webpack-dev-server": "^1.12.1" }, "scripts": { "start": "grunt", - "test": "grunt test" + "test": "grunt test", + "postinstall": "npm install --prefix electron" }, "author": "Antelle", - "license": "MIT" + "license": "MIT", + "readme": "README.md" }