Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c386e67e2 | ||
|
|
b572876099 | ||
|
|
48c4f28d5d | ||
|
|
8df3899920 | ||
|
|
f9a79bf2c4 | ||
|
|
809dd897f7 | ||
|
|
8d2e5b12aa | ||
|
|
d5a61acd01 | ||
|
|
aa11e1a65e | ||
|
|
839afdda36 | ||
|
|
40b85c76fb | ||
|
|
da8ba46d21 | ||
|
|
b15fd7258d | ||
|
|
701d593b5b | ||
|
|
c8ad6c8416 | ||
|
|
b859daf39b | ||
|
|
d2d58e675a | ||
|
|
518b34873b | ||
|
|
763ee2e1f5 | ||
|
|
9dd8ee05e9 | ||
|
|
b4a49e2321 | ||
|
|
04c2f487fc | ||
|
|
708ac26837 | ||
|
|
9431a1bbf5 | ||
|
|
006e841a84 | ||
|
|
7a9a16ce0e | ||
|
|
342242b2c3 | ||
|
|
06ece835c7 | ||
|
|
78f322b51a | ||
|
|
88b25a54cb | ||
|
|
638b1a9aac | ||
|
|
e526d0e3f0 | ||
|
|
75da650a80 | ||
|
|
8a9243b639 | ||
|
|
2baf2b0dcf | ||
|
|
ec126d52e2 | ||
|
|
3c2cab94d7 | ||
|
|
12dea11a9a | ||
|
|
bf0192412a | ||
|
|
acad35a52b | ||
|
|
671212c23d | ||
|
|
146de47e0c | ||
|
|
7187cc5e1d | ||
|
|
0a656830c8 | ||
|
|
8142025fdd | ||
|
|
98b78b3856 | ||
|
|
5140d729e2 | ||
|
|
3a4b1f56d9 | ||
|
|
bd5d8bded9 | ||
|
|
ee6c96cdc8 | ||
|
|
720118c5df | ||
|
|
3ec6e2f8e8 | ||
|
|
af540cf250 | ||
|
|
d9ed7ced22 | ||
|
|
61a78107c4 | ||
|
|
3de2f1d25d | ||
|
|
e0fa3e18d1 | ||
|
|
3e49fc4c75 | ||
|
|
6fe9d56b3c | ||
|
|
32126939a6 | ||
|
|
7962631a03 | ||
|
|
10dc9a4528 | ||
|
|
e93cdae808 | ||
|
|
cd9ba11d24 | ||
|
|
f7d6802f58 | ||
|
|
b1b1911eba | ||
|
|
d2536d8dcd | ||
|
|
500eaa4569 | ||
|
|
6f068ebf18 | ||
|
|
f840f7b15d | ||
|
|
c172477004 | ||
|
|
9e76878c7c | ||
|
|
06fef55e2c | ||
|
|
0ce5fc462a | ||
|
|
40dd3637d7 | ||
|
|
caa17a48a8 | ||
|
|
2fe980f2ab | ||
|
|
0b38897868 | ||
|
|
f3a7f75be3 | ||
|
|
0bf3c0d43f | ||
|
|
6a25732a9f | ||
|
|
117955bcf9 | ||
|
|
95d2731d13 | ||
|
|
f7da54dded | ||
|
|
87aebe9216 | ||
|
|
6bc140e123 | ||
|
|
4897f9e41e | ||
|
|
4a7797df6c | ||
|
|
f438ed6d54 | ||
|
|
9230389a91 | ||
|
|
9857f818ca | ||
|
|
c6bc6b059e | ||
|
|
7556ebf974 | ||
|
|
2591b22f35 | ||
|
|
748c70cd0f | ||
|
|
d0a6791f21 | ||
|
|
8beef89250 | ||
|
|
b5a19b0186 | ||
|
|
b530f6d29a | ||
|
|
d71b7d2f3d | ||
|
|
170f50f6fc | ||
|
|
d01aebe484 | ||
|
|
881ffa115e | ||
|
|
9cc9958bdf | ||
|
|
16d2bd6786 | ||
|
|
1e607d30e6 | ||
|
|
1923234ffa | ||
|
|
7a25b0fd39 | ||
|
|
1dbf647b1b | ||
|
|
0b3b49189f | ||
|
|
73b8ae6431 | ||
|
|
4402db826d | ||
|
|
95078ea2f1 | ||
|
|
b270ad52b4 | ||
|
|
810f785a1f | ||
|
|
8f5e19b64b | ||
|
|
3017b39e5f | ||
|
|
c2ad509283 | ||
|
|
540c607527 | ||
|
|
17d51ee5f1 | ||
|
|
522e356fe3 | ||
|
|
5c0756855a | ||
|
|
38466f934f | ||
|
|
c198e634d4 | ||
|
|
45e1f87d07 | ||
|
|
89f3741f17 | ||
|
|
dba37cc40a | ||
|
|
a2c3d13bc4 | ||
|
|
c576b52b97 | ||
|
|
4234cf5de6 | ||
|
|
b7cd6e97e4 | ||
|
|
03cf6c0d50 | ||
|
|
9658535f15 | ||
|
|
3c30f1519e | ||
|
|
afbf0f369f | ||
|
|
4527de78f0 | ||
|
|
0dd453e0e2 | ||
|
|
bdb7ce22c0 | ||
|
|
1c7a41ad4f | ||
|
|
42126fa5a9 | ||
|
|
9baf47cd6d | ||
|
|
f25353135d | ||
|
|
f5d2936594 | ||
|
|
8be52aee53 | ||
|
|
0306f604aa | ||
|
|
04789ecc22 | ||
|
|
c55aceb937 | ||
|
|
b8363a53f3 | ||
|
|
372acf88b1 | ||
|
|
4d7336ddec | ||
|
|
d85cf24f8a | ||
|
|
f64c146d2c | ||
|
|
a83e035ac8 | ||
|
|
520438814c | ||
|
|
f716fe50de | ||
|
|
6a7df2444d | ||
|
|
89c644a1c4 | ||
|
|
02a505e2f9 | ||
|
|
b46533d243 | ||
|
|
76ed537d61 | ||
|
|
6da998f13a | ||
|
|
c8a854b60c | ||
|
|
582211dde7 | ||
|
|
9ee4ed6ef9 | ||
|
|
ee11875459 | ||
|
|
fb973e3021 | ||
|
|
bbbef70984 | ||
|
|
ba043e09e9 | ||
|
|
84df59498a | ||
|
|
7e3f134024 | ||
|
|
1eb354a322 | ||
|
|
826c761dc6 | ||
|
|
14b2d10e17 | ||
|
|
404dd1baff | ||
|
|
19e79187fc | ||
|
|
bf9f81b80b | ||
|
|
ae1f141b63 | ||
|
|
971cee45a0 | ||
|
|
0a7e285188 | ||
|
|
0047b54f50 | ||
|
|
8692aed447 | ||
|
|
52cef043e1 | ||
|
|
5c9f4c042e | ||
|
|
47b19d1d74 | ||
|
|
1c5be170c5 | ||
|
|
a0ec451696 | ||
|
|
a97897ce6f | ||
|
|
f431951ec5 | ||
|
|
5a0389444a | ||
|
|
52d10b279f | ||
|
|
10f52c9027 | ||
|
|
5a8d0f47a3 | ||
|
|
c66ce36d70 | ||
|
|
dfb4859007 | ||
|
|
cb79552103 | ||
|
|
b2846d0a29 | ||
|
|
55844451f2 | ||
|
|
743cba7538 | ||
|
|
2fb58ebb27 | ||
|
|
a68874bc34 | ||
|
|
cc12720856 | ||
|
|
1659103ab8 | ||
|
|
22994253c5 | ||
|
|
52da7649e1 | ||
|
|
8ea3502d8e | ||
|
|
cabe9e3ae7 | ||
|
|
7af9556d59 | ||
|
|
e5ef7ea065 | ||
|
|
49f82746a8 | ||
|
|
e086cf392b | ||
|
|
8e45ffbddc | ||
|
|
7bb59fed76 | ||
|
|
a8e1665214 | ||
|
|
3c4604595a | ||
|
|
fbc4cab3a8 | ||
|
|
e8e1f3cc70 | ||
|
|
b990a087a1 | ||
|
|
84ac7568ee | ||
|
|
093f6e7e25 | ||
|
|
849ae772e2 | ||
|
|
2fd0bf4339 | ||
|
|
68ce33c34b | ||
|
|
882c730738 | ||
|
|
55ad94bbb9 | ||
|
|
54870ab8fd | ||
|
|
edc32041fd | ||
|
|
4b6c8876e7 | ||
|
|
d7e11e20c1 | ||
|
|
6cdca3af38 | ||
|
|
27b8633bc2 | ||
|
|
a035796503 | ||
|
|
7feed36dac | ||
|
|
6c7a824608 | ||
|
|
ed2c5af7e9 | ||
|
|
0c143d0491 | ||
|
|
9efce05f11 | ||
|
|
4903b1e141 | ||
|
|
5718f31fe6 | ||
|
|
860dd5bdcd | ||
|
|
6326620cd8 | ||
|
|
c6c9c19be6 | ||
|
|
ac38b8f60a | ||
|
|
91e5a4c894 | ||
|
|
385224077b | ||
|
|
a011bf7ec2 | ||
|
|
58a6a97130 | ||
|
|
71d9dd84ae | ||
|
|
eaf5875d4c | ||
|
|
caae1ac801 | ||
|
|
746d0196b3 | ||
|
|
f04f54cc82 | ||
|
|
8132325e11 | ||
|
|
57684537fa | ||
|
|
167be95bd4 | ||
|
|
6cdf53f1a4 | ||
|
|
6d06d825e5 | ||
|
|
c923e46321 | ||
|
|
b122231485 | ||
|
|
73f94402dd | ||
|
|
0800d5f33a | ||
|
|
6929931cae |
@@ -1,6 +1,12 @@
|
||||
{
|
||||
"presets": [],
|
||||
"presets": [
|
||||
["@babel/typescript", { "jsxPragma": "h" }]
|
||||
],
|
||||
"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
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
{
|
||||
"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
@@ -0,0 +1,243 @@
|
||||
{
|
||||
"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,10 +25,12 @@ 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: Grunt
|
||||
run: grunt
|
||||
- name: Build
|
||||
run: npm start
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v1
|
||||
with:
|
||||
@@ -41,7 +43,7 @@ jobs:
|
||||
mkdir keys
|
||||
echo "$VIRUS_TOTAL" > keys/virus-total.json
|
||||
- name: Check on VirusTotal
|
||||
run: grunt virustotal
|
||||
run: npm start virustotal
|
||||
if: ${{ github.repository == 'keeweb/keeweb' }}
|
||||
|
||||
linux:
|
||||
@@ -129,8 +131,6 @@ 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: Grunt
|
||||
run: grunt --max-old-space-size=4096 desktop-darwin ${{ github.repository != 'keeweb/keeweb' && '--skip-sign' || '' }}
|
||||
- name: Build
|
||||
run: npm start -- --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,8 +188,6 @@ 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 }}
|
||||
@@ -203,8 +201,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: Grunt
|
||||
run: grunt desktop-win32 ${{ github.repository != 'keeweb/keeweb' && '--skip-sign' || '' }}
|
||||
- name: Build
|
||||
run: npm start -- desktop-win32 ${{ github.repository != 'keeweb/keeweb' && '--skip-sign' || '' }}
|
||||
- name: Upload ia32 exe artifact
|
||||
uses: actions/upload-artifact@v1
|
||||
with:
|
||||
@@ -353,9 +351,9 @@ jobs:
|
||||
mkdir keys
|
||||
echo "$PRIVATE_KEY" > keys/private-key.pem
|
||||
echo "$KEEWEB_SIGN" > keys/keeweb-sign.json
|
||||
- name: Grunt
|
||||
- name: Build
|
||||
working-directory: keeweb
|
||||
run: grunt finish-release
|
||||
run: npm start finish-release
|
||||
- name: Copy signatures to assets
|
||||
run: cp keeweb/dist/desktop/Verify.sign.sha256 assets
|
||||
- name: Copy checksums to assets
|
||||
|
||||
+14
-70
@@ -1,12 +1,10 @@
|
||||
/* 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');
|
||||
@@ -93,11 +91,6 @@ 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: '**',
|
||||
@@ -105,13 +98,6 @@ 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'],
|
||||
@@ -119,13 +105,6 @@ 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'],
|
||||
@@ -148,8 +127,7 @@ 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 }
|
||||
@@ -157,8 +135,7 @@ 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 }
|
||||
@@ -229,50 +206,43 @@ 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'],
|
||||
desktop: ['desktop/**/*.js', '!desktop/node_modules/**'],
|
||||
app: ['app/scripts/**/*.js', 'app/scripts/**/*.ts'],
|
||||
test: ['test/**/*.ts'],
|
||||
desktop: ['desktop/**/*.js'],
|
||||
build: ['Gruntfile.js', 'grunt.*.js', 'build/**/*.js', 'webpack.config.js'],
|
||||
plugins: ['plugins/**/*.js'],
|
||||
util: ['util/**/*.js'],
|
||||
@@ -348,27 +318,7 @@ module.exports = function (grunt) {
|
||||
}
|
||||
},
|
||||
webpack: {
|
||||
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
|
||||
}
|
||||
app: webpackConfig.config(webpackOptions)
|
||||
},
|
||||
electron: {
|
||||
options: {
|
||||
@@ -774,12 +724,6 @@ 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 without building an installer, build the app with `grunt` and start it this way:
|
||||
To run the desktop (electron) app in development mode, start it this way:
|
||||
```bash
|
||||
npm run dev
|
||||
npm run electron
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<!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>
|
||||
+7
-8
@@ -73,13 +73,12 @@
|
||||
<link rel="stylesheet" href="css/app.css?__inline=true" />
|
||||
<script src="js/app.js?__inline=true"></script>
|
||||
</head>
|
||||
<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 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>
|
||||
</html>
|
||||
|
||||
@@ -43,7 +43,6 @@
|
||||
"dropbox": "Dropbox",
|
||||
"gdrive": "Google Drive",
|
||||
"onedrive": "OneDrive",
|
||||
"msteams": "Microsoft Teams",
|
||||
"menuAllItems": "All Items",
|
||||
"menuColors": "Colors",
|
||||
"menuTrash": "Trash",
|
||||
@@ -726,6 +725,7 @@
|
||||
"exportHtmlDate": "Export date",
|
||||
"exportGenerator": "Software",
|
||||
"exportDescription": "This file is generated with {}.",
|
||||
"exportEntries": "Entries",
|
||||
"importCsvTitle": "Import from CSV",
|
||||
"importCsvRun": "Import",
|
||||
"importIgnoreField": "Ignore",
|
||||
@@ -14,7 +14,6 @@
|
||||
"or": "ou",
|
||||
"history": "historique",
|
||||
"template": "modèle",
|
||||
"templates": "templates",
|
||||
"notImplemented": "Non Implémenté",
|
||||
"saveChanges": "Sauvegarder les modifications",
|
||||
"discardChanges": "Annuler les modifications",
|
||||
@@ -47,7 +46,6 @@
|
||||
"menuColors": "Couleurs",
|
||||
"menuTrash": "Corbeille",
|
||||
"menuSetGeneral": "Général",
|
||||
"menuSetBrowser": "Navigateur",
|
||||
"menuSetAbout": "À propos",
|
||||
"menuSetDevices": "Appareils",
|
||||
"menuAlertNoTags": "Aucun tag",
|
||||
@@ -62,7 +60,7 @@
|
||||
"sysMenuServices": "Services",
|
||||
"sysMenuHide": "Cacher {}",
|
||||
"sysMenuHideOthers": "Cacher autres",
|
||||
"sysMenuUnhide": "Montrer tout",
|
||||
"sysMenuUnhide": "Montrer tous",
|
||||
"sysMenuQuit": "Quitter {}",
|
||||
"sysMenuEdit": "Editer",
|
||||
"sysMenuUndo": "Annuler",
|
||||
@@ -82,8 +80,6 @@
|
||||
"alertCopy": "Copier",
|
||||
"alertClose": "Fermer",
|
||||
"alertDoNotAsk": "Ne plus me le redemander",
|
||||
"alertAllow": "Autoriser",
|
||||
"alertDeny": "Refuser",
|
||||
"appBeta": "ATTENTION: version beta, aperçu seulement",
|
||||
"footerOpen": "Ouvrir/Nouveau",
|
||||
"footerSyncError": "Erreur de synchronisation",
|
||||
@@ -143,7 +139,6 @@
|
||||
"keyChangeMessageExpired": "La clé maître pour cette base de donnée est expirée. Merci de saisir une nouvelle clé.",
|
||||
"keyChangeRepeatPassword": "Mot de passe, encore une fois",
|
||||
"keyEnter": "Entrée",
|
||||
"keyEsc": "Esc",
|
||||
"iconFavTitle": "Télécharger et utiliser le favicon du site web",
|
||||
"iconSelCustom": "Sélectionner une icône personnalisée",
|
||||
"listEmptyTitle": "Vide",
|
||||
@@ -272,7 +267,6 @@
|
||||
"detMore": "plus",
|
||||
"detClickToAddField": "cliquez pour ajouter un nouveau champ",
|
||||
"detMenuAddNewField": "Ajouter nouveau champ",
|
||||
"detMenuAddNewWebsite": "Ajouter un autre site",
|
||||
"detMenuShowEmpty": "Montrer les champs vides",
|
||||
"detMenuHideEmpty": "Cacher les champs vides",
|
||||
"detMenuAddField": "Ajouter {}",
|
||||
@@ -346,7 +340,6 @@
|
||||
"autoTypeSelectionHintOpt": "Entrez seulement le compte",
|
||||
"autoTypeSelectionHintShift": "Autres champs",
|
||||
"autoTypeSelectionOtp": "code à usage unique",
|
||||
"autoTypeUnlockMessage": "Déverrouiller pour saisir automatiquement",
|
||||
"appSecWarn": "Non sécurisé !",
|
||||
"appSecWarnBody1": "Vous avez chargé cette appli avec une connexion non sécurisée. Quelqu'un peut vous observer et voler vos mots de passe. Nous vous recommandons fortement d'arrêter cette connexion, à moins que vous ne compreniez exactement ce que vous faites.",
|
||||
"appSecWarnBody2": "Oui, votre base est chiffrée mais personne ne peut garantir que l'application n'a pas été modifiée avant d'arriver à vous.",
|
||||
@@ -621,38 +614,6 @@
|
||||
"setPlTranslateLink": "traduire l'appli dans votre langue",
|
||||
"setPlAutoUpdate": "Mise à jour automatique",
|
||||
"setPlLoadGallery": "Charger le catalogue",
|
||||
"setBrowserTitle": "Navigateur",
|
||||
"setBrowserIntroDesktop": "KeeWeb peut entrer des mots de passe en utilisant la saisie automatique, cependant, une extension peut être un moyen plus pratique de le faire. KeeWeb prend en charge deux extensions de navigateur:",
|
||||
"setBrowserIntroKeeWebConnect": "l'extension officielle KeeWeb est construite avec les fonctionnalités de KeeWeb, mais vous risquez de manquer certaines fonctionnalités avancées que l'on peut trouver dans l'autre extension.",
|
||||
"setBrowserIntroKeePassXcBrowser": "cette extension a été développée pour KeePassXC, elle existe depuis un moment et est assez fiable. L'extension ne fait pas partie de KeeWeb et il peut y avoir des problèmes de compatibilité.",
|
||||
"setBrowserIntroWeb": "Installez notre extension de navigateur pour remplir automatiquement les mots de passe de KeeWeb sur différentes pages. L'extension de navigateur se connectera à un onglet KeeWeb de votre navigateur pour récupérer les mots de passe. Cliquez ici pour télécharger l'extension:",
|
||||
"setBrowserNotEnabled": "L'intégration du navigateur n'est pas activée, les extensions ne pourront pas se connecter à KeeWeb. Utilisez les cases à cocher ci-dessous pour l'activer:",
|
||||
"setBrowserEnablePerBrowser": "Activer l'intégration dans le navigateur en cochant ci dessous:",
|
||||
"setBrowserFocusIfLocked": "Ouvrir KeeWeb si le navigateur essaye de se connecter alors que aucun fichier n'est ouvert",
|
||||
"setBrowserFocusIfEmpty": "Afficher la liste si aucune correspondance n'est trouvée avec l'URL",
|
||||
"setBrowserOtherBrowsers": "Autres navigateurs",
|
||||
"setBrowserExtensionFor": "pour {}",
|
||||
"setBrowserExtensionNotSupported": "Non supporté",
|
||||
"setBrowserExtensionHelp": "Comment installer ?",
|
||||
"setBrowserExtensionInstall": "Installer l'application",
|
||||
"setBrowserExtensionKPXCWarnHeader": "{} cessera de fonctionner",
|
||||
"setBrowserExtensionKPXCWarnBody1": "Malheureusement, il n'est pas possible de connecter une extension à plusieurs applications. Si vous connectez l'extension à KeeWeb, nous remplacerons son association d'application, ce qui signifie que l'intégration avec {} cessera de fonctionner. Même si vous décochez cette case, l'association avec {} ne sera pas restaurée. Pour le faire fonctionner à nouveau, configurez l'intégration du navigateur dans les paramètres de {}.",
|
||||
"setBrowserExtensionKPXCWarnBody2": "Configurer l'extension pour utiliser KeeWeb?",
|
||||
"setBrowserSessions": "Sessions",
|
||||
"setBrowserSessionsEmpty": "Aucune session connectée",
|
||||
"setBrowserSessionsIntro": "Ces extensions sont connectées à KeeWeb",
|
||||
"setBrowserSessionsActiveTooltip": "Session active",
|
||||
"setBrowserSessionsActiveText": "Cette session est active. Elle peut échanger des données avec KeeWeb selon les permissions:",
|
||||
"setBrowserSessionsInactiveTooltip": "session inactive",
|
||||
"setBrowserSessionsInactiveText": "Cette session est inactive. L'extension est connectée à KeeWeb, cependant, elle n'a pas essayé d'échanger des données. Lorsque l'extension demande quelque chose, vous pourrez choisir ce que vous souhaitez partager.",
|
||||
"setBrowserSessionsDeniedTooltip": "Accès interdit",
|
||||
"setBrowserSessionsDeniedText": "Cette session est inactive. L'extension est connectée à KeeWeb mais vous avez refusé l'accès aux données.",
|
||||
"setBrowserSessionsConnectedDate": "Connecté",
|
||||
"setBrowserSessionsTerminate": "Clôturer la session",
|
||||
"setBrowserSessionsAccessToFiles": "Autoriser l'accès aux fichiers",
|
||||
"setBrowserSessionsNoFileAccess": "L'extension n'a accès à aucun fichier, elle ne pourra pas récupérer les mots de passe de KeeWeb.",
|
||||
"setBrowserSessionsPasswordsRead": "Accès aux mot de passe",
|
||||
"setBrowserSessionsPasswordsWritten": "Mots de passe enregistrés",
|
||||
"setDevicesTitle": "Appareils",
|
||||
"setDevicesEnableUsb": "Autoriser les interactions avec les appareils USB",
|
||||
"setDevicesYubiKeyIntro": "Il est recommandé de lire {} avant d'utiliser une YubiKey.",
|
||||
@@ -687,12 +648,12 @@
|
||||
"setHelpProblems2": "ou {}",
|
||||
"setHelpOpenIssue": "faire connaître le problème sur GitHub",
|
||||
"setHelpContactLink": "contactez le développeur directement",
|
||||
"setHelpAppInfo": "Informations",
|
||||
"setHelpAppInfo": "Information",
|
||||
"setHelpOtherPlatforms": "Autres plateformes",
|
||||
"setHelpDesktopApps": "Applis Desktop",
|
||||
"setHelpWebApp": "Appli web",
|
||||
"setHelpUpdates": "Mises à jour",
|
||||
"setHelpTwitter": "Twitter",
|
||||
"setHelpTwitter": "Appli twitter",
|
||||
"dropboxSetupDesc": "Une configuration particulière est nécessaire pour utiliser Dropbox dans une application auto-hébergée. Merci de créer votre propre application Dropbox et d'inscrire sa clé ci-dessous.",
|
||||
"dropboxAppKey": "Clé Dropbox",
|
||||
"dropboxAppKeyDesc": "Copier la clé de votre appli Dropbox (Réglages développeur)",
|
||||
@@ -708,8 +669,6 @@
|
||||
"dropboxLinkFull": "Tout Dropbox ou n'importe quel dossier",
|
||||
"dropboxLinkCustom": "Votre appli Dropbox",
|
||||
"gdriveSharedWithMe": "Partagé avec moi",
|
||||
"gdriveSharedDrives": "Lecteurs partagés",
|
||||
"gdriveTeamDrives": "Team drives",
|
||||
"webdavSaveMethod": "Méthode de sauvegarde",
|
||||
"webdavSaveMove": "Envoyer un fichier temporaire et le déplacer",
|
||||
"webdavSavePut": "Écraser le fichier kdbx avec PUT",
|
||||
@@ -738,39 +697,5 @@
|
||||
"yubiKeyDisabledErrorHeader": "L'USB est désactivé",
|
||||
"yubiKeyDisabledErrorBody": "Yubikey est nécessaire pour ouvrir ce fichier, merci d'activer les appareils USB dans les paramètres",
|
||||
"yubiKeyErrorWithCode": "Erreur Yubikey code {}.",
|
||||
"bioOpenAuthPrompt": "ouvrir \"{}\"",
|
||||
"extensionErrorNoOpenFiles": "Aucun fichier ouvert",
|
||||
"extensionErrorUserRejected": "La requête a été refusée",
|
||||
"extensionErrorNoMatches": "Aucune correspondance",
|
||||
"extensionErrorAlertDisplayed": "Impossible de poser une question maintenant car une autre boîte de dialogue est affichée, veuillez réessayer",
|
||||
"extensionConnectHeader": "Échange de données avec l'extension",
|
||||
"extensionConnectIntro": "Une extension de navigateur qui a pour nom {} essaie d'échanger des données avec KeeWeb.",
|
||||
"extensionConnectUnknownActivity": "KeeWeb ne vérifie pas que l'application connectée est ce qu'elle prétend être. N'approuvez la demande que si vous en connaissez l'origine.",
|
||||
"extensionConnectFiles": "Lors de cette session, autoriser l'accès à:",
|
||||
"extensionConnectAllOtherFiles": "Tous les autres fichiers",
|
||||
"extensionConnectAllFiles": "Tous les fichiers",
|
||||
"extensionConnectAskGet": "Demandez avant d'envoyer les mots de passe à l'extension:",
|
||||
"extensionConnectAskGetMultiple": "s'il y a plus d'une correspondance",
|
||||
"extensionConnectAskGetAlways": "Toujours",
|
||||
"extensionConnectAskSave": "Demandez avant d'enregistrer de nouveaux mots de passe dans KeeWeb:",
|
||||
"extensionConnectAskSaveAlways": "Toujours",
|
||||
"extensionConnectAskSaveAuto": "quand ce n'est pas possible de sauvegarder automatiquement",
|
||||
"extensionConnectSettingsAreForSession": "Les paramètres que vous sélectionnez ici ne sont valides que pour la session en cours. Vous pouvez afficher et gérer les sessions dans les paramètres de KeeWeb.",
|
||||
"extensionUnlockMessage": "Déverrouiller pour connecter l'extension du navigateur",
|
||||
"extensionNewGroupHeader": "Nouveau groupe",
|
||||
"extensionNewGroupBody": "{} essaye de créer un nouveau groupe. Autoriser cela?",
|
||||
"extensionNewGroupPath": "Dossier du groupe",
|
||||
"extensionNewGroupFile": "Ce groupe sera créé dans:",
|
||||
"extensionSaveEntryHeader": "Sauvegarder mot de passe",
|
||||
"extensionSaveEntryBody": "{} essaye de sauvegarder un mot de passe. Autoriser cela?",
|
||||
"extensionSaveEntryAuto": "Sauvegarder les autres mots de passe automatiquement lors de cette session",
|
||||
"extensionSaveEntryNewGroup": "nouveau groupe",
|
||||
"extensionSelectPasswordFor": "Sélectionner un mot de passe pour {}",
|
||||
"selectEntryHeader": "Sélectionner une entrée",
|
||||
"selectEntryEnterHint": "Utiliser la ligne sélectionnée",
|
||||
"selectEntryTypingHint": "Ecrire pour filtrer",
|
||||
"selectEntryContains": "Contient le texte",
|
||||
"selectEntrySubdomains": "Sous-domaines",
|
||||
"selectEntryFieldHeader": "Sélectionnez un champ",
|
||||
"selectEntryFieldTouch": "Appuyez sur le bouton de votre appareil pour générer un code à usage unique."
|
||||
"bioOpenAuthPrompt": "ouvrir \"{}\""
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"rules": {
|
||||
"import/no-commonjs": "error"
|
||||
}
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
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,10 +1,6 @@
|
||||
import { Logger } from 'util/logger';
|
||||
|
||||
const logger = new Logger(
|
||||
'auto-type-obfuscator',
|
||||
undefined,
|
||||
localStorage.debugAutoType ? Logger.Level.All : Logger.Level.Warn
|
||||
);
|
||||
const logger = new Logger('auto-type-obfuscator');
|
||||
|
||||
const MaxFakeOps = 30;
|
||||
const MaxSteps = 1000;
|
||||
|
||||
@@ -3,11 +3,7 @@ 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',
|
||||
undefined,
|
||||
localStorage.debugAutoType ? Logger.Level.All : Logger.Level.Info
|
||||
);
|
||||
const emitterLogger = new Logger('auto-type-emitter');
|
||||
|
||||
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.debug('Auto type event', entry);
|
||||
logger.info('Auto type event', entry);
|
||||
if (this.running) {
|
||||
logger.debug('Already running, skipping event');
|
||||
logger.info('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.debug('Start', sequence);
|
||||
logger.info('Start', sequence);
|
||||
const ts = logger.ts();
|
||||
try {
|
||||
const parser = new AutoTypeParser(sequence);
|
||||
const runner = parser.parse();
|
||||
logger.debug('Parsed', this.printOps(runner.ops));
|
||||
logger.info('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.debug('Resolved', this.printOps(runner.ops));
|
||||
logger.info('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.debug('Obfuscated');
|
||||
logger.info('Obfuscated');
|
||||
}
|
||||
runner.run((err) => {
|
||||
this.running = false;
|
||||
@@ -112,7 +112,7 @@ const AutoType = {
|
||||
logger.error('Run error', err);
|
||||
return callback && callback(err);
|
||||
}
|
||||
logger.debug('Complete', logger.ts(ts));
|
||||
logger.info('Complete', logger.ts(ts));
|
||||
return callback && callback();
|
||||
}, windowId);
|
||||
});
|
||||
@@ -153,7 +153,7 @@ const AutoType = {
|
||||
},
|
||||
|
||||
hideWindow(callback) {
|
||||
logger.debug('Hide window');
|
||||
logger.info('Hide window');
|
||||
if (Launcher.isAppFocused()) {
|
||||
Launcher.hideApp();
|
||||
setTimeout(callback, Timeouts.AutoTypeAfterHide);
|
||||
@@ -163,7 +163,7 @@ const AutoType = {
|
||||
},
|
||||
|
||||
getActiveWindowInfo(callback) {
|
||||
logger.debug('Getting window info');
|
||||
logger.info('Getting window info');
|
||||
const helper = new AutoTypeHelper();
|
||||
return helper.getActiveWindowInfo((err, windowInfo) => {
|
||||
if (err) {
|
||||
@@ -171,11 +171,12 @@ 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.debug('Window info', windowInfo.id, windowInfo.title, windowInfo.url);
|
||||
logger.info('Window info', windowInfo.id, windowInfo.title, windowInfo.url);
|
||||
}
|
||||
return callback(err, windowInfo);
|
||||
});
|
||||
@@ -183,12 +184,12 @@ const AutoType = {
|
||||
|
||||
activeWindowMatches(windowInfo, callback) {
|
||||
if (!windowInfo || !windowInfo.id) {
|
||||
logger.debug('Skipped active window check because window id is unknown');
|
||||
logger.info('Skipped active window check because window id is unknown');
|
||||
return callback(true);
|
||||
}
|
||||
this.getActiveWindowInfo((err, activeWindowInfo) => {
|
||||
if (!activeWindowInfo) {
|
||||
logger.debug('Error during active window check, something is wrong', err);
|
||||
logger.info('Error during active window check, something is wrong', err);
|
||||
return callback(false);
|
||||
}
|
||||
if (activeWindowInfo.id !== windowInfo.id && !this.supportsEventsWithWindowId) {
|
||||
@@ -222,19 +223,19 @@ const AutoType = {
|
||||
);
|
||||
const evt = { filter, windowInfo };
|
||||
if (!AppModel.instance.files.hasOpenFiles()) {
|
||||
logger.debug('auto-type event delayed');
|
||||
logger.info('auto-type event delayed');
|
||||
this.focusMainWindow();
|
||||
try {
|
||||
await AppModel.instance.unlockAnyFile('autoTypeUnlockMessage');
|
||||
} catch {
|
||||
logger.debug('auto-type event canceled');
|
||||
logger.info('auto-type event canceled');
|
||||
return;
|
||||
}
|
||||
if (this.selectEntryView) {
|
||||
this.selectEntryView.show();
|
||||
}
|
||||
}
|
||||
logger.debug('processing auto-type event');
|
||||
logger.info('processing auto-type event');
|
||||
this.processEventWithFilter(evt);
|
||||
});
|
||||
},
|
||||
@@ -280,7 +281,7 @@ const AutoType = {
|
||||
topMessage
|
||||
});
|
||||
this.selectEntryView.on('result', (result) => {
|
||||
logger.debug('Entry selected', result);
|
||||
logger.info('Entry selected', result);
|
||||
this.selectEntryView.off('result');
|
||||
this.selectEntryView.remove();
|
||||
this.selectEntryView = null;
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Collection } from 'framework/collection';
|
||||
import { EntryModel } from 'models/entry-model';
|
||||
|
||||
class EntryCollection extends Collection {
|
||||
static model = EntryModel;
|
||||
}
|
||||
|
||||
export { EntryCollection };
|
||||
@@ -1,24 +0,0 @@
|
||||
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 };
|
||||
@@ -1,39 +0,0 @@
|
||||
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 };
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Collection } from 'framework/collection';
|
||||
import { GroupModel } from 'models/group-model';
|
||||
|
||||
class GroupCollection extends Collection {
|
||||
static model = GroupModel;
|
||||
}
|
||||
|
||||
export { GroupCollection };
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Collection } from 'framework/collection';
|
||||
import { MenuItemModel } from 'models/menu/menu-item-model';
|
||||
|
||||
class MenuItemCollection extends Collection {
|
||||
static model = MenuItemModel;
|
||||
}
|
||||
|
||||
export { MenuItemCollection };
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Collection } from 'framework/collection';
|
||||
import { MenuOptionModel } from 'models/menu/menu-option-model';
|
||||
|
||||
class MenuOptionCollection extends Collection {
|
||||
static model = MenuOptionModel;
|
||||
}
|
||||
|
||||
export { MenuOptionCollection };
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Collection } from 'framework/collection';
|
||||
import { MenuSectionModel } from 'models/menu/menu-section-model';
|
||||
|
||||
class MenuSectionCollection extends Collection {
|
||||
static model = MenuSectionModel;
|
||||
}
|
||||
|
||||
export { MenuSectionCollection };
|
||||
@@ -1,52 +0,0 @@
|
||||
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 };
|
||||
@@ -1,81 +0,0 @@
|
||||
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 };
|
||||
@@ -1,5 +1,5 @@
|
||||
const DragDropInfo = {
|
||||
dragObject: null
|
||||
dragObject: null as unknown
|
||||
};
|
||||
|
||||
export { DragDropInfo };
|
||||
@@ -1,102 +0,0 @@
|
||||
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 };
|
||||
@@ -1,29 +0,0 @@
|
||||
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 };
|
||||
@@ -0,0 +1,29 @@
|
||||
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 };
|
||||
@@ -0,0 +1,689 @@
|
||||
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 };
|
||||
@@ -1,18 +0,0 @@
|
||||
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 };
|
||||
@@ -0,0 +1,26 @@
|
||||
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 };
|
||||
+34
-29
@@ -1,11 +1,18 @@
|
||||
import { AppSettingsModel } from 'models/app-settings-model';
|
||||
import { AppSettings } from 'models/app-settings';
|
||||
import { Locale } from 'util/locale';
|
||||
import {
|
||||
PasswordGeneratorAppSetting,
|
||||
PasswordGeneratorOptions,
|
||||
PasswordGeneratorPreset,
|
||||
PasswordGeneratorCustomPreset
|
||||
} from 'util/generators/password-generator';
|
||||
|
||||
const GeneratorPresets = {
|
||||
get defaultPreset() {
|
||||
export const GeneratorPresets = {
|
||||
get defaultPreset(): PasswordGeneratorPreset {
|
||||
return {
|
||||
name: 'Default',
|
||||
title: Locale.genPresetDefault,
|
||||
builtIn: true,
|
||||
length: 16,
|
||||
upper: true,
|
||||
lower: true,
|
||||
@@ -13,7 +20,7 @@ const GeneratorPresets = {
|
||||
};
|
||||
},
|
||||
|
||||
get browserExtensionPreset() {
|
||||
get browserExtensionPreset(): PasswordGeneratorOptions {
|
||||
return {
|
||||
name: 'BrowserExtension',
|
||||
length: 20,
|
||||
@@ -25,12 +32,13 @@ const GeneratorPresets = {
|
||||
};
|
||||
},
|
||||
|
||||
get builtIn() {
|
||||
get builtIn(): PasswordGeneratorPreset[] {
|
||||
return [
|
||||
this.defaultPreset,
|
||||
{
|
||||
name: 'Pronounceable',
|
||||
title: Locale.genPresetPronounceable,
|
||||
builtIn: true,
|
||||
length: 10,
|
||||
lower: true,
|
||||
upper: true
|
||||
@@ -38,6 +46,7 @@ const GeneratorPresets = {
|
||||
{
|
||||
name: 'Med',
|
||||
title: Locale.genPresetMed,
|
||||
builtIn: true,
|
||||
length: 16,
|
||||
upper: true,
|
||||
lower: true,
|
||||
@@ -49,15 +58,17 @@ const GeneratorPresets = {
|
||||
{
|
||||
name: 'Long',
|
||||
title: Locale.genPresetLong,
|
||||
builtIn: true,
|
||||
length: 32,
|
||||
upper: true,
|
||||
lower: true,
|
||||
digits: true
|
||||
},
|
||||
{ name: 'Pin4', title: Locale.genPresetPin4, length: 4, digits: true },
|
||||
{ name: 'Pin4', title: Locale.genPresetPin4, builtIn: true, length: 4, digits: true },
|
||||
{
|
||||
name: 'Mac',
|
||||
title: Locale.genPresetMac,
|
||||
builtIn: true,
|
||||
length: 17,
|
||||
include: '0123456789ABCDEF',
|
||||
pattern: 'XX-'
|
||||
@@ -65,24 +76,23 @@ 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() {
|
||||
get all(): PasswordGeneratorPreset[] {
|
||||
let presets = this.builtIn;
|
||||
presets.forEach((preset) => {
|
||||
preset.builtIn = true;
|
||||
});
|
||||
const setting = AppSettingsModel.generatorPresets;
|
||||
const setting = AppSettings.generatorPresets;
|
||||
if (setting) {
|
||||
if (setting.user) {
|
||||
presets = presets.concat(setting.user.map((item) => ({ ...item })));
|
||||
@@ -104,7 +114,7 @@ const GeneratorPresets = {
|
||||
return presets;
|
||||
},
|
||||
|
||||
get enabled() {
|
||||
get enabled(): PasswordGeneratorPreset[] {
|
||||
const allPresets = this.all.filter((preset) => !preset.disabled);
|
||||
if (!allPresets.length) {
|
||||
allPresets.push(this.defaultPreset);
|
||||
@@ -112,15 +122,15 @@ const GeneratorPresets = {
|
||||
return allPresets;
|
||||
},
|
||||
|
||||
getOrCreateSetting() {
|
||||
let setting = AppSettingsModel.generatorPresets;
|
||||
getOrCreateSetting(): PasswordGeneratorAppSetting {
|
||||
let setting = AppSettings.generatorPresets;
|
||||
if (!setting) {
|
||||
setting = { user: [] };
|
||||
setting = { user: [], disabled: {} };
|
||||
}
|
||||
return setting;
|
||||
},
|
||||
|
||||
add(preset) {
|
||||
add(preset: PasswordGeneratorCustomPreset): void {
|
||||
const setting = this.getOrCreateSetting();
|
||||
if (preset.name && !setting.user.filter((p) => p.name === preset.name).length) {
|
||||
setting.user.push(preset);
|
||||
@@ -128,13 +138,13 @@ const GeneratorPresets = {
|
||||
}
|
||||
},
|
||||
|
||||
remove(name) {
|
||||
remove(name: string): void {
|
||||
const setting = this.getOrCreateSetting();
|
||||
setting.user = setting.user.filter((p) => p.name !== name);
|
||||
this.save(setting);
|
||||
},
|
||||
|
||||
setPreset(name, props) {
|
||||
setPreset(name: string, props: Partial<PasswordGeneratorCustomPreset>): void {
|
||||
const setting = this.getOrCreateSetting();
|
||||
const preset = setting.user.filter((p) => p.name === name)[0];
|
||||
if (preset) {
|
||||
@@ -143,22 +153,20 @@ const GeneratorPresets = {
|
||||
}
|
||||
},
|
||||
|
||||
setDisabled(name, disabled) {
|
||||
setDisabled(name: string, disabled: boolean): void {
|
||||
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) {
|
||||
setDefault(name: string | null): void {
|
||||
const setting = this.getOrCreateSetting();
|
||||
if (name) {
|
||||
setting.default = name;
|
||||
@@ -168,10 +176,7 @@ const GeneratorPresets = {
|
||||
this.save(setting);
|
||||
},
|
||||
|
||||
save(setting) {
|
||||
AppSettingsModel.set({ generatorPresets: undefined }, { silent: true });
|
||||
AppSettingsModel.generatorPresets = setting;
|
||||
save(setting: PasswordGeneratorAppSetting): void {
|
||||
AppSettings.generatorPresets = { ...setting };
|
||||
}
|
||||
};
|
||||
|
||||
export { GeneratorPresets };
|
||||
@@ -0,0 +1,63 @@
|
||||
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 };
|
||||
@@ -0,0 +1,314 @@
|
||||
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 };
|
||||
@@ -1,18 +1,18 @@
|
||||
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 { Events } from 'util/events';
|
||||
import { Shortcuts } from 'comp/browser/shortcuts';
|
||||
import { Alert, Alerts } from 'comp/ui/alerts';
|
||||
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 {
|
||||
alert = null;
|
||||
|
||||
fileInput = null;
|
||||
private _alert?: Alert;
|
||||
private _fileInput?: HTMLInputElement;
|
||||
|
||||
constructor() {
|
||||
this.pasteEvent = this.pasteEvent.bind(this);
|
||||
@@ -21,12 +21,12 @@ class OtpQrReader {
|
||||
read() {
|
||||
let screenshotKey = Shortcuts.screenshotToClipboardShortcut();
|
||||
if (screenshotKey) {
|
||||
screenshotKey = Locale.detSetupOtpAlertBodyWith.replace('{}', screenshotKey);
|
||||
screenshotKey = Locale.detSetupOtpAlertBodyWith.with(screenshotKey);
|
||||
}
|
||||
const pasteKey = Features.isMobile
|
||||
? ''
|
||||
: Locale.detSetupOtpAlertBodyWith.replace('{}', Shortcuts.actionShortcutSymbol() + 'V');
|
||||
this.startListenClipoard();
|
||||
: Locale.detSetupOtpAlertBodyWith.with(Shortcuts.actionShortcutSymbol() + 'V');
|
||||
this.startListenClipboard();
|
||||
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.replace('{}', pasteKey || '');
|
||||
this.alert = Alerts.alert({
|
||||
: Locale.detSetupOtpAlertBody3.with(pasteKey || '');
|
||||
this._alert = Alerts.alert({
|
||||
icon: 'qrcode',
|
||||
header: Locale.detSetupOtpAlert,
|
||||
body: [
|
||||
Locale.detSetupOtpAlertBody,
|
||||
Locale.detSetupOtpAlertBody1,
|
||||
Locale.detSetupOtpAlertBody2.replace('{}', screenshotKey || ''),
|
||||
Locale.detSetupOtpAlertBody2.with(screenshotKey || ''),
|
||||
line3,
|
||||
Locale.detSetupOtpAlertBody4
|
||||
].join('\n'),
|
||||
@@ -52,7 +52,7 @@ class OtpQrReader {
|
||||
enter: '',
|
||||
buttons,
|
||||
complete: (res) => {
|
||||
this.alert = null;
|
||||
this._alert = undefined;
|
||||
this.stopListenClipboard();
|
||||
if (res === 'select') {
|
||||
this.selectFile();
|
||||
@@ -64,78 +64,93 @@ 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();
|
||||
}
|
||||
|
||||
fileSelected() {
|
||||
const file = this.fileInput.files[0];
|
||||
private fileSelected() {
|
||||
const file = this._fileInput?.files?.[0];
|
||||
if (!file || file.type.indexOf('image') < 0) {
|
||||
return;
|
||||
}
|
||||
this.readFile(file);
|
||||
}
|
||||
|
||||
startListenClipoard() {
|
||||
private startListenClipboard() {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
document.addEventListener('paste', this.pasteEvent);
|
||||
}
|
||||
|
||||
stopListenClipboard() {
|
||||
private stopListenClipboard() {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
document.removeEventListener('paste', this.pasteEvent);
|
||||
}
|
||||
|
||||
pasteEvent(e) {
|
||||
const item = [...e.clipboardData.items].find(
|
||||
private pasteEvent(e: ClipboardEvent) {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) {
|
||||
logger.info('Empty clipboard data');
|
||||
return;
|
||||
}
|
||||
const item = [...items].find(
|
||||
(item) => item.kind === 'file' && item.type.indexOf('image') !== -1
|
||||
);
|
||||
if (!item) {
|
||||
logger.debug('Paste without file');
|
||||
logger.info('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
|
||||
});
|
||||
}
|
||||
this.readFile(item.getAsFile());
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
this.readFile(file);
|
||||
} else {
|
||||
logger.info('Empty file');
|
||||
}
|
||||
}
|
||||
|
||||
readFile(file) {
|
||||
private readFile(file: File) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
logger.debug('Image data loaded');
|
||||
if (typeof reader.result !== 'string') {
|
||||
logger.info('Read not a string');
|
||||
return;
|
||||
}
|
||||
logger.info('Image data loaded');
|
||||
this.readQr(reader.result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
readQr(imageData) {
|
||||
private readQr(imageData: string) {
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
logger.debug('Image format loaded');
|
||||
logger.info('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();
|
||||
try {
|
||||
const otp = Otp.parseUrl(url);
|
||||
Events.emit('qr-read', otp);
|
||||
} catch (err) {
|
||||
logger.error('Error parsing QR code', err);
|
||||
if (typeof url === 'string' && url) {
|
||||
Events.emit('qr-url-read', url);
|
||||
} else {
|
||||
logger.error('Error reading QR code');
|
||||
Alerts.error({
|
||||
header: Locale.detOtpQrWrong,
|
||||
body: Locale.detOtpQrWrongBody,
|
||||
pre: err.toString()
|
||||
body: Locale.detOtpQrWrongBody
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -148,7 +163,7 @@ class OtpQrReader {
|
||||
}
|
||||
};
|
||||
image.onerror = () => {
|
||||
logger.debug('Image load error');
|
||||
logger.info('Image load error');
|
||||
this.removeAlert();
|
||||
Alerts.error({
|
||||
header: Locale.detOtpImageError,
|
||||
@@ -158,13 +173,13 @@ class OtpQrReader {
|
||||
image.src = imageData;
|
||||
}
|
||||
|
||||
enterManually() {
|
||||
private enterManually() {
|
||||
Events.emit('qr-enter-manually');
|
||||
}
|
||||
|
||||
removeAlert() {
|
||||
if (this.alert) {
|
||||
this.alert.closeImmediate();
|
||||
private removeAlert() {
|
||||
if (this._alert) {
|
||||
this._alert.closeImmediate();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
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,306 +0,0 @@
|
||||
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 };
|
||||
@@ -0,0 +1,472 @@
|
||||
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,295 +0,0 @@
|
||||
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,17 +1,20 @@
|
||||
import { Events } from 'framework/events';
|
||||
import { Events } from 'util/events';
|
||||
import { Launcher } from 'comp/launcher';
|
||||
import { AppSettingsModel } from 'models/app-settings-model';
|
||||
import { AppSettings } from 'models/app-settings';
|
||||
|
||||
interface CopyPasteResult {
|
||||
success: boolean;
|
||||
seconds?: number;
|
||||
}
|
||||
|
||||
const CopyPaste = {
|
||||
simpleCopy: !!(Launcher && Launcher.clipboardSupported),
|
||||
|
||||
copy(text) {
|
||||
if (this.simpleCopy) {
|
||||
copy(text: string): CopyPasteResult {
|
||||
if (Launcher) {
|
||||
Launcher.setClipboardText(text);
|
||||
const clipboardSeconds = AppSettingsModel.clipboardSeconds;
|
||||
const clipboardSeconds = AppSettings.clipboardSeconds;
|
||||
if (clipboardSeconds > 0) {
|
||||
const clearClipboard = () => {
|
||||
if (Launcher.getClipboardText() === text) {
|
||||
if (Launcher?.getClipboardText() === text) {
|
||||
Launcher.clearClipboardText();
|
||||
}
|
||||
};
|
||||
@@ -28,39 +31,41 @@ const CopyPaste = {
|
||||
return { success: true };
|
||||
}
|
||||
} catch (e) {}
|
||||
return false;
|
||||
return { success: false };
|
||||
}
|
||||
},
|
||||
|
||||
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;
|
||||
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;
|
||||
hiddenInput.focus();
|
||||
hiddenInput.on({
|
||||
'copy cut paste'() {
|
||||
setTimeout(() => hiddenInput.blur(), 0);
|
||||
},
|
||||
blur() {
|
||||
hiddenInput.remove();
|
||||
}
|
||||
});
|
||||
|
||||
const onChange = () => {
|
||||
setTimeout(() => hiddenInput.blur(), 0);
|
||||
};
|
||||
hiddenInput.addEventListener('cut', onChange);
|
||||
hiddenInput.addEventListener('copy', onChange);
|
||||
hiddenInput.addEventListener('paste', onChange);
|
||||
hiddenInput.addEventListener('blur', () => hiddenInput.remove());
|
||||
},
|
||||
|
||||
copyHtml(html) {
|
||||
copyHtml(html: string): boolean {
|
||||
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);
|
||||
|
||||
+9
-6
@@ -1,9 +1,12 @@
|
||||
import { Events } from 'framework/events';
|
||||
import { Events } from 'util/events';
|
||||
import { Launcher } from 'comp/launcher';
|
||||
import { Features } from 'util/features';
|
||||
|
||||
const FocusDetector = {
|
||||
init() {
|
||||
isFocused: false,
|
||||
detectsFocusWithEvents: false,
|
||||
|
||||
init(): void {
|
||||
this.isFocused = true;
|
||||
this.detectsFocusWithEvents = !Features.isDesktop && !Features.isMobile;
|
||||
if (this.detectsFocusWithEvents) {
|
||||
@@ -22,13 +25,13 @@ const FocusDetector = {
|
||||
}
|
||||
},
|
||||
|
||||
hasFocus() {
|
||||
hasFocus(): Promise<boolean> {
|
||||
if (this.detectsFocusWithEvents) {
|
||||
return this.isFocused;
|
||||
return Promise.resolve(this.isFocused);
|
||||
} else if (Launcher) {
|
||||
return Launcher.isAppFocused();
|
||||
return Launcher.ipcRenderer.invoke('is-app-focused');
|
||||
} else {
|
||||
return true;
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,24 +1,24 @@
|
||||
import { Events } from 'framework/events';
|
||||
import { AppSettingsModel } from 'models/app-settings-model';
|
||||
import { Events } from 'util/events';
|
||||
import { AppSettings } from 'models/app-settings';
|
||||
|
||||
const IdleTracker = {
|
||||
actionTime: Date.now(),
|
||||
init() {
|
||||
init(): void {
|
||||
setInterval(this.checkIdle.bind(this), 1000 * 60);
|
||||
},
|
||||
checkIdle() {
|
||||
checkIdle(): void {
|
||||
const idleMinutes = (Date.now() - this.actionTime) / 1000 / 60;
|
||||
const maxIdleMinutes = AppSettingsModel.idleMinutes;
|
||||
const maxIdleMinutes = AppSettings.idleMinutes;
|
||||
if (maxIdleMinutes && idleMinutes > maxIdleMinutes) {
|
||||
Events.emit('before-user-idle');
|
||||
Events.emit('user-idle');
|
||||
}
|
||||
},
|
||||
regUserAction() {
|
||||
regUserAction(): void {
|
||||
this.actionTime = Date.now();
|
||||
}
|
||||
};
|
||||
|
||||
Events.on('power-monitor-resume', () => IdleTracker.checkIdle);
|
||||
Events.on('power-monitor-resume', () => IdleTracker.checkIdle());
|
||||
|
||||
export { IdleTracker };
|
||||
@@ -1,62 +1,77 @@
|
||||
import { Events } from 'framework/events';
|
||||
import { Events } from 'util/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 = {};
|
||||
shortcuts = new Map<Keys, ShortcutDef[]>();
|
||||
|
||||
init() {
|
||||
$(document).bind('keypress', this.keypress.bind(this));
|
||||
$(document).bind('keydown', this.keydown.bind(this));
|
||||
document.addEventListener('keypress', (e) => this.keypress(e));
|
||||
document.addEventListener('keydown', (e) => this.keydown(e));
|
||||
|
||||
this.shortcuts[Keys.DOM_VK_A] = [
|
||||
this.shortcuts.set(Keys.DOM_VK_A, [
|
||||
{
|
||||
handler: this.handleAKey,
|
||||
thisArg: this,
|
||||
handler: (e) => this.handleAKey(e),
|
||||
shortcut: this.SHORTCUT_ACTION,
|
||||
modal: true,
|
||||
modal: '*',
|
||||
noPrevent: true
|
||||
}
|
||||
];
|
||||
]);
|
||||
}
|
||||
|
||||
onKey(key, handler, thisArg, shortcut, modal, noPrevent) {
|
||||
let keyShortcuts = this.shortcuts[key];
|
||||
onKey(
|
||||
key: Keys,
|
||||
handler: (e: KeyboardEvent) => void,
|
||||
shortcut?: number,
|
||||
modal?: string,
|
||||
noPrevent?: boolean
|
||||
): () => void {
|
||||
let keyShortcuts = this.shortcuts.get(key);
|
||||
if (!keyShortcuts) {
|
||||
this.shortcuts[key] = keyShortcuts = [];
|
||||
keyShortcuts = [];
|
||||
this.shortcuts.set(key, keyShortcuts);
|
||||
}
|
||||
keyShortcuts.push({
|
||||
const def: ShortcutDef = {
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
offKey(key, handler, thisArg) {
|
||||
if (this.shortcuts[key]) {
|
||||
this.shortcuts[key] = this.shortcuts[key].filter(
|
||||
(sh) => sh.handler !== handler || sh.thisArg !== thisArg
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
isActionKey(e) {
|
||||
isActionKey(e: KeyboardEvent): boolean {
|
||||
return e[shortcutKeyProp];
|
||||
}
|
||||
|
||||
keydown(e) {
|
||||
keydown(e: KeyboardEvent): void {
|
||||
IdleTracker.regUserAction();
|
||||
const code = e.keyCode || e.which;
|
||||
const keyShortcuts = this.shortcuts[code];
|
||||
const keyShortcuts = this.shortcuts.get(code);
|
||||
if (keyShortcuts && keyShortcuts.length) {
|
||||
for (const sh of keyShortcuts) {
|
||||
if (FocusManager.modal && sh.modal !== FocusManager.modal && sh.modal !== '*') {
|
||||
@@ -91,18 +106,23 @@ class KeyHandler {
|
||||
}
|
||||
break;
|
||||
}
|
||||
sh.handler.call(sh.thisArg, e, code);
|
||||
let immediatePropagationStopped = false;
|
||||
e.stopImmediatePropagation = () => {
|
||||
immediatePropagationStopped = true;
|
||||
KeyboardEvent.prototype.stopImmediatePropagation.call(e);
|
||||
};
|
||||
sh.handler(e, code);
|
||||
if (isActionKey && !sh.noPrevent) {
|
||||
e.preventDefault();
|
||||
}
|
||||
if (e.isImmediatePropagationStopped()) {
|
||||
if (immediatePropagationStopped) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
keypress(e) {
|
||||
keypress(e: KeyboardEvent): void {
|
||||
if (
|
||||
!FocusManager.modal &&
|
||||
e.which !== Keys.DOM_VK_RETURN &&
|
||||
@@ -114,7 +134,7 @@ class KeyHandler {
|
||||
) {
|
||||
Events.emit('keypress', e);
|
||||
} else if (FocusManager.modal) {
|
||||
Events.emit('keypress:' + FocusManager.modal, e);
|
||||
Events.emit('keypress-modal', e, FocusManager.modal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,11 +142,8 @@ class KeyHandler {
|
||||
IdleTracker.regUserAction();
|
||||
}
|
||||
|
||||
handleAKey(e) {
|
||||
if (
|
||||
e.target.tagName.toLowerCase() === 'input' &&
|
||||
['password', 'text'].indexOf(e.target.type) >= 0
|
||||
) {
|
||||
handleAKey(e: KeyboardEvent): void {
|
||||
if (e.target instanceof HTMLInputElement && ['password', 'text'].includes(e.target.type)) {
|
||||
e.stopImmediatePropagation();
|
||||
} else {
|
||||
e.preventDefault();
|
||||
+13
-21
@@ -1,19 +1,13 @@
|
||||
import { Events } from 'framework/events';
|
||||
import { Events } from 'util/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 = {
|
||||
logger: null,
|
||||
|
||||
init() {
|
||||
this.logger = new Logger('popup-notifier');
|
||||
|
||||
init(): void {
|
||||
if (Launcher) {
|
||||
window.open = noop;
|
||||
window.open = () => null;
|
||||
} else {
|
||||
const windowOpen = window.open;
|
||||
window.open = function (...args) {
|
||||
@@ -21,24 +15,22 @@ 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) {
|
||||
deferCheckClosed(win: Window): void {
|
||||
setTimeout(PopupNotifier.checkClosed.bind(PopupNotifier, win), Timeouts.CheckWindowClosed);
|
||||
},
|
||||
|
||||
checkClosed(win) {
|
||||
checkClosed(win: Window): void {
|
||||
if (win.closed) {
|
||||
setTimeout(
|
||||
PopupNotifier.triggerClosed.bind(PopupNotifier, win),
|
||||
@@ -57,7 +49,7 @@ const PopupNotifier = {
|
||||
}
|
||||
},
|
||||
|
||||
tryGetLocationSearch(win) {
|
||||
tryGetLocationSearch(win: Window): string | undefined {
|
||||
try {
|
||||
if (win.location.host === location.host) {
|
||||
return win.location.search;
|
||||
@@ -65,8 +57,8 @@ const PopupNotifier = {
|
||||
} catch {}
|
||||
},
|
||||
|
||||
triggerClosed(window, locationSearch) {
|
||||
Events.emit('popup-closed', { window, locationSearch });
|
||||
triggerClosed(window: Window, locationSearch?: string): void {
|
||||
Events.emit('popup-closed', window, locationSearch);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import * as kdbxweb from 'kdbxweb';
|
||||
|
||||
const SecureInput = function () {
|
||||
this.el = null;
|
||||
this.minChar = 0x1400 + Math.round(Math.random() * 100);
|
||||
this.maxLen = 1024;
|
||||
this.length = 0;
|
||||
this.pseudoValue = '';
|
||||
this.salt = new Uint32Array(0);
|
||||
};
|
||||
|
||||
SecureInput.prototype.setElement = function (el) {
|
||||
this.el = el;
|
||||
this.el.val(this.pseudoValue);
|
||||
this.el.on('input', this._input.bind(this));
|
||||
};
|
||||
|
||||
SecureInput.prototype.reset = function () {
|
||||
this.el = null;
|
||||
this.length = 0;
|
||||
this.pseudoValue = '';
|
||||
|
||||
if (this.salt) {
|
||||
for (let i = 0; i < this.salt.length; i++) {
|
||||
this.salt[i] = 0;
|
||||
}
|
||||
}
|
||||
this.salt = new Uint32Array(0);
|
||||
};
|
||||
|
||||
SecureInput.prototype._input = function () {
|
||||
const selStart = this.el[0].selectionStart;
|
||||
const value = this.el.val();
|
||||
let newPs = '';
|
||||
const newSalt = new Uint32Array(this.maxLen);
|
||||
let valIx = 0,
|
||||
psIx = 0;
|
||||
while (valIx < value.length) {
|
||||
const valCh = value.charCodeAt(valIx);
|
||||
const psCh = this.pseudoValue.charCodeAt(psIx);
|
||||
const isSpecial = this._isSpecialChar(valCh);
|
||||
if (psCh === valCh) {
|
||||
// not changed
|
||||
newPs += this._getChar(newPs.length);
|
||||
newSalt[newPs.length - 1] = psCh ^ this.salt[psIx] ^ newPs.charCodeAt(newPs.length - 1);
|
||||
psIx++;
|
||||
valIx++;
|
||||
} else if (isSpecial) {
|
||||
// deleted
|
||||
psIx++;
|
||||
} else {
|
||||
// inserted or replaced
|
||||
newPs += this._getChar(newPs.length);
|
||||
newSalt[newPs.length - 1] = newPs.charCodeAt(newPs.length - 1) ^ valCh;
|
||||
valIx++;
|
||||
}
|
||||
}
|
||||
this.length = newPs.length;
|
||||
this.pseudoValue = newPs;
|
||||
this.salt = newSalt;
|
||||
this.el.val(newPs);
|
||||
this.el[0].selectionStart = selStart;
|
||||
this.el[0].selectionEnd = selStart;
|
||||
};
|
||||
|
||||
SecureInput.prototype._getChar = function (ix) {
|
||||
return String.fromCharCode(this.minChar + ix);
|
||||
};
|
||||
|
||||
SecureInput.prototype._isSpecialChar = function (ch) {
|
||||
return ch >= this.minChar && ch <= this.minChar + this.maxLen;
|
||||
};
|
||||
|
||||
Object.defineProperty(SecureInput.prototype, 'value', {
|
||||
enumerable: true,
|
||||
get() {
|
||||
const pseudoValue = this.pseudoValue;
|
||||
const salt = this.salt;
|
||||
const len = pseudoValue.length;
|
||||
let byteLength = 0;
|
||||
const valueBytes = new Uint8Array(len * 4);
|
||||
const saltBytes = kdbxweb.CryptoEngine.random(len * 4);
|
||||
let ch;
|
||||
let bytes;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const pseudoCharCode = pseudoValue.charCodeAt(i);
|
||||
ch = String.fromCharCode(salt[i] ^ pseudoCharCode);
|
||||
bytes = kdbxweb.ByteUtils.stringToBytes(ch);
|
||||
for (let j = 0; j < bytes.length; j++) {
|
||||
valueBytes[byteLength] = bytes[j] ^ saltBytes[byteLength];
|
||||
byteLength++;
|
||||
}
|
||||
}
|
||||
return new kdbxweb.ProtectedValue(
|
||||
valueBytes.buffer.slice(0, byteLength),
|
||||
saltBytes.buffer.slice(0, byteLength)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export { SecureInput };
|
||||
@@ -1,36 +1,49 @@
|
||||
import { Launcher } from 'comp/launcher';
|
||||
import { Keys } from 'const/keys';
|
||||
import { AppSettingsModel } from 'models/app-settings-model';
|
||||
import { AppSettingsFieldName, AppSettings } from 'models/app-settings';
|
||||
import { Features } from 'util/features';
|
||||
import { StringFormat } from 'util/formatting/string-format';
|
||||
import { Locale } from 'util/locale';
|
||||
|
||||
let allowedKeys;
|
||||
let allowedKeys: Map<number, string>;
|
||||
|
||||
function getAllowedKeys() {
|
||||
function getAllowedKeys(): Map<number, string> {
|
||||
if (!allowedKeys) {
|
||||
allowedKeys = {};
|
||||
allowedKeys = new Map<number, string>();
|
||||
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[code] = keyName;
|
||||
allowedKeys.set(code, keyName);
|
||||
}
|
||||
}
|
||||
}
|
||||
return allowedKeys;
|
||||
}
|
||||
|
||||
const globalShortcuts = {
|
||||
const AllGlobalShortcuts = {
|
||||
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: {},
|
||||
copyOtp: undefined,
|
||||
autoType: { mac: 'Ctrl+Alt+T', all: 'Shift+Alt+T' },
|
||||
restoreApp: {}
|
||||
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'
|
||||
};
|
||||
|
||||
const Shortcuts = {
|
||||
keyEventToShortcut(event) {
|
||||
keyEventToShortcut(event: KeyboardEvent): { valid: boolean; value: string } {
|
||||
const modifiers = [];
|
||||
if (event.ctrlKey) {
|
||||
modifiers.push('Ctrl');
|
||||
@@ -44,13 +57,14 @@ const Shortcuts = {
|
||||
if (Features.isMac && event.metaKey) {
|
||||
modifiers.push('Meta');
|
||||
}
|
||||
const keyName = getAllowedKeys()[event.which];
|
||||
const keyName = getAllowedKeys().get(event.which);
|
||||
return {
|
||||
value: modifiers.join('+') + '+' + (keyName || '…'),
|
||||
valid: modifiers.length > 0 && !!keyName
|
||||
};
|
||||
},
|
||||
presentShortcut(shortcutValue, formatting) {
|
||||
|
||||
presentShortcut(shortcutValue: string | undefined, formatting?: boolean): string {
|
||||
if (!shortcutValue) {
|
||||
return '-';
|
||||
}
|
||||
@@ -72,30 +86,38 @@ const Shortcuts = {
|
||||
})
|
||||
.join('');
|
||||
},
|
||||
actionShortcutSymbol(formatting) {
|
||||
|
||||
actionShortcutSymbol(formatting?: boolean): string {
|
||||
return Features.isMac ? '⌘' : this.formatShortcut(Locale.ctrlKey, formatting);
|
||||
},
|
||||
altShortcutSymbol(formatting) {
|
||||
|
||||
altShortcutSymbol(formatting?: boolean): string {
|
||||
return Features.isMac ? '⌥' : this.formatShortcut(Locale.altKey, formatting);
|
||||
},
|
||||
shiftShortcutSymbol(formatting) {
|
||||
|
||||
shiftShortcutSymbol(formatting?: boolean): string {
|
||||
return Features.isMac ? '⇧' : this.formatShortcut(Locale.shiftKey, formatting);
|
||||
},
|
||||
ctrlShortcutSymbol(formatting) {
|
||||
|
||||
ctrlShortcutSymbol(formatting?: boolean): string {
|
||||
return Features.isMac ? '⌃' : this.formatShortcut(Locale.ctrlKey, formatting);
|
||||
},
|
||||
formatShortcut(shortcut, formatting) {
|
||||
|
||||
formatShortcut(shortcut: string, formatting?: boolean): string {
|
||||
return formatting ? `${shortcut} + ` : `${shortcut}+`;
|
||||
},
|
||||
globalShortcutText(type, formatting) {
|
||||
|
||||
globalShortcutText(type: GlobalShortcutType, formatting?: boolean): string {
|
||||
return this.presentShortcut(this.globalShortcut(type), formatting);
|
||||
},
|
||||
globalShortcut(type) {
|
||||
const appSettingsShortcut = AppSettingsModel[this.globalShortcutAppSettingsKey(type)];
|
||||
if (appSettingsShortcut) {
|
||||
|
||||
globalShortcut(type: GlobalShortcutType): string | undefined {
|
||||
const setting = GlobalShortcutAppSettingsFields[type];
|
||||
const appSettingsShortcut = AppSettings[setting];
|
||||
if (typeof appSettingsShortcut === 'string') {
|
||||
return appSettingsShortcut;
|
||||
}
|
||||
const globalShortcut = globalShortcuts[type];
|
||||
const globalShortcut = AllGlobalShortcuts[type];
|
||||
if (globalShortcut) {
|
||||
if (Features.isMac && globalShortcut.mac) {
|
||||
return globalShortcut.mac;
|
||||
@@ -104,21 +126,21 @@ const Shortcuts = {
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
setGlobalShortcut(type, value) {
|
||||
if (!globalShortcuts[type]) {
|
||||
|
||||
setGlobalShortcut(type: GlobalShortcutType, value: string | null): void {
|
||||
if (!AllGlobalShortcuts[type]) {
|
||||
throw new Error('Bad shortcut: ' + type);
|
||||
}
|
||||
const setting = GlobalShortcutAppSettingsFields[type];
|
||||
if (value) {
|
||||
AppSettingsModel[this.globalShortcutAppSettingsKey(type)] = value;
|
||||
AppSettings.set(setting, value);
|
||||
} else {
|
||||
delete AppSettingsModel[this.globalShortcutAppSettingsKey(type)];
|
||||
AppSettings.delete(setting);
|
||||
}
|
||||
Launcher.setGlobalShortcuts(AppSettingsModel);
|
||||
Launcher?.ipcRenderer.invoke('set-global-shortcuts', { [setting]: value });
|
||||
},
|
||||
globalShortcutAppSettingsKey(type) {
|
||||
return 'globalShortcut' + StringFormat.capFirst(type);
|
||||
},
|
||||
screenshotToClipboardShortcut() {
|
||||
|
||||
screenshotToClipboardShortcut(): string {
|
||||
if (Features.isiOS) {
|
||||
return 'Sleep+Home';
|
||||
}
|
||||
+6
-4
@@ -1,4 +1,4 @@
|
||||
import { Events } from 'framework/events';
|
||||
import { Events } from 'util/events';
|
||||
import { Launcher } from 'comp/launcher';
|
||||
|
||||
const LocalStorageKeyName = 'instanceCheck';
|
||||
@@ -7,15 +7,16 @@ const LocalStorageResponseKeyName = 'instanceMaster';
|
||||
const instanceKey = Date.now().toString();
|
||||
|
||||
const SingleInstanceChecker = {
|
||||
init() {
|
||||
init(): void {
|
||||
if (Launcher) {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
window.addEventListener('storage', SingleInstanceChecker.storageChanged);
|
||||
SingleInstanceChecker.setKey(LocalStorageKeyName, instanceKey);
|
||||
},
|
||||
|
||||
storageChanged(e) {
|
||||
storageChanged(e: StorageEvent): void {
|
||||
if (!e.newValue) {
|
||||
return;
|
||||
}
|
||||
@@ -25,12 +26,13 @@ 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, value) {
|
||||
setKey(key: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
setTimeout(() => {
|
||||
+4
-4
@@ -1,18 +1,18 @@
|
||||
import { Events } from 'framework/events';
|
||||
import { Events } from 'util/events';
|
||||
|
||||
const ThemeWatcher = {
|
||||
dark: false,
|
||||
|
||||
init() {
|
||||
init(): void {
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
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 };
|
||||
@@ -0,0 +1,42 @@
|
||||
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}`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -29,12 +29,12 @@ const ChalRespCalculator = {
|
||||
const cacheKey = this.getCacheKey(params);
|
||||
const respFromCache = this.cache[cacheKey]?.[hexChallenge];
|
||||
if (respFromCache) {
|
||||
logger.debug('Found ChalResp in cache');
|
||||
logger.info('Found ChalResp in cache');
|
||||
return resolve(Buffer.from(respFromCache, 'hex'));
|
||||
}
|
||||
|
||||
if (!AppSettingsModel.enableUsb) {
|
||||
logger.debug('USB is disabled');
|
||||
logger.info('USB is disabled');
|
||||
Alerts.error({
|
||||
header: Locale.yubiKeyDisabledErrorHeader,
|
||||
body: Locale.yubiKeyDisabledErrorBody,
|
||||
@@ -49,7 +49,7 @@ const ChalRespCalculator = {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('Calculating ChalResp using a YubiKey', params);
|
||||
logger.info('Calculating ChalResp using a YubiKey', params);
|
||||
|
||||
this._calc(params, challenge, (err, response) => {
|
||||
if (err) {
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Events } from 'framework/events';
|
||||
import { Events } from 'util/events';
|
||||
import { Logger } from 'util/logger';
|
||||
import { NativeModules } from 'comp/launcher/native-modules';
|
||||
import { AppSettingsModel } from 'models/app-settings-model';
|
||||
import { AppSettings } from 'models/app-settings';
|
||||
import { Features } from 'util/features';
|
||||
import { noop } from 'util/fn';
|
||||
|
||||
const logger = new Logger('usb-listener');
|
||||
|
||||
@@ -10,51 +11,47 @@ const UsbListener = {
|
||||
supported: Features.isDesktop,
|
||||
attachedYubiKeys: 0,
|
||||
|
||||
init() {
|
||||
init(): void {
|
||||
if (!this.supported) {
|
||||
return;
|
||||
}
|
||||
|
||||
Events.on('native-modules-yubikeys', (e) => {
|
||||
if (e.numYubiKeys !== this.attachedYubiKeys) {
|
||||
logger.debug(`YubiKeys changed ${this.attachedYubiKeys} => ${e.numYubiKeys}`);
|
||||
this.attachedYubiKeys = e.numYubiKeys;
|
||||
Events.on('native-modules-yubikeys', (numYubiKeys) => {
|
||||
if (numYubiKeys !== this.attachedYubiKeys) {
|
||||
logger.info(`YubiKeys changed ${this.attachedYubiKeys} => ${numYubiKeys}`);
|
||||
this.attachedYubiKeys = numYubiKeys;
|
||||
Events.emit('usb-devices-changed');
|
||||
}
|
||||
});
|
||||
|
||||
AppSettingsModel.on('change:enableUsb', (model, enabled) => {
|
||||
AppSettings.onChange('enableUsb', (enabled) => {
|
||||
if (enabled) {
|
||||
this.start();
|
||||
this.start().catch(noop);
|
||||
} else {
|
||||
this.stop();
|
||||
this.stop().catch(noop);
|
||||
}
|
||||
});
|
||||
|
||||
if (AppSettingsModel.enableUsb) {
|
||||
this.start();
|
||||
if (AppSettings.enableUsb) {
|
||||
this.start().catch(noop);
|
||||
}
|
||||
},
|
||||
|
||||
start() {
|
||||
async start(): Promise<void> {
|
||||
logger.info('Starting USB listener');
|
||||
|
||||
if (this.usb) {
|
||||
this.stop();
|
||||
}
|
||||
|
||||
try {
|
||||
NativeModules.startUsbListener();
|
||||
await NativeModules.startUsbListener();
|
||||
} catch (e) {
|
||||
logger.error('Error starting USB listener', e);
|
||||
}
|
||||
},
|
||||
|
||||
stop() {
|
||||
async stop(): Promise<void> {
|
||||
logger.info('Stopping USB listener');
|
||||
|
||||
try {
|
||||
NativeModules.stopUsbListener();
|
||||
await NativeModules.stopUsbListener();
|
||||
} catch (e) {
|
||||
logger.error('Error stopping USB listener', e);
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
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 };
|
||||
@@ -1,119 +0,0 @@
|
||||
/* 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 };
|
||||
@@ -0,0 +1,136 @@
|
||||
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 };
|
||||
@@ -0,0 +1,308 @@
|
||||
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 };
|
||||
@@ -0,0 +1,76 @@
|
||||
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
@@ -0,0 +1 @@
|
||||
../../../../desktop/desktop-ipc.d.ts
|
||||
@@ -1,7 +0,0 @@
|
||||
let Launcher;
|
||||
|
||||
if (window.process && window.process.versions && window.process.versions.electron) {
|
||||
Launcher = require('./launcher-electron').Launcher;
|
||||
}
|
||||
|
||||
export { Launcher };
|
||||
@@ -0,0 +1,9 @@
|
||||
import { LauncherElectron } from './launcher-electron';
|
||||
|
||||
let Launcher: LauncherElectron | undefined;
|
||||
|
||||
if (global.process?.versions?.electron) {
|
||||
Launcher = new LauncherElectron();
|
||||
}
|
||||
|
||||
export { Launcher };
|
||||
@@ -1,330 +0,0 @@
|
||||
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 };
|
||||
@@ -0,0 +1,350 @@
|
||||
// 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);
|
||||
// });
|
||||
@@ -1,250 +0,0 @@
|
||||
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 };
|
||||
@@ -0,0 +1,268 @@
|
||||
// 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 };
|
||||
@@ -0,0 +1,196 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
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 };
|
||||
@@ -1,193 +0,0 @@
|
||||
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 };
|
||||
@@ -0,0 +1,213 @@
|
||||
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 };
|
||||
@@ -1,38 +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 = {
|
||||
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 };
|
||||
@@ -0,0 +1,46 @@
|
||||
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 };
|
||||
@@ -1,122 +0,0 @@
|
||||
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 };
|
||||
@@ -0,0 +1,305 @@
|
||||
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,12 +4,14 @@
|
||||
// which you embed in the source code of your application.
|
||||
// (In this context, the client secret is obviously not treated as a secret.)
|
||||
|
||||
const DropboxApps = {
|
||||
export const DropboxApps = {
|
||||
AppFolder: { id: 'qp7ctun6qt5n9d6', secret: '07s5r4ck1uvlj6a' },
|
||||
FullDropbox: { id: 'eor7hvv6u6oslq9', secret: 'ez04o1iwf6yprq3' }
|
||||
};
|
||||
} as const;
|
||||
|
||||
const GDriveApps = {
|
||||
export const DropboxChooserAppKey = 'qp7ctun6qt5n9d6';
|
||||
|
||||
export const GDriveApps = {
|
||||
Local: {
|
||||
id: '783608538594-36tkdh8iscrq8t8dq87gghubnhivhjp5.apps.googleusercontent.com',
|
||||
secret: 'yAtyfc9TIQ9GyQgQmo3i0HAP'
|
||||
@@ -22,32 +24,19 @@ const GDriveApps = {
|
||||
id: '847548101761-h2pcl2p6m1tssnlqm0vrm33crlveccbr.apps.googleusercontent.com',
|
||||
secret: 'nTSCiqXtUNmURIIdASaC1TJK'
|
||||
}
|
||||
};
|
||||
} as const;
|
||||
|
||||
const OneDriveApps = {
|
||||
export const OneDriveApps = {
|
||||
Local: {
|
||||
id: 'b97c53d5-db5b-4124-aab9-d39195293815'
|
||||
id: 'b97c53d5-db5b-4124-aab9-d39195293815',
|
||||
secret: null
|
||||
},
|
||||
Production: {
|
||||
id: 'bbc74d1b-3a9c-46e6-9da4-4c645e830923'
|
||||
id: 'bbc74d1b-3a9c-46e6-9da4-4c645e830923',
|
||||
secret: null
|
||||
},
|
||||
Desktop: {
|
||||
id: 'bbc74d1b-3a9c-46e6-9da4-4c645e830923',
|
||||
secret: 'aOMJaktJEAs_Tmh]fx4iQ[Zd3mp3KK7-'
|
||||
}
|
||||
};
|
||||
|
||||
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 };
|
||||
} as const;
|
||||
@@ -1,5 +1,5 @@
|
||||
const Colors = {
|
||||
AllColors: ['yellow', 'green', 'red', 'orange', 'blue', 'violet'],
|
||||
export const Colors = {
|
||||
AllColors: ['yellow', 'green', 'red', 'orange', 'blue', 'violet'] as const,
|
||||
|
||||
ColorsValues: {
|
||||
yellow: 'ffff00',
|
||||
@@ -8,7 +8,7 @@ const Colors = {
|
||||
orange: 'ff8800',
|
||||
blue: '0000ff',
|
||||
violet: 'ff00ff'
|
||||
},
|
||||
} as Record<string, string>,
|
||||
|
||||
BgColors: {
|
||||
yellow: 'ffff88',
|
||||
@@ -17,7 +17,5 @@ const Colors = {
|
||||
orange: 'ffcc88',
|
||||
blue: '8888ff',
|
||||
violet: 'ff88ff'
|
||||
}
|
||||
};
|
||||
|
||||
export { Colors };
|
||||
} as Record<string, string>
|
||||
} as const;
|
||||
@@ -1,99 +0,0 @@
|
||||
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 @@
|
||||
const BuiltInFields = [
|
||||
export const BuiltInFields = [
|
||||
'Title',
|
||||
'Password',
|
||||
'UserName',
|
||||
@@ -8,5 +8,3 @@ const BuiltInFields = [
|
||||
'TOTP Settings',
|
||||
'_etm_template_uuid'
|
||||
];
|
||||
|
||||
export { BuiltInFields };
|
||||
@@ -1,14 +1,12 @@
|
||||
// https://support.yubico.com/support/solutions/articles/15000028104-yubikey-usb-id-values
|
||||
// https://github.com/Yubico/yubikey-personalization/blob/master/ykcore/ykdef.h#L274
|
||||
|
||||
const YubiKeyVendorId = 0x1050;
|
||||
export const YubiKeyVendorId = 0x1050;
|
||||
|
||||
const YubiKeyProductIds = {
|
||||
export const YubiKeyProductIds = {
|
||||
Gen1: [0x0010],
|
||||
NEO: [0x0110, 0x0112, 0x0113, 0x0114, 0x0115, 0x0116],
|
||||
YK4: [0x0401, 0x0402, 0x0403, 0x0404, 0x0405, 0x0406, 0x0407]
|
||||
};
|
||||
|
||||
const YubiKeyChallengeSize = 64;
|
||||
|
||||
export { YubiKeyVendorId, YubiKeyProductIds, YubiKeyChallengeSize };
|
||||
export const YubiKeyChallengeSize = 64;
|
||||
@@ -1,4 +1,4 @@
|
||||
const IconMap = [
|
||||
export const IconMap = [
|
||||
'key',
|
||||
'globe',
|
||||
'exclamation-triangle',
|
||||
@@ -69,5 +69,3 @@ const IconMap = [
|
||||
'signature',
|
||||
'mobile'
|
||||
];
|
||||
|
||||
export { IconMap };
|
||||
@@ -1,119 +0,0 @@
|
||||
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 };
|
||||
@@ -0,0 +1,117 @@
|
||||
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
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
const KnownAppVersions = {
|
||||
KeePassXC: '2.6.4'
|
||||
};
|
||||
|
||||
export { KnownAppVersions };
|
||||
@@ -0,0 +1,3 @@
|
||||
export const KnownAppVersions = {
|
||||
KeePassXC: '2.6.4'
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
const Links = {
|
||||
export const Links = {
|
||||
Homepage: 'https://keeweb.info',
|
||||
Repo: 'https://github.com/keeweb/keeweb',
|
||||
Desktop: 'https://github.com/keeweb/keeweb/releases/latest',
|
||||
@@ -23,14 +23,15 @@ 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'
|
||||
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'
|
||||
};
|
||||
|
||||
export { Links };
|
||||
@@ -1,4 +1,4 @@
|
||||
const RuntimeInfo = {
|
||||
export const RuntimeInfo = {
|
||||
version: '@@VERSION',
|
||||
beta: !!'@@BETA',
|
||||
buildDate: '@@DATE',
|
||||
@@ -6,5 +6,3 @@ const RuntimeInfo = {
|
||||
devMode: '@@DEVMODE',
|
||||
appleTeamId: '@@APPLE_TEAM_ID'
|
||||
};
|
||||
|
||||
export { RuntimeInfo };
|
||||
@@ -1,5 +1,8 @@
|
||||
const Timeouts = {
|
||||
export const Timeouts = {
|
||||
ShowTip: 200,
|
||||
HideTip: 500,
|
||||
CopyTip: 1500,
|
||||
HideAlert: 150,
|
||||
AutoHideHint: 3000,
|
||||
FileChangeSync: 3000,
|
||||
BeforeAutoLock: 300,
|
||||
@@ -19,7 +22,13 @@ const Timeouts = {
|
||||
NativeModuleHostRestartTime: 3000,
|
||||
FastAnimation: 100,
|
||||
AutoTypeCopyPaste: 300,
|
||||
KeeWebConnectRequest: 60000
|
||||
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
|
||||
};
|
||||
|
||||
export { Timeouts };
|
||||
+2
-2
@@ -143,7 +143,7 @@ const BrowserExtensionConnector = {
|
||||
if (e?.data?.kwConnect !== 'request') {
|
||||
return;
|
||||
}
|
||||
logger.debug('Extension -> KeeWeb', e.data);
|
||||
logger.info('Extension -> KeeWeb', e.data);
|
||||
pendingBrowserMessages.push(e.data);
|
||||
this.processBrowserMessages();
|
||||
},
|
||||
@@ -173,7 +173,7 @@ const BrowserExtensionConnector = {
|
||||
},
|
||||
|
||||
sendWebResponse(response) {
|
||||
logger.debug('KeeWeb -> Extension', response);
|
||||
logger.info('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.debug('Extension -> KeeWeb -> (decrypted)', payload);
|
||||
logger.info('Extension -> KeeWeb -> (decrypted)', payload);
|
||||
|
||||
if (!payload) {
|
||||
throw new Error('Empty request payload');
|
||||
@@ -115,7 +115,7 @@ function decryptRequest(request) {
|
||||
}
|
||||
|
||||
function encryptResponse(request, payload) {
|
||||
logger.debug('KeeWeb -> Extension (decrypted)', payload);
|
||||
logger.info('KeeWeb -> Extension (decrypted)', payload);
|
||||
|
||||
const nonceBytes = kdbxweb.ByteUtils.base64ToBytes(request.nonce);
|
||||
incrementNonce(nonceBytes);
|
||||
@@ -280,7 +280,7 @@ function getHumanReadableExtensionName(client) {
|
||||
}
|
||||
|
||||
function focusKeeWeb() {
|
||||
logger.debug('Focus KeeWeb');
|
||||
logger.info('Focus KeeWeb');
|
||||
if (Launcher) {
|
||||
if (!Launcher.isAppFocused()) {
|
||||
Launcher.showMainWindow();
|
||||
@@ -1,251 +0,0 @@
|
||||
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 };
|
||||
@@ -1,12 +0,0 @@
|
||||
import EventEmitter from 'events';
|
||||
|
||||
class Events extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.setMaxListeners(1000);
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new Events();
|
||||
|
||||
export { instance as Events };
|
||||
@@ -1,126 +0,0 @@
|
||||
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 };
|
||||
@@ -1,48 +0,0 @@
|
||||
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 };
|
||||
@@ -1,69 +0,0 @@
|
||||
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 };
|
||||
@@ -1,61 +0,0 @@
|
||||
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 };
|
||||
@@ -1,290 +0,0 @@
|
||||
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 };
|
||||
@@ -1,4 +0,0 @@
|
||||
import Handlebars from 'hbs';
|
||||
|
||||
// inspired by https://stackoverflow.com/questions/22103989/adding-offset-to-index-when-looping-through-items-in-handlebars/39588001#39588001
|
||||
Handlebars.registerHelper('add', (lvalue, rvalue) => parseInt(lvalue) + parseInt(rvalue));
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user