disabled desktop update archives

This commit is contained in:
antelle 2021-01-08 22:54:45 +01:00
parent bee96ebf15
commit 75fa636366
No known key found for this signature in database
GPG Key ID: 63C9777AAB7C563C
20 changed files with 176 additions and 296 deletions

View File

@ -95,11 +95,6 @@ jobs:
with:
name: KeeWeb-${{ steps.get_tag.outputs.tag }}.linux.x86_64.rpm
path: dist/desktop/KeeWeb-${{ steps.get_tag.outputs.tag }}.linux.x86_64.rpm
- name: Upload update artifact
uses: actions/upload-artifact@v1
with:
name: UpdateDesktop.zip
path: dist/desktop/UpdateDesktop.zip
darwin:
runs-on: macos-latest
@ -124,9 +119,6 @@ jobs:
path: dist
- name: Install npm modules
run: npm ci
- name: Install desktop npm modules
working-directory: desktop
run: npm ci
- name: Install grunt
run: sudo npm i -g grunt-cli
- name: Write secrets
@ -179,9 +171,6 @@ jobs:
path: dist
- name: Install npm modules
run: npm ci
- name: Install desktop npm modules
working-directory: desktop
run: npm ci
- name: Install grunt
run: npm i -g grunt-cli
- name: Write secrets
@ -328,11 +317,6 @@ jobs:
with:
name: KeeWeb-${{ steps.get_tag.outputs.tag }}.win.arm64.zip
path: assets
- name: Download update artifact
uses: actions/download-artifact@v1
with:
name: UpdateDesktop.zip
path: assets
- name: Zip html
working-directory: html
run: zip -vr ../assets/KeeWeb-${{ steps.get_tag.outputs.tag }}.html.zip .
@ -505,15 +489,6 @@ jobs:
asset_path: assets/KeeWeb-${{ steps.get_tag.outputs.tag }}.win.arm64.zip
asset_name: KeeWeb-${{ steps.get_tag.outputs.tag }}.win.arm64.zip
asset_content_type: application/octet-stream
- name: Upload update asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: assets/UpdateDesktop.zip
asset_name: UpdateDesktop.zip
asset_content_type: application/octet-stream
- name: Upload verify.sign asset
uses: actions/upload-release-asset@v1
env:

View File

@ -138,18 +138,6 @@ module.exports = function (grunt) {
expand: true,
nonull: true
},
'desktop-update': {
cwd: 'tmp/desktop/keeweb-linux-x64/resources/',
src: 'app.asar',
dest: 'tmp/desktop/update/',
expand: true,
nonull: true
},
'desktop-update-helper': {
src: ['helper/darwin/KeeWebHelper', 'helper/win32/KeeWebHelper.exe'],
dest: 'tmp/desktop/update/',
nonull: true
},
'desktop-darwin-helper-x64': {
src: 'helper/darwin/KeeWebHelper',
dest: 'tmp/desktop/KeeWeb-darwin-x64/KeeWeb.app/Contents/Resources/',
@ -461,13 +449,6 @@ module.exports = function (grunt) {
options: {
level: 6
},
'desktop-update': {
options: {
archive: 'dist/desktop/UpdateDesktop.zip',
comment: zipCommentPlaceholder
},
files: [{ cwd: 'tmp/desktop/update', src: '**', expand: true, nonull: true }]
},
'win32-x64': {
options: { archive: `dist/desktop/KeeWeb-${pkg.version}.win.x64.zip` },
files: [{ cwd: 'tmp/desktop/KeeWeb-win32-x64', src: '**', expand: true }]
@ -599,35 +580,6 @@ module.exports = function (grunt) {
]
}
},
'sign-archive': {
'desktop-update': {
options: {
file: 'dist/desktop/UpdateDesktop.zip',
signature: zipCommentPlaceholder
}
}
},
'sign-desktop-files': {
'desktop-update': {
options: {
path: 'tmp/desktop/update'
}
}
},
'validate-desktop-update': {
desktop: {
options: {
file: 'dist/desktop/UpdateDesktop.zip',
expected: [
'app.asar',
'helper/darwin/KeeWebHelper',
'helper/win32/KeeWebHelper.exe'
],
expectedCount: 7,
publicKey: 'app/resources/public-key.pem'
}
}
},
'osx-sign': {
options: {
get identity() {
@ -758,10 +710,7 @@ module.exports = function (grunt) {
sign: 'dist/desktop/Verify.sign.sha256'
},
files: {
'dist/desktop/Verify.sha256': [
'dist/desktop/KeeWeb-*',
'dist/desktop/UpdateDesktop.zip'
]
'dist/desktop/Verify.sha256': ['dist/desktop/KeeWeb-*']
}
}
},

View File

@ -1,3 +1,4 @@
import kdbxweb from 'kdbxweb';
import { Events } from 'framework/events';
import { RuntimeInfo } from 'const/runtime-info';
import { Transport } from 'comp/browser/transport';
@ -15,7 +16,6 @@ const Updater = {
UpdateInterval: 1000 * 60 * 60 * 24,
MinUpdateTimeout: 500,
MinUpdateSize: 10000,
UpdateCheckFiles: ['app.asar'],
nextCheckTimeout: null,
updateCheckDate: new Date(0),
enabled: Launcher && Launcher.updaterEnabled(),
@ -34,7 +34,7 @@ const Updater = {
updateInProgress() {
return (
UpdateModel.status === 'checking' ||
['downloading', 'extracting'].indexOf(UpdateModel.updateStatus) >= 0
['downloading', 'extracting', 'updating'].indexOf(UpdateModel.updateStatus) >= 0
);
},
@ -180,28 +180,61 @@ const Updater = {
}
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;
const useCache = !startedByUser;
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);
url: updateAssetUrl,
file: updateAssetName,
cleanupOldFiles: true,
cache: useCache,
success: (assetFilePath) => {
logger.info('Downloading update signatures');
Transport.httpGet({
url: updateUrlBasePath + 'Verify.sign.sha256',
text: true,
file: updateAssetName + '.sign',
cleanupOldFiles: true,
cache: useCache,
success: (assetFileSignaturePath) => {
this.verifySignature(assetFilePath, updateAssetName, (err, valid) => {
if (err) {
UpdateModel.set({
updateStatus: 'error',
updateError: 'Error verifying update signature'
});
return;
}
if (!valid) {
UpdateModel.set({
updateStatus: 'error',
updateError: '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 extracting update'
updateError: 'Error downloading update signatures'
});
} else {
UpdateModel.set({ updateStatus: 'ready', updateError: null });
if (!startedByUser) {
Events.emit('update-app');
}
if (typeof successCallback === 'function') {
successCallback();
}
}
});
},
@ -215,61 +248,64 @@ const Updater = {
});
},
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;
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);
});
if (!containsAll) {
return cb('Bad archive');
}
this.validateArchiveSignature(updateFile, zip)
.then(() => {
zip.extract(null, appPath, (err) => {
zip.close();
if (err) {
return cb(err);
}
Launcher.deleteFile(updateFile);
cb();
});
})
.catch((e) => {
return cb('Invalid archive: ' + e);
});
});
},
validateArchiveSignature(archivePath, zip) {
if (!zip.comment) {
return Promise.reject('No comment in ZIP');
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;
}
if (zip.comment.length !== 512) {
return Promise.reject('Bad comment length in ZIP: ' + zip.comment.length);
}
try {
const zipFileData = Launcher.req('fs').readFileSync(archivePath);
const dataToVerify = zipFileData.slice(0, zip.centralDirectory.headerOffset + 22);
const signature = window.Buffer.from(zip.comment, 'hex');
return SignatureVerifier.verify(dataToVerify, signature)
.catch(() => {
throw new Error('Error verifying signature');
})
.then((isValid) => {
if (!isValid) {
throw new Error('Invalid signature');
}
});
} catch (err) {
return Promise.reject(err.toString());
return undefined;
},
installAndRestart() {
if (!Launcher) {
return;
}
const updateAssetName = this.getUpdateAssetName(UpdateModel.lastVersion);
const updateFilePath = Transport.cacheFilePath(updateAssetName);
Launcher.requestRestartAndUpdate(updateFilePath);
}
};

View File

@ -1,15 +1,33 @@
import { Launcher } from 'comp/launcher';
import { Logger } from 'util/logger';
import { noop } from 'util/fn';
import { StringFormat } from 'util/formatting/string-format';
const logger = new Logger('transport');
const Transport = {
cacheFilePath(fileName) {
return Launcher.getTempPath(fileName);
},
httpGet(config) {
let tmpFile;
const fs = Launcher.req('fs');
if (config.file) {
tmpFile = Launcher.getTempPath(config.file);
const baseTempPath = Launcher.getTempPath();
if (config.cleanupOldFiles) {
const allFiles = fs.readdirSync(baseTempPath);
for (const file of allFiles) {
if (
file !== config.file &&
StringFormat.replaceVersion(file, '0') ===
StringFormat.replaceVersion(config.file, '0')
) {
fs.unlinkSync(Launcher.joinPath(baseTempPath, file));
}
}
}
tmpFile = Launcher.joinPath(baseTempPath, config.file);
if (fs.existsSync(tmpFile)) {
try {
if (config.cache && fs.statSync(tmpFile).size > 0) {
@ -62,8 +80,10 @@ const Transport = {
});
res.on('end', () => {
data = window.Buffer.concat(data);
if (config.json) {
if (config.text || config.json) {
data = data.toString('utf8');
}
if (config.json) {
try {
data = JSON.parse(data);
} catch (e) {

View File

@ -17,6 +17,9 @@ const Launcher = {
platform() {
return process.platform;
},
arch() {
return process.arch;
},
electron() {
return this.req('electron');
},
@ -55,7 +58,15 @@ const Launcher = {
return this.joinPath(this.userDataPath, fileName || '');
},
getTempPath(fileName) {
return this.joinPath(this.remoteApp().getPath('temp'), fileName || '');
let tempPath = this.joinPath(this.remoteApp().getPath('temp'), 'KeeWeb');
const fs = this.req('fs');
if (!fs.existsSync(tempPath)) {
fs.mkdirSync(tempPath);
}
if (fileName) {
tempPath = this.joinPath(tempPath, fileName);
}
return tempPath;
},
getDocumentsPath(fileName) {
return this.joinPath(this.remoteApp().getPath('documents'), fileName || '');
@ -164,18 +175,18 @@ const Launcher = {
requestExit() {
const app = this.remoteApp();
app.setHookBeforeQuitEvent(false);
if (this.restartPending) {
app.restartApp();
if (this.pendingUpdateFile) {
app.restartAndUpdate(this.pendingUpdateFile);
} else {
app.quit();
}
},
requestRestart() {
this.restartPending = true;
requestRestartAndUpdate(updateFilePath) {
this.pendingUpdateFile = updateFilePath;
this.requestExit();
},
cancelRestart() {
this.restartPending = false;
this.pendingUpdateFile = undefined;
},
setClipboardText(text) {
return this.electron().clipboard.writeText(text);

View File

@ -7,7 +7,7 @@ const Links = {
License: 'https://github.com/keeweb/keeweb/blob/master/LICENSE',
LicenseApache: 'https://opensource.org/licenses/Apache-2.0',
LicenseLinkCCBY40: 'https://creativecommons.org/licenses/by/4.0/',
UpdateDesktop: 'https://github.com/keeweb/keeweb/releases/download/v{ver}/UpdateDesktop.zip',
UpdateBasePath: 'https://github.com/keeweb/keeweb/releases/download/v{ver}/',
ReleaseNotes: 'https://github.com/keeweb/keeweb/blob/master/release-notes.md#release-notes',
SelfHostedDropbox: 'https://github.com/keeweb/keeweb#self-hosting',
UpdateJson: 'https://app.keeweb.info/update.json',

View File

@ -373,7 +373,7 @@
"setGenExtractingUpdate": "Extracting update...",
"setGenCheckErr": "There was an error downloading new version",
"setGenNeverChecked": "Never checked for updates",
"setGenRestartToUpdate": "Restart the app to update",
"setGenRestartToUpdate": "Restart KeeWeb to update",
"setGenDownloadAndRestart": "Download update and restart",
"setGenAppearance": "Appearance",
"setGenTheme": "Theme",

View File

@ -29,6 +29,10 @@ const StringFormat = {
pascalCase(str) {
return this.capFirst(str.replace(this.camelCaseRegex, (match) => match[1].toUpperCase()));
},
replaceVersion(str, replacement) {
return str.replace(/\d+\.\d+\.\d+/g, replacement);
}
};

View File

@ -55,7 +55,7 @@ class SettingsGeneralView extends View {
'changeFieldLabelDblClickAutoType',
'change .settings__general-titlebar-style': 'changeTitlebarStyle',
'click .settings__general-update-btn': 'checkUpdate',
'click .settings__general-restart-btn': 'restartApp',
'click .settings__general-restart-btn': 'installUpdateAndRestart',
'click .settings__general-download-update-btn': 'downloadUpdate',
'click .settings__general-update-found-btn': 'installFoundUpdate',
'change .settings__general-prv-check': 'changeStorageEnabled',
@ -421,9 +421,9 @@ class SettingsGeneralView extends View {
Events.emit('refresh');
}
restartApp() {
installUpdateAndRestart() {
if (Launcher) {
Launcher.requestRestart();
Updater.installAndRestart();
} else {
window.location.reload();
}
@ -435,7 +435,7 @@ class SettingsGeneralView extends View {
installFoundUpdate() {
Updater.update(true, () => {
Launcher.requestRestart();
Updater.installAndRestart();
});
}

View File

@ -37,7 +37,6 @@
{{#if isDesktop}}
<h3>Desktop modules</h3>
<ul>
<li><a href="https://github.com/antelle/node-stream-zip" target="_blank">node-stream-zip</a><span class="muted-color">, node.js library for fast reading of large ZIPs, &copy; 2015 Antelle</span></li>
<li><a href="https://github.com/ranisalt/node-argon2" target="_blank">node-argon2</a><span class="muted-color">, node.js bindings for Argon2 hashing algorithm, &copy; 2015 Ranieri Althoff</span></li>
<li><a href="https://github.com/tessel/node-usb" target="_blank">node-usb</a><span class="muted-color">, improved USB library for Node.js, &copy; 2012 Nonolith Labs, LLC</span></li>
<li><a href="https://github.com/atom/node-keytar" target="_blank">node-keytar</a><span class="muted-color">, native password node module, &copy; 2013 GitHub Inc.</span></li>

View File

@ -1,28 +0,0 @@
module.exports = function (grunt) {
grunt.registerMultiTask('sign-archive', 'Signs archive with a private key', function () {
const done = this.async();
const fs = require('fs');
const sign = require('../util/sign');
const file = fs.readFileSync(this.options().file);
const ix = file.toString('binary').lastIndexOf(this.options().signature);
if (ix < 0) {
grunt.warn('Signature placeholder not found');
return;
}
const data = file.slice(0, ix);
sign(grunt, data).then((signature) => {
signature = Buffer.from(signature.toString('hex'), 'binary');
if (
signature.byteLength !== Buffer.from(this.options().signature, 'binary').byteLength
) {
grunt.warn('Bad signature length');
return;
}
for (let i = 0; i < signature.byteLength; i++) {
file[ix + i] = signature[i];
}
fs.writeFileSync(this.options().file, file);
done();
});
});
};

View File

@ -1,63 +0,0 @@
module.exports = function (grunt) {
grunt.registerMultiTask(
'validate-desktop-update',
'Validates desktop update package',
function () {
const path = require('path');
const crypto = require('crypto');
const fs = require('fs');
const done = this.async();
const StreamZip = require(path.resolve(
__dirname,
'../../desktop/node_modules/node-stream-zip'
));
const zip = new StreamZip({ file: this.options().file, storeEntries: true });
const expFiles = this.options().expected;
const expFilesCount = this.options().expectedCount;
const publicKey = fs.readFileSync(this.options().publicKey, 'binary');
const zipFileData = fs.readFileSync(this.options().file);
zip.on('error', (err) => {
grunt.warn(err);
});
zip.on('ready', () => {
let valid = true;
if (!zip.comment) {
grunt.warn('No comment in ZIP');
return;
}
if (zip.comment.length !== 512) {
grunt.warn('Bad comment length in ZIP');
return;
}
const verify = crypto.createVerify('RSA-SHA256');
verify.write(zipFileData.slice(0, zip.centralDirectory.headerOffset + 22));
verify.end();
const signature = Buffer.from(zip.comment, 'hex');
if (!verify.verify(publicKey, signature)) {
grunt.warn('Invalid ZIP signature');
return;
}
if (zip.entriesCount !== expFilesCount) {
grunt.warn(
`ZIP contains ${zip.entriesCount} entries, expected ${expFilesCount}`
);
valid = false;
}
expFiles.forEach((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();
}
});
}
);
};

View File

@ -13,7 +13,7 @@ let mainWindow = null;
let appIcon = null;
let ready = false;
let appReady = false;
let restartPending = false;
let pendingUpdateFilePath;
let mainWindowPosition = {};
let updateMainWindowPositionTimeout = null;
let mainWindowMaximized = false;
@ -95,9 +95,8 @@ const settingsPromise = loadSettingsEncryptionKey().then((key) => {
});
main.on('window-all-closed', () => {
if (restartPending) {
main.relaunch();
main.exit(0);
if (pendingUpdateFilePath) {
exitAndStartUpdate();
} else {
if (process.platform !== 'darwin') {
main.quit();
@ -160,11 +159,11 @@ main.on('web-contents-created', (event, contents) => {
}
});
});
main.restartApp = function () {
restartPending = true;
main.restartAndUpdate = function (updateFilePath) {
pendingUpdateFilePath = updateFilePath;
mainWindow.close();
setTimeout(() => {
restartPending = false;
pendingUpdateFilePath = undefined;
}, 1000);
};
main.minimizeApp = function (menuItemLabels) {
@ -947,3 +946,10 @@ function httpRequest(config, log, onLoad) {
}
req.end();
}
function exitAndStartUpdate() {
if (pendingUpdateFilePath) {
// TODO: install the update
main.exit(0);
}
}

View File

@ -1,13 +0,0 @@
{
"name": "KeeWeb",
"version": "1.16.7",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"node-stream-zip": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.4.2.tgz",
"integrity": "sha512-9WbviH8qi0HgHmHbQleydUZToH3vNlN83CyOrhrwGaPzmBPH1SUYUkFyHjWl6GWMMODlM81qgycZEfWm267gKA=="
}
}
}

View File

@ -14,9 +14,5 @@
"url": "http://antelle.net"
},
"license": "MIT",
"readme": "../README.md",
"dependencies": {
"node-stream-zip": "^1.4.2"
},
"devDependencies": {}
"readme": "../README.md"
}

View File

@ -24,7 +24,6 @@ module.exports = function(grunt) {
'clean:desktop',
'build-desktop-app-content',
'build-desktop-executables-linux',
'build-desktop-update',
'build-desktop-archives-linux',
'build-desktop-dist-linux'
]);

View File

@ -24,15 +24,6 @@ module.exports = function (grunt) {
'string-replace:desktop-public-key'
]);
grunt.registerTask('build-desktop-update', [
'copy:desktop-update',
'copy:desktop-update-helper',
'sign-desktop-files:desktop-update',
'compress:desktop-update',
'sign-archive:desktop-update',
'validate-desktop-update'
]);
grunt.registerTask('build-desktop-executables-linux', [
'electron:linux',
'chmod:linux-desktop-x64',
@ -126,7 +117,6 @@ module.exports = function (grunt) {
'clean:desktop',
'build-desktop-app-content',
'build-desktop-executables',
'build-desktop-update',
'build-desktop-archives',
'build-desktop-dist',
'sign-dist'

5
package-lock.json generated
View File

@ -11412,11 +11412,6 @@
}
}
},
"node-stream-zip": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.12.0.tgz",
"integrity": "sha512-HZ3XehqShTFj9gHauRJ3Bri9eiCTOII7/crtXzURtT14NdnOFs9Ia5E82W7z3izVBNx760tqwddxrBJVG52Y1Q=="
},
"noop-logger": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/noop-logger/-/noop-logger-0.1.1.tgz",

View File

@ -76,7 +76,6 @@
"morphdom": "^2.6.1",
"node-fetch": "^2.6.1",
"node-sass": "^5.0.0",
"node-stream-zip": "1.12.0",
"normalize.css": "8.0.1",
"optimize-css-assets-webpack-plugin": "^5.0.4",
"pikaday": "1.8.2",
@ -109,7 +108,6 @@
"scripts": {
"start": "grunt",
"test": "grunt test",
"postinstall": "cd desktop && npm install",
"build-beta": "grunt --beta && cp dist/index.html ../keeweb-beta/index.html && cd ../keeweb-beta && git add index.html && git commit -a -m 'beta' && git push origin master",
"electron": "cross-env KEEWEB_IS_PORTABLE=0 ELECTRON_DISABLE_SECURITY_WARNINGS=1 KEEWEB_HTML_PATH=http://localhost:8085 electron desktop --no-sandbox",
"dev": "grunt dev",

View File

@ -29,4 +29,10 @@ describe('StringFormat', () => {
it('should convert kebab case to pascal case', () => {
expect(StringFormat.pascalCase('aa-bbb-c')).to.eql('AaBbbC');
});
it('should replace version', () => {
expect(StringFormat.replaceVersion('KeeWeb-1.11.123.x64.dmg', 'ver')).to.eql(
'KeeWeb-ver.x64.dmg'
);
});
});