Compare commits

...
286 Commits
Author SHA1 Message Date
antelle 2c386e67e2 plugin settings 2021-07-04 20:54:59 +02:00
antelle b572876099 global shortcuts 2021-07-04 17:12:28 +02:00
antelle 48c4f28d5d settings 2021-07-04 16:01:34 +02:00
antelle 8df3899920 advanced settings 2021-07-04 14:35:54 +02:00
antelle f9a79bf2c4 general settings 2021-07-04 14:02:43 +02:00
antelle 809dd897f7 settings scrolling 2021-07-04 13:15:19 +02:00
antelle 8d2e5b12aa fixed storage logout 2021-07-04 13:05:42 +02:00
antelle d5a61acd01 settings 2021-07-04 13:02:48 +02:00
antelle aa11e1a65e settings general pages 2021-07-03 21:55:11 +02:00
antelle 839afdda36 storage listing 2021-07-03 20:22:54 +02:00
antelle 40b85c76fb storage file listing 2021-07-03 20:05:28 +02:00
antelle da8ba46d21 icon style fix 2021-06-19 13:03:57 +02:00
antelle b15fd7258d icon selection 2021-06-19 12:49:16 +02:00
antelle 701d593b5b math.div sass migration 2021-06-19 11:50:37 +02:00
antelle c8ad6c8416 grp 2021-06-17 22:36:51 +02:00
antelle b859daf39b up deps 2021-06-17 22:36:10 +02:00
antelle d2d58e675a removed test code 2021-06-14 21:09:38 +02:00
antelle 518b34873b auto-type hint 2021-06-14 21:08:55 +02:00
antelle 763ee2e1f5 group 2021-06-14 07:25:29 +02:00
antelle 9dd8ee05e9 eslint 2021-06-13 12:03:03 +02:00
antelle b4a49e2321 generator presets 2021-06-13 12:02:46 +02:00
antelle 04c2f487fc tag panel 2021-06-13 10:34:44 +02:00
antelle 708ac26837 panel mode 2021-06-13 10:11:45 +02:00
antelle 9431a1bbf5 generator presets link 2021-06-12 22:22:08 +02:00
antelle 006e841a84 ci linting 2021-06-12 21:18:49 +02:00
antelle 7a9a16ce0e eslint 2021-06-12 21:14:05 +02:00
antelle 342242b2c3 about 2021-06-12 20:52:39 +02:00
antelle 06ece835c7 generator fix 2021-06-12 20:40:23 +02:00
antelle 78f322b51a moved some models 2021-06-12 20:39:01 +02:00
antelle 88b25a54cb generator fix 2021-06-12 20:15:55 +02:00
antelle 638b1a9aac menu fix 2021-06-12 20:14:19 +02:00
antelle e526d0e3f0 fixed double context menu 2021-06-12 17:16:05 +02:00
antelle 75da650a80 footer generator shortcut 2021-06-12 16:36:16 +02:00
antelle 8a9243b639 fix 2021-06-12 16:32:21 +02:00
antelle 2baf2b0dcf hide menu on any key 2021-06-12 16:30:25 +02:00
antelle ec126d52e2 fixed list sorting 2021-06-12 16:23:23 +02:00
antelle 3c2cab94d7 list context menu 2021-06-12 16:19:19 +02:00
antelle 12dea11a9a generator 2021-06-12 13:47:00 +02:00
antelle bf0192412a Model.reset 2021-06-12 10:01:19 +02:00
antelle acad35a52b fixed displaying first item 2021-06-06 15:54:37 +02:00
antelle 671212c23d eslint 2021-06-06 10:22:42 +02:00
antelle 146de47e0c list size calculation 2021-06-06 10:21:17 +02:00
antelle 7187cc5e1d fixed list jitter 2021-06-06 10:18:24 +02:00
antelle 0a656830c8 tooltip fixes 2021-06-05 23:42:46 +02:00
antelle 8142025fdd filtering 2021-06-05 22:39:03 +02:00
antelle 98b78b3856 item height 2021-06-05 22:05:23 +02:00
antelle 5140d729e2 scrolling 2021-06-05 21:15:44 +02:00
antelle 3a4b1f56d9 scrolling 2021-06-05 20:14:56 +02:00
antelle bd5d8bded9 list search 2021-06-05 18:39:49 +02:00
antelle ee6c96cdc8 modular lodash 2021-06-02 22:00:45 +02:00
antelle 720118c5df menu filters 2021-06-01 23:58:32 +02:00
antelle 3ec6e2f8e8 removed a prop 2021-06-01 22:03:40 +02:00
antelle af540cf250 settings about and help 2021-06-01 21:30:21 +02:00
antelle d9ed7ced22 settings menu 2021-06-01 20:35:34 +02:00
antelle 61a78107c4 settings page 2021-06-01 20:01:04 +02:00
antelle 3de2f1d25d query 2021-06-01 18:31:32 +02:00
antelle e0fa3e18d1 query 2021-06-01 17:57:29 +02:00
antelle 3e49fc4c75 file backups 2021-06-01 17:10:30 +02:00
antelle 6fe9d56b3c settings mode 2021-05-31 23:48:30 +02:00
antelle 32126939a6 sync 2021-05-31 22:25:03 +02:00
antelle 7962631a03 OpenParams 2021-05-30 22:53:01 +02:00
antelle 10dc9a4528 fixed open 2021-05-30 22:49:42 +02:00
antelle e93cdae808 cleanup 2021-05-30 22:22:23 +02:00
antelle cd9ba11d24 logging modals 2021-05-30 22:21:53 +02:00
antelle f7d6802f58 opening files 2021-05-30 22:18:38 +02:00
antelle b1b1911eba open 2021-05-30 16:26:14 +02:00
antelle d2536d8dcd displaying an import error 2021-05-29 22:32:11 +02:00
antelle 500eaa4569 tests tsconfig 2021-05-29 22:12:28 +02:00
antelle 6f068ebf18 better tests compatibility 2021-05-29 22:04:59 +02:00
antelle f840f7b15d local file alert 2021-05-29 21:40:20 +02:00
antelle c172477004 rename 2021-05-29 21:31:22 +02:00
antelle 9e76878c7c xml 2021-05-29 20:12:31 +02:00
antelle 06fef55e2c drag-drop 2021-05-29 19:29:39 +02:00
antelle 0ce5fc462a eslint 2021-05-29 16:22:45 +02:00
antelle 40dd3637d7 open screen progress 2021-05-29 16:16:16 +02:00
antelle caa17a48a8 open idle 2021-05-29 08:59:58 +02:00
antelle 2fe980f2ab open view split 2021-05-28 23:16:14 +02:00
antelle 0b38897868 password focus 2021-05-28 20:10:07 +02:00
antelle f3a7f75be3 moved a variable 2021-05-28 19:50:13 +02:00
antelle 0bf3c0d43f useKey hook 2021-05-28 19:38:29 +02:00
antelle 6a25732a9f simplified code 2021-05-28 17:37:39 +02:00
antelle 117955bcf9 removed a manifest check 2021-05-28 17:17:48 +02:00
antelle 95d2731d13 list wrap 2021-05-28 17:08:18 +02:00
antelle f7da54dded plugins 2021-05-28 16:42:00 +02:00
antelle 87aebe9216 plugins 2021-05-28 15:51:20 +02:00
antelle 6bc140e123 plugin vars 2021-05-28 14:28:13 +02:00
antelle 4897f9e41e plugin gallery 2021-05-28 13:20:25 +02:00
antelle 4a7797df6c menu selection 2021-05-28 11:56:33 +02:00
antelle f438ed6d54 cleanup 2021-05-28 11:47:08 +02:00
antelle 9230389a91 moved a component 2021-05-28 11:44:06 +02:00
antelle 9857f818ca dragging 2021-05-28 11:43:10 +02:00
antelle c6bc6b059e cleanup 2021-05-28 09:24:31 +02:00
antelle 7556ebf974 scroll 2021-05-28 09:22:17 +02:00
antelle 2591b22f35 readonly 2021-05-27 23:14:01 +02:00
antelle 748c70cd0f optimized id generation a bit 2021-05-27 23:13:33 +02:00
antelle d0a6791f21 id 2021-05-27 21:54:55 +02:00
antelle 8beef89250 fix 2021-05-27 21:50:46 +02:00
antelle b5a19b0186 properly destroying nodes 2021-05-27 21:50:30 +02:00
antelle b530f6d29a shortcuts 2021-05-27 21:40:48 +02:00
antelle d71b7d2f3d menu improvements 2021-05-27 21:33:27 +02:00
antelle 170f50f6fc menu 2021-05-27 20:57:27 +02:00
antelle d01aebe484 alerts fix 2021-05-27 18:50:11 +02:00
antelle 881ffa115e todo 2021-05-27 17:48:17 +02:00
antelle 9cc9958bdf footer 2021-05-27 17:39:19 +02:00
antelle 16d2bd6786 opening demo 2021-05-27 17:13:44 +02:00
antelle 1e607d30e6 open-view 2021-05-27 16:58:27 +02:00
antelle 1923234ffa secure-input 2021-05-27 16:05:15 +02:00
antelle 7a25b0fd39 mode 2021-05-27 14:38:14 +02:00
antelle 1dbf647b1b open view 2021-05-27 14:37:36 +02:00
antelle 0b3b49189f eslint 2021-05-27 14:06:03 +02:00
antelle 73b8ae6431 fix 2021-05-27 14:04:23 +02:00
antelle 4402db826d alerts 2021-05-27 14:03:07 +02:00
antelle 95078ea2f1 renamed localized-with 2021-05-27 12:10:38 +02:00
antelle b270ad52b4 titlebar 2021-05-27 12:09:21 +02:00
antelle 810f785a1f app-view 2021-05-27 09:51:42 +02:00
antelle 8f5e19b64b workspace 2021-05-27 09:42:49 +02:00
antelle 3017b39e5f file-info 2021-05-26 09:33:49 +02:00
antelle c2ad509283 disabled input 2021-05-25 22:45:38 +02:00
antelle 540c607527 secure-input 2021-05-25 22:35:25 +02:00
antelle 17d51ee5f1 yubikey 2021-05-25 21:26:50 +02:00
antelle 522e356fe3 filter 2021-05-25 20:25:53 +02:00
antelle 5c0756855a kdbx-to-html 2021-05-25 19:56:23 +02:00
antelle 38466f934f version 2021-05-25 18:51:27 +02:00
antelle c198e634d4 release notes 2021-05-25 18:51:12 +02:00
antelle 45e1f87d07 file, group, entry 2021-05-25 18:44:53 +02:00
antelle 89f3741f17 cleanup 2021-05-25 08:57:04 +02:00
antelle dba37cc40a locale 2021-05-25 08:56:21 +02:00
antelle a2c3d13bc4 cleanup 2021-05-25 08:32:14 +02:00
antelle c576b52b97 optional 2021-05-25 08:24:05 +02:00
antelle 4234cf5de6 optional field 2021-05-25 08:23:44 +02:00
antelle b7cd6e97e4 added FileStorageExtraOptions 2021-05-25 08:23:28 +02:00
antelle 03cf6c0d50 removed a weird module 2021-05-25 08:20:01 +02:00
antelle 9658535f15 fullscreen 2021-05-24 23:53:53 +02:00
antelle 3c30f1519e extracted window-class 2021-05-24 23:48:44 +02:00
antelle afbf0f369f storage 2021-05-24 23:42:08 +02:00
antelle 4527de78f0 fixed file init 2021-05-24 22:54:59 +02:00
antelle 0dd453e0e2 better model init 2021-05-24 22:51:33 +02:00
antelle bdb7ce22c0 chalresp 2021-05-24 22:46:23 +02:00
antelle 1c7a41ad4f backup 2021-05-24 22:43:20 +02:00
antelle 42126fa5a9 file-info 2021-05-24 22:37:34 +02:00
antelle 9baf47cd6d menu 2021-05-24 22:26:27 +02:00
antelle f25353135d menu 2021-05-24 22:26:04 +02:00
antelle f5d2936594 fix 2021-05-24 18:57:12 +02:00
antelle 8be52aee53 config-loader 2021-05-24 18:49:45 +02:00
antelle 0306f604aa logger.info 2021-05-24 18:03:06 +02:00
antelle 04789ecc22 rename 2021-05-24 18:02:02 +02:00
antelle c55aceb937 logger debug 2021-05-24 18:01:26 +02:00
antelle b8363a53f3 updater 2021-05-24 17:51:24 +02:00
antelle 372acf88b1 simplified code 2021-05-24 16:43:32 +02:00
antelle 4d7336ddec extracted PropertiesOfType 2021-05-24 16:41:26 +02:00
antelle d85cf24f8a improved runtime-data 2021-05-24 16:37:41 +02:00
antelle f64c146d2c moved some components 2021-05-24 16:06:09 +02:00
antelle a83e035ac8 renamed some events 2021-05-24 14:00:35 +02:00
antelle 520438814c cleanup 2021-05-24 13:47:07 +02:00
antelle f716fe50de tip 2021-05-24 13:19:50 +02:00
antelle 6a7df2444d rename 2021-05-24 10:01:25 +02:00
antelle 89c644a1c4 readme 2021-05-23 08:27:33 +02:00
antelle 02a505e2f9 webpack 2021-05-23 08:25:01 +02:00
antelle b46533d243 non-enumerable emitter 2021-05-23 01:02:30 +02:00
antelle 76ed537d61 bootstrap 2021-05-23 00:51:37 +02:00
antelle 6da998f13a views 101 2021-05-23 00:12:02 +02:00
antelle c8a854b60c rename 2021-05-22 23:32:29 +02:00
antelle 582211dde7 cleanup 2021-05-22 22:54:13 +02:00
antelle 9ee4ed6ef9 storage 2021-05-22 22:46:46 +02:00
antelle ee11875459 storage 2021-05-22 17:39:10 +02:00
antelle fb973e3021 storage 2021-05-21 10:08:18 +02:00
antelle bbbef70984 storage oauth listener 2021-05-20 09:53:32 +02:00
antelle ba043e09e9 html-renderer 2021-05-20 09:30:31 +02:00
antelle 84df59498a pkce 2021-05-20 09:13:14 +02:00
antelle 7e3f134024 io-cache 2021-05-20 08:54:28 +02:00
antelle 1eb354a322 kdbxweb-init 2021-05-19 20:48:42 +02:00
antelle 826c761dc6 some restructure 2021-05-19 19:18:31 +02:00
antelle 14b2d10e17 moved desktop-ipc 2021-05-19 17:57:42 +02:00
antelle 404dd1baff qr reader 2021-05-19 17:37:43 +02:00
antelle 19e79187fc app rights checker 2021-05-19 17:08:21 +02:00
antelle bf9f81b80b test 2021-05-19 16:09:57 +02:00
antelle ae1f141b63 deleted a class 2021-05-19 15:49:04 +02:00
antelle 971cee45a0 transport 2021-05-19 15:47:36 +02:00
antelle 0a7e285188 shortcuts 2021-05-19 14:07:29 +02:00
antelle 0047b54f50 some refactoring 2021-05-19 13:06:07 +02:00
antelle 8692aed447 restructured files 2021-05-19 12:26:44 +02:00
antelle 52cef043e1 merge fix 2021-05-19 10:44:18 +02:00
antelle 5c9f4c042e Merge branch 'develop' into ts
# Conflicts:
#	app/scripts/comp/launcher/launcher-electron.js
2021-05-19 10:44:03 +02:00
antelle 47b19d1d74 Merge branch 'master' into develop 2021-05-19 10:42:24 +02:00
antelle 070573e68a Merge branch 'release-1.18'
Build / web (push) Canceled after 0s
Build / linux (push) Canceled after 0s
Build / darwin (push) Canceled after 0s
Build / win32 (push) Canceled after 0s
Build / publish (push) Canceled after 0s
2021-05-19 10:35:35 +02:00
antelle 946d7769f1 bump version 2021-05-19 10:33:41 +02:00
antelle f0426a3ec2 fix #1820: minimize on close on macOS 2021-05-19 10:33:17 +02:00
antelle b2d67818a8 fix #1818: extension connection error if browser cannot be identified 2021-05-19 10:16:40 +02:00
antelle c0b159d824 fix #1824: saving KDBX3 files with compression disabled 2021-05-19 10:07:59 +02:00
antelle 1c5be170c5 more components 2021-05-19 09:57:47 +02:00
antelle a0ec451696 more components 2021-05-19 08:58:34 +02:00
antelle a97897ce6f native modules 2021-05-18 23:22:41 +02:00
antelle f431951ec5 alerts 2021-05-18 23:22:28 +02:00
antelle 5a0389444a logging 2021-05-16 23:04:56 +02:00
antelle 52d10b279f export-api 2021-05-16 23:03:26 +02:00
antelle 10f52c9027 runtime data model 2021-05-16 22:56:50 +02:00
antelle 5a8d0f47a3 stricter checking 2021-05-16 15:22:52 +02:00
antelle c66ce36d70 date-format 2021-05-16 15:06:45 +02:00
antelle dfb4859007 settings-manager 2021-05-16 15:04:02 +02:00
antelle cb79552103 idle-tracker 2021-05-16 14:45:40 +02:00
antelle b2846d0a29 feature tester 2021-05-16 14:41:16 +02:00
antelle 55844451f2 theme watcher 2021-05-16 14:32:06 +02:00
antelle 743cba7538 removed supported flags from launcher 2021-05-16 14:26:41 +02:00
antelle 2fb58ebb27 more code style 2021-05-16 14:25:03 +02:00
antelle a68874bc34 enforcing code style 2021-05-16 14:15:21 +02:00
antelle cc12720856 more checks 2021-05-16 13:57:39 +02:00
antelle 1659103ab8 better types 2021-05-16 13:55:56 +02:00
antelle 22994253c5 changed desktop-ipc location 2021-05-16 13:33:34 +02:00
antelle 52da7649e1 changed a symlink 2021-05-16 13:32:15 +02:00
antelle 8ea3502d8e setting deletion 2021-05-16 13:22:13 +02:00
antelle cabe9e3ae7 storage tests fix 2021-05-16 13:14:02 +02:00
antelle 7af9556d59 app settings 2021-05-16 11:30:44 +02:00
antelle e5ef7ea065 removed a cast 2021-05-15 23:24:09 +02:00
antelle 49f82746a8 cleanup 2021-05-15 23:23:07 +02:00
antelle e086cf392b settings store 2021-05-15 23:21:02 +02:00
antelle 8e45ffbddc launcher 2021-05-15 22:51:06 +02:00
antelle 7bb59fed76 checking batch set without listeners 2021-05-15 20:41:42 +02:00
antelle a8e1665214 derived model events support 2021-05-15 20:37:54 +02:00
antelle 3c4604595a signature verifier test 2021-05-15 17:33:03 +02:00
antelle fbc4cab3a8 -jquery 2021-05-15 17:10:21 +02:00
antelle e8e1f3cc70 moved eslint-disable closer 2021-05-15 17:03:12 +02:00
antelle b990a087a1 removed eslint-disable 2021-05-15 16:59:09 +02:00
antelle 84ac7568ee fix eslint 2021-05-15 16:54:04 +02:00
antelle 093f6e7e25 records 2021-05-15 16:34:56 +02:00
antelle 849ae772e2 moved model 2021-05-15 16:26:31 +02:00
antelle 2fd0bf4339 model 2021-05-15 16:13:05 +02:00
antelle 68ce33c34b events 2021-05-14 23:29:17 +02:00
antelle 882c730738 modules 2021-05-14 23:25:44 +02:00
antelle 55ad94bbb9 todo-ts 2021-05-14 23:22:30 +02:00
antelle 54870ab8fd cleanup 2021-05-14 23:21:38 +02:00
antelle edc32041fd package-lock 2021-05-14 21:20:01 +02:00
antelle 4b6c8876e7 Merge branch 'develop' into ts
# Conflicts:
#	package-lock.json
#	package.json
2021-05-14 21:19:28 +02:00
antelle d7e11e20c1 Merge branch 'master' into develop 2021-05-14 21:18:46 +02:00
antelle d590318153 Merge branch 'release-1.18'
Build / web (push) Canceled after 0s
Build / linux (push) Canceled after 0s
Build / darwin (push) Canceled after 0s
Build / win32 (push) Canceled after 0s
Build / publish (push) Canceled after 0s
2021-05-14 21:02:12 +02:00
antelle 5816ca9c4d bump version 2021-05-14 21:01:43 +02:00
antelle 6627e8c0ec fix #1817: crash on files with large attachments as KDBX3 2021-05-14 21:00:43 +02:00
antelle 379e28e8a4 about version in the the bug report template 2021-05-14 20:56:48 +02:00
antelle 6cdca3af38 ignored an inspection 2021-05-14 19:09:09 +02:00
antelle 27b8633bc2 improved locale 2021-05-14 18:50:45 +02:00
antelle a035796503 signature verifier tests 2021-05-13 23:59:20 +02:00
antelle 7feed36dac more otp tests 2021-05-13 23:41:46 +02:00
antelle 6c7a824608 locale 2021-05-13 20:01:08 +02:00
antelle ed2c5af7e9 locale 2021-05-13 13:56:29 +02:00
antelle 0c143d0491 moved locales out of scripts 2021-05-13 13:05:57 +02:00
antelle 9efce05f11 eslint fix 2021-05-13 12:39:22 +02:00
antelle 4903b1e141 npm run eslint 2021-05-13 12:12:07 +02:00
antelle 5718f31fe6 otp 2021-05-13 12:11:18 +02:00
antelle 4fb25451a2 fix #1816: old Chromium support, such as Android Edge 2021-05-13 10:45:48 +02:00
antelle 860dd5bdcd file-saver 2021-05-13 10:13:17 +02:00
antelle 6326620cd8 kdbxweb tests 2021-05-13 10:09:50 +02:00
antelle c6c9c19be6 utils 2021-05-13 09:33:36 +02:00
antelle ac38b8f60a features 2021-05-13 09:15:59 +02:00
antelle 91e5a4c894 logger 2021-05-13 09:10:42 +02:00
antelle 385224077b more utils 2021-05-13 09:01:55 +02:00
antelle a011bf7ec2 Merge branch 'develop' into ts 2021-05-12 21:43:59 +02:00
antelle 58a6a97130 Merge branch 'master' into develop 2021-05-12 21:43:27 +02:00
antelle f59b846643 Merge branch 'release-1.18'
Build / linux (push) Canceled after 0s
Build / web (push) Canceled after 0s
Build / darwin (push) Canceled after 0s
Build / win32 (push) Canceled after 0s
Build / publish (push) Canceled after 0s
2021-05-12 21:40:25 +02:00
antelle 28d7dfeb67 bump version 2021-05-12 21:39:47 +02:00
antelle 6cc39d2107 fix #1808: restore KeeWeb from system tray on extension request 2021-05-12 21:38:54 +02:00
antelle a92a01095b fix #1814: option to disable auto-type title filter by default 2021-05-12 21:00:51 +02:00
antelle 3f4cddd2a2 fix #1811: app doesn't quit during update 2021-05-12 20:50:24 +02:00
antelle aa734ea976 fix #1813: fixed custom icon selection 2021-05-11 18:50:37 +02:00
antelle 71d9dd84ae password strength 2021-05-10 23:49:40 +02:00
antelle eaf5875d4c more utils 2021-05-10 23:41:54 +02:00
antelle caae1ac801 more utils 2021-05-10 23:21:18 +02:00
antelle 746d0196b3 protected-value 2021-05-10 23:10:36 +02:00
antelle f04f54cc82 fn.ts 2021-05-10 21:23:16 +02:00
antelle 30628004fd https link in the readme 2021-05-10 20:40:38 +02:00
antelle a6e87b52fd fix #1810: fixed the extension on old macOS starting 10.11 2021-05-10 20:40:30 +02:00
antelle 8132325e11 eslintrc formatting 2021-05-10 20:31:19 +02:00
antelle 57684537fa first test 2021-05-10 18:30:12 +02:00
antelle 167be95bd4 const 2021-05-10 18:21:56 +02:00
antelle 6cdf53f1a4 Merge branch 'develop' into ts 2021-05-10 18:12:36 +02:00
antelle 6d06d825e5 fixed donate button border 2021-05-10 18:12:11 +02:00
antelle c923e46321 sorted dependencies 2021-05-10 17:57:54 +02:00
antelle b122231485 tests 2021-05-10 17:56:09 +02:00
antelle 73f94402dd TS setup 2021-05-09 23:55:40 +02:00
antelle 0800d5f33a Merge branch 'master' into develop 2021-05-09 23:25:27 +02:00
antelle 337ef44b56 Merge branch 'release-1.18'
Build / web (push) Canceled after 0s
Build / linux (push) Canceled after 0s
Build / darwin (push) Canceled after 0s
Build / win32 (push) Canceled after 0s
Build / publish (push) Canceled after 0s
2021-05-09 14:32:23 +02:00
antelle 70b4effde8 fix #1806: fixed a possible config loading error during startup 2021-05-09 14:31:58 +02:00
antelle 9e4c0f5c47 Merge branch 'release-1.18' 2021-05-09 14:12:50 +02:00
antelle 535cdc72f7 fix #1805: auto-unchecking auto-type filters if nothing found 2021-05-09 14:07:22 +02:00
antelle 8d258ecab2 fix #1804: entry table sizing in Firefox 2021-05-09 10:17:55 +02:00
antelle 3079e28f6f bump version 2021-05-09 10:09:26 +02:00
antelle 4970290614 fix #1804: filling OTP in browser extensions 2021-05-09 10:08:59 +02:00
antelle 6929931cae Merge branch 'master' into develop 2021-05-09 09:04:12 +02:00
495 changed files with 34591 additions and 26556 deletions
+9 -1
View File
@@ -1,12 +1,20 @@
{
"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
}],
"@babel/plugin-proposal-optional-catch-binding",
"@babel/plugin-proposal-optional-chaining",
"@babel/plugin-proposal-nullish-coalescing-operator",
"@babel/plugin-proposal-logical-assignment-operators",
"@babel/plugin-external-helpers"
]
}
-69
View File
@@ -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
View File
@@ -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
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ A clear and concise description of what you expected to happen.
If applicable, add screenshots to help explain your problem.
**Environment**
Your user-agent (from Settings/Help section)
Please copy all version information from Settings/Help
**Kdbx File**
Does it happen on Demo or New database?
+11 -13
View File
@@ -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
View File
@@ -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}.`,
+1 -1
View File
@@ -44,7 +44,7 @@ The app can be built with grunt: `grunt` (html files will be in `dist/`).
Desktop apps are built with `grunt desktop`. This requires some magic and currently works only on CI,
you can find more details in [the GitHub Actions workflow](.github/workflows/build.yaml).
To run the desktop (electron) app 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
+7 -8
View File
@@ -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>
@@ -432,7 +432,8 @@
"setGenColorfulIcons": "Colorful custom icons in the list",
"setGenUseMarkdown": "Markdown in notes",
"setGenUseGroupIconForEntries": "Automatically use group icon for new entries",
"setGenDirectAutotype": "If only one matching entry is found, select that one automatically for Autotype",
"setGenDirectAutotype": "If only one matching entry is found, select that one automatically for auto-type",
"setGenAutoTypeTitleFilterEnabled": "Filter entries by title in auto-type",
"setGenFunction": "Function",
"setGenAutoSyncOnClose": "Automatically save and sync on close",
"setGenAutoSyncTimer": "Automatically save and sync periodically",
@@ -724,6 +725,7 @@
"exportHtmlDate": "Export date",
"exportGenerator": "Software",
"exportDescription": "This file is generated with {}.",
"exportEntries": "Entries",
"importCsvTitle": "Import from CSV",
"importCsvRun": "Import",
"importIgnoreField": "Ignore",
-5
View File
@@ -1,5 +0,0 @@
{
"rules": {
"import/no-commonjs": "error"
}
}
-212
View File
@@ -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;
+1 -5
View File
@@ -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;
+32 -18
View File
@@ -33,9 +33,9 @@ const AutoType = {
const entry = (e && e.entry) || null;
const sequence = (e && e.sequence) || null;
const context = (e && e.context) || null;
logger.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);
});
},
@@ -246,7 +247,7 @@ const AutoType = {
},
processEventWithFilter(evt) {
const entries = evt.filter.getEntries();
let entries = evt.filter.getEntries();
if (entries.length === 1 && AppSettingsModel.directAutotype) {
this.hideWindow(() => {
this.runAndHandleResult({ entry: entries[0] }, evt.windowInfo.id);
@@ -255,6 +256,19 @@ const AutoType = {
}
this.focusMainWindow();
if (entries.length === 0) {
if (evt.filter.useUrl) {
evt.filter.useUrl = false;
if (evt.filter.title && AppSettingsModel.autoTypeTitleFilterEnabled) {
evt.filter.useTitle = true;
}
}
entries = evt.filter.getEntries();
if (entries.length === 0 && evt.filter.useTitle) {
evt.filter.useTitle = false;
}
}
const humanReadableTarget = evt.filter.title || evt.filter.url;
const topMessage = humanReadableTarget
? Locale.autoTypeMsgMatchedByWindow.replace('{}', humanReadableTarget)
@@ -267,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;
+188
View File
@@ -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 };
-102
View File
@@ -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 };
-29
View File
@@ -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 };
+29
View File
@@ -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 };
+689
View File
@@ -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 };
-18
View File
@@ -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 };
+26
View File
@@ -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 };
@@ -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 };
+314
View File
@@ -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 };
-306
View File
@@ -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 };
+472
View File
@@ -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 };
-291
View File
@@ -1,291 +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 (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);
@@ -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();
@@ -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);
}
};
-101
View File
@@ -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';
}
@@ -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(() => {
@@ -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;
}
};
-118
View File
@@ -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 };
+42
View File
@@ -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;
}
}
@@ -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);
}
+323
View File
@@ -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 };
-119
View File
@@ -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 };
+136
View File
@@ -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 };
+308
View File
@@ -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
View File
@@ -0,0 +1 @@
../../../../desktop/desktop-ipc.d.ts
-7
View File
@@ -1,7 +0,0 @@
let Launcher;
if (window.process && window.process.versions && window.process.versions.electron) {
Launcher = require('./launcher-electron').Launcher;
}
export { Launcher };
+9
View File
@@ -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.platform() === 'darwin' || 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);
// });
-250
View File
@@ -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 };
+268
View File
@@ -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 };
+196
View File
@@ -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;
}
+182
View File
@@ -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 };
-122
View File
@@ -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 };
+305
View File
@@ -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,19 +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-'
}
};
export { DropboxApps, GDriveApps, OneDriveApps };
} 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;
-92
View File
@@ -1,92 +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
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
};
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 };
-119
View File
@@ -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 };
+117
View File
@@ -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
}
-5
View File
@@ -1,5 +0,0 @@
const KnownAppVersions = {
KeePassXC: '2.6.4'
};
export { KnownAppVersions };
+3
View File
@@ -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',
@@ -35,5 +35,3 @@ const Links = {
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 };
@@ -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);
},
@@ -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();
@@ -325,7 +325,7 @@ async function findEntry(request, returnIfOneMatch, filterOptions) {
if (!entries.length) {
if (AppSettingsModel.extensionFocusIfEmpty) {
filter.useUrl = false;
if (filter.title) {
if (filter.title && AppSettingsModel.autoTypeTitleFilterEnabled) {
filter.useTitle = true;
entries = filter.getEntries();
if (!entries.length) {
@@ -522,7 +522,7 @@ const ProtocolHandlers = {
}
const otpPromise = new Promise((resolve, reject) => {
selectEntryFieldView.on('result', () => reject(makeError(Errors.userRejected)));
selectEntryFieldView?.on('result', () => reject(makeError(Errors.userRejected)));
entry.otpGenerator.next((err, otp) => {
if (otp) {
resolve(otp);
@@ -536,9 +536,7 @@ const ProtocolHandlers = {
try {
totp = await otpPromise;
} finally {
if (selectEntryFieldView) {
selectEntryFieldView.remove();
}
selectEntryFieldView?.remove();
}
return encryptResponse(request, {
-251
View File
@@ -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 };
-12
View File
@@ -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 };
-126
View File
@@ -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 };
-48
View File
@@ -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 };
-69
View File
@@ -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 };
-61
View File
@@ -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 };
-290
View File
@@ -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 };
-4
View File
@@ -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