Compare commits

..
17 Commits
Author SHA1 Message Date
antelle 1930c265c8 Merge branch 'master' into release-1.18 2021-07-18 16:21:06 +02:00
antelle 1c434d7c79 release notes 2021-07-18 16:20:52 +02:00
antelle c0c75ac8a8 Merge branch 'master' into release-1.18 2021-07-18 16:19:59 +02:00
antelle 4b7124aa57 disabled automatic installation of KeePassXC-Browser extension 2021-07-18 16:19:28 +02:00
antelle 93cee76b4f up translations 2021-07-18 16:09:59 +02:00
antelle 1673580dfa bump version 2021-07-18 16:06:15 +02:00
antelle bf0e94582d release notes 2021-07-18 16:06:14 +02:00
antelle 29ae7e8ed2 fix #1845: fixed an error during writing to closed socket 2021-07-18 16:06:13 +02:00
antelle 9b2c40a078 added yubikeys code logging 2021-07-18 16:06:12 +02:00
antelle f0bb382449 fixed variable usage 2021-07-18 16:06:11 +02:00
antelle 062ddb3f72 added MS Teams apps 2021-07-18 16:06:10 +02:00
antelle 208a1b551e renames some ms teams constants 2021-07-18 16:06:09 +02:00
Saulo Alves f8af64e45b renamed teams to microsoft teams; fixed grunt prettier error in onedrive 2021-07-18 16:06:08 +02:00
Saulo Alves 98268f21c0 added teams storage; added tenant id configuration for onedrive and teams 2021-07-18 16:06:07 +02:00
antelle dcbe8d70da fixed donate button border 2021-07-18 16:06:06 +02:00
Dimitri Witkowski 1d61c6ca03 Merge pull request #1838 from magnetikonline/fix-keeweb-connect-extension-link
Fix broken links to download the KeeWeb Connect browser extension
2021-06-19 20:12:38 +02:00
Peter Mescalchin 433f0acf3c fix broken links to download KeeWeb Connect browser extension due to incorrect const/Links property 2021-06-05 15:11:53 +10:00
493 changed files with 27067 additions and 34506 deletions
+1 -7
View File
@@ -1,12 +1,6 @@
{
"presets": [
["@babel/typescript", { "jsxPragma": "h" }]
],
"presets": [],
"plugins": [
["@babel/transform-react-jsx", {
"runtime": "automatic",
"importSource": "preact"
}],
["@babel/plugin-proposal-class-properties", { "loose": true }],
["@babel/plugin-proposal-object-rest-spread", { "loose": true,
"useBuiltIns": true
+69
View File
@@ -0,0 +1,69 @@
{
"plugins": ["prettier", "import", "babel"],
"extends": ["standard", "eslint:recommended", "plugin:prettier/recommended"],
"rules": {
"semi": ["error", "always"],
"one-var": "off",
"space-before-function-paren": "off",
"no-throw-literal": "off",
"camelcase": ["error", { "properties": "always" }],
"no-console": "error",
"no-alert": "error",
"no-debugger": "error",
"prefer-arrow-callback": "error",
"object-property-newline": "off",
"no-useless-escape": "off",
"no-var": "error",
"prefer-const": "error",
"no-unused-expressions": "off",
"strict": ["error", "never"],
"no-mixed-operators": "off",
"prefer-promise-reject-errors": "off",
"standard/no-callback-literal": "off",
"object-curly-spacing": "off",
"quote-props": "off",
"no-new-object": "error",
"object-shorthand": "error",
"no-array-constructor": "error",
"array-callback-return": "error",
"no-eval": "error",
"no-new-func": "error",
"prefer-rest-params": "error",
"prefer-spread": "error",
"no-useless-constructor": "error",
"no-dupe-class-members": "error",
"no-duplicate-imports": "error",
"eqeqeq": "error",
"no-unneeded-ternary": "error",
"curly": "error",
"prettier/prettier": "error",
"no-empty": "off",
"no-restricted-syntax": [
"error",
{
"selector": "ExportDefaultDeclaration",
"message": "Prefer named exports"
}
],
"import/no-webpack-loader-syntax": "off",
"import/no-relative-parent-imports": "error",
"import/first": "error",
"import/no-default-export": "error",
"babel/no-unused-expressions": "error",
"node/no-callback-literal": "off"
},
"parserOptions": {
"sourceType": "module",
"ecmaFeatures": {
"impliedStrict": true
}
},
"parser": "babel-eslint",
"globals": {
"_": true,
"$": true
},
"env": {
"browser": true
}
}
-243
View File
@@ -1,243 +0,0 @@
{
"plugins": [
"prettier",
"import",
"babel",
"react",
"@typescript-eslint"
],
"extends": [
"standard",
"eslint:recommended",
"plugin:prettier/recommended"
],
"rules": {
"semi": [
"error",
"always"
],
"one-var": "off",
"space-before-function-paren": "off",
"no-throw-literal": "off",
"camelcase": [
"error",
{
"properties": "always"
}
],
"no-console": "error",
"no-alert": "error",
"no-debugger": "error",
"prefer-arrow-callback": "error",
"object-property-newline": "off",
"no-useless-escape": "off",
"no-var": "error",
"prefer-const": "error",
"no-unused-expressions": "off",
"strict": [
"error",
"never"
],
"no-mixed-operators": "off",
"prefer-promise-reject-errors": "off",
"standard/no-callback-literal": "off",
"object-curly-spacing": "off",
"quote-props": "off",
"no-new-object": "error",
"object-shorthand": "error",
"no-array-constructor": "error",
"array-callback-return": "error",
"no-eval": "error",
"no-new-func": "error",
"prefer-rest-params": "error",
"prefer-spread": "error",
"no-useless-constructor": "error",
"no-dupe-class-members": "error",
"no-duplicate-imports": "error",
"eqeqeq": "error",
"no-unneeded-ternary": "error",
"curly": "error",
"prettier/prettier": "error",
"no-empty": "off",
"no-restricted-syntax": [
"error",
{
"selector": "ExportDefaultDeclaration",
"message": "Prefer named exports"
}
],
"import/no-webpack-loader-syntax": "off",
"import/no-relative-parent-imports": "error",
"import/first": "error",
"import/no-default-export": "error",
"babel/no-unused-expressions": "error",
"node/no-callback-literal": "off"
},
"parserOptions": {
"sourceType": "module",
"ecmaFeatures": {
"impliedStrict": true
}
},
"parser": "babel-eslint",
"overrides": [
{
"files": [
"*.ts",
"*.tsx"
],
"extends": [
"standard",
"eslint:recommended",
"preact",
"plugin:prettier/recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking",
"plugin:import/typescript"
],
"rules": {
"semi": [
"error",
"always"
],
"one-var": "off",
"space-before-function-paren": "off",
"no-throw-literal": "error",
"camelcase": [
"error",
{
"properties": "always"
}
],
"no-console": "error",
"no-alert": "error",
"no-debugger": "error",
"prefer-arrow-callback": "error",
"object-property-newline": "off",
"no-useless-escape": "off",
"no-var": "error",
"prefer-const": "error",
"no-unused-expressions": "off",
"strict": [
"error",
"never"
],
"no-mixed-operators": "off",
"prefer-promise-reject-errors": "off",
"standard/no-callback-literal": "off",
"object-curly-spacing": "off",
"quote-props": "off",
"no-new-object": "error",
"object-shorthand": "error",
"no-array-constructor": "error",
"array-callback-return": "error",
"no-eval": "error",
"no-new-func": "error",
"prefer-rest-params": "error",
"prefer-spread": "error",
"no-useless-constructor": "error",
"no-dupe-class-members": "off",
"no-duplicate-imports": "error",
"eqeqeq": "error",
"no-unneeded-ternary": "error",
"curly": "error",
"prettier/prettier": "error",
"no-empty": "off",
"no-restricted-syntax": [
"error",
{
"selector": "ExportDefaultDeclaration",
"message": "Prefer named exports"
}
],
"import/no-webpack-loader-syntax": "off",
"import/no-relative-parent-imports": "error",
"import/first": "error",
"import/no-default-export": "error",
"babel/no-unused-expressions": "error",
"node/no-callback-literal": "off",
"import/no-commonjs": "off",
"prefer-template": "off",
"no-else-return": "off",
"jest/valid-expect": "off",
"jest/expect-expect": "off",
"react-hooks/exhaustive-deps": "off",
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/explicit-module-boundary-types": "error",
"@typescript-eslint/no-non-null-assertion": "error",
"@typescript-eslint/no-var-requires": "off",
"@typescript-eslint/naming-convention": [
"error",
{
"selector": ["objectLiteralProperty", "classProperty", "typeProperty"],
"modifiers": ["private", "protected"],
"format": ["camelCase"],
"leadingUnderscore": "require",
"trailingUnderscore": "forbid"
},
{
"selector": ["classProperty", "typeProperty"],
"modifiers": ["public"],
"format": ["camelCase", "PascalCase", "UPPER_CASE", "snake_case"],
"leadingUnderscore": "forbid",
"trailingUnderscore": "forbid"
},
{
"selector": ["method"],
"format": ["camelCase"],
"leadingUnderscore": "forbid",
"trailingUnderscore": "forbid"
},
{
"selector": ["class", "enum"],
"format": ["PascalCase"],
"leadingUnderscore": "forbid",
"trailingUnderscore": "forbid"
},
{
"selector": ["enumMember"],
"format": ["PascalCase", "UPPER_CASE"],
"leadingUnderscore": "forbid",
"trailingUnderscore": "forbid"
},
{
"selector": ["variable"],
"format": ["camelCase", "PascalCase", "UPPER_CASE"],
"leadingUnderscore": "forbid",
"trailingUnderscore": "forbid"
}
]
},
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": 11,
"sourceType": "module",
"project": [
"tsconfig.json",
"jsconfig.json"
]
}
}
],
"settings": {
"import/parsers": {
"@typescript-eslint/parser": [
".ts",
".tsx"
]
}
},
"globals": {
"_": true,
"$": true
},
"env": {
"browser": true,
"node": true,
"es2020": true
}
}
+13 -11
View File
@@ -25,12 +25,10 @@ jobs:
registry-url: 'https://registry.npmjs.org'
- name: Install npm modules
run: npm ci
- name: Lint
run: npm lint
- name: Test
run: npm test
- name: Build
run: npm start
- name: Grunt
run: grunt
- name: Upload artifact
uses: actions/upload-artifact@v1
with:
@@ -43,7 +41,7 @@ jobs:
mkdir keys
echo "$VIRUS_TOTAL" > keys/virus-total.json
- name: Check on VirusTotal
run: npm start virustotal
run: grunt virustotal
if: ${{ github.repository == 'keeweb/keeweb' }}
linux:
@@ -131,6 +129,8 @@ jobs:
registry-url: 'https://registry.npmjs.org'
- name: Install npm modules
run: npm ci
- name: Install grunt
run: sudo npm i -g grunt-cli
- name: Write secrets
env:
CODESIGN: ${{ secrets.CODESIGN }}
@@ -147,8 +147,8 @@ jobs:
with:
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- name: Build
run: npm start -- --max-old-space-size=4096 desktop-darwin ${{ github.repository != 'keeweb/keeweb' && '--skip-sign' || '' }}
- name: Grunt
run: grunt --max-old-space-size=4096 desktop-darwin ${{ github.repository != 'keeweb/keeweb' && '--skip-sign' || '' }}
- name: Upload x64 dmg artifact
uses: actions/upload-artifact@v1
with:
@@ -188,6 +188,8 @@ jobs:
registry-url: 'https://registry.npmjs.org'
- name: Install npm modules
run: npm ci
- name: Install grunt
run: npm i -g grunt-cli
- name: Write secrets
env:
CODESIGN: ${{ secrets.CODESIGN }}
@@ -201,8 +203,8 @@ jobs:
echo $Env:MS_CODESIGN_PRIVATE_KEY_CLIENT > keys/code-signing/private-key-client.pem
echo $Env:MS_CODESIGN_PUBLIC_KEY_CLIENT > keys/code-signing/public-key-client.pem
echo $Env:MS_CODESIGN_PUBLIC_KEY_SERVER > keys/code-signing/public-key-server.pem
- name: Build
run: npm start -- desktop-win32 ${{ github.repository != 'keeweb/keeweb' && '--skip-sign' || '' }}
- name: Grunt
run: grunt desktop-win32 ${{ github.repository != 'keeweb/keeweb' && '--skip-sign' || '' }}
- name: Upload ia32 exe artifact
uses: actions/upload-artifact@v1
with:
@@ -351,9 +353,9 @@ jobs:
mkdir keys
echo "$PRIVATE_KEY" > keys/private-key.pem
echo "$KEEWEB_SIGN" > keys/keeweb-sign.json
- name: Build
- name: Grunt
working-directory: keeweb
run: npm start finish-release
run: grunt finish-release
- name: Copy signatures to assets
run: cp keeweb/dist/desktop/Verify.sign.sha256 assets
- name: Copy checksums to assets
+70 -14
View File
@@ -1,10 +1,12 @@
/* eslint-env node */
const fs = require('fs-extra');
const path = require('path');
const { execSync } = require('child_process');
const debug = require('debug');
const webpackConfig = require('./build/webpack.config');
const webpackConfigTest = require('./test/test.webpack.config');
const pkg = require('./package.json');
debug.enable('electron-notarize');
@@ -91,6 +93,11 @@ module.exports = function (grunt) {
desktop: ['tmp/desktop', 'dist/desktop']
},
copy: {
html: {
src: 'app/index.html',
dest: 'tmp/index.html',
nonull: true
},
'content-dist': {
cwd: 'app/content/',
src: '**',
@@ -98,6 +105,13 @@ module.exports = function (grunt) {
expand: true,
nonull: true
},
icons: {
cwd: 'app/icons/',
src: ['*.png', '*.svg'],
dest: 'tmp/icons/',
expand: true,
nonull: true
},
'dist-icons': {
cwd: 'app/icons/',
src: ['*.png', '*.svg'],
@@ -105,6 +119,13 @@ module.exports = function (grunt) {
expand: true,
nonull: true
},
manifest: {
cwd: 'app/manifest/',
src: ['*.json', '*.xml'],
dest: 'tmp/',
expand: true,
nonull: true
},
'dist-manifest': {
cwd: 'app/manifest/',
src: ['*.json', '*.xml'],
@@ -127,7 +148,8 @@ module.exports = function (grunt) {
'desktop-darwin-installer-helper-x64': {
cwd: 'tmp/desktop/KeeWeb Installer.app',
src: '**',
dest: 'tmp/desktop/KeeWeb-darwin-x64/KeeWeb.app/Contents/Installer/KeeWeb Installer.app',
dest:
'tmp/desktop/KeeWeb-darwin-x64/KeeWeb.app/Contents/Installer/KeeWeb Installer.app',
expand: true,
nonull: true,
options: { mode: true }
@@ -135,7 +157,8 @@ module.exports = function (grunt) {
'desktop-darwin-installer-helper-arm64': {
cwd: 'tmp/desktop/KeeWeb Installer.app',
src: '**',
dest: 'tmp/desktop/KeeWeb-darwin-arm64/KeeWeb.app/Contents/Installer/KeeWeb Installer.app',
dest:
'tmp/desktop/KeeWeb-darwin-arm64/KeeWeb.app/Contents/Installer/KeeWeb Installer.app',
expand: true,
nonull: true,
options: { mode: true }
@@ -206,43 +229,50 @@ module.exports = function (grunt) {
nonull: true
},
'native-messaging-host-darwin-x64': {
src: 'node_modules/@keeweb/keeweb-native-messaging-host/darwin-x64/keeweb-native-messaging-host',
dest: 'tmp/desktop/KeeWeb-darwin-x64/KeeWeb.app/Contents/MacOS/util/keeweb-native-messaging-host',
src:
'node_modules/@keeweb/keeweb-native-messaging-host/darwin-x64/keeweb-native-messaging-host',
dest:
'tmp/desktop/KeeWeb-darwin-x64/KeeWeb.app/Contents/MacOS/util/keeweb-native-messaging-host',
nonull: true,
options: { mode: '0755' }
},
'native-messaging-host-darwin-arm64': {
src: 'node_modules/@keeweb/keeweb-native-messaging-host/darwin-arm64/keeweb-native-messaging-host',
dest: 'tmp/desktop/KeeWeb-darwin-arm64/KeeWeb.app/Contents/MacOS/util/keeweb-native-messaging-host',
src:
'node_modules/@keeweb/keeweb-native-messaging-host/darwin-arm64/keeweb-native-messaging-host',
dest:
'tmp/desktop/KeeWeb-darwin-arm64/KeeWeb.app/Contents/MacOS/util/keeweb-native-messaging-host',
nonull: true,
options: { mode: '0755' }
},
'native-messaging-host-linux-x64': {
src: 'node_modules/@keeweb/keeweb-native-messaging-host/linux-x64/keeweb-native-messaging-host',
src:
'node_modules/@keeweb/keeweb-native-messaging-host/linux-x64/keeweb-native-messaging-host',
dest: 'tmp/desktop/keeweb-linux-x64/keeweb-native-messaging-host',
nonull: true,
options: { mode: '0755' }
},
'native-messaging-host-win32-x64': {
src: 'node_modules/@keeweb/keeweb-native-messaging-host/win32-x64/keeweb-native-messaging-host.exe',
src:
'node_modules/@keeweb/keeweb-native-messaging-host/win32-x64/keeweb-native-messaging-host.exe',
dest: 'tmp/desktop/KeeWeb-win32-x64/keeweb-native-messaging-host.exe',
nonull: true
},
'native-messaging-host-win32-ia32': {
src: 'node_modules/@keeweb/keeweb-native-messaging-host/win32-ia32/keeweb-native-messaging-host.exe',
src:
'node_modules/@keeweb/keeweb-native-messaging-host/win32-ia32/keeweb-native-messaging-host.exe',
dest: 'tmp/desktop/KeeWeb-win32-ia32/keeweb-native-messaging-host.exe',
nonull: true
},
'native-messaging-host-win32-arm64': {
src: 'node_modules/@keeweb/keeweb-native-messaging-host/win32-arm64/keeweb-native-messaging-host.exe',
src:
'node_modules/@keeweb/keeweb-native-messaging-host/win32-arm64/keeweb-native-messaging-host.exe',
dest: 'tmp/desktop/KeeWeb-win32-arm64/keeweb-native-messaging-host.exe',
nonull: true
}
},
eslint: {
app: ['app/scripts/**/*.js', 'app/scripts/**/*.ts'],
test: ['test/**/*.ts'],
desktop: ['desktop/**/*.js'],
app: ['app/scripts/**/*.js'],
desktop: ['desktop/**/*.js', '!desktop/node_modules/**'],
build: ['Gruntfile.js', 'grunt.*.js', 'build/**/*.js', 'webpack.config.js'],
plugins: ['plugins/**/*.js'],
util: ['util/**/*.js'],
@@ -318,7 +348,27 @@ module.exports = function (grunt) {
}
},
webpack: {
app: webpackConfig.config(webpackOptions)
app: webpackConfig.config(webpackOptions),
test: webpackConfigTest
},
'webpack-dev-server': {
options: {
webpack: webpackConfig.config({
...webpackOptions,
mode: 'development',
sha: 'dev'
}),
publicPath: '/',
contentBase: [
path.resolve(__dirname, 'tmp'),
path.resolve(__dirname, 'app/content')
],
progress: false
},
js: {
keepalive: true,
port: 8085
}
},
electron: {
options: {
@@ -724,6 +774,12 @@ module.exports = function (grunt) {
}
}
},
'run-test': {
options: {
headless: true
},
default: 'test/runner.html'
},
virustotal: {
options: {
prefix: `keeweb.v${pkg.version}-${sha}.`,
+1 -1
View File
@@ -44,7 +44,7 @@ The app can be built with grunt: `grunt` (html files will be in `dist/`).
Desktop apps are built with `grunt desktop`. This requires some magic and currently works only on CI,
you can find more details in [the GitHub Actions workflow](.github/workflows/build.yaml).
To run the desktop (electron) app in development mode, start it this way:
To run the desktop (electron) app without building an installer, build the app with `grunt` and start it this way:
```bash
npm run dev
npm run electron
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>KeeWeb</title>
</head>
<body>
<script>
window.opener.postMessage(
{ storage: 'teams', search: location.search },
window.location.origin
);
window.close();
</script>
</body>
</html>
+8 -7
View File
@@ -73,12 +73,13 @@
<link rel="stylesheet" href="css/app.css?__inline=true" />
<script src="js/app.js?__inline=true"></script>
</head>
<body class="th-dark">
<div class="app">
<noscript>
<h1>KeeWeb</h1>
<p>KeeWeb is a password manager written in JavaScript. Please enable JavaScript to run it.</p>
</noscript>
</div>
<body class="th-d">
<noscript>
<h1>KeeWeb</h1>
<p>
KeeWeb is a password manager written in JavaScript. Please enable JavaScript to run
it.
</p>
</noscript>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
{
"rules": {
"import/no-commonjs": "error"
}
}
+212
View File
@@ -0,0 +1,212 @@
import { Events } from 'framework/events';
import { StartProfiler } from 'comp/app/start-profiler';
import { FileInfoCollection } from 'collections/file-info-collection';
import { AppRightsChecker } from 'comp/app/app-rights-checker';
import { ExportApi } from 'comp/app/export-api';
import { SingleInstanceChecker } from 'comp/app/single-instance-checker';
import { Updater } from 'comp/app/updater';
import { UsbListener } from 'comp/app/usb-listener';
import { BrowserExtensionConnector } from 'comp/extension/browser-extension-connector';
import { FeatureTester } from 'comp/browser/feature-tester';
import { FocusDetector } from 'comp/browser/focus-detector';
import { IdleTracker } from 'comp/browser/idle-tracker';
import { ThemeWatcher } from 'comp/browser/theme-watcher';
import { KeyHandler } from 'comp/browser/key-handler';
import { PopupNotifier } from 'comp/browser/popup-notifier';
import { Launcher } from 'comp/launcher';
import { SettingsManager } from 'comp/settings/settings-manager';
import { Alerts } from 'comp/ui/alerts';
import { Timeouts } from 'const/timeouts';
import { AppModel } from 'models/app-model';
import { AppSettingsModel } from 'models/app-settings-model';
import { RuntimeDataModel } from 'models/runtime-data-model';
import { UpdateModel } from 'models/update-model';
import { PluginManager } from 'plugins/plugin-manager';
import { Features } from 'util/features';
import { KdbxwebInit } from 'util/kdbxweb/kdbxweb-init';
import { Locale } from 'util/locale';
import { AppView } from 'views/app-view';
import 'hbs-helpers';
import { AutoType } from './auto-type';
import { Storage } from './storage';
StartProfiler.milestone('loading modules');
const ready = (Launcher && Launcher.ready) || $;
ready(() => {
StartProfiler.milestone('document ready');
const appModel = new AppModel();
StartProfiler.milestone('creating app model');
Promise.resolve()
.then(loadConfigs)
.then(initModules)
.then(loadRemoteConfig)
.then(ensureCanRun)
.then(initStorage)
.then(initUsbListener)
.then(showApp)
.then(postInit)
.catch((e) => {
appModel.appLogger.error('Error starting app', e);
});
function ensureCanRun() {
if (Features.isFrame && !appModel.settings.allowIframes) {
return Promise.reject(
'Running in iframe is not allowed (this can be changed in the app config).'
);
}
return FeatureTester.test()
.catch((e) => {
Alerts.error({
header: Locale.appSettingsError,
body: Locale.appNotSupportedError,
pre: e,
buttons: [],
esc: false,
enter: false,
click: false
});
throw 'Feature testing failed: ' + e;
})
.then(() => {
StartProfiler.milestone('checking features');
});
}
function loadConfigs() {
return Promise.all([
AppSettingsModel.load(),
UpdateModel.load(),
RuntimeDataModel.load(),
FileInfoCollection.load()
]).then(() => {
StartProfiler.milestone('loading configs');
});
}
function initModules() {
KeyHandler.init();
PopupNotifier.init();
KdbxwebInit.init();
FocusDetector.init();
AutoType.init();
ThemeWatcher.init();
SettingsManager.init();
window.kw = ExportApi;
return PluginManager.init().then(() => {
StartProfiler.milestone('initializing modules');
});
}
function showSettingsLoadError() {
Alerts.error({
header: Locale.appSettingsError,
body: Locale.appSettingsErrorBody,
buttons: [],
esc: false,
enter: false,
click: false
});
}
function loadRemoteConfig() {
return Promise.resolve()
.then(() => {
SettingsManager.setBySettings();
const configParam = getConfigParam();
if (configParam) {
return appModel
.loadConfig(configParam)
.then(() => {
SettingsManager.setBySettings();
})
.catch((e) => {
if (!appModel.settings.cacheConfigSettings) {
showSettingsLoadError();
throw e;
}
});
}
})
.then(() => {
StartProfiler.milestone('loading remote config');
});
}
function initStorage() {
for (const prv of Object.values(Storage)) {
prv.init();
}
StartProfiler.milestone('initializing storage');
}
function initUsbListener() {
UsbListener.init();
StartProfiler.milestone('starting usb');
}
function showApp() {
return Promise.resolve().then(() => {
const skipHttpsWarning =
localStorage.skipHttpsWarning || appModel.settings.skipHttpsWarning;
const protocolIsInsecure = ['https:', 'file:', 'app:'].indexOf(location.protocol) < 0;
const hostIsInsecure = location.hostname !== 'localhost';
if (protocolIsInsecure && hostIsInsecure && !skipHttpsWarning) {
return new Promise((resolve) => {
Alerts.error({
header: Locale.appSecWarn,
icon: 'user-secret',
esc: false,
enter: false,
click: false,
body: Locale.appSecWarnBody1 + '\n\n' + Locale.appSecWarnBody2,
buttons: [{ result: '', title: Locale.appSecWarnBtn, error: true }],
complete: () => {
showView();
resolve();
}
});
});
} else {
showView();
return new Promise((resolve) => requestAnimationFrame(resolve));
}
});
}
function postInit() {
setTimeout(() => {
Updater.init();
SingleInstanceChecker.init();
AppRightsChecker.init();
IdleTracker.init();
BrowserExtensionConnector.init(appModel);
PluginManager.runAutoUpdate();
}, Timeouts.AutoUpdatePluginsAfterStart);
}
function showView() {
new AppView(appModel).render();
StartProfiler.milestone('first view rendering');
Events.emit('app-ready');
StartProfiler.milestone('app ready event');
StartProfiler.report();
}
function getConfigParam() {
const metaConfig = document.head.querySelector('meta[name=kw-config]');
if (metaConfig && metaConfig.content && metaConfig.content[0] !== '(') {
return metaConfig.content;
}
const match = location.search.match(/[?&]config=([^&]+)/i);
if (match && match[1]) {
return match[1];
}
}
});
@@ -1,6 +1,10 @@
import { Logger } from 'util/logger';
const logger = new Logger('auto-type-obfuscator');
const logger = new Logger(
'auto-type-obfuscator',
undefined,
localStorage.debugAutoType ? Logger.Level.All : Logger.Level.Warn
);
const MaxFakeOps = 30;
const MaxSteps = 1000;
+5 -1
View File
@@ -3,7 +3,11 @@ import { AutoTypeObfuscator } from 'auto-type/auto-type-obfuscator';
import { StringFormat } from 'util/formatting/string-format';
import { Logger } from 'util/logger';
const emitterLogger = new Logger('auto-type-emitter');
const emitterLogger = new Logger(
'auto-type-emitter',
undefined,
localStorage.debugAutoType ? Logger.Level.All : Logger.Level.Info
);
const AutoTypeRunner = function (ops) {
this.ops = ops;
+17 -18
View File
@@ -33,9 +33,9 @@ const AutoType = {
const entry = (e && e.entry) || null;
const sequence = (e && e.sequence) || null;
const context = (e && e.context) || null;
logger.info('Auto type event', entry);
logger.debug('Auto type event', entry);
if (this.running) {
logger.info('Already running, skipping event');
logger.debug('Already running, skipping event');
return;
}
if (entry) {
@@ -83,19 +83,19 @@ const AutoType = {
this.running = true;
const sequence = result.sequence || result.entry.getEffectiveAutoTypeSeq();
const context = result.context;
logger.info('Start', sequence);
logger.debug('Start', sequence);
const ts = logger.ts();
try {
const parser = new AutoTypeParser(sequence);
const runner = parser.parse();
logger.info('Parsed', this.printOps(runner.ops));
logger.debug('Parsed', this.printOps(runner.ops));
runner.resolve(result.entry, context, (err) => {
if (err) {
this.running = false;
logger.error('Resolve error', err);
return callback && callback(err);
}
logger.info('Resolved', this.printOps(runner.ops));
logger.debug('Resolved', this.printOps(runner.ops));
if (result.entry.autoTypeObfuscation) {
try {
runner.obfuscate();
@@ -104,7 +104,7 @@ const AutoType = {
logger.error('Obfuscate error', e);
return callback && callback(e);
}
logger.info('Obfuscated');
logger.debug('Obfuscated');
}
runner.run((err) => {
this.running = false;
@@ -112,7 +112,7 @@ const AutoType = {
logger.error('Run error', err);
return callback && callback(err);
}
logger.info('Complete', logger.ts(ts));
logger.debug('Complete', logger.ts(ts));
return callback && callback();
}, windowId);
});
@@ -153,7 +153,7 @@ const AutoType = {
},
hideWindow(callback) {
logger.info('Hide window');
logger.debug('Hide window');
if (Launcher.isAppFocused()) {
Launcher.hideApp();
setTimeout(callback, Timeouts.AutoTypeAfterHide);
@@ -163,7 +163,7 @@ const AutoType = {
},
getActiveWindowInfo(callback) {
logger.info('Getting window info');
logger.debug('Getting window info');
const helper = new AutoTypeHelper();
return helper.getActiveWindowInfo((err, windowInfo) => {
if (err) {
@@ -171,12 +171,11 @@ const AutoType = {
} else {
if (!windowInfo.url) {
// try to find a URL in the title
const urlMatcher =
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\\+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_\\+.~#?&\/=]*)/;
const urlMatcher = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\\+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_\\+.~#?&\/=]*)/;
const urlMatches = urlMatcher.exec(windowInfo.title);
windowInfo.url = urlMatches && urlMatches.length > 0 ? urlMatches[0] : null;
}
logger.info('Window info', windowInfo.id, windowInfo.title, windowInfo.url);
logger.debug('Window info', windowInfo.id, windowInfo.title, windowInfo.url);
}
return callback(err, windowInfo);
});
@@ -184,12 +183,12 @@ const AutoType = {
activeWindowMatches(windowInfo, callback) {
if (!windowInfo || !windowInfo.id) {
logger.info('Skipped active window check because window id is unknown');
logger.debug('Skipped active window check because window id is unknown');
return callback(true);
}
this.getActiveWindowInfo((err, activeWindowInfo) => {
if (!activeWindowInfo) {
logger.info('Error during active window check, something is wrong', err);
logger.debug('Error during active window check, something is wrong', err);
return callback(false);
}
if (activeWindowInfo.id !== windowInfo.id && !this.supportsEventsWithWindowId) {
@@ -223,19 +222,19 @@ const AutoType = {
);
const evt = { filter, windowInfo };
if (!AppModel.instance.files.hasOpenFiles()) {
logger.info('auto-type event delayed');
logger.debug('auto-type event delayed');
this.focusMainWindow();
try {
await AppModel.instance.unlockAnyFile('autoTypeUnlockMessage');
} catch {
logger.info('auto-type event canceled');
logger.debug('auto-type event canceled');
return;
}
if (this.selectEntryView) {
this.selectEntryView.show();
}
}
logger.info('processing auto-type event');
logger.debug('processing auto-type event');
this.processEventWithFilter(evt);
});
},
@@ -281,7 +280,7 @@ const AutoType = {
topMessage
});
this.selectEntryView.on('result', (result) => {
logger.info('Entry selected', result);
logger.debug('Entry selected', result);
this.selectEntryView.off('result');
this.selectEntryView.remove();
this.selectEntryView = null;
-188
View File
@@ -1,188 +0,0 @@
import 'util/kdbxweb/protected-value';
import { h, render } from 'preact';
import { App } from 'ui/app';
import { Events } from 'util/events';
import { StartProfiler } from 'util/logging/start-profiler';
import { AppRightsChecker } from 'comp/launcher/app-rights-checker';
import { ExportApi } from 'comp/app/export-api';
import { SingleInstanceChecker } from 'comp/browser/single-instance-checker';
import { FocusDetector } from 'comp/browser/focus-detector';
import { IdleTracker } from 'comp/browser/idle-tracker';
import { ThemeWatcher } from 'comp/browser/theme-watcher';
import { KeyHandler } from 'comp/browser/key-handler';
import { PopupNotifier } from 'comp/browser/popup-notifier';
import { SettingsManager } from 'comp/settings/settings-manager';
import { Alerts } from 'comp/ui/alerts';
import { AppSettings } from 'models/app-settings';
import { RuntimeData } from 'models/runtime-data';
import { Features } from 'util/features';
import { KdbxwebInit } from 'comp/kdbxweb/kdbxweb-init';
import { Locale } from 'util/locale';
import { Logger } from 'util/logger';
import { FeatureTester } from 'util/browser/feature-tester';
import { UsbListener } from 'comp/devices/usb-listener';
import { noop } from 'util/fn';
import { ConfigLoader } from 'comp/settings/config-loader';
import { WindowClass } from 'comp/browser/window-class';
import { FileManager } from 'models/file-manager';
import { Updater } from './comp/app/updater';
import { Timeouts } from 'const/timeouts';
import { PluginManager } from 'plugins/plugin-manager';
import { Workspace } from 'models/workspace';
declare global {
interface Window {
kw: typeof ExportApi;
}
}
StartProfiler.milestone('loading modules');
document.addEventListener('DOMContentLoaded', () => {
bootstrap().catch(noop);
});
export async function bootstrap(): Promise<void> {
StartProfiler.milestone('document ready');
const logger = new Logger('bootstrap');
try {
await loadConfigs();
await initModules();
await loadRemoteConfig();
await ensureCanRun();
addWindowClasses();
initUsbListener();
await showApp();
postInit();
} catch (e) {
logger.error('Error starting app', e);
}
async function ensureCanRun() {
if (Features.isFrame && !AppSettings.allowIframes) {
return Promise.reject(
'Running in iframe is not allowed (this can be changed in the app config).'
);
}
try {
await FeatureTester.test();
StartProfiler.milestone('checking features');
} catch (e) {
Alerts.error({
header: Locale.appSettingsError,
body: Locale.appNotSupportedError,
pre: String(e),
buttons: [],
esc: undefined,
enter: undefined,
click: undefined
});
logger.error('Feature test failed', e);
throw new Error(`Feature test failed`);
}
}
function loadConfigs() {
return Promise.all([AppSettings.init(), RuntimeData.init(), FileManager.init()]).then(
() => {
StartProfiler.milestone('loading configs');
}
);
}
async function initModules() {
KeyHandler.init();
PopupNotifier.init();
KdbxwebInit.init();
FocusDetector.init();
// AutoType.init(); // TODO
ThemeWatcher.init();
SettingsManager.init();
await PluginManager.init();
window.kw = ExportApi;
StartProfiler.milestone('initializing modules');
}
async function loadRemoteConfig() {
SettingsManager.setBySettings();
const loaded = await ConfigLoader.loadConfig();
if (loaded) {
SettingsManager.setBySettings();
}
StartProfiler.milestone('loading remote config');
}
function addWindowClasses() {
WindowClass.init();
StartProfiler.milestone('adding window classes');
}
function initUsbListener() {
UsbListener.init();
StartProfiler.milestone('starting usb');
}
function showApp() {
return Promise.resolve().then(() => {
const skipHttpsWarning = AppSettings.skipHttpsWarning;
const protocolIsInsecure = ['https:', 'file:', 'app:'].indexOf(location.protocol) < 0;
const hostIsInsecure = location.hostname !== 'localhost';
if (protocolIsInsecure && hostIsInsecure && !skipHttpsWarning) {
return new Promise<void>((resolve) => {
Alerts.error({
header: Locale.appSecWarn,
icon: 'user-secret',
esc: undefined,
enter: undefined,
click: undefined,
body: Locale.appSecWarnBody1 + '\n\n' + Locale.appSecWarnBody2,
buttons: [{ result: '', title: Locale.appSecWarnBtn, error: true }],
complete: () => {
resolve(showView());
}
});
});
} else {
return showView().then(
() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
);
}
});
}
function postInit() {
Updater.init();
SingleInstanceChecker.init();
AppRightsChecker.init().catch(noop);
IdleTracker.init();
// BrowserExtensionConnector.init(appModel); // TODO
setTimeout(() => {
PluginManager.runAutoUpdate().catch(noop);
}, Timeouts.PluginsAutoUpdateAfterStart);
}
function showView() {
Workspace.showOpen();
const root = document.querySelector('.app');
if (root) {
const app = h(App, null);
render(app, document.body, root);
}
StartProfiler.milestone('first view rendering');
Events.emit('app-ready');
StartProfiler.milestone('app ready event');
StartProfiler.report();
return Promise.resolve();
}
}
@@ -0,0 +1,8 @@
import { Collection } from 'framework/collection';
import { EntryModel } from 'models/entry-model';
class EntryCollection extends Collection {
static model = EntryModel;
}
export { EntryCollection };
@@ -0,0 +1,24 @@
import { Collection } from 'framework/collection';
import { Model } from 'framework/model';
class FileCollection extends Collection {
static model = Model;
hasOpenFiles() {
return this.some((file) => file.active);
}
hasUnsavedFiles() {
return this.some((file) => file.modified);
}
hasDirtyFiles() {
return this.some((file) => file.dirty);
}
getByName(name) {
return this.find((file) => file.name.toLowerCase() === name.toLowerCase());
}
}
export { FileCollection };
@@ -0,0 +1,39 @@
import { Collection } from 'framework/collection';
import { SettingsStore } from 'comp/settings/settings-store';
import { FileInfoModel } from 'models/file-info-model';
class FileInfoCollection extends Collection {
static model = FileInfoModel;
load() {
return SettingsStore.load('file-info').then((data) => {
if (data) {
for (const item of data) {
this.push(new FileInfoModel(item));
}
}
});
}
save() {
SettingsStore.save('file-info', this);
}
getMatch(storage, name, path) {
return this.find((fi) => {
return (
(fi.storage || '') === (storage || '') &&
(fi.name || '') === (name || '') &&
(fi.path || '') === (path || '')
);
});
}
getByName(name) {
return this.find((file) => file.name.toLowerCase() === name.toLowerCase());
}
}
const instance = new FileInfoCollection();
export { instance as FileInfoCollection };
@@ -0,0 +1,8 @@
import { Collection } from 'framework/collection';
import { GroupModel } from 'models/group-model';
class GroupCollection extends Collection {
static model = GroupModel;
}
export { GroupCollection };
@@ -0,0 +1,8 @@
import { Collection } from 'framework/collection';
import { MenuItemModel } from 'models/menu/menu-item-model';
class MenuItemCollection extends Collection {
static model = MenuItemModel;
}
export { MenuItemCollection };
@@ -0,0 +1,8 @@
import { Collection } from 'framework/collection';
import { MenuOptionModel } from 'models/menu/menu-option-model';
class MenuOptionCollection extends Collection {
static model = MenuOptionModel;
}
export { MenuOptionCollection };
@@ -0,0 +1,8 @@
import { Collection } from 'framework/collection';
import { MenuSectionModel } from 'models/menu/menu-section-model';
class MenuSectionCollection extends Collection {
static model = MenuSectionModel;
}
export { MenuSectionCollection };
@@ -0,0 +1,52 @@
import { Collection } from 'framework/collection';
import { Model } from 'framework/model';
import { Comparators } from 'util/data/comparators';
class SearchResultCollection extends Collection {
static model = Model;
comparators = {
'none': null,
'title': Comparators.stringComparator('title', true),
'-title': Comparators.stringComparator('title', false),
'website': Comparators.stringComparator('url', true),
'-website': Comparators.stringComparator('url', false),
'user': Comparators.stringComparator('user', true),
'-user': Comparators.stringComparator('user', false),
'created': Comparators.dateComparator('created', true),
'-created': Comparators.dateComparator('created', false),
'updated': Comparators.dateComparator('updated', true),
'-updated': Comparators.dateComparator('updated', false),
'-attachments': (x, y) => {
return this.attachmentSortVal(x).localeCompare(this.attachmentSortVal(y));
},
'-rank': Comparators.rankComparator().bind(this)
};
defaultComparator = 'title';
entryFilter = null;
constructor(models, options) {
super(models);
const comparatorName = (options && options.comparator) || this.defaultComparator;
this.comparator = this.comparators[comparatorName];
}
sortEntries(comparator, filter) {
this.entryFilter = filter;
this.comparator = this.comparators[comparator] || this.comparators[this.defaultComparator];
this.sort();
}
attachmentSortVal(entry) {
const att = entry.attachments;
let str = att.length ? String.fromCharCode(64 + att.length) : 'Z';
if (att[0]) {
str += att[0].title;
}
return str;
}
}
export { SearchResultCollection };
@@ -0,0 +1,81 @@
import { Launcher } from 'comp/launcher';
import { Alerts } from 'comp/ui/alerts';
import { AppSettingsModel } from 'models/app-settings-model';
import { Features } from 'util/features';
import { Locale } from 'util/locale';
const AppRightsChecker = {
AppPath: '/Applications/KeeWeb.app',
init() {
if (!Features.isDesktop || !Features.isMac) {
return;
}
if (AppSettingsModel.skipFolderRightsWarning) {
return;
}
if (!Launcher.getAppPath().startsWith(this.AppPath)) {
return;
}
this.needRunInstaller((needRun) => {
if (needRun) {
this.showAlert();
this.runInstaller();
}
});
},
needRunInstaller(callback) {
Launcher.statFile(this.AppPath, (stat) => {
const folderIsRoot = stat && stat.uid === 0;
callback(!folderIsRoot);
});
},
showAlert() {
const command = 'sudo chown -R root ' + this.AppPath;
this.alert = Alerts.alert({
icon: 'lock',
header: Locale.appRightsAlert,
body:
Locale.appRightsAlertBody1.replace('{}', this.AppPath) +
'\n' +
Locale.appRightsAlertBody2,
pre: command,
buttons: [
{ result: 'skip', title: Locale.alertDoNotAsk, error: true },
Alerts.buttons.ok
],
success: (result) => {
if (result === 'skip') {
this.dontAskAnymore();
}
this.alert = null;
}
});
},
runInstaller() {
Launcher.spawn({
cmd: this.AppPath + '/Contents/Installer/KeeWeb Installer.app/Contents/MacOS/applet',
args: ['--install'],
complete: () => {
this.needRunInstaller((needRun) => {
if (this.alert && !needRun) {
this.alert.closeWithResult('cancel');
}
});
}
});
},
dontAskAnymore() {
this.needRunInstaller((needRun) => {
if (needRun) {
AppSettingsModel.skipFolderRightsWarning = true;
}
});
}
};
export { AppRightsChecker };
@@ -29,12 +29,12 @@ const ChalRespCalculator = {
const cacheKey = this.getCacheKey(params);
const respFromCache = this.cache[cacheKey]?.[hexChallenge];
if (respFromCache) {
logger.info('Found ChalResp in cache');
logger.debug('Found ChalResp in cache');
return resolve(Buffer.from(respFromCache, 'hex'));
}
if (!AppSettingsModel.enableUsb) {
logger.info('USB is disabled');
logger.debug('USB is disabled');
Alerts.error({
header: Locale.yubiKeyDisabledErrorHeader,
body: Locale.yubiKeyDisabledErrorBody,
@@ -49,7 +49,7 @@ const ChalRespCalculator = {
return;
}
logger.info('Calculating ChalResp using a YubiKey', params);
logger.debug('Calculating ChalResp using a YubiKey', params);
this._calc(params, challenge, (err, response) => {
if (err) {
@@ -1,5 +1,5 @@
const DragDropInfo = {
dragObject: null as unknown
dragObject: null
};
export { DragDropInfo };
+102
View File
@@ -0,0 +1,102 @@
import { AppSettingsModel } from 'models/app-settings-model';
import { UrlFormat } from 'util/formatting/url-format';
const ChooserAppKey = 'qp7ctun6qt5n9d6';
const DropboxChooser = function (callback) {
this.cb = callback;
this.onMessage = this.onMessage.bind(this);
};
DropboxChooser.prototype.callback = function (err, res) {
if (this.cb) {
this.cb(err, res);
}
this.cb = null;
};
DropboxChooser.prototype.choose = function () {
const windowFeatures = 'width=640,height=552,left=357,top=100,resizable=yes,location=yes';
const url = this.buildUrl();
this.popup = window.open(url, 'dropbox', windowFeatures);
if (!this.popup) {
return this.callback('Failed to open window');
}
window.addEventListener('message', this.onMessage);
this.closeInt = setInterval(this.checkClose.bind(this), 200);
};
DropboxChooser.prototype.buildUrl = function () {
return UrlFormat.makeUrl('https://www.dropbox.com/chooser', {
origin: window.location.protocol + '//' + window.location.host,
'app_key': AppSettingsModel.dropboxAppKey || ChooserAppKey,
'link_type': 'direct',
trigger: 'js',
multiselect: 'false',
extensions: '',
folderselect: 'false',
iframe: 'false',
version: 2
});
};
DropboxChooser.prototype.onMessage = function (e) {
if (e.source !== this.popup || e.origin !== 'https://www.dropbox.com') {
return;
}
const data = JSON.parse(e.data);
switch (data.method) {
case 'origin_request':
e.source.postMessage(JSON.stringify({ method: 'origin' }), 'https://www.dropbox.com');
break;
case 'files_selected':
this.popup.close();
this.success(data.params);
break;
case 'close_dialog':
this.popup.close();
break;
case 'web_session_error':
case 'web_session_unlinked':
this.callback(data.method);
break;
case 'resize':
this.popup.resize(data.params);
break;
case 'error':
this.callback(data.params);
break;
}
};
DropboxChooser.prototype.checkClose = function () {
if (this.popup.closed) {
clearInterval(this.closeInt);
window.removeEventListener('message', this.onMessage);
if (!this.result) {
this.callback('closed');
}
}
};
DropboxChooser.prototype.success = function (params) {
if (!params || !params[0] || !params[0].link || params[0].is_dir) {
return this.callback('bad result');
}
this.result = params[0];
this.readFile(this.result.link);
};
DropboxChooser.prototype.readFile = function (url) {
const xhr = new XMLHttpRequest();
xhr.addEventListener('load', () => {
this.callback(null, { name: this.result.name, data: xhr.response });
});
xhr.addEventListener('error', this.callback.bind(this, 'download error'));
xhr.addEventListener('abort', this.callback.bind(this, 'download abort'));
xhr.open('GET', url);
xhr.responseType = 'arraybuffer';
xhr.send();
};
export { DropboxChooser };
+29
View File
@@ -0,0 +1,29 @@
import { AppSettingsModel } from 'models/app-settings-model';
import { RuntimeDataModel } from 'models/runtime-data-model';
const ExportApi = {
settings: {
get(key) {
return key ? AppSettingsModel[key] : { ...AppSettingsModel };
},
set(key, value) {
AppSettingsModel[key] = value;
},
del(key) {
delete AppSettingsModel[key];
}
},
runtimeData: {
get(key) {
return key ? RuntimeDataModel[key] : { ...RuntimeDataModel };
},
set(key, value) {
RuntimeDataModel[key] = value;
},
del(key) {
delete RuntimeDataModel[key];
}
}
};
export { ExportApi };
-29
View File
@@ -1,29 +0,0 @@
import { AppSettingsFieldName, AppSettings } from 'models/app-settings';
import { RuntimeData } from 'models/runtime-data';
const ExportApi = {
settings: {
get(key?: AppSettingsFieldName): unknown {
return key ? AppSettings[key] : { ...AppSettings };
},
set(key: AppSettingsFieldName, value: unknown): boolean {
return AppSettings.set(key, value);
},
del(key: AppSettingsFieldName): void {
AppSettings.delete(key);
}
},
runtimeData: {
get(key?: string): unknown {
return key ? RuntimeData.get(key) : { ...RuntimeData };
},
set(key: string, value: unknown): boolean {
return RuntimeData.set(key, value);
},
del(key: string): void {
RuntimeData.delete(key);
}
}
};
export { ExportApi };
-689
View File
@@ -1,689 +0,0 @@
import * as kdbxweb from 'kdbxweb';
import { Logger } from 'util/logger';
import { Storage } from 'storage';
import { Locale } from 'util/locale';
import { IdGenerator } from 'util/generators/id-generator';
import { OpenParams } from 'models/ui/open-state';
import { FileManager } from 'models/file-manager';
import { File } from 'models/file';
import { FileInfo } from 'models/file-info';
import {
StorageFileData,
StorageFileNotFoundError,
StorageFileOptions,
StorageFileStat,
StorageRevConflictError,
StorageSaveResult
} from 'storage/types';
import { AppSettings } from 'models/app-settings';
import { errorToString, noop } from 'util/fn';
import { StorageBase } from 'storage/storage-base';
import { UrlFormat } from 'util/formatting/url-format';
import { DateFormat } from 'util/formatting/date-format';
const MaxLoadLoops = 3;
interface FileSyncOptions {
storage?: string;
path?: string;
opts: StorageFileOptions;
}
interface SyncContext {
logger: Logger;
file: File;
fileInfo: FileInfo;
storage: StorageBase;
path: string;
stat?: StorageFileStat;
opts?: StorageFileOptions;
retryCount: number;
}
class FileController {
async open(params: OpenParams): Promise<File> {
const logger = new Logger('open', params.name);
logger.info('File open request');
if (!params.name) {
throw new Error('Empty name');
}
const fileInfo = params.id
? FileManager.getFileInfoById(params.id)
: FileManager.getFileInfo(params.name, params.storage, params.path);
if (!params.opts && fileInfo && fileInfo.opts) {
params.opts = fileInfo.opts;
}
if (fileInfo && fileInfo.modified) {
logger.info('Open file from cache because it is modified');
const file = await this.openFileFromCache(params, fileInfo, logger);
logger.info('Sync just opened modified file');
setTimeout(() => {
this.syncFile(file).catch(noop);
}, 0);
return file;
} else if (params.fileData) {
logger.info('Open file from supplied content');
if (params.storage === 'file' && params.path) {
const stat = await Storage.file.stat(params.path);
params.rev = stat.rev;
return this.openFileWithData(params, fileInfo, params.fileData, false);
} else {
return this.openFileWithData(params, fileInfo, params.fileData, true);
}
} else if (!params.storage && fileInfo) {
logger.info('Open file from cache as main storage');
return this.openFileFromCache(params, fileInfo, logger);
} else if (
fileInfo?.openDate &&
fileInfo.rev === params.rev &&
fileInfo.storage !== 'file' &&
!AppSettings.disableOfflineStorage
) {
logger.info('Open file from cache because it is latest');
try {
return this.openFileFromCache(params, fileInfo, logger);
} catch (err) {
if (err instanceof kdbxweb.KdbxError) {
// TODO: || err.ykError
throw err;
}
logger.info('Error loading file from cache, trying to open from storage', err);
return this.openFileFromStorage(params, fileInfo, logger, true);
}
} else if (
!fileInfo ||
!fileInfo.openDate ||
params.storage === 'file' ||
AppSettings.disableOfflineStorage
) {
return this.openFileFromStorage(params, fileInfo, logger, false);
} else {
logger.info('Open file from cache, will sync after load', params.storage);
let file: File;
try {
file = await this.openFileFromCache(params, fileInfo, logger);
} catch (err) {
if (err instanceof kdbxweb.KdbxError) {
// TODO: || err.ykError
throw err;
}
logger.info('Error loading file from cache, trying to open from storage', err);
return this.openFileFromStorage(params, fileInfo, logger, true);
}
logger.info('Sync just opened file');
setTimeout(() => {
this.syncFile(file).catch(noop);
}, 0);
return file;
}
}
private async openFileFromCache(
params: OpenParams,
fileInfo: FileInfo,
logger: Logger
): Promise<File> {
let data: ArrayBuffer;
try {
const res = await Storage.cache.load(fileInfo.id);
if (!res?.data) {
throw new Error(Locale.openFileNoCacheError);
}
data = res.data;
logger.info('Loaded from cache');
} catch (err) {
logger.info('Loaded error from cache', err);
throw err;
}
return this.openFileWithData(params, fileInfo, data, false);
}
private async openFileFromStorage(
params: OpenParams,
fileInfo: FileInfo | undefined,
logger: Logger,
noCache: boolean
): Promise<File> {
if (!params.storage) {
throw new Error('Storage open error: empty storage');
}
if (!params.path) {
throw new Error('Storage open error: empty path');
}
logger.info('Open file from storage', params.storage);
const storage = Storage.get(params.storage ?? '');
if (!storage) {
logger.error('Storage not found', storage);
throw new Error(`Storage not found: ${storage ?? ''}`);
}
const cacheRev = (fileInfo && fileInfo.rev) || null;
if (cacheRev && storage.stat) {
logger.info('Stat file');
let stat: StorageFileStat;
try {
stat = await storage.stat(params.path, params.opts);
} catch (err) {
if (
!noCache &&
fileInfo &&
storage.name !== 'file' &&
!AppSettings.disableOfflineStorage
) {
logger.info('Open file from cache because of stat error', err);
return this.openFileFromCache(params, fileInfo, logger);
} else {
logger.info('Stat error', err);
throw err;
}
}
if (
!noCache &&
fileInfo &&
storage.name !== 'file' &&
stat.rev === cacheRev &&
!AppSettings.disableOfflineStorage
) {
logger.info('Open file from cache because it is latest');
return this.openFileFromCache(params, fileInfo, logger);
}
logger.info(`Open file from storage (${stat.rev ?? '(empty)'}, local ${cacheRev})`);
}
logger.info('Load from storage');
let res: StorageFileData;
try {
res = await storage.load(params.path, params.opts);
} catch (err) {
if (fileInfo && fileInfo.openDate && !AppSettings.disableOfflineStorage) {
logger.info('Open file from cache because of storage load error', err);
return this.openFileFromCache(params, fileInfo, logger);
} else {
logger.info('Storage load error', err);
throw err;
}
}
logger.info('Open file from content loaded from storage');
params.fileData = res.data;
params.rev = res.rev;
const needSaveToCache = storage.name !== 'file';
return this.openFileWithData(params, fileInfo, res.data, needSaveToCache);
}
private async openFileWithData(
params: OpenParams,
fileInfo: FileInfo | undefined,
data: ArrayBuffer,
updateCacheOnSuccess: boolean
): Promise<File> {
if (!params.name) {
throw new Error('Open file without name');
}
if (!params.password) {
throw new Error('Open file without password');
}
const logger = new Logger('open', params.name);
let needLoadKeyFile = false;
if (!params.keyFileData && fileInfo && fileInfo.keyFileName) {
params.keyFileName = fileInfo.keyFileName;
if (AppSettings.rememberKeyFiles === 'data' && fileInfo.keyFileHash) {
params.keyFileData = File.createKeyFileWithHash(fileInfo.keyFileHash);
} else if (AppSettings.rememberKeyFiles === 'path' && fileInfo.keyFilePath) {
params.keyFilePath = fileInfo.keyFilePath;
if (Storage.file.enabled) {
needLoadKeyFile = true;
}
}
} else if (params.keyFilePath && !params.keyFileData && !fileInfo) {
needLoadKeyFile = true;
}
if (needLoadKeyFile && params.keyFilePath) {
let data: ArrayBuffer;
try {
const res = await Storage.file.load(params.keyFilePath);
data = res.data;
} catch (err) {
logger.info('Storage load error', err);
throw err;
}
params.keyFileData = data;
}
const id = fileInfo ? fileInfo.id : IdGenerator.uuid();
if (FileManager.getFileById(id)) {
throw new Error('Duplicate file id');
}
const file = await File.open(id, params.name, params.password, data, params.keyFileData);
file.batchSet(() => {
file.storage = params.storage;
file.path = params.path;
file.keyFileName = params.keyFileName;
file.keyFilePath = params.keyFilePath;
file.backup = fileInfo?.backup;
file.chalResp = params.chalResp;
file.syncDate = fileInfo?.syncDate;
// if (params.encryptedPassword) { // TODO: touch id
// file.encryptedPassword = fileInfo?.encryptedPassword;
// file.encryptedPasswordDate = fileInfo?.encryptedPasswordDate || new Date();
// }
});
if (fileInfo?.modified) {
if (fileInfo.editState) {
logger.info('Loaded local edit state');
file.setLocalEditState(fileInfo.editState);
}
logger.info('Mark file as modified');
file.modified = true;
}
if (updateCacheOnSuccess && !AppSettings.disableOfflineStorage && params.fileData) {
logger.info('Save loaded file to cache');
await Storage.cache.save(file.id, params.fileData);
}
const rev = params.rev || (fileInfo && fileInfo.rev);
// this.setFileOpts(file, params.opts); // TODO: file opts
logger.info('Add last open file', file.id, file.name, file.storage, file.path, rev);
FileManager.addLastOpenFileInfo(file, rev);
FileManager.addFile(file);
// this.fileOpened(file, data, params); // TODO: fileOpened
return file;
}
async syncFile(file: File, options?: FileSyncOptions): Promise<void> {
if (file.demo) {
return;
}
const logger = new Logger('sync', file.name);
const storageName = options?.storage || file.storage;
const storage = storageName ? Storage.get(storageName) : undefined;
const opts = options?.opts || file.opts;
let path = options?.path || file.path;
if (storage?.getPathForName && (!path || storage.name !== file.storage)) {
path = storage.getPathForName(file.name);
}
const optionsForLogging = { ...options };
if (optionsForLogging.opts && optionsForLogging.opts.password) {
optionsForLogging.opts = { ...optionsForLogging.opts };
optionsForLogging.opts.password = '***';
}
logger.info('Sync started', storage, path, optionsForLogging);
const fileInfo = this.getOrCreateFileInfo(file, logger);
let syncError: unknown;
try {
if (file.syncing) {
throw new Error('Sync in progress');
}
file.setSyncProgress();
if (!storage) {
if (!file.modified && fileInfo.id === file.id) {
logger.info('Local, not modified');
} else {
logger.info('Local, save to cache');
const data = await file.getData();
try {
await Storage.cache.save(fileInfo.id, data);
logger.info('Saved to cache');
} catch (err) {
logger.info('Cache save error', err);
throw err;
}
// this.scheduleBackupFile(file, data); // TODO: backups
}
} else {
if (!path) {
throw new Error('Cannot sync file to storage but without path');
}
const ctx: SyncContext = {
logger,
file,
fileInfo,
storage,
path,
opts,
retryCount: 0
};
logger.info('Stat file');
let stat: StorageFileStat | undefined;
try {
stat = await storage.stat(path, opts);
ctx.stat = stat;
} catch (err) {
if (err instanceof StorageFileNotFoundError) {
logger.info('File does not exist in storage, creating');
await this.saveToCacheAndStorage(ctx);
} else if (file.dirty) {
if (AppSettings.disableOfflineStorage) {
logger.info('Stat error, dirty, cache is disabled', err);
throw err;
}
logger.info('Stat error, dirty, save to cache');
const data = await file.getData();
try {
await Storage.cache.save(fileInfo.id, data);
file.dirty = false;
logger.info('Saved to cache, exit with error', err);
} catch (e) {
logger.error('Error saving to cache', e);
}
throw err;
} else {
logger.info('Stat error, not dirty', err);
throw err;
}
}
if (stat) {
if (stat.rev === fileInfo.rev) {
if (file.modified) {
logger.info('Stat found same version, modified, saving');
await this.saveToCacheAndStorage(ctx);
} else {
logger.info('Stat found same version, not modified');
}
} else {
logger.info('Found new version, loading from storage');
await this.loadFromStorageAndMerge(ctx);
}
}
}
logger.info('Sync finished OK');
} catch (e) {
syncError = e;
logger.error('Sync finished with error', syncError);
}
file.setSyncComplete(path, storageName, syncError ? errorToString(syncError) : undefined);
fileInfo.batchSet(() => {
fileInfo.name = file.name;
fileInfo.storage = storageName;
fileInfo.path = path;
// fileInfo.opts = this.getStoreOpts(file); // TODO: storage opts
fileInfo.modified = file.dirty ? fileInfo.modified : file.modified;
fileInfo.editState = file.dirty ? fileInfo.editState : file.getLocalEditState();
fileInfo.syncDate = file.syncDate;
fileInfo.chalResp = file.chalResp;
if (AppSettings.rememberKeyFiles === 'data') {
fileInfo.keyFileName = file.keyFileName;
fileInfo.keyFileHash = file.getKeyFileHash();
}
});
if (!FileManager.getFileById(fileInfo.id)) {
FileManager.addFileInfo(fileInfo, true);
}
if (syncError) {
throw syncError;
}
}
private getOrCreateFileInfo(file: File, logger: Logger): FileInfo {
let fileInfo = FileManager.getFileInfoById(file.id);
if (!fileInfo) {
logger.info('Create new file info');
const dt = new Date();
fileInfo = new FileInfo({
id: file.id,
name: file.name,
storage: file.storage,
path: file.path,
// opts: this.getStoreOpts(file), // TODO: store opts
modified: file.modified,
syncDate: dt,
openDate: dt,
backup: file.backup
});
}
return fileInfo;
}
private async saveToStorage(ctx: SyncContext, data: ArrayBuffer) {
ctx.logger.info('Save data to storage');
const storageRev = ctx.fileInfo.storage === ctx.storage.name ? ctx.fileInfo.rev : undefined;
let saveResult: StorageSaveResult;
try {
saveResult = await ctx.storage.save(ctx.path, data, ctx.opts, storageRev);
} catch (err) {
if (err instanceof StorageRevConflictError) {
ctx.logger.info('Save rev conflict, reloading from storage');
await this.loadFromStorageAndMerge(ctx);
return;
} else {
ctx.logger.info('Error saving data to storage');
throw err;
}
}
if (saveResult.rev) {
ctx.logger.info('Update rev in file info');
ctx.fileInfo.rev = saveResult.rev;
}
if (saveResult.path) {
ctx.logger.info('Update path in file info', saveResult.path);
ctx.file.path = saveResult.path;
ctx.fileInfo.path = saveResult.path;
ctx.path = saveResult.path;
}
ctx.file.syncDate = new Date();
ctx.logger.info('Save to storage complete, updated sync date');
// this.scheduleBackupFile(file, data); // TODO: backups
}
private async saveToCacheAndStorage(ctx: SyncContext) {
ctx.logger.info('Getting file data for saving');
const data = await ctx.file.getData();
if (ctx.storage.name === 'file') {
ctx.logger.info('Saving to file storage');
} else if (!ctx.file.dirty) {
ctx.logger.info('Saving to storage, skip cache because not dirty');
} else if (AppSettings.disableOfflineStorage) {
ctx.logger.info('Saving to storage, cache is disabled');
} else {
ctx.logger.info('Saving to cache');
await Storage.cache.save(ctx.file.id, data);
ctx.file.dirty = false;
ctx.logger.info('Saved to cache, saving to storage');
}
await this.saveToStorage(ctx, data);
}
private async loadFromStorageAndMerge(ctx: SyncContext) {
if (++ctx.retryCount === MaxLoadLoops) {
throw new Error('Too many load attempts');
}
ctx.logger.info('Load from storage, attempt', ctx.retryCount);
const storageLoadRes = await ctx.storage.load(ctx.path, ctx.opts);
ctx.logger.info('Load from storage', storageLoadRes.rev);
try {
await ctx.file.mergeOrUpdate(storageLoadRes.data);
ctx.logger.info('Merge complete OK');
} catch (err) {
ctx.logger.info('Merge error', err);
// this.refresh(); // TODO: trigger refresh
if (
err instanceof kdbxweb.KdbxError &&
err.code === kdbxweb.Consts.ErrorCodes.InvalidKey
) {
ctx.logger.info('Remote key changed, request to enter new key');
// Events.emit('remote-key-changed', { file }); // TODO: show the key change screen
}
throw err;
}
if (ctx.stat?.rev) {
ctx.logger.info('Update rev in file info');
ctx.fileInfo.rev = ctx.stat.rev;
}
ctx.file.syncDate = new Date();
if (ctx.file.modified) {
ctx.logger.info('Updated sync date, saving modified file');
await this.saveToCacheAndStorage(ctx);
} else if (ctx.file.dirty) {
if (AppSettings.disableOfflineStorage) {
ctx.logger.info('File is dirty and cache is disabled');
throw new Error('File is dirty and cache is disabled');
}
ctx.logger.info('Saving not modified dirty file to cache');
await Storage.cache.save(ctx.fileInfo.id, storageLoadRes.data);
ctx.file.dirty = false;
ctx.logger.info('Complete, remove dirty flag');
} else {
ctx.logger.info('Complete, no changes');
}
}
private async backupFile(file: File, data: ArrayBuffer): Promise<void> {
if (file.backupInProgress) {
return;
}
file.backupInProgress = true;
try {
const opts = file.opts;
const backup = file.backup;
const logger = new Logger('backup', file.name);
if (!backup || !backup.storage || !backup.path) {
throw new Error('Invalid backup settings');
}
const storage = Storage.get(backup.storage);
if (!storage) {
throw new Error(`Invalid backup storage: ${backup.storage}`);
}
let path = backup.path.replace('{date}', DateFormat.dtStrFs(new Date()));
logger.info('Backup file to', backup.storage, path);
let folderPath = UrlFormat.fileToDir(path);
if (storage.getPathForName) {
folderPath = storage.getPathForName(folderPath).replace('.kdbx', '');
}
try {
await storage.stat(folderPath, opts);
logger.info('Backup folder exists, saving');
} catch (err) {
if (err instanceof StorageFileNotFoundError) {
logger.info('Backup folder does not exist');
if (!storage.mkdir) {
throw new Error('Mkdir not supported by ' + backup.storage);
}
try {
await storage.mkdir(folderPath);
logger.info('Backup folder created');
} catch (err) {
logger.error('Error creating backup folder', err);
throw new Error('Error creating backup folder');
}
} else {
logger.error('Stat folder error', err);
throw new Error('Cannot stat backup folder');
}
}
if (storage.getPathForName) {
path = storage.getPathForName(path);
}
try {
await storage.save(path, data, opts);
} catch (err) {
logger.error('Backup error', err);
throw err;
}
logger.info('Backup complete');
backup.lastTime = Date.now();
file.backup = backup;
const fileInfo = FileManager.getFileInfoById(file.id);
if (fileInfo) {
fileInfo.backup = backup;
}
} finally {
file.backupInProgress = false;
}
}
private scheduleBackupFile(file: File, data: ArrayBuffer): void {
const backup = file.backup;
if (!backup || !backup.enabled || file.backupInProgress) {
return;
}
const logger = new Logger('backup', file.name);
let needBackup = false;
if (!backup.lastTime) {
needBackup = true;
logger.info('No last backup time, backup now');
} else {
const dt = new Date(backup.lastTime);
switch (backup.schedule) {
case '0':
break;
case '1d':
dt.setDate(dt.getDate() + 1);
break;
case '1w':
dt.setDate(dt.getDate() + 7);
break;
case '1m':
dt.setMonth(dt.getMonth() + 1);
break;
default:
return;
}
if (dt.getTime() <= Date.now()) {
needBackup = true;
}
const lastTimeStr = DateFormat.dtStrFs(backup.lastTime);
const dtStr = DateFormat.dtStrFs(dt);
logger.info(
`Last backup time: ${lastTimeStr}, schedule: ${backup.schedule}, ` +
`next time: ${dtStr}, ${needBackup ? 'backup now' : 'skip backup'}`
);
}
const fileInfo = FileManager.getFileInfoById(file.id);
if (fileInfo) {
fileInfo.backup = backup;
}
if (needBackup) {
this.backupFile(file, data).catch(noop);
}
}
deleteAllCachedFiles(): void {
for (const fileInfo of FileManager.fileInfos) {
if (fileInfo.storage && !fileInfo.modified) {
Storage.cache.remove(fileInfo.id).catch(noop);
}
}
}
deleteAllStoredTokens(): void {
for (const storage of Object.values(Storage.getAll())) {
storage.deleteStoredToken();
}
}
}
const instance = new FileController();
export { instance as FileController };
+18
View File
@@ -0,0 +1,18 @@
import { Logger } from 'util/logger';
const logger = new Logger(
'focus-manager',
undefined,
localStorage.debugFocusManager ? Logger.Level.Debug : Logger.Level.Info
);
const FocusManager = {
modal: null,
setModal(modal) {
this.modal = modal;
logger.debug('Set modal', modal);
}
};
export { FocusManager };
-26
View File
@@ -1,26 +0,0 @@
import { Logger } from 'util/logger';
const logger = new Logger('focus-manager');
class FocusManager {
private readonly _modalStack: string[] = [];
get modal(): string | undefined {
return this._modalStack[this._modalStack.length - 1];
}
pushModal(value: string): void {
this._modalStack.push(value);
logger.debug('Push modal', value);
}
popModal(): string | undefined {
const lastModal = this._modalStack.pop();
logger.debug('Pop modal', lastModal, '=>', this._modalStack[this._modalStack.length - 1]);
return lastModal;
}
}
const instance = new FocusManager();
export { instance as FocusManager };
@@ -1,18 +1,11 @@
import { AppSettings } from 'models/app-settings';
import { AppSettingsModel } from 'models/app-settings-model';
import { Locale } from 'util/locale';
import {
PasswordGeneratorAppSetting,
PasswordGeneratorOptions,
PasswordGeneratorPreset,
PasswordGeneratorCustomPreset
} from 'util/generators/password-generator';
export const GeneratorPresets = {
get defaultPreset(): PasswordGeneratorPreset {
const GeneratorPresets = {
get defaultPreset() {
return {
name: 'Default',
title: Locale.genPresetDefault,
builtIn: true,
length: 16,
upper: true,
lower: true,
@@ -20,7 +13,7 @@ export const GeneratorPresets = {
};
},
get browserExtensionPreset(): PasswordGeneratorOptions {
get browserExtensionPreset() {
return {
name: 'BrowserExtension',
length: 20,
@@ -32,13 +25,12 @@ export const GeneratorPresets = {
};
},
get builtIn(): PasswordGeneratorPreset[] {
get builtIn() {
return [
this.defaultPreset,
{
name: 'Pronounceable',
title: Locale.genPresetPronounceable,
builtIn: true,
length: 10,
lower: true,
upper: true
@@ -46,7 +38,6 @@ export const GeneratorPresets = {
{
name: 'Med',
title: Locale.genPresetMed,
builtIn: true,
length: 16,
upper: true,
lower: true,
@@ -58,17 +49,15 @@ export const GeneratorPresets = {
{
name: 'Long',
title: Locale.genPresetLong,
builtIn: true,
length: 32,
upper: true,
lower: true,
digits: true
},
{ name: 'Pin4', title: Locale.genPresetPin4, builtIn: true, length: 4, digits: true },
{ name: 'Pin4', title: Locale.genPresetPin4, length: 4, digits: true },
{
name: 'Mac',
title: Locale.genPresetMac,
builtIn: true,
length: 17,
include: '0123456789ABCDEF',
pattern: 'XX-'
@@ -76,23 +65,24 @@ export const GeneratorPresets = {
{
name: 'Hash128',
title: Locale.genPresetHash128,
builtIn: true,
length: 32,
include: '0123456789abcdef'
},
{
name: 'Hash256',
title: Locale.genPresetHash256,
builtIn: true,
length: 64,
include: '0123456789abcdef'
}
];
},
get all(): PasswordGeneratorPreset[] {
get all() {
let presets = this.builtIn;
const setting = AppSettings.generatorPresets;
presets.forEach((preset) => {
preset.builtIn = true;
});
const setting = AppSettingsModel.generatorPresets;
if (setting) {
if (setting.user) {
presets = presets.concat(setting.user.map((item) => ({ ...item })));
@@ -114,7 +104,7 @@ export const GeneratorPresets = {
return presets;
},
get enabled(): PasswordGeneratorPreset[] {
get enabled() {
const allPresets = this.all.filter((preset) => !preset.disabled);
if (!allPresets.length) {
allPresets.push(this.defaultPreset);
@@ -122,15 +112,15 @@ export const GeneratorPresets = {
return allPresets;
},
getOrCreateSetting(): PasswordGeneratorAppSetting {
let setting = AppSettings.generatorPresets;
getOrCreateSetting() {
let setting = AppSettingsModel.generatorPresets;
if (!setting) {
setting = { user: [], disabled: {} };
setting = { user: [] };
}
return setting;
},
add(preset: PasswordGeneratorCustomPreset): void {
add(preset) {
const setting = this.getOrCreateSetting();
if (preset.name && !setting.user.filter((p) => p.name === preset.name).length) {
setting.user.push(preset);
@@ -138,13 +128,13 @@ export const GeneratorPresets = {
}
},
remove(name: string): void {
remove(name) {
const setting = this.getOrCreateSetting();
setting.user = setting.user.filter((p) => p.name !== name);
this.save(setting);
},
setPreset(name: string, props: Partial<PasswordGeneratorCustomPreset>): void {
setPreset(name, props) {
const setting = this.getOrCreateSetting();
const preset = setting.user.filter((p) => p.name === name)[0];
if (preset) {
@@ -153,20 +143,22 @@ export const GeneratorPresets = {
}
},
setDisabled(name: string, disabled: boolean): void {
setDisabled(name, disabled) {
const setting = this.getOrCreateSetting();
if (disabled) {
if (!setting.disabled) {
setting.disabled = {};
}
setting.disabled[name] = true;
} else if (setting.disabled) {
delete setting.disabled[name];
} else {
if (setting.disabled) {
delete setting.disabled[name];
}
}
this.save(setting);
},
setDefault(name: string | null): void {
setDefault(name) {
const setting = this.getOrCreateSetting();
if (name) {
setting.default = name;
@@ -176,7 +168,10 @@ export const GeneratorPresets = {
this.save(setting);
},
save(setting: PasswordGeneratorAppSetting): void {
AppSettings.generatorPresets = { ...setting };
save(setting) {
AppSettingsModel.set({ generatorPresets: undefined }, { silent: true });
AppSettingsModel.generatorPresets = setting;
}
};
export { GeneratorPresets };
@@ -1,63 +0,0 @@
import { FileOpener } from 'util/browser/file-opener';
import { IconSelectState } from 'models/ui/icon-select-state';
import { Logger } from 'util/logger';
import { Events } from 'util/events';
import { IconUrlFormat } from 'util/formatting/icon-url-format';
const logger = new Logger('icon-select');
class IconSelectController {
openFile() {
FileOpener.open((file) => {
FileOpener.readBinary(file)
.then((fileData) => {
IconSelectState.selectedIconDataUrl =
IconUrlFormat.toDataUrl(fileData) ?? undefined;
})
.catch((ex) => logger.error('Error reading file', ex));
}, 'image/*');
}
downloadFavicon(websiteUrl: string): void {
if (IconSelectState.downloading) {
return;
}
IconSelectState.setDownloading();
const iconUrl = this.getIconUrl(websiteUrl);
const img = document.createElement('img');
img.crossOrigin = 'Anonymous';
img.src = iconUrl;
img.onload = () => {
IconSelectState.downloading = false;
logger.debug('Favicon downloaded', iconUrl);
const data = this.getIconData(img);
logger.debug(`Favicon data extracted: ${data.length} chars`);
Events.emit('custom-icon-downloaded', data);
};
img.onerror = (e) => {
logger.error('Favicon download error', iconUrl, e);
IconSelectState.setDownloadError();
};
}
private getIconUrl(websiteUrl: string): string {
const domain = websiteUrl.replace(/^.*:\/+/, '').replace(/\/.*/, '');
return 'https://services.keeweb.info/favicon/' + domain;
}
private getIconData(img: HTMLImageElement): string {
const size = Math.min(img.width, 32);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = size;
canvas.height = size;
ctx?.drawImage(img, 0, 0, size, size);
return canvas.toDataURL().replace(/^.*,/, '');
}
}
const instance = new IconSelectController();
export { instance as IconSelectController };
@@ -3,37 +3,34 @@ import { Logger } from 'util/logger';
const logger = new Logger('online-password-checker');
const exposedPasswords = new Map<string, boolean>();
const exposedPasswords = {};
function checkIfPasswordIsExposedOnline(
password: kdbxweb.ProtectedValue
): Promise<boolean | undefined> {
function checkIfPasswordIsExposedOnline(password) {
if (!password || !password.isProtected || !password.byteLength) {
return Promise.resolve(false);
return false;
}
const saltedValue = password.saltedValue();
const cached = exposedPasswords.get(saltedValue);
const cached = exposedPasswords[saltedValue];
if (cached !== undefined) {
return Promise.resolve(cached);
return cached;
}
const passwordBytes = password.getBinary();
return crypto.subtle
.digest({ name: 'SHA-1' }, passwordBytes)
.then((sha1) => {
kdbxweb.ByteUtils.zeroBuffer(passwordBytes);
const sha1Hex = kdbxweb.ByteUtils.bytesToHex(sha1).toUpperCase();
const shaFirst = sha1Hex.substr(0, 5);
sha1 = kdbxweb.ByteUtils.bytesToHex(sha1).toUpperCase();
const shaFirst = sha1.substr(0, 5);
return fetch(`https://api.pwnedpasswords.com/range/${shaFirst}`)
.then((response) => response.text())
.then((response) => {
const isPresent = response.includes(sha1Hex.substr(5));
exposedPasswords.set(saltedValue, isPresent);
const isPresent = response.includes(sha1.substr(5));
exposedPasswords[saltedValue] = isPresent;
return isPresent;
});
})
.catch((e) => {
logger.error('Error checking password online', e);
return undefined;
});
}
-314
View File
@@ -1,314 +0,0 @@
import * as kdbxweb from 'kdbxweb';
import { FileManager } from 'models/file-manager';
import { Workspace } from 'models/workspace';
import { AppSettings } from 'models/app-settings';
import { FileOpener } from 'util/browser/file-opener';
import { Alerts } from 'comp/ui/alerts';
import { Locale } from 'util/locale';
import { errorToString } from 'util/fn';
import { DropboxChooser } from 'storage/dropbox-chooser';
import { Logger } from 'util/logger';
import { FileController } from 'comp/app/file-controller';
import { OpenState } from 'models/ui/open-state';
import { StorageBase } from 'storage/storage-base';
import { BrowserAuthStartedError, OAuthRejectedError, StorageListItem } from 'storage/types';
import { FunctionComponent, h } from 'preact';
import { OpenStorageFileList } from 'ui/open/open-storage-file-list';
import { UrlFormat } from 'util/formatting/url-format';
const logger = new Logger('open');
class OpenController {
open(): void {
if (OpenState.busy) {
return;
}
if (OpenState.id && FileManager.getFileById(OpenState.id)) {
Workspace.showList();
return;
}
OpenState.beginOpen();
FileController.open(OpenState)
.then(() => {
Workspace.showList();
})
.catch((err) => {
logger.error('Open error', err);
const invalidKey =
err instanceof kdbxweb.KdbxError &&
err.code === kdbxweb.Consts.ErrorCodes.InvalidKey;
OpenState.setOpenError(invalidKey);
if (!invalidKey) {
// } else if (err.userCanceled) { // TODO: yubikey cancellation
// if (err.notFound) { // TODO: handle file not found
// err = Locale.openErrorFileNotFound;
// }
const alertBody = Locale.openErrorDescription;
// if (err.maybeTouchIdChanged) { // TODO: handle touch ID
// alertBody += '\n' + Locale.openErrorDescriptionMaybeTouchIdChanged;
// }
Alerts.error({
header: Locale.openError,
body: alertBody,
pre: errorToString(err)
});
}
});
}
selectKeyFileFromDropbox(): void {
if (OpenState.busy || !AppSettings.canOpen) {
return;
}
const dropboxChooser = new DropboxChooser((err, res) => {
if (!err && res) {
OpenState.setKeyFile(res.name, res.data);
}
});
dropboxChooser.choose();
}
chooseFile(): void {
if (OpenState.busy || !AppSettings.canOpen) {
return;
}
FileOpener.open((file) => {
this.readFile(file).catch((e) => logger.error('Error selecting file', e));
});
}
async readFile(file: File): Promise<void> {
const fileData = await FileOpener.readBinary(file);
const format = OpenController.getOpenFileFormat(file, fileData);
switch (format) {
case 'kdbx': {
const name = file.name.replace(/\.kdbx$/i, '');
OpenState.setFile(
name,
fileData,
file.path || undefined,
file.path ? 'file' : undefined
);
break;
}
case 'xml': {
const name = file.name.replace(/\.\w+$/i, '');
const xml = kdbxweb.ByteUtils.bytesToString(fileData);
try {
await Workspace.importFileFromXml(name, xml);
} catch (e) {
logger.error('Error importing XML', e);
Alerts.error({
header: Locale.openXml,
body: Locale.openError,
pre: errorToString(e)
});
}
break;
}
case 'csv':
// TODO: load CSV
break;
case 'kdb':
Alerts.error({
header: Locale.openWrongFile,
body: Locale.openKdbFileBody
});
break;
default:
Alerts.error({
header: Locale.openWrongFile,
body: Locale.openWrongFileBody
});
break;
}
}
chooseKeyFile(): void {
if (OpenState.busy || !AppSettings.canOpen) {
return;
}
FileOpener.open((file) => {
this.readKeyFile(file).catch((e) => logger.error('Error selecting keyfile', e));
});
}
async readKeyFile(file: File): Promise<void> {
const keyFileData = await FileOpener.readBinary(file);
const path = AppSettings.rememberKeyFiles === 'path' && file.path ? file.path : undefined;
OpenState.setKeyFile(file.name, keyFileData, path);
}
readFileAndKeyFile(file: File, keyFile?: File): void {
this.readFile(file)
.then(() => {
if (keyFile) {
return this.readKeyFile(keyFile);
}
})
.catch((e) => {
logger.error('Error reading file and keyfile', e);
});
}
readDroppedFiles(files: File[]): void {
if (!files.length) {
return;
}
const dataFile = files.find((file) => /\.kdbx$/i.test(file.name));
const keyFile = files.find((file) => /\.keyx?$/i.test(file.name));
if (dataFile) {
this.readFileAndKeyFile(dataFile, keyFile);
return;
}
this.readFile(files[0]).catch((e) => logger.error('Error reading dropped files', e));
}
listStorage(storage: StorageBase, dir?: string, prevDir?: string): void {
if (OpenState.busy || !storage.list) {
return;
}
OpenState.setStorageProgress(storage.name);
storage
.list(dir)
.then((files) => {
OpenState.resetStorageProgress();
if (!files.length) {
Alerts.error({
header: Locale.openNothingFound,
body: Locale.openNothingFoundBody
});
return;
}
const collator = new Intl.Collator(undefined, { sensitivity: 'base' });
files.sort((x, y) => {
if (x.dir !== y.dir) {
return (y.dir ? 1 : 0) - (x.dir ? 1 : 0);
}
return collator.compare(x.path, y.path);
});
if (dir) {
files.unshift({
path: prevDir ?? '',
name: '..',
dir: true
});
}
this.showStorageFileListAlert(storage, files, dir);
})
.catch((e) => {
OpenState.resetStorageProgress();
if (e instanceof BrowserAuthStartedError || e instanceof OAuthRejectedError) {
return;
}
if (!Alerts.alertDisplayed) {
Alerts.error({
header: Locale.openError,
body: Locale.openListErrorBody,
pre: errorToString(e)
});
}
});
}
private showStorageFileListAlert(
storage: StorageBase,
files: StorageListItem[],
currentDir?: string
) {
const listView: FunctionComponent = () => {
const fileSelected = (file: StorageListItem) => {
if (file.dir) {
this.listStorage(storage, file.path, currentDir);
} else {
alert.closeWithResult('');
OpenState.setStorageFile(storage.name, {
path: file.path,
name: UrlFormat.getDataFileName(file.name),
rev: file.rev
});
}
};
return h(OpenStorageFileList, {
files,
fileSelected
});
};
const alert = Alerts.alert({
header: Locale.openSelectFile,
body: Locale.openSelectFileBody,
icon: storage.icon || 'file-alt',
buttons: [{ result: '', title: Locale.alertCancel }],
esc: '',
click: '',
view: listView
});
}
private static getOpenFileFormat(
file: File,
fileData: ArrayBuffer
): 'kdbx' | 'kdb' | 'xml' | 'csv' | undefined {
if (fileData.byteLength < 8) {
return undefined;
}
const fileSig = new Uint32Array(fileData, 0, 2);
if (fileSig[0] === kdbxweb.Consts.Signatures.FileMagic) {
if (fileSig[1] === kdbxweb.Consts.Signatures.Sig2Kdb) {
return 'kdb';
} else if (fileSig[1] === kdbxweb.Consts.Signatures.Sig2Kdbx) {
return 'kdbx';
} else {
return undefined;
}
} else if (AppSettings.canImportXml) {
try {
const str = kdbxweb.ByteUtils.bytesToString(fileSig).trim();
if (str.startsWith('<?xml')) {
return 'xml';
}
} catch (e) {}
return undefined;
} else if (AppSettings.canImportCsv && /\.csv$/i.test(file.name)) {
return 'csv';
} else {
return undefined;
}
}
private static async showLocalFileAlert(): Promise<void> {
if (AppSettings.skipOpenLocalWarn) {
return;
}
const res = await Alerts.alert({
header: Locale.openLocalFile,
body: Locale.openLocalFileBody,
icon: 'file-alt',
buttons: [
{ result: 'skip', title: Locale.openLocalFileDontShow, error: true },
{ result: 'ok', title: Locale.alertOk }
],
click: '',
esc: '',
enter: ''
}).wait();
if (res === 'skip') {
AppSettings.skipOpenLocalWarn = true;
}
}
}
const instance = new OpenController();
export { instance as OpenController };
@@ -0,0 +1,99 @@
import { SearchResultCollection } from 'collections/search-result-collection';
import { Ranking } from 'util/data/ranking';
const urlPartsRegex = /^(\w+:\/\/)?(?:(?:www|wwws|secure)\.)?([^\/]+)\/?(.*)/;
class SelectEntryFilter {
constructor(windowInfo, appModel, files, filterOptions) {
this.title = windowInfo.title;
this.useTitle = !!windowInfo.title && !windowInfo.url;
this.url = windowInfo.url;
this.useUrl = !!windowInfo.url;
this.subdomains = true;
this.text = '';
this.appModel = appModel;
this.files = files;
this.filterOptions = filterOptions;
}
getEntries() {
const filter = {
text: this.text,
...this.filterOptions
};
let entries = this.appModel
.getEntriesByFilter(filter, this.files)
.map((e) => [e, this._getEntryRank(e)]);
if (this.useUrl || this.useTitle) {
entries = entries.filter((e) => e[1]);
}
entries = entries.sort((x, y) =>
x[1] === y[1] ? x[0].title.localeCompare(y[0].title) : y[1] - x[1]
);
entries = entries.map((p) => p[0]);
return new SearchResultCollection(entries, { comparator: 'none' });
}
_getEntryRank(entry) {
let titleRank = 0;
let urlRank = 0;
if (this.useTitle && this.title && entry.title) {
titleRank = Ranking.getStringRank(entry.title.toLowerCase(), this.title.toLowerCase());
if (!titleRank) {
return 0;
}
}
if (this.useUrl && this.url) {
const searchUrlLower = this.url.toLowerCase();
const searchUrlParts = urlPartsRegex.exec(searchUrlLower);
for (const url of entry.getAllUrls()) {
const entryUrlParts = urlPartsRegex.exec(url.toLowerCase());
if (entryUrlParts) {
const [, scheme, domain, path] = entryUrlParts;
const [, searchScheme, searchDomain, searchPath] = searchUrlParts;
if (
domain === searchDomain ||
(this.subdomains && searchDomain.indexOf('.' + domain) > 0)
) {
if (domain === searchDomain) {
urlRank += 20;
} else {
urlRank += 10;
}
if (path === searchPath) {
urlRank += 10;
} else if (path && searchPath) {
if (path.lastIndexOf(searchPath, 0) === 0) {
urlRank += 5;
} else if (searchPath.lastIndexOf(path, 0) === 0) {
urlRank += 3;
}
}
if (scheme === searchScheme) {
urlRank += 1;
}
}
}
}
if (entry.searchText.includes(searchUrlLower)) {
// the url is in some field; include it
urlRank += 5;
}
}
if (this.useTitle && !titleRank) {
return 0;
}
if (this.useUrl && !urlRank) {
return 0;
}
return titleRank + urlRank;
}
}
export { SelectEntryFilter };
@@ -1,49 +1,36 @@
import { Launcher } from 'comp/launcher';
import { Keys } from 'const/keys';
import { AppSettingsFieldName, AppSettings } from 'models/app-settings';
import { AppSettingsModel } from 'models/app-settings-model';
import { Features } from 'util/features';
import { StringFormat } from 'util/formatting/string-format';
import { Locale } from 'util/locale';
let allowedKeys: Map<number, string>;
let allowedKeys;
function getAllowedKeys(): Map<number, string> {
function getAllowedKeys() {
if (!allowedKeys) {
allowedKeys = new Map<number, string>();
allowedKeys = {};
for (const [name, code] of Object.entries(Keys)) {
if (typeof code === 'string') {
continue;
}
const keyName = name.replace('DOM_VK_', '');
if (/^([0-9A-Z]|F\d{1,2})$/.test(keyName)) {
allowedKeys.set(code, keyName);
allowedKeys[code] = keyName;
}
}
}
return allowedKeys;
}
const AllGlobalShortcuts = {
const globalShortcuts = {
copyPassword: { mac: 'Ctrl+Alt+C', all: 'Shift+Alt+C' },
copyUser: { mac: 'Ctrl+Alt+B', all: 'Shift+Alt+B' },
copyUrl: { mac: 'Ctrl+Alt+U', all: 'Shift+Alt+U' },
copyOtp: undefined,
copyOtp: {},
autoType: { mac: 'Ctrl+Alt+T', all: 'Shift+Alt+T' },
restoreApp: undefined
};
export type GlobalShortcutType = keyof typeof AllGlobalShortcuts;
const GlobalShortcutAppSettingsFields: Record<GlobalShortcutType, AppSettingsFieldName> = {
copyPassword: 'globalShortcutCopyPassword',
copyUser: 'globalShortcutCopyUser',
copyUrl: 'globalShortcutCopyUrl',
copyOtp: 'globalShortcutCopyOtp',
autoType: 'globalShortcutAutoType',
restoreApp: 'globalShortcutRestoreApp'
restoreApp: {}
};
const Shortcuts = {
keyEventToShortcut(event: KeyboardEvent): { valid: boolean; value: string } {
keyEventToShortcut(event) {
const modifiers = [];
if (event.ctrlKey) {
modifiers.push('Ctrl');
@@ -57,14 +44,13 @@ const Shortcuts = {
if (Features.isMac && event.metaKey) {
modifiers.push('Meta');
}
const keyName = getAllowedKeys().get(event.which);
const keyName = getAllowedKeys()[event.which];
return {
value: modifiers.join('+') + '+' + (keyName || '…'),
valid: modifiers.length > 0 && !!keyName
};
},
presentShortcut(shortcutValue: string | undefined, formatting?: boolean): string {
presentShortcut(shortcutValue, formatting) {
if (!shortcutValue) {
return '-';
}
@@ -86,38 +72,30 @@ const Shortcuts = {
})
.join('');
},
actionShortcutSymbol(formatting?: boolean): string {
actionShortcutSymbol(formatting) {
return Features.isMac ? '⌘' : this.formatShortcut(Locale.ctrlKey, formatting);
},
altShortcutSymbol(formatting?: boolean): string {
altShortcutSymbol(formatting) {
return Features.isMac ? '⌥' : this.formatShortcut(Locale.altKey, formatting);
},
shiftShortcutSymbol(formatting?: boolean): string {
shiftShortcutSymbol(formatting) {
return Features.isMac ? '⇧' : this.formatShortcut(Locale.shiftKey, formatting);
},
ctrlShortcutSymbol(formatting?: boolean): string {
ctrlShortcutSymbol(formatting) {
return Features.isMac ? '⌃' : this.formatShortcut(Locale.ctrlKey, formatting);
},
formatShortcut(shortcut: string, formatting?: boolean): string {
formatShortcut(shortcut, formatting) {
return formatting ? `${shortcut} + ` : `${shortcut}+`;
},
globalShortcutText(type: GlobalShortcutType, formatting?: boolean): string {
globalShortcutText(type, formatting) {
return this.presentShortcut(this.globalShortcut(type), formatting);
},
globalShortcut(type: GlobalShortcutType): string | undefined {
const setting = GlobalShortcutAppSettingsFields[type];
const appSettingsShortcut = AppSettings[setting];
if (typeof appSettingsShortcut === 'string') {
globalShortcut(type) {
const appSettingsShortcut = AppSettingsModel[this.globalShortcutAppSettingsKey(type)];
if (appSettingsShortcut) {
return appSettingsShortcut;
}
const globalShortcut = AllGlobalShortcuts[type];
const globalShortcut = globalShortcuts[type];
if (globalShortcut) {
if (Features.isMac && globalShortcut.mac) {
return globalShortcut.mac;
@@ -126,21 +104,21 @@ const Shortcuts = {
}
return undefined;
},
setGlobalShortcut(type: GlobalShortcutType, value: string | null): void {
if (!AllGlobalShortcuts[type]) {
setGlobalShortcut(type, value) {
if (!globalShortcuts[type]) {
throw new Error('Bad shortcut: ' + type);
}
const setting = GlobalShortcutAppSettingsFields[type];
if (value) {
AppSettings.set(setting, value);
AppSettingsModel[this.globalShortcutAppSettingsKey(type)] = value;
} else {
AppSettings.delete(setting);
delete AppSettingsModel[this.globalShortcutAppSettingsKey(type)];
}
Launcher?.ipcRenderer.invoke('set-global-shortcuts', { [setting]: value });
Launcher.setGlobalShortcuts(AppSettingsModel);
},
screenshotToClipboardShortcut(): string {
globalShortcutAppSettingsKey(type) {
return 'globalShortcut' + StringFormat.capFirst(type);
},
screenshotToClipboardShortcut() {
if (Features.isiOS) {
return 'Sleep+Home';
}
@@ -1,4 +1,4 @@
import { Events } from 'util/events';
import { Events } from 'framework/events';
import { Launcher } from 'comp/launcher';
const LocalStorageKeyName = 'instanceCheck';
@@ -7,16 +7,15 @@ const LocalStorageResponseKeyName = 'instanceMaster';
const instanceKey = Date.now().toString();
const SingleInstanceChecker = {
init(): void {
init() {
if (Launcher) {
return;
}
// eslint-disable-next-line @typescript-eslint/unbound-method
window.addEventListener('storage', SingleInstanceChecker.storageChanged);
SingleInstanceChecker.setKey(LocalStorageKeyName, instanceKey);
},
storageChanged(e: StorageEvent): void {
storageChanged(e) {
if (!e.newValue) {
return;
}
@@ -26,13 +25,12 @@ const SingleInstanceChecker = {
instanceKey + Math.random().toString()
);
} else if (e.key === LocalStorageResponseKeyName && e.newValue.indexOf(instanceKey) < 0) {
// eslint-disable-next-line @typescript-eslint/unbound-method
window.removeEventListener('storage', SingleInstanceChecker.storageChanged);
Events.emit('second-instance');
}
},
setKey(key: string, value: string): void {
setKey(key, value) {
try {
localStorage.setItem(key, value);
setTimeout(() => {
@@ -4,27 +4,17 @@ const logger = new Logger('start-profiler');
let lastTs = 0;
interface AppProfileOperation {
name: string;
elapsed: number;
}
interface AppProfileData {
timings: AppProfileOperation[];
totalTime: number;
}
const operations: AppProfileOperation[] = [];
const operations = [];
const StartProfiler = {
milestone(name: string): void {
milestone(name) {
const ts = logger.ts();
const elapsed = ts - lastTs;
lastTs = ts;
operations.push({ name, elapsed });
},
report(): void {
report() {
const networkTime = this.getNetworkTime();
operations.unshift({ name: 'fetching', elapsed: networkTime });
@@ -33,11 +23,11 @@ const StartProfiler = {
this.printReport('App', operations, time);
},
reportAppProfile(data: AppProfileData): void {
reportAppProfile(data) {
this.printReport('Electron app', data.timings, data.totalTime);
},
printReport(name: string, operations: AppProfileOperation[], totalTime: number): void {
printReport(name, operations, totalTime) {
const message =
`${name} started in ${totalTime}ms: ` +
operations.map((op) => `${op.name}=${Math.round(op.elapsed)}ms`).join(', ');
@@ -45,12 +35,11 @@ const StartProfiler = {
logger.info(message);
},
getNetworkTime(): number {
let perfEntry: PerformanceNavigationTiming | PerformanceTiming | undefined;
getNetworkTime() {
let perfEntry;
if (performance.getEntriesByType) {
const [entry] = performance.getEntriesByType('navigation');
perfEntry = entry as PerformanceNavigationTiming;
[perfEntry] = performance.getEntriesByType('navigation');
}
if (!perfEntry || !perfEntry.responseEnd || !perfEntry.fetchStart) {
perfEntry = performance.timing;
+306
View File
@@ -0,0 +1,306 @@
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 };
-472
View File
@@ -1,472 +0,0 @@
import * as kdbxweb from 'kdbxweb';
import { RuntimeInfo } from 'const/runtime-info';
import { Launcher } from 'comp/launcher';
import { Links } from 'const/links';
import { AppSettings } from 'models/app-settings';
import { SemVer } from 'util/data/semver';
import { Logger } from 'util/logger';
import { SignatureVerifier } from 'util/data/signature-verifier';
import { Model } from 'util/model';
import { Timeouts } from 'const/timeouts';
import { RuntimeData } from 'models/runtime-data';
import { noop } from 'util/fn';
import { StringFormat } from 'util/formatting/string-format';
import { ClientRequestArgs } from 'http';
import * as fs from 'fs';
import path from 'path';
import https from 'https';
const logger = new Logger('updater');
class Updater extends Model {
readonly enabled = Launcher?.updaterEnabled() ?? false;
status: 'unknown' | 'ready' | 'checking' | 'error' | 'ok' = 'unknown';
updateStatus:
| 'unknown'
| 'found'
| 'ready'
| 'downloading'
| 'extracting'
| 'updating'
| 'error' = 'unknown';
updateError?: string;
private _nextCheckTimeout?: number;
private _updateCheckDate = new Date(0);
init(): void {
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');
this.updateStatus = 'ready';
}
});
})
.catch((e) => {
logger.error('Failed to register a service worker', e);
});
}
}
get updateInProgress(): boolean {
return (
this.status === 'checking' ||
['downloading', 'extracting', 'updating'].includes(this.updateStatus)
);
}
scheduleNextCheck(): void {
if (this._nextCheckTimeout) {
clearTimeout(this._nextCheckTimeout);
this._nextCheckTimeout = undefined;
}
if (!this.enabled || !AppSettings.autoUpdate) {
return;
}
let timeDiff = Timeouts.MinUpdateTimeout;
const lastCheckDate = RuntimeData.lastUpdateCheckDate;
if (lastCheckDate) {
timeDiff = Math.min(
Math.max(
Timeouts.UpdateInterval + (lastCheckDate.getTime() - Date.now()),
Timeouts.MinUpdateTimeout
),
Timeouts.UpdateInterval
);
}
this._nextCheckTimeout = window.setTimeout(() => {
this.check(false).catch(noop);
}, timeDiff);
logger.info(`Next update check will happen in ${Math.round(timeDiff / 1000)}s`);
}
async check(startedByUser: boolean) {
if (!this.enabled || this.updateInProgress) {
return;
}
this.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 = Date.now() - this._updateCheckDate.getTime();
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...');
let data: Buffer | undefined;
try {
const httpRes = await this.httpGet({
url: Links.UpdateJson
});
data = httpRes.data;
} catch (e) {
logger.error('Update check error', e);
RuntimeData.batchSet(() => {
RuntimeData.lastUpdateCheckDate = new Date();
RuntimeData.lastUpdateCheckError = 'Error checking last version';
});
this.status = 'error';
this.scheduleNextCheck();
return;
}
let updateJson: Record<string, unknown>;
try {
const parsed = JSON.parse(data?.toString() || '') as unknown;
if (!parsed || typeof parsed !== 'object') {
throw new Error('Empty data');
}
updateJson = parsed as Record<string, unknown>;
} catch (e) {
logger.error('Failed to parse JSON', data);
return;
}
const updateVersion = updateJson.version;
const updateDate = new Date(String(updateJson.date));
const dt = new Date();
logger.info('Update check', updateVersion || 'no version', updateDate);
if (typeof updateVersion !== 'string' || !updateDate.getTime()) {
RuntimeData.batchSet(() => {
RuntimeData.lastUpdateCheckDate = dt;
RuntimeData.lastUpdateCheckError = 'Failed to parse update information';
});
this.status = 'error';
this.scheduleNextCheck();
return;
}
const prevLastVersion = RuntimeData.lastUpdateVersion;
RuntimeData.batchSet(() => {
RuntimeData.lastUpdateCheckDate = dt;
RuntimeData.lastSuccessUpdateCheckDate = dt;
RuntimeData.lastUpdateVersionReleaseDate = updateDate;
RuntimeData.lastUpdateVersion = updateVersion;
RuntimeData.lastUpdateCheckError = undefined;
});
this.status = 'ok';
this.scheduleNextCheck();
if (prevLastVersion === RuntimeData.lastUpdateVersion && this.updateStatus === 'ready') {
logger.info('Waiting for the user to apply downloaded update');
return;
}
if (!startedByUser && AppSettings.autoUpdate === 'install') {
await this.update();
} else if (SemVer.compareVersions(updateVersion, RuntimeInfo.version) > 0) {
this.updateStatus = 'found';
}
}
async updateAndRestart() {
await this.update();
await this.installAndRestart();
}
private async update() {
if (!this.enabled) {
logger.info('Updater is disabled');
return;
}
const ver = RuntimeData.lastUpdateVersion;
if (!ver) {
logger.info('No last update version');
return;
}
if (SemVer.compareVersions(RuntimeInfo.version, ver) >= 0) {
logger.info('You are using the latest version');
return;
}
this.batchSet(() => {
this.updateStatus = 'downloading';
this.updateError = undefined;
});
logger.info('Downloading update', ver);
const updateAssetName = this.getUpdateAssetName(ver);
if (!updateAssetName) {
logger.error('Empty updater asset name for', process.platform, process.arch);
return;
}
const updateUrlBasePath = Links.UpdateBasePath.replace('{ver}', ver);
const updateAssetUrl = updateUrlBasePath + updateAssetName;
let assetFilePath: string;
try {
const httpRes = await this.httpGet({
url: updateAssetUrl,
file: updateAssetName,
cleanupOldFiles: true,
cache: true
});
if (!httpRes.fileName) {
throw new Error('No fileName');
}
assetFilePath = httpRes.fileName;
} catch (e) {
logger.error('Error downloading update', e);
this.batchSet(() => {
this.updateStatus = 'error';
this.updateError = 'Error downloading update';
});
return;
}
logger.info('Downloading update signatures');
let assetFileSignaturePath: string;
try {
const httpRes = await this.httpGet({
url: updateUrlBasePath + 'Verify.sign.sha256',
file: updateAssetName + '.sign',
cleanupOldFiles: true,
cache: true
});
if (!httpRes.fileName) {
throw new Error('No fileName');
}
assetFileSignaturePath = httpRes.fileName;
} catch (e) {
logger.error('Error downloading update signatures', e);
this.batchSet(() => {
this.updateStatus = 'error';
this.updateError = 'Error downloading update signatures';
});
return;
}
let valid: boolean;
try {
valid = await this.verifySignature(assetFilePath, updateAssetName);
} catch (err) {
this.batchSet(() => {
this.updateStatus = 'error';
this.updateError = 'Error verifying update signature';
});
return;
}
if (!valid) {
this.batchSet(() => {
this.updateStatus = 'error';
this.updateError = 'Invalid update signature';
});
fs.unlink(assetFilePath, noop);
fs.unlink(assetFileSignaturePath, noop);
return;
}
logger.info('Update is ready', assetFilePath);
this.batchSet(() => {
this.updateStatus = 'ready';
this.updateError = undefined;
});
}
private async verifySignature(assetFilePath: string, assetName: string): Promise<boolean> {
logger.info('Verifying update signature', assetName);
const signaturesTxt = await fs.promises.readFile(assetFilePath + '.sign', 'utf8');
const assetSignatureLine = signaturesTxt
.split('\n')
.find((line) => line.endsWith(assetName));
if (!assetSignatureLine) {
logger.error('Signature not found for asset', assetName);
throw new Error('Asset signature not found');
}
const signature = kdbxweb.ByteUtils.hexToBytes(assetSignatureLine.split(' ')[0]);
const fileBytes = fs.readFileSync(assetFilePath);
try {
const valid = await SignatureVerifier.verify(fileBytes, signature);
logger.info(`Update asset signature is ${valid ? 'valid' : 'invalid'}`);
return valid;
} catch (e) {
logger.error('Error verifying signature', e);
throw e;
}
}
private getUpdateAssetName(ver: string): string | undefined {
const platform = process.platform;
const arch = process.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;
}
async installAndRestart(): Promise<void> {
if (!Launcher || !RuntimeData.lastUpdateVersion) {
return;
}
const updateAssetName = this.getUpdateAssetName(RuntimeData.lastUpdateVersion);
const updateFilePath = await this.cacheFilePath(updateAssetName);
Launcher.requestRestartAndUpdate(updateFilePath);
}
async cacheFilePath(fileName?: string): Promise<string> {
const tempPath = await Launcher?.ipcRenderer.invoke('get-temp-path');
if (!tempPath) {
throw new Error('Failed to get temp path');
}
return fileName ? path.join(tempPath, fileName) : tempPath;
}
async httpGet(config: {
url: string;
file?: string;
cache?: boolean;
cleanupOldFiles?: boolean;
noRedirect?: boolean;
}): Promise<{ fileName?: string; data?: Buffer }> {
let tmpFile: string;
if (config.file) {
const baseTempPath = await this.cacheFilePath();
if (config.cleanupOldFiles) {
const allFiles = await fs.promises.readdir(baseTempPath);
for (const file of allFiles) {
if (
file !== config.file &&
StringFormat.replaceVersion(file, '0') ===
StringFormat.replaceVersion(config.file, '0')
) {
await fs.promises.unlink(path.join(baseTempPath, file));
}
}
}
tmpFile = path.join(baseTempPath, config.file);
let tmpFileExists = true;
try {
await fs.promises.access(tmpFile);
} catch {
tmpFileExists = false;
}
if (tmpFileExists) {
try {
if (config.cache && (await fs.promises.stat(tmpFile)).size > 0) {
logger.info('File already downloaded', config.url);
return Promise.resolve({ fileName: tmpFile });
} else {
await fs.promises.unlink(tmpFile);
}
} catch (e) {
fs.promises.unlink(tmpFile).catch(noop);
}
}
}
logger.info('GET ' + config.url);
const proxy = await Launcher?.ipcRenderer.invoke('resolve-proxy', config.url);
const url = new URL(config.url);
const opts: ClientRequestArgs = {
host: url.host,
port: url.port,
path: url.pathname + url.search
};
opts.headers = { 'User-Agent': navigator.userAgent };
logger.info(
'Request to ' +
config.url +
' ' +
(proxy ? `using proxy ${proxy.host}:${proxy.port}` : 'without proxy')
);
if (proxy) {
opts.headers.Host = url.host;
opts.host = proxy.host;
opts.port = proxy.port;
opts.path = config.url;
}
return new Promise((resolve, reject) => {
https
.get(opts, (res) => {
logger.info(`Response from ${config.url}: `, res.statusCode);
if (res.statusCode === 200) {
if (config.file) {
const file = fs.createWriteStream(tmpFile);
res.pipe(file);
file.on('finish', () => {
file.on('close', () => {
resolve({ fileName: tmpFile });
});
file.close();
});
file.on('error', (err) => {
reject(err);
});
} else {
const chunks: Buffer[] = [];
res.on('data', (chunk) => {
chunks.push(chunk);
});
res.on('end', () => {
resolve({ data: Buffer.concat(chunks) });
});
}
} else if (
res.headers.location &&
(res.statusCode === 301 || res.statusCode === 302)
) {
if (config.noRedirect) {
return reject(new Error('Too many redirects'));
}
config.url = res.headers.location;
config.noRedirect = true;
resolve(this.httpGet(config));
} else {
reject(new Error(`HTTP status ${res.statusCode ?? 0}`));
}
})
.on('error', (e) => {
logger.error('Cannot GET ' + config.url, e);
if (tmpFile) {
fs.unlink(tmpFile, noop);
}
reject(e);
});
});
}
}
const instance = new Updater();
export { instance as Updater };
@@ -1,9 +1,8 @@
import { Events } from 'util/events';
import { Events } from 'framework/events';
import { Logger } from 'util/logger';
import { NativeModules } from 'comp/launcher/native-modules';
import { AppSettings } from 'models/app-settings';
import { AppSettingsModel } from 'models/app-settings-model';
import { Features } from 'util/features';
import { noop } from 'util/fn';
const logger = new Logger('usb-listener');
@@ -11,47 +10,51 @@ const UsbListener = {
supported: Features.isDesktop,
attachedYubiKeys: 0,
init(): void {
init() {
if (!this.supported) {
return;
}
Events.on('native-modules-yubikeys', (numYubiKeys) => {
if (numYubiKeys !== this.attachedYubiKeys) {
logger.info(`YubiKeys changed ${this.attachedYubiKeys} => ${numYubiKeys}`);
this.attachedYubiKeys = numYubiKeys;
Events.on('native-modules-yubikeys', (e) => {
if (e.numYubiKeys !== this.attachedYubiKeys) {
logger.debug(`YubiKeys changed ${this.attachedYubiKeys} => ${e.numYubiKeys}`);
this.attachedYubiKeys = e.numYubiKeys;
Events.emit('usb-devices-changed');
}
});
AppSettings.onChange('enableUsb', (enabled) => {
AppSettingsModel.on('change:enableUsb', (model, enabled) => {
if (enabled) {
this.start().catch(noop);
this.start();
} else {
this.stop().catch(noop);
this.stop();
}
});
if (AppSettings.enableUsb) {
this.start().catch(noop);
if (AppSettingsModel.enableUsb) {
this.start();
}
},
async start(): Promise<void> {
start() {
logger.info('Starting USB listener');
if (this.usb) {
this.stop();
}
try {
await NativeModules.startUsbListener();
NativeModules.startUsbListener();
} catch (e) {
logger.error('Error starting USB listener', e);
}
},
async stop(): Promise<void> {
stop() {
logger.info('Stopping USB listener');
try {
await NativeModules.stopUsbListener();
NativeModules.stopUsbListener();
} catch (e) {
logger.error('Error stopping USB listener', e);
}
+295
View File
@@ -0,0 +1,295 @@
import { Events } from 'framework/events';
import { Launcher } from 'comp/launcher';
import { NativeModules } from 'comp/launcher/native-modules';
import { Logger } from 'util/logger';
import { UsbListener } from 'comp/app/usb-listener';
import { AppSettingsModel } from 'models/app-settings-model';
import { Timeouts } from 'const/timeouts';
import { YubiKeyProductIds, YubiKeyChallengeSize } from 'const/hardware';
import { Locale } from 'util/locale';
const logger = new Logger('yubikey');
const YubiKey = {
ykmanStatus: undefined,
process: null,
aborted: false,
cmd() {
if (this._cmd) {
return this._cmd;
}
const macYkmanPath = '/usr/local/bin/ykman';
if (Launcher.platform() === 'darwin' && Launcher.fileExistsSync(macYkmanPath)) {
this._cmd = macYkmanPath;
} else {
this._cmd = 'ykman';
}
return this._cmd;
},
checkToolStatus() {
if (this.ykmanStatus === 'ok') {
return Promise.resolve(this.ykmanStatus);
}
return new Promise((resolve) => {
this.ykmanStatus = 'checking';
this._cmd = undefined;
Launcher.spawn({
cmd: this.cmd(),
args: ['-v'],
noStdOutLogging: true,
complete: (err, stdout, code) => {
if (err || code !== 0) {
this.ykmanStatus = 'error';
} else {
this.ykmanStatus = 'ok';
}
resolve(this.ykmanStatus);
}
});
});
},
abort() {
logger.info('Aborting');
if (this.process) {
logger.info('Killing the process');
try {
this.process.kill();
} catch {}
}
this.aborted = true;
this.process = null;
},
list(callback) {
NativeModules.getYubiKeys({})
.then((yubiKeys) => {
yubiKeys = yubiKeys.map(({ serial, vid, pid, version, slots }) => {
return {
vid,
pid,
serial,
slots,
fullName: this.getKeyFullName(pid, version, serial)
};
});
callback(null, yubiKeys);
})
.catch(callback);
},
getKeyFullName(pid, version, serial) {
let name = 'YubiKey';
if (YubiKeyProductIds.Gen1.includes(pid)) {
name += ' Gen 1';
} else if (YubiKeyProductIds.NEO.includes(pid)) {
name += ' NEO';
} else if (YubiKeyProductIds.YK4.includes(pid)) {
if (version >= '5.1.0') {
name += ' 5';
}
}
return `${name} ${serial}`;
},
listWithYkman(callback) {
this._listWithYkman(callback, true);
},
_listWithYkman(callback, canRetry) {
if (this.process) {
return callback('Already in progress');
}
this.aborted = false;
logger.info('Listing YubiKeys');
if (!UsbListener.attachedYubiKeys) {
return callback(null, []);
}
this.process = Launcher.spawn({
cmd: this.cmd(),
args: ['list'],
noStdOutLogging: true,
complete: (err, stdout) => {
this.process = null;
if (this.aborted) {
return callback('Aborted');
}
if (err) {
return callback(err);
}
const yubiKeysIncludingEmpty = stdout
.trim()
.split(/\n/g)
.map((line) => {
const fullName = line;
const serial = (line.match(/\d{5,}$/g) || [])[0];
return { fullName, serial };
});
const yubiKeys = yubiKeysIncludingEmpty.filter((s) => s.serial);
if (
yubiKeysIncludingEmpty.length === 1 &&
yubiKeys.length === 0 &&
stdout.startsWith('YubiKey') &&
stdout.includes('CCID') &&
!stdout.includes('Serial')
) {
logger.info('The YubiKey is probably stuck');
if (!AppSettingsModel.yubiKeyStuckWorkaround) {
return callback(Locale.yubiKeyStuckError);
}
if (canRetry) {
return this._repairStuckYubiKey(callback);
}
}
if (!yubiKeys.length) {
return callback('No YubiKeys returned by "ykman list"');
}
callback(null, yubiKeys);
}
});
},
_repairStuckYubiKey(callback) {
logger.info('Repairing a stuck YubiKey');
let openTimeout;
const countYubiKeys = UsbListener.attachedYubiKeys;
const onDevicesChangedDuringRepair = () => {
if (UsbListener.attachedYubiKeys === countYubiKeys) {
logger.info('YubiKey was reconnected');
Events.off('usb-devices-changed', onDevicesChangedDuringRepair);
clearTimeout(openTimeout);
this.aborted = false;
setTimeout(() => {
this._listWithYkman(callback, false);
}, Timeouts.ExternalDeviceAfterReconnect);
}
};
Events.on('usb-devices-changed', onDevicesChangedDuringRepair);
Launcher.spawn({
cmd: this.cmd(),
args: ['config', 'usb', '-e', 'oath', '-f'],
noStdOutLogging: true,
complete: (err) => {
logger.info('Repair complete', err ? 'with error' : 'OK');
if (err) {
Events.off('usb-devices-changed', onDevicesChangedDuringRepair);
return callback(`YubiKey repair error: ${err}`);
}
openTimeout = setTimeout(() => {
Events.off('usb-devices-changed', onDevicesChangedDuringRepair);
}, Timeouts.ExternalDeviceReconnect);
}
});
},
getOtpCodes(serial, callback) {
if (this.process) {
return callback('Already in progress');
}
this.aborted = false;
this.process = Launcher.spawn({
cmd: this.cmd(),
args: ['-d', serial, 'oath', 'accounts', 'code'],
noStdOutLogging: true,
throwOnStdErr: true,
complete: (err, stdout) => {
this.process = null;
if (window.debugYubiKey) {
logger.info('received codes', err, stdout);
}
if (this.aborted) {
return callback('Aborted');
}
if (err) {
return callback(err);
}
const codes = [];
for (const line of stdout.split('\n')) {
const match = line.match(/^(.*?):(.*?)\s+(.*)$/);
if (!match) {
continue;
}
const [, title, user, code] = match;
const needsTouch = !code.match(/^\d+$/);
codes.push({
title,
user,
needsTouch
});
}
callback(null, codes);
}
});
},
getOtp(serial, entry, callback) {
return Launcher.spawn({
cmd: this.cmd(),
args: ['-d', serial, 'oath', 'accounts', 'code', '--single', entry],
noStdOutLogging: true,
complete: (err, stdout) => {
if (err) {
return callback(err);
}
const otp = stdout.trim();
callback(null, otp);
}
});
},
calculateChalResp(chalResp, challenge, callback) {
const { vid, pid, serial, slot } = chalResp;
const yubiKey = { vid, pid, serial };
challenge = Buffer.from(challenge);
// https://github.com/Yubico/yubikey-personalization-gui/issues/86
// https://github.com/keepassxreboot/keepassxc/blob/develop/src/keys/drivers/YubiKey.cpp#L318
const padLen = YubiKeyChallengeSize - challenge.byteLength;
const paddedChallenge = Buffer.alloc(YubiKeyChallengeSize, padLen);
challenge.copy(paddedChallenge);
NativeModules.yubiKeyChallengeResponse(
yubiKey,
[...paddedChallenge],
slot,
(err, result) => {
if (result) {
result = Buffer.from(result);
}
if (err) {
err.ykError = true;
}
return callback(err, result);
}
);
},
cancelChalResp() {
NativeModules.yubiKeyCancelChallengeResponse();
}
};
export { YubiKey };
@@ -1,20 +1,17 @@
import { Events } from 'util/events';
import { Events } from 'framework/events';
import { Launcher } from 'comp/launcher';
import { AppSettings } from 'models/app-settings';
interface CopyPasteResult {
success: boolean;
seconds?: number;
}
import { AppSettingsModel } from 'models/app-settings-model';
const CopyPaste = {
copy(text: string): CopyPasteResult {
if (Launcher) {
simpleCopy: !!(Launcher && Launcher.clipboardSupported),
copy(text) {
if (this.simpleCopy) {
Launcher.setClipboardText(text);
const clipboardSeconds = AppSettings.clipboardSeconds;
const clipboardSeconds = AppSettingsModel.clipboardSeconds;
if (clipboardSeconds > 0) {
const clearClipboard = () => {
if (Launcher?.getClipboardText() === text) {
if (Launcher.getClipboardText() === text) {
Launcher.clearClipboardText();
}
};
@@ -31,41 +28,39 @@ const CopyPaste = {
return { success: true };
}
} catch (e) {}
return { success: false };
return false;
}
},
createHiddenInput(text: string): void {
const hiddenInput = document.createElement('input');
hiddenInput.value = text;
hiddenInput.classList.add('hide-by-pos');
document.body.appendChild(hiddenInput);
hiddenInput.selectionStart = 0;
hiddenInput.selectionEnd = text.length;
createHiddenInput(text) {
const hiddenInput = $('<input/>')
.val(text)
.attr({ type: 'text', 'class': 'hide-by-pos' })
.appendTo(document.body);
hiddenInput[0].selectionStart = 0;
hiddenInput[0].selectionEnd = text.length;
hiddenInput.focus();
const onChange = () => {
setTimeout(() => hiddenInput.blur(), 0);
};
hiddenInput.addEventListener('cut', onChange);
hiddenInput.addEventListener('copy', onChange);
hiddenInput.addEventListener('paste', onChange);
hiddenInput.addEventListener('blur', () => hiddenInput.remove());
hiddenInput.on({
'copy cut paste'() {
setTimeout(() => hiddenInput.blur(), 0);
},
blur() {
hiddenInput.remove();
}
});
},
copyHtml(html: string): boolean {
copyHtml(html) {
const el = document.createElement('div');
el.style.userSelect = 'auto';
el.style.webkitUserSelect = 'auto';
el.style.mozUserSelect = 'auto';
el.innerHTML = html;
document.body.appendChild(el);
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
if (!sel) {
el.remove();
return false;
}
sel.removeAllRanges();
sel.addRange(range);
@@ -1,28 +1,25 @@
import { Features } from 'util/features';
const FeatureTester = {
async test(): Promise<void> {
test() {
return Promise.resolve()
.then(() => this.checkWebAssembly())
.then(() => this.checkLocalStorage())
.then(() => this.checkWebCrypto());
},
checkWebAssembly(): void {
checkWebAssembly() {
try {
const module = new global.WebAssembly.Module(
Uint8Array.of(0x0, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00)
);
const instance = new global.WebAssembly.Instance(module);
if (!(instance instanceof global.WebAssembly.Instance)) {
throw new Error();
}
return new global.WebAssembly.Instance(module) instanceof global.WebAssembly.Instance;
} catch (e) {
throw new Error('WebAssembly is not supported');
throw 'WebAssembly is not supported';
}
},
checkLocalStorage(): void {
checkLocalStorage() {
if (Features.isDesktop) {
return;
}
@@ -30,13 +27,13 @@ const FeatureTester = {
localStorage.setItem('_test', '1');
localStorage.removeItem('_test');
} catch (e) {
throw new Error('LocalStorage is not supported');
throw 'LocalStorage is not supported';
}
},
checkWebCrypto(): void {
checkWebCrypto() {
if (!global.crypto.subtle) {
throw new Error('WebCrypto is not supported');
throw 'WebCrypto is not supported';
}
}
};
@@ -1,12 +1,9 @@
import { Events } from 'util/events';
import { Events } from 'framework/events';
import { Launcher } from 'comp/launcher';
import { Features } from 'util/features';
const FocusDetector = {
isFocused: false,
detectsFocusWithEvents: false,
init(): void {
init() {
this.isFocused = true;
this.detectsFocusWithEvents = !Features.isDesktop && !Features.isMobile;
if (this.detectsFocusWithEvents) {
@@ -25,13 +22,13 @@ const FocusDetector = {
}
},
hasFocus(): Promise<boolean> {
hasFocus() {
if (this.detectsFocusWithEvents) {
return Promise.resolve(this.isFocused);
return this.isFocused;
} else if (Launcher) {
return Launcher.ipcRenderer.invoke('is-app-focused');
return Launcher.isAppFocused();
} else {
return Promise.resolve(true);
return true;
}
}
};
@@ -1,24 +1,24 @@
import { Events } from 'util/events';
import { AppSettings } from 'models/app-settings';
import { Events } from 'framework/events';
import { AppSettingsModel } from 'models/app-settings-model';
const IdleTracker = {
actionTime: Date.now(),
init(): void {
init() {
setInterval(this.checkIdle.bind(this), 1000 * 60);
},
checkIdle(): void {
checkIdle() {
const idleMinutes = (Date.now() - this.actionTime) / 1000 / 60;
const maxIdleMinutes = AppSettings.idleMinutes;
const maxIdleMinutes = AppSettingsModel.idleMinutes;
if (maxIdleMinutes && idleMinutes > maxIdleMinutes) {
Events.emit('before-user-idle');
Events.emit('user-idle');
}
},
regUserAction(): void {
regUserAction() {
this.actionTime = Date.now();
}
};
Events.on('power-monitor-resume', () => IdleTracker.checkIdle());
Events.on('power-monitor-resume', () => IdleTracker.checkIdle);
export { IdleTracker };
@@ -1,77 +1,62 @@
import { Events } from 'util/events';
import { Events } from 'framework/events';
import { IdleTracker } from 'comp/browser/idle-tracker';
import { Keys } from 'const/keys';
import { FocusManager } from 'comp/app/focus-manager';
const shortcutKeyProp = navigator.platform.indexOf('Mac') >= 0 ? 'metaKey' : 'ctrlKey';
interface ShortcutDef {
handler: (e: KeyboardEvent, code: number) => void;
shortcut?: number;
modal?: string;
noPrevent?: boolean;
}
class KeyHandler {
SHORTCUT_ACTION = 1;
SHORTCUT_OPT = 2;
SHORTCUT_SHIFT = 4;
shortcuts = new Map<Keys, ShortcutDef[]>();
shortcuts = {};
init() {
document.addEventListener('keypress', (e) => this.keypress(e));
document.addEventListener('keydown', (e) => this.keydown(e));
$(document).bind('keypress', this.keypress.bind(this));
$(document).bind('keydown', this.keydown.bind(this));
this.shortcuts.set(Keys.DOM_VK_A, [
this.shortcuts[Keys.DOM_VK_A] = [
{
handler: (e) => this.handleAKey(e),
handler: this.handleAKey,
thisArg: this,
shortcut: this.SHORTCUT_ACTION,
modal: '*',
modal: true,
noPrevent: true
}
]);
];
}
onKey(
key: Keys,
handler: (e: KeyboardEvent) => void,
shortcut?: number,
modal?: string,
noPrevent?: boolean
): () => void {
let keyShortcuts = this.shortcuts.get(key);
onKey(key, handler, thisArg, shortcut, modal, noPrevent) {
let keyShortcuts = this.shortcuts[key];
if (!keyShortcuts) {
keyShortcuts = [];
this.shortcuts.set(key, keyShortcuts);
this.shortcuts[key] = keyShortcuts = [];
}
const def: ShortcutDef = {
keyShortcuts.push({
handler,
thisArg,
shortcut,
modal,
noPrevent
};
keyShortcuts.push(def);
return () => {
const keyShortcuts = this.shortcuts.get(key);
if (keyShortcuts) {
const ix = keyShortcuts.indexOf(def);
if (ix >= 0) {
keyShortcuts.splice(ix, 1);
}
}
};
});
}
isActionKey(e: KeyboardEvent): boolean {
offKey(key, handler, thisArg) {
if (this.shortcuts[key]) {
this.shortcuts[key] = this.shortcuts[key].filter(
(sh) => sh.handler !== handler || sh.thisArg !== thisArg
);
}
}
isActionKey(e) {
return e[shortcutKeyProp];
}
keydown(e: KeyboardEvent): void {
keydown(e) {
IdleTracker.regUserAction();
const code = e.keyCode || e.which;
const keyShortcuts = this.shortcuts.get(code);
const keyShortcuts = this.shortcuts[code];
if (keyShortcuts && keyShortcuts.length) {
for (const sh of keyShortcuts) {
if (FocusManager.modal && sh.modal !== FocusManager.modal && sh.modal !== '*') {
@@ -106,23 +91,18 @@ class KeyHandler {
}
break;
}
let immediatePropagationStopped = false;
e.stopImmediatePropagation = () => {
immediatePropagationStopped = true;
KeyboardEvent.prototype.stopImmediatePropagation.call(e);
};
sh.handler(e, code);
sh.handler.call(sh.thisArg, e, code);
if (isActionKey && !sh.noPrevent) {
e.preventDefault();
}
if (immediatePropagationStopped) {
if (e.isImmediatePropagationStopped()) {
break;
}
}
}
}
keypress(e: KeyboardEvent): void {
keypress(e) {
if (
!FocusManager.modal &&
e.which !== Keys.DOM_VK_RETURN &&
@@ -134,7 +114,7 @@ class KeyHandler {
) {
Events.emit('keypress', e);
} else if (FocusManager.modal) {
Events.emit('keypress-modal', e, FocusManager.modal);
Events.emit('keypress:' + FocusManager.modal, e);
}
}
@@ -142,8 +122,11 @@ class KeyHandler {
IdleTracker.regUserAction();
}
handleAKey(e: KeyboardEvent): void {
if (e.target instanceof HTMLInputElement && ['password', 'text'].includes(e.target.type)) {
handleAKey(e) {
if (
e.target.tagName.toLowerCase() === 'input' &&
['password', 'text'].indexOf(e.target.type) >= 0
) {
e.stopImmediatePropagation();
} else {
e.preventDefault();
@@ -1,13 +1,19 @@
import { Events } from 'util/events';
import { Events } from 'framework/events';
import { Launcher } from 'comp/launcher';
import { Alerts } from 'comp/ui/alerts';
import { Timeouts } from 'const/timeouts';
import { Locale } from 'util/locale';
import { Logger } from 'util/logger';
import { noop } from 'util/fn';
const PopupNotifier = {
init(): void {
logger: null,
init() {
this.logger = new Logger('popup-notifier');
if (Launcher) {
window.open = () => null;
window.open = noop;
} else {
const windowOpen = window.open;
window.open = function (...args) {
@@ -15,22 +21,24 @@ const PopupNotifier = {
if (win) {
PopupNotifier.deferCheckClosed(win);
Events.emit('popup-opened', win);
} else if (!Alerts.alertDisplayed) {
Alerts.error({
header: Locale.authPopupRequired,
body: Locale.authPopupRequiredBody
});
} else {
if (!Alerts.alertDisplayed) {
Alerts.error({
header: Locale.authPopupRequired,
body: Locale.authPopupRequiredBody
});
}
}
return win;
};
}
},
deferCheckClosed(win: Window): void {
deferCheckClosed(win) {
setTimeout(PopupNotifier.checkClosed.bind(PopupNotifier, win), Timeouts.CheckWindowClosed);
},
checkClosed(win: Window): void {
checkClosed(win) {
if (win.closed) {
setTimeout(
PopupNotifier.triggerClosed.bind(PopupNotifier, win),
@@ -49,7 +57,7 @@ const PopupNotifier = {
}
},
tryGetLocationSearch(win: Window): string | undefined {
tryGetLocationSearch(win) {
try {
if (win.location.host === location.host) {
return win.location.search;
@@ -57,8 +65,8 @@ const PopupNotifier = {
} catch {}
},
triggerClosed(window: Window, locationSearch?: string): void {
Events.emit('popup-closed', window, locationSearch);
triggerClosed(window, locationSearch) {
Events.emit('popup-closed', { window, locationSearch });
}
};
+101
View File
@@ -0,0 +1,101 @@
import * as kdbxweb from 'kdbxweb';
const SecureInput = function () {
this.el = null;
this.minChar = 0x1400 + Math.round(Math.random() * 100);
this.maxLen = 1024;
this.length = 0;
this.pseudoValue = '';
this.salt = new Uint32Array(0);
};
SecureInput.prototype.setElement = function (el) {
this.el = el;
this.el.val(this.pseudoValue);
this.el.on('input', this._input.bind(this));
};
SecureInput.prototype.reset = function () {
this.el = null;
this.length = 0;
this.pseudoValue = '';
if (this.salt) {
for (let i = 0; i < this.salt.length; i++) {
this.salt[i] = 0;
}
}
this.salt = new Uint32Array(0);
};
SecureInput.prototype._input = function () {
const selStart = this.el[0].selectionStart;
const value = this.el.val();
let newPs = '';
const newSalt = new Uint32Array(this.maxLen);
let valIx = 0,
psIx = 0;
while (valIx < value.length) {
const valCh = value.charCodeAt(valIx);
const psCh = this.pseudoValue.charCodeAt(psIx);
const isSpecial = this._isSpecialChar(valCh);
if (psCh === valCh) {
// not changed
newPs += this._getChar(newPs.length);
newSalt[newPs.length - 1] = psCh ^ this.salt[psIx] ^ newPs.charCodeAt(newPs.length - 1);
psIx++;
valIx++;
} else if (isSpecial) {
// deleted
psIx++;
} else {
// inserted or replaced
newPs += this._getChar(newPs.length);
newSalt[newPs.length - 1] = newPs.charCodeAt(newPs.length - 1) ^ valCh;
valIx++;
}
}
this.length = newPs.length;
this.pseudoValue = newPs;
this.salt = newSalt;
this.el.val(newPs);
this.el[0].selectionStart = selStart;
this.el[0].selectionEnd = selStart;
};
SecureInput.prototype._getChar = function (ix) {
return String.fromCharCode(this.minChar + ix);
};
SecureInput.prototype._isSpecialChar = function (ch) {
return ch >= this.minChar && ch <= this.minChar + this.maxLen;
};
Object.defineProperty(SecureInput.prototype, 'value', {
enumerable: true,
get() {
const pseudoValue = this.pseudoValue;
const salt = this.salt;
const len = pseudoValue.length;
let byteLength = 0;
const valueBytes = new Uint8Array(len * 4);
const saltBytes = kdbxweb.CryptoEngine.random(len * 4);
let ch;
let bytes;
for (let i = 0; i < len; i++) {
const pseudoCharCode = pseudoValue.charCodeAt(i);
ch = String.fromCharCode(salt[i] ^ pseudoCharCode);
bytes = kdbxweb.ByteUtils.stringToBytes(ch);
for (let j = 0; j < bytes.length; j++) {
valueBytes[byteLength] = bytes[j] ^ saltBytes[byteLength];
byteLength++;
}
}
return new kdbxweb.ProtectedValue(
valueBytes.buffer.slice(0, byteLength),
saltBytes.buffer.slice(0, byteLength)
);
}
});
export { SecureInput };
@@ -1,18 +1,18 @@
import { Events } from 'util/events';
import { Events } from 'framework/events';
const ThemeWatcher = {
dark: false,
init(): void {
init() {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
if (mediaQuery && mediaQuery.addEventListener) {
mediaQuery.addEventListener('change', (e) => {
const dark = e.matches;
this.dark = dark;
Events.emit('dark-mode-changed', dark);
Events.emit('dark-mode-changed', { dark });
});
}
this.dark = mediaQuery.matches;
this.dark = !!mediaQuery.matches;
}
};
+118
View File
@@ -0,0 +1,118 @@
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) {
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) {
logger.info('File already downloaded ' + config.url);
return config.success(tmpFile);
} else {
fs.unlinkSync(tmpFile);
}
} catch (e) {
fs.unlink(tmpFile, noop);
}
}
}
const proto = config.url.split(':')[0];
logger.info('GET ' + config.url);
const opts = Launcher.req('url').parse(config.url);
opts.headers = { 'User-Agent': navigator.userAgent };
Launcher.resolveProxy(config.url, (proxy) => {
logger.info(
'Request to ' +
config.url +
' ' +
(proxy ? 'using proxy ' + proxy.host + ':' + proxy.port : 'without proxy')
);
if (proxy) {
opts.headers.Host = opts.host;
opts.host = proxy.host;
opts.port = proxy.port;
opts.path = config.url;
}
Launcher.req(proto)
.get(opts, (res) => {
logger.info('Response from ' + config.url + ': ' + res.statusCode);
if (res.statusCode === 200) {
if (config.file) {
const file = fs.createWriteStream(tmpFile);
res.pipe(file);
file.on('finish', () => {
file.close(() => {
config.success(tmpFile);
});
});
file.on('error', (err) => {
config.error(err);
});
} else {
let data = [];
res.on('data', (chunk) => {
data.push(chunk);
});
res.on('end', () => {
data = window.Buffer.concat(data);
if (config.text || config.json) {
data = data.toString('utf8');
}
if (config.json) {
try {
data = JSON.parse(data);
} catch (e) {
config.error('Error parsing JSON: ' + e.message);
}
}
config.success(data);
});
}
} else if (res.headers.location && [301, 302].indexOf(res.statusCode) >= 0) {
if (config.noRedirect) {
return config.error('Too many redirects');
}
config.url = res.headers.location;
config.noRedirect = true;
Transport.httpGet(config);
} else {
config.error('HTTP status ' + res.statusCode);
}
})
.on('error', (e) => {
logger.error('Cannot GET ' + config.url, e);
if (tmpFile) {
fs.unlink(tmpFile, noop);
}
config.error(e);
});
});
}
};
export { Transport };
-42
View File
@@ -1,42 +0,0 @@
import { Features } from 'util/features';
import { AppSettings } from 'models/app-settings';
import { Events } from 'util/events';
export const WindowClass = {
init(): void {
const browserCssClass = Features.browserCssClass;
if (browserCssClass) {
document.body.classList.add(browserCssClass);
}
if (AppSettings.titlebarStyle !== 'default') {
document.body.classList.add(`titlebar-${AppSettings.titlebarStyle}`);
if (Features.renderCustomTitleBar) {
document.body.classList.add('titlebar-custom');
}
}
if (Features.isMobile) {
document.body.classList.add('mobile');
}
Events.on('enter-full-screen', () => document.body.classList.add('fullscreen'));
Events.on('leave-full-screen', () => document.body.classList.remove('fullscreen'));
},
setThemeClass(theme: string): void {
for (const cls of document.body.classList) {
if (cls.startsWith('th-')) {
document.body.classList.remove(cls);
}
}
document.body.classList.add(`th-${theme}`);
},
setFontSizeClass(fontSize: number): void {
for (const cls of document.body.classList) {
if (cls.startsWith('font-size-')) {
document.body.classList.remove(cls);
}
}
document.body.classList.add(`font-size-${fontSize}`);
}
};
@@ -1,80 +0,0 @@
import { ContextMenu, ContextMenuItem } from 'models/menu/context-menu';
import { StringFormat } from 'util/formatting/string-format';
import { Locale } from 'util/locale';
import { Features } from 'util/features';
import { Shortcuts } from 'comp/browser/shortcuts';
import { Position } from 'util/types';
import { FileManager } from 'models/file-manager';
import { Entry } from 'models/entry';
import { File } from 'models/file';
export class ContextMenuNew {
static show(pos: Position): void {
const newEntryMenuItem = new ContextMenuItem(
'entry',
'key',
StringFormat.capFirst(Locale.entry),
() => this.newEntryClicked()
);
if (!Features.isMobile) {
const shortcut = Shortcuts.presentShortcut('Alt+N');
newEntryMenuItem.hint = `(${Locale.searchShiftClickOr} ${shortcut})`;
}
const newGroupMenuItem = new ContextMenuItem(
'group',
'folder',
StringFormat.capFirst(Locale.group),
() => this.newGroupClicked()
);
const templateMenuItems: ContextMenuItem[] = [];
const hasMultipleFiles = FileManager.files.length > 1;
for (const file of FileManager.files) {
for (const entry of file.allEntryTemplates()) {
if (!entry.title) {
continue;
}
const id = `tmpl:${entry.id}`;
templateMenuItems.push(
new ContextMenuItem(
id,
entry.icon ?? 'sticky-note-o',
hasMultipleFiles ? `${file.name} / ${entry.title}` : entry.title,
() => this.newFromTemplateClicked(entry, file)
)
);
}
}
const collator = new Intl.Collator(undefined, { sensitivity: 'base' });
templateMenuItems.sort((x, y) => {
return collator.compare(x.title, y.title);
});
templateMenuItems.push(
new ContextMenuItem(
'tmpl',
'sticky-note-o',
StringFormat.capFirst(Locale.template),
() => this.newTemplateClicked()
)
);
ContextMenu.toggle('new', pos, [newEntryMenuItem, newGroupMenuItem, ...templateMenuItems]);
}
static newEntryClicked(): void {
// TODO
}
static newGroupClicked(): void {
// TODO
}
static newTemplateClicked(): void {
// TODO
}
static newFromTemplateClicked(entry: Entry, file: File): void {
// TODO
}
}
@@ -1,92 +0,0 @@
import { ContextMenu, ContextMenuItem } from 'models/menu/context-menu';
import { Position } from 'util/types';
import { StringFormat } from 'util/formatting/string-format';
import { Locale } from 'util/locale';
import { QuerySort } from 'models/query';
import { Workspace } from 'models/workspace';
const arrow = '→';
export class ContextMenuSort {
static show(pos: Position): void {
const items: ContextMenuItem[] = [
new ContextMenuItem(
QuerySort.TitleAsc,
'sort-alpha-down',
StringFormat.capFirst(Locale.title) + ' ' + Locale.searchAZ.with(arrow),
() => this.itemClicked(QuerySort.TitleAsc)
),
new ContextMenuItem(
QuerySort.TitleDesc,
'sort-alpha-down-alt',
StringFormat.capFirst(Locale.title) + ' ' + Locale.searchZA.with(arrow),
() => this.itemClicked(QuerySort.TitleDesc)
),
new ContextMenuItem(
QuerySort.WebsiteAsc,
'sort-alpha-down',
StringFormat.capFirst(Locale.website) + ' ' + Locale.searchAZ.with(arrow),
() => this.itemClicked(QuerySort.WebsiteAsc)
),
new ContextMenuItem(
QuerySort.WebsiteDesc,
'sort-alpha-down-alt',
StringFormat.capFirst(Locale.website) + ' ' + Locale.searchZA.with(arrow),
() => this.itemClicked(QuerySort.WebsiteDesc)
),
new ContextMenuItem(
QuerySort.UserAsc,
'sort-alpha-down',
StringFormat.capFirst(Locale.user) + ' ' + Locale.searchAZ.with(arrow),
() => this.itemClicked(QuerySort.UserAsc)
),
new ContextMenuItem(
QuerySort.UserDesc,
'sort-alpha-down-alt',
StringFormat.capFirst(Locale.user) + ' ' + Locale.searchZA.with(arrow),
() => this.itemClicked(QuerySort.UserDesc)
),
new ContextMenuItem(
QuerySort.CreatedAsc,
'sort-numeric-down',
StringFormat.capFirst(Locale.searchCreated) + ' ' + Locale.searchON.with(arrow),
() => this.itemClicked(QuerySort.CreatedAsc)
),
new ContextMenuItem(
QuerySort.CreatedDesc,
'sort-numeric-down-alt',
StringFormat.capFirst(Locale.searchCreated) + ' ' + Locale.searchNO.with(arrow),
() => this.itemClicked(QuerySort.CreatedDesc)
),
new ContextMenuItem(
QuerySort.UpdatedAsc,
'sort-numeric-down',
StringFormat.capFirst(Locale.searchUpdated) + ' ' + Locale.searchON.with(arrow),
() => this.itemClicked(QuerySort.UpdatedAsc)
),
new ContextMenuItem(
QuerySort.UpdatedDesc,
'sort-numeric-down-alt',
StringFormat.capFirst(Locale.searchUpdated) + ' ' + Locale.searchNO.with(arrow),
() => this.itemClicked(QuerySort.UpdatedDesc)
),
new ContextMenuItem(
QuerySort.AttachmentsDesc,
'sort-amount-down',
Locale.searchAttachments,
() => this.itemClicked(QuerySort.AttachmentsDesc)
),
new ContextMenuItem(QuerySort.RankDesc, 'sort-amount-down', Locale.searchRank, () =>
this.itemClicked(QuerySort.RankDesc)
)
];
const selectedItem = items.find((it) => it.id === Workspace.query.sort);
ContextMenu.toggle('sort', pos, items, selectedItem);
}
private static itemClicked(sort: QuerySort) {
Workspace.query.sort = sort;
}
}
-323
View File
@@ -1,323 +0,0 @@
import { Events } from 'util/events';
import { Launcher } from 'comp/launcher';
import { NativeModules } from 'comp/launcher/native-modules';
import { Logger } from 'util/logger';
import { UsbListener } from 'comp/devices/usb-listener';
import { AppSettings } from 'models/app-settings';
import { Timeouts } from 'const/timeouts';
import { YubiKeyProductIds, YubiKeyChallengeSize } from 'const/hardware';
import { Locale } from 'util/locale';
import * as fs from 'fs';
import { YubiKeyInfo } from 'comp/launcher/desktop-ipc';
import { FileChalRespConfig } from 'models/file-info';
const logger = new Logger('yubikey');
let cmd: string | undefined;
type ToolStatus = 'unknown' | 'ok' | 'checking' | 'error';
const YkmanProcessName = 'ykman';
export interface YubiKeyInfoWithName extends YubiKeyInfo {
fullName: string;
}
export interface ShortYubiKeyInfo {
fullName: string;
serial: string;
}
export interface YubiKeyOtpCodeInfo {
title: string;
user: string;
needsTouch: boolean;
}
const YubiKey = {
ykmanStatus: 'unknown' as ToolStatus,
processIsRunning: false,
aborted: false,
async cmd(): Promise<string> {
if (cmd) {
return cmd;
}
const macYkmanPath = '/usr/local/bin/ykman';
if (process.platform === 'darwin') {
try {
await fs.promises.access(macYkmanPath);
cmd = macYkmanPath;
return cmd;
} catch (err) {}
}
cmd = 'ykman';
return cmd;
},
async checkToolStatus(): Promise<ToolStatus> {
if (this.ykmanStatus === 'ok') {
return Promise.resolve(this.ykmanStatus);
}
this.ykmanStatus = 'checking';
cmd = undefined;
try {
await Launcher?.spawn({
cmd: await this.cmd(),
args: ['-v'],
noStdOutLogging: true
});
this.ykmanStatus = 'ok';
} catch {
this.ykmanStatus = 'error';
}
return this.ykmanStatus;
},
abort(): void {
logger.info('Aborting');
if (this.processIsRunning) {
logger.info('Killing the process');
try {
Launcher?.killProcess(YkmanProcessName);
} catch {}
}
this.aborted = true;
this.processIsRunning = false;
},
async list(): Promise<YubiKeyInfoWithName[]> {
const yubiKeys = await NativeModules.getYubiKeys();
return yubiKeys.map((yk) => ({
...yk,
fullName: this.getKeyFullName(yk)
}));
},
getKeyFullName(yk: YubiKeyInfo): string {
let name = 'YubiKey';
if (YubiKeyProductIds.Gen1.includes(yk.pid)) {
name += ' Gen 1';
} else if (YubiKeyProductIds.NEO.includes(yk.pid)) {
name += ' NEO';
} else if (YubiKeyProductIds.YK4.includes(yk.pid)) {
if (yk.version >= '5.1.0') {
name += ' 5';
}
}
return `${name} ${yk.serial}`;
},
listWithYkman(): Promise<ShortYubiKeyInfo[]> {
return this.listWithYkmanInternal(true);
},
async listWithYkmanInternal(canRetry: boolean): Promise<ShortYubiKeyInfo[]> {
if (this.processIsRunning) {
throw new Error('Already in progress');
}
this.aborted = false;
logger.info('Listing YubiKeys');
if (!UsbListener.attachedYubiKeys) {
return Promise.resolve([]);
}
this.processIsRunning = true;
let stdout;
try {
const res = await Launcher?.spawn({
cmd: await this.cmd(),
args: ['list'],
noStdOutLogging: true,
name: YkmanProcessName
});
stdout = res?.stdout ?? '';
} catch (err) {
if (this.aborted) {
throw new Error('Aborted');
}
throw err;
} finally {
this.processIsRunning = false;
}
const yubiKeysIncludingEmpty: ShortYubiKeyInfo[] = stdout
.trim()
.split(/\n/g)
.map((line) => {
const fullName = line;
const serial = (line.match(/\d{5,}$/g) || [])[0];
return { fullName, serial };
});
const yubiKeys = yubiKeysIncludingEmpty.filter((s) => s.serial);
if (
yubiKeysIncludingEmpty.length === 1 &&
yubiKeys.length === 0 &&
stdout.startsWith('YubiKey') &&
stdout.includes('CCID') &&
!stdout.includes('Serial')
) {
logger.info('The YubiKey is probably stuck');
if (!AppSettings.yubiKeyStuckWorkaround) {
throw new Error(Locale.yubiKeyStuckError);
}
if (canRetry) {
return this.repairStuckYubiKey();
}
}
if (!yubiKeys.length) {
throw new Error('No YubiKeys returned by "ykman list"');
}
return yubiKeys;
},
async repairStuckYubiKey(): Promise<ShortYubiKeyInfo[]> {
logger.info('Repairing a stuck YubiKey');
let result: Promise<ShortYubiKeyInfo[]> | undefined;
let openTimeout = 0;
const countYubiKeys = UsbListener.attachedYubiKeys;
const onDevicesChangedDuringRepair = () => {
if (UsbListener.attachedYubiKeys === countYubiKeys) {
logger.info('YubiKey was reconnected');
Events.off('usb-devices-changed', onDevicesChangedDuringRepair);
clearTimeout(openTimeout);
this.aborted = false;
result = new Promise<ShortYubiKeyInfo[]>((resolve) => {
setTimeout(() => {
resolve(this.listWithYkmanInternal(false));
}, Timeouts.ExternalDeviceAfterReconnect);
});
}
};
Events.on('usb-devices-changed', onDevicesChangedDuringRepair);
try {
await Launcher?.spawn({
cmd: await this.cmd(),
args: ['config', 'usb', '-e', 'oath', '-f'],
noStdOutLogging: true
});
} catch (err) {
logger.info('Repair complete with error', err);
Events.off('usb-devices-changed', onDevicesChangedDuringRepair);
throw new Error('YubiKey repair error');
}
logger.info('Repair complete OK');
openTimeout = window.setTimeout(() => {
Events.off('usb-devices-changed', onDevicesChangedDuringRepair);
}, Timeouts.ExternalDeviceReconnect);
if (!result) {
throw new Error('YubiKeys not changed during repair');
}
return result;
},
async getOtpCodes(serial: number): Promise<YubiKeyOtpCodeInfo[]> {
if (this.processIsRunning) {
throw new Error('Already in progress');
}
this.aborted = false;
let stdout: string;
this.processIsRunning = true;
try {
const res = await Launcher?.spawn({
cmd: await this.cmd(),
args: ['-d', String(serial), 'oath', 'accounts', 'code'],
noStdOutLogging: true,
throwOnStdErr: true
});
stdout = res?.stdout ?? '';
} catch (err) {
if (this.aborted) {
throw new Error('Aborted');
}
throw err;
} finally {
this.processIsRunning = false;
}
const codes: YubiKeyOtpCodeInfo[] = [];
for (const line of stdout.split('\n')) {
const match = /^(.*?):(.*?)\s+(.*)$/.exec(line);
if (!match) {
continue;
}
const [, title, user, code] = match;
const needsTouch = !/^\d+$/.exec(code);
codes.push({
title,
user,
needsTouch
});
}
return codes;
},
async getOtp(serial: number, entry: string): Promise<string> {
const res = await Launcher?.spawn({
cmd: await this.cmd(),
args: ['-d', String(serial), 'oath', 'accounts', 'code', '--single', entry],
noStdOutLogging: true
});
const code = res?.stdout?.trim();
if (!code) {
throw new Error('Nothing returned');
}
return code;
},
calculateChalResp(chalResp: FileChalRespConfig, challenge: ArrayBuffer): Promise<ArrayBuffer> {
// const { vid, pid, serial, slot } = chalResp;
// const yubiKey = { vid, pid, serial };
const challengeBuffer = Buffer.from(challenge);
// https://github.com/Yubico/yubikey-personalization-gui/issues/86
// https://github.com/keepassxreboot/keepassxc/blob/develop/src/keys/drivers/YubiKey.cpp#L318
const padLen = YubiKeyChallengeSize - challenge.byteLength;
const paddedChallenge = Buffer.alloc(YubiKeyChallengeSize, padLen);
challengeBuffer.copy(paddedChallenge);
throw new Error('Not implemented'); // TODO: chal-resp
// NativeModules.yubiKeyChallengeResponse(
// yubiKey,
// [...paddedChallenge],
// slot,
// (err, result) => {
// if (result) {
// result = Buffer.from(result);
// }
// if (err) {
// err.ykError = true;
// }
// return callback(err, result);
// }
// );
},
async cancelChalResp(): Promise<void> {
await NativeModules.yubiKeyCancelChallengeResponse();
}
};
export { YubiKey };
@@ -143,7 +143,7 @@ const BrowserExtensionConnector = {
if (e?.data?.kwConnect !== 'request') {
return;
}
logger.info('Extension -> KeeWeb', e.data);
logger.debug('Extension -> KeeWeb', e.data);
pendingBrowserMessages.push(e.data);
this.processBrowserMessages();
},
@@ -173,7 +173,7 @@ const BrowserExtensionConnector = {
},
sendWebResponse(response) {
logger.info('KeeWeb -> Extension', response);
logger.debug('KeeWeb -> Extension', response);
response.kwConnect = 'response';
postMessage(response, window.location.origin);
},
@@ -102,7 +102,7 @@ function decryptRequest(request) {
const json = new TextDecoder().decode(data);
const payload = JSON.parse(json);
logger.info('Extension -> KeeWeb -> (decrypted)', payload);
logger.debug('Extension -> KeeWeb -> (decrypted)', payload);
if (!payload) {
throw new Error('Empty request payload');
@@ -115,7 +115,7 @@ function decryptRequest(request) {
}
function encryptResponse(request, payload) {
logger.info('KeeWeb -> Extension (decrypted)', payload);
logger.debug('KeeWeb -> Extension (decrypted)', payload);
const nonceBytes = kdbxweb.ByteUtils.base64ToBytes(request.nonce);
incrementNonce(nonceBytes);
@@ -280,7 +280,7 @@ function getHumanReadableExtensionName(client) {
}
function focusKeeWeb() {
logger.info('Focus KeeWeb');
logger.debug('Focus KeeWeb');
if (Launcher) {
if (!Launcher.isAppFocused()) {
Launcher.showMainWindow();
+119
View File
@@ -0,0 +1,119 @@
/* eslint-disable import/no-commonjs */
import * as kdbxweb from 'kdbxweb';
import { RuntimeInfo } from 'const/runtime-info';
import { Links } from 'const/links';
import { DateFormat } from 'comp/i18n/date-format';
import { StringFormat } from 'util/formatting/string-format';
import { Locale } from 'util/locale';
const Templates = {
db: require('templates/export/db.hbs'),
entry: require('templates/export/entry.hbs')
};
const FieldMapping = [
{ name: 'UserName', locStr: 'user' },
{ name: 'Password', locStr: 'password', protect: true },
{ name: 'URL', locStr: 'website' },
{ name: 'Notes', locStr: 'notes' }
];
const KnownFields = { 'Title': true };
for (const { name } of FieldMapping) {
KnownFields[name] = true;
}
function walkGroup(db, group, parents) {
parents = [...parents, group];
if (
group.uuid.equals(db.meta.recycleBinUuid) ||
group.uuid.equals(db.meta.entryTemplatesGroup)
) {
return '';
}
const self = group.entries.map((entry) => walkEntry(db, entry, parents)).join('\n');
const children = group.groups
.map((childGroup) => walkGroup(db, childGroup, parents))
.join('\n');
return self + children;
}
function walkEntry(db, entry, parents) {
const path = parents.map((group) => group.name).join(' / ');
const fields = [];
for (const field of FieldMapping) {
const value = entryField(entry, field.name);
if (value) {
fields.push({
title: StringFormat.capFirst(Locale[field.locStr]),
value,
protect: field.protect
});
}
}
for (const [fieldName, fieldValue] of entry.fields) {
if (!KnownFields[fieldName]) {
const value = entryField(entry, fieldName);
if (value) {
fields.push({
title: fieldName,
value,
protect: fieldValue.isProtected
});
}
}
}
const title = entryField(entry, 'Title');
let expires;
if (entry.times.expires && entry.times.expiryTime) {
expires = DateFormat.dtStr(entry.times.expiryTime);
}
const attachments = [...entry.binaries]
.map(([name, data]) => {
if (data && data.ref) {
data = data.value;
}
if (data) {
const base64 = kdbxweb.ByteUtils.bytesToBase64(data);
data = 'data:application/octet-stream;base64,' + base64;
}
return { name, data };
})
.filter((att) => att.name && att.data);
return Templates.entry({
path,
title,
fields,
tags: entry.tags.join(', '),
created: DateFormat.dtStr(entry.times.creationTime),
modified: DateFormat.dtStr(entry.times.lastModTime),
expires,
attachments
});
}
function entryField(entry, fieldName) {
const value = entry.fields.get(fieldName);
return (value && value.isProtected && value.getText()) || value || '';
}
const KdbxToHtml = {
convert(db, options) {
const content = db.groups.map((group) => walkGroup(db, group, [])).join('\n');
return Templates.db({
name: options.name,
date: DateFormat.dtStr(Date.now()),
appLink: Links.Homepage,
appVersion: RuntimeInfo.version,
contentHtml: content
});
},
entryToHtml(db, entry) {
return walkEntry(db, entry, []);
}
};
export { KdbxToHtml };
@@ -1,18 +1,18 @@
import { Events } from 'util/events';
import { Shortcuts } from 'comp/browser/shortcuts';
import { Alert, Alerts } from 'comp/ui/alerts';
import QrCode from 'jsqrcode';
import { Events } from 'framework/events';
import { Shortcuts } from 'comp/app/shortcuts';
import { Alerts } from 'comp/ui/alerts';
import { Otp } from 'util/data/otp';
import { Features } from 'util/features';
import { Locale } from 'util/locale';
import { Logger } from 'util/logger';
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const QrCode = require('jsqrcode');
const logger = new Logger('otp-qr-reader');
class OtpQrReader {
private _alert?: Alert;
private _fileInput?: HTMLInputElement;
alert = null;
fileInput = null;
constructor() {
this.pasteEvent = this.pasteEvent.bind(this);
@@ -21,12 +21,12 @@ class OtpQrReader {
read() {
let screenshotKey = Shortcuts.screenshotToClipboardShortcut();
if (screenshotKey) {
screenshotKey = Locale.detSetupOtpAlertBodyWith.with(screenshotKey);
screenshotKey = Locale.detSetupOtpAlertBodyWith.replace('{}', screenshotKey);
}
const pasteKey = Features.isMobile
? ''
: Locale.detSetupOtpAlertBodyWith.with(Shortcuts.actionShortcutSymbol() + 'V');
this.startListenClipboard();
: Locale.detSetupOtpAlertBodyWith.replace('{}', Shortcuts.actionShortcutSymbol() + 'V');
this.startListenClipoard();
const buttons = [
{ result: 'manually', title: Locale.detSetupOtpManualButton, silent: true },
Alerts.buttons.cancel
@@ -36,14 +36,14 @@ class OtpQrReader {
}
const line3 = Features.isMobile
? Locale.detSetupOtpAlertBody3Mobile
: Locale.detSetupOtpAlertBody3.with(pasteKey || '');
this._alert = Alerts.alert({
: Locale.detSetupOtpAlertBody3.replace('{}', pasteKey || '');
this.alert = Alerts.alert({
icon: 'qrcode',
header: Locale.detSetupOtpAlert,
body: [
Locale.detSetupOtpAlertBody,
Locale.detSetupOtpAlertBody1,
Locale.detSetupOtpAlertBody2.with(screenshotKey || ''),
Locale.detSetupOtpAlertBody2.replace('{}', screenshotKey || ''),
line3,
Locale.detSetupOtpAlertBody4
].join('\n'),
@@ -52,7 +52,7 @@ class OtpQrReader {
enter: '',
buttons,
complete: (res) => {
this._alert = undefined;
this.alert = null;
this.stopListenClipboard();
if (res === 'select') {
this.selectFile();
@@ -64,93 +64,78 @@ class OtpQrReader {
}
selectFile() {
if (!this._fileInput) {
if (!this.fileInput) {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('capture', 'camera');
input.setAttribute('accept', 'image/*');
input.setAttribute('class', 'hide-by-pos');
this._fileInput = input;
this._fileInput.onchange = () => this.fileSelected();
this.fileInput = input;
this.fileInput.onchange = this.fileSelected;
}
this._fileInput.click();
this.fileInput.click();
}
private fileSelected() {
const file = this._fileInput?.files?.[0];
fileSelected() {
const file = this.fileInput.files[0];
if (!file || file.type.indexOf('image') < 0) {
return;
}
this.readFile(file);
}
private startListenClipboard() {
// eslint-disable-next-line @typescript-eslint/unbound-method
startListenClipoard() {
document.addEventListener('paste', this.pasteEvent);
}
private stopListenClipboard() {
// eslint-disable-next-line @typescript-eslint/unbound-method
stopListenClipboard() {
document.removeEventListener('paste', this.pasteEvent);
}
private pasteEvent(e: ClipboardEvent) {
const items = e.clipboardData?.items;
if (!items) {
logger.info('Empty clipboard data');
return;
}
const item = [...items].find(
pasteEvent(e) {
const item = [...e.clipboardData.items].find(
(item) => item.kind === 'file' && item.type.indexOf('image') !== -1
);
if (!item) {
logger.info('Paste without file');
logger.debug('Paste without file');
return;
}
logger.info('Reading pasted image', item.type);
if (this._alert) {
this._alert.change({
if (this.alert) {
this.alert.change({
header: Locale.detOtpImageReading
});
}
const file = item.getAsFile();
if (file) {
this.readFile(file);
} else {
logger.info('Empty file');
}
this.readFile(item.getAsFile());
}
private readFile(file: File) {
readFile(file) {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result !== 'string') {
logger.info('Read not a string');
return;
}
logger.info('Image data loaded');
logger.debug('Image data loaded');
this.readQr(reader.result);
};
reader.readAsDataURL(file);
}
private readQr(imageData: string) {
readQr(imageData) {
const image = new Image();
image.onload = () => {
logger.info('Image format loaded');
logger.debug('Image format loaded');
try {
const ts = logger.ts();
// eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment
const url = new QrCode(image).decode();
logger.info('QR code read', logger.ts(ts));
this.removeAlert();
if (typeof url === 'string' && url) {
Events.emit('qr-url-read', url);
} else {
logger.error('Error reading QR code');
try {
const otp = Otp.parseUrl(url);
Events.emit('qr-read', otp);
} catch (err) {
logger.error('Error parsing QR code', err);
Alerts.error({
header: Locale.detOtpQrWrong,
body: Locale.detOtpQrWrongBody
body: Locale.detOtpQrWrongBody,
pre: err.toString()
});
}
} catch (e) {
@@ -163,7 +148,7 @@ class OtpQrReader {
}
};
image.onerror = () => {
logger.info('Image load error');
logger.debug('Image load error');
this.removeAlert();
Alerts.error({
header: Locale.detOtpImageError,
@@ -173,13 +158,13 @@ class OtpQrReader {
image.src = imageData;
}
private enterManually() {
enterManually() {
Events.emit('qr-enter-manually');
}
private removeAlert() {
if (this._alert) {
this._alert.closeImmediate();
removeAlert() {
if (this.alert) {
this.alert.closeImmediate();
}
}
}
@@ -1,9 +1,9 @@
import { Locale } from 'util/locale';
import { SettingsManager } from 'comp/settings/settings-manager';
import { StringFormat } from 'util/formatting/string-format';
const DateFormat = {
months(): string[] {
const format = new Intl.DateTimeFormat(Locale.localeName, { month: 'long' });
months() {
const format = new Intl.DateTimeFormat(SettingsManager.activeLocale, { month: 'long' });
const months = [];
for (let month = 0; month < 12; month++) {
months.push(format.format(new Date(2008, month)));
@@ -11,8 +11,8 @@ const DateFormat = {
return months;
},
weekDays(): string[] {
const format = new Intl.DateTimeFormat(Locale.localeName, { weekday: 'long' });
weekDays() {
const format = new Intl.DateTimeFormat(SettingsManager.activeLocale, { weekday: 'long' });
const weekdays = [];
for (let day = 1; day < 8; day++) {
weekdays.push(format.format(new Date(2007, 9, 6 + day)));
@@ -20,8 +20,8 @@ const DateFormat = {
return weekdays;
},
shortWeekDays(): string[] {
const format = new Intl.DateTimeFormat(Locale.localeName, { weekday: 'short' });
shortWeekDays() {
const format = new Intl.DateTimeFormat(SettingsManager.activeLocale, { weekday: 'short' });
const weekdays = [];
for (let day = 1; day < 8; day++) {
weekdays.push(format.format(new Date(2007, 9, 6 + day)));
@@ -29,24 +29,24 @@ const DateFormat = {
return weekdays;
},
dtStr(dt: Date | number): string {
dtStr(dt) {
if (typeof dt === 'number') {
dt = new Date(dt);
}
return dt
? new Intl.DateTimeFormat(Locale.localeName, {
? new Intl.DateTimeFormat(SettingsManager.activeLocale, {
dateStyle: 'medium',
timeStyle: 'medium'
}).format(dt)
: '';
},
dStr(dt: Date | number): string {
dStr(dt) {
if (typeof dt === 'number') {
dt = new Date(dt);
}
return dt
? new Intl.DateTimeFormat(Locale.localeName, {
? new Intl.DateTimeFormat(SettingsManager.activeLocale, {
year: 'numeric',
month: 'short',
day: 'numeric'
@@ -54,12 +54,12 @@ const DateFormat = {
: '';
},
dtStrFs(dt: Date | number): string {
dtStrFs(dt) {
if (typeof dt === 'number') {
dt = new Date(dt);
}
return dt
? dt.getFullYear().toString() +
? dt.getFullYear() +
'-' +
StringFormat.pad(dt.getMonth() + 1, 2) +
'-' +
-136
View File
@@ -1,136 +0,0 @@
import { h } from 'preact';
import * as kdbxweb from 'kdbxweb';
import { RuntimeInfo } from 'const/runtime-info';
import { Links } from 'const/links';
import { DateFormat } from 'util/formatting/date-format';
import { StringFormat } from 'util/formatting/string-format';
import { Locale } from 'util/locale';
import { HtmlRenderer } from 'util/browser/html-renderer';
import { ExportFile } from 'views/standalone/export-file';
import { ExportEntry, ExportEntryParameters } from 'views/standalone/export-entry';
const FieldMapping = [
{ name: 'UserName', loc: () => Locale.user },
{ name: 'Password', loc: () => Locale.password, protect: true },
{ name: 'URL', loc: () => Locale.website },
{ name: 'Notes', loc: () => Locale.notes }
];
const KnownFields = new Set('Title');
for (const { name } of FieldMapping) {
KnownFields.add(name);
}
function convertEntry(db: kdbxweb.Kdbx, entry: kdbxweb.KdbxEntry): ExportEntryParameters {
const path = getParents(entry).join(' / ');
const fields = [];
for (const field of FieldMapping) {
const value = entryField(entry, field.name);
if (value) {
fields.push({
title: StringFormat.capFirst(field.loc()),
value,
protect: field.protect
});
}
}
for (const [fieldName, fieldValue] of entry.fields) {
if (!KnownFields.has(fieldName)) {
const value = entryField(entry, fieldName);
if (value) {
fields.push({
title: fieldName,
value,
protect: fieldValue instanceof kdbxweb.ProtectedValue
});
}
}
}
const title = entryField(entry, 'Title');
let expires;
if (entry.times.expires && entry.times.expiryTime) {
expires = DateFormat.dtStr(entry.times.expiryTime);
}
const attachments = [...entry.binaries]
.map(([name, data]) => {
let value;
if (kdbxweb.KdbxBinaries.isKdbxBinaryWithHash(data)) {
value = data.value;
} else {
value = data;
}
if (value instanceof kdbxweb.ProtectedValue) {
value = value.getBinary();
}
let dataHref = '';
if (value) {
const base64 = kdbxweb.ByteUtils.bytesToBase64(value);
dataHref = 'data:application/octet-stream;base64,' + base64;
}
return { name, dataHref };
})
.filter((att) => att.name && att.dataHref);
return {
id: entry.uuid.id,
path,
title,
fields,
tags: entry.tags,
created: entry.times.creationTime ? DateFormat.dtStr(entry.times.creationTime) : '',
modified: entry.times.lastModTime ? DateFormat.dtStr(entry.times.lastModTime) : '',
expires,
attachments
};
}
function entryField(entry: kdbxweb.KdbxEntry, fieldName: string): string {
const value = entry.fields.get(fieldName);
if (value instanceof kdbxweb.ProtectedValue) {
return value.getText();
}
return value || '';
}
function getParents(entry: kdbxweb.KdbxEntry): string[] {
const parents = [];
const group = entry.parentGroup;
while (group?.name) {
parents.push(group.name);
}
return parents;
}
const KdbxToHtml = {
convert(db: kdbxweb.Kdbx, name: string): string {
const entries: ExportEntryParameters[] = [];
for (const group of db.groups) {
if (
group.uuid.equals(db.meta.recycleBinUuid) ||
group.uuid.equals(db.meta.entryTemplatesGroup)
) {
continue;
}
for (const entry of group.allEntries()) {
entries.push(convertEntry(db, entry));
}
}
return HtmlRenderer.renderToHtml(
h(ExportFile, {
name,
date: DateFormat.dtStr(Date.now()),
appVersion: RuntimeInfo.version,
appLink: Links.Homepage,
entries
})
);
},
entryToHtml(db: kdbxweb.Kdbx, entry: kdbxweb.KdbxEntry): string {
const params = convertEntry(db, entry);
return HtmlRenderer.renderToHtml(h(ExportEntry, params));
}
};
export { KdbxToHtml };
-308
View File
@@ -1,308 +0,0 @@
import * as kdbxweb from 'kdbxweb';
import { Logger } from 'util/logger';
import { Features } from 'util/features';
import { NativeModules } from 'comp/launcher/native-modules';
const logger = new Logger('argon2');
interface Argon2WasmModule {
ALLOC_NORMAL: 'ALLOC_NORMAL';
HEAP8: Uint8Array;
// eslint-disable-next-line @typescript-eslint/naming-convention
_free(ptr: number): void;
allocate(buf: ArrayLike<number>, type: 'i8', flags: 'ALLOC_NORMAL'): number;
// eslint-disable-next-line @typescript-eslint/naming-convention,camelcase
_argon2_hash(
iterations: number,
memory: number,
parallelism: number,
password: number,
passwordLen: number,
salt: number,
saltLen: number,
hash: number,
length: number,
encoded: number,
encodedLen: number,
type: number,
version: number
): number;
}
interface Argon2HashArgs {
password: ArrayBuffer;
salt: ArrayBuffer;
memory: number;
iterations: number;
length: number;
parallelism: number;
type: kdbxweb.CryptoEngine.Argon2Type;
version: kdbxweb.CryptoEngine.Argon2Version;
}
interface Argon2Runtime {
hash(args: Argon2HashArgs): Promise<Uint8Array>;
}
let runtimeModule: Argon2Runtime | undefined;
interface MessageFromWorkerWorkerLog {
op: 'log';
args: unknown[];
}
interface MessageFromWorkerWorkerPostRun {
op: 'postRun';
}
interface MessageFromWorkerWorkerHash {
op: 'hash';
hash: Uint8Array;
}
interface MessageFromWorkerWorkerError {
op: 'error';
error: string;
}
type MessageFromWorkerWorker =
| MessageFromWorkerWorkerLog
| MessageFromWorkerWorkerPostRun
| MessageFromWorkerWorkerHash
| MessageFromWorkerWorkerError;
const KdbxwebInit = {
init(): void {
kdbxweb.CryptoEngine.setArgon2Impl((...args) => this.argon2(...args));
},
async argon2(
password: ArrayBuffer,
salt: ArrayBuffer,
memory: number,
iterations: number,
length: number,
parallelism: number,
type: kdbxweb.CryptoEngine.Argon2Type,
version: kdbxweb.CryptoEngine.Argon2Version
): Promise<ArrayBuffer> {
const runtime = await this.loadRuntime(memory);
const ts = logger.ts();
const hash = await runtime.hash({
password,
salt,
memory,
iterations,
length,
parallelism,
type,
version
});
logger.info('Hash computed', logger.ts(ts));
return hash;
},
loadRuntime(requiredMemory: number): Promise<Argon2Runtime> {
if (runtimeModule) {
return Promise.resolve(runtimeModule);
}
if (!global.WebAssembly) {
return Promise.reject(new Error('WebAssembly is not supported'));
}
if (Features.isDesktop) {
logger.info('Using native argon2');
runtimeModule = {
hash(args) {
const ts = logger.ts();
const password = kdbxweb.ProtectedValue.fromBinary(args.password).dataAndSalt();
const salt = kdbxweb.ProtectedValue.fromBinary(args.salt).dataAndSalt();
return NativeModules.argon2(password, salt, {
type: args.type,
version: args.version,
hashLength: args.length,
timeCost: args.iterations,
parallelism: args.parallelism,
memoryCost: args.memory
})
.then((res) => {
password.data.fill(0);
salt.data.fill(0);
logger.info('Argon2 hash calculated', logger.ts(ts));
const pv = new kdbxweb.ProtectedValue(
kdbxweb.ByteUtils.arrayToBuffer(new Uint8Array(res.data)),
kdbxweb.ByteUtils.arrayToBuffer(new Uint8Array(res.salt))
);
return pv.getBinary();
})
.catch((err) => {
password.data.fill(0);
salt.data.fill(0);
logger.error('Argon2 error', err);
throw err;
});
}
};
return Promise.resolve(runtimeModule);
}
return new Promise<Argon2Runtime>((resolve, reject) => {
const loadTimeout = setTimeout(() => reject(new Error('Timeout')), 5000);
try {
const ts = logger.ts();
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const argon2LoaderCode = require('argon2').default as string;
const wasmBinaryBase64 = require('argon2-wasm') as string;
const KB = 1024 * 1024;
const MB = 1024 * KB;
const GB = 1024 * MB;
const WASM_PAGE_SIZE = 64 * 1024;
const totalMemory = (2 * GB - 64 * KB) / 1024 / WASM_PAGE_SIZE;
const initialMemory = Math.min(
Math.max(Math.ceil((requiredMemory * 1024) / WASM_PAGE_SIZE), 256) + 256,
totalMemory
);
const memoryDecl = `var wasmMemory=new WebAssembly.Memory({initial:${initialMemory},maximum:${totalMemory}});`;
const moduleDecl =
'var Module={' +
'wasmJSMethod: "native-wasm",' +
`wasmBinary: Uint8Array.from(atob("${wasmBinaryBase64}"), c => c.charCodeAt(0)),` +
'print(...args) { postMessage({op:"log",args}) },' +
'printErr(...args) { postMessage({op:"log",args}) },' +
`postRun:${this.workerPostRun.toString()},` +
`calcHash:${this.calcHash.toString()},` +
'wasmMemory:wasmMemory,' +
'buffer:wasmMemory.buffer,' +
`TOTAL_MEMORY:${initialMemory * WASM_PAGE_SIZE}` +
'}';
const script = argon2LoaderCode.replace(/^var Module.*?}/, memoryDecl + moduleDecl);
const blob = new Blob([script], { type: 'application/javascript' });
const objectUrl = URL.createObjectURL(blob);
const worker = new Worker(objectUrl);
const onMessage = (e: MessageEvent) => {
const data = e.data as MessageFromWorkerWorker;
switch (data.op) {
case 'log':
logger.info(...data.args);
break;
case 'postRun':
logger.info('WebAssembly runtime loaded (web worker)', logger.ts(ts));
URL.revokeObjectURL(objectUrl);
clearTimeout(loadTimeout);
worker.removeEventListener('message', onMessage);
runtimeModule = {
hash(args: Argon2HashArgs): Promise<Uint8Array> {
return new Promise((resolve, reject) => {
worker.postMessage(args);
const onHashMessage = (e: MessageEvent) => {
worker.removeEventListener('message', onHashMessage);
worker.terminate();
runtimeModule = undefined;
const msg = e.data as MessageFromWorkerWorker;
switch (msg.op) {
case 'hash':
resolve(msg.hash);
break;
case 'error':
logger.error('Worker error', msg.error);
reject(new Error(msg.error));
break;
default:
reject(
new Error('Unexpected message from worker')
);
}
};
worker.addEventListener('message', onHashMessage);
});
}
};
resolve(runtimeModule);
break;
default:
logger.error('Unknown message', e.data);
URL.revokeObjectURL(objectUrl);
reject(new Error('Load error'));
}
};
worker.addEventListener('message', onMessage);
} catch (err) {
reject(err);
}
}).catch((err) => {
logger.warn('WebAssembly error', err);
throw new Error('WebAssembly error');
});
},
// eslint-disable-next-line object-shorthand
workerPostRun: function (): void {
/* eslint-disable */
const postRunMsg: MessageFromWorkerWorkerPostRun = { op: 'postRun' };
(self as unknown as MessagePort).postMessage(postRunMsg);
self.onmessage = (e: MessageEvent) => {
try {
// @ts-ignore
const hash = Module.calcHash(Module, e.data) as Uint8Array;
const hashMsg: MessageFromWorkerWorkerHash = { op: 'hash', hash };
(self as unknown as MessagePort).postMessage(hashMsg);
} catch (e) {
const errMsg: MessageFromWorkerWorkerError = { op: 'error', error: e.toString() };
(self as unknown as MessagePort).postMessage(errMsg);
}
};
/* eslint-enable */
},
// eslint-disable-next-line object-shorthand
calcHash: function (Module: Argon2WasmModule, args: Argon2HashArgs): Uint8Array {
const { memory, iterations, length, parallelism, type, version } = args;
const passwordLen = args.password.byteLength;
const password = Module.allocate(new Uint8Array(args.password), 'i8', Module.ALLOC_NORMAL);
const saltLen = args.salt.byteLength;
const salt = Module.allocate(new Uint8Array(args.salt), 'i8', Module.ALLOC_NORMAL);
const hash = Module.allocate(new Array(length), 'i8', Module.ALLOC_NORMAL);
const encodedLen = 512;
const encoded = Module.allocate(new Array(encodedLen), 'i8', Module.ALLOC_NORMAL);
const res = Module._argon2_hash(
iterations,
memory,
parallelism,
password,
passwordLen,
salt,
saltLen,
hash,
length,
encoded,
encodedLen,
type,
version
);
if (res) {
throw new Error(`Argon2 error ${res}`);
}
const hashArr = new Uint8Array(length);
for (let i = 0; i < length; i++) {
hashArr[i] = Module.HEAP8[hash + i];
}
Module._free(password);
Module._free(salt);
Module._free(hash);
Module._free(encoded);
return hashArr;
}
};
export { KdbxwebInit };
@@ -1,76 +0,0 @@
import { Launcher } from 'comp/launcher';
import { Alert, Alerts } from 'comp/ui/alerts';
import { Features } from 'util/features';
import { Locale } from 'util/locale';
import { RuntimeData } from 'models/runtime-data';
import * as fs from 'fs';
const InstalledAppPath = '/Applications/KeeWeb.app';
let alert: Alert | undefined;
const AppRightsChecker = {
async init(): Promise<void> {
if (!Launcher || !Features.isMac) {
return;
}
if (RuntimeData.skipFolderRightsWarning) {
return;
}
const appPath = await Launcher.ipcRenderer.invoke('get-app-path');
if (!appPath.startsWith(InstalledAppPath)) {
return;
}
const needRun = await this.needRunInstaller();
if (needRun) {
this.showAlert();
await this.runInstaller();
}
},
async needRunInstaller(): Promise<boolean> {
const stat = await fs.promises.stat(InstalledAppPath);
const folderIsRoot = stat && stat.uid === 0;
return !folderIsRoot;
},
showAlert(): void {
const command = `sudo chown -R root ${InstalledAppPath}`;
alert = Alerts.alert({
icon: 'lock',
header: Locale.appRightsAlert,
body: Locale.appRightsAlertBody1 + '\n' + Locale.appRightsAlertBody2,
pre: command,
buttons: [
{ result: 'skip', title: Locale.alertDoNotAsk, error: true },
Alerts.buttons.ok
],
success: async (result) => {
if (result === 'skip') {
await this.dontAskAnymore();
}
alert = undefined;
}
});
},
async runInstaller(): Promise<void> {
await Launcher?.spawn({
cmd: `${InstalledAppPath}/Contents/Installer/KeeWeb Installer.app/Contents/MacOS/applet`,
args: ['--install']
});
const needRun = await this.needRunInstaller();
if (alert && !needRun) {
alert.closeWithResult('cancel');
}
},
async dontAskAnymore(): Promise<void> {
const needRun = await this.needRunInstaller();
if (needRun) {
RuntimeData.skipFolderRightsWarning = true;
}
}
};
export { AppRightsChecker };
-1
View File
@@ -1 +0,0 @@
../../../../desktop/desktop-ipc.d.ts
+7
View File
@@ -0,0 +1,7 @@
let Launcher;
if (window.process && window.process.versions && window.process.versions.electron) {
Launcher = require('./launcher-electron').Launcher;
}
export { Launcher };
-9
View File
@@ -1,9 +0,0 @@
import { LauncherElectron } from './launcher-electron';
let Launcher: LauncherElectron | undefined;
if (global.process?.versions?.electron) {
Launcher = new LauncherElectron();
}
export { Launcher };
@@ -0,0 +1,330 @@
import { Events } from 'framework/events';
import { StartProfiler } from 'comp/app/start-profiler';
import { RuntimeInfo } from 'const/runtime-info';
import { Locale } from 'util/locale';
import { Logger } from 'util/logger';
import { noop } from 'util/fn';
const logger = new Logger('launcher');
const Launcher = {
name: 'electron',
version: window.process.versions.electron,
autoTypeSupported: true,
thirdPartyStoragesSupported: true,
clipboardSupported: true,
req: window.require,
platform() {
return process.platform;
},
arch() {
return process.arch;
},
electron() {
return this.req('electron');
},
remoteApp() {
return this.electron().remote.app;
},
remReq(mod) {
return this.electron().remote.require(mod);
},
openLink(href) {
if (/^(http|https|ftp|sftp|mailto):/i.test(href)) {
this.electron().shell.openExternal(href);
}
},
devTools: true,
openDevTools() {
this.electron().remote.getCurrentWindow().webContents.openDevTools({ mode: 'bottom' });
},
getSaveFileName(defaultPath, callback) {
if (defaultPath) {
const homePath = this.remReq('electron').app.getPath('userDesktop');
defaultPath = this.joinPath(homePath, defaultPath);
}
this.remReq('electron')
.dialog.showSaveDialog({
title: Locale.launcherSave,
defaultPath,
filters: [{ name: Locale.launcherFileFilter, extensions: ['kdbx'] }]
})
.then((res) => callback(res.filePath));
},
getUserDataPath(fileName) {
if (!this.userDataPath) {
this.userDataPath = this.remoteApp().getPath('userData');
}
return this.joinPath(this.userDataPath, fileName || '');
},
getTempPath(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 || '');
},
getAppPath(fileName) {
const dirname = this.req('path').dirname;
const appPath = __dirname.endsWith('app.asar') ? __dirname : this.remoteApp().getAppPath();
return this.joinPath(dirname(appPath), fileName || '');
},
getWorkDirPath(fileName) {
return this.joinPath(process.cwd(), fileName || '');
},
joinPath(...parts) {
return this.req('path').join(...parts);
},
writeFile(path, data, callback) {
this.req('fs').writeFile(path, window.Buffer.from(data), callback);
},
readFile(path, encoding, callback) {
this.req('fs').readFile(path, encoding, (err, contents) => {
const data = typeof contents === 'string' ? contents : new Uint8Array(contents);
callback(data, err);
});
},
fileExists(path, callback) {
const fs = this.req('fs');
fs.access(path, fs.constants.F_OK, (err) => callback(!err));
},
fileExistsSync(path) {
const fs = this.req('fs');
return !fs.accessSync(path, fs.constants.F_OK);
},
deleteFile(path, callback) {
this.req('fs').unlink(path, callback || noop);
},
statFile(path, callback) {
this.req('fs').stat(path, (err, stats) => callback(stats, err));
},
mkdir(dir, callback) {
const fs = this.req('fs');
const path = this.req('path');
const stack = [];
const collect = function (dir, stack, callback) {
fs.exists(dir, (exists) => {
if (exists) {
return callback();
}
stack.unshift(dir);
const newDir = path.dirname(dir);
if (newDir === dir || !newDir || newDir === '.' || newDir === '/') {
return callback();
}
collect(newDir, stack, callback);
});
};
const create = function (stack, callback) {
if (!stack.length) {
return callback();
}
fs.mkdir(stack.shift(), (err) => (err ? callback(err) : create(stack, callback)));
};
collect(dir, stack, () => create(stack, callback));
},
parsePath(fileName) {
const path = this.req('path');
return {
path: fileName,
dir: path.dirname(fileName),
file: path.basename(fileName)
};
},
createFsWatcher(path) {
return this.req('fs').watch(path, { persistent: false });
},
loadConfig(name) {
return this.remoteApp().loadConfig(name);
},
saveConfig(name, data) {
return this.remoteApp().saveConfig(name, data);
},
preventExit(e) {
e.returnValue = false;
return false;
},
exit() {
this.exitRequested = true;
this.requestExit();
},
requestExit() {
const app = this.remoteApp();
app.setHookBeforeQuitEvent(false);
if (this.pendingUpdateFile) {
app.restartAndUpdate(this.pendingUpdateFile);
} else {
app.quit();
}
},
requestRestartAndUpdate(updateFilePath) {
this.pendingUpdateFile = updateFilePath;
this.requestExit();
},
cancelRestart() {
this.pendingUpdateFile = undefined;
},
setClipboardText(text) {
return this.electron().clipboard.writeText(text);
},
getClipboardText() {
return this.electron().clipboard.readText();
},
clearClipboardText() {
const { clipboard } = this.electron();
clipboard.clear();
if (process.platform === 'linux') {
clipboard.clear('selection');
}
},
quitOnRealQuitEventIfMinimizeOnQuitIsEnabled() {
return !!this.pendingUpdateFile;
},
minimizeApp() {
this.remoteApp().minimizeApp({
restore: Locale.menuRestoreApp.replace('{}', 'KeeWeb'),
quit: Locale.menuQuitApp.replace('{}', 'KeeWeb')
});
},
canDetectOsSleep() {
return process.platform !== 'linux';
},
updaterEnabled() {
return process.platform !== 'linux';
},
getMainWindow() {
return this.remoteApp().getMainWindow();
},
resolveProxy(url, callback) {
const window = this.getMainWindow();
const session = window.webContents.session;
session.resolveProxy(url).then((proxy) => {
const match = /^proxy\s+([\w\.]+):(\d+)+\s*/i.exec(proxy);
proxy = match && match[1] ? { host: match[1], port: +match[2] } : null;
callback(proxy);
});
},
hideApp() {
const app = this.remoteApp();
if (this.platform() === 'darwin') {
app.hide();
} else {
app.minimizeThenHideIfInTray();
}
},
isAppFocused() {
return !!this.electron().remote.BrowserWindow.getFocusedWindow();
},
showMainWindow() {
this.remoteApp().showAndFocusMainWindow();
},
spawn(config) {
const ts = logger.ts();
const { ipcRenderer } = this.electron();
let { complete } = config;
delete config.complete;
ipcRenderer
.invoke('spawnProcess', config)
.then((res) => {
if (res.err) {
logger.error('spawn error: ' + config.cmd + ', ' + logger.ts(ts), res.err);
complete?.(res.err);
} else {
const code = res.code;
const stdout = res.stdout || '';
const stderr = res.stderr || '';
const msg = 'spawn ' + config.cmd + ': ' + code + ', ' + logger.ts(ts);
if (code !== 0) {
logger.error(msg + '\n' + stdout + '\n' + stderr);
} else {
logger.info(msg + (stdout && !config.noStdOutLogging ? '\n' + stdout : ''));
}
complete?.(code !== 0 ? 'Exit code ' + code : null, stdout, code);
}
complete = null;
})
.catch((err) => {
complete?.(err);
});
},
checkOpenFiles() {
this.readyToOpenFiles = true;
if (this.pendingFileToOpen) {
this.openFile(this.pendingFileToOpen);
delete this.pendingFileToOpen;
}
},
openFile(file) {
if (this.readyToOpenFiles) {
Events.emit('launcher-open-file', file);
} else {
this.pendingFileToOpen = file;
}
},
setGlobalShortcuts(appSettings) {
this.remoteApp().setGlobalShortcuts(appSettings);
},
minimizeMainWindow() {
this.getMainWindow().minimize();
},
maximizeMainWindow() {
this.getMainWindow().maximize();
},
restoreMainWindow() {
this.getMainWindow().restore();
},
mainWindowMaximized() {
return this.getMainWindow().isMaximized();
}
};
Events.on('launcher-exit-request', () => {
setTimeout(() => Launcher.exit(), 0);
});
Events.on('launcher-minimize', () => setTimeout(() => Events.emit('app-minimized'), 0));
Events.on('launcher-maximize', () => setTimeout(() => Events.emit('app-maximized'), 0));
Events.on('launcher-unmaximize', () => setTimeout(() => Events.emit('app-unmaximized'), 0));
Events.on('launcher-started-minimized', () => setTimeout(() => Launcher.minimizeApp(), 0));
Events.on('start-profile', (data) => StartProfiler.reportAppProfile(data));
window.launcherOpen = (file) => Launcher.openFile(file);
if (window.launcherOpenedFile) {
logger.info('Open file request', window.launcherOpenedFile);
Launcher.openFile(window.launcherOpenedFile);
delete window.launcherOpenedFile;
}
Events.on('app-ready', () =>
setTimeout(() => {
Launcher.checkOpenFiles();
Launcher.remoteApp().setAboutPanelOptions({
applicationVersion: RuntimeInfo.version,
version: RuntimeInfo.commit
});
}, 0)
);
if (process.platform === 'darwin') {
Launcher.remoteApp().setHookBeforeQuitEvent(true);
}
Launcher.remoteApp().on('remote-app-event', (e) => {
if (window.debugRemoteAppEvents) {
logger.debug('remote-app-event', e.name);
}
Events.emit(e.name, e.data);
});
export { Launcher };
@@ -1,350 +0,0 @@
// import { Events } from 'util/events';
// import { StartProfiler } from 'comp/app/start-profiler';
// import { RuntimeInfo } from 'const/runtime-info';
import { Logger } from 'util/logger';
import { noop } from 'util/fn';
import * as electron from 'electron';
import { DesktopIpcRendererEvents, DesktopIpcMainEvents, DesktopIpcMainCalls } from './desktop-ipc';
const logger = new Logger('launcher');
type TypedIpcRenderer = {
on<EventName extends keyof DesktopIpcRendererEvents>(
event: EventName,
listener: (
event: electron.IpcRendererEvent,
...args: Parameters<DesktopIpcRendererEvents[EventName]>
) => void
): void;
send<EventName extends keyof DesktopIpcMainEvents>(
event: EventName,
...args: Parameters<DesktopIpcMainEvents[EventName]>
): void;
invoke<CallName extends keyof DesktopIpcMainCalls>(
method: CallName,
...args: Parameters<DesktopIpcMainCalls[CallName]>
): Promise<ReturnType<DesktopIpcMainCalls[CallName]>>;
};
export class LauncherElectron {
name = 'electron';
version = process.versions.electron;
pendingUpdateFile?: string;
mainWindowMaximized?: boolean;
constructor() {
this.ipcRenderer.on('log', (e, name, level, ...args) => {
const logger = new Logger(name);
logger[level](...args);
});
}
get ipcRenderer(): TypedIpcRenderer {
return electron.ipcRenderer as TypedIpcRenderer;
}
openLink(href: string): void {
if (/^(http|https|ftp|sftp|mailto):/i.test(href)) {
electron.shell.openExternal(href).catch(noop);
}
}
// openDevTools() {
// this.electron().remote.getCurrentWindow().webContents.openDevTools({ mode: 'bottom' });
// }
// getSaveFileName(defaultPath, callback) {
// if (defaultPath) {
// const homePath = this.remReq('electron').app.getPath('userDesktop');
// defaultPath = this.joinPath(homePath, defaultPath);
// }
// this.remReq('electron')
// .dialog.showSaveDialog({
// title: Locale.launcherSave,
// defaultPath,
// filters: [{ name: Locale.launcherFileFilter, extensions: ['kdbx'] }]
// })
// .then((res) => callback(res.filePath));
// },
// getUserDataPath(fileName) {
// if (!this.userDataPath) {
// this.userDataPath = this.remoteApp().getPath('userData');
// }
// return this.joinPath(this.userDataPath, fileName || '');
// },
// getTempPath(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 || '');
// },
// getAppPath(fileName) {
// const dirname = this.req('path').dirname;
// const appPath = __dirname.endsWith('app.asar') ? __dirname : this.remoteApp().getAppPath();
// return this.joinPath(dirname(appPath), fileName || '');
// },
// getWorkDirPath(fileName) {
// return this.joinPath(process.cwd(), fileName || '');
// },
// joinPath(...parts) {
// return this.req('path').join(...parts);
// },
// writeFile(path, data, callback) {
// this.req('fs').writeFile(path, window.Buffer.from(data), callback);
// },
// readFile(path, encoding, callback) {
// this.req('fs').readFile(path, encoding, (err, contents) => {
// const data = typeof contents === 'string' ? contents : new Uint8Array(contents);
// callback(data, err);
// });
// },
// fileExists(path, callback) {
// const fs = this.req('fs');
// fs.access(path, fs.constants.F_OK, (err) => callback(!err));
// },
// fileExistsSync(path) {
// const fs = this.req('fs');
// return !fs.accessSync(path, fs.constants.F_OK);
// },
// deleteFile(path, callback) {
// this.req('fs').unlink(path, callback || noop);
// },
// statFile(path, callback) {
// this.req('fs').stat(path, (err, stats) => callback(stats, err));
// },
// mkdir(dir, callback) {
// const fs = this.req('fs');
// const path = this.req('path');
// const stack = [];
//
// const collect = function (dir, stack, callback) {
// fs.exists(dir, (exists) => {
// if (exists) {
// return callback();
// }
//
// stack.unshift(dir);
// const newDir = path.dirname(dir);
// if (newDir === dir || !newDir || newDir === '.' || newDir === '/') {
// return callback();
// }
//
// collect(newDir, stack, callback);
// });
// };
//
// const create = function (stack, callback) {
// if (!stack.length) {
// return callback();
// }
//
// fs.mkdir(stack.shift(), (err) => (err ? callback(err) : create(stack, callback)));
// };
//
// collect(dir, stack, () => create(stack, callback));
// },
// parsePath(fileName) {
// const path = this.req('path');
// return {
// path: fileName,
// dir: path.dirname(fileName),
// file: path.basename(fileName)
// };
// },
// createFsWatcher(path) {
// return this.req('fs').watch(path, { persistent: false });
// },
// preventExit(e) {
// e.returnValue = false;
// return false;
// },
// exit() {
// this.exitRequested = true;
// this.requestExit();
// },
requestExit(): void {
throw new Error('TODO');
// const app = this.remoteApp();
// app.setHookBeforeQuitEvent(false);
// if (this.pendingUpdateFile) {
// app.restartAndUpdate(this.pendingUpdateFile);
// } else {
// app.quit();
// }
}
requestRestartAndUpdate(updateFilePath: string): void {
this.pendingUpdateFile = updateFilePath;
this.requestExit();
}
// cancelRestart() {
// this.pendingUpdateFile = undefined;
// },
setClipboardText(text: string): void {
electron.clipboard.writeText(text);
}
getClipboardText(): string {
return electron.clipboard.readText();
}
clearClipboardText(): void {
electron.clipboard.clear();
if (process.platform === 'linux') {
electron.clipboard.clear('selection');
}
}
// quitOnRealQuitEventIfMinimizeOnQuitIsEnabled() {
// return !!this.pendingUpdateFile;
// },
// minimizeApp() {
// this.remoteApp().minimizeApp({
// restore: Locale.menuRestoreApp.replace('{}', 'KeeWeb'),
// quit: Locale.menuQuitApp.replace('{}', 'KeeWeb')
// });
// },
// canDetectOsSleep() {
// return process.platform !== 'linux';
// },
updaterEnabled(): boolean {
return process.platform !== 'linux';
}
// getMainWindow() {
// return this.remoteApp().getMainWindow();
// },
// resolveProxy(url, callback) {
// const window = this.getMainWindow();
// const session = window.webContents.session;
// session.resolveProxy(url).then((proxy) => {
// const match = /^proxy\s+([\w\.]+):(\d+)+\s*/i.exec(proxy);
// proxy = match && match[1] ? { host: match[1], port: +match[2] } : null;
// callback(proxy);
// });
// },
// hideApp() {
// const app = this.remoteApp();
// if (this.platform() === 'darwin') {
// app.hide();
// } else {
// app.minimizeThenHideIfInTray();
// }
// },
// isAppFocused() {
// return !!this.electron().remote.BrowserWindow.getFocusedWindow();
// },
// showMainWindow() {
// this.remoteApp().showAndFocusMainWindow();
// },
async spawn(config: {
cmd: string;
args: string[];
noStdOutLogging?: boolean;
throwOnStdErr?: boolean;
name?: string;
}): Promise<{ code?: number; stdout?: string; stderr?: string }> {
const ts = logger.ts();
const res = await this.ipcRenderer.invoke('spawn-process', {
cmd: config.cmd,
args: config.args
});
if (res.err) {
logger.error('spawn error: ' + config.cmd + ', ' + logger.ts(ts), res.err);
throw new Error(res.err);
} else {
const msg = `spawn ${config.cmd}: ${res.code || 0}, ${logger.ts(ts)}`;
if (res.code) {
logger.error(`${msg}\n${res.stdout ?? ''}\n${res.stderr ?? ''}`);
throw new Error(`Exit code ${res.code}`);
} else {
logger.info(msg + (res.stdout && !config.noStdOutLogging ? '\n' + res.stdout : ''));
if (res.stderr && config.throwOnStdErr) {
throw new Error(`Error returned: ${res.stderr.trim()}`);
}
}
return res;
}
}
async killProcess(name: string): Promise<void> {
await this.ipcRenderer.invoke('kill-process', name);
}
// checkOpenFiles() {
// this.readyToOpenFiles = true;
// if (this.pendingFileToOpen) {
// this.openFile(this.pendingFileToOpen);
// delete this.pendingFileToOpen;
// }
// },
// openFile(file) {
// if (this.readyToOpenFiles) {
// Events.emit('launcher-open-file', file);
// } else {
// this.pendingFileToOpen = file;
// }
// },
// minimizeMainWindow() {
// this.getMainWindow().minimize();
// },
// maximizeMainWindow() {
// this.getMainWindow().maximize();
// },
// restoreMainWindow() {
// this.getMainWindow().restore();
// },
// mainWindowMaximized() {
// return this.getMainWindow().isMaximized();
// }
}
// Events.on('launcher-exit-request', () => {
// setTimeout(() => Launcher.exit(), 0);
// });
// Events.on('launcher-minimize', () => setTimeout(() => Events.emit('app-minimized'), 0));
// Events.on('launcher-maximize', () => setTimeout(() => Events.emit('app-maximized'), 0));
// Events.on('launcher-unmaximize', () => setTimeout(() => Events.emit('app-unmaximized'), 0));
// Events.on('launcher-started-minimized', () => setTimeout(() => Launcher.minimizeApp(), 0));
// Events.on('start-profile', (data) => StartProfiler.reportAppProfile(data));
//
// window.launcherOpen = (file) => Launcher.openFile(file);
// if (window.launcherOpenedFile) {
// logger.info('Open file request', window.launcherOpenedFile);
// Launcher.openFile(window.launcherOpenedFile);
// delete window.launcherOpenedFile;
// }
// Events.on('app-ready', () =>
// setTimeout(() => {
// Launcher.checkOpenFiles();
// Launcher.remoteApp().setAboutPanelOptions({
// applicationVersion: RuntimeInfo.version,
// version: RuntimeInfo.commit
// });
// }, 0)
// );
//
// if (process.platform === 'darwin') {
// Launcher.remoteApp().setHookBeforeQuitEvent(true);
// }
//
// Launcher.remoteApp().on('remote-app-event', (e) => {
// if (window.debugRemoteAppEvents) {
// logger.info('remote-app-event', e.name);
// }
// Events.emit(e.name, e.data);
// });
+250
View File
@@ -0,0 +1,250 @@
import * as kdbxweb from 'kdbxweb';
import { Events } from 'framework/events';
import { Logger } from 'util/logger';
import { Launcher } from 'comp/launcher';
import { Timeouts } from 'const/timeouts';
let NativeModules;
if (Launcher) {
const logger = new Logger('native-module-connector');
let hostRunning = false;
let hostStartPromise;
let callId = 0;
let promises = {};
let ykChalRespCallbacks = {};
const { ipcRenderer } = Launcher.electron();
ipcRenderer.on('nativeModuleCallback', (e, msg) => NativeModules.hostCallback(msg));
ipcRenderer.on('nativeModuleHostError', (e, err) => NativeModules.hostError(err));
ipcRenderer.on('nativeModuleHostExit', (e, { code, sig }) => NativeModules.hostExit(code, sig));
ipcRenderer.on('nativeModuleHostDisconnect', () => NativeModules.hostDisconnect());
ipcRenderer.on('log', (e, ...args) => NativeModules.log(...args));
const handlers = {
yubikeys(numYubiKeys) {
Events.emit('native-modules-yubikeys', { numYubiKeys });
},
log(...args) {
logger.info('Message from host', ...args);
},
result({ callId, result, error }) {
const promise = promises[callId];
if (promise) {
delete promises[callId];
if (error) {
logger.error('Received an error', promise.cmd, error);
promise.reject(error);
} else {
promise.resolve(result);
}
}
},
yubiKeyChallengeResponseResult({ callbackId, error, result }) {
const callback = ykChalRespCallbacks[callbackId];
if (callback) {
const willBeCalledAgain = error && error.touchRequested;
if (!willBeCalledAgain) {
delete ykChalRespCallbacks[callbackId];
}
callback(error, result);
}
}
};
NativeModules = {
startHost() {
if (hostRunning) {
return Promise.resolve();
}
if (hostStartPromise) {
return hostStartPromise;
}
logger.debug('Starting native module host');
hostStartPromise = this.callNoWait('start').then(() => {
hostStartPromise = undefined;
hostRunning = true;
if (this.usbListenerRunning) {
return this.call('startUsbListener');
}
});
return hostStartPromise;
},
hostError(e) {
logger.error('Host error', e);
},
hostDisconnect() {
logger.error('Host disconnected');
},
hostExit(code, sig) {
logger.error(`Host exited with code ${code} and signal ${sig}`);
hostRunning = false;
const err = new Error('Native module host crashed');
for (const promise of Object.values(promises)) {
promise.reject(err);
}
promises = {};
for (const callback of Object.values(ykChalRespCallbacks)) {
callback(err);
}
ykChalRespCallbacks = {};
if (code !== 0) {
this.autoRestartHost();
}
},
hostCallback(message) {
const { cmd, args } = message;
// logger.debug('Callback', cmd, args);
if (handlers[cmd]) {
handlers[cmd](...args);
} else {
logger.error('No callback', cmd);
}
},
log(name, level, ...args) {
if (!name) {
return;
}
const logger = new Logger(name);
logger[level](...args);
},
autoRestartHost() {
setTimeout(() => {
try {
this.startHost();
} catch (e) {
logger.error('Native module host failed to auto-restart', e);
}
}, Timeouts.NativeModuleHostRestartTime);
},
call(cmd, ...args) {
return this.startHost().then(() => this.callNoWait(cmd, ...args));
},
callNoWait(cmd, ...args) {
return new Promise((resolve, reject) => {
callId++;
if (callId === Number.MAX_SAFE_INTEGER) {
callId = 1;
}
// logger.debug('Call', cmd, args, callId);
promises[callId] = { cmd, resolve, reject };
ipcRenderer.send('nativeModuleCall', { cmd, args, callId });
});
},
startUsbListener() {
this.call('startUsbListener');
this.usbListenerRunning = true;
},
stopUsbListener() {
this.usbListenerRunning = false;
if (hostRunning) {
this.call('stopUsbListener');
}
},
getYubiKeys(config) {
return this.call('getYubiKeys', config);
},
yubiKeyChallengeResponse(yubiKey, challenge, slot, callback) {
ykChalRespCallbacks[callId] = callback;
return this.call('yubiKeyChallengeResponse', yubiKey, challenge, slot, callId);
},
yubiKeyCancelChallengeResponse() {
if (hostRunning) {
this.call('yubiKeyCancelChallengeResponse');
}
},
argon2(password, salt, options) {
return this.call('argon2', password, salt, options);
},
hardwareCryptoDeleteKey: async () => {
await ipcRenderer.invoke('hardwareCryptoDeleteKey');
},
hardwareEncrypt: async (value) => {
const { data, salt } = await ipcRenderer.invoke('hardwareEncrypt', value.dataAndSalt());
return new kdbxweb.ProtectedValue(data, salt);
},
hardwareDecrypt: async (value, touchIdPrompt) => {
const { data, salt } = await ipcRenderer.invoke(
'hardwareDecrypt',
value.dataAndSalt(),
touchIdPrompt
);
return new kdbxweb.ProtectedValue(data, salt);
},
kbdGetActiveWindow(options) {
return this.call('kbdGetActiveWindow', options);
},
kbdGetActivePid() {
return this.call('kbdGetActivePid');
},
kbdShowWindow(win) {
return this.call('kbdShowWindow', win);
},
kbdText(str) {
return this.call('kbdText', str);
},
kbdTextAsKeys(str, mods) {
return this.call('kbdTextAsKeys', str, mods);
},
kbdKeyPress(code, modifiers) {
return this.call('kbdKeyPress', code, modifiers);
},
kbdShortcut(code, modifiers) {
return this.call('kbdShortcut', code, modifiers);
},
kbdKeyMoveWithModifier(down, modifiers) {
return this.call('kbdKeyMoveWithModifier', down, modifiers);
},
kbdKeyPressWithCharacter(character, code, modifiers) {
return this.call('kbdKeyPressWithCharacter', character, code, modifiers);
},
kbdEnsureModifierNotPressed() {
return this.call('kbdEnsureModifierNotPressed');
}
};
global.NativeModules = NativeModules;
}
export { NativeModules };
-268
View File
@@ -1,268 +0,0 @@
// import * as kdbxweb from 'kdbxweb';
import { Events } from 'util/events';
import { Logger } from 'util/logger';
import { noop, unreachable } from 'util/fn';
import { Launcher } from 'comp/launcher';
import { Timeouts } from 'const/timeouts';
import {
NativeArgon2OptionsArg,
NativeDataWithSalt,
NativeModuleCalls,
NativeModuleHostCallbackMessage,
YubiKeyInfo
} from './desktop-ipc';
const logger = new Logger('native-module-connector');
type YkChalRespCallback = (err?: Error) => void;
interface PendingCall {
cmd: string;
resolve: (result: unknown) => void;
reject: (err: unknown) => void;
}
class NativeModules {
private _hostRunning = false;
private _hostStartPromise: Promise<void> | undefined;
private _usbListenerRunning = false;
private _callId = 0;
private _pendingCalls = new Map<number, PendingCall>();
private _ykChalRespCallbacks = new Map<number, YkChalRespCallback>();
constructor() {
if (!Launcher) {
return;
}
Launcher.ipcRenderer.on('native-module-host-callback', (e, msg) => {
this.hostCallback(msg);
});
Launcher.ipcRenderer.on('native-module-host-error', (e, err) => {
this.hostError(err);
});
Launcher.ipcRenderer.on('native-module-host-exit', (e, code, sig) => {
this.hostExit(code, sig);
});
Launcher.ipcRenderer.on('native-module-host-disconnect', () => {
this.hostDisconnect();
});
}
private startHost(): Promise<void> {
if (this._hostRunning) {
return Promise.resolve();
}
if (this._hostStartPromise) {
return this._hostStartPromise;
}
logger.info('Starting native module host');
this._hostStartPromise = this.call('start').then(() => {
this._hostStartPromise = undefined;
this._hostRunning = true;
if (this._usbListenerRunning) {
return this.call('start-usb-listener');
}
});
return this._hostStartPromise;
}
private hostError(e: unknown): void {
logger.error('Host error', e);
}
private hostDisconnect(): void {
logger.error('Host disconnected');
}
private hostExit(code: number | undefined, sig: string | undefined): void {
logger.error(`Host exited with code ${String(code)} and signal ${String(sig)}`);
this._hostRunning = false;
const err = new Error('Native module host crashed');
for (const call of this._pendingCalls.values()) {
call.reject(err);
}
this._pendingCalls.clear();
for (const callback of this._ykChalRespCallbacks.values()) {
callback(err);
}
this._ykChalRespCallbacks.clear();
if (code !== 0) {
this.autoRestartHost();
}
}
private hostCallback(msg: NativeModuleHostCallbackMessage): void {
switch (msg.cmd) {
case 'yubikeys':
this.numYubiKeysReceived(msg.numYubiKeys);
return;
}
unreachable('Unexpected native host callback', msg.cmd);
}
private autoRestartHost(): void {
setTimeout(() => {
try {
this.startHost().catch(noop);
} catch (e) {
logger.error('Native module host failed to auto-restart', e);
}
}, Timeouts.NativeModuleHostRestartTime);
}
private async call<Cmd extends keyof NativeModuleCalls>(
cmd: Cmd,
...args: Parameters<NativeModuleCalls[Cmd]>
): Promise<ReturnType<NativeModuleCalls[Cmd]>> {
if (cmd !== 'start') {
await this.startHost();
}
return new Promise<unknown>((resolve, reject) => {
this._callId++;
if (this._callId === Number.MAX_SAFE_INTEGER) {
this._callId = 1;
}
this._pendingCalls.set(this._callId, { cmd, resolve, reject });
Launcher?.ipcRenderer.send('native-module-call', cmd, ...args);
}) as Promise<ReturnType<NativeModuleCalls[Cmd]>>;
}
private result(callId: number, result: unknown, error: unknown) {
const call = this._pendingCalls.get(callId);
if (call) {
this._pendingCalls.delete(callId);
if (error) {
logger.error('Received an error', call.cmd, error);
call.reject(error);
} else {
call.resolve(result);
}
}
}
private numYubiKeysReceived(numYubiKeys: number) {
Events.emit('native-modules-yubikeys', numYubiKeys);
}
// private yubiKeyChallengeResponseResult({ callbackId, error, result }) {
// const callback = ykChalRespCallbacks[callbackId];
// if (callback) {
// const willBeCalledAgain = error && error.touchRequested;
// if (!willBeCalledAgain) {
// delete ykChalRespCallbacks[callbackId];
// }
// callback(error, result);
// }
// }
startUsbListener(): Promise<void> {
return this.call('start-usb-listener');
this._usbListenerRunning = true;
}
stopUsbListener(): Promise<void> {
this._usbListenerRunning = false;
if (this._hostRunning) {
return this.call('stop-usb-listener');
}
return Promise.resolve();
}
getYubiKeys(): Promise<YubiKeyInfo[]> {
return this.call('get-yubikeys');
}
// yubiKeyChallengeResponse(yubiKey, challenge, slot, callback) {
// ykChalRespCallbacks[callId] = callback;
// return this.call('yubiKeyChallengeResponse', yubiKey, challenge, slot, callId);
// }
yubiKeyCancelChallengeResponse(): Promise<void> {
if (this._hostRunning) {
return this.call('yubikey-cancel-chal-resp');
}
return Promise.resolve();
}
argon2(
password: NativeDataWithSalt,
salt: NativeDataWithSalt,
options: NativeArgon2OptionsArg
): Promise<NativeDataWithSalt> {
return this.call('argon2', password, salt, options);
}
// hardwareCryptoDeleteKey: () => {
// return Launcher.ipcRenderer.invoke('hardwareCryptoDeleteKey');
// },
//
// hardwareEncrypt: async (value) => {
// const { data, salt } = await ipcRenderer.invoke('hardwareEncrypt', value.dataAndSalt());
// return new kdbxweb.ProtectedValue(data, salt);
// },
//
// hardwareDecrypt: async (value, touchIdPrompt) => {
// const { data, salt } = await ipcRenderer.invoke(
// 'hardwareDecrypt',
// value.dataAndSalt(),
// touchIdPrompt
// );
// return new kdbxweb.ProtectedValue(data, salt);
// },
//
// kbdGetActiveWindow(options) {
// return this.call('kbdGetActiveWindow', options);
// },
//
// kbdGetActivePid() {
// return this.call('kbdGetActivePid');
// },
//
// kbdShowWindow(win) {
// return this.call('kbdShowWindow', win);
// },
//
// kbdText(str) {
// return this.call('kbdText', str);
// },
//
// kbdTextAsKeys(str, mods) {
// return this.call('kbdTextAsKeys', str, mods);
// },
//
// kbdKeyPress(code, modifiers) {
// return this.call('kbdKeyPress', code, modifiers);
// },
//
// kbdShortcut(code, modifiers) {
// return this.call('kbdShortcut', code, modifiers);
// },
//
// kbdKeyMoveWithModifier(down, modifiers) {
// return this.call('kbdKeyMoveWithModifier', down, modifiers);
// },
//
// kbdKeyPressWithCharacter(character, code, modifiers) {
// return this.call('kbdKeyPressWithCharacter', character, code, modifiers);
// },
//
// kbdEnsureModifierNotPressed() {
// return this.call('kbdEnsureModifierNotPressed');
// }
}
const instance = new NativeModules();
export { instance as NativeModules };
-196
View File
@@ -1,196 +0,0 @@
import * as kdbxweb from 'kdbxweb';
import { BuiltInFields } from 'const/entry-fields';
import { AdvancedFilter, Filter } from 'models/filter';
import { Entry } from 'models/entry';
interface SearchContext {
matches: string[];
}
export const EntryMatcher = { matches };
const BuiltInFieldsSet = new Set<string>(BuiltInFields);
function matches(entry: Entry, filter: Filter): boolean {
if (filter.tagLower) {
if (entry.searchTags && entry.searchTags.indexOf(filter.tagLower) < 0) {
return false;
}
}
if (filter.textLower) {
if (filter.advanced) {
if (!matchesAdv(entry, filter)) {
return false;
}
} else if (filter.textLowerParts) {
const parts = filter.textLowerParts;
for (let i = 0; i < parts.length; i++) {
if (!entry.searchText?.includes(parts[i])) {
return false;
}
}
} else if (!entry.searchText?.includes(filter.textLower)) {
return false;
}
}
if (filter.color) {
if (filter.color === '*') {
if (!entry.searchColor) {
return false;
}
} else if (entry.searchColor !== filter.color) {
return false;
}
}
if (filter.autoType) {
if (!entry.autoTypeEnabled) {
return false;
}
}
if (filter.otp) {
if (
!entry.fields?.has('otp') &&
!entry.fields?.has('TOTP Seed') &&
entry.backend !== 'otp-device'
) {
return false;
}
}
return true;
}
function matchesAdv(entry: Entry, filter: Filter): boolean {
const adv = filter.advanced;
if (!adv || !filter.text || !filter.textLower) {
return false;
}
let compare: (val: kdbxweb.KdbxEntryField) => boolean;
const context: SearchContext = { matches: [] };
if (adv.regex) {
try {
const regex = new RegExp(filter.text, adv.cs ? '' : 'i');
compare = (val) => matchRegex(val, regex);
} catch (e) {
return false;
}
} else if (adv.cs) {
if (filter.textParts) {
const textParts = filter.textParts;
compare = (val) => matchStringMulti(val, textParts, context, false);
} else {
const text = filter.text;
compare = (val) => matchString(val, text);
}
} else if (filter.textLowerParts) {
const textLowerParts = filter.textLowerParts;
compare = (val) => matchStringMultiLower(val, textLowerParts, context);
} else {
const textLower = filter.textLower;
compare = (val) => matchStringLower(val, textLower);
}
if (matchFields(entry.getAllFields(), adv, compare)) {
return true;
}
if (adv.history && entry.getAllHistoryEntriesFields) {
for (const historyEntryFields of entry.getAllHistoryEntriesFields()) {
if (matchFields(historyEntryFields, adv, compare)) {
return true;
}
}
}
return false;
}
function matchString(str: kdbxweb.KdbxEntryField, find: string): boolean {
if (str instanceof kdbxweb.ProtectedValue) {
return str.includes(find);
}
return str.includes(find);
}
function matchStringLower(str: kdbxweb.KdbxEntryField, findLower: string): boolean {
if (str instanceof kdbxweb.ProtectedValue) {
return str.includesLower(findLower);
}
return str.toLowerCase().includes(findLower);
}
function matchStringMulti(
str: kdbxweb.KdbxEntryField,
find: string[],
context: SearchContext,
lower: boolean
): boolean {
for (let i = 0; i < find.length; i++) {
const item = find[i];
const strMatches = lower ? matchStringLower(str, item) : matchString(str, item);
if (strMatches) {
if (context.matches) {
if (!context.matches.includes(item)) {
context.matches.push(item);
}
} else {
context.matches = [item];
}
}
}
return context.matches && context.matches.length === find.length;
}
function matchStringMultiLower(
str: kdbxweb.KdbxEntryField,
find: string[],
context: SearchContext
): boolean {
return matchStringMulti(str, find, context, true);
}
function matchRegex(str: kdbxweb.KdbxEntryField, regex: RegExp): boolean {
if (str instanceof kdbxweb.ProtectedValue) {
str = str.getText();
}
return regex.test(str);
}
function matchFields(
fields: Map<string, kdbxweb.KdbxEntryField>,
adv: AdvancedFilter,
compare: (val: kdbxweb.KdbxEntryField) => boolean
): boolean {
if (adv.user && matchField(fields.get('UserName'), compare)) {
return true;
}
if (adv.url && matchField(fields.get('URL'), compare)) {
return true;
}
if (adv.notes && matchField(fields.get('Notes'), compare)) {
return true;
}
if (adv.pass && matchField(fields.get('Password'), compare)) {
return true;
}
if (adv.title && matchField(fields.get('Title'), compare)) {
return true;
}
if (adv.other || adv.protect) {
for (const [field, value] of fields) {
if (BuiltInFieldsSet.has(field)) {
if (typeof value === 'string') {
if (adv.other && matchField(value, compare)) {
return true;
}
} else if (adv.protect && matchField(value, compare)) {
return true;
}
}
}
}
return false;
}
function matchField(
val: kdbxweb.KdbxEntryField | undefined,
compare: (val: kdbxweb.KdbxEntryField) => boolean
): boolean {
return val ? compare(val) : false;
}
-182
View File
@@ -1,182 +0,0 @@
import { Features } from 'util/features';
import { Logger } from 'util/logger';
import { AppSettings, AppSettingsFieldName } from 'models/app-settings';
import { Alerts } from 'comp/ui/alerts';
import { Locale } from 'util/locale';
import { FileManager } from 'models/file-manager';
import { FileInfo, FileStorageExtraOptions } from 'models/file-info';
import { IdGenerator } from 'util/generators/id-generator';
import { PluginManager } from 'plugins/plugin-manager';
import { PluginManifest } from 'plugins/types';
const logger = new Logger('config-loader');
class ConfigLoader {
async loadConfig(): Promise<boolean> {
const configParam = this.getConfigParam();
if (configParam) {
try {
const config = await this.loadFromNetwork(configParam);
if (!config.settings) {
logger.error('Invalid app config, no settings section', config);
throw new Error('Invalid app config, no settings section');
}
await this.applyUserConfig(config);
return true;
} catch (e) {
if (!AppSettings.cacheConfigSettings) {
this.showSettingsLoadError();
throw e;
}
}
}
return false;
}
private getConfigParam(): string | null {
const metaConfig = document.head.querySelector('meta[name=kw-config]') as
| HTMLMetaElement
| undefined;
if (metaConfig && metaConfig.content && metaConfig.content[0] !== '(') {
return metaConfig.content;
}
if (!location.search) {
return null;
}
const sp = new URLSearchParams(location.search);
return sp.get('config');
}
private loadFromNetwork(configLocation: string): Promise<Record<string, string>> {
return new Promise((resolve, reject) => {
this.ensureCanLoadConfig(configLocation);
logger.info('Loading config from', configLocation);
const ts = logger.ts();
const xhr = new XMLHttpRequest();
xhr.open('GET', configLocation);
xhr.responseType = 'json';
xhr.send();
xhr.addEventListener('load', () => {
const response = xhr.response as unknown;
if (!response || typeof response !== 'object') {
const errorDesc = xhr.statusText === 'OK' ? 'Malformed JSON' : xhr.statusText;
logger.error('Error loading app config', errorDesc);
return reject('Error loading app config');
}
logger.info('Loaded app config from', configLocation, logger.ts(ts));
resolve(response as Record<string, string>);
});
xhr.addEventListener('error', () => {
logger.error('Error loading app config', xhr.statusText, xhr.status);
reject(new Error('Error loading app config'));
});
});
}
private ensureCanLoadConfig(url: string) {
if (!Features.isSelfHosted) {
throw new Error('Configs are supported only in self-hosted installations');
}
const link = document.createElement('a');
link.href = url;
const isExternal = link.host && link.host !== location.host;
if (isExternal) {
throw new Error('Loading config from this location is not allowed');
}
}
private async applyUserConfig(config: Record<string, unknown>): Promise<void> {
if (!config.settings) {
logger.error('Invalid app config, no settings section', config);
throw new Error('Invalid app config, no settings section');
}
const settings = config.settings as Record<string, unknown>;
for (const [key, value] of Object.entries(settings)) {
const isSet = AppSettings.set(key as AppSettingsFieldName, value);
if (!isSet) {
logger.warn('Invalid setting ignored', key, value);
}
}
if (Array.isArray(config.files)) {
if (config.showOnlyFilesFromConfig === true) {
FileManager.reset();
}
for (const file of config.files.reverse()) {
if (!file || typeof file !== 'object') {
continue;
}
const fileRec = file as Record<string, unknown>;
if (typeof fileRec.storage !== 'string' || !fileRec.storage) {
continue;
}
if (typeof fileRec.name !== 'string' || !fileRec.name) {
continue;
}
if (typeof fileRec.path !== 'string' || !fileRec.path) {
continue;
}
let opts: FileStorageExtraOptions | undefined;
if (typeof fileRec.opts === 'object' && fileRec.opts) {
const optsRec = fileRec.opts as Record<string, unknown>;
opts = {
user: typeof optsRec.user === 'string' ? optsRec.user : undefined,
encpass: typeof optsRec.encpass === 'string' ? optsRec.encpass : undefined
};
}
if (FileManager.getFileInfo(fileRec.storage, fileRec.name, fileRec.path)) {
continue;
}
const fi = new FileInfo({
id: IdGenerator.uuid(),
name: fileRec.name,
storage: fileRec.storage,
path: fileRec.path,
opts
});
FileManager.addFileInfo(fi, true);
}
}
if (Array.isArray(config.plugins)) {
const pluginPromises: Promise<void>[] = [];
for (const plugin of config.plugins as Record<string, unknown>[]) {
if (typeof plugin.url === 'string') {
const pluginPromise = PluginManager.installIfNew(
plugin.url,
plugin.manifest as PluginManifest,
true
);
pluginPromises.push(pluginPromise);
}
}
await Promise.all(pluginPromises);
}
if (config.advancedSearch) {
// TODO: set advanced search
// this.advancedSearch = config.advancedSearch;
// this.addFilter({ advanced: this.advancedSearch });
}
}
private showSettingsLoadError() {
Alerts.error({
header: Locale.appSettingsError,
body: Locale.appSettingsErrorBody,
buttons: [],
esc: undefined,
enter: undefined,
click: undefined
});
}
}
const instance = new ConfigLoader();
export { instance as ConfigLoader };
@@ -0,0 +1,193 @@
import { Events } from 'framework/events';
import { Features } from 'util/features';
import { Locale } from 'util/locale';
import { ThemeWatcher } from 'comp/browser/theme-watcher';
import { AppSettingsModel } from 'models/app-settings-model';
import { Logger } from 'util/logger';
import { Launcher } from 'comp/launcher';
const logger = new Logger('settings-manager');
const SettingsManager = {
neutralLocale: null,
activeLocale: 'en-US',
activeTheme: null,
allLocales: {
'en-US': 'English',
'de-DE': 'Deutsch',
'fr-FR': 'Français'
},
allThemes: {
dark: 'setGenThemeDark',
light: 'setGenThemeLight',
sd: 'setGenThemeSd',
sl: 'setGenThemeSl',
fb: 'setGenThemeFb',
bl: 'setGenThemeBl',
db: 'setGenThemeDb',
lb: 'setGenThemeLb',
te: 'setGenThemeTe',
lt: 'setGenThemeLt',
dc: 'setGenThemeDc',
hc: 'setGenThemeHc'
},
// changing something here? don't forget about desktop/app.js
autoSwitchedThemes: [
{
name: 'setGenThemeDefault',
dark: 'dark',
light: 'light'
},
{
name: 'setGenThemeSol',
dark: 'sd',
light: 'sl'
},
{
name: 'setGenThemeBlue',
dark: 'fb',
light: 'bl'
},
{
name: 'setGenThemeBrown',
dark: 'db',
light: 'lb'
},
{
name: 'setGenThemeTerminal',
dark: 'te',
light: 'lt'
},
{
name: 'setGenThemeHighContrast',
dark: 'dc',
light: 'hc'
}
],
customLocales: {},
init() {
Events.on('dark-mode-changed', () => this.darkModeChanged());
},
setBySettings() {
this.setTheme(AppSettingsModel.theme);
this.setFontSize(AppSettingsModel.fontSize);
const locale = AppSettingsModel.locale;
try {
if (locale) {
this.setLocale(AppSettingsModel.locale);
} else {
this.setLocale(this.getBrowserLocale());
}
} catch (ex) {}
},
getDefaultTheme() {
return 'dark';
},
setTheme(theme) {
if (!theme) {
if (this.activeTheme) {
return;
}
theme = this.getDefaultTheme();
}
for (const cls of document.body.classList) {
if (/^th-/.test(cls)) {
document.body.classList.remove(cls);
}
}
if (AppSettingsModel.autoSwitchTheme) {
theme = this.selectDarkOrLightTheme(theme);
}
document.body.classList.add(this.getThemeClass(theme));
const metaThemeColor = document.head.querySelector('meta[name=theme-color]');
if (metaThemeColor) {
metaThemeColor.content = window.getComputedStyle(document.body).backgroundColor;
}
this.activeTheme = theme;
logger.debug('Theme changed', theme);
Events.emit('theme-applied');
},
getThemeClass(theme) {
return 'th-' + theme;
},
selectDarkOrLightTheme(theme) {
for (const config of this.autoSwitchedThemes) {
if (config.light === theme || config.dark === theme) {
return ThemeWatcher.dark ? config.dark : config.light;
}
}
return theme;
},
darkModeChanged() {
if (AppSettingsModel.autoSwitchTheme) {
for (const config of this.autoSwitchedThemes) {
if (config.light === this.activeTheme || config.dark === this.activeTheme) {
const newTheme = ThemeWatcher.dark ? config.dark : config.light;
logger.debug('Setting theme triggered by system settings change', newTheme);
this.setTheme(newTheme);
break;
}
}
}
},
setFontSize(fontSize) {
const defaultFontSize = Features.isMobile ? 14 : 12;
document.documentElement.style.fontSize = defaultFontSize + (fontSize || 0) * 2 + 'px';
},
setLocale(loc) {
if (!loc || loc === this.activeLocale) {
return;
}
let localeValues;
if (loc !== 'en-US') {
if (this.customLocales[loc]) {
localeValues = this.customLocales[loc];
} else {
localeValues = require('../../locales/' + loc + '.json');
}
}
if (!this.neutralLocale) {
this.neutralLocale = { ...Locale };
}
Object.assign(Locale, this.neutralLocale, localeValues);
this.activeLocale = loc;
Events.emit('set-locale', loc);
if (Launcher) {
const { ipcRenderer } = Launcher.electron();
const localeValuesForDesktopApp = {};
for (const [key, value] of Object.entries(Locale)) {
if (key.startsWith('sysMenu')) {
localeValuesForDesktopApp[key] = value;
}
}
ipcRenderer.invoke('setLocale', {
locale: loc,
...localeValuesForDesktopApp
});
}
},
getBrowserLocale() {
const language = (navigator.languages && navigator.languages[0]) || navigator.language;
if (language && language.startsWith('en')) {
return 'en-US';
}
return language;
}
};
export { SettingsManager };
@@ -1,213 +0,0 @@
import { Events } from 'util/events';
import { Features } from 'util/features';
import { Locale } from 'util/locale';
import { ThemeWatcher } from 'comp/browser/theme-watcher';
import { AppSettings, AppSettingsFontSize } from 'models/app-settings';
import { Logger } from 'util/logger';
import { Launcher } from 'comp/launcher';
import { noop } from 'util/fn';
import { WindowClass } from 'comp/browser/window-class';
const logger = new Logger('settings-manager');
const SettingsManager = {
activeTheme: null as string | null,
allLocales: {
'en-US': 'English',
'de-DE': 'Deutsch',
'fr-FR': 'Français'
} as Record<string, string>,
get builtInThemes(): Record<string, string> {
return {
dark: Locale.setGenThemeDark,
light: Locale.setGenThemeLight,
sd: Locale.setGenThemeSd,
sl: Locale.setGenThemeSl,
fb: Locale.setGenThemeFb,
bl: Locale.setGenThemeBl,
db: Locale.setGenThemeDb,
lb: Locale.setGenThemeLb,
te: Locale.setGenThemeTe,
lt: Locale.setGenThemeLt,
dc: Locale.setGenThemeDc,
hc: Locale.setGenThemeHc
};
},
get allThemes(): Record<string, string> {
return {
...this.builtInThemes,
...this.customThemes
};
},
// changing something here? don't forget about desktop/app.js
get autoSwitchedThemes(): { name: string; dark: string; light: string }[] {
return [
{
name: Locale.setGenThemeDefault,
dark: 'dark',
light: 'light'
},
{
name: Locale.setGenThemeSol,
dark: 'sd',
light: 'sl'
},
{
name: Locale.setGenThemeBlue,
dark: 'fb',
light: 'bl'
},
{
name: Locale.setGenThemeBrown,
dark: 'db',
light: 'lb'
},
{
name: Locale.setGenThemeTerminal,
dark: 'te',
light: 'lt'
},
{
name: Locale.setGenThemeHighContrast,
dark: 'dc',
light: 'hc'
}
];
},
customLocales: new Map<string, Record<string, string>>(),
customThemes: {} as Record<string, string>,
init(): void {
Events.on('dark-mode-changed', () => this.darkModeChanged());
},
setBySettings(): void {
this.setTheme(AppSettings.theme);
this.setFontSize(AppSettings.fontSize);
const locale = AppSettings.locale;
try {
if (locale) {
this.setLocale(AppSettings.locale);
} else {
this.setLocale(this.getBrowserLocale());
}
} catch (ex) {}
},
getDefaultTheme(): string {
return 'dark';
},
setTheme(theme: string | undefined | null): void {
if (!theme) {
if (this.activeTheme) {
return;
}
theme = this.getDefaultTheme();
}
for (const cls of document.body.classList) {
if (/^th-/.test(cls)) {
document.body.classList.remove(cls);
}
}
if (AppSettings.autoSwitchTheme) {
theme = this.selectDarkOrLightTheme(theme);
}
WindowClass.setThemeClass(theme);
const metaThemeColor = document.head.querySelector('meta[name=theme-color]') as
| HTMLMetaElement
| undefined;
if (metaThemeColor) {
metaThemeColor.content = window.getComputedStyle(document.body).backgroundColor;
}
this.activeTheme = theme;
logger.info('Theme changed', theme);
Events.emit('theme-changed');
},
selectDarkOrLightTheme(theme: string): string {
for (const config of this.autoSwitchedThemes) {
if (config.light === theme || config.dark === theme) {
return ThemeWatcher.dark ? config.dark : config.light;
}
}
return theme;
},
darkModeChanged(): void {
if (AppSettings.autoSwitchTheme) {
for (const config of this.autoSwitchedThemes) {
if (config.light === this.activeTheme || config.dark === this.activeTheme) {
const newTheme = ThemeWatcher.dark ? config.dark : config.light;
logger.info('Setting theme triggered by system settings change', newTheme);
this.setTheme(newTheme);
break;
}
}
}
},
setFontSize(fontSize: AppSettingsFontSize): void {
const defaultFontSize = Features.isMobile ? 14 : 12;
const sizeInPx = defaultFontSize + (fontSize || 0) * 2;
document.documentElement.style.fontSize = `${sizeInPx}px`;
WindowClass.setFontSizeClass(fontSize);
},
setLocale(loc: string | undefined | null): void {
if (!loc || loc === Locale.localeName) {
return;
}
if (loc === 'en-US') {
Locale.set(undefined);
} else {
let localeValues = this.customLocales.get(loc);
if (!localeValues) {
localeValues = require('locales/' + loc + '.json') as Record<string, string>;
}
Locale.set(localeValues, loc);
}
Events.emit('locale-changed', loc);
if (Launcher) {
const localeValuesForDesktopApp = this.getDesktopAppLocaleValues();
Launcher.ipcRenderer.invoke('set-locale', loc, localeValuesForDesktopApp).catch(noop);
}
},
getBrowserLocale(): string {
const language = (navigator.languages && navigator.languages[0]) || navigator.language;
if (language && language.startsWith('en')) {
return 'en-US';
}
return language;
},
getDesktopAppLocaleValues(): Record<string, string> {
return {
sysMenuAboutKeeWeb: Locale.sysMenuAboutKeeWeb.with('KeeWeb'),
sysMenuServices: Locale.sysMenuServices,
sysMenuHide: Locale.sysMenuHide.with('KeeWeb'),
sysMenuHideOthers: Locale.sysMenuHideOthers,
sysMenuUnhide: Locale.sysMenuUnhide,
sysMenuQuit: Locale.sysMenuQuit.with('KeeWeb'),
sysMenuEdit: Locale.sysMenuEdit,
sysMenuUndo: Locale.sysMenuUndo,
sysMenuRedo: Locale.sysMenuRedo,
sysMenuCut: Locale.sysMenuCut,
sysMenuCopy: Locale.sysMenuCopy,
sysMenuPaste: Locale.sysMenuPaste,
sysMenuSelectAll: Locale.sysMenuSelectAll,
sysMenuWindow: Locale.sysMenuWindow,
sysMenuMinimize: Locale.sysMenuMinimize,
sysMenuClose: Locale.sysMenuClose
};
}
};
export { SettingsManager };
@@ -0,0 +1,38 @@
import { Launcher } from 'comp/launcher';
import { StringFormat } from 'util/formatting/string-format';
import { Logger } from 'util/logger';
const logger = new Logger('settings');
const SettingsStore = {
load(key) {
let loadPromise;
if (Launcher) {
loadPromise = Launcher.loadConfig(key);
} else {
loadPromise = Promise.resolve().then(() => {
return localStorage[StringFormat.camelCase(key)];
});
}
return loadPromise
.then((data) => (data ? JSON.parse(data) : null))
.catch((err) => {
logger.error(`Error loading ${key}`, err);
});
},
save(key, data) {
if (Launcher) {
return Launcher.saveConfig(key, JSON.stringify(data)).catch((err) => {
logger.error(`Error saving ${key}`, err);
});
}
return Promise.resolve().then(() => {
if (typeof localStorage !== 'undefined') {
localStorage[StringFormat.camelCase(key)] = JSON.stringify(data);
}
});
}
};
export { SettingsStore };
@@ -1,46 +0,0 @@
import { Launcher } from 'comp/launcher';
import { StringFormat } from 'util/formatting/string-format';
import { Logger } from 'util/logger';
const logger = new Logger('settings');
const SettingsStore = {
async load(key: string): Promise<unknown> {
let data: string | null;
if (Launcher) {
data = await Launcher.ipcRenderer.invoke('load-config', key);
} else {
data = localStorage?.getItem(StringFormat.camelCase(key));
}
if (!data) {
return Promise.resolve();
}
try {
const parsed = JSON.parse(data) as unknown;
if (typeof parsed !== 'object' || !parsed) {
logger.error(`Error loading ${key}: read not an object`, parsed);
return Promise.resolve();
}
return Promise.resolve(parsed);
} catch (err) {
logger.error(`Error loading ${key}`, err);
return Promise.resolve();
}
},
async save(key: string, data: unknown): Promise<void> {
const dataStr = JSON.stringify(data);
if (Launcher) {
try {
await Launcher.ipcRenderer.invoke('save-config', key, dataStr);
} catch (err) {
logger.error(`Error saving ${key}`, err);
}
} else {
localStorage?.setItem(StringFormat.camelCase(key), dataStr);
}
return Promise.resolve();
}
};
export { SettingsStore };
+122
View File
@@ -0,0 +1,122 @@
import { Locale } from 'util/locale';
import { ModalView } from 'views/modal-view';
const Alerts = {
alertDisplayed: false,
buttons: {
ok: {
result: 'yes',
get title() {
return Locale.alertOk;
}
},
yes: {
result: 'yes',
get title() {
return Locale.alertYes;
}
},
allow: {
result: 'yes',
get title() {
return Locale.alertAllow;
}
},
no: {
result: '',
get title() {
return Locale.alertNo;
}
},
cancel: {
result: '',
get title() {
return Locale.alertCancel;
}
},
deny: {
result: '',
get title() {
return Locale.alertDeny;
}
}
},
alert(config) {
if (config.skipIfAlertDisplayed && Alerts.alertDisplayed) {
return null;
}
Alerts.alertDisplayed = true;
const view = new ModalView(config);
view.render();
view.once('result', (res, check) => {
if (res && config.success) {
config.success(res, check);
}
if (!res && config.cancel) {
config.cancel();
}
if (config.complete) {
config.complete(res, check);
}
});
view.on('will-close', () => {
Alerts.alertDisplayed = false;
});
return view;
},
notImplemented() {
this.alert({
header: Locale.notImplemented,
body: '',
icon: 'exclamation-triangle',
buttons: [this.buttons.ok],
esc: '',
click: '',
enter: ''
});
},
info(config) {
this.alert({
header: '',
body: '',
icon: 'info',
buttons: [this.buttons.ok],
esc: '',
click: '',
enter: '',
...config
});
},
error(config) {
this.alert({
header: '',
body: '',
icon: 'exclamation-circle',
buttons: [this.buttons.ok],
esc: '',
click: '',
enter: '',
...config
});
},
yesno(config) {
this.alert({
header: '',
body: '',
icon: 'question',
buttons: [this.buttons.yes, this.buttons.no],
esc: '',
click: '',
enter: 'yes',
...config
});
}
};
export { Alerts };
-305
View File
@@ -1,305 +0,0 @@
import { FunctionComponent, h, render } from 'preact';
import { Locale } from 'util/locale';
import { Modal, ModalEmitterEvents } from 'ui/modal';
import { TypedEmitter } from 'tiny-typed-emitter';
import { Timeouts } from 'const/timeouts';
import { KeyHandler } from 'comp/browser/key-handler';
import { Keys } from 'const/keys';
import { Callback } from 'util/types';
import { FocusManager } from 'comp/app/focus-manager';
export interface AlertButton {
result: string;
title: string;
error?: boolean;
silent?: boolean;
}
export interface AlertConfig {
header: string;
body: string | string[];
icon?: string;
buttons?: AlertButton[];
esc?: string;
click?: string;
enter?: string;
skipIfAlertDisplayed?: boolean;
pre?: string;
hint?: string;
opaque?: boolean;
wide?: boolean;
checkbox?: string;
view?: FunctionComponent;
success?: (result: string, checked?: boolean) => void;
complete?: (result: string, checked?: boolean) => void;
cancel?: () => void;
}
let alertDisplayed = false;
export class Alert {
readonly config: AlertConfig;
private _promise?: Promise<string>;
private _resolve?: (result: string) => void;
private _emitter?: TypedEmitter<ModalEmitterEvents>;
private _result: string | undefined;
private _checked: boolean | undefined;
private _visible = false;
private _el?: HTMLElement;
private _listeners: Callback[] = [];
constructor(config: AlertConfig) {
this.config = config;
}
get result(): string | undefined {
return this._result;
}
get visible(): boolean {
return this._visible;
}
show(): void {
this._emitter = new TypedEmitter<ModalEmitterEvents>();
const vnode = h(Modal, {
emitter: this._emitter,
header: this.config.header,
body: typeof this.config.body === 'string' ? [this.config.body] : this.config.body,
opaque: this.config.opaque,
wide: this.config.wide,
icon: this.config.icon,
pre: this.config.pre,
link: this.config.checkbox,
hint: this.config.hint,
checkbox: this.config.checkbox,
buttons: this.config.buttons ?? [],
view: this.config.view,
modalClicked: () => this.modalClicked(),
buttonClicked: (result) => this.buttonClicked(result),
checkboxChanged: () => this.checkboxChanged()
});
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
this._el = document.createElement('div');
document.body.appendChild(this._el);
render(vnode, document.body, this._el);
this.listenKeys();
FocusManager.pushModal('alert');
this._visible = true;
alertDisplayed = true;
}
wait(): Promise<string | undefined> {
if (!this._visible) {
return Promise.resolve(this._result);
}
if (!this._promise) {
this._promise = new Promise((resolve) => {
this._resolve = resolve;
});
}
return this._promise;
}
closeWithResult(result: string, immediate?: boolean): void {
this._visible = false;
alertDisplayed = false;
for (const off of this._listeners) {
off();
}
FocusManager.popModal();
this._result = result;
if (result) {
this.config.success?.(result, this._checked);
} else {
this.config.cancel?.();
}
this.config.complete?.(result, this._checked);
this._resolve?.(result);
if (immediate) {
this.destroy();
} else {
this._emitter?.emit('hide');
setTimeout(() => {
this.destroy();
}, Timeouts.HideAlert);
}
}
closeImmediate(): void {
if (this._el) {
this.closeWithResult('', true);
}
}
change(config: { header?: string }): void {
this._emitter?.emit('change-config', config);
}
private modalClicked() {
if (typeof this.config.click === 'string') {
this.closeWithResult('');
}
}
private buttonClicked(result: string) {
this.closeWithResult(result);
}
private checkboxChanged() {
this._checked = !this._checked;
}
private escPressed() {
this.closeWithResult(this.config.esc || '');
}
private enterPressed(e: Event) {
e.stopImmediatePropagation();
e.preventDefault();
this.closeWithResult(this.config.enter || '');
}
private listenKeys() {
if (typeof this.config.esc === 'string') {
const offKey = KeyHandler.onKey(
Keys.DOM_VK_ESCAPE,
() => this.escPressed(),
undefined,
'alert'
);
this._listeners.push(offKey);
}
if (typeof this.config.enter === 'string') {
const offKey = KeyHandler.onKey(
Keys.DOM_VK_RETURN,
(e) => this.enterPressed(e),
undefined,
'alert'
);
this._listeners.push(offKey);
}
}
private destroy() {
if (!this._el) {
return;
}
render(null, this._el);
this._el?.remove();
this._el = undefined;
}
}
export const Alerts = {
get alertDisplayed(): boolean {
return alertDisplayed;
},
buttons: {
ok: {
result: 'yes',
get title() {
return Locale.alertOk;
}
},
yes: {
result: 'yes',
get title() {
return Locale.alertYes;
}
},
allow: {
result: 'yes',
get title() {
return Locale.alertAllow;
}
},
no: {
result: '',
get title() {
return Locale.alertNo;
}
},
cancel: {
result: '',
get title() {
return Locale.alertCancel;
}
},
deny: {
result: '',
get title() {
return Locale.alertDeny;
}
}
} as Record<string, AlertButton>,
alert(config: AlertConfig): Alert {
const alert = new Alert(config);
if (config.skipIfAlertDisplayed && Alerts.alertDisplayed) {
return alert;
}
alert.show();
return alert;
},
notImplemented(): Alert {
return this.alert({
header: Locale.notImplemented,
body: '',
icon: 'exclamation-triangle',
buttons: [this.buttons.ok],
esc: '',
click: '',
enter: ''
});
},
info(config: AlertConfig): Alert {
return this.alert({
icon: 'info',
buttons: [this.buttons.ok],
esc: '',
click: '',
enter: '',
...config
});
},
error(config: AlertConfig): Alert {
return this.alert({
icon: 'exclamation-circle',
buttons: [this.buttons.ok],
esc: '',
click: '',
enter: '',
...config
});
},
yesno(config: AlertConfig): Alert {
return this.alert({
icon: 'question',
buttons: [this.buttons.yes, this.buttons.no],
esc: '',
click: '',
enter: 'yes',
...config
});
}
};
@@ -4,14 +4,12 @@
// which you embed in the source code of your application.
// (In this context, the client secret is obviously not treated as a secret.)
export const DropboxApps = {
const DropboxApps = {
AppFolder: { id: 'qp7ctun6qt5n9d6', secret: '07s5r4ck1uvlj6a' },
FullDropbox: { id: 'eor7hvv6u6oslq9', secret: 'ez04o1iwf6yprq3' }
} as const;
};
export const DropboxChooserAppKey = 'qp7ctun6qt5n9d6';
export const GDriveApps = {
const GDriveApps = {
Local: {
id: '783608538594-36tkdh8iscrq8t8dq87gghubnhivhjp5.apps.googleusercontent.com',
secret: 'yAtyfc9TIQ9GyQgQmo3i0HAP'
@@ -24,19 +22,32 @@ export const GDriveApps = {
id: '847548101761-h2pcl2p6m1tssnlqm0vrm33crlveccbr.apps.googleusercontent.com',
secret: 'nTSCiqXtUNmURIIdASaC1TJK'
}
} as const;
};
export const OneDriveApps = {
const OneDriveApps = {
Local: {
id: 'b97c53d5-db5b-4124-aab9-d39195293815',
secret: null
id: 'b97c53d5-db5b-4124-aab9-d39195293815'
},
Production: {
id: 'bbc74d1b-3a9c-46e6-9da4-4c645e830923',
secret: null
id: 'bbc74d1b-3a9c-46e6-9da4-4c645e830923'
},
Desktop: {
id: 'bbc74d1b-3a9c-46e6-9da4-4c645e830923',
secret: 'aOMJaktJEAs_Tmh]fx4iQ[Zd3mp3KK7-'
}
} as const;
};
const MsTeamsApps = {
Local: {
id: '8fbe2245-13d5-446f-bedc-74c3b2e1f635'
},
Production: {
id: '8fbe2245-13d5-446f-bedc-74c3b2e1f635'
},
Desktop: {
id: '8fbe2245-13d5-446f-bedc-74c3b2e1f635',
secret: 'F02~HYaWs-~7MndJcVRtv9~h-50Brk_9ho'
}
};
export { DropboxApps, GDriveApps, OneDriveApps, MsTeamsApps };
@@ -1,5 +1,5 @@
export const Colors = {
AllColors: ['yellow', 'green', 'red', 'orange', 'blue', 'violet'] as const,
const Colors = {
AllColors: ['yellow', 'green', 'red', 'orange', 'blue', 'violet'],
ColorsValues: {
yellow: 'ffff00',
@@ -8,7 +8,7 @@ export const Colors = {
orange: 'ff8800',
blue: '0000ff',
violet: 'ff00ff'
} as Record<string, string>,
},
BgColors: {
yellow: 'ffff88',
@@ -17,5 +17,7 @@ export const Colors = {
orange: 'ffcc88',
blue: '8888ff',
violet: 'ff88ff'
} as Record<string, string>
} as const;
}
};
export { Colors };
+99
View File
@@ -0,0 +1,99 @@
const DefaultAppSettings = {
theme: null, // UI theme
autoSwitchTheme: false, // automatically switch between light and dark theme
locale: null, // user interface language
expandGroups: true, // show entries from all subgroups
listViewWidth: null, // width of the entry list representation
menuViewWidth: null, // width of the left menu
tagsViewHeight: null, // tags menu section height
autoUpdate: 'install', // auto-update options: "install", "check", ""
clipboardSeconds: 0, // number of seconds after which the clipboard will be cleared
autoSave: true, // auto-save open files
autoSaveInterval: 0, // interval between performing automatic sync, minutes, -1: on every change
rememberKeyFiles: 'path', // remember keyfiles selected on the Open screen
idleMinutes: 15, // app lock timeout after inactivity, minutes
minimizeOnClose: false, // minimise the app instead of closing
minimizeOnFieldCopy: false, // minimise the app on copy
tableView: false, // view entries as a table instead of list
colorfulIcons: false, // use colorful custom icons instead of grayscale
useMarkdown: true, // use Markdown in Notes field
directAutotype: true, // if only one matching entry is found, select that one automatically
autoTypeTitleFilterEnabled: true, // enable the title filtering in auto-type by default
titlebarStyle: 'default', // window titlebar style
lockOnMinimize: true, // lock the app when it's minimized
lockOnCopy: false, // lock the app after a password was copied
lockOnAutoType: false, // lock the app after performing auto-type
lockOnOsLock: false, // lock the app when the computer is locked
helpTipCopyShown: false, // disable the tooltip about copying fields
templateHelpShown: false, // disable the tooltip about entry templates
skipOpenLocalWarn: false, // disable the warning about opening a local file
hideEmptyFields: false, // hide empty fields in entries
skipHttpsWarning: false, // disable the non-HTTPS warning
demoOpened: false, // hide the demo button inside the More... menu
fontSize: 0, // font size: 0, 1, 2
tableViewColumns: null, // columns displayed in the table view
generatorPresets: null, // presets used in the password generator
generatorHidePassword: false, // hide password in the generator
cacheConfigSettings: false, // cache config settings and use them if the config can't be loaded
allowIframes: false, // allow displaying the app in IFrames
useGroupIconForEntries: false, // automatically use group icon when creating new entries
enableUsb: true, // enable interaction with USB devices
fieldLabelDblClickAutoType: false, // trigger auto-type by doubleclicking field label
auditPasswords: true, // enable password audit
auditPasswordEntropy: true, // show warnings for weak passwords
excludePinsFromAudit: true, // exclude PIN codes from audit
checkPasswordsOnHIBP: false, // check passwords on Have I Been Pwned
auditPasswordAge: 0, // show warnings about old passwords, number of years, 0 = disabled
deviceOwnerAuth: null, // Touch ID: null / 'memory' / 'file'
deviceOwnerAuthTimeoutMinutes: 0, // how often master password is required with Touch ID
disableOfflineStorage: false, // don't cache loaded files in offline storage
shortLivedStorageToken: false, // short-lived sessions in cloud storage providers
extensionFocusIfLocked: true, // focus KeeWeb if a browser extension tries to connect while KeeWeb is locked
extensionFocusIfEmpty: true, // show the entry selection screen if there's no match found by URL
yubiKeyShowIcon: true, // show an icon to open OTP codes from YubiKey
yubiKeyAutoOpen: false, // auto-load one-time codes when there are open files
yubiKeyMatchEntries: true, // show matching one-time codes in entries
yubiKeyShowChalResp: true, // show YubiKey challenge-response option
yubiKeyRememberChalResp: false, // remember YubiKey challenge-response codes while the app is open
yubiKeyStuckWorkaround: false, // enable the workaround for stuck YubiKeys
canOpen: true, // can select and open new files
canOpenDemo: true, // can open a demo file
canOpenSettings: true, // can go to settings
canCreate: true, // can create new files
canImportXml: true, // can import files from XML
canImportCsv: true, // can import files from CSV
canRemoveLatest: true, // can remove files from the recent file list
canExportXml: true, // can export files as XML
canExportHtml: true, // can export files as HTML
canSaveTo: true, // can save existing files to filesystem
canOpenStorage: true, // can open files from cloud storage providers
canOpenGenerator: true, // can open password generator
canOpenOtpDevice: true, // can open OTP codes from USB tokens
dropbox: true, // enable Dropbox integration
dropboxFolder: null, // default folder path
dropboxAppKey: null, // custom Dropbox app key
dropboxSecret: null, // custom Dropbox app secret
webdav: true, // enable WebDAV integration
webdavSaveMethod: 'move', // how to save files with WebDAV: "move" or "put"
webdavStatReload: false, // WebDAV: reload the file instead of relying on Last-Modified
gdrive: true, // enable Google Drive integration
gdriveClientId: null, // custom Google Drive client id
gdriveClientSecret: null, // custom Google Drive client secret
onedrive: true, // enable OneDrive integration
onedriveClientId: null, // custom OneDrive client id
onedriveClientSecret: null, // custom OneDrive client secret
onedriveTenantId: null, // custom OneDrive tenant id
msteams: false, // enable Microsoft Teams integration
msteamsClientId: null, // custom Microsoft Teams client id
msteamsClientSecret: null, // custom Microsoft Teams client secret
msteamsTenantId: null // custom Microsoft Teams tenant id
};
export { DefaultAppSettings };
@@ -1,4 +1,4 @@
export const BuiltInFields = [
const BuiltInFields = [
'Title',
'Password',
'UserName',
@@ -8,3 +8,5 @@ export const BuiltInFields = [
'TOTP Settings',
'_etm_template_uuid'
];
export { BuiltInFields };
@@ -1,12 +1,14 @@
// https://support.yubico.com/support/solutions/articles/15000028104-yubikey-usb-id-values
// https://github.com/Yubico/yubikey-personalization/blob/master/ykcore/ykdef.h#L274
export const YubiKeyVendorId = 0x1050;
const YubiKeyVendorId = 0x1050;
export const YubiKeyProductIds = {
const YubiKeyProductIds = {
Gen1: [0x0010],
NEO: [0x0110, 0x0112, 0x0113, 0x0114, 0x0115, 0x0116],
YK4: [0x0401, 0x0402, 0x0403, 0x0404, 0x0405, 0x0406, 0x0407]
};
export const YubiKeyChallengeSize = 64;
const YubiKeyChallengeSize = 64;
export { YubiKeyVendorId, YubiKeyProductIds, YubiKeyChallengeSize };
@@ -1,4 +1,4 @@
export const IconMap = [
const IconMap = [
'key',
'globe',
'exclamation-triangle',
@@ -69,3 +69,5 @@ export const IconMap = [
'signature',
'mobile'
];
export { IconMap };
+119
View File
@@ -0,0 +1,119 @@
const Keys = {
DOM_VK_CANCEL: 3,
DOM_VK_HELP: 6,
DOM_VK_BACK_SPACE: 8,
DOM_VK_TAB: 9,
DOM_VK_CLEAR: 12,
DOM_VK_RETURN: 13,
DOM_VK_ENTER: 14,
DOM_VK_SHIFT: 16,
DOM_VK_CONTROL: 17,
DOM_VK_ALT: 18,
DOM_VK_PAUSE: 19,
DOM_VK_CAPS_LOCK: 20,
DOM_VK_ESCAPE: 27,
DOM_VK_SPACE: 32,
DOM_VK_PAGE_UP: 33,
DOM_VK_PAGE_DOWN: 34,
DOM_VK_END: 35,
DOM_VK_HOME: 36,
DOM_VK_LEFT: 37,
DOM_VK_UP: 38,
DOM_VK_RIGHT: 39,
DOM_VK_DOWN: 40,
DOM_VK_PRINTSCREEN: 44,
DOM_VK_INSERT: 45,
DOM_VK_DELETE: 46,
DOM_VK_0: 48,
DOM_VK_1: 49,
DOM_VK_2: 50,
DOM_VK_3: 51,
DOM_VK_4: 52,
DOM_VK_5: 53,
DOM_VK_6: 54,
DOM_VK_7: 55,
DOM_VK_8: 56,
DOM_VK_9: 57,
DOM_VK_SEMICOLON: 59,
DOM_VK_EQUALS: 61,
DOM_VK_A: 65,
DOM_VK_B: 66,
DOM_VK_C: 67,
DOM_VK_D: 68,
DOM_VK_E: 69,
DOM_VK_F: 70,
DOM_VK_G: 71,
DOM_VK_H: 72,
DOM_VK_I: 73,
DOM_VK_J: 74,
DOM_VK_K: 75,
DOM_VK_L: 76,
DOM_VK_M: 77,
DOM_VK_N: 78,
DOM_VK_O: 79,
DOM_VK_P: 80,
DOM_VK_Q: 81,
DOM_VK_R: 82,
DOM_VK_S: 83,
DOM_VK_T: 84,
DOM_VK_U: 85,
DOM_VK_V: 86,
DOM_VK_W: 87,
DOM_VK_X: 88,
DOM_VK_Y: 89,
DOM_VK_Z: 90,
DOM_VK_CONTEXT_MENU: 93,
DOM_VK_NUMPAD0: 96,
DOM_VK_NUMPAD1: 97,
DOM_VK_NUMPAD2: 98,
DOM_VK_NUMPAD3: 99,
DOM_VK_NUMPAD4: 100,
DOM_VK_NUMPAD5: 101,
DOM_VK_NUMPAD6: 102,
DOM_VK_NUMPAD7: 103,
DOM_VK_NUMPAD8: 104,
DOM_VK_NUMPAD9: 105,
DOM_VK_MULTIPLY: 106,
DOM_VK_ADD: 107,
DOM_VK_SEPARATOR: 108,
DOM_VK_SUBTRACT: 109,
DOM_VK_DECIMAL: 110,
DOM_VK_DIVIDE: 111,
DOM_VK_F1: 112,
DOM_VK_F2: 113,
DOM_VK_F3: 114,
DOM_VK_F4: 115,
DOM_VK_F5: 116,
DOM_VK_F6: 117,
DOM_VK_F7: 118,
DOM_VK_F8: 119,
DOM_VK_F9: 120,
DOM_VK_F10: 121,
DOM_VK_F11: 122,
DOM_VK_F12: 123,
DOM_VK_F13: 124,
DOM_VK_F14: 125,
DOM_VK_F15: 126,
DOM_VK_F16: 127,
DOM_VK_F17: 128,
DOM_VK_F18: 129,
DOM_VK_F19: 130,
DOM_VK_F20: 131,
DOM_VK_F21: 132,
DOM_VK_F22: 133,
DOM_VK_F23: 134,
DOM_VK_F24: 135,
DOM_VK_NUM_LOCK: 144,
DOM_VK_SCROLL_LOCK: 145,
DOM_VK_COMMA: 188,
DOM_VK_PERIOD: 190,
DOM_VK_SLASH: 191,
DOM_VK_BACK_QUOTE: 192,
DOM_VK_OPEN_BRACKET: 219,
DOM_VK_BACK_SLASH: 220,
DOM_VK_CLOSE_BRACKET: 221,
DOM_VK_QUOTE: 222,
DOM_VK_META: 224
};
export { Keys };
-117
View File
@@ -1,117 +0,0 @@
export enum Keys {
DOM_VK_CANCEL = 3,
DOM_VK_HELP = 6,
DOM_VK_BACK_SPACE = 8,
DOM_VK_TAB = 9,
DOM_VK_CLEAR = 12,
DOM_VK_RETURN = 13,
DOM_VK_ENTER = 14,
DOM_VK_SHIFT = 16,
DOM_VK_CONTROL = 17,
DOM_VK_ALT = 18,
DOM_VK_PAUSE = 19,
DOM_VK_CAPS_LOCK = 20,
DOM_VK_ESCAPE = 27,
DOM_VK_SPACE = 32,
DOM_VK_PAGE_UP = 33,
DOM_VK_PAGE_DOWN = 34,
DOM_VK_END = 35,
DOM_VK_HOME = 36,
DOM_VK_LEFT = 37,
DOM_VK_UP = 38,
DOM_VK_RIGHT = 39,
DOM_VK_DOWN = 40,
DOM_VK_PRINTSCREEN = 44,
DOM_VK_INSERT = 45,
DOM_VK_DELETE = 46,
DOM_VK_0 = 48,
DOM_VK_1 = 49,
DOM_VK_2 = 50,
DOM_VK_3 = 51,
DOM_VK_4 = 52,
DOM_VK_5 = 53,
DOM_VK_6 = 54,
DOM_VK_7 = 55,
DOM_VK_8 = 56,
DOM_VK_9 = 57,
DOM_VK_SEMICOLON = 59,
DOM_VK_EQUALS = 61,
DOM_VK_A = 65,
DOM_VK_B = 66,
DOM_VK_C = 67,
DOM_VK_D = 68,
DOM_VK_E = 69,
DOM_VK_F = 70,
DOM_VK_G = 71,
DOM_VK_H = 72,
DOM_VK_I = 73,
DOM_VK_J = 74,
DOM_VK_K = 75,
DOM_VK_L = 76,
DOM_VK_M = 77,
DOM_VK_N = 78,
DOM_VK_O = 79,
DOM_VK_P = 80,
DOM_VK_Q = 81,
DOM_VK_R = 82,
DOM_VK_S = 83,
DOM_VK_T = 84,
DOM_VK_U = 85,
DOM_VK_V = 86,
DOM_VK_W = 87,
DOM_VK_X = 88,
DOM_VK_Y = 89,
DOM_VK_Z = 90,
DOM_VK_CONTEXT_MENU = 93,
DOM_VK_NUMPAD0 = 96,
DOM_VK_NUMPAD1 = 97,
DOM_VK_NUMPAD2 = 98,
DOM_VK_NUMPAD3 = 99,
DOM_VK_NUMPAD4 = 100,
DOM_VK_NUMPAD5 = 101,
DOM_VK_NUMPAD6 = 102,
DOM_VK_NUMPAD7 = 103,
DOM_VK_NUMPAD8 = 104,
DOM_VK_NUMPAD9 = 105,
DOM_VK_MULTIPLY = 106,
DOM_VK_ADD = 107,
DOM_VK_SEPARATOR = 108,
DOM_VK_SUBTRACT = 109,
DOM_VK_DECIMAL = 110,
DOM_VK_DIVIDE = 111,
DOM_VK_F1 = 112,
DOM_VK_F2 = 113,
DOM_VK_F3 = 114,
DOM_VK_F4 = 115,
DOM_VK_F5 = 116,
DOM_VK_F6 = 117,
DOM_VK_F7 = 118,
DOM_VK_F8 = 119,
DOM_VK_F9 = 120,
DOM_VK_F10 = 121,
DOM_VK_F11 = 122,
DOM_VK_F12 = 123,
DOM_VK_F13 = 124,
DOM_VK_F14 = 125,
DOM_VK_F15 = 126,
DOM_VK_F16 = 127,
DOM_VK_F17 = 128,
DOM_VK_F18 = 129,
DOM_VK_F19 = 130,
DOM_VK_F20 = 131,
DOM_VK_F21 = 132,
DOM_VK_F22 = 133,
DOM_VK_F23 = 134,
DOM_VK_F24 = 135,
DOM_VK_NUM_LOCK = 144,
DOM_VK_SCROLL_LOCK = 145,
DOM_VK_COMMA = 188,
DOM_VK_PERIOD = 190,
DOM_VK_SLASH = 191,
DOM_VK_BACK_QUOTE = 192,
DOM_VK_OPEN_BRACKET = 219,
DOM_VK_BACK_SLASH = 220,
DOM_VK_CLOSE_BRACKET = 221,
DOM_VK_QUOTE = 222,
DOM_VK_META = 224
}
+5
View File
@@ -0,0 +1,5 @@
const KnownAppVersions = {
KeePassXC: '2.6.4'
};
export { KnownAppVersions };
-3
View File
@@ -1,3 +0,0 @@
export const KnownAppVersions = {
KeePassXC: '2.6.4'
};
@@ -1,4 +1,4 @@
export const Links = {
const Links = {
Homepage: 'https://keeweb.info',
Repo: 'https://github.com/keeweb/keeweb',
Desktop: 'https://github.com/keeweb/keeweb/releases/latest',
@@ -23,15 +23,14 @@ export const Links = {
HaveIBeenPwnedPrivacy: 'https://haveibeenpwned.com/Passwords',
ExtensionHelpForOtherBrowsers:
'https://github.com/keeweb/keeweb/wiki/Browser-AutoFill#other-browsers',
ExtensionHelpForKPXC:
'https://github.com/keeweb/keeweb/wiki/Browser-AutoFill#keepassxc-browser',
KWCForChrome:
'https://chrome.google.com/webstore/detail/keeweb-connect/pikpfmjfkekaeinceagbebpfkmkdlcjk',
KWCForFirefox: 'https://addons.mozilla.org/firefox/addon/keeweb-connect/',
KWCForEdge:
'https://microsoftedge.microsoft.com/addons/detail/keewebconnect/nmggpehkjmeaeocmaijenpejbepckinm',
KWCForSafari: 'https://apps.apple.com/app/keeweb-connect/id1565748094',
KPXCForChrome:
'https://chrome.google.com/webstore/detail/keepassxc-browser/oboonakemofpalcgghocfoadofidjkkk',
KPXCForFirefox: 'https://addons.mozilla.org/firefox/addon/keepassxc-browser/',
KPXCForEdge:
'https://microsoftedge.microsoft.com/addons/detail/keepassxcbrowser/pdffhmdngciaglkoonimfcmckehcpafo'
KWCForSafari: 'https://apps.apple.com/app/keeweb-connect/id1565748094'
};
export { Links };
@@ -1,4 +1,4 @@
export const RuntimeInfo = {
const RuntimeInfo = {
version: '@@VERSION',
beta: !!'@@BETA',
buildDate: '@@DATE',
@@ -6,3 +6,5 @@ export const RuntimeInfo = {
devMode: '@@DEVMODE',
appleTeamId: '@@APPLE_TEAM_ID'
};
export { RuntimeInfo };
@@ -1,8 +1,5 @@
export const Timeouts = {
ShowTip: 200,
HideTip: 500,
const Timeouts = {
CopyTip: 1500,
HideAlert: 150,
AutoHideHint: 3000,
FileChangeSync: 3000,
BeforeAutoLock: 300,
@@ -22,13 +19,7 @@ export const Timeouts = {
NativeModuleHostRestartTime: 3000,
FastAnimation: 100,
AutoTypeCopyPaste: 300,
KeeWebConnectRequest: 60000,
UpdateInterval: 1000 * 60 * 60 * 24,
MinUpdateTimeout: 500,
InputShake: 1000,
WindowResizeUpdateDebounce: 200,
SaveFileInfoDebounce: 100,
PluginsAutoUpdateAfterStart: 2000,
PluginsUpdate: 1000 * 60 * 60 * 24 * 7,
ContextMenuCleanup: 1000
KeeWebConnectRequest: 60000
};
export { Timeouts };
+251
View File
@@ -0,0 +1,251 @@
import EventEmitter from 'events';
const SymbolEvents = Symbol('events');
const SymbolArray = Symbol('array');
function emitSet(target, value, prevValue) {
const emitter = target[SymbolEvents];
if (!emitter.paused) {
const updates = { added: [], removed: [] };
if (prevValue) {
emitter.emit('remove', prevValue, target);
updates.removed.push(prevValue);
}
if (value) {
emitter.emit('add', value, target);
updates.added.push(value);
}
emitter.emit('change', updates, target);
}
}
function emitRemoved(target, removed) {
const emitter = target[SymbolEvents];
if (!emitter.paused) {
for (const item of removed) {
emitter.emit('remove', item, target);
}
emitter.emit('change', { added: [], removed }, target);
}
}
function checkType(target, value) {
const modelClass = target.constructor.model;
if (!modelClass) {
throw new Error(`Model type not defined for ${target.constructor.name}`);
}
if (!(value instanceof modelClass)) {
const valueType = value && value.constructor ? value.constructor.name : typeof value;
throw new Error(`Attempt to write ${valueType} into ${target.constructor.name}`);
}
}
const ProxyDef = {
set(target, property, value) {
const numProp = parseInt(property);
if (isNaN(numProp)) {
target[property] = value;
return true;
}
checkType(target, value);
const array = target[SymbolArray];
const prevValue = array[property];
if (prevValue !== value) {
array[property] = value;
emitSet(target, value, prevValue);
}
return true;
},
get(target, property) {
if (typeof property !== 'string') {
return target[property];
}
const numProp = parseInt(property);
if (isNaN(numProp)) {
return target[property];
}
return target[SymbolArray][property];
}
};
class Collection {
constructor(items) {
const emitter = new EventEmitter();
emitter.setMaxListeners(100);
const properties = {
[SymbolEvents]: { value: emitter },
[SymbolArray]: { value: [] }
};
Object.defineProperties(this, properties);
if (items) {
this.push(...items);
}
return new Proxy(this, ProxyDef);
}
get length() {
return this[SymbolArray].length;
}
set length(value) {
const array = this[SymbolArray];
let removed;
if (value < array.length) {
removed = array.slice(value);
}
array.length = value;
if (removed) {
emitRemoved(this, removed);
}
}
push(...items) {
if (items.length) {
for (const item of items) {
checkType(this, item);
}
this[SymbolEvents].paused = true;
this[SymbolArray].push(...items);
this[SymbolEvents].paused = false;
for (const item of items) {
this[SymbolEvents].emit('add', item, this);
}
this[SymbolEvents].emit('change', { added: items, removed: [] }, this);
}
}
pop() {
this[SymbolEvents].paused = true;
const item = this[SymbolArray].pop();
this[SymbolEvents].paused = false;
if (item) {
this[SymbolEvents].emit('remove', item, this);
this[SymbolEvents].emit('change', { added: [], removed: [item] }, this);
}
return item;
}
shift() {
this[SymbolEvents].paused = true;
const item = this[SymbolArray].shift();
this[SymbolEvents].paused = false;
if (item) {
this[SymbolEvents].emit('remove', item, this);
this[SymbolEvents].emit('change', { added: [], removed: [item] }, this);
}
return item;
}
unshift(...items) {
if (items.length) {
for (const item of items) {
checkType(this, item);
}
this[SymbolEvents].paused = true;
this[SymbolArray].unshift(...items);
this[SymbolEvents].paused = false;
for (const item of items) {
this[SymbolEvents].emit('add', item, this);
}
this[SymbolEvents].emit('change', { added: items, removed: [] }, this);
}
}
splice(start, deleteCount, ...items) {
for (const item of items) {
checkType(this, item);
}
this[SymbolEvents].paused = true;
const removed = this[SymbolArray].splice(start, deleteCount, ...items);
this[SymbolEvents].paused = false;
for (const item of removed) {
this[SymbolEvents].emit('remove', item, this);
}
for (const item of items) {
this[SymbolEvents].emit('add', item, this);
}
if (removed.length || items.length) {
this[SymbolEvents].emit('change', { added: items, removed }, this);
}
}
on(eventName, listener) {
this[SymbolEvents].on(eventName, listener);
}
once(eventName, listener) {
this[SymbolEvents].once(eventName, listener);
}
off(eventName, listener) {
this[SymbolEvents].off(eventName, listener);
}
get(id) {
return this.find((model) => model.id === id);
}
remove(idOrModel) {
for (let i = 0; i < this.length; i++) {
while (i < this.length && (this[i].id === idOrModel || this[i] === idOrModel)) {
this.splice(i, 1);
}
}
}
sort() {
return this[SymbolArray].sort(this.comparator);
}
fill() {
throw new Error('Not implemented');
}
copyWithin() {
throw new Error('Not implemented');
}
toJSON() {
return this[SymbolArray].concat();
}
}
const ProxiedArrayMethods = [
Symbol.iterator,
'concat',
'entries',
'every',
'filter',
'find',
'findIndex',
'flat',
'flatMap',
'forEach',
'includes',
'indexOf',
'join',
'keys',
'lastIndexOf',
'map',
'reduce',
'reduceRight',
'reverse',
'slice',
'some',
'values'
];
for (const method of ProxiedArrayMethods) {
Object.defineProperty(Collection.prototype, method, {
value: function proxyMethod(...args) {
return this[SymbolArray][method](...args);
}
});
}
export { Collection };
+12
View File
@@ -0,0 +1,12 @@
import EventEmitter from 'events';
class Events extends EventEmitter {
constructor() {
super();
this.setMaxListeners(1000);
}
}
const instance = new Events();
export { instance as Events };
+126
View File
@@ -0,0 +1,126 @@
import EventEmitter from 'events';
import { Logger } from 'util/logger';
const SymbolEvents = Symbol('events');
const SymbolDefaults = Symbol('defaults');
const SymbolExtensions = Symbol('extensions');
function emitPropChange(target, property, value, prevValue) {
const emitter = target[SymbolEvents];
if (!emitter.paused) {
emitter.emit('change:' + property, target, value, prevValue);
if (!emitter.noChange) {
emitter.emit('change', target, { [property]: value });
}
}
}
const ProxyDef = {
deleteProperty(target, property) {
if (Object.prototype.hasOwnProperty.call(target, property)) {
const defaults = target[SymbolDefaults];
const value = defaults[property];
const prevValue = target[property];
if (prevValue !== value) {
if (Object.prototype.hasOwnProperty.call(defaults, property)) {
target[property] = value;
} else {
delete target[property];
}
emitPropChange(target, property, value, prevValue);
}
return true;
}
return true;
},
set(target, property, value, receiver) {
if (Object.prototype.hasOwnProperty.call(target, property) || target[SymbolExtensions]) {
if (target[property] !== value) {
const prevValue = target[property];
target[property] = value;
emitPropChange(target, property, value, prevValue);
}
return true;
} else {
new Logger(receiver.constructor.name).warn(
`Unknown property: ${property}`,
new Error().stack
);
}
return false;
}
};
class Model {
constructor(data) {
const emitter = new EventEmitter();
emitter.setMaxListeners(100);
const properties = {
[SymbolEvents]: { value: emitter }
};
for (const [propName, defaultValue] of Object.entries(this[SymbolDefaults])) {
properties[propName] = {
configurable: true,
enumerable: true,
writable: true,
value: defaultValue
};
}
Object.defineProperties(this, properties);
const object = new Proxy(this, ProxyDef);
if (data) {
object.set(data, { silent: true });
}
return object;
}
set(props, { silent } = {}) {
const emitter = this[SymbolEvents];
if (silent) {
emitter.paused = true;
}
emitter.noChange = true;
for (const [prop, value] of Object.entries(props)) {
this[prop] = value;
}
emitter.noChange = false;
if (silent) {
emitter.paused = false;
} else {
emitter.emit('change', this, props);
}
}
on(eventName, listener) {
this[SymbolEvents].on(eventName, listener);
}
once(eventName, listener) {
this[SymbolEvents].once(eventName, listener);
}
off(eventName, listener) {
this[SymbolEvents].off(eventName, listener);
}
emit(eventName, ...args) {
this[SymbolEvents].emit(eventName, ...args);
}
static defineModelProperties(properties, options) {
this.prototype[SymbolDefaults] = { ...this.prototype[SymbolDefaults], ...properties };
if (options && options.extensions) {
this.prototype[SymbolExtensions] = true;
}
}
static set(properties) {
this.prototype[SymbolDefaults] = properties;
}
}
export { Model };
+48
View File
@@ -0,0 +1,48 @@
import { Events } from 'framework/events';
import { AppSettingsModel } from 'models/app-settings-model';
import { Locale } from 'util/locale';
import { Tip } from 'util/ui/tip';
import { Timeouts } from 'const/timeouts';
const Copyable = {
hideFieldCopyTip() {
if (this.fieldCopyTip) {
this.fieldCopyTip.hide();
this.fieldCopyTip = null;
}
},
fieldCopied(e) {
this.hideFieldCopyTip();
const fieldLabel = e.source.labelEl;
const clipboardTime = e.copyRes.seconds;
const msg = clipboardTime
? Locale.detFieldCopiedTime.replace('{}', clipboardTime)
: Locale.detFieldCopied;
let tip;
if (!this.isHidden()) {
tip = Tip.createTip(fieldLabel[0], {
title: msg,
placement: 'right',
fast: true,
force: true,
noInit: true
});
this.fieldCopyTip = tip;
tip.show();
}
setTimeout(() => {
if (tip) {
tip.hide();
}
this.fieldCopyTip = null;
if (e.source.model.name === '$Password' && AppSettingsModel.lockOnCopy) {
setTimeout(() => {
Events.emit('lock-workspace');
}, Timeouts.BeforeAutoLock);
}
}, Timeouts.CopyTip);
}
};
export { Copyable };
+69
View File
@@ -0,0 +1,69 @@
import { Events } from 'framework/events';
const Resizable = {
listenDrag(dragView) {
this.listenTo(dragView, 'dragstart', this.dragStart);
this.listenTo(dragView, 'drag', this.drag);
this.listenTo(dragView, 'autosize', this.autoSize);
},
dragStart(e) {
this._dragInfo = this.getDragInfo(e.coord);
},
drag(e) {
const dragInfo = this._dragInfo;
let size = dragInfo.startSize + e.offset;
size = Math.max(dragInfo.min, Math.min(dragInfo.max, size));
this.$el[dragInfo.prop](size);
this.emit('view-resize', size);
Events.emit('page-geometry', { source: 'resizable' });
},
autoSize(e) {
const dragInfo = this.getDragInfo(e.coord);
if (dragInfo.auto !== undefined) {
this.$el.css(dragInfo.prop, dragInfo.auto);
} else {
this.$el.css(dragInfo.prop, '');
}
this.fixSize(dragInfo);
this.emit('view-resize', null);
Events.emit('page-geometry', { source: 'resizable' });
},
fixSize(cfg) {
const size = this.$el[cfg.prop]();
const newSize = Math.max(cfg.min, Math.min(cfg.max, size));
if (newSize !== size) {
this.$el[cfg.prop](size);
}
},
// TODO: check size on window resize
// checkSize: function() {
// if (this.maxWidth) {
// this.fixSize(this.getDragInfo('x'));
// }
// if (this.maxHeight) {
// this.fixSize(this.getDragInfo('y'));
// }
// },
getDragInfo(coord) {
const prop = coord === 'x' ? 'Width' : 'Height';
const propLower = prop.toLowerCase();
const min = this.getSizeProp('min' + prop);
const max = this.getSizeProp('max' + prop);
const auto = this.getSizeProp('auto' + prop);
const startSize = this.$el[propLower]();
return { startSize, prop: propLower, min, max, auto };
},
getSizeProp(prop) {
const member = this[prop];
return typeof member === 'function' ? member.call(this) : member;
}
};
export { Resizable };
+61
View File
@@ -0,0 +1,61 @@
import baron from 'baron';
import { Events } from 'framework/events';
import { Features } from 'util/features';
const isEnabled = !Features.isMobile;
const SymbolRemoveScrollListenerAdded = Symbol('removeScrollAdded');
const Scrollable = {
createScroll(opts) {
// opts.cssGuru = true;
if (isEnabled) {
if (this.scroll) {
this.removeScroll();
}
this.scroll = baron(opts);
if (!this[SymbolRemoveScrollListenerAdded]) {
this.once('remove', () => this.removeScroll);
this[SymbolRemoveScrollListenerAdded] = true;
}
}
this.scroller = this.$el.find('.scroller');
this.scrollerBar = this.$el.find('.scroller__bar');
this.scrollerBarWrapper = this.$el.find('.scroller__bar-wrapper');
},
removeScroll() {
if (this.scroll) {
try {
this.scroll.dispose();
} catch {}
this.scroll = null;
}
},
pageResized() {
// TODO: check size on window resize
// if (this.checkSize && (!e || e.source === 'window')) {
// this.checkSize();
// }
if (this.scroll) {
this.scroll.update();
requestAnimationFrame(() => {
if (this.scroll) {
this.scroll.update();
const barHeight = Math.round(this.scrollerBar.height());
const wrapperHeight = Math.round(this.scrollerBarWrapper.height());
this.scrollerBarWrapper.toggleClass('invisible', barHeight >= wrapperHeight);
}
});
}
},
initScroll() {
if (isEnabled) {
this.listenTo(Events, 'page-geometry', this.pageResized);
}
}
};
export { Scrollable };
+290
View File
@@ -0,0 +1,290 @@
import morphdom from 'morphdom';
import EventEmitter from 'events';
import { Tip } from 'util/ui/tip';
import { KeyHandler } from 'comp/browser/key-handler';
import { FocusManager } from 'comp/app/focus-manager';
import { Logger } from 'util/logger';
const DoesNotBubble = {
mouseenter: true,
mouseleave: true,
blur: true,
focus: true
};
const DefaultTemplateOptions = {
allowProtoPropertiesByDefault: true,
allowedProtoProperties: { length: true, active: true }
};
class View extends EventEmitter {
parent = undefined;
template = undefined;
events = {};
model = undefined;
options = {};
views = {};
hidden = undefined;
removed = false;
modal = undefined;
eventListeners = {};
elementEventListeners = [];
debugLogger = localStorage.debugView ? new Logger('view', this.constructor.name) : undefined;
constructor(model = undefined, options = {}) {
super();
this.model = model;
this.options = options;
this.setMaxListeners(100);
}
render(templateData) {
if (this.removed) {
return;
}
let ts;
if (this.debugLogger) {
this.debugLogger.debug('Render start');
ts = this.debugLogger.ts();
}
if (this.el) {
Tip.destroyTips(this.el);
}
this.renderElement(templateData);
Tip.createTips(this.el);
this.debugLogger?.debug('Render finished', this.debugLogger.ts(ts));
return this;
}
renderElement(templateData) {
const html = this.template(templateData, DefaultTemplateOptions);
if (this.el) {
const mountRoot = this.options.ownParent ? this.el.firstChild : this.el;
morphdom(mountRoot, html);
this.bindElementEvents();
} else {
let parent = this.options.parent || this.parent;
if (parent) {
if (typeof parent === 'string') {
parent = document.querySelector(parent);
}
if (!parent) {
throw new Error(`Error rendering ${this.constructor.name}: parent not found`);
}
if (this.options.replace) {
Tip.destroyTips(parent);
parent.innerHTML = '';
}
const el = document.createElement('div');
el.innerHTML = html;
const root = el.firstChild;
if (this.options.ownParent) {
if (root) {
parent.appendChild(root);
}
this.el = parent;
} else {
this.el = root;
parent.appendChild(this.el);
}
if (this.modal) {
FocusManager.setModal(this.modal);
}
this.bindEvents();
} else {
throw new Error(
`Error rendering ${this.constructor.name}: I don't know how to insert the view`
);
}
this.$el = $(this.el); // legacy
}
}
bindEvents() {
const eventsMap = {};
for (const [eventDef, method] of Object.entries(this.events)) {
const spaceIx = eventDef.indexOf(' ');
let event, selector;
if (spaceIx > 0) {
event = eventDef.substr(0, spaceIx);
selector = eventDef.substr(spaceIx + 1);
if (DoesNotBubble[event]) {
this.elementEventListeners.push({ event, selector, method, els: [] });
continue;
}
} else {
event = eventDef;
}
if (!eventsMap[event]) {
eventsMap[event] = [];
}
eventsMap[event].push({ selector, method });
}
for (const [event, handlers] of Object.entries(eventsMap)) {
this.debugLogger?.debug('Bind', 'view', event, handlers);
const listener = (e) => this.eventListener(e, handlers);
this.eventListeners[event] = listener;
this.el.addEventListener(event, listener);
}
this.bindElementEvents();
}
unbindEvents() {
for (const [event, listener] of Object.entries(this.eventListeners)) {
this.el.removeEventListener(event, listener);
}
this.unbindElementEvents();
}
bindElementEvents() {
if (!this.elementEventListeners.length) {
return;
}
this.unbindElementEvents();
for (const cfg of this.elementEventListeners) {
const els = this.el.querySelectorAll(cfg.selector);
this.debugLogger?.debug('Bind', 'element', cfg.event, cfg.selector, els.length);
cfg.listener = (e) => this.eventListener(e, [cfg]);
for (const el of els) {
el.addEventListener(cfg.event, cfg.listener);
cfg.els.push(el);
}
}
}
unbindElementEvents() {
if (!this.elementEventListeners.length) {
return;
}
for (const cfg of this.elementEventListeners) {
for (const el of cfg.els) {
el.removeEventListener(cfg.event, cfg.listener);
}
cfg.els = [];
}
}
eventListener(e, handlers) {
this.debugLogger?.debug('Listener fired', e.type);
for (const { selector, method } of handlers) {
if (selector) {
const closest = e.target.closest(selector);
if (!closest || !this.el.contains(closest)) {
continue;
}
}
if (!this[method]) {
this.debugLogger?.debug('Method not defined', method);
continue;
}
this.debugLogger?.debug('Handling event', e.type, method);
this[method](e);
}
}
remove() {
if (this.modal && FocusManager.modal === this.modal) {
FocusManager.setModal(null);
}
this.emit('remove');
this.removeInnerViews();
Tip.hideTips(this.el);
this.el.remove();
this.removed = true;
this.debugLogger?.debug('Remove');
}
removeInnerViews() {
if (this.views) {
for (const view of Object.values(this.views)) {
if (view) {
if (view instanceof Array) {
view.forEach((v) => v.remove());
} else {
view.remove();
}
}
}
this.views = {};
}
}
listenTo(model, event, callback) {
const boundCallback = callback.bind(this);
model.on(event, boundCallback);
this.once('remove', () => model.off(event, boundCallback));
}
hide() {
Tip.hideTips(this.el);
return this.toggle(false);
}
show() {
return this.toggle(true);
}
toggle(visible) {
this.debugLogger?.debug(visible ? 'Show' : 'Hide');
if (visible === undefined) {
visible = this.hidden;
}
if (this.hidden === !visible) {
this.debugLogger?.debug('Toggle: noop', visible);
return;
}
this.hidden = !visible;
if (this.modal) {
if (visible) {
FocusManager.setModal(this.modal);
} else if (FocusManager.modal === this.modal) {
FocusManager.setModal(null);
}
}
if (this.el) {
this.el.classList.toggle('show', !!visible);
this.el.classList.toggle('hide', !visible);
if (!visible) {
Tip.hideTips(this.el);
}
}
this.emit(visible ? 'show' : 'hide');
}
isHidden() {
return !!this.hidden;
}
isVisible() {
return !this.hidden;
}
afterPaint(callback) {
requestAnimationFrame(() => requestAnimationFrame(callback));
}
onKey(key, handler, shortcut, modal, noPrevent) {
KeyHandler.onKey(key, handler, this, shortcut, modal, noPrevent);
this.once('remove', () => KeyHandler.offKey(key, handler, this));
}
off(event, listener) {
if (listener === undefined) {
return super.removeAllListeners(event);
} else {
return super.off(event, listener);
}
}
}
export { View, DefaultTemplateOptions };

Some files were not shown because too many files have changed in this diff Show More