Compare commits
17
Commits
ts
..
release-1.18
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1930c265c8 | ||
|
|
1c434d7c79 | ||
|
|
c0c75ac8a8 | ||
|
|
4b7124aa57 | ||
|
|
93cee76b4f | ||
|
|
1673580dfa | ||
|
|
bf0e94582d | ||
|
|
29ae7e8ed2 | ||
|
|
9b2c40a078 | ||
|
|
f0bb382449 | ||
|
|
062ddb3f72 | ||
|
|
208a1b551e | ||
|
|
f8af64e45b | ||
|
|
98268f21c0 | ||
|
|
dcbe8d70da | ||
|
|
1d61c6ca03 | ||
|
|
433f0acf3c |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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}.`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"rules": {
|
||||
"import/no-commonjs": "error"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
+3
-3
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
+29
-34
@@ -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 };
|
||||
+9
-12
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
+4
-6
@@ -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(() => {
|
||||
+8
-19
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
+8
-11
@@ -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';
|
||||
}
|
||||
}
|
||||
};
|
||||
+6
-9
@@ -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();
|
||||
+21
-13
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
+4
-4
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
+2
-2
@@ -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);
|
||||
},
|
||||
+3
-3
@@ -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();
|
||||
@@ -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) +
|
||||
'-' +
|
||||
@@ -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 };
|
||||
@@ -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
@@ -1 +0,0 @@
|
||||
../../../../desktop/desktop-ipc.d.ts
|
||||
@@ -0,0 +1,7 @@
|
||||
let Launcher;
|
||||
|
||||
if (window.process && window.process.versions && window.process.versions.electron) {
|
||||
Launcher = require('./launcher-electron').Launcher;
|
||||
}
|
||||
|
||||
export { Launcher };
|
||||
@@ -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);
|
||||
// });
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
const KnownAppVersions = {
|
||||
KeePassXC: '2.6.4'
|
||||
};
|
||||
|
||||
export { KnownAppVersions };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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
Reference in New Issue
Block a user