Compare commits

..

230 Commits

Author SHA1 Message Date
joejulian 885d599db1 Merge pull request 'Complete API token gRPC authorization' (#3) from feature/api-token-grpc-authz into main
ci / lint-test (push) Successful in 1m14s
ci / build (push) Successful in 2m39s
Reviewed-on: #3
2026-04-11 07:11:01 +00:00
Joe Julian e757be66d9 Complete API token authz UI flows 2026-04-11 00:03:30 -07:00
Joe Julian bc226647e1 Remove redundant gRPC auth interceptor 2026-04-10 23:48:25 -07:00
Joe Julian 533fb2d550 Audit API token lifecycle actions 2026-04-10 23:40:34 -07:00
Joe Julian 8dfba6e94f Enforce API token authz across gRPC methods 2026-04-10 23:36:56 -07:00
Joe Julian 6cc86bb944 Trim completed TODO items
ci / lint-test (push) Successful in 1m15s
ci / build (push) Successful in 2m30s
2026-04-10 23:25:01 -07:00
joejulian a9c15c2d23 Merge pull request 'Local-first remote sync and cross-platform UI parity' (#2) from feature/local-first-remote-sync into main
ci / lint-test (push) Successful in 1m13s
ci / build (push) Successful in 2m34s
2026-04-11 06:15:41 +00:00
Joe Julian b7d6dbdc97 Keep desktop detail pane visible 2026-04-10 23:09:47 -07:00
Joe Julian 2deca549f5 Fix desktop header menu layout 2026-04-10 23:05:30 -07:00
Joe Julian fe3c07e3dd Unify action menus and collapse empty detail pane 2026-04-10 22:12:50 -07:00
Joe Julian c4f110e0ad Refine compact header menus 2026-04-10 21:48:05 -07:00
Joe Julian 56a0711860 Right-align compact header menus 2026-04-10 19:23:13 -07:00
Joe Julian 54f13d352c Fix compact header overlay ordering 2026-04-10 18:55:02 -07:00
Joe Julian 550d9f362c Add ship-it skill and menu placement logs 2026-04-10 16:08:08 -07:00
Joe Julian ac3478889c Make header action cluster own menus 2026-04-10 15:48:57 -07:00
Joe Julian 44da1e6599 Confirm debug logging is enabled 2026-04-10 15:36:23 -07:00
Joe Julian b59cf8044b Log header bounds to Android logcat 2026-04-10 15:32:35 -07:00
Joe Julian 5838588fc5 Log compact header button bounds 2026-04-10 15:28:33 -07:00
Joe Julian 0e9fd478e5 Align header action cluster with layout.E 2026-04-10 15:04:41 -07:00
Joe Julian 2f1cd7876c Normalize app UI pane packages 2026-04-09 13:20:12 -07:00
Joe Julian ccaee9fa34 Split app UI layout packages 2026-04-09 09:20:57 -07:00
Joe Julian c442a20d3e Refactor app UI to satisfy gocyclo 2026-04-09 07:23:10 -07:00
Joe Julian cdf0c0c2c7 Extract app UI action models 2026-04-09 06:58:05 -07:00
Joe Julian 6ccff23804 Extract app UI layout primitives 2026-04-09 06:53:21 -07:00
Joe Julian c3a9c0fddb Extract app UI platform glue 2026-04-09 06:50:16 -07:00
Joe Julian b593b1e6a7 Drop vendored gioui cmd fork 2026-04-09 06:47:36 -07:00
Joe Julian fe921b8790 Move app packages under internal 2026-04-09 06:42:21 -07:00
Joe Julian 7751b5472a Move app UI package under internal/appui 2026-04-09 06:35:59 -07:00
Joe Julian 07a071503a Use viewport width for adaptive layout 2026-04-08 23:49:07 -07:00
Joe Julian b256a77d0c Split command entrypoint from app package 2026-04-08 23:36:22 -07:00
Joe Julian 74d10535a1 Add behavior tests for extracted menu model 2026-04-08 23:29:35 -07:00
Joe Julian 16f603ccba Move sync menu state decisions out of renderers 2026-04-08 23:27:47 -07:00
Joe Julian 9660369851 Extract header and menu rendering 2026-04-08 23:23:47 -07:00
Joe Julian 0a9201e0d1 Add explicit header dropdown layout types 2026-04-08 23:19:04 -07:00
Joe Julian 74a2bbdc92 Split lifecycle and sync dialog UI 2026-04-08 23:15:10 -07:00
Joe Julian 168927713c Use desktop overlay model for phone header menus 2026-04-08 20:40:20 -07:00
Joe Julian 7a50138640 Align Android header menus 2026-04-08 17:43:17 -07:00
Joe Julian d7741d14f5 Fix Android header menus 2026-04-08 16:27:46 -07:00
Joe Julian 5a98fe1a75 Document local-first sync rules 2026-04-08 15:58:45 -07:00
Joe Julian 09e6425b1c Fix Android header menu anchoring 2026-04-08 15:47:34 -07:00
Joe Julian 4f9792d027 Fix password visibility icon state 2026-04-08 08:40:00 -07:00
Joe Julian 36c6687168 Use Gio east alignment for dropdown actions 2026-04-07 22:05:13 -07:00
Joe Julian 101a875837 Revert "Right-align dropdown actions"
This reverts commit 81f1bcfca8.
2026-04-07 21:52:57 -07:00
Joe Julian 81f1bcfca8 Right-align dropdown actions 2026-04-07 21:50:25 -07:00
Joe Julian b33f4905ab Keep dropdowns right-aligned 2026-04-07 21:41:52 -07:00
Joe Julian edf0a9090d Anchor main menu below trigger 2026-04-07 21:34:33 -07:00
Joe Julian 9b3f10f086 Anchor sync menu below chevron 2026-04-07 21:25:56 -07:00
Joe Julian cbfbe3be14 Drop sync menu below trigger 2026-04-07 21:21:13 -07:00
Joe Julian f1f5d80ed8 Align desktop pane workflow with phone 2026-04-07 21:16:13 -07:00
Joe Julian edac0f50a6 Remove desktop list pane tabs 2026-04-07 21:11:48 -07:00
Joe Julian 288cb34f1a Align desktop entries pane order with phone 2026-04-07 21:05:45 -07:00
Joe Julian e88d1fd875 Use Android picker for local vaults 2026-04-07 07:20:39 -07:00
Joe Julian a867ac4664 Codify workflow parity requirements 2026-04-07 07:12:29 -07:00
Joe Julian 1aab5367a8 Prioritize phone vault actions 2026-04-07 06:53:13 -07:00
Joe Julian 5d435f1f1f Move remote sync actions into sync menu 2026-04-06 22:30:05 -07:00
Joe Julian 7868a77c8a Reset remote sync dialog scroll state 2026-04-06 22:28:14 -07:00
Joe Julian 43ef58936b Match remote sync credentials by host 2026-04-06 22:22:18 -07:00
Joe Julian cb6fbd05a3 Retheme remote sync test fixtures 2026-04-06 22:00:10 -07:00
Joe Julian 739d918c21 Add lifecycle remote sync shortcut 2026-04-06 21:56:29 -07:00
Joe Julian 332ab58f58 Implement local-first remote sync flow 2026-04-06 21:49:56 -07:00
joejulian 8433d536f6 Merge pull request 'Sync Arch package version metadata' (#1) from feature/archlinux-pkgver-sync into main
ci / lint-test (push) Successful in 1m15s
ci / build (push) Successful in 2m29s
Reviewed-on: #1
2026-04-06 18:49:12 +00:00
Joe Julian 21b2e60df4 Sync Arch package version metadata 2026-04-06 11:38:57 -07:00
Joe Julian 70f18e89bf Use fixed APK signing key in CI
ci / lint-test (push) Successful in 1m12s
ci / build (push) Successful in 2m36s
2026-04-06 07:26:48 -07:00
Joe Julian c361ec5ba3 Keep remote form open during manual entry
ci / lint-test (push) Successful in 1m11s
ci / build (push) Successful in 2m32s
2026-04-06 07:13:12 -07:00
Joe Julian 0c6d707325 Fix Android group browser scrolling
ci / lint-test (push) Successful in 1m16s
ci / build (push) Successful in 2m37s
2026-04-06 00:04:38 -07:00
Joe Julian 1c72a5009f Stamp app version into builds
ci / lint-test (push) Successful in 1m17s
ci / build (push) Successful in 2m33s
2026-04-05 23:56:58 -07:00
Joe Julian 9aeb98da58 Add About section
ci / lint-test (push) Successful in 1m12s
ci / build (push) Successful in 2m33s
2026-04-05 23:42:53 -07:00
Joe Julian c19bdd48e2 Simplify desktop remote open flow
ci / lint-test (push) Successful in 1m14s
ci / build (push) Successful in 2m42s
2026-04-05 21:34:59 -07:00
Joe Julian e5f42924f8 Simplify desktop unlock flow
ci / lint-test (push) Successful in 1m16s
ci / build (push) Successful in 2m39s
2026-04-05 21:24:36 -07:00
Joe Julian 13eb9028c7 Add Arch package for Linux client
ci / lint-test (push) Successful in 1m21s
ci / build (push) Successful in 2m45s
2026-04-05 20:31:17 -07:00
Joe Julian 5fa79bdf08 Move unlock controls ahead of vault summary
ci / lint-test (push) Successful in 1m14s
ci / build (push) Successful in 2m39s
2026-04-05 18:35:42 -07:00
Joe Julian 1a11944423 Pin Gio for Android rendering
ci / lint-test (push) Successful in 1m47s
ci / build (push) Successful in 3m34s
2026-04-05 16:55:16 -07:00
Joe Julian b0842566c0 Document Android APK test workflow 2026-04-05 16:51:38 -07:00
Joe Julian eb6624cba5 Simplify recent vault open flow and Android local sync
ci / lint-test (push) Successful in 1m46s
ci / build (push) Successful in 3m44s
2026-04-05 16:37:43 -07:00
Joe Julian 37f1a0ef8f Use Gio clipboard commands on Android
ci / lint-test (push) Successful in 1m45s
ci / build (push) Has been cancelled
2026-04-04 09:41:28 -07:00
Joe Julian da4a8d4622 Trigger CI after Traefik TLS fix 2026-04-03 21:41:20 -07:00
Joe Julian 81df59ee15 Use system CA for CI artifact uploads 2026-04-03 20:37:27 -07:00
Joe Julian 69fac41003 Enable cgo for Linux desktop CI builds 2026-04-03 20:31:34 -07:00
Joe Julian 1ee916b863 Limit CI desktop builds to supported targets 2026-04-03 20:27:13 -07:00
Joe Julian 9c41b4814b Use dedicated Android CI runner 2026-04-03 18:04:53 -07:00
Joe Julian 2f4f016fe7 Match Gitea workflow runner labels 2026-04-03 17:23:38 -07:00
Joe Julian 832aa86a34 Add Gitea CI and release pipeline 2026-04-03 16:38:56 -07:00
Joe Julian a4933f2127 Route Android back through Gio settings flow 2026-04-03 11:21:17 -07:00
Joe Julian 6afdf49f5e Add API policy pickers and contextual search labels 2026-04-03 10:55:17 -07:00
Joe Julian 9269c4b488 Remove redundant recycle bin notice 2026-04-03 10:45:47 -07:00
Joe Julian 78313f130d Fix group tools disclosure icon 2026-04-03 10:43:07 -07:00
Joe Julian e75b3eb418 Harden phone entry click state 2026-04-03 10:38:14 -07:00
Joe Julian ae7483e929 Limit group view to direct entries 2026-04-03 08:51:19 -07:00
Joe Julian db0501fca0 Keep parent breadcrumb on phone 2026-04-03 08:35:09 -07:00
Joe Julian 6629ba2483 Snapshot phone entry list during redraw 2026-04-03 08:28:58 -07:00
Joe Julian 3dc0f13af9 Fix phone header spacer 2026-04-03 07:25:25 -07:00
Joe Julian b3db70db91 Autosave Android settings and refine header 2026-04-03 07:23:31 -07:00
Joe Julian e2addfc288 Right-align phone action row 2026-04-03 07:19:26 -07:00
Joe Julian e0fba15246 Align phone header actions right 2026-04-03 07:18:10 -07:00
Joe Julian a8bb5bf6b9 Tighten phone group and detail layout 2026-04-03 07:10:25 -07:00
Joe Julian b76cb7e1df Simplify phone vault screen and settings navigation 2026-04-02 21:50:36 -07:00
Joe Julian 3667fce5f4 Make phone entry pane fully scrollable 2026-04-02 21:38:51 -07:00
Joe Julian c2a4fa4087 Trim verbose group navigation copy 2026-04-02 21:32:07 -07:00
Joe Julian e4ebb2e723 Collapse phone subgroup browser after navigation 2026-04-02 09:41:33 -07:00
Joe Julian 7fc0661b32 Fix Android reopen crash and simplify group navigation 2026-04-02 07:28:55 -07:00
Joe Julian 5b6f7bb1de Fix Android local open flow 2026-04-01 21:42:19 -07:00
Joe Julian b030b69c0d Move remote preference help out of open flow 2026-04-01 19:22:56 -07:00
Joe Julian 25c972263f Integrate autofill privacy settings controls 2026-04-01 18:46:10 -07:00
Joe Julian 818cb2d70e Add accessibility settings preferences 2026-04-01 18:46:10 -07:00
Joe Julian c0afe96f56 Move sync defaults into vault settings 2026-04-01 18:46:10 -07:00
Joe Julian 6cfb5ea567 Stabilize settings persistence tests 2026-04-01 18:46:10 -07:00
Joe Julian ea1cf43cbf Add autofill privacy settings controls 2026-04-01 18:46:10 -07:00
Joe Julian 9a920f9a5a Add accessibility settings preferences 2026-04-01 18:46:10 -07:00
Joe Julian b813c8f221 Add notification feedback settings 2026-04-01 18:46:10 -07:00
Joe Julian 6f2b4d49b3 Add persisted UI settings preferences 2026-04-01 18:46:10 -07:00
Joe Julian 7ee969fb81 Move remote preference help out of open flow 2026-04-01 17:37:06 -07:00
Joe Julian 99a581674d Align audit UI tests with empty-state model 2026-04-01 17:21:16 -07:00
Joe Julian 004a0278a7 Refine audit UI and vault settings placement 2026-04-01 17:21:16 -07:00
Joe Julian 2aa859f7cb Improve local vault open usability 2026-04-01 17:17:37 -07:00
Joe Julian 75fbd340aa Refine entry detail and edit panels 2026-04-01 17:15:58 -07:00
Joe Julian 1a8113d43b Tighten mobile locked vault hierarchy 2026-04-01 17:15:20 -07:00
Joe Julian dafc696053 Improve autofill status feedback 2026-04-01 17:13:43 -07:00
Joe Julian f205e753e8 Refine API token management UI 2026-04-01 17:12:29 -07:00
Joe Julian 3324eec9ec Improve entries navigation readability 2026-04-01 17:12:17 -07:00
Joe Julian 8c339eb309 Refine remote open lifecycle flow 2026-04-01 17:10:26 -07:00
Joe Julian 9ec8ef12b0 Clarify group navigation and management 2026-04-01 17:10:04 -07:00
Joe Julian 468b2dc933 Refine recycle bin and sync UX 2026-04-01 17:09:51 -07:00
Joe Julian a1cbec85da Clarify lifecycle loading and target selection 2026-04-01 17:00:24 -07:00
Joe Julian 7de55d1041 Avoid blocking UI during vault open and unlock 2026-04-01 16:54:16 -07:00
Joe Julian 1468bd5c5a Improve recent targets and workflow context 2026-04-01 16:43:15 -07:00
Joe Julian a2a7cb431d Refine group, editor, and admin workflows 2026-04-01 16:34:24 -07:00
Joe Julian baf3f27656 Clarify start, locked, and recycle flows 2026-04-01 16:29:53 -07:00
Joe Julian 72e67039cf Tighten navigation and admin UI 2026-04-01 16:24:11 -07:00
Joe Julian b8b4ab9e1d Refine settings and empty-state affordances 2026-04-01 15:00:46 -07:00
Joe Julian 84fea5e5d7 Simplify lifecycle and section UI 2026-04-01 14:58:56 -07:00
Joe Julian 73ff0fb77d Add UI review follow-ups to TODO 2026-04-01 14:22:53 -07:00
Joe Julian bd99b928d9 Honor alternate autofill targets from entry fields 2026-04-01 10:36:08 -07:00
Joe Julian 06ea95f0b3 Fix entry and group navigation workflows 2026-04-01 10:24:57 -07:00
Joe Julian b0721e5af1 Support Android app targets in autofill service 2026-04-01 05:56:52 -07:00
Joe Julian d9d1cf134d Disambiguate same-host Android fill matches 2026-04-01 05:21:15 -07:00
Joe Julian cc7214b880 Add Android accessibility-based Chrome form fill 2026-04-01 01:24:28 -07:00
Joe Julian 4cad54a696 Add Android autofill service packaging 2026-03-31 23:13:10 -07:00
Joe Julian 8499b4304e Tighten Android lifecycle screen layout 2026-03-31 22:53:47 -07:00
Joe Julian eb73cab155 Add KeePassGO branding assets 2026-03-31 22:34:36 -07:00
Joe Julian beefd51b15 Fix Android rendering by pinning Gio v0.8 2026-03-31 22:23:45 -07:00
Joe Julian 0ab444b0b9 Align Android build guidance with Gio 2026-03-31 21:37:19 -07:00
Joe Julian 867aae5ffe Harden API token policy button state 2026-03-31 20:23:59 -07:00
Joe Julian ff7f84c386 Test Android packaging configuration 2026-03-31 20:15:48 -07:00
Joe Julian 2000c27324 Isolate test state and paint full frame 2026-03-31 20:12:54 -07:00
Joe Julian 182b469b0b Fix API token detail panel panic 2026-03-30 14:46:07 -07:00
Joe Julian 27fdd77aa1 Restore entries tab state and preload recent vault 2026-03-30 14:31:06 -07:00
Joe Julian 88151dc780 Fix hidden root section restore 2026-03-30 09:24:41 -07:00
Joe Julian b2c26622e8 Add configurable vault security settings 2026-03-30 07:58:27 -07:00
Joe Julian 84f39b99de Host the gRPC API and add token admin views 2026-03-30 07:50:34 -07:00
Joe Julian fca121f170 Fix Android startup defaults and window sizing 2026-03-30 07:30:06 -07:00
Joe Julian 794eed04d4 Record audit events for API token authorization 2026-03-29 23:19:54 -07:00
Joe Julian e9eca73336 Add token management helpers for API credentials 2026-03-29 23:17:34 -07:00
Joe Julian 74dfe3f3d0 Expose API approval prompts to app state and UI 2026-03-29 23:14:39 -07:00
Joe Julian f77a185e46 Add API approval broker for gRPC authorization prompts 2026-03-29 23:09:36 -07:00
Joe Julian 6a7594e128 Authorize gRPC requests with vault API tokens 2026-03-29 22:56:18 -07:00
Joe Julian 666252f18c Add API token domain model and policy evaluation 2026-03-29 22:46:44 -07:00
Joe Julian f87b3f1989 Add API token authorization TODO segments 2026-03-29 22:38:09 -07:00
Joe Julian caf4ec6266 Preserve single-root KDBX group trees 2026-03-29 22:17:40 -07:00
Joe Julian 47d0ccc7ce Set up Android SDK prerequisites for APK builds 2026-03-29 21:59:52 -07:00
Joe Julian 96ec583c7e Add advanced synchronize dialog and APK tooling 2026-03-29 21:38:46 -07:00
Joe Julian 6e2760c514 Preserve remote conflicts in entry history 2026-03-29 21:19:42 -07:00
Joe Julian 59905ccd98 Tighten banner and navigation behavior 2026-03-29 21:16:38 -07:00
Joe Julian 62bb20edb0 Keep recent remote passwords masked 2026-03-29 21:07:57 -07:00
Joe Julian 4e082c345a Scroll lifecycle screen and restore remote groups 2026-03-29 17:46:48 -07:00
Joe Julian 5bcd951e6a Collapse empty phone detail pane 2026-03-29 17:41:51 -07:00
Joe Julian c0747b62a0 Rebalance desktop open-vault layout 2026-03-29 17:36:41 -07:00
Joe Julian 3066442c1b Reset password peek across view changes 2026-03-29 17:27:19 -07:00
Joe Julian 4a3e52ec1c Remember recent remote vault connections 2026-03-29 17:05:42 -07:00
Joe Julian daecb5ff32 Tighten desktop vault navigation header 2026-03-29 16:58:21 -07:00
Joe Julian 99113aa26b Honor env state dir in default UI paths 2026-03-29 16:54:20 -07:00
Joe Julian 4b78a649b1 Simplify locked vault screen 2026-03-29 16:47:29 -07:00
Joe Julian 2e9e2aae5f Add collapsible group tools section 2026-03-29 16:39:27 -07:00
Joe Julian db06fcd385 Use structured custom fields in entry editor 2026-03-29 16:37:20 -07:00
Joe Julian d7026d5c0e Restore per-vault groups and scroll edit forms 2026-03-29 16:32:54 -07:00
Joe Julian 20d5b3ba3d Require isolated state dir for test runs 2026-03-29 16:26:21 -07:00
Joe Julian 1c4fb59960 Improve group deletion and status banner UX 2026-03-29 16:17:22 -07:00
Joe Julian fe3fa854bb Support KeePassGO state dir via flag and env 2026-03-29 15:41:44 -07:00
Joe Julian 37b83e654a Clear KeePassGO master password after use 2026-03-29 15:38:30 -07:00
Joe Julian 761fae9b9b Fix KeePassGO open screen field and banner UX 2026-03-29 15:35:32 -07:00
Joe Julian fc9580403d Default Enter to opening the selected vault 2026-03-29 15:33:41 -07:00
Joe Julian 92e8fce0e7 Persist recent KeePassGO vault list 2026-03-29 15:28:20 -07:00
Joe Julian cd21cc26c9 Improve KeePassGO vault navigation UX 2026-03-29 15:18:14 -07:00
Joe Julian fa75908552 Simplify KeePassGO desktop vault workflow 2026-03-29 14:50:33 -07:00
Joe Julian 7e2e396264 Refine lifecycle setup screen 2026-03-29 14:00:20 -07:00
Joe Julian 01e06deeef Document remaining AGENTS gaps 2026-03-29 13:53:27 -07:00
Joe Julian 97f6a34472 Update lifecycle tests for controller state 2026-03-29 13:46:30 -07:00
Joe Julian fc8630f894 Restore search path context helper 2026-03-29 13:46:30 -07:00
Joe Julian ef0fb7f5d9 Align keyboard and tests with controller state 2026-03-29 13:46:30 -07:00
Joe Julian 3d0a6bc43a Remove duplicate app state group deletion 2026-03-29 13:46:30 -07:00
Joe Julian e693aab13b Merge branch 'seg01-appstate' into merge-main-01-seg01-appstate 2026-03-29 13:46:30 -07:00
Joe Julian a2f1539027 Centralize app state ownership in controller 2026-03-29 13:46:30 -07:00
Joe Julian 508fe7abc1 Fix rebased main path tests 2026-03-29 13:44:05 -07:00
Joe Julian ed1e2c3e6b Add password generation UI profile workflow 2026-03-29 13:44:05 -07:00
Joe Julian 2b535a90e4 Add local vault lifecycle UI coverage 2026-03-29 13:43:13 -07:00
Joe Julian 3f29fae12f Drop obsolete UI path fallback after rebase 2026-03-29 13:41:44 -07:00
Joe Julian c050cb3e40 Sync UI path fallbacks during main landing 2026-03-29 13:40:57 -07:00
Joe Julian cfec0c6703 Align moved-entry test with controller navigation 2026-03-29 13:40:36 -07:00
Joe Julian 00717f32ce Add attachment replace workflow UI 2026-03-29 13:40:36 -07:00
Joe Julian fc90e91174 test: align rebased path coverage with controller state 2026-03-29 13:37:57 -07:00
Joe Julian c248b89fcd test: align keyboard navigation with controller paths 2026-03-29 13:37:00 -07:00
Joe Julian 73992a95cc test: align template UI coverage with controller navigation 2026-03-29 13:37:00 -07:00
Joe Julian ba945b5527 test: cover template CRUD UI flows 2026-03-29 13:37:00 -07:00
Joe Julian 43fc278fd1 Make group navigation controller-driven 2026-03-29 13:37:00 -07:00
Joe Julian 484448b51c Merge commit '7651b3b' into merge-main-13-seg13-copy-reveal 2026-03-29 13:36:26 -07:00
Joe Julian a708e17b50 Merge commit 'cffe05a' into merge-main-13-seg13-copy-reveal
# Conflicts:
#	main.go
2026-03-29 13:35:59 -07:00
Joe Julian 17ff374be7 Resolve remote lifecycle landing conflicts 2026-03-29 13:35:18 -07:00
Joe Julian d05830335f Add WebDAV lifecycle UI feedback 2026-03-29 13:35:18 -07:00
Joe Julian 4848b09be1 Merge commit '4fe912b' into merge-main-13-seg13-copy-reveal 2026-03-29 13:35:12 -07:00
Joe Julian 5a458c0c8c Add entry history browsing to UI 2026-03-29 13:35:12 -07:00
Joe Julian beda174819 Add keyboard-first accessibility behavior 2026-03-29 13:35:12 -07:00
Joe Julian 4e67328a09 Merge branch 'main' into merge-main-20-seg20-regression 2026-03-29 13:34:26 -07:00
Joe Julian c5670a9a36 Add secret-safe copy and reveal UX coverage 2026-03-29 13:34:25 -07:00
Joe Julian a14101e415 Remove dead deleted-object helper 2026-03-29 13:34:12 -07:00
Joe Julian c5e2df4ca7 Add regression coverage for KDBX reopen cycles 2026-03-29 13:34:12 -07:00
Joe Julian 43d253aa21 Add Segment 6 entry editor coverage 2026-03-29 13:34:12 -07:00
Joe Julian 6c5e9b42d3 Add gRPC vault lifecycle backend flow 2026-03-29 13:34:12 -07:00
Joe Julian 5a6ba8ff57 Add gRPC group deletion and custom entry fields 2026-03-29 13:33:52 -07:00
Joe Julian 6c1ccdad16 Complete search section behavior 2026-03-29 13:33:52 -07:00
Joe Julian 6748d31f87 Add keyboard-first accessibility behavior 2026-03-29 13:33:52 -07:00
Joe Julian 40fd1bfde9 Remove dead deleted-object helper 2026-03-29 13:33:30 -07:00
Joe Julian 57f6ae658d Add regression coverage for KDBX reopen cycles 2026-03-29 13:33:30 -07:00
Joe Julian a857f972ea Add Segment 6 entry editor coverage 2026-03-29 13:33:30 -07:00
Joe Julian f009573b4a Add gRPC vault lifecycle backend flow 2026-03-29 13:33:30 -07:00
Joe Julian 4b4696ce30 Merge branch 'seg09-search' into merge-main-09-seg09-search 2026-03-29 13:33:07 -07:00
Joe Julian afa6c8821f Add keyboard-first accessibility behavior 2026-03-29 13:33:07 -07:00
Joe Julian afe9680d7a Complete search section behavior 2026-03-29 13:33:07 -07:00
Joe Julian 422de535af Complete UI state and error surfaces 2026-03-29 13:32:28 -07:00
Joe Julian 44bba18149 Add UI master key setup and change flows 2026-03-29 11:23:54 -07:00
Joe Julian e15cfb1535 Break TODO into parallel work segments 2026-03-29 11:10:12 -07:00
Joe Julian a2a8fcbd14 Reconstruct KeePassGO repository 2026-03-29 11:04:38 -07:00
132 changed files with 22959 additions and 17222 deletions
+116
View File
@@ -0,0 +1,116 @@
---
name: keepassgo-apk-test
description: Build and run the KeePassGO Android APK for emulator testing. Use when work requires `make apk`, APK install/launch, emulator validation, or checking known KeePassGO Android regressions such as black screens, clipboard, open flow, or local sync behavior.
---
# KeePassGO APK Test
Use this skill together with the installed `android-emulator-debug` skill. That skill covers generic emulator and `adb` mechanics. This skill adds the KeePassGO-specific build, install, and validation requirements that have already been established in this repo.
## Use This Skill When
- Building `build/keepassgo.apk`.
- Installing or launching KeePassGO in the Android emulator.
- Verifying Android regressions such as black screen, clipboard, open flow, file picker, or Advanced Sync behavior.
- Checking whether a Gio or Android toolchain change broke runtime behavior.
## Known Working Environment
- Preferred emulator AVD: `KeepassGoAPI35`
- Package: `org.julianfamily.keepassgo`
- Activity: `org.gioui.GioActivity`
- Local APK build defaults:
`ANDROID_SDK_ROOT=/opt/android-sdk`
`ANDROID_NDK_ROOT=/opt/android-ndk`
`JAVA_HOME=/usr/lib/jvm/java-25-openjdk`
- CI APK build uses:
`ANDROID_SDK_ROOT=/opt/android-sdk`
`ANDROID_NDK_ROOT=/opt/android-sdk/ndk`
`JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64`
## Known Regression
- `gioui.org v0.9.0` caused a black screen in the `KeepassGoAPI35` emulator.
- `gioui.org v0.8.0` rendered correctly in the same environment.
- If Android rendering regresses after a Gio change, treat Gio as a primary suspect and verify against this known-bad versus known-good history before guessing about app logic.
## Safety Rules
- Do not clobber the users real KeePassGO state while testing.
- For host-side validation, use isolated state such as:
`KEEPASSGO_STATE_DIR="$(mktemp -d)" go test ./...`
- Prefer sanitized demo or test vaults when seeding emulator tests.
- If a real vault is absolutely required to reproduce a problem, do not modify it and do not overwrite the users existing recent-vault state.
## Build Workflow
1. Verify the JDK/SDK paths match the known working environment.
2. Build with `make apk`.
3. If `make apk` fails, inspect the effective `JAVA_HOME`, `ANDROID_SDK_ROOT`, and `ANDROID_NDK_ROOT` before changing code.
4. If the problem is Android-only, avoid desktop-only conclusions from `go test ./...`.
Typical local build:
```sh
JAVA_HOME=/usr/lib/jvm/java-25-openjdk make apk
```
## Emulator Workflow
1. Reuse an existing emulator session if one is already running.
2. Prefer the `KeepassGoAPI35` AVD.
3. Install with `adb install -r build/keepassgo.apk`.
4. Launch with:
```sh
adb shell monkey -p org.julianfamily.keepassgo -c android.intent.category.LAUNCHER 1
```
5. Confirm focus before drawing conclusions:
```sh
adb shell dumpsys window | rg 'mCurrentFocus|mFocusedApp'
```
6. Capture evidence before editing code:
screenshot
focused window
relevant `logcat`
## Validation Checklist
- APK builds successfully with `make apk`.
- App launches to `org.julianfamily.keepassgo/org.gioui.GioActivity`.
- Screenshot shows the expected screen, not just a black frame.
- `logcat` shows no app crash or Android runtime fatal error.
- If the change is about user interaction, perform the tap-through in the emulator instead of stopping at install success.
## Issue-Specific Checks
### Black Screen
- Confirm the app is focused.
- Capture a screenshot and `logcat` before changing anything.
- Compare the current Gio version against the known `v0.9.0` regression history.
- If needed, test whether a minimal Gio example renders in the same emulator before blaming KeePassGO layout code.
### Clipboard
- Verify behavior in the emulator, not just unit tests.
- Android clipboard writes must use the Gio-native command path, not desktop clipboard backends.
### File Picker Or Local Sync
- Verify the picker flow on Android itself.
- Prefer document-stream or content-URI based behavior over raw filesystem paths when Android permissions are involved.
## Report Back
When closing out work, state:
- the exact build command used
- whether the emulator session was reused or newly started
- whether install succeeded
- whether the app actually rendered
- what was verified manually in the emulator
- what remains unverified
+109
View File
@@ -0,0 +1,109 @@
---
name: keepassgo-ship-it
description: KeePassGO-specific ship workflow. Use when the user says `ship it` in this repository and expects the current work to be committed, the Arch package rebuilt and installed, the Android APK rebuilt and zipped, the ZIP uploaded to Nextcloud, and the rebuilt app launched in the emulator with a controlled demo vault opened.
---
# KeePassGO Ship It
Use this skill only in the KeePassGO repository. This is not a global shorthand.
Use it together with:
- `android-emulator-debug` for emulator and `adb` mechanics
- `keepass-credentials` for Nextcloud credentials
- `public-repo-sanitization` before the commit/push step
## Meaning Of `ship it`
When the user says `ship it`, do all of this unless they narrow the scope:
1. Commit the relevant KeePassGO source changes first.
2. Build and install the Arch package from that committed source.
3. Build the Android APK from that same committed source.
4. Zip the APK.
5. Upload the ZIP to the user's configured Nextcloud DAV destination for this repository.
6. Install the rebuilt APK in the emulator.
7. Launch the rebuilt app in the emulator.
8. Open a controlled demo vault in the emulator.
Do not stop after the commit or after the package build. `ship it` means finish the full loop.
## Required Sequence
### 1. Commit First
- Make sure the worktree state intended for shipping is committed before building.
- If the repo is dirty in unrelated ways, commit only the relevant changes.
- Before the commit or push, run the public-repo sanitization checks.
### 2. Build And Install The Arch Package
From the repo root:
```sh
make archlinux-pkgbuild
cd packaging/archlinux/keepassgo-git
makepkg -si --noconfirm
```
The installed package version must correspond to the committed source, not a dirty worktree.
### 3. Build The APK
Use the repo's known-good local JDK unless the environment already proves otherwise:
```sh
JAVA_HOME=/usr/lib/jvm/java-25-openjdk make apk
```
If that JDK is unavailable on the current host, use the working replacement already established for the machine and say so in the closeout.
### 4. Zip The APK
- Create the ZIP under the globally required temporary secret-safe directory.
- Use a name that includes the commit, for example:
`keepassgo-<shortsha>-apk.zip`
### 5. Upload To Nextcloud
- Get credentials and the DAV endpoint with `keepass-http`, not by asking the user if KeePass likely has them.
- Prefer the established KeePass entry and DAV destination already in use for this repository's shipping workflow.
- Use the globally required temporary secret-safe directory for any temporary curl config or secret material.
- Ensure that directory exists with mode `700`.
- Create secret temp files with mode `600`.
- After upload, zero and unlink the temp secret file. Do not use `rm -f` or `rm -rf`.
### 6. Emulator Install And Launch
- Reuse the existing emulator session if one is already running.
- Install with replacement:
```sh
adb install -r build/keepassgo.apk
```
- Launch KeePassGO and confirm it is focused.
- Treat the emulator as timing-sensitive. If Android shows a transient "Wait" style ANR dialog and the user says the app is otherwise fine, do not misclassify that as an app-logic failure.
### 7. Open A Controlled Demo Vault
- Do not rely on the user's real vault for this step.
- Use a controlled/sanitized demo vault that you can unlock yourself.
- Open it in the emulator before closing out `ship it`.
- Capture a screenshot if needed to verify the app really rendered and opened the vault.
## Closeout Requirements
When reporting back after `ship it`, include:
- the commit that was shipped
- the installed Arch package version
- the APK path
- the uploaded ZIP URL
- confirmation that the emulator app was launched
- confirmation that the controlled demo vault was opened
## Constraints
- Keep this workflow specific to KeePassGO.
- Preserve emulator state; do not kill or reset it unless the user explicitly asks.
- Do not use `rm -rf`.
- Do not use `rm -f`.
+179
View File
@@ -0,0 +1,179 @@
name: ci
on:
push:
branches:
- main
tags:
- "v*"
- "release-*"
- "[0-9]+.[0-9]+.[0-9]+*"
permissions:
contents: write
env:
GO_VERSION: "1.26.1"
ANDROID_SDK_ROOT: /opt/android-sdk
ANDROID_NDK_ROOT: /opt/android-sdk/ndk
JAVA_HOME: /usr/lib/jvm/java-21-openjdk-amd64
DIST_DIR: dist
jobs:
lint-test:
runs-on: keepassgo-android
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Install native build dependencies
shell: bash
run: |
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y --no-install-recommends \
zsh \
pkg-config \
libx11-dev \
libx11-xcb-dev \
libxkbcommon-dev \
libxkbcommon-x11-dev \
libwayland-dev \
libvulkan-dev \
libegl1-mesa-dev \
libxcursor-dev \
libxfixes-dev
- name: Lint
shell: bash
run: |
set -euo pipefail
state_dir="$(mktemp -d)"
trap 'rm -rf -- "$state_dir"' EXIT
KEEPASSGO_STATE_DIR="$state_dir" go tool golangci-lint run --build-tags nox11,nowayland,novulkan ./...
- name: Test
shell: bash
run: |
set -euo pipefail
state_dir="$(mktemp -d)"
trap 'rm -rf -- "$state_dir"' EXIT
KEEPASSGO_STATE_DIR="$state_dir" go test -tags nox11,nowayland,novulkan ./...
build:
needs: lint-test
runs-on: keepassgo-android
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Install native build dependencies
shell: bash
run: |
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y --no-install-recommends \
zsh \
pkg-config \
libx11-dev \
libx11-xcb-dev \
libxkbcommon-dev \
libxkbcommon-x11-dev \
libwayland-dev \
libvulkan-dev \
libegl1-mesa-dev \
libxcursor-dev \
libxfixes-dev
- name: Prepare dist directory
shell: bash
run: |
set -euo pipefail
mkdir -p "${DIST_DIR}"
- name: Build desktop binaries
shell: bash
run: |
set -euo pipefail
app_version="$(git describe --tags --always --dirty)"
# Gio needs a Linux ARM64 cgo cross-toolchain for desktop builds.
# Keep the CI matrix to targets this runner can build reproducibly.
for target in \
"linux amd64" \
"windows amd64" \
"windows arm64"
do
set -- ${target}
goos="$1"
goarch="$2"
ext=""
cgo_enabled=0
if [[ "${goos}" == "linux" ]]; then
cgo_enabled=1
fi
if [[ "${goos}" == "windows" ]]; then
ext=".exe"
fi
out="${DIST_DIR}/keepassgo-${goos}-${goarch}${ext}"
GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED="${cgo_enabled}" \
go build -ldflags "-X git.julianfamily.org/keepassgo.appVersion=${app_version}" -o "${out}" ./cmd/keepassgo
done
- name: Build APK
shell: bash
run: |
set -euo pipefail
signkey_path="$(mktemp)"
trap 'rm -f -- "$signkey_path"' EXIT
printf '%s' '${{ secrets.APK_SIGNKEY_B64 }}' | base64 -d > "$signkey_path"
export APP_VERSION="$(git describe --tags --always --dirty)"
make apk SIGNKEY="$signkey_path" SIGNPASS='${{ secrets.APK_SIGNPASS }}'
cp build/keepassgo.apk "${DIST_DIR}/keepassgo.apk"
- name: Upload CI artifacts
uses: christopherhx/gitea-upload-artifact@v4
env:
NODE_OPTIONS: --use-system-ca
SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
with:
name: keepassgo-${{ gitea.sha }}
path: |
dist/keepassgo-linux-amd64
dist/keepassgo-windows-amd64.exe
dist/keepassgo-windows-arm64.exe
dist/keepassgo.apk
retention-days: 30
- name: Publish release artifacts
if: startsWith(gitea.ref, 'refs/tags/')
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_REF_NAME: ${{ gitea.ref_name }}
SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
REQUESTS_CA_BUNDLE: /etc/ssl/certs/ca-certificates.crt
shell: bash
run: |
set -euo pipefail
python3 scripts/gitea_release.py \
--server "${GITEA_SERVER_URL}" \
--repo "${GITEA_REPOSITORY}" \
--token "${GITEA_TOKEN}" \
--tag "${GITEA_REF_NAME}" \
"${DIST_DIR}/keepassgo-linux-amd64" \
"${DIST_DIR}/keepassgo-windows-amd64.exe" \
"${DIST_DIR}/keepassgo-windows-arm64.exe" \
"${DIST_DIR}/keepassgo.apk"
+7
View File
@@ -1,2 +1,9 @@
build/ build/
*.apk *.apk
/keepassgo
android/keepassgo-android.jar
packaging/archlinux/keepassgo-git/*.pkg.tar.zst
packaging/archlinux/keepassgo-git/PKGBUILD
packaging/archlinux/keepassgo-git/pkg/
packaging/archlinux/keepassgo-git/src/
packaging/archlinux/keepassgo-git/keepassgo/
+11
View File
@@ -1,8 +1,19 @@
linters: linters:
enable: enable:
- errcheck - errcheck
- gocyclo
- gosimple - gosimple
- govet - govet
- ineffassign - ineffassign
- staticcheck - staticcheck
- unused - unused
linters-settings:
gocyclo:
min-complexity: 15
issues:
exclude-rules:
- path: _test\.go
linters:
- gocyclo
+63
View File
@@ -14,6 +14,8 @@ These instructions apply to all future work in this repository.
## Skills ## Skills
- Use the installed Go skills whenever they materially apply to the current slice of work. - Use the installed Go skills whenever they materially apply to the current slice of work.
- Use the installed `android-emulator-debug` skill for Android emulator lifecycle, `adb`, screenshots, and log capture work.
- Use the repo-local `keepassgo-apk-test` skill for KeePassGO-specific APK build and emulator test runs.
- The available Go skills for this repository are: - The available Go skills for this repository are:
`go-code-review`, `go-code-review`,
`go-concurrency`, `go-concurrency`,
@@ -93,6 +95,20 @@ These features are product requirements, not “nice to have” ideas.
- Phone should optimize for low tap count, not purity of mobile patterns. - Phone should optimize for low tap count, not purity of mobile patterns.
- The stacked phone layout is the current preferred phone direction. - The stacked phone layout is the current preferred phone direction.
- Do not reintroduce the abandoned phone flow mode unless explicitly requested. - Do not reintroduce the abandoned phone flow mode unless explicitly requested.
- Keep the product feeling like the same application on desktop, Android phone,
and Android tablet.
- Platform adaptation is allowed for layout and spacing, not for changing the
user's mental model of the workflow.
- Use the same action names, the same primary next steps, and the same workflow
order across platforms unless there is a hard platform constraint.
- Treat workflow prominence and reachability as product behavior, not visual
polish. A feature is not parity-complete if it technically exists but is
harder to discover or reach on one platform.
- Prefer shared workflow decisions with platform-specific presentation, rather
than platform-specific workflow branches.
- Make all test strings `Heist Movie` themed. Use characters, crews, casinos,
vaults, and locations from heist movies so test fixtures stay obviously fake
and consistent with the product theme.
## Architecture ## Architecture
@@ -102,6 +118,20 @@ These features are product requirements, not “nice to have” ideas.
- Prefer behavior-oriented tests that describe expected product behavior rather than implementation details. - Prefer behavior-oriented tests that describe expected product behavior rather than implementation details.
- Provide a secure gRPC API as a first-class programmatic surface, not as a thin wrapper around UI state. - Provide a secure gRPC API as a first-class programmatic surface, not as a thin wrapper around UI state.
- Design browser-extension and automation integrations against the gRPC API, not against ad hoc local protocols. - Design browser-extension and automation integrations against the gRPC API, not against ad hoc local protocols.
- Treat the vault model as local-first across all platforms:
every usable vault is a local KDBX file first, and remote sync attaches to
that local vault rather than replacing it as the primary object.
- Keep the remote-sync model standardized across desktop, Android phone, and
Android tablet:
shared remote configuration belongs in the vault,
cross-platform workflow stays the same,
and only Android's initial KDBX share/import transport may differ.
- Do not persist remote credentials in plaintext app-local state.
Keep only non-secret binding metadata outside the vault.
- When working on remote-sync behavior, preserve the local cache / local-first
design:
opening or creating the local vault is the main workflow,
and remote setup, remote settings, and remote use attach to that vault.
## Delivery Discipline ## Delivery Discipline
@@ -112,6 +142,14 @@ These features are product requirements, not “nice to have” ideas.
implement the minimum code to satisfy them, implement the minimum code to satisfy them,
verify with `go test ./...` and relevant lint checks, verify with `go test ./...` and relevant lint checks,
and commit each completed behavior. and commit each completed behavior.
- For cross-platform UI work, behavior tests must cover workflow parity, not
just feature or label parity.
- For lifecycle, open, unlock, sync, and other primary flows, tests should
assert the same conceptual next step across desktop, phone, and tablet
layouts.
- When Android or phone UX is part of the slice, verify real reachability on an
emulator or device for the exact flow being changed. Do not count “the same
buttons exist somewhere on screen” as sufficient validation.
- Only stop before the requirements are satisfied if the work is genuinely blocked by a missing decision, missing external dependency, or a hard technical constraint that cannot be resolved within the repo. - Only stop before the requirements are satisfied if the work is genuinely blocked by a missing decision, missing external dependency, or a hard technical constraint that cannot be resolved within the repo.
- If blocked, state the blocker concretely and stop only at that point. - If blocked, state the blocker concretely and stop only at that point.
@@ -120,14 +158,39 @@ These features are product requirements, not “nice to have” ideas.
- Plan for direct KDBX support. - Plan for direct KDBX support.
- Plan for direct WebDAV-based workflows. - Plan for direct WebDAV-based workflows.
- Avoid adding npm-based or browser-stack dependencies. - Avoid adding npm-based or browser-stack dependencies.
- Keep remote configuration and synchronization local-first:
the app should maintain a live local KDBX cache even when using a remote
store, so remote outage does not eliminate vault access.
- Prefer vault-backed remote setup and lookup over ad hoc local credential
storage.
- On Android, use system picker/share mechanisms for local vault import/export
rather than raw path entry when a user is selecting or sharing a vault file.
## Tooling ## Tooling
- Keep `golangci-lint` passing. - Keep `golangci-lint` passing.
- Keep `go test ./...` passing. - Keep `go test ./...` passing.
- Track `gogio` as a Go tool and keep a reproducible `make apk` path for Android packaging. - Track `gogio` as a Go tool and keep a reproducible `make apk` path for Android packaging.
- Keep the Android build requirements aligned with the known working setup:
`ANDROID_SDK_ROOT=/opt/android-sdk`,
local `ANDROID_NDK_ROOT=/opt/android-ndk`,
CI `ANDROID_NDK_ROOT=/opt/android-sdk/ndk`,
local `JAVA_HOME=/usr/lib/jvm/java-25-openjdk`,
CI `JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64`.
- Remember the known Android runtime regression:
`gioui.org v0.9.0` produced a black screen on the `KeepassGoAPI35` emulator, while `gioui.org v0.8.0` rendered correctly. Treat Gio upgrades on Android as regression-sensitive and verify them on-device or in the emulator.
- When validating an APK in the emulator, prefer the known KeePassGO setup:
AVD `KeepassGoAPI35`,
package `org.julianfamily.keepassgo`,
activity `org.gioui.GioActivity`.
- Do not run emulator/manual APK tests against the users real persisted app state.
Use an isolated `KEEPASSGO_STATE_DIR` for host-side validation, and when emulator testing requires seeded vault data, use sanitized test/demo vaults rather than the users real vault files whenever possible.
- When running tests or other automated validation that may touch persisted UI state, set `KEEPASSGO_STATE_DIR` to an isolated temporary directory so recent-vault history and other local state do not pollute the users real config. - When running tests or other automated validation that may touch persisted UI state, set `KEEPASSGO_STATE_DIR` to an isolated temporary directory so recent-vault history and other local state do not pollute the users real config.
- Prefer commands shaped like `KEEPASSGO_STATE_DIR=\"$(mktemp -d)\" go test ./...` for ad hoc local validation unless a test already manages its own isolated state directory. - Prefer commands shaped like `KEEPASSGO_STATE_DIR=\"$(mktemp -d)\" go test ./...` for ad hoc local validation unless a test already manages its own isolated state directory.
- For the Arch package, treat
`packaging/archlinux/keepassgo-git/PKGBUILD.tmpl`
as the source of truth and regenerate `PKGBUILD` from the template before
building.
- Do not assume the agent can decrypt SOPS-encrypted secrets in this repository. - Do not assume the agent can decrypt SOPS-encrypted secrets in this repository.
- If work requires plaintext from a SOPS-encrypted secret, stop and ask the user to decrypt it or otherwise provide the needed plaintext. - If work requires plaintext from a SOPS-encrypted secret, stop and ask the user to decrypt it or otherwise provide the needed plaintext.
- Do not commit generated binaries. - Do not commit generated binaries.
+3 -2
View File
@@ -12,6 +12,7 @@ Environment:
- `ANDROID_NDK_ROOT` defaults to `/opt/android-ndk`. - `ANDROID_NDK_ROOT` defaults to `/opt/android-ndk`.
- `JAVA_HOME` defaults to `/usr/lib/jvm/java-25-openjdk`. - `JAVA_HOME` defaults to `/usr/lib/jvm/java-25-openjdk`.
- `APP_ID` overrides the Android application id. - `APP_ID` overrides the Android application id.
- `APP_VERSION` overrides the version shown inside KeePassGO itself.
- `APK_OUT` overrides the output path. - `APK_OUT` overrides the output path.
- `APK_VERSION` overrides the packaged app version. - `APK_VERSION` overrides the packaged app version.
- `ANDROID_MIN_SDK` overrides the minimum supported Android SDK. - `ANDROID_MIN_SDK` overrides the minimum supported Android SDK.
@@ -28,12 +29,12 @@ Installed machine prerequisites expected by this repo:
The repo tracks `gogio` as a Go tool, so the build runs through: The repo tracks `gogio` as a Go tool, so the build runs through:
```sh ```sh
go tool gogio -target android ... go tool gogio -target android ./cmd/keepassgo ...
``` ```
The Android build uses the branded icon asset at: The Android build uses the branded icon asset at:
- `assets/keepassgo-icon.png` - `internal/assets/keepassgo-icon.png`
Note: Note:
+29 -3
View File
@@ -5,10 +5,27 @@ PATH := $(JAVA_HOME)/bin:$(ANDROID_SDK_ROOT)/cmdline-tools/latest/bin:$(ANDROID_
APP_ID ?= org.julianfamily.keepassgo APP_ID ?= org.julianfamily.keepassgo
APK_OUT ?= build/keepassgo.apk APK_OUT ?= build/keepassgo.apk
APK_VERSION ?= 0.1.0.1 APK_VERSION ?= 0.1.0.1
APP_VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
GO_LDFLAGS ?= -X git.julianfamily.org/keepassgo/internal/appui.appVersion=$(APP_VERSION)
ANDROID_MIN_SDK ?= 28 ANDROID_MIN_SDK ?= 28
ANDROID_TARGET_SDK ?= 35 ANDROID_TARGET_SDK ?= 35
SIGNKEY ?=
SIGNPASS ?=
ARCH_PKG_DIR ?= packaging/archlinux/keepassgo-git
ARCH_PKG_TMPL ?= $(ARCH_PKG_DIR)/PKGBUILD.tmpl
ARCH_PKGBUILD ?= $(ARCH_PKG_DIR)/PKGBUILD
ARCH_PKGVER ?= $(shell printf 'r%s.%s' "$$(git rev-list --count HEAD 2>/dev/null || echo 0)" "$$(git rev-parse --short HEAD 2>/dev/null || echo dev)")
ARCH_REPO_DIR ?= $(CURDIR)
.PHONY: apk GOGIO_SIGN_FLAGS :=
ifneq ($(strip $(SIGNKEY)),)
GOGIO_SIGN_FLAGS += -signkey $(SIGNKEY)
endif
ifneq ($(strip $(SIGNPASS)),)
GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS)
endif
.PHONY: apk archlinux-pkgbuild
apk: android/keepassgo-android.jar apk: android/keepassgo-android.jar
@test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; } @test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
@test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; } @test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; }
@@ -24,12 +41,14 @@ apk: android/keepassgo-android.jar
go tool gogio -target android \ go tool gogio -target android \
-buildmode exe \ -buildmode exe \
-appid $(APP_ID) \ -appid $(APP_ID) \
-ldflags "$(GO_LDFLAGS)" \
$(GOGIO_SIGN_FLAGS) \
-o $(APK_OUT) \ -o $(APK_OUT) \
-version $(APK_VERSION) \ -version $(APK_VERSION) \
-minsdk $(ANDROID_MIN_SDK) \ -minsdk $(ANDROID_MIN_SDK) \
-targetsdk $(ANDROID_TARGET_SDK) \ -targetsdk $(ANDROID_TARGET_SDK) \
-icon assets/keepassgo-icon.png \ -icon internal/assets/keepassgo-icon.png \
. ./cmd/keepassgo
android/keepassgo-android.jar: $(shell find androidsrc -type f | sort) android/keepassgo-android.jar: $(shell find androidsrc -type f | sort)
@test -x "$(JAVA_HOME)/bin/javac" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; } @test -x "$(JAVA_HOME)/bin/javac" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
@@ -42,3 +61,10 @@ android/keepassgo-android.jar: $(shell find androidsrc -type f | sort)
-d "$$tmpdir" \ -d "$$tmpdir" \
$$(find androidsrc -name '\''*.java'\'' | sort); \ $$(find androidsrc -name '\''*.java'\'' | sort); \
"$(JAVA_HOME)/bin/jar" --create --file "$$(pwd)/android/keepassgo-android.jar" -C "$$tmpdir" .' "$(JAVA_HOME)/bin/jar" --create --file "$$(pwd)/android/keepassgo-android.jar" -C "$$tmpdir" .'
archlinux-pkgbuild: $(ARCH_PKG_TMPL) Makefile
@mkdir -p "$(ARCH_PKG_DIR)"
@sed \
-e 's|@PKGVER@|$(ARCH_PKGVER)|g' \
-e 's|@REPO_DIR@|$(ARCH_REPO_DIR)|g' \
"$(ARCH_PKG_TMPL)" > "$(ARCH_PKGBUILD)"
+27 -2
View File
@@ -38,9 +38,34 @@ KDBX security and KDF compatibility notes are documented in [`docs/kdbx-compatib
Desktop build: Desktop build:
```bash ```bash
go build ./... go build ./cmd/keepassgo
``` ```
By default, build outputs stamp the app version from `git describe --tags --always --dirty`.
You can override the version shown in KeePassGO with:
```bash
go build -ldflags "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=v0.0.1" ./cmd/keepassgo
```
## Arch Linux Package
An AUR-style package definition for the Linux desktop client lives under:
- `packaging/archlinux/keepassgo-git/`
From that directory you can build and install it with:
```bash
makepkg -si
```
The package installs:
- `/usr/bin/keepassgo`
- a desktop entry at `/usr/share/applications/keepassgo.desktop`
- application icons under the hicolor theme
## Android Packaging ## Android Packaging
KeePassGO uses Gio, so Android packaging is done with `gogio`. KeePassGO uses Gio, so Android packaging is done with `gogio`.
@@ -64,7 +89,7 @@ go get -tool gioui.org/cmd/gogio@latest
Package: Package:
```bash ```bash
go tool gogio -target android -icon assets/keepassgo-icon.png . go tool gogio -target android -icon internal/assets/keepassgo-icon.png ./cmd/keepassgo
``` ```
You will need the Android SDK and NDK installed and configured for real device or release packaging. You will need the Android SDK and NDK installed and configured for real device or release packaging.
+31 -241
View File
@@ -11,6 +11,36 @@ The product is not complete until the global exit criteria at the end of this fi
These items came from a hands-on emulator and desktop walkthrough. These items came from a hands-on emulator and desktop walkthrough.
They should be treated as usability work, not just polish. They should be treated as usability work, not just polish.
### Cross-Platform Workflow Parity
These items are required to keep desktop, Android phone, and Android tablet
feeling like the same application rather than three related UIs.
- Workflow parity:
define canonical workflows for open, unlock, set up remote sync, use remote
sync, browse entries, and edit entries.
- Workflow parity:
ensure desktop, phone, and tablet use the same action names and the same
primary next steps for those workflows.
- Workflow parity:
remove or reduce platform-specific workflow exceptions where the same user
intent currently takes a different route on different form factors.
- Testing:
add cross-mode behavior tests that assert workflow order and action
prominence, not just label presence.
- Testing:
add explicit lifecycle/open-screen tests for reachability of the primary
action on desktop, phone, and tablet layouts.
- Testing:
add explicit remote-sync workflow tests that prove setup, settings, use, and
removal are reachable from the same primary affordance family across modes.
- Android verification:
validate changed lifecycle/open/sync workflows on the emulator or a device,
including with the on-screen keyboard visible.
- Android verification:
treat “present but below the fold or behind an unexpected branch” as a parity
failure, not as acceptable platform variation.
### Primary Workflow Changes ### Primary Workflow Changes
These should remain in the main user flow rather than being hidden behind a settings gear. These should remain in the main user flow rather than being hidden behind a settings gear.
@@ -102,195 +132,6 @@ These are important, but they should likely move behind a dedicated settings gea
- Phone and desktop layouts both present a clear information hierarchy. - Phone and desktop layouts both present a clear information hierarchy.
- The Android open flow is reliable enough to review and use without ANR during ordinary vault-open operations. - The Android open flow is reliable enough to review and use without ANR during ordinary vault-open operations.
## API Token And gRPC Authorization Parallel Segments
These segments define the work for programmatic access control over gRPC.
They are designed to be independently landable wherever file overlap permits.
The feature is not complete until all segment exit criteria and the global exit criteria are satisfied.
### API Segment A: Token Domain Model
Scope:
- Represent API tokens as first-class vault-backed records.
- Mark token entries explicitly as API credentials rather than generic passwords.
- Store token metadata:
token id,
hashed secret or verifier,
display name,
client name,
created at,
expires at,
disabled state.
- Keep the persisted representation compatible with KDBX entry fields.
Exit criteria:
- A domain type exists for API tokens and round-trips through the persisted vault model.
- Generic entry listing can distinguish API token entries from ordinary secrets.
- Tests cover create, load, save, and parse behavior for API token entries.
- `go test ./...` passes.
### API Segment B: Token Issuance And Rotation
Scope:
- Generate new API tokens for external tools.
- Return the cleartext token only at creation or explicit rotation time.
- Rotate an existing token while preserving its identity and policy linkage.
- Revoke or disable a token without deleting policy history.
Exit criteria:
- Token issuance, rotation, disable, and revoke operations exist in the domain/service layer.
- Cleartext token material is only exposed on creation or rotation paths.
- Tests cover generation, rotation, and disable/revoke semantics.
- `go test ./...` passes.
### API Segment C: Token Expiration
Scope:
- Allow tokens to have optional expiration timestamps.
- Treat expired tokens as unauthenticated.
- Surface expiration in UI and gRPC management views.
- Support non-expiring tokens explicitly.
Exit criteria:
- Expired tokens are rejected by the gRPC authentication path.
- Token expiration can be created, edited, and removed through the service layer.
- Tests cover valid, expired, and non-expiring token behavior.
- `go test ./...` passes.
### API Segment D: Authorization Policy Model
Scope:
- Define an authorization model for token-scoped access.
- Support allow and deny rules over:
folders/groups,
specific entries,
entry fields where needed,
and operation types.
- Keep specific deny rules higher priority than broad allow rules.
- Model “not yet decided” separately from “denied”.
Exit criteria:
- A policy evaluator exists for token, resource, and operation tuples.
- Explicit deny overrides allow.
- Unspecified access is distinguishable from denied access.
- Tests cover allow, deny, inherited group scope, and exact-entry scope behavior.
- `go test ./...` passes.
### API Segment E: gRPC Authentication And Authorization Enforcement
Scope:
- Replace the current single static bearer-token interceptor with token-backed auth.
- Authenticate callers using issued KeePassGO API tokens.
- Authorize every gRPC method against token policy.
- Apply scope checks to lifecycle, list, read, mutation, copy, and password-generation RPCs.
Exit criteria:
- gRPC requests authenticate through stored API tokens rather than one static shared secret.
- Every RPC enforces token-specific authorization before mutating or revealing vault data.
- Unauthorized requests return the correct authz/authn gRPC status.
- Integration tests cover permitted, denied, expired, and revoked token behavior.
- `go test ./...` passes.
### API Segment F: Approval Queue And Pending Access Requests
Scope:
- When a token requests access to a resource that is neither explicitly allowed nor denied:
create a pending approval request.
- Include:
token identity,
client name,
requested operation,
requested group/entry scope,
requested time,
and permanence choice.
- Allow the request to be accepted, denied, or canceled by the user.
Exit criteria:
- Unspecified access creates a pending approval instead of silently denying or allowing.
- Pending approvals are queryable from the application layer.
- Canceling the prompt results in the API request failing without granting access.
- Tests cover pending creation, approval, denial, and cancellation.
- `go test ./...` passes.
### API Segment G: Approval UI
Scope:
- Show a user-facing approval screen/dialog when a pending API request needs a decision.
- Provide actions:
allow once,
deny once,
allow permanently,
deny permanently,
cancel.
- Make the requested scope and operation clear to the user.
- Ensure the dialog appears only for requests not already decided.
Exit criteria:
- A pending request triggers a visible approval surface in the app.
- The user can allow, deny, or cancel from the UI.
- Permanent decisions become persisted policy rules.
- UI tests cover each approval outcome.
- `go test ./...` passes.
### API Segment H: gRPC Request Blocking And Resume Behavior
Scope:
- Define how an in-flight gRPC call waits for or fails on user approval.
- Hold the request while approval is pending within a bounded timeout.
- Return unauthenticated or permission-denied when denied/canceled/expired.
- Resume the original call automatically when approval is granted.
Exit criteria:
- Pending requests block safely without leaking goroutines.
- Allowed requests resume and complete without the client reissuing the call where practical.
- Denied and canceled requests return a consistent gRPC status code and message.
- Tests cover timeout, allow, deny, and cancel paths.
- `go test ./...` passes.
### API Segment I: Token Management UI
Scope:
- Add UI for listing API tokens.
- Create token flow with one-time secret display.
- Edit token display metadata and expiration.
- Disable, revoke, and rotate tokens.
- Show effective policy summary per token.
Exit criteria:
- Users can manage API tokens from the app UI end to end.
- One-time token display is explicit and not re-shown later.
- Expiration and disable state are visible.
- UI tests cover create, rotate, disable, revoke, and edit flows.
- `go test ./...` passes.
### API Segment J: Policy Management UI
Scope:
- Let users define folder, entry, and operation scopes for each token.
- Show explicit allow and deny rules.
- Show inherited implications of a folder-level rule.
- Let users review prior permanent decisions created from approval prompts.
Exit criteria:
- Users can inspect and edit token policy from the UI.
- Folder-level and entry-level rules are distinguishable and editable.
- Permanent prompt decisions are visible as policy.
- UI tests cover rule creation, update, and deletion.
- `go test ./...` passes.
### API Segment K: Audit And Event History
Scope:
- Record token issuance, rotation, revoke, approval, deny, and prompt outcomes.
- Record authorization failures and expirations without logging secret material.
- Provide a bounded event history visible in the UI and/or gRPC admin surface.
Exit criteria:
- Security-relevant API token events are captured without secret leakage.
- Approval outcomes and policy changes are auditable.
- Tests cover audit generation for the main token lifecycle and approval actions.
- `go test ./...` passes.
### Segment 1: Application State Ownership ### Segment 1: Application State Ownership
Scope: Scope:
@@ -359,19 +200,6 @@ Exit criteria:
- Validation and visible error states exist for missing or invalid key material. - Validation and visible error states exist for missing or invalid key material.
- `go test ./...` passes. - `go test ./...` passes.
### Segment 5: KDBX Security Settings Preservation
Scope:
- Preserve supported cipher and KDF settings when reopening and saving.
- Surface relevant settings in product-facing docs or UI where appropriate.
- Document unsupported settings explicitly.
Exit criteria:
- Reopen-and-save cycles preserve supported KDBX security settings.
- Compatibility notes are current in `docs/kdbx-compatibility.md`.
- Tests cover settings preservation across save cycles.
- `go test ./...` passes.
### Segment 6: Entry CRUD UI ### Segment 6: Entry CRUD UI
Scope: Scope:
@@ -577,33 +405,6 @@ Exit criteria:
- UI tests or controller-integrated tests cover these states. - UI tests or controller-integrated tests cover these states.
- `go test ./...` passes. - `go test ./...` passes.
### Segment 18: Desktop Automation Resolution
Scope:
- Either implement a desktop login automation mechanism comparable in purpose to KeePass auto-type,
- or explicitly finalize the design that secure gRPC supersedes auto-type.
- Keep the decision documented in-repo.
Exit criteria:
- The desktop automation requirement is explicitly resolved in code or docs.
- The chosen approach is documented in `docs/desktop-automation.md`.
- Any implemented behavior is tested.
- `go test ./...` passes.
### Segment 19: Packaging And Runbook
Scope:
- Keep the app runnable from source.
- Document desktop build and run steps.
- Document Android packaging with `gogio`.
- Add icon and metadata placeholders if missing.
Exit criteria:
- `README.md` is accurate for local build, run, and Android packaging guidance.
- Placeholder metadata exists where needed for packaging.
- The app still builds from the repo.
- `go test ./...` passes.
### Segment 20: Regression And Integration Coverage ### Segment 20: Regression And Integration Coverage
Scope: Scope:
@@ -621,11 +422,10 @@ Exit criteria:
Do not treat the product as complete until all of the following are true: Do not treat the product as complete until all of the following are true:
- Segment 1 through Segment 20 are all complete. - All remaining numbered segments, API segments, and UI review follow-ups are complete.
- KeePassGO can create, open, edit, save, save-as, lock, and unlock local KDBX databases through the UI. - KeePassGO can create, open, edit, save, save-as, lock, and unlock local KDBX databases through the UI.
- KeePassGO can open and save remote WebDAV-backed KDBX databases through the UI, including visible conflict and error handling. - KeePassGO can open and save remote WebDAV-backed KDBX databases through the UI, including visible conflict and error handling.
- KeePassGO supports master password, key file, and composite key workflows in the product. - KeePassGO supports master password, key file, and composite key workflows in the product.
- KeePassGO preserves supported KDBX security and KDF settings and documents unsupported settings.
- KeePassGO supports nested groups, path-aware navigation, explicit template navigation, and explicit recycle-bin navigation. - KeePassGO supports nested groups, path-aware navigation, explicit template navigation, and explicit recycle-bin navigation.
- KeePassGO supports entry create, edit, duplicate, delete, restore, history browse, and history restore through the UI. - KeePassGO supports entry create, edit, duplicate, delete, restore, history browse, and history restore through the UI.
- KeePassGO supports title, username, password, URL, notes, tags, and custom string fields through the UI. - KeePassGO supports title, username, password, URL, notes, tags, and custom string fields through the UI.
@@ -635,17 +435,7 @@ Do not treat the product as complete until all of the following are true:
- KeePassGO supports copy username, copy password, copy URL, and reveal or hide password behavior end to end. - KeePassGO supports copy username, copy password, copy URL, and reveal or hide password behavior end to end.
- KeePassGO exposes password generation profiles through both UI and gRPC. - KeePassGO exposes password generation profiles through both UI and gRPC.
- The secure gRPC API is broad enough for trusted automation and browser-extension-style integration. - The secure gRPC API is broad enough for trusted automation and browser-extension-style integration.
- The desktop automation requirement is explicitly resolved.
- Keyboard-first navigation and common shortcuts exist for major product workflows. - Keyboard-first navigation and common shortcuts exist for major product workflows.
- The UI no longer depends on prototype-only mock behavior for any core workflow. - The UI no longer depends on prototype-only mock behavior for any core workflow.
- Build and run instructions exist for desktop, and packaging guidance exists for Android.
- `go test ./...` passes. - `go test ./...` passes.
- `go tool golangci-lint run ./...` passes. - `go tool golangci-lint run ./...` passes.
## Remaining Gaps Against AGENTS.md
None currently identified.
The last explicitly tracked gaps are now closed:
- KDBX security settings are product-configurable at the major cipher/KDF family level for both new vault creation and existing sessions.
- The current accessibility support boundary is documented in `docs/accessibility.md`, while in-repo focus and labeling behavior remains tested.
+23
View File
@@ -22,3 +22,26 @@
android:name="android.accessibilityservice" android:name="android.accessibilityservice"
android:resource="@xml/keepassgo_accessibility_service" /> android:resource="@xml/keepassgo_accessibility_service" />
</service> </service>
<provider
android:name="org.julianfamily.keepassgo.SharedVaultProvider"
android:authorities="org.julianfamily.keepassgo.share"
android:exported="false"
android:grantUriPermissions="true" />
<activity
android:name="org.julianfamily.keepassgo.SharedVaultImportActivity"
android:exported="true"
android:theme="@android:style/Theme.Translucent.NoTitleBar">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/octet-stream" />
<data android:mimeType="application/x-keepass2" />
<data android:mimeType="application/vnd.keepass" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="content" android:pathPattern=".*\\.kdbx" />
<data android:scheme="file" android:pathPattern=".*\\.kdbx" />
</intent-filter>
</activity>
Binary file not shown.
@@ -0,0 +1,81 @@
package org.julianfamily.keepassgo;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
public final class AndroidShare {
private static final String DEFAULT_TITLE = "KeePassGO Vault";
private AndroidShare() {
}
public static void shareVault(Context context, String path, String title) throws IOException {
File source = new File(path);
if (!source.isFile()) {
throw new IOException("vault file not found: " + path);
}
File shared = copyToSharedExport(context, source);
Uri uri = SharedVaultProvider.uriForFile(shared.getName());
Intent send = new Intent(Intent.ACTION_SEND);
send.setType("application/x-keepass2");
send.putExtra(Intent.EXTRA_STREAM, uri);
send.putExtra(Intent.EXTRA_TITLE, sanitizeTitle(title, source.getName()));
send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
Intent chooser = Intent.createChooser(send, "Share vault");
chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
chooser.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
context.startActivity(chooser);
}
static File sharedDirectory(Context context) {
return new File(new File(context.getFilesDir(), "keepassgo"), "shared");
}
private static File copyToSharedExport(Context context, File source) throws IOException {
File dir = sharedDirectory(context);
if (!dir.exists() && !dir.mkdirs()) {
throw new IOException("failed to create " + dir.getAbsolutePath());
}
File target = new File(dir, sanitizeFilename(source.getName()));
try (FileInputStream in = new FileInputStream(source);
FileOutputStream out = new FileOutputStream(target, false)) {
byte[] buffer = new byte[8192];
int count;
while ((count = in.read(buffer)) >= 0) {
out.write(buffer, 0, count);
}
}
return target;
}
private static String sanitizeFilename(String name) {
String trimmed = name == null ? "" : name.trim();
if (trimmed.isEmpty()) {
return "shared-vault.kdbx";
}
if (trimmed.endsWith(".kdbx")) {
return trimmed;
}
return trimmed + ".kdbx";
}
private static String sanitizeTitle(String title, String fallbackName) {
String trimmed = title == null ? "" : title.trim();
if (!trimmed.isEmpty()) {
return trimmed;
}
String fallback = fallbackName == null ? "" : fallbackName.trim();
if (!fallback.isEmpty()) {
return fallback;
}
return DEFAULT_TITLE;
}
}
@@ -0,0 +1,131 @@
package org.julianfamily.keepassgo;
import android.app.Activity;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.provider.OpenableColumns;
import android.util.Log;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
public final class SharedVaultImportActivity extends Activity {
private static final String TAG = "KeePassGOImport";
private static final String DEFAULT_NAME = "shared-vault.kdbx";
@Override
protected void onCreate(Bundle state) {
super.onCreate(state);
handleIntent(getIntent());
launchMainActivity();
finish();
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
handleIntent(intent);
launchMainActivity();
finish();
}
private void handleIntent(Intent intent) {
Uri uri = resolveSharedUri(intent);
if (uri == null) {
Log.i(TAG, "no shared vault URI on intent");
return;
}
try {
persistPendingImport(uri);
Log.i(TAG, "queued shared vault import from " + uri);
} catch (IOException | RuntimeException err) {
Log.e(TAG, "failed to queue shared vault import", err);
}
}
private Uri resolveSharedUri(Intent intent) {
if (intent == null) {
return null;
}
String action = intent.getAction();
if (Intent.ACTION_SEND.equals(action)) {
return intent.getParcelableExtra(Intent.EXTRA_STREAM);
}
if (Intent.ACTION_VIEW.equals(action)) {
return intent.getData();
}
return null;
}
private void persistPendingImport(Uri uri) throws IOException {
File dir = new File(getFilesDir(), "keepassgo");
if (!dir.exists() && !dir.mkdirs()) {
throw new IOException("failed to create " + dir.getAbsolutePath());
}
File pendingFile = new File(dir, "pending-shared-vault.kdbx");
try (InputStream in = getContentResolver().openInputStream(uri)) {
if (in == null) {
throw new IOException("failed to open shared vault stream");
}
try (FileOutputStream out = new FileOutputStream(pendingFile, false)) {
byte[] buffer = new byte[8192];
int count;
while ((count = in.read(buffer)) >= 0) {
out.write(buffer, 0, count);
}
}
}
File nameFile = new File(dir, "pending-shared-vault-name.txt");
try (FileOutputStream out = new FileOutputStream(nameFile, false)) {
out.write(resolveDisplayName(uri).getBytes(StandardCharsets.UTF_8));
}
}
private String resolveDisplayName(Uri uri) {
String displayName = queryDisplayName(uri);
if (!displayName.isEmpty()) {
return displayName;
}
String lastSegment = uri.getLastPathSegment();
if (lastSegment != null && !lastSegment.trim().isEmpty()) {
return lastSegment.trim();
}
return DEFAULT_NAME;
}
private String queryDisplayName(Uri uri) {
Cursor cursor = null;
try {
cursor = getContentResolver().query(uri, new String[]{OpenableColumns.DISPLAY_NAME}, null, null, null);
if (cursor != null && cursor.moveToFirst()) {
int index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
if (index >= 0) {
String value = cursor.getString(index);
if (value != null) {
return value.trim();
}
}
}
} catch (RuntimeException err) {
Log.w(TAG, "failed to query display name", err);
} finally {
if (cursor != null) {
cursor.close();
}
}
return "";
}
private void launchMainActivity() {
Intent launch = new Intent();
launch.setClassName(this, "org.gioui.GioActivity");
startActivity(launch);
}
}
@@ -0,0 +1,100 @@
package org.julianfamily.keepassgo;
import android.content.ContentProvider;
import android.content.ContentValues;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.provider.OpenableColumns;
import java.io.File;
import java.io.FileNotFoundException;
public final class SharedVaultProvider extends ContentProvider {
private static final String AUTHORITY = "org.julianfamily.keepassgo.share";
@Override
public boolean onCreate() {
return true;
}
@Override
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
File file = resolveSharedFile(uri);
String[] columns = projection;
if (columns == null || columns.length == 0) {
columns = new String[]{OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE};
}
MatrixCursor cursor = new MatrixCursor(columns, 1);
Object[] row = new Object[columns.length];
for (int i = 0; i < columns.length; i++) {
switch (columns[i]) {
case OpenableColumns.DISPLAY_NAME:
row[i] = file.getName();
break;
case OpenableColumns.SIZE:
row[i] = file.length();
break;
default:
row[i] = null;
break;
}
}
cursor.addRow(row);
return cursor;
}
@Override
public String getType(Uri uri) {
return "application/x-keepass2";
}
@Override
public Uri insert(Uri uri, ContentValues values) {
throw new UnsupportedOperationException("insert is not supported");
}
@Override
public int delete(Uri uri, String selection, String[] selectionArgs) {
throw new UnsupportedOperationException("delete is not supported");
}
@Override
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
throw new UnsupportedOperationException("update is not supported");
}
@Override
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
File file = resolveSharedFile(uri);
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
}
static Uri uriForFile(String name) {
return new Uri.Builder()
.scheme("content")
.authority(AUTHORITY)
.appendPath(name)
.build();
}
private File resolveSharedFile(Uri uri) {
if (getContext() == null) {
throw new IllegalStateException("provider context is unavailable");
}
String name = sanitizeFilename(uri.getLastPathSegment());
return new File(AndroidShare.sharedDirectory(getContext()), name);
}
private static String sanitizeFilename(String name) {
if (name == null) {
return "shared-vault.kdbx";
}
String trimmed = name.trim();
if (trimmed.isEmpty()) {
return "shared-vault.kdbx";
}
return new File(trimmed).getName();
}
}
+6 -2
View File
@@ -13,9 +13,10 @@ const (
DefaultAppID = "org.julianfamily.keepassgo" DefaultAppID = "org.julianfamily.keepassgo"
DefaultAPKOut = "build/keepassgo.apk" DefaultAPKOut = "build/keepassgo.apk"
DefaultVersion = "0.1.0.1" DefaultVersion = "0.1.0.1"
DefaultLdflags = "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=dev"
DefaultMinSDK = "28" DefaultMinSDK = "28"
DefaultTargetSDK = "35" DefaultTargetSDK = "35"
DefaultIconPath = "assets/keepassgo-icon.png" DefaultIconPath = "internal/assets/keepassgo-icon.png"
) )
type Config struct { type Config struct {
@@ -25,6 +26,7 @@ type Config struct {
AppID string AppID string
APKOut string APKOut string
Version string Version string
Ldflags string
MinSDK string MinSDK string
TargetSDK string TargetSDK string
IconPath string IconPath string
@@ -38,6 +40,7 @@ func DefaultConfig() Config {
AppID: DefaultAppID, AppID: DefaultAppID,
APKOut: DefaultAPKOut, APKOut: DefaultAPKOut,
Version: DefaultVersion, Version: DefaultVersion,
Ldflags: DefaultLdflags,
MinSDK: DefaultMinSDK, MinSDK: DefaultMinSDK,
TargetSDK: DefaultTargetSDK, TargetSDK: DefaultTargetSDK,
IconPath: DefaultIconPath, IconPath: DefaultIconPath,
@@ -49,12 +52,13 @@ func (c Config) GogioArgs() []string {
"-target", "android", "-target", "android",
"-buildmode", "exe", "-buildmode", "exe",
"-appid", c.AppID, "-appid", c.AppID,
"-ldflags", c.Ldflags,
"-o", c.APKOut, "-o", c.APKOut,
"-version", c.Version, "-version", c.Version,
"-minsdk", c.MinSDK, "-minsdk", c.MinSDK,
"-targetsdk", c.TargetSDK, "-targetsdk", c.TargetSDK,
"-icon", c.IconPath, "-icon", c.IconPath,
".", "./cmd/keepassgo",
} }
} }
+4 -1
View File
@@ -15,12 +15,13 @@ func TestDefaultConfigGogioArgs(t *testing.T) {
"-target", "android", "-target", "android",
"-buildmode", "exe", "-buildmode", "exe",
"-appid", DefaultAppID, "-appid", DefaultAppID,
"-ldflags", DefaultLdflags,
"-o", DefaultAPKOut, "-o", DefaultAPKOut,
"-version", DefaultVersion, "-version", DefaultVersion,
"-minsdk", DefaultMinSDK, "-minsdk", DefaultMinSDK,
"-targetsdk", DefaultTargetSDK, "-targetsdk", DefaultTargetSDK,
"-icon", DefaultIconPath, "-icon", DefaultIconPath,
".", "./cmd/keepassgo",
} }
if got := cfg.GogioArgs(); !slices.Equal(got, want) { if got := cfg.GogioArgs(); !slices.Equal(got, want) {
@@ -52,6 +53,7 @@ func TestValidateAcceptsCompleteAndroidToolchainLayout(t *testing.T) {
AppID: DefaultAppID, AppID: DefaultAppID,
APKOut: DefaultAPKOut, APKOut: DefaultAPKOut,
Version: DefaultVersion, Version: DefaultVersion,
Ldflags: DefaultLdflags,
MinSDK: DefaultMinSDK, MinSDK: DefaultMinSDK,
TargetSDK: DefaultTargetSDK, TargetSDK: DefaultTargetSDK,
IconPath: filepath.Join(root, "icon.png"), IconPath: filepath.Join(root, "icon.png"),
@@ -77,6 +79,7 @@ func TestValidateRejectsMissingPrerequisites(t *testing.T) {
AppID: DefaultAppID, AppID: DefaultAppID,
APKOut: DefaultAPKOut, APKOut: DefaultAPKOut,
Version: DefaultVersion, Version: DefaultVersion,
Ldflags: DefaultLdflags,
MinSDK: DefaultMinSDK, MinSDK: DefaultMinSDK,
TargetSDK: DefaultTargetSDK, TargetSDK: DefaultTargetSDK,
} }
+7
View File
@@ -0,0 +1,7 @@
package main
import "git.julianfamily.org/keepassgo/internal/appui"
func main() {
appui.Main()
}
+1 -1
View File
@@ -15,7 +15,7 @@ KeePassGO currently targets keyboard-first desktop use on Linux and Windows.
- list navigation - list navigation
- search focus - search focus
- new-entry focus transitions - new-entry focus transitions
- Controls that participate in keyboard navigation have intent-revealing accessibility labels through `accessibilityLabel` in [`ui_accessibility.go`](/home/jjulian/dev/go/src/git.julianfamily.org/keepassgo/ui_accessibility.go). - Controls that participate in keyboard navigation have intent-revealing accessibility labels through `accessibilityLabel` in [`ui_accessibility.go`](/workspace/keepassgo/ui_accessibility.go).
## Current screen-reader boundary ## Current screen-reader boundary
+148
View File
@@ -0,0 +1,148 @@
# Local-First Remote Sync Plan
## Goal
Redesign remote-backed vault handling so every platform uses the same local-first model:
- every vault is a local KDBX file first
- remote sync is an optional binding on top of that local file
- shared remote configuration lives in the vault
- user-specific remote credentials live in the vault
- app-local state stores only non-secret binding metadata
Android adds only one platform-specific capability on top of that model:
- share/import the initial local KDBX file between devices
## Product Rules
1. A remote-backed vault must always have a local cache KDBX file.
2. Opening a remote-backed vault should open the local KDBX first.
3. Shared remote configuration must be stored in the vault, not only in app state.
4. Remote credentials must not be stored in plaintext app-local state.
5. Remote credentials should be stored in the vault and resolved by a stable reference.
6. The app state file should keep only the metadata needed to reopen the local vault and find the remote binding.
7. Sync must support both manual and automatic modes.
8. Android-specific sharing should transfer the KDBX file, not a bespoke remote-secret bundle.
## Target Data Model
### In-Vault Shared Remote Profile
Store a reusable remote profile in the vault with fields such as:
- profile ID
- profile name
- backend type, initially WebDAV
- base URL
- remote object path
- optional notes or labels
- default sync policy, if shared defaults are desirable
### In-Vault User Credential Binding
Store user-specific credentials in the vault as normal vault data, referenced by:
- remote profile ID
- credential entry UUID, or another stable internal reference
- optional username field override if needed
The credential entry should contain the actual username/password or token.
### Local App State
Persist only non-secret binding state such as:
- local vault path
- selected remote profile ID
- selected credential entry reference
- sync policy override
- last sync metadata
- conflict or recovery markers
## Core Flows
### Create Or Configure Remote Sync
1. Open or create a local vault.
2. Create or edit a shared remote profile in that vault.
3. Create or select a credential entry in that vault.
4. Bind the local vault to the selected remote profile and credential reference.
5. Choose manual or automatic sync behavior.
### Reopen Existing Remote-Backed Vault
1. Open the local vault file from app state.
2. Resolve the selected remote profile from vault contents.
3. Resolve the credential entry from vault contents.
4. Offer or perform sync based on the binding policy.
### Bootstrap A New Android Device
1. Share the local KDBX file through Android Sharesheet.
2. Import and open that KDBX locally on the new device.
3. Select a remote profile stored in the vault.
4. Select or create that users credential entry in the vault.
5. Bind the local vault as the cache for the remote-backed setup.
## Migration Requirements
1. Migrate existing remote connections that save credentials in app state.
2. On first open after upgrade, move any recoverable remote credentials into the vault.
3. Replace saved plaintext credential state with a vault credential reference.
4. If migration cannot write into the vault yet, hold the old state only long enough to prompt the user to complete migration.
5. Remove legacy local plaintext credential persistence after migration is complete.
## Implementation Phases
### Phase 1: Domain Model
- define remote profile structures independent of Gio UI
- define credential reference structures independent of Gio UI
- define sync binding state independent of Gio UI
- add behavior tests for local-first remote-backed vaults
### Phase 2: Vault Storage
- persist remote profiles in the vault
- persist credential references in the vault
- resolve credentials from normal vault entries
- add behavior tests for read/write and lookup semantics
### Phase 3: State And Open Flow
- shrink app state to non-secret metadata only
- update open flows to always prefer the local cache vault
- update reopen behavior on all platforms to use the same model
- add migration coverage for old remote state
### Phase 4: Sync Binding
- bind a local vault to a selected remote profile
- support manual sync
- support automatic sync on open/save
- define conflict and remote-failure handling for the local cache model
### Phase 5: Android Bootstrap
- add Android Sharesheet export of the current local KDBX
- add Android import flow for a shared KDBX
- keep the remote pivot flow consistent with desktop after local open
## Open Questions
1. Where in the vault should remote profiles live: custom metadata, dedicated entries, or another KDBX-compatible structure?
2. Should credential references point to entry UUIDs directly, or should KeePassGO maintain an additional logical identifier?
3. Should automatic sync run only on open/save initially, or also on app resume?
4. How should multiple remote profiles per vault be presented in the UI?
5. What should happen when the credential entry reference no longer resolves?
## Recommended First Slice
Implement the shared domain model and tests first:
- model a local vault plus optional remote binding
- define in-vault remote profile and credential reference semantics
- add tests proving app state no longer needs plaintext remote credentials
That slice standardizes the architecture before any Android-specific sharing work begins.
+4 -3
View File
@@ -2,10 +2,9 @@ module git.julianfamily.org/keepassgo
go 1.26 go 1.26
replace gioui.org/cmd => ./third_party/gioui-cmd
require ( require (
gioui.org v0.8.0 gioui.org v0.8.0
gioui.org/x v0.8.0
github.com/atotto/clipboard v0.1.4 github.com/atotto/clipboard v0.1.4
github.com/tobischo/gokeepasslib/v3 v3.6.2 github.com/tobischo/gokeepasslib/v3 v3.6.2
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90 golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90
@@ -18,6 +17,7 @@ require (
4d63.com/gochecknoglobals v0.2.2 // indirect 4d63.com/gochecknoglobals v0.2.2 // indirect
gioui.org/cmd v0.8.0 // indirect gioui.org/cmd v0.8.0 // indirect
gioui.org/shader v1.0.8 // indirect gioui.org/shader v1.0.8 // indirect
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 // indirect
github.com/4meepo/tagalign v1.4.2 // indirect github.com/4meepo/tagalign v1.4.2 // indirect
github.com/Abirdcfly/dupword v0.1.3 // indirect github.com/Abirdcfly/dupword v0.1.3 // indirect
github.com/Antonboom/errname v1.0.0 // indirect github.com/Antonboom/errname v1.0.0 // indirect
@@ -74,6 +74,7 @@ require (
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect github.com/gobwas/glob v0.2.3 // indirect
github.com/godbus/dbus/v5 v5.0.6 // indirect
github.com/gofrs/flock v0.12.1 // indirect github.com/gofrs/flock v0.12.1 // indirect
github.com/golang/protobuf v1.5.4 // indirect github.com/golang/protobuf v1.5.4 // indirect
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
@@ -192,7 +193,7 @@ require (
go.uber.org/multierr v1.6.0 // indirect go.uber.org/multierr v1.6.0 // indirect
go.uber.org/zap v1.24.0 // indirect go.uber.org/zap v1.24.0 // indirect
golang.org/x/crypto v0.48.0 // indirect golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect
golang.org/x/image v0.37.0 // indirect golang.org/x/image v0.37.0 // indirect
golang.org/x/mod v0.33.0 // indirect golang.org/x/mod v0.33.0 // indirect
+10 -2
View File
@@ -39,9 +39,15 @@ eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKw
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA= eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
gioui.org v0.8.0 h1:QV5p5JvsmSmGiIXVYOKn6d9YDliTfjtLlVf5J+BZ9Pg= gioui.org v0.8.0 h1:QV5p5JvsmSmGiIXVYOKn6d9YDliTfjtLlVf5J+BZ9Pg=
gioui.org v0.8.0/go.mod h1:vEMmpxMOd/iwJhXvGVIzWEbxMWhnMQ9aByOGQdlQ8rc= gioui.org v0.8.0/go.mod h1:vEMmpxMOd/iwJhXvGVIzWEbxMWhnMQ9aByOGQdlQ8rc=
gioui.org/cmd v0.8.0 h1:oy5qOlc1UXcglc5HBCMZQELiIzQ2obhT98mw+SuWafQ=
gioui.org/cmd v0.8.0/go.mod h1:wKLAyAgRR25VMYFzGX2Ecia0m0Td562wDcZ3LaPHPTI=
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA= gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM= gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
gioui.org/x v0.8.0 h1:RhIlQNOFKKn8D8FeaKKaXCo7vB3x+fq4VcD10HW/YpA=
gioui.org/x v0.8.0/go.mod h1:aXtQb+kyqoUOjDl5/uMqAopjzVzMkeHBbMQOGT5KnSE=
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 h1:bGG/g4ypjrCJoSvFrP5hafr9PPB5aw8SjcOWWila7ZI=
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0/go.mod h1:+axXBRUTIDlCeE73IKeD/os7LoEnTKdkp8/gQOFjqyo=
github.com/4meepo/tagalign v1.4.2 h1:0hcLHPGMjDyM1gHG58cS73aQF8J4TdVR96TZViorO9E= github.com/4meepo/tagalign v1.4.2 h1:0hcLHPGMjDyM1gHG58cS73aQF8J4TdVR96TZViorO9E=
github.com/4meepo/tagalign v1.4.2/go.mod h1:+p4aMyFM+ra7nb41CnFG6aSDXqRxU/w1VQqScKqDARI= github.com/4meepo/tagalign v1.4.2/go.mod h1:+p4aMyFM+ra7nb41CnFG6aSDXqRxU/w1VQqScKqDARI=
github.com/Abirdcfly/dupword v0.1.3 h1:9Pa1NuAsZvpFPi9Pqkd93I7LIYRURj+A//dFd5tgBeE= github.com/Abirdcfly/dupword v0.1.3 h1:9Pa1NuAsZvpFPi9Pqkd93I7LIYRURj+A//dFd5tgBeE=
@@ -224,6 +230,8 @@ github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8=
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo= github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
github.com/godbus/dbus/v5 v5.0.6 h1:mkgN1ofwASrYnJ5W6U/BxG15eXXXjirgZc7CLqkcaro=
github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
@@ -669,8 +677,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90 h1:kyPrwnEYXdME284bE7xgS9BPxhG7MCa5hw1/TpaTJVs= golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90 h1:kyPrwnEYXdME284bE7xgS9BPxhG7MCa5hw1/TpaTJVs=
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:jqkJFnLVkS8zgKKY4+MOPCZtuZGw3hONUjhapUSwZ8c= golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:jqkJFnLVkS8zgKKY4+MOPCZtuZGw3hONUjhapUSwZ8c=
golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
+5 -5
View File
@@ -7,11 +7,11 @@ import (
"strings" "strings"
"sync" "sync"
"git.julianfamily.org/keepassgo/clipboard" "git.julianfamily.org/keepassgo/internal/clipboard"
"git.julianfamily.org/keepassgo/passwords" "git.julianfamily.org/keepassgo/internal/passwords"
"git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/internal/vault"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1" keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
"git.julianfamily.org/keepassgo/session"
"git.julianfamily.org/keepassgo/vault"
"google.golang.org/grpc" "google.golang.org/grpc"
) )
@@ -41,7 +41,7 @@ func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]pass
} }
service := NewServerWithLifecycle(vault.Model{}, profiles, clipboardWriter, lifecycle) service := NewServerWithLifecycle(vault.Model{}, profiles, clipboardWriter, lifecycle)
server := grpc.NewServer(grpc.UnaryInterceptor(AuthInterceptor(service))) server := grpc.NewServer()
keepassgov1.RegisterVaultServiceServer(server, service) keepassgov1.RegisterVaultServiceServer(server, service)
host := &Host{ host := &Host{
+15 -5
View File
@@ -5,10 +5,11 @@ import (
"net" "net"
"testing" "testing"
"git.julianfamily.org/keepassgo/passwords" "git.julianfamily.org/keepassgo/internal/apitokens"
"git.julianfamily.org/keepassgo/internal/passwords"
"git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/internal/vault"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1" keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
"git.julianfamily.org/keepassgo/session"
"git.julianfamily.org/keepassgo/vault"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/credentials/insecure"
) )
@@ -19,8 +20,17 @@ func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) {
lifecycle := &session.Manager{} lifecycle := &session.Manager{}
if err := lifecycle.Create(vault.Model{ if err := lifecycle.Create(vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
testAPITokenEntry(t), testAPITokenEntry(t,
{ID: "entry-1", Title: "Git Server", Path: []string{"Root", "Internet"}}, apitokens.PolicyRule{
Effect: apitokens.EffectAllow,
Operation: apitokens.OperationManageVault,
Resource: apitokens.Resource{
Kind: apitokens.ResourceGroup,
Path: []string{"Root"},
},
},
),
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
}, },
}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil { }, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
t.Fatalf("Create() error = %v", err) t.Fatalf("Create() error = %v", err)
+79 -45
View File
@@ -10,16 +10,15 @@ import (
"sync" "sync"
"time" "time"
"git.julianfamily.org/keepassgo/apiaudit" "git.julianfamily.org/keepassgo/internal/apiapproval"
"git.julianfamily.org/keepassgo/apiapproval" "git.julianfamily.org/keepassgo/internal/apiaudit"
"git.julianfamily.org/keepassgo/apitokens" "git.julianfamily.org/keepassgo/internal/apitokens"
"git.julianfamily.org/keepassgo/clipboard" "git.julianfamily.org/keepassgo/internal/clipboard"
"git.julianfamily.org/keepassgo/passwords" "git.julianfamily.org/keepassgo/internal/passwords"
"git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/internal/webdav"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1" keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
"git.julianfamily.org/keepassgo/session"
"git.julianfamily.org/keepassgo/vault"
"git.julianfamily.org/keepassgo/webdav"
"google.golang.org/grpc"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata" "google.golang.org/grpc/metadata"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
@@ -89,7 +88,10 @@ func (s *Server) SetSessionState(model vault.Model, locked, dirty bool) {
s.dirty = dirty s.dirty = dirty
} }
func (s *Server) GetSessionStatus(_ context.Context, _ *keepassgov1.GetSessionStatusRequest) (*keepassgov1.GetSessionStatusResponse, error) { func (s *Server) GetSessionStatus(ctx context.Context, _ *keepassgov1.GetSessionStatusRequest) (*keepassgov1.GetSessionStatusResponse, error) {
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil {
return nil, err
}
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
@@ -100,7 +102,10 @@ func (s *Server) GetSessionStatus(_ context.Context, _ *keepassgov1.GetSessionSt
}, nil }, nil
} }
func (s *Server) OpenVault(_ context.Context, req *keepassgov1.OpenVaultRequest) (*keepassgov1.OpenVaultResponse, error) { func (s *Server) OpenVault(ctx context.Context, req *keepassgov1.OpenVaultRequest) (*keepassgov1.OpenVaultResponse, error) {
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil {
return nil, err
}
if s.lifecycle == nil { if s.lifecycle == nil {
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured") return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
} }
@@ -124,7 +129,10 @@ func (s *Server) OpenVault(_ context.Context, req *keepassgov1.OpenVaultRequest)
return &keepassgov1.OpenVaultResponse{}, nil return &keepassgov1.OpenVaultResponse{}, nil
} }
func (s *Server) OpenRemoteVault(_ context.Context, req *keepassgov1.OpenRemoteVaultRequest) (*keepassgov1.OpenRemoteVaultResponse, error) { func (s *Server) OpenRemoteVault(ctx context.Context, req *keepassgov1.OpenRemoteVaultRequest) (*keepassgov1.OpenRemoteVaultResponse, error) {
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil {
return nil, err
}
if s.lifecycle == nil { if s.lifecycle == nil {
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured") return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
} }
@@ -153,7 +161,10 @@ func (s *Server) OpenRemoteVault(_ context.Context, req *keepassgov1.OpenRemoteV
return &keepassgov1.OpenRemoteVaultResponse{}, nil return &keepassgov1.OpenRemoteVaultResponse{}, nil
} }
func (s *Server) SaveVault(_ context.Context, _ *keepassgov1.SaveVaultRequest) (*keepassgov1.SaveVaultResponse, error) { func (s *Server) SaveVault(ctx context.Context, _ *keepassgov1.SaveVaultRequest) (*keepassgov1.SaveVaultResponse, error) {
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil {
return nil, err
}
if s.lifecycle == nil { if s.lifecycle == nil {
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured") return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
} }
@@ -169,7 +180,10 @@ func (s *Server) SaveVault(_ context.Context, _ *keepassgov1.SaveVaultRequest) (
return &keepassgov1.SaveVaultResponse{}, nil return &keepassgov1.SaveVaultResponse{}, nil
} }
func (s *Server) LockVault(_ context.Context, _ *keepassgov1.LockVaultRequest) (*keepassgov1.LockVaultResponse, error) { func (s *Server) LockVault(ctx context.Context, _ *keepassgov1.LockVaultRequest) (*keepassgov1.LockVaultResponse, error) {
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil {
return nil, err
}
if s.lifecycle == nil { if s.lifecycle == nil {
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured") return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
} }
@@ -185,7 +199,10 @@ func (s *Server) LockVault(_ context.Context, _ *keepassgov1.LockVaultRequest) (
return &keepassgov1.LockVaultResponse{}, nil return &keepassgov1.LockVaultResponse{}, nil
} }
func (s *Server) UnlockVault(_ context.Context, req *keepassgov1.UnlockVaultRequest) (*keepassgov1.UnlockVaultResponse, error) { func (s *Server) UnlockVault(ctx context.Context, req *keepassgov1.UnlockVaultRequest) (*keepassgov1.UnlockVaultResponse, error) {
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil {
return nil, err
}
if s.lifecycle == nil { if s.lifecycle == nil {
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured") return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
} }
@@ -465,7 +482,10 @@ func (s *Server) RestoreEntryHistory(ctx context.Context, req *keepassgov1.Resto
return &keepassgov1.RestoreEntryHistoryResponse{Entry: entryToProto(entry)}, nil return &keepassgov1.RestoreEntryHistoryResponse{Entry: entryToProto(entry)}, nil
} }
func (s *Server) ListTemplates(_ context.Context, _ *keepassgov1.ListTemplatesRequest) (*keepassgov1.ListTemplatesResponse, error) { func (s *Server) ListTemplates(ctx context.Context, _ *keepassgov1.ListTemplatesRequest) (*keepassgov1.ListTemplatesResponse, error) {
if _, err := s.authorizeTemplateCollectionRequest(ctx, apitokens.OperationListTemplates); err != nil {
return nil, err
}
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
@@ -483,10 +503,13 @@ func (s *Server) ListTemplates(_ context.Context, _ *keepassgov1.ListTemplatesRe
return resp, nil return resp, nil
} }
func (s *Server) UpsertTemplate(_ context.Context, req *keepassgov1.UpsertTemplateRequest) (*keepassgov1.UpsertTemplateResponse, error) { func (s *Server) UpsertTemplate(ctx context.Context, req *keepassgov1.UpsertTemplateRequest) (*keepassgov1.UpsertTemplateResponse, error) {
if req.GetTemplate() == nil { if req.GetTemplate() == nil {
return nil, status.Error(codes.InvalidArgument, "missing template") return nil, status.Error(codes.InvalidArgument, "missing template")
} }
if _, err := s.authorizeTemplateRequest(ctx, apitokens.OperationMutateTemplate, req.GetTemplate().GetId()); err != nil {
return nil, err
}
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -502,7 +525,10 @@ func (s *Server) UpsertTemplate(_ context.Context, req *keepassgov1.UpsertTempla
return &keepassgov1.UpsertTemplateResponse{Template: entryToProto(entry)}, nil return &keepassgov1.UpsertTemplateResponse{Template: entryToProto(entry)}, nil
} }
func (s *Server) DeleteTemplate(_ context.Context, req *keepassgov1.DeleteTemplateRequest) (*keepassgov1.DeleteTemplateResponse, error) { func (s *Server) DeleteTemplate(ctx context.Context, req *keepassgov1.DeleteTemplateRequest) (*keepassgov1.DeleteTemplateResponse, error) {
if _, err := s.authorizeTemplateRequest(ctx, apitokens.OperationMutateTemplate, req.GetId()); err != nil {
return nil, err
}
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -521,10 +547,16 @@ func (s *Server) DeleteTemplate(_ context.Context, req *keepassgov1.DeleteTempla
return &keepassgov1.DeleteTemplateResponse{}, nil return &keepassgov1.DeleteTemplateResponse{}, nil
} }
func (s *Server) InstantiateTemplate(_ context.Context, req *keepassgov1.InstantiateTemplateRequest) (*keepassgov1.InstantiateTemplateResponse, error) { func (s *Server) InstantiateTemplate(ctx context.Context, req *keepassgov1.InstantiateTemplateRequest) (*keepassgov1.InstantiateTemplateResponse, error) {
if req.GetOverrides() == nil { if req.GetOverrides() == nil {
return nil, status.Error(codes.InvalidArgument, "missing overrides") return nil, status.Error(codes.InvalidArgument, "missing overrides")
} }
if _, err := s.authorizeTemplateRequest(ctx, apitokens.OperationListTemplates, req.GetTemplateId()); err != nil {
return nil, err
}
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateEntry, req.GetOverrides().GetPath()); err != nil {
return nil, err
}
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -685,12 +717,11 @@ func (s *Server) CopyEntryField(ctx context.Context, req *keepassgov1.CopyEntryF
} }
func (s *Server) GeneratePassword(ctx context.Context, req *keepassgov1.GeneratePasswordRequest) (*keepassgov1.GeneratePasswordResponse, error) { func (s *Server) GeneratePassword(ctx context.Context, req *keepassgov1.GeneratePasswordRequest) (*keepassgov1.GeneratePasswordResponse, error) {
s.mu.RLock() if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationGeneratePassword); err != nil {
defer s.mu.RUnlock()
if _, err := s.authenticateRequest(ctx); err != nil {
return nil, err return nil, err
} }
s.mu.RLock()
defer s.mu.RUnlock()
if s.locked { if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked") return nil, status.Error(codes.FailedPrecondition, "vault is locked")
@@ -784,6 +815,11 @@ func (s *Server) snapshotModel() (vault.Model, bool) {
var timeNow = func() time.Time { return time.Now().UTC() } var timeNow = func() time.Time { return time.Now().UTC() }
var (
vaultPolicyPath = []string{"Root"}
templatePolicyPath = []string{"Root", "Templates"}
)
func (s *Server) authenticateRequest(ctx context.Context) (apitokens.Token, error) { func (s *Server) authenticateRequest(ctx context.Context) (apitokens.Token, error) {
md, ok := metadata.FromIncomingContext(ctx) md, ok := metadata.FromIncomingContext(ctx)
if !ok { if !ok {
@@ -826,6 +862,14 @@ func (s *Server) authorizePathRequest(ctx context.Context, op apitokens.Operatio
return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: path}) return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: path})
} }
func (s *Server) authorizeVaultRequest(ctx context.Context, op apitokens.Operation) (apitokens.Token, error) {
return s.authorizePathRequest(ctx, op, vaultPolicyPath)
}
func (s *Server) authorizeTemplateCollectionRequest(ctx context.Context, op apitokens.Operation) (apitokens.Token, error) {
return s.authorizePathRequest(ctx, op, templatePolicyPath)
}
func (s *Server) authorizeEntryRequest(ctx context.Context, op apitokens.Operation, entry vault.Entry) (apitokens.Token, error) { func (s *Server) authorizeEntryRequest(ctx context.Context, op apitokens.Operation, entry vault.Entry) (apitokens.Token, error) {
token, err := s.authenticateRequest(ctx) token, err := s.authenticateRequest(ctx)
if err != nil { if err != nil {
@@ -834,6 +878,18 @@ func (s *Server) authorizeEntryRequest(ctx context.Context, op apitokens.Operati
return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path}) return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path})
} }
func (s *Server) authorizeTemplateRequest(ctx context.Context, op apitokens.Operation, templateID string) (apitokens.Token, error) {
token, err := s.authenticateRequest(ctx)
if err != nil {
return apitokens.Token{}, err
}
return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{
Kind: apitokens.ResourceEntry,
Path: templatePolicyPath,
EntryID: templateID,
})
}
func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (apitokens.Token, error) { func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (apitokens.Token, error) {
switch apitokens.Evaluate(token, op, resource) { switch apitokens.Evaluate(token, op, resource) {
case apitokens.DecisionAllow: case apitokens.DecisionAllow:
@@ -955,25 +1011,3 @@ func copyOperation(target string) apitokens.Operation {
return apitokens.OperationCopyPassword return apitokens.OperationCopyPassword
} }
} }
func AuthInterceptor(server *Server) grpc.UnaryServerInterceptor {
return func(
ctx context.Context,
req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (any, error) {
switch info.FullMethod {
case "/keepassgo.v1.VaultService/GetSessionStatus",
"/keepassgo.v1.VaultService/OpenVault",
"/keepassgo.v1.VaultService/OpenRemoteVault",
"/keepassgo.v1.VaultService/SaveVault",
"/keepassgo.v1.VaultService/LockVault",
"/keepassgo.v1.VaultService/UnlockVault":
if _, err := server.authenticateRequest(ctx); err != nil {
return nil, err
}
}
return handler(ctx, req)
}
}
@@ -10,14 +10,14 @@ import (
"testing" "testing"
"time" "time"
"git.julianfamily.org/keepassgo/apiaudit" "git.julianfamily.org/keepassgo/internal/apiapproval"
"git.julianfamily.org/keepassgo/apiapproval" "git.julianfamily.org/keepassgo/internal/apiaudit"
"git.julianfamily.org/keepassgo/apitokens" "git.julianfamily.org/keepassgo/internal/apitokens"
"git.julianfamily.org/keepassgo/passwords" "git.julianfamily.org/keepassgo/internal/passwords"
"git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/internal/webdav"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1" keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
"git.julianfamily.org/keepassgo/session"
"git.julianfamily.org/keepassgo/vault"
"git.julianfamily.org/keepassgo/webdav"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/credentials/insecure"
@@ -50,11 +50,11 @@ func TestVaultServiceRejectsExpiredAPITokens(t *testing.T) {
client, _, cleanup := newTestClientForModel(t, vault.Model{ client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
token.Entry([]string{"Root", "API Tokens"}), token.Entry([]string{"Root", "API Tokens"}),
@@ -78,38 +78,98 @@ func TestVaultServiceRejectsUnauthorizedEntryAccess(t *testing.T) {
client, _, cleanup := newTestClientForModel(t, vault.Model{ client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
testAPITokenEntry(t, testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}}},
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "git-server", Path: []string{"Root", "Internet"}}}, apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
), ),
}, },
}) })
defer cleanup() defer cleanup()
_, err := client.CopyEntryField(tokenContext(defaultTestTokenSecret), &keepassgov1.CopyEntryFieldRequest{Id: "git-server", Target: "password"}) _, err := client.CopyEntryField(tokenContext(defaultTestTokenSecret), &keepassgov1.CopyEntryFieldRequest{Id: "vault-console", Target: "password"})
if status.Code(err) != codes.PermissionDenied { if status.Code(err) != codes.PermissionDenied {
t.Fatalf("CopyEntryField() code = %v, want %v", status.Code(err), codes.PermissionDenied) t.Fatalf("CopyEntryField() code = %v, want %v", status.Code(err), codes.PermissionDenied)
} }
} }
func TestVaultServiceRejectsUnauthorizedVaultManagement(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
),
},
})
defer cleanup()
_, err := client.GetSessionStatus(tokenContext(defaultTestTokenSecret), &keepassgov1.GetSessionStatusRequest{})
if status.Code(err) != codes.PermissionDenied {
t.Fatalf("GetSessionStatus() code = %v, want %v", status.Code(err), codes.PermissionDenied)
}
}
func TestVaultServiceRejectsUnauthorizedTemplateMutation(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListTemplates, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Templates"}}},
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationMutateTemplate, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Templates"}}},
),
},
Templates: []vault.Entry{
{ID: "website-login", Title: "Website Login", Path: []string{"Templates"}},
},
})
defer cleanup()
_, err := client.UpsertTemplate(tokenContext(defaultTestTokenSecret), &keepassgov1.UpsertTemplateRequest{
Template: &keepassgov1.Entry{Id: "website-login", Title: "Updated"},
})
if status.Code(err) != codes.PermissionDenied {
t.Fatalf("UpsertTemplate() code = %v, want %v", status.Code(err), codes.PermissionDenied)
}
}
func TestVaultServiceRejectsUnauthorizedPasswordGeneration(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationGeneratePassword, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
),
},
})
defer cleanup()
_, err := client.GeneratePassword(tokenContext(defaultTestTokenSecret), &keepassgov1.GeneratePasswordRequest{Profile: "strong"})
if status.Code(err) != codes.PermissionDenied {
t.Fatalf("GeneratePassword() code = %v, want %v", status.Code(err), codes.PermissionDenied)
}
}
func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) { func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) {
t.Parallel() t.Parallel()
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
testAPITokenEntry(t), testAPITokenEntry(t),
@@ -139,8 +199,8 @@ func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) {
if err := <-errCh; err != nil { if err := <-errCh; err != nil {
t.Fatalf("ListEntries() error = %v", err) t.Fatalf("ListEntries() error = %v", err)
} }
if len(resp.Entries) != 1 || resp.Entries[0].Id != "git-server" { if len(resp.Entries) != 1 || resp.Entries[0].Id != "vault-console" {
t.Fatalf("ListEntries().Entries = %#v, want git-server", resp.Entries) t.Fatalf("ListEntries().Entries = %#v, want vault-console", resp.Entries)
} }
} }
@@ -150,11 +210,11 @@ func TestVaultServicePersistsPermanentDenyApproval(t *testing.T) {
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
testAPITokenEntry(t), testAPITokenEntry(t),
@@ -196,7 +256,7 @@ func TestVaultServiceReturnsCanceledForCanceledApproval(t *testing.T) {
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
testAPITokenEntry(t), testAPITokenEntry(t),
}, },
} }
@@ -225,7 +285,7 @@ func TestVaultServiceTimesOutPendingApproval(t *testing.T) {
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
testAPITokenEntry(t), testAPITokenEntry(t),
}, },
} }
@@ -244,7 +304,7 @@ func TestVaultServiceRecordsApprovalAuditEvents(t *testing.T) {
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
testAPITokenEntry(t), testAPITokenEntry(t),
}, },
} }
@@ -282,11 +342,11 @@ func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) {
model: vault.Model{ model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -454,7 +514,7 @@ func TestVaultServiceOpensAndSavesVaultThroughLifecycleBackend(t *testing.T) {
if _, err := client.OpenRemoteVault(ctx, &keepassgov1.OpenRemoteVaultRequest{ if _, err := client.OpenRemoteVault(ctx, &keepassgov1.OpenRemoteVaultRequest{
BaseUrl: "https://dav.example.com", BaseUrl: "https://dav.example.com",
Path: "vaults/main.kdbx", Path: "vaults/main.kdbx",
Username: "jjulian", Username: "rustyryan",
Password: "dav-token", Password: "dav-token",
MasterPassword: "correct horse battery staple", MasterPassword: "correct horse battery staple",
}); err != nil { }); err != nil {
@@ -618,8 +678,8 @@ func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) {
t.Fatalf("len(ListEntries().Entries) = %d, want 1", len(resp.Entries)) t.Fatalf("len(ListEntries().Entries) = %d, want 1", len(resp.Entries))
} }
if resp.Entries[0].Title != "Git Server" { if resp.Entries[0].Title != "Vault Console" {
t.Fatalf("ListEntries().Entries[0].Title = %q, want %q", resp.Entries[0].Title, "Git Server") t.Fatalf("ListEntries().Entries[0].Title = %q, want %q", resp.Entries[0].Title, "Vault Console")
} }
if got := resp.Entries[0].Fields["X-Role"]; got != "automation" { if got := resp.Entries[0].Fields["X-Role"]; got != "automation" {
t.Fatalf("ListEntries().Entries[0].Fields[X-Role] = %q, want %q", got, "automation") t.Fatalf("ListEntries().Entries[0].Fields[X-Role] = %q, want %q", got, "automation")
@@ -747,7 +807,7 @@ func TestVaultServiceCopiesEntryFieldsForAuthorizedClients(t *testing.T) {
ctx := tokenContext(defaultTestTokenSecret) ctx := tokenContext(defaultTestTokenSecret)
if _, err := client.CopyEntryField(ctx, &keepassgov1.CopyEntryFieldRequest{ if _, err := client.CopyEntryField(ctx, &keepassgov1.CopyEntryFieldRequest{
Id: "git-server", Id: "vault-console",
Target: "password", Target: "password",
}); err != nil { }); err != nil {
t.Fatalf("CopyEntryField() error = %v", err) t.Fatalf("CopyEntryField() error = %v", err)
@@ -767,11 +827,11 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
ctx := tokenContext(defaultTestTokenSecret) ctx := tokenContext(defaultTestTokenSecret)
upserted, err := client.UpsertEntry(ctx, &keepassgov1.UpsertEntryRequest{ upserted, err := client.UpsertEntry(ctx, &keepassgov1.UpsertEntryRequest{
Entry: &keepassgov1.Entry{ Entry: &keepassgov1.Entry{
Id: "ha-codex", Id: "surveillance-console",
Title: "Home Assistant (Codex)", Title: "Surveillance Console",
Username: "codex", Username: "codex",
Password: "token-2", Password: "token-2",
Url: "https://lights.julianfamily.org", Url: "https://surveillance.crew.example.invalid",
Fields: map[string]string{ Fields: map[string]string{
"X-Role": "lights-admin", "X-Role": "lights-admin",
}, },
@@ -782,8 +842,8 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
t.Fatalf("UpsertEntry() error = %v", err) t.Fatalf("UpsertEntry() error = %v", err)
} }
if upserted.Entry.Title != "Home Assistant (Codex)" { if upserted.Entry.Title != "Surveillance Console" {
t.Fatalf("UpsertEntry().Entry.Title = %q, want %q", upserted.Entry.Title, "Home Assistant (Codex)") t.Fatalf("UpsertEntry().Entry.Title = %q, want %q", upserted.Entry.Title, "Surveillance Console")
} }
if got := upserted.Entry.Fields["X-Role"]; got != "lights-admin" { if got := upserted.Entry.Fields["X-Role"]; got != "lights-admin" {
t.Fatalf("UpsertEntry().Entry.Fields[X-Role] = %q, want %q", got, "lights-admin") t.Fatalf("UpsertEntry().Entry.Fields[X-Role] = %q, want %q", got, "lights-admin")
@@ -809,7 +869,7 @@ func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T)
defer cleanup() defer cleanup()
ctx := tokenContext(defaultTestTokenSecret) ctx := tokenContext(defaultTestTokenSecret)
if _, err := client.DeleteEntry(ctx, &keepassgov1.DeleteEntryRequest{Id: "git-server"}); err != nil { if _, err := client.DeleteEntry(ctx, &keepassgov1.DeleteEntryRequest{Id: "vault-console"}); err != nil {
t.Fatalf("DeleteEntry() error = %v", err) t.Fatalf("DeleteEntry() error = %v", err)
} }
@@ -822,13 +882,13 @@ func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T)
t.Fatalf("len(ListEntries().Entries) = %d, want 0 after delete", len(listed.Entries)) t.Fatalf("len(ListEntries().Entries) = %d, want 0 after delete", len(listed.Entries))
} }
restored, err := client.RestoreEntry(ctx, &keepassgov1.RestoreEntryRequest{Id: "git-server"}) restored, err := client.RestoreEntry(ctx, &keepassgov1.RestoreEntryRequest{Id: "vault-console"})
if err != nil { if err != nil {
t.Fatalf("RestoreEntry() error = %v", err) t.Fatalf("RestoreEntry() error = %v", err)
} }
if restored.Entry.Title != "Git Server" { if restored.Entry.Title != "Vault Console" {
t.Fatalf("RestoreEntry().Entry.Title = %q, want %q", restored.Entry.Title, "Git Server") t.Fatalf("RestoreEntry().Entry.Title = %q, want %q", restored.Entry.Title, "Vault Console")
} }
listed, err = client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}}) listed, err = client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
@@ -836,8 +896,8 @@ func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T)
t.Fatalf("ListEntries() error = %v", err) t.Fatalf("ListEntries() error = %v", err)
} }
if len(listed.Entries) != 1 || listed.Entries[0].Title != "Git Server" { if len(listed.Entries) != 1 || listed.Entries[0].Title != "Vault Console" {
t.Fatalf("ListEntries().Entries = %#v, want restored Git Server entry", listed.Entries) t.Fatalf("ListEntries().Entries = %#v, want restored Vault Console entry", listed.Entries)
} }
} }
@@ -863,11 +923,11 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
instantiated, err := client.InstantiateTemplate(ctx, &keepassgov1.InstantiateTemplateRequest{ instantiated, err := client.InstantiateTemplate(ctx, &keepassgov1.InstantiateTemplateRequest{
TemplateId: "website-login", TemplateId: "website-login",
Overrides: &keepassgov1.Entry{ Overrides: &keepassgov1.Entry{
Id: "dynadot", Id: "bellagio",
Title: "Dynadot", Title: "Bellagio",
Username: "jjulian", Username: "rustyryan",
Password: "hunter2", Password: "hunter2",
Url: "https://www.dynadot.com", Url: "https://bellagio.example.invalid",
Fields: map[string]string{ Fields: map[string]string{
"Environment": "staging", "Environment": "staging",
}, },
@@ -879,8 +939,8 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
t.Fatalf("InstantiateTemplate() error = %v", err) t.Fatalf("InstantiateTemplate() error = %v", err)
} }
if instantiated.Entry.Title != "Dynadot" || instantiated.Entry.Notes != "Reusable template for website accounts." { if instantiated.Entry.Title != "Bellagio" || instantiated.Entry.Notes != "Reusable template for website accounts." {
t.Fatalf("InstantiateTemplate().Entry = %#v, want Dynadot entry with template notes", instantiated.Entry) t.Fatalf("InstantiateTemplate().Entry = %#v, want Bellagio entry with template notes", instantiated.Entry)
} }
if got := instantiated.Entry.Fields["Environment"]; got != "staging" { if got := instantiated.Entry.Fields["Environment"]; got != "staging" {
t.Fatalf("InstantiateTemplate().Entry.Fields[Environment] = %q, want %q", got, "staging") t.Fatalf("InstantiateTemplate().Entry.Fields[Environment] = %q, want %q", got, "staging")
@@ -956,7 +1016,7 @@ func TestVaultServiceListsAndRestoresEntryHistoryForAuthorizedClients(t *testing
defer cleanup() defer cleanup()
ctx := tokenContext(defaultTestTokenSecret) ctx := tokenContext(defaultTestTokenSecret)
history, err := client.ListEntryHistory(ctx, &keepassgov1.ListEntryHistoryRequest{Id: "git-server"}) history, err := client.ListEntryHistory(ctx, &keepassgov1.ListEntryHistoryRequest{Id: "vault-console"})
if err != nil { if err != nil {
t.Fatalf("ListEntryHistory() error = %v", err) t.Fatalf("ListEntryHistory() error = %v", err)
} }
@@ -965,7 +1025,7 @@ func TestVaultServiceListsAndRestoresEntryHistoryForAuthorizedClients(t *testing
} }
restored, err := client.RestoreEntryHistory(ctx, &keepassgov1.RestoreEntryHistoryRequest{ restored, err := client.RestoreEntryHistory(ctx, &keepassgov1.RestoreEntryHistoryRequest{
Id: "git-server", Id: "vault-console",
HistoryIndex: 0, HistoryIndex: 0,
}) })
if err != nil { if err != nil {
@@ -986,14 +1046,14 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
uploaded := []byte("attachment-content") uploaded := []byte("attachment-content")
if _, err := client.UploadAttachment(ctx, &keepassgov1.UploadAttachmentRequest{ if _, err := client.UploadAttachment(ctx, &keepassgov1.UploadAttachmentRequest{
EntryId: "git-server", EntryId: "vault-console",
Name: "token.txt", Name: "token.txt",
Content: uploaded, Content: uploaded,
}); err != nil { }); err != nil {
t.Fatalf("UploadAttachment() error = %v", err) t.Fatalf("UploadAttachment() error = %v", err)
} }
listed, err := client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "git-server"}) listed, err := client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "vault-console"})
if err != nil { if err != nil {
t.Fatalf("ListAttachments() error = %v", err) t.Fatalf("ListAttachments() error = %v", err)
} }
@@ -1002,7 +1062,7 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
} }
downloaded, err := client.DownloadAttachment(ctx, &keepassgov1.DownloadAttachmentRequest{ downloaded, err := client.DownloadAttachment(ctx, &keepassgov1.DownloadAttachmentRequest{
EntryId: "git-server", EntryId: "vault-console",
Name: "token.txt", Name: "token.txt",
}) })
if err != nil { if err != nil {
@@ -1013,13 +1073,13 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
} }
if _, err := client.DeleteAttachment(ctx, &keepassgov1.DeleteAttachmentRequest{ if _, err := client.DeleteAttachment(ctx, &keepassgov1.DeleteAttachmentRequest{
EntryId: "git-server", EntryId: "vault-console",
Name: "token.txt", Name: "token.txt",
}); err != nil { }); err != nil {
t.Fatalf("DeleteAttachment() error = %v", err) t.Fatalf("DeleteAttachment() error = %v", err)
} }
listed, err = client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "git-server"}) listed, err = client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "vault-console"})
if err != nil { if err != nil {
t.Fatalf("ListAttachments() error = %v", err) t.Fatalf("ListAttachments() error = %v", err)
} }
@@ -1055,44 +1115,47 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Fields: map[string]string{ Fields: map[string]string{
"X-Role": "automation", "X-Role": "automation",
}, },
History: []vault.Entry{ History: []vault.Entry{
{ {
ID: "git-server-h1", ID: "vault-console-h1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-0", Password: "token-0",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
{ {
ID: "ha-codex", ID: "surveillance-console",
Title: "Home Assistant (Codex)", Title: "Surveillance Console",
Username: "codex", Username: "codex",
Password: "token-2", Password: "token-2",
URL: "https://lights.julianfamily.org", URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"}, Path: []string{"Root", "Home Assistant"},
}, },
testAPITokenEntry(t, testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListTemplates, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Templates"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateGroup, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateGroup, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateTemplate, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Templates"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationReadEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationReadEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "git-server", Path: []string{"Root", "Internet"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationGeneratePassword, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "git-server", Path: []string{"Root", "Internet"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "git-server", Path: []string{"Root", "Internet"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
), ),
}, },
Templates: []vault.Entry{ Templates: []vault.Entry{
@@ -1125,7 +1188,7 @@ func newTestHarnessForModel(t *testing.T, model vault.Model) (keepassgov1.VaultS
listener := bufconn.Listen(1024 * 1024) listener := bufconn.Listen(1024 * 1024)
clipboardWriter := &memoryClipboardWriter{} clipboardWriter := &memoryClipboardWriter{}
service := NewServer(model, passwords.DefaultProfiles(), clipboardWriter) service := NewServer(model, passwords.DefaultProfiles(), clipboardWriter)
server := grpc.NewServer(grpc.UnaryInterceptor(AuthInterceptor(service))) server := grpc.NewServer()
keepassgov1.RegisterVaultServiceServer(server, service) keepassgov1.RegisterVaultServiceServer(server, service)
go func() { go func() {
@@ -1168,7 +1231,7 @@ func newTestHarnessWithLifecycle(t *testing.T, lifecycle *stubLifecycle) (keepas
)) ))
lifecycle.model = model lifecycle.model = model
service := NewServerWithLifecycle(model, passwords.DefaultProfiles(), clipboardWriter, lifecycle) service := NewServerWithLifecycle(model, passwords.DefaultProfiles(), clipboardWriter, lifecycle)
server := grpc.NewServer(grpc.UnaryInterceptor(AuthInterceptor(service))) server := grpc.NewServer()
keepassgov1.RegisterVaultServiceServer(server, service) keepassgov1.RegisterVaultServiceServer(server, service)
go func() { go func() {
@@ -9,7 +9,7 @@ import (
"sync" "sync"
"time" "time"
"git.julianfamily.org/keepassgo/apitokens" "git.julianfamily.org/keepassgo/internal/apitokens"
) )
var ( var (
@@ -6,7 +6,7 @@ import (
"testing" "testing"
"time" "time"
"git.julianfamily.org/keepassgo/apitokens" "git.julianfamily.org/keepassgo/internal/apitokens"
) )
func TestBrokerCreatesPendingRequestAndAllowsOnce(t *testing.T) { func TestBrokerCreatesPendingRequestAndAllowsOnce(t *testing.T) {
@@ -5,7 +5,7 @@ import (
"sync" "sync"
"time" "time"
"git.julianfamily.org/keepassgo/apitokens" "git.julianfamily.org/keepassgo/internal/apitokens"
) )
type EventType string type EventType string
@@ -16,6 +16,12 @@ const (
EventApprovalDenied EventType = "approval_denied" EventApprovalDenied EventType = "approval_denied"
EventApprovalCanceled EventType = "approval_canceled" EventApprovalCanceled EventType = "approval_canceled"
EventApprovalTimedOut EventType = "approval_timed_out" EventApprovalTimedOut EventType = "approval_timed_out"
EventTokenIssued EventType = "token_issued"
EventTokenUpdated EventType = "token_updated"
EventTokenRotated EventType = "token_rotated"
EventTokenDisabled EventType = "token_disabled"
EventTokenRevoked EventType = "token_revoked"
EventTokenDeleted EventType = "token_deleted"
EventAutofillFound EventType = "autofill_found" EventAutofillFound EventType = "autofill_found"
EventAutofillAmbiguous EventType = "autofill_ambiguous" EventAutofillAmbiguous EventType = "autofill_ambiguous"
EventAutofillBlocked EventType = "autofill_blocked" EventAutofillBlocked EventType = "autofill_blocked"
@@ -4,7 +4,7 @@ import (
"testing" "testing"
"time" "time"
"git.julianfamily.org/keepassgo/apitokens" "git.julianfamily.org/keepassgo/internal/apitokens"
) )
func TestLogKeepsNewestEventsWithinBound(t *testing.T) { func TestLogKeepsNewestEventsWithinBound(t *testing.T) {
@@ -12,7 +12,7 @@ import (
"strings" "strings"
"time" "time"
"git.julianfamily.org/keepassgo/vault" "git.julianfamily.org/keepassgo/internal/vault"
) )
const ( const (
@@ -57,12 +57,15 @@ const (
OperationListEntries Operation = "list_entries" OperationListEntries Operation = "list_entries"
OperationListGroups Operation = "list_groups" OperationListGroups Operation = "list_groups"
OperationListTemplates Operation = "list_templates"
OperationReadEntry Operation = "read_entry" OperationReadEntry Operation = "read_entry"
OperationCopyPassword Operation = "copy_password" OperationCopyPassword Operation = "copy_password"
OperationCopyUsername Operation = "copy_username" OperationCopyUsername Operation = "copy_username"
OperationCopyURL Operation = "copy_url" OperationCopyURL Operation = "copy_url"
OperationMutateEntry Operation = "mutate_entry" OperationMutateEntry Operation = "mutate_entry"
OperationMutateGroup Operation = "mutate_group" OperationMutateGroup Operation = "mutate_group"
OperationMutateTemplate Operation = "mutate_template"
OperationGeneratePassword Operation = "generate_password"
OperationManageVault Operation = "manage_vault" OperationManageVault Operation = "manage_vault"
) )
@@ -5,7 +5,7 @@ import (
"testing" "testing"
"time" "time"
"git.julianfamily.org/keepassgo/vault" "git.julianfamily.org/keepassgo/internal/vault"
) )
func TestTokenEntryRoundTripsThroughVaultEntry(t *testing.T) { func TestTokenEntryRoundTripsThroughVaultEntry(t *testing.T) {
+141
View File
@@ -0,0 +1,141 @@
package appstate
import (
"fmt"
"strings"
"git.julianfamily.org/keepassgo/internal/vault"
)
type SyncMode string
const (
SyncModeManual SyncMode = "manual"
SyncModeAutomaticOnOpenSave SyncMode = "automatic_on_open_save"
)
type RemoteBinding struct {
LocalVaultPath string `json:"localVaultPath"`
RemoteProfileID string `json:"remoteProfileId"`
CredentialEntryID string `json:"credentialEntryId"`
SyncMode SyncMode `json:"syncMode,omitempty"`
}
type ResolvedRemoteBinding struct {
Profile vault.RemoteProfile
Credentials vault.Entry
}
type RemoteBindingInput struct {
LocalVaultPath string
RemoteProfileID string
RemoteProfileName string
BaseURL string
RemotePath string
CredentialEntryID string
CredentialTitle string
Username string
Password string
CredentialPath []string
SyncMode SyncMode
}
func (b RemoteBinding) Resolve(model vault.Model) (ResolvedRemoteBinding, error) {
profile, err := model.RemoteProfileByID(b.RemoteProfileID)
if err != nil {
return ResolvedRemoteBinding{}, fmt.Errorf("resolve remote profile: %w", err)
}
credentials, err := model.EntryByID(b.CredentialEntryID)
if err != nil {
return ResolvedRemoteBinding{}, fmt.Errorf("resolve remote credentials: %w", err)
}
return ResolvedRemoteBinding{
Profile: profile,
Credentials: credentials,
}, nil
}
func ConfigureRemoteBinding(model *vault.Model, input RemoteBindingInput) (RemoteBinding, error) {
if model == nil {
return RemoteBinding{}, fmt.Errorf("model is required")
}
input.LocalVaultPath = strings.TrimSpace(input.LocalVaultPath)
input.RemoteProfileID = strings.TrimSpace(input.RemoteProfileID)
input.RemoteProfileName = strings.TrimSpace(input.RemoteProfileName)
input.BaseURL = strings.TrimSpace(input.BaseURL)
input.RemotePath = strings.TrimSpace(input.RemotePath)
input.CredentialEntryID = strings.TrimSpace(input.CredentialEntryID)
input.CredentialTitle = strings.TrimSpace(input.CredentialTitle)
input.Username = strings.TrimSpace(input.Username)
switch {
case input.LocalVaultPath == "":
return RemoteBinding{}, fmt.Errorf("local vault path is required")
case input.RemoteProfileID == "":
return RemoteBinding{}, fmt.Errorf("remote profile id is required")
case input.BaseURL == "":
return RemoteBinding{}, fmt.Errorf("remote base URL is required")
case input.RemotePath == "":
return RemoteBinding{}, fmt.Errorf("remote path is required")
case input.CredentialEntryID == "":
return RemoteBinding{}, fmt.Errorf("credential entry id is required")
case input.Password == "":
return RemoteBinding{}, fmt.Errorf("credential password is required")
}
if input.RemoteProfileName == "" {
input.RemoteProfileName = input.RemoteProfileID
}
if input.CredentialTitle == "" {
input.CredentialTitle = "Remote Sign-In"
}
model.UpsertRemoteProfile(vault.RemoteProfile{
ID: input.RemoteProfileID,
Name: input.RemoteProfileName,
Backend: vault.RemoteBackendWebDAV,
BaseURL: input.BaseURL,
Path: input.RemotePath,
})
model.UpsertEntry(vault.Entry{
ID: input.CredentialEntryID,
Title: input.CredentialTitle,
Username: input.Username,
Password: input.Password,
URL: input.BaseURL,
Path: append([]string(nil), input.CredentialPath...),
})
return RemoteBinding{
LocalVaultPath: input.LocalVaultPath,
RemoteProfileID: input.RemoteProfileID,
CredentialEntryID: input.CredentialEntryID,
SyncMode: normalizeSyncMode(input.SyncMode),
}, nil
}
func RemoveRemoteBinding(model *vault.Model, binding RemoteBinding) error {
if model == nil {
return fmt.Errorf("model is required")
}
if strings.TrimSpace(binding.RemoteProfileID) == "" {
return fmt.Errorf("remote profile id is required")
}
if strings.TrimSpace(binding.CredentialEntryID) == "" {
return fmt.Errorf("credential entry id is required")
}
model.RemoveRemoteProfileByID(binding.RemoteProfileID)
model.RemoveEntryByID(binding.CredentialEntryID)
return nil
}
func normalizeSyncMode(mode SyncMode) SyncMode {
switch mode {
case SyncModeAutomaticOnOpenSave:
return SyncModeAutomaticOnOpenSave
default:
return SyncModeManual
}
}
+250
View File
@@ -0,0 +1,250 @@
package appstate
import (
"encoding/json"
"errors"
"strings"
"testing"
"git.julianfamily.org/keepassgo/internal/vault"
)
func TestRemoteBindingResolveUsesVaultProfileAndCredentialEntry(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{
ID: "linuscaldwell-webdav",
Title: "Bellagio WebDAV Sign-In",
Username: "linuscaldwell",
Password: "bellagio-pass-1",
Path: []string{"Crew", "Internet"},
},
},
RemoteProfiles: []vault.RemoteProfile{
{
ID: "bellagio-webdav",
Name: "Bellagio Vault",
Backend: vault.RemoteBackendWebDAV,
BaseURL: "https://dav.example.invalid/remote.php/dav",
Path: "files/bellagio/keepass.kdbx",
},
},
}
binding := RemoteBinding{
LocalVaultPath: "/tmp/bellagio.kdbx",
RemoteProfileID: "bellagio-webdav",
CredentialEntryID: "linuscaldwell-webdav",
SyncMode: SyncModeAutomaticOnOpenSave,
}
resolved, err := binding.Resolve(model)
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if got := resolved.Profile.BaseURL; got != "https://dav.example.invalid/remote.php/dav" {
t.Fatalf("resolved profile base URL = %q, want remote.php/dav URL", got)
}
if got := resolved.Profile.Path; got != "files/bellagio/keepass.kdbx" {
t.Fatalf("resolved profile path = %q, want files/bellagio/keepass.kdbx", got)
}
if got := resolved.Credentials.Username; got != "linuscaldwell" {
t.Fatalf("resolved credentials username = %q, want linuscaldwell", got)
}
if got := resolved.Credentials.Password; got != "bellagio-pass-1" {
t.Fatalf("resolved credentials password = %q, want bellagio-pass-1", got)
}
}
func TestRemoteBindingResolveFailsWhenVaultReferenceIsMissing(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{ID: "linuscaldwell-webdav", Title: "Bellagio WebDAV Sign-In"},
},
}
_, err := (RemoteBinding{
LocalVaultPath: "/tmp/bellagio.kdbx",
RemoteProfileID: "bellagio-webdav",
CredentialEntryID: "missing-creds",
}).Resolve(model)
if !errors.Is(err, vault.ErrRemoteProfileNotFound) {
t.Fatalf("Resolve() error = %v, want ErrRemoteProfileNotFound first", err)
}
model.RemoteProfiles = []vault.RemoteProfile{{
ID: "bellagio-webdav",
Name: "Bellagio Vault",
Backend: vault.RemoteBackendWebDAV,
BaseURL: "https://dav.example.invalid/remote.php/dav",
Path: "files/bellagio/keepass.kdbx",
}}
_, err = (RemoteBinding{
LocalVaultPath: "/tmp/bellagio.kdbx",
RemoteProfileID: "bellagio-webdav",
CredentialEntryID: "missing-creds",
}).Resolve(model)
if !errors.Is(err, vault.ErrEntryNotFound) {
t.Fatalf("Resolve() error = %v, want ErrEntryNotFound", err)
}
}
func TestRemoteBindingJSONStoresOnlyNonSecretReferences(t *testing.T) {
t.Parallel()
content, err := json.Marshal(RemoteBinding{
LocalVaultPath: "/tmp/bellagio.kdbx",
RemoteProfileID: "bellagio-webdav",
CredentialEntryID: "remote-creds-1",
SyncMode: SyncModeAutomaticOnOpenSave,
})
if err != nil {
t.Fatalf("json.Marshal(RemoteBinding) error = %v", err)
}
text := string(content)
for _, disallowed := range []string{"bellagio-pass-1", "password", "username", "baseUrl"} {
if strings.Contains(text, disallowed) {
t.Fatalf("binding JSON %q unexpectedly contains %q", text, disallowed)
}
}
}
func TestConfigureRemoteBindingStoresProfileAndCredentialsInVault(t *testing.T) {
t.Parallel()
var model vault.Model
binding, err := ConfigureRemoteBinding(&model, RemoteBindingInput{
LocalVaultPath: "/tmp/bellagio.kdbx",
RemoteProfileID: "bellagio-webdav",
RemoteProfileName: "Bellagio Vault",
BaseURL: "https://dav.example.invalid/remote.php/dav",
RemotePath: "files/bellagio/keepass.kdbx",
CredentialEntryID: "remote-creds-1",
CredentialTitle: "Bellagio WebDAV Sign-In",
Username: "linuscaldwell",
Password: "bellagio-pass-1",
CredentialPath: []string{"Crew", "Internet"},
SyncMode: SyncModeAutomaticOnOpenSave,
})
if err != nil {
t.Fatalf("ConfigureRemoteBinding() error = %v", err)
}
if len(model.RemoteProfiles) != 1 {
t.Fatalf("len(RemoteProfiles) = %d, want 1", len(model.RemoteProfiles))
}
if got := model.RemoteProfiles[0].BaseURL; got != "https://dav.example.invalid/remote.php/dav" {
t.Fatalf("stored remote profile base URL = %q, want remote.php/dav URL", got)
}
credentials, err := model.EntryByID("remote-creds-1")
if err != nil {
t.Fatalf("EntryByID(remote-creds-1) error = %v", err)
}
if credentials.Username != "linuscaldwell" || credentials.Password != "bellagio-pass-1" {
t.Fatalf("stored credential entry = %#v, want linuscaldwell/bellagio-pass-1", credentials)
}
if credentials.URL != "https://dav.example.invalid/remote.php/dav" {
t.Fatalf("stored credential entry URL = %q, want remote.php/dav URL", credentials.URL)
}
if binding.LocalVaultPath != "/tmp/bellagio.kdbx" {
t.Fatalf("binding LocalVaultPath = %q, want /tmp/bellagio.kdbx", binding.LocalVaultPath)
}
if binding.RemoteProfileID != "bellagio-webdav" || binding.CredentialEntryID != "remote-creds-1" {
t.Fatalf("binding = %#v, want only vault references", binding)
}
}
func TestConfigureRemoteBindingRejectsIncompleteInput(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
input RemoteBindingInput
}{
{
name: "missing_local_vault_path",
input: RemoteBindingInput{
RemoteProfileID: "bellagio-webdav",
BaseURL: "https://dav.example.invalid/remote.php/dav",
RemotePath: "files/bellagio/keepass.kdbx",
CredentialEntryID: "remote-creds-1",
Password: "bellagio-pass-1",
},
},
{
name: "missing_remote_base_url",
input: RemoteBindingInput{
LocalVaultPath: "/tmp/bellagio.kdbx",
RemoteProfileID: "bellagio-webdav",
RemotePath: "files/bellagio/keepass.kdbx",
CredentialEntryID: "remote-creds-1",
Password: "bellagio-pass-1",
},
},
{
name: "missing_credential_entry_id",
input: RemoteBindingInput{
LocalVaultPath: "/tmp/bellagio.kdbx",
RemoteProfileID: "bellagio-webdav",
BaseURL: "https://dav.example.invalid/remote.php/dav",
RemotePath: "files/bellagio/keepass.kdbx",
Password: "bellagio-pass-1",
},
},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var model vault.Model
if _, err := ConfigureRemoteBinding(&model, tc.input); err == nil {
t.Fatalf("ConfigureRemoteBinding(%#v) error = nil, want validation error", tc.input)
}
})
}
}
func TestRemoveRemoteBindingRemovesProfileAndCredentialsFromVault(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{{
ID: "remote-creds-1",
Title: "Bellagio WebDAV Sign-In",
Username: "linuscaldwell",
Password: "bellagio-pass-1",
}},
RemoteProfiles: []vault.RemoteProfile{{
ID: "bellagio-webdav",
Name: "Bellagio Vault",
Backend: vault.RemoteBackendWebDAV,
BaseURL: "https://dav.example.invalid/remote.php/dav",
Path: "files/bellagio/keepass.kdbx",
}},
}
err := RemoveRemoteBinding(&model, RemoteBinding{
LocalVaultPath: "/tmp/bellagio.kdbx",
RemoteProfileID: "bellagio-webdav",
CredentialEntryID: "remote-creds-1",
})
if err != nil {
t.Fatalf("RemoveRemoteBinding() error = %v", err)
}
if got := len(model.RemoteProfiles); got != 0 {
t.Fatalf("len(RemoteProfiles) = %d, want 0", got)
}
if _, err := model.EntryByID("remote-creds-1"); !errors.Is(err, vault.ErrEntryNotFound) {
t.Fatalf("EntryByID(remote-creds-1) error = %v, want ErrEntryNotFound", err)
}
}
@@ -7,10 +7,11 @@ import (
"strings" "strings"
"time" "time"
"git.julianfamily.org/keepassgo/apiapproval" "git.julianfamily.org/keepassgo/internal/apiapproval"
"git.julianfamily.org/keepassgo/apitokens" "git.julianfamily.org/keepassgo/internal/apiaudit"
"git.julianfamily.org/keepassgo/vault" "git.julianfamily.org/keepassgo/internal/apitokens"
"git.julianfamily.org/keepassgo/webdav" "git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/internal/webdav"
) )
type Section string type Section string
@@ -26,6 +27,7 @@ const (
SectionRecycleBin Section = "recycle-bin" SectionRecycleBin Section = "recycle-bin"
SectionAPITokens Section = "api-tokens" SectionAPITokens Section = "api-tokens"
SectionAPIAudit Section = "api-audit" SectionAPIAudit Section = "api-audit"
SectionAbout Section = "about"
) )
type CurrentSession interface { type CurrentSession interface {
@@ -61,6 +63,7 @@ type SynchronizableSession interface {
type AdvancedSynchronizableSession interface { type AdvancedSynchronizableSession interface {
CurrentSession CurrentSession
SynchronizeFromLocal(string) error SynchronizeFromLocal(string) error
SynchronizeFromLocalBytes(string, []byte) error
SynchronizeToLocal(string) error SynchronizeToLocal(string) error
SynchronizeFromRemote(webdav.Client, string) error SynchronizeFromRemote(webdav.Client, string) error
SynchronizeToRemote(webdav.Client, string) error SynchronizeToRemote(webdav.Client, string) error
@@ -99,6 +102,7 @@ type ApprovalManager interface {
type State struct { type State struct {
Session CurrentSession Session CurrentSession
Approvals ApprovalManager Approvals ApprovalManager
AuditLog *apiaudit.Log
Section Section Section Section
CurrentPath []string CurrentPath []string
SearchQuery string SearchQuery string
@@ -131,6 +135,52 @@ func (s *State) APITokens() ([]apitokens.Token, error) {
return apitokens.Entries(model) return apitokens.Entries(model)
} }
func (s *State) RemoteProfiles() ([]vault.RemoteProfile, error) {
model, err := s.currentModel()
if err != nil {
return nil, err
}
profiles := slices.Clone(model.RemoteProfiles)
slices.SortFunc(profiles, func(a, b vault.RemoteProfile) int {
switch {
case a.Name < b.Name:
return -1
case a.Name > b.Name:
return 1
case a.ID < b.ID:
return -1
case a.ID > b.ID:
return 1
default:
return 0
}
})
return profiles, nil
}
func (s *State) RemoteCredentialEntries() ([]vault.Entry, error) {
model, err := s.currentModel()
if err != nil {
return nil, err
}
entries := slices.Clone(model.Entries)
slices.SortFunc(entries, func(a, b vault.Entry) int {
switch {
case a.Title < b.Title:
return -1
case a.Title > b.Title:
return 1
case a.ID < b.ID:
return -1
case a.ID > b.ID:
return 1
default:
return 0
}
})
return entries, nil
}
func (s *State) IssueAPIToken(name, clientName string, expiresAt *time.Time, now time.Time) (apitokens.Token, string, error) { func (s *State) IssueAPIToken(name, clientName string, expiresAt *time.Time, now time.Time) (apitokens.Token, string, error) {
session, ok := s.Session.(MutableSession) session, ok := s.Session.(MutableSession)
if !ok { if !ok {
@@ -147,6 +197,7 @@ func (s *State) IssueAPIToken(name, clientName string, expiresAt *time.Time, now
apitokens.Upsert(&model, token) apitokens.Upsert(&model, token)
session.Replace(model) session.Replace(model)
s.Dirty = true s.Dirty = true
s.recordTokenAudit(apiaudit.EventTokenIssued, token, "issued API token")
return token, secret, nil return token, secret, nil
} }
@@ -170,6 +221,7 @@ func (s *State) RotateAPIToken(id string, now time.Time) (apitokens.Token, strin
apitokens.Upsert(&model, token) apitokens.Upsert(&model, token)
session.Replace(model) session.Replace(model)
s.Dirty = true s.Dirty = true
s.recordTokenAudit(apiaudit.EventTokenRotated, token, "rotated API token")
return token, secret, nil return token, secret, nil
} }
@@ -185,6 +237,7 @@ func (s *State) UpsertAPIToken(token apitokens.Token) error {
apitokens.Upsert(&model, token) apitokens.Upsert(&model, token)
session.Replace(model) session.Replace(model)
s.Dirty = true s.Dirty = true
s.recordTokenAudit(apiaudit.EventTokenUpdated, token, "updated API token")
return nil return nil
} }
@@ -201,9 +254,11 @@ func (s *State) DisableAPIToken(id string) error {
if err != nil { if err != nil {
return err return err
} }
apitokens.Upsert(&model, apitokens.Disable(token)) token = apitokens.Disable(token)
apitokens.Upsert(&model, token)
session.Replace(model) session.Replace(model)
s.Dirty = true s.Dirty = true
s.recordTokenAudit(apiaudit.EventTokenDisabled, token, "disabled API token")
return nil return nil
} }
@@ -220,9 +275,11 @@ func (s *State) RevokeAPIToken(id string, when time.Time) error {
if err != nil { if err != nil {
return err return err
} }
apitokens.Upsert(&model, apitokens.Revoke(token, when)) token = apitokens.Revoke(token, when)
apitokens.Upsert(&model, token)
session.Replace(model) session.Replace(model)
s.Dirty = true s.Dirty = true
s.recordTokenAudit(apiaudit.EventTokenRevoked, token, "revoked API token")
return nil return nil
} }
@@ -235,14 +292,37 @@ func (s *State) DeleteAPIToken(id string) error {
if err != nil { if err != nil {
return err return err
} }
token, err := apitokens.Find(model, id)
if err != nil {
return err
}
if err := apitokens.Delete(&model, id); err != nil { if err := apitokens.Delete(&model, id); err != nil {
return err return err
} }
session.Replace(model) session.Replace(model)
s.Dirty = true s.Dirty = true
s.recordTokenAudit(apiaudit.EventTokenDeleted, token, "deleted API token")
return nil return nil
} }
func (s *State) recordTokenAudit(eventType apiaudit.EventType, token apitokens.Token, message string) {
if s.AuditLog == nil {
return
}
s.AuditLog.Record(apiaudit.Event{
Type: eventType,
TokenID: token.ID,
TokenName: token.Name,
ClientName: token.ClientName,
Resource: apitokens.Resource{
Kind: apitokens.ResourceEntry,
Path: apitokens.EntryPath,
EntryID: token.ID,
},
Message: message,
})
}
func (s *State) SecuritySettings() (vault.SecuritySettings, error) { func (s *State) SecuritySettings() (vault.SecuritySettings, error) {
security, ok := s.Session.(SecurityConfigurableSession) security, ok := s.Session.(SecurityConfigurableSession)
if !ok { if !ok {
@@ -302,7 +382,7 @@ func (s *State) VisibleEntries() ([]vault.Entry, error) {
} }
if s.Section == SectionEntries { if s.Section == SectionEntries {
return model.EntriesUnderPath(s.CurrentPath), nil return entriesInPath(model.Entries, s.CurrentPath), nil
} }
if s.Section == SectionRecycleBin || len(s.CurrentPath) == 0 { if s.Section == SectionRecycleBin || len(s.CurrentPath) == 0 {
return entries, nil return entries, nil
@@ -375,7 +455,7 @@ func (s *State) entriesForSection(model vault.Model) []vault.Entry {
return slices.Clone(model.Templates) return slices.Clone(model.Templates)
case SectionRecycleBin: case SectionRecycleBin:
return slices.Clone(model.RecycleBin) return slices.Clone(model.RecycleBin)
case SectionAPITokens, SectionAPIAudit: case SectionAPITokens, SectionAPIAudit, SectionAbout:
return nil return nil
default: default:
return slices.Clone(model.Entries) return slices.Clone(model.Entries)
@@ -722,6 +802,18 @@ func (s *State) SynchronizeFromLocal(path string) error {
return nil return nil
} }
func (s *State) SynchronizeFromLocalBytes(name string, content []byte) error {
session, ok := s.Session.(AdvancedSynchronizableSession)
if !ok {
return fmt.Errorf("session is not advanced-synchronizable")
}
if err := session.SynchronizeFromLocalBytes(name, content); err != nil {
return err
}
s.Dirty = false
return nil
}
func (s *State) SynchronizeToLocal(path string) error { func (s *State) SynchronizeToLocal(path string) error {
session, ok := s.Session.(AdvancedSynchronizableSession) session, ok := s.Session.(AdvancedSynchronizableSession)
if !ok { if !ok {
@@ -820,6 +912,66 @@ func (s *State) OpenRemoteVault(client webdav.Client, path string, key vault.Mas
return nil return nil
} }
func (s *State) OpenBoundRemoteVault(binding RemoteBinding, key vault.MasterKey) error {
model, err := s.currentModel()
if err != nil {
return err
}
resolved, err := binding.Resolve(model)
if err != nil {
return err
}
client := webdav.Client{
BaseURL: resolved.Profile.BaseURL,
Username: resolved.Credentials.Username,
Password: resolved.Credentials.Password,
}
return s.OpenRemoteVault(client, resolved.Profile.Path, key)
}
func (s *State) ConfigureRemoteBinding(input RemoteBindingInput) (RemoteBinding, error) {
session, ok := s.Session.(MutableSession)
if !ok {
return RemoteBinding{}, fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return RemoteBinding{}, err
}
binding, err := ConfigureRemoteBinding(&model, input)
if err != nil {
return RemoteBinding{}, err
}
session.Replace(model)
s.Dirty = true
return binding, nil
}
func (s *State) RemoveRemoteBinding(binding RemoteBinding) error {
session, ok := s.Session.(MutableSession)
if !ok {
return fmt.Errorf("session is not mutable")
}
model, err := session.Current()
if err != nil {
return err
}
if err := RemoveRemoteBinding(&model, binding); err != nil {
return err
}
session.Replace(model)
s.Dirty = true
return nil
}
func (s *State) CreateGroup(name string) error { func (s *State) CreateGroup(name string) error {
session, ok := s.Session.(MutableSession) session, ok := s.Session.(MutableSession)
if !ok { if !ok {
@@ -6,11 +6,12 @@ import (
"testing" "testing"
"time" "time"
"git.julianfamily.org/keepassgo/apiapproval" "git.julianfamily.org/keepassgo/internal/apiapproval"
"git.julianfamily.org/keepassgo/apitokens" "git.julianfamily.org/keepassgo/internal/apiaudit"
"git.julianfamily.org/keepassgo/session" "git.julianfamily.org/keepassgo/internal/apitokens"
"git.julianfamily.org/keepassgo/vault" "git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/webdav" "git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/internal/webdav"
) )
func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) { func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
@@ -20,13 +21,13 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
Session: stubSession{ Session: stubSession{
model: vault.Model{ model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}}, {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Joe", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Joe", "Home Assistant"}}, {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}},
}, },
}, },
}, },
CurrentPath: []string{"Joe", "Internet"}, CurrentPath: []string{"Crew", "Internet"},
} }
got, err := state.VisibleEntries() got, err := state.VisibleEntries()
@@ -39,25 +40,26 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
titles = append(titles, entry.Title) titles = append(titles, entry.Title)
} }
if !slices.Equal(titles, []string{"Dynadot", "Git Server"}) { if !slices.Equal(titles, []string{"Bellagio", "Vault Console"}) {
t.Fatalf("visible titles = %v, want [Dynadot Git Server]", titles) t.Fatalf("visible titles = %v, want [Bellagio Vault Console]", titles)
} }
} }
func TestVisibleEntriesIncludesDescendantEntriesAtParentGroup(t *testing.T) { func TestVisibleEntriesAtParentGroupOnlyShowsDirectEntries(t *testing.T) {
t.Parallel() t.Parallel()
state := State{ state := State{
Session: stubSession{ Session: stubSession{
model: vault.Model{ model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}}, {ID: "joe-note", Title: "Crew Note", Path: []string{"Crew"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Joe", "Internet"}}, {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Joe", "Home Assistant"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}},
}, },
}, },
}, },
CurrentPath: []string{"Joe"}, CurrentPath: []string{"Crew"},
} }
got, err := state.VisibleEntries() got, err := state.VisibleEntries()
@@ -69,8 +71,8 @@ func TestVisibleEntriesIncludesDescendantEntriesAtParentGroup(t *testing.T) {
for _, entry := range got { for _, entry := range got {
titles = append(titles, entry.Title) titles = append(titles, entry.Title)
} }
if !slices.Equal(titles, []string{"Dynadot", "Git Server", "Home Assistant (Codex)"}) { if !slices.Equal(titles, []string{"Crew Note"}) {
t.Fatalf("visible titles = %v, want descendant entries from Joe", titles) t.Fatalf("visible titles = %v, want only direct entries from Crew", titles)
} }
} }
@@ -108,7 +110,8 @@ func TestIssueRotateDisableRevokeAndDeleteAPIToken(t *testing.T) {
t.Parallel() t.Parallel()
session := &mutableStubSession{model: vault.Model{}} session := &mutableStubSession{model: vault.Model{}}
state := State{Session: session} auditLog := apiaudit.New(10)
state := State{Session: session, AuditLog: auditLog}
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC) now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
expiresAt := now.Add(24 * time.Hour) expiresAt := now.Add(24 * time.Hour)
@@ -161,6 +164,89 @@ func TestIssueRotateDisableRevokeAndDeleteAPIToken(t *testing.T) {
if len(tokens) != 0 { if len(tokens) != 0 {
t.Fatalf("APITokens() after delete = %#v, want empty", tokens) t.Fatalf("APITokens() after delete = %#v, want empty", tokens)
} }
events := auditLog.Events()
if len(events) != 5 {
t.Fatalf("len(AuditLog.Events()) = %d, want 5", len(events))
}
if events[0].Type != apiaudit.EventTokenDeleted ||
events[1].Type != apiaudit.EventTokenRevoked ||
events[2].Type != apiaudit.EventTokenDisabled ||
events[3].Type != apiaudit.EventTokenRotated ||
events[4].Type != apiaudit.EventTokenIssued {
t.Fatalf("AuditLog.Events() types = %#v, want deleted/revoked/disabled/rotated/issued", events)
}
if events[0].TokenID != issued.ID || events[0].Resource.EntryID != issued.ID {
t.Fatalf("delete audit event = %#v, want token/resource id %q", events[0], issued.ID)
}
if events[4].TokenName != "CLI" || events[4].ClientName != "grpc-cli" {
t.Fatalf("issued audit event = %#v, want CLI/grpc-cli metadata", events[4])
}
}
func TestRemoteProfilesReturnsVaultProfiles(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
RemoteProfiles: []vault.RemoteProfile{
{
ID: "bellagio-webdav",
Name: "Bellagio Vault",
Backend: vault.RemoteBackendWebDAV,
BaseURL: "https://dav.example.invalid/remote.php/dav",
Path: "files/bellagio/keepass.kdbx",
},
{
ID: "archive-webdav",
Name: "Archive Vault",
Backend: vault.RemoteBackendWebDAV,
BaseURL: "https://dav.example.invalid/remote.php/dav",
Path: "files/bellagio/archive.kdbx",
},
},
},
},
}
got, err := state.RemoteProfiles()
if err != nil {
t.Fatalf("RemoteProfiles() error = %v", err)
}
if len(got) != 2 {
t.Fatalf("len(RemoteProfiles()) = %d, want 2", len(got))
}
if got[0].ID != "archive-webdav" || got[1].ID != "bellagio-webdav" {
t.Fatalf("RemoteProfiles() = %#v, want sorted by name/id", got)
}
}
func TestRemoteCredentialEntriesReturnsSortedVaultEntries(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "cred-2", Title: "Zulu Sign-In", Username: "zuser", Path: []string{"Crew", "Internet"}},
{ID: "cred-1", Title: "Alpha Sign-In", Username: "auser", Path: []string{"Crew", "Internet"}},
{ID: "cred-3", Title: "Mint Sign-In", Username: "frankcatton", Path: []string{"Crew", "Safe House"}},
},
},
},
}
got, err := state.RemoteCredentialEntries()
if err != nil {
t.Fatalf("RemoteCredentialEntries() error = %v", err)
}
if len(got) != 3 {
t.Fatalf("len(RemoteCredentialEntries()) = %d, want 3", len(got))
}
if got[0].ID != "cred-1" || got[1].ID != "cred-3" || got[2].ID != "cred-2" {
t.Fatalf("RemoteCredentialEntries() = %#v, want entries sorted by title", got)
}
} }
func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) { func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
@@ -170,14 +256,14 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
Session: stubSession{ Session: stubSession{
model: vault.Model{ model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}}, {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "git-server", Title: "Git Server", URL: "https://git.julianfamily.org", Path: []string{"Joe", "Internet"}}, {ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", URL: "https://lights.julianfamily.org", Path: []string{"Joe", "Home Assistant"}}, {ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Security Office"}},
}, },
}, },
}, },
CurrentPath: []string{"Joe", "Internet"}, CurrentPath: []string{"Crew", "Internet"},
SearchQuery: "lights", SearchQuery: "surveillance",
} }
got, err := state.VisibleEntries() got, err := state.VisibleEntries()
@@ -185,8 +271,8 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
t.Fatalf("VisibleEntries() error = %v", err) t.Fatalf("VisibleEntries() error = %v", err)
} }
if len(got) != 1 || got[0].Title != "Home Assistant (Codex)" { if len(got) != 1 || got[0].Title != "Surveillance Console" {
t.Fatalf("VisibleEntries() = %#v, want Home Assistant search match", got) t.Fatalf("VisibleEntries() = %#v, want Security Office search match", got)
} }
} }
@@ -197,13 +283,13 @@ func TestVisibleEntriesReturnsDescendantsAfterClearingSearch(t *testing.T) {
Session: stubSession{ Session: stubSession{
model: vault.Model{ model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}}, {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "git-server", Title: "Git Server", URL: "https://git.julianfamily.org", Path: []string{"Joe", "Internet"}}, {ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", URL: "https://lights.julianfamily.org", Path: []string{"Joe", "Home Assistant"}}, {ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Security Office"}},
}, },
}, },
}, },
CurrentPath: []string{"Joe"}, CurrentPath: []string{"Crew"},
SearchQuery: "missing", SearchQuery: "missing",
} }
@@ -220,8 +306,8 @@ func TestVisibleEntriesReturnsDescendantsAfterClearingSearch(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("VisibleEntries() after clearing search error = %v", err) t.Fatalf("VisibleEntries() after clearing search error = %v", err)
} }
if len(got) != 3 { if len(got) != 0 {
t.Fatalf("len(VisibleEntries()) after clearing search = %d, want 3 descendant entries", len(got)) t.Fatalf("len(VisibleEntries()) after clearing search = %d, want 0 direct entries at Crew", len(got))
} }
} }
@@ -348,8 +434,8 @@ func TestVisibleEntriesUsesGlobalSearchWithinRecycleBin(t *testing.T) {
Session: stubSession{ Session: stubSession{
model: vault.Model{ model: vault.Model{
RecycleBin: []vault.Entry{ RecycleBin: []vault.Entry{
{ID: "deleted-1", Title: "Deleted Dynadot", Path: []string{"Root", "Internet"}}, {ID: "deleted-1", Title: "Deleted Bellagio", Path: []string{"Root", "Internet"}},
{ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}}, {ID: "deleted-2", Title: "Deleted Vault Vent", URL: "https://climate.example.com", Path: []string{"Root", "Safe House"}},
}, },
}, },
}, },
@@ -417,13 +503,13 @@ func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
Session: stubSession{ Session: stubSession{
model: vault.Model{ model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}}, {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Joe", "Home Assistant"}}, {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}},
{ID: "alma", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}}, {ID: "alma", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}},
}, },
}, },
}, },
CurrentPath: []string{"Joe"}, CurrentPath: []string{"Crew"},
} }
got, err := state.ChildGroups() got, err := state.ChildGroups()
@@ -431,8 +517,8 @@ func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
t.Fatalf("ChildGroups() error = %v", err) t.Fatalf("ChildGroups() error = %v", err)
} }
if !slices.Equal(got, []string{"Home Assistant", "Internet"}) { if !slices.Equal(got, []string{"Internet", "Security Office"}) {
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got) t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got)
} }
} }
@@ -469,19 +555,19 @@ func TestSelectVisibleEntryAndToggleSelection(t *testing.T) {
Session: stubSession{ Session: stubSession{
model: vault.Model{ model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}}, {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Joe", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
}, },
}, },
}, },
CurrentPath: []string{"Joe", "Internet"}, CurrentPath: []string{"Crew", "Internet"},
} }
if err := state.SelectVisibleIndex(1); err != nil { if err := state.SelectVisibleIndex(1); err != nil {
t.Fatalf("SelectVisibleIndex() error = %v", err) t.Fatalf("SelectVisibleIndex() error = %v", err)
} }
if got := state.SelectedEntryID; got != "git-server" { if got := state.SelectedEntryID; got != "vault-console" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "git-server") t.Fatalf("SelectedEntryID = %q, want %q", got, "vault-console")
} }
if err := state.ToggleVisibleIndex(1); err != nil { if err := state.ToggleVisibleIndex(1); err != nil {
@@ -523,14 +609,14 @@ func TestDeleteSelectedEntryUpdatesSessionAndClearsSelection(t *testing.T) {
sess := &mutableStubSession{ sess := &mutableStubSession{
model: vault.Model{ model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
}, },
}, },
} }
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server", SelectedEntryID: "vault-console",
} }
if err := state.DeleteSelectedEntry(); err != nil { if err := state.DeleteSelectedEntry(); err != nil {
@@ -545,8 +631,8 @@ func TestDeleteSelectedEntryUpdatesSessionAndClearsSelection(t *testing.T) {
t.Fatalf("len(Entries) = %d, want 0", len(sess.model.Entries)) t.Fatalf("len(Entries) = %d, want 0", len(sess.model.Entries))
} }
if len(sess.model.RecycleBin) != 1 || sess.model.RecycleBin[0].ID != "git-server" { if len(sess.model.RecycleBin) != 1 || sess.model.RecycleBin[0].ID != "vault-console" {
t.Fatalf("RecycleBin = %#v, want git-server entry", sess.model.RecycleBin) t.Fatalf("RecycleBin = %#v, want vault-console entry", sess.model.RecycleBin)
} }
if !state.Dirty { if !state.Dirty {
@@ -560,7 +646,7 @@ func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) {
sess := &mutableStubSession{ sess := &mutableStubSession{
model: vault.Model{ model: vault.Model{
RecycleBin: []vault.Entry{ RecycleBin: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
}, },
}, },
} }
@@ -569,7 +655,7 @@ func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) {
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
} }
if err := state.RestoreEntry("git-server"); err != nil { if err := state.RestoreEntry("vault-console"); err != nil {
t.Fatalf("RestoreEntry() error = %v", err) t.Fatalf("RestoreEntry() error = %v", err)
} }
@@ -578,8 +664,8 @@ func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) {
t.Fatalf("VisibleEntries() error = %v", err) t.Fatalf("VisibleEntries() error = %v", err)
} }
if len(got) != 1 || got[0].ID != "git-server" { if len(got) != 1 || got[0].ID != "vault-console" {
t.Fatalf("VisibleEntries() = %#v, want restored git-server entry", got) t.Fatalf("VisibleEntries() = %#v, want restored vault-console entry", got)
} }
if !state.Dirty { if !state.Dirty {
@@ -599,27 +685,27 @@ func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) {
} }
entry := vault.Entry{ entry := vault.Entry{
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "bellagio-pass-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
} }
if err := state.UpsertEntry(entry); err != nil { if err := state.UpsertEntry(entry); err != nil {
t.Fatalf("UpsertEntry() error = %v", err) t.Fatalf("UpsertEntry() error = %v", err)
} }
if got := state.SelectedEntryID; got != "git-server" { if got := state.SelectedEntryID; got != "vault-console" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "git-server") t.Fatalf("SelectedEntryID = %q, want %q", got, "vault-console")
} }
got, err := state.VisibleEntries() got, err := state.VisibleEntries()
if err != nil { if err != nil {
t.Fatalf("VisibleEntries() error = %v", err) t.Fatalf("VisibleEntries() error = %v", err)
} }
if len(got) != 1 || got[0].Password != "token-1" { if len(got) != 1 || got[0].Password != "bellagio-pass-1" {
t.Fatalf("VisibleEntries() = %#v, want persisted git-server entry", got) t.Fatalf("VisibleEntries() = %#v, want persisted vault-console entry", got)
} }
if !state.Dirty { if !state.Dirty {
@@ -651,11 +737,11 @@ func TestInstantiateTemplateCreatesEntryAndSelectsIt(t *testing.T) {
} }
entry, err := state.InstantiateTemplate("website-login", vault.Entry{ entry, err := state.InstantiateTemplate("website-login", vault.Entry{
ID: "dynadot", ID: "bellagio",
Title: "Dynadot", Title: "Bellagio",
Username: "jjulian", Username: "rustyryan",
Password: "hunter2", Password: "hunter2",
URL: "https://www.dynadot.com", URL: "https://bellagio.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}) })
if err != nil { if err != nil {
@@ -666,16 +752,16 @@ func TestInstantiateTemplateCreatesEntryAndSelectsIt(t *testing.T) {
t.Fatalf("entry.Notes = %q, want template notes", entry.Notes) t.Fatalf("entry.Notes = %q, want template notes", entry.Notes)
} }
if got := state.SelectedEntryID; got != "dynadot" { if got := state.SelectedEntryID; got != "bellagio" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "dynadot") t.Fatalf("SelectedEntryID = %q, want %q", got, "bellagio")
} }
got, err := state.VisibleEntries() got, err := state.VisibleEntries()
if err != nil { if err != nil {
t.Fatalf("VisibleEntries() error = %v", err) t.Fatalf("VisibleEntries() error = %v", err)
} }
if len(got) != 1 || got[0].ID != "dynadot" { if len(got) != 1 || got[0].ID != "bellagio" {
t.Fatalf("VisibleEntries() = %#v, want instantiated dynadot entry", got) t.Fatalf("VisibleEntries() = %#v, want instantiated bellagio entry", got)
} }
if !state.Dirty { if !state.Dirty {
@@ -742,24 +828,24 @@ func TestDuplicateSelectedEntryCreatesCopyAndSelectsIt(t *testing.T) {
sess := &mutableStubSession{model: vault.Model{ sess := &mutableStubSession{model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
}, },
}} }}
state := State{ state := State{
Session: sess, Session: sess,
SelectedEntryID: "git-server", SelectedEntryID: "vault-console",
} }
duplicate, err := state.DuplicateSelectedEntry("git-server-copy") duplicate, err := state.DuplicateSelectedEntry("vault-console-copy")
if err != nil { if err != nil {
t.Fatalf("DuplicateSelectedEntry() error = %v", err) t.Fatalf("DuplicateSelectedEntry() error = %v", err)
} }
if duplicate.ID != "git-server-copy" { if duplicate.ID != "vault-console-copy" {
t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "git-server-copy") t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "vault-console-copy")
} }
if state.SelectedEntryID != "git-server-copy" { if state.SelectedEntryID != "vault-console-copy" {
t.Fatalf("SelectedEntryID = %q, want git-server-copy", state.SelectedEntryID) t.Fatalf("SelectedEntryID = %q, want vault-console-copy", state.SelectedEntryID)
} }
if len(sess.model.Entries) != 2 { if len(sess.model.Entries) != 2 {
t.Fatalf("len(Entries) = %d, want 2 after duplicate", len(sess.model.Entries)) t.Fatalf("len(Entries) = %d, want 2 after duplicate", len(sess.model.Entries))
@@ -771,13 +857,13 @@ func TestMoveSelectedEntryMovesEntryToNewPathAndMarksDirty(t *testing.T) {
sess := &mutableStubSession{model: vault.Model{ sess := &mutableStubSession{model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
}, },
}} }}
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server", SelectedEntryID: "vault-console",
} }
if err := state.MoveSelectedEntry([]string{"Root", "Infrastructure"}); err != nil { if err := state.MoveSelectedEntry([]string{"Root", "Infrastructure"}); err != nil {
@@ -790,8 +876,8 @@ func TestMoveSelectedEntryMovesEntryToNewPathAndMarksDirty(t *testing.T) {
} }
newPath := sess.model.EntriesInPath([]string{"Root", "Infrastructure"}) newPath := sess.model.EntriesInPath([]string{"Root", "Infrastructure"})
if len(newPath) != 1 || newPath[0].ID != "git-server" { if len(newPath) != 1 || newPath[0].ID != "vault-console" {
t.Fatalf("EntriesInPath(Root/Infrastructure) = %#v, want moved git-server entry", newPath) t.Fatalf("EntriesInPath(Root/Infrastructure) = %#v, want moved vault-console entry", newPath)
} }
if !state.Dirty { if !state.Dirty {
@@ -805,19 +891,19 @@ func TestRestoreSelectedEntryVersionReplacesCurrentVersion(t *testing.T) {
sess := &mutableStubSession{model: vault.Model{ sess := &mutableStubSession{model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Password: "new-token", Password: "new-token",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
History: []vault.Entry{ History: []vault.Entry{
{ID: "git-server-h1", Title: "Git Server", Password: "old-token", Path: []string{"Root", "Internet"}}, {ID: "vault-console-h1", Title: "Vault Console", Password: "old-token", Path: []string{"Root", "Internet"}},
}, },
}, },
}, },
}} }}
state := State{ state := State{
Session: sess, Session: sess,
SelectedEntryID: "git-server", SelectedEntryID: "vault-console",
} }
if err := state.RestoreSelectedEntryVersion(0); err != nil { if err := state.RestoreSelectedEntryVersion(0); err != nil {
@@ -861,7 +947,7 @@ func TestCreateVaultResetsSelectionPathAndDirtyState(t *testing.T) {
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server", SelectedEntryID: "vault-console",
Dirty: true, Dirty: true,
} }
@@ -890,7 +976,7 @@ func TestOpenVaultResetsSelectionPathAndDirtyState(t *testing.T) {
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server", SelectedEntryID: "vault-console",
Dirty: true, Dirty: true,
} }
@@ -941,7 +1027,7 @@ func TestOpenRemoteVaultResetsSelectionPathAndDirtyState(t *testing.T) {
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server", SelectedEntryID: "vault-console",
Dirty: true, Dirty: true,
} }
@@ -963,20 +1049,199 @@ func TestOpenRemoteVaultResetsSelectionPathAndDirtyState(t *testing.T) {
} }
} }
func TestLockClearsSelectionAndMakesVaultUnavailable(t *testing.T) { func TestOpenBoundRemoteVaultResolvesClientFromVaultBinding(t *testing.T) {
t.Parallel() t.Parallel()
sess := &lockableStubSession{ sess := &lifecycleStubSession{
model: vault.Model{ model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}}, {
ID: "remote-creds-1",
Title: "Bellagio WebDAV Sign-In",
Username: "linuscaldwell",
Password: "bellagio-pass-1",
Path: []string{"Crew", "Internet"},
},
},
RemoteProfiles: []vault.RemoteProfile{
{
ID: "bellagio-webdav",
Name: "Bellagio Vault",
Backend: vault.RemoteBackendWebDAV,
BaseURL: "https://dav.example.invalid/remote.php/dav",
Path: "files/bellagio/keepass.kdbx",
},
}, },
}, },
} }
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server", SelectedEntryID: "vault-console",
Dirty: true,
}
err := state.OpenBoundRemoteVault(RemoteBinding{
LocalVaultPath: "/tmp/bellagio.kdbx",
RemoteProfileID: "bellagio-webdav",
CredentialEntryID: "remote-creds-1",
SyncMode: SyncModeAutomaticOnOpenSave,
}, vault.MasterKey{Password: "correct horse battery staple"})
if err != nil {
t.Fatalf("OpenBoundRemoteVault() error = %v", err)
}
if got := sess.remoteClient.BaseURL; got != "https://dav.example.invalid/remote.php/dav" {
t.Fatalf("remote client base URL = %q, want remote.php/dav URL", got)
}
if got := sess.remoteClient.Username; got != "linuscaldwell" {
t.Fatalf("remote client username = %q, want linuscaldwell", got)
}
if got := sess.remoteClient.Password; got != "bellagio-pass-1" {
t.Fatalf("remote client password = %q, want bellagio-pass-1", got)
}
if got := sess.remotePath; got != "files/bellagio/keepass.kdbx" {
t.Fatalf("remotePath = %q, want files/bellagio/keepass.kdbx", got)
}
if len(state.CurrentPath) != 0 {
t.Fatalf("CurrentPath = %v, want empty", state.CurrentPath)
}
if state.SelectedEntryID != "" {
t.Fatalf("SelectedEntryID = %q, want empty", state.SelectedEntryID)
}
if state.Dirty {
t.Fatal("Dirty = true, want false after bound remote open")
}
}
func TestOpenBoundRemoteVaultReturnsResolutionErrors(t *testing.T) {
t.Parallel()
sess := &lifecycleStubSession{model: vault.Model{}}
state := State{Session: sess}
err := state.OpenBoundRemoteVault(RemoteBinding{
LocalVaultPath: "/tmp/bellagio.kdbx",
RemoteProfileID: "missing-profile",
CredentialEntryID: "remote-creds-1",
}, vault.MasterKey{Password: "correct horse battery staple"})
if !errors.Is(err, vault.ErrRemoteProfileNotFound) {
t.Fatalf("OpenBoundRemoteVault() error = %v, want ErrRemoteProfileNotFound", err)
}
}
func TestConfigureRemoteBindingPersistsIntoCurrentVaultAndMarksDirty(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{model: vault.Model{}}
state := State{Session: sess}
binding, err := state.ConfigureRemoteBinding(RemoteBindingInput{
LocalVaultPath: "/tmp/bellagio.kdbx",
RemoteProfileID: "bellagio-webdav",
RemoteProfileName: "Bellagio Vault",
BaseURL: "https://dav.example.invalid/remote.php/dav",
RemotePath: "files/bellagio/keepass.kdbx",
CredentialEntryID: "remote-creds-1",
CredentialTitle: "Bellagio WebDAV Sign-In",
Username: "linuscaldwell",
Password: "bellagio-pass-1",
CredentialPath: []string{"Crew", "Internet"},
SyncMode: SyncModeAutomaticOnOpenSave,
})
if err != nil {
t.Fatalf("ConfigureRemoteBinding() error = %v", err)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after ConfigureRemoteBinding")
}
if got := binding.RemoteProfileID; got != "bellagio-webdav" {
t.Fatalf("binding.RemoteProfileID = %q, want bellagio-webdav", got)
}
if got := len(sess.model.RemoteProfiles); got != 1 {
t.Fatalf("len(RemoteProfiles) = %d, want 1", got)
}
credentials, err := sess.model.EntryByID("remote-creds-1")
if err != nil {
t.Fatalf("EntryByID(remote-creds-1) error = %v", err)
}
if credentials.Username != "linuscaldwell" || credentials.Password != "bellagio-pass-1" {
t.Fatalf("stored credential entry = %#v, want linuscaldwell/bellagio-pass-1", credentials)
}
}
func TestConfigureRemoteBindingRequiresMutableSession(t *testing.T) {
t.Parallel()
state := State{Session: stubSession{model: vault.Model{}}}
_, err := state.ConfigureRemoteBinding(RemoteBindingInput{
LocalVaultPath: "/tmp/bellagio.kdbx",
RemoteProfileID: "bellagio-webdav",
BaseURL: "https://dav.example.invalid/remote.php/dav",
RemotePath: "files/bellagio/keepass.kdbx",
CredentialEntryID: "remote-creds-1",
Password: "bellagio-pass-1",
})
if err == nil {
t.Fatal("ConfigureRemoteBinding() error = nil, want mutability error")
}
}
func TestRemoveRemoteBindingRemovesVaultDataAndMarksDirty(t *testing.T) {
t.Parallel()
sess := &mutableStubSession{model: vault.Model{
Entries: []vault.Entry{{
ID: "remote-creds-1",
Title: "Bellagio WebDAV Sign-In",
Username: "linuscaldwell",
Password: "bellagio-pass-1",
}},
RemoteProfiles: []vault.RemoteProfile{{
ID: "bellagio-webdav",
Name: "Bellagio Vault",
Backend: vault.RemoteBackendWebDAV,
BaseURL: "https://dav.example.invalid/remote.php/dav",
Path: "files/bellagio/keepass.kdbx",
}},
}}
state := State{Session: sess}
err := state.RemoveRemoteBinding(RemoteBinding{
LocalVaultPath: "/tmp/bellagio.kdbx",
RemoteProfileID: "bellagio-webdav",
CredentialEntryID: "remote-creds-1",
})
if err != nil {
t.Fatalf("RemoveRemoteBinding() error = %v", err)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after RemoveRemoteBinding")
}
if got := len(sess.model.RemoteProfiles); got != 0 {
t.Fatalf("len(RemoteProfiles) = %d, want 0", got)
}
if _, err := sess.model.EntryByID("remote-creds-1"); !errors.Is(err, vault.ErrEntryNotFound) {
t.Fatalf("EntryByID(remote-creds-1) error = %v, want ErrEntryNotFound", err)
}
}
func TestLockClearsSelectionAndMakesVaultUnavailable(t *testing.T) {
t.Parallel()
sess := &lockableStubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
},
}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console",
} }
if err := state.Lock(); err != nil { if err := state.Lock(); err != nil {
@@ -999,7 +1264,7 @@ func TestUnlockRestoresVaultVisibility(t *testing.T) {
sess := &lockableStubSession{ sess := &lockableStubSession{
model: vault.Model{ model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
}, },
}, },
locked: true, locked: true,
@@ -1017,8 +1282,8 @@ func TestUnlockRestoresVaultVisibility(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("VisibleEntries() error = %v", err) t.Fatalf("VisibleEntries() error = %v", err)
} }
if len(got) != 1 || got[0].ID != "git-server" { if len(got) != 1 || got[0].ID != "vault-console" {
t.Fatalf("VisibleEntries() = %#v, want git-server entry after unlock", got) t.Fatalf("VisibleEntries() = %#v, want vault-console entry after unlock", got)
} }
} }
@@ -1047,7 +1312,7 @@ func TestShowSectionResetsPathAndSelection(t *testing.T) {
state := State{ state := State{
Section: SectionEntries, Section: SectionEntries,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server", SelectedEntryID: "vault-console",
SearchQuery: "git", SearchQuery: "git",
} }
@@ -1083,7 +1348,7 @@ func TestBeginNewEntryClearsSelectionAndStatus(t *testing.T) {
t.Parallel() t.Parallel()
state := State{ state := State{
SelectedEntryID: "git-server", SelectedEntryID: "vault-console",
ErrorMessage: "previous error", ErrorMessage: "previous error",
} }
@@ -1127,7 +1392,7 @@ func TestEnterGroupAppendsPathAndClearsSelection(t *testing.T) {
state := State{ state := State{
CurrentPath: []string{"Root"}, CurrentPath: []string{"Root"},
SelectedEntryID: "git-server", SelectedEntryID: "vault-console",
} }
state.EnterGroup("Internet") state.EnterGroup("Internet")
@@ -1145,13 +1410,13 @@ func TestNavigateToPathReplacesPathAndClearsSelection(t *testing.T) {
state := State{ state := State{
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server", SelectedEntryID: "vault-console",
} }
state.NavigateToPath([]string{"Root", "Home Assistant"}) state.NavigateToPath([]string{"Root", "Security Office"})
if !slices.Equal(state.CurrentPath, []string{"Root", "Home Assistant"}) { if !slices.Equal(state.CurrentPath, []string{"Root", "Security Office"}) {
t.Fatalf("CurrentPath = %v, want [Root Home Assistant]", state.CurrentPath) t.Fatalf("CurrentPath = %v, want [Root Security Office]", state.CurrentPath)
} }
if got := state.SelectedEntryID; got != "" { if got := state.SelectedEntryID; got != "" {
t.Fatalf("SelectedEntryID = %q, want empty", got) t.Fatalf("SelectedEntryID = %q, want empty", got)
@@ -1184,8 +1449,8 @@ func TestDeleteCurrentGroupMovesToParentAndMarksDirty(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("ChildGroups() error = %v", err) t.Fatalf("ChildGroups() error = %v", err)
} }
if !slices.Equal(got, []string{"Home Assistant", "Internet"}) { if !slices.Equal(got, []string{"Internet", "Security Office"}) {
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got) t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got)
} }
} }
@@ -1207,8 +1472,8 @@ func TestCreateGroupPersistsGroupAndMarksDirty(t *testing.T) {
t.Fatalf("ChildGroups() error = %v", err) t.Fatalf("ChildGroups() error = %v", err)
} }
if !slices.Equal(got, []string{"Finance", "Home Assistant", "Internet"}) { if !slices.Equal(got, []string{"Finance", "Internet", "Security Office"}) {
t.Fatalf("ChildGroups() = %v, want Finance, Home Assistant, Internet", got) t.Fatalf("ChildGroups() = %v, want Finance, Internet, Security Office", got)
} }
if !state.Dirty { if !state.Dirty {
t.Fatal("Dirty = false, want true after CreateGroup") t.Fatal("Dirty = false, want true after CreateGroup")
@@ -1281,8 +1546,8 @@ func TestDeleteCurrentGroupRemovesItNavigatesToParentAndMarksDirty(t *testing.T)
t.Fatalf("ChildGroups() error = %v", err) t.Fatalf("ChildGroups() error = %v", err)
} }
if !slices.Equal(got, []string{"Home Assistant", "Internet"}) { if !slices.Equal(got, []string{"Internet", "Security Office"}) {
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got) t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got)
} }
if !state.Dirty { if !state.Dirty {
t.Fatal("Dirty = false, want true after DeleteCurrentGroup") t.Fatal("Dirty = false, want true after DeleteCurrentGroup")
@@ -1296,14 +1561,14 @@ func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) {
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot", SelectedEntryID: "bellagio",
} }
if err := state.MoveSelectedEntry([]string{"Root", "Home Assistant"}); err != nil { if err := state.MoveSelectedEntry([]string{"Root", "Security Office"}); err != nil {
t.Fatalf("MoveSelectedEntry() error = %v", err) t.Fatalf("MoveSelectedEntry() error = %v", err)
} }
state.NavigateToPath([]string{"Root", "Home Assistant"}) state.NavigateToPath([]string{"Root", "Security Office"})
got, err := state.VisibleEntries() got, err := state.VisibleEntries()
if err != nil { if err != nil {
t.Fatalf("VisibleEntries() error = %v", err) t.Fatalf("VisibleEntries() error = %v", err)
@@ -1312,8 +1577,8 @@ func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) {
if len(got) != 2 { if len(got) != 2 {
t.Fatalf("len(VisibleEntries()) = %d, want 2", len(got)) t.Fatalf("len(VisibleEntries()) = %d, want 2", len(got))
} }
if got[0].ID != "dynadot" && got[1].ID != "dynadot" { if got[0].ID != "bellagio" && got[1].ID != "bellagio" {
t.Fatalf("VisibleEntries() = %#v, want moved dynadot entry in destination group", got) t.Fatalf("VisibleEntries() = %#v, want moved bellagio entry in destination group", got)
} }
if !state.Dirty { if !state.Dirty {
t.Fatal("Dirty = false, want true after MoveSelectedEntry") t.Fatal("Dirty = false, want true after MoveSelectedEntry")
@@ -1325,7 +1590,7 @@ func TestMoveCurrentGroupMovesHierarchyAndMarksDirty(t *testing.T) {
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
}, },
} }
model.CreateGroup([]string{"Root", "Internet"}, "Infrastructure") model.CreateGroup([]string{"Root", "Internet"}, "Infrastructure")
@@ -1336,15 +1601,15 @@ func TestMoveCurrentGroupMovesHierarchyAndMarksDirty(t *testing.T) {
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
} }
if err := state.MoveCurrentGroup([]string{"Root", "Joe"}); err != nil { if err := state.MoveCurrentGroup([]string{"Root", "Crew"}); err != nil {
t.Fatalf("MoveCurrentGroup() error = %v", err) t.Fatalf("MoveCurrentGroup() error = %v", err)
} }
if !slices.Equal(state.CurrentPath, []string{"Root", "Joe", "Internet"}) { if !slices.Equal(state.CurrentPath, []string{"Root", "Crew", "Internet"}) {
t.Fatalf("CurrentPath = %v, want [Root Joe Internet]", state.CurrentPath) t.Fatalf("CurrentPath = %v, want [Root Crew Internet]", state.CurrentPath)
} }
if got := session.model.EntriesInPath([]string{"Root", "Joe", "Internet"}); len(got) != 1 || got[0].ID != "git-server" { if got := session.model.EntriesInPath([]string{"Root", "Crew", "Internet"}); len(got) != 1 || got[0].ID != "vault-console" {
t.Fatalf("EntriesInPath(Root/Joe/Internet) = %#v, want moved entry", got) t.Fatalf("EntriesInPath(Root/Crew/Internet) = %#v, want moved entry", got)
} }
if !state.Dirty { if !state.Dirty {
t.Fatal("Dirty = false, want true after MoveCurrentGroup") t.Fatal("Dirty = false, want true after MoveCurrentGroup")
@@ -1358,7 +1623,7 @@ func TestAddAttachmentToSelectedEntryPersistsAndMarksDirty(t *testing.T) {
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot", SelectedEntryID: "bellagio",
} }
if err := state.AddAttachmentToSelectedEntry("token.txt", []byte("secret")); err != nil { if err := state.AddAttachmentToSelectedEntry("token.txt", []byte("secret")); err != nil {
@@ -1386,7 +1651,7 @@ func TestAddAttachmentToSelectedEntryRejectsDuplicateNames(t *testing.T) {
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot", SelectedEntryID: "bellagio",
} }
err := state.AddAttachmentToSelectedEntry("token.txt", []byte("replacement")) err := state.AddAttachmentToSelectedEntry("token.txt", []byte("replacement"))
@@ -1412,7 +1677,7 @@ func TestReplaceAttachmentOnSelectedEntryPersistsAndMarksDirty(t *testing.T) {
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot", SelectedEntryID: "bellagio",
} }
if err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement")); err != nil { if err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement")); err != nil {
@@ -1438,7 +1703,7 @@ func TestReplaceAttachmentOnSelectedEntryRequiresExistingAttachment(t *testing.T
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot", SelectedEntryID: "bellagio",
} }
err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement")) err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement"))
@@ -1456,7 +1721,7 @@ func TestDeleteAttachmentFromSelectedEntryPersistsAndMarksDirty(t *testing.T) {
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot", SelectedEntryID: "bellagio",
} }
if err := state.DeleteAttachmentFromSelectedEntry("token.txt"); err != nil { if err := state.DeleteAttachmentFromSelectedEntry("token.txt"); err != nil {
@@ -1482,7 +1747,7 @@ func TestDeleteAttachmentFromSelectedEntryRequiresExistingAttachment(t *testing.
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot", SelectedEntryID: "bellagio",
} }
err := state.DeleteAttachmentFromSelectedEntry("token.txt") err := state.DeleteAttachmentFromSelectedEntry("token.txt")
@@ -1510,8 +1775,8 @@ func (s *mutableStubSession) Replace(model vault.Model) {
func testVaultModel() vault.Model { func testVaultModel() vault.Model {
return vault.Model{ return vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "dynadot", Title: "Dynadot", Path: []string{"Root", "Internet"}}, {ID: "bellagio", Title: "Bellagio", Path: []string{"Root", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Root", "Home Assistant"}}, {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Root", "Security Office"}},
}, },
} }
} }
@@ -1553,14 +1818,16 @@ func (s *saveableStubSession) Save() error {
type lifecycleStubSession struct { type lifecycleStubSession struct {
createCalls int createCalls int
model vault.Model
openPath string openPath string
saveAsPath string saveAsPath string
remoteClient webdav.Client
remotePath string remotePath string
changedKey vault.MasterKey changedKey vault.MasterKey
} }
func (s *lifecycleStubSession) Current() (vault.Model, error) { func (s *lifecycleStubSession) Current() (vault.Model, error) {
return vault.Model{}, nil return s.model, nil
} }
func (s *lifecycleStubSession) Create(_ vault.Model, _ vault.MasterKey) error { func (s *lifecycleStubSession) Create(_ vault.Model, _ vault.MasterKey) error {
@@ -1578,11 +1845,32 @@ func (s *lifecycleStubSession) SaveAs(path string) error {
return nil return nil
} }
func (s *lifecycleStubSession) OpenRemote(_ webdav.Client, path string, _ vault.MasterKey) error { func (s *lifecycleStubSession) OpenRemote(client webdav.Client, path string, _ vault.MasterKey) error {
s.remoteClient = client
s.remotePath = path s.remotePath = path
return nil return nil
} }
func (s *lifecycleStubSession) SynchronizeFromLocal(string) error {
return nil
}
func (s *lifecycleStubSession) SynchronizeFromLocalBytes(string, []byte) error {
return nil
}
func (s *lifecycleStubSession) SynchronizeToLocal(string) error {
return nil
}
func (s *lifecycleStubSession) SynchronizeFromRemote(webdav.Client, string) error {
return nil
}
func (s *lifecycleStubSession) SynchronizeToRemote(webdav.Client, string) error {
return nil
}
func (s *lifecycleStubSession) ChangeMasterKey(key vault.MasterKey) error { func (s *lifecycleStubSession) ChangeMasterKey(key vault.MasterKey) error {
s.changedKey = key s.changedKey = key
return nil return nil
+93
View File
@@ -0,0 +1,93 @@
package api
import (
"strings"
"git.julianfamily.org/keepassgo/internal/apiaudit"
"git.julianfamily.org/keepassgo/internal/apitokens"
)
type AuditQuickFilter struct {
Label string
Query string
}
func Operations() []apitokens.Operation {
return []apitokens.Operation{
apitokens.OperationListEntries,
apitokens.OperationListGroups,
apitokens.OperationListTemplates,
apitokens.OperationReadEntry,
apitokens.OperationCopyPassword,
apitokens.OperationCopyUsername,
apitokens.OperationCopyURL,
apitokens.OperationMutateEntry,
apitokens.OperationMutateGroup,
apitokens.OperationMutateTemplate,
apitokens.OperationGeneratePassword,
apitokens.OperationManageVault,
}
}
func AuditDecisionLabel(eventType apiaudit.EventType) string {
switch eventType {
case apiaudit.EventApprovalRequested:
return "Requested"
case apiaudit.EventApprovalAllowed:
return "Allowed"
case apiaudit.EventApprovalDenied:
return "Denied"
case apiaudit.EventApprovalCanceled:
return "Canceled"
case apiaudit.EventApprovalTimedOut:
return "Timed Out"
case apiaudit.EventAuthRejected:
return "Auth Rejected"
default:
return strings.ReplaceAll(string(eventType), "_", " ")
}
}
func AuditOperationLabel(operation apitokens.Operation) string {
if strings.TrimSpace(string(operation)) == "" {
return "Other"
}
return strings.ReplaceAll(string(operation), "_", " ")
}
func CompactAuditFilterLabel(label string) string {
label = strings.TrimSpace(label)
if len(label) <= 22 {
return label
}
return label[:19] + "..."
}
func AuditEventSearchTerms(event apiaudit.Event) string {
parts := []string{
string(event.Type),
AuditDecisionLabel(event.Type),
event.TokenName,
event.ClientName,
string(event.Operation),
AuditOperationLabel(event.Operation),
strings.Join(event.Resource.Path, " / "),
event.Resource.EntryID,
event.Message,
}
switch event.Type {
case apiaudit.EventApprovalAllowed:
parts = append(parts, "allow approved")
case apiaudit.EventApprovalDenied:
parts = append(parts, "deny denied")
case apiaudit.EventApprovalRequested:
parts = append(parts, "prompt requested")
case apiaudit.EventApprovalCanceled:
parts = append(parts, "cancel canceled")
case apiaudit.EventApprovalTimedOut:
parts = append(parts, "timeout timed out")
case apiaudit.EventAuthRejected:
parts = append(parts, "rejected unauthorized")
}
return strings.ToLower(strings.Join(parts, " "))
}
+228 -105
View File
@@ -1,4 +1,4 @@
package main package appui
import ( import (
"fmt" "fmt"
@@ -10,91 +10,12 @@ import (
"gioui.org/unit" "gioui.org/unit"
"gioui.org/widget" "gioui.org/widget"
"gioui.org/widget/material" "gioui.org/widget/material"
"git.julianfamily.org/keepassgo/apiaudit" "git.julianfamily.org/keepassgo/internal/apiaudit"
"git.julianfamily.org/keepassgo/apitokens" "git.julianfamily.org/keepassgo/internal/apitokens"
apiui "git.julianfamily.org/keepassgo/internal/appui/api"
) )
func apiOperations() []apitokens.Operation { type apiAuditQuickFilter = apiui.AuditQuickFilter
return []apitokens.Operation{
apitokens.OperationListEntries,
apitokens.OperationListGroups,
apitokens.OperationReadEntry,
apitokens.OperationCopyPassword,
apitokens.OperationCopyUsername,
apitokens.OperationCopyURL,
apitokens.OperationMutateEntry,
apitokens.OperationMutateGroup,
apitokens.OperationManageVault,
}
}
type apiAuditQuickFilter struct {
Label string
Query string
}
func apiAuditDecisionLabel(eventType apiaudit.EventType) string {
switch eventType {
case apiaudit.EventApprovalRequested:
return "Requested"
case apiaudit.EventApprovalAllowed:
return "Allowed"
case apiaudit.EventApprovalDenied:
return "Denied"
case apiaudit.EventApprovalCanceled:
return "Canceled"
case apiaudit.EventApprovalTimedOut:
return "Timed Out"
case apiaudit.EventAuthRejected:
return "Auth Rejected"
default:
return strings.ReplaceAll(string(eventType), "_", " ")
}
}
func apiAuditOperationLabel(operation apitokens.Operation) string {
if strings.TrimSpace(string(operation)) == "" {
return "Other"
}
return strings.ReplaceAll(string(operation), "_", " ")
}
func compactAuditFilterLabel(label string) string {
label = strings.TrimSpace(label)
if len(label) <= 22 {
return label
}
return label[:19] + "..."
}
func apiAuditEventSearchTerms(event apiaudit.Event) string {
parts := []string{
string(event.Type),
apiAuditDecisionLabel(event.Type),
event.TokenName,
event.ClientName,
string(event.Operation),
apiAuditOperationLabel(event.Operation),
strings.Join(event.Resource.Path, " / "),
event.Resource.EntryID,
event.Message,
}
switch event.Type {
case apiaudit.EventApprovalAllowed:
parts = append(parts, "allow approved")
case apiaudit.EventApprovalDenied:
parts = append(parts, "deny denied")
case apiaudit.EventApprovalRequested:
parts = append(parts, "prompt requested")
case apiaudit.EventApprovalCanceled:
parts = append(parts, "cancel canceled")
case apiaudit.EventApprovalTimedOut:
parts = append(parts, "timeout timed out")
case apiaudit.EventAuthRejected:
parts = append(parts, "rejected unauthorized")
}
return strings.ToLower(strings.Join(parts, " "))
}
func apiAuditFilterButtons(clicks *[]widget.Clickable, filters []apiAuditQuickFilter) []widget.Clickable { func apiAuditFilterButtons(clicks *[]widget.Clickable, filters []apiAuditQuickFilter) []widget.Clickable {
if len(filters) == 0 { if len(filters) == 0 {
@@ -126,7 +47,7 @@ func (u *ui) apiAuditQuickFilters(events []apiaudit.Event) ([]apiAuditQuickFilte
} }
if _, ok := decisionSeen[event.Type]; !ok { if _, ok := decisionSeen[event.Type]; !ok {
decisionSeen[event.Type] = struct{}{} decisionSeen[event.Type] = struct{}{}
label := apiAuditDecisionLabel(event.Type) label := apiui.AuditDecisionLabel(event.Type)
decisions = append(decisions, apiAuditQuickFilter{Label: label, Query: label}) decisions = append(decisions, apiAuditQuickFilter{Label: label, Query: label})
} }
if strings.TrimSpace(string(event.Operation)) == "" { if strings.TrimSpace(string(event.Operation)) == "" {
@@ -136,7 +57,7 @@ func (u *ui) apiAuditQuickFilters(events []apiaudit.Event) ([]apiAuditQuickFilte
continue continue
} }
operationSeen[event.Operation] = struct{}{} operationSeen[event.Operation] = struct{}{}
label := apiAuditOperationLabel(event.Operation) label := apiui.AuditOperationLabel(event.Operation)
operations = append(operations, apiAuditQuickFilter{Label: label, Query: label}) operations = append(operations, apiAuditQuickFilter{Label: label, Query: label})
} }
@@ -208,7 +129,22 @@ func (u *ui) ensureAPIPolicyRemoveClickables(count int) []widget.Clickable {
return clicks return clicks
} }
func (u *ui) ensureAPIPolicyEditClickables(count int) []widget.Clickable {
if count <= 0 {
u.apiPolicyEdits = nil
return nil
}
if len(u.apiPolicyEdits) == count {
return u.apiPolicyEdits
}
clicks := make([]widget.Clickable, count)
copy(clicks, u.apiPolicyEdits)
u.apiPolicyEdits = clicks
return clicks
}
func (u *ui) loadSelectedAPITokenIntoEditor() { func (u *ui) loadSelectedAPITokenIntoEditor() {
u.selectedAPIPolicyIndex = -1
token, ok := u.selectedAPIToken() token, ok := u.selectedAPIToken()
if !ok { if !ok {
u.apiTokenSecret = "" u.apiTokenSecret = ""
@@ -222,6 +158,7 @@ func (u *ui) loadSelectedAPITokenIntoEditor() {
u.apiPolicyAllow.Value = true u.apiPolicyAllow.Value = true
u.apiPolicyGroupScope = true u.apiPolicyGroupScope = true
u.apiPolicyGroupScopeW.Value = true u.apiPolicyGroupScopeW.Value = true
u.ensureAPIPolicyEditClickables(0)
u.ensureAPIPolicyRemoveClickables(0) u.ensureAPIPolicyRemoveClickables(0)
return return
} }
@@ -233,6 +170,7 @@ func (u *ui) loadSelectedAPITokenIntoEditor() {
u.apiTokenExpiresAt.SetText("") u.apiTokenExpiresAt.SetText("")
} }
u.apiTokenDisabled.Value = token.Disabled u.apiTokenDisabled.Value = token.Disabled
u.ensureAPIPolicyEditClickables(len(token.Policies))
u.ensureAPIPolicyRemoveClickables(len(token.Policies)) u.ensureAPIPolicyRemoveClickables(len(token.Policies))
} }
@@ -321,7 +259,7 @@ func parseAPITokenExpiry(text string) (*time.Time, error) {
func parseAPIPolicyOperation(text string) (apitokens.Operation, error) { func parseAPIPolicyOperation(text string) (apitokens.Operation, error) {
value := apitokens.Operation(strings.TrimSpace(text)) value := apitokens.Operation(strings.TrimSpace(text))
for _, operation := range apiOperations() { for _, operation := range apiui.Operations() {
if operation == value { if operation == value {
return value, nil return value, nil
} }
@@ -329,14 +267,10 @@ func parseAPIPolicyOperation(text string) (apitokens.Operation, error) {
return "", fmt.Errorf("unknown API operation %q", text) return "", fmt.Errorf("unknown API operation %q", text)
} }
func (u *ui) addAPIPolicyRuleAction() error { func (u *ui) apiPolicyRuleFromEditor() (apitokens.PolicyRule, error) {
token, ok := u.selectedAPIToken()
if !ok {
return fmt.Errorf("no API token selected")
}
operation, err := parseAPIPolicyOperation(u.apiPolicyOperation.Text()) operation, err := parseAPIPolicyOperation(u.apiPolicyOperation.Text())
if err != nil { if err != nil {
return err return apitokens.PolicyRule{}, err
} }
rule := apitokens.PolicyRule{ rule := apitokens.PolicyRule{
Operation: operation, Operation: operation,
@@ -349,16 +283,28 @@ func (u *ui) addAPIPolicyRuleAction() error {
if u.apiPolicyGroupScope { if u.apiPolicyGroupScope {
path := parsePath(u.apiPolicyPath.Text()) path := parsePath(u.apiPolicyPath.Text())
if len(path) == 0 { if len(path) == 0 {
return fmt.Errorf("policy path is required for group scope") return apitokens.PolicyRule{}, fmt.Errorf("policy path is required for group scope")
} }
rule.Resource = apitokens.Resource{Kind: apitokens.ResourceGroup, Path: path} rule.Resource = apitokens.Resource{Kind: apitokens.ResourceGroup, Path: path}
} else { } else {
entryID := strings.TrimSpace(u.apiPolicyEntryID.Text()) entryID := strings.TrimSpace(u.apiPolicyEntryID.Text())
if entryID == "" { if entryID == "" {
return fmt.Errorf("entry id is required for entry scope") return apitokens.PolicyRule{}, fmt.Errorf("entry id is required for entry scope")
} }
rule.Resource = apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entryID} rule.Resource = apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entryID}
} }
return rule, nil
}
func (u *ui) addAPIPolicyRuleAction() error {
token, ok := u.selectedAPIToken()
if !ok {
return fmt.Errorf("no API token selected")
}
rule, err := u.apiPolicyRuleFromEditor()
if err != nil {
return err
}
if !uiHasPolicyRule(token.Policies, rule) { if !uiHasPolicyRule(token.Policies, rule) {
token.Policies = append(token.Policies, rule) token.Policies = append(token.Policies, rule)
} }
@@ -369,6 +315,114 @@ func (u *ui) addAPIPolicyRuleAction() error {
return nil return nil
} }
func (u *ui) editAPIPolicyRuleAction(index int) error {
token, ok := u.selectedAPIToken()
if !ok {
return fmt.Errorf("no API token selected")
}
if index < 0 || index >= len(token.Policies) {
return fmt.Errorf("policy index %d out of range", index)
}
rule := token.Policies[index]
u.selectedAPIPolicyIndex = index
u.apiPolicyOperation.SetText(string(rule.Operation))
u.apiPolicyAllow.Value = rule.Effect == apitokens.EffectAllow
if rule.Resource.Kind == apitokens.ResourceEntry {
u.apiPolicyGroupScope = false
u.apiPolicyGroupScopeW.Value = false
u.apiPolicyEntryID.SetText(strings.TrimSpace(rule.Resource.EntryID))
u.apiPolicyPath.SetText("")
return nil
}
u.apiPolicyGroupScope = true
u.apiPolicyGroupScopeW.Value = true
u.apiPolicyPath.SetText(strings.Join(rule.Resource.Path, " / "))
u.apiPolicyEntryID.SetText("")
return nil
}
func (u *ui) saveAPIPolicyRuleAction() error {
token, ok := u.selectedAPIToken()
if !ok {
return fmt.Errorf("no API token selected")
}
index := u.selectedAPIPolicyIndex
if index < 0 || index >= len(token.Policies) {
return fmt.Errorf("no API policy rule selected")
}
rule, err := u.apiPolicyRuleFromEditor()
if err != nil {
return err
}
for i, existing := range token.Policies {
if i != index && uiHasPolicyRule([]apitokens.PolicyRule{existing}, rule) {
token.Policies = append(token.Policies[:index], token.Policies[index+1:]...)
if err := u.state.UpsertAPIToken(token); err != nil {
return err
}
u.loadSelectedAPITokenIntoEditor()
return nil
}
}
token.Policies[index] = rule
if err := u.state.UpsertAPIToken(token); err != nil {
return err
}
u.loadSelectedAPITokenIntoEditor()
return nil
}
func (u *ui) apiPolicyGroupPathSummary() string {
path := parsePath(u.apiPolicyPath.Text())
if len(path) == 0 {
return "No group selected"
}
return strings.Join(path, " / ")
}
func (u *ui) apiPolicyEntrySummary() string {
id := strings.TrimSpace(u.apiPolicyEntryID.Text())
if id == "" {
return "No entry selected"
}
if item, ok := u.selectedEntry(); ok && item.ID == id {
if strings.TrimSpace(item.Title) != "" {
return item.Title + " (" + id + ")"
}
}
return id
}
func (u *ui) useCurrentGroupForPolicyAction() error {
u.syncCurrentPath()
path := u.displayPath()
if len(path) == 0 {
return fmt.Errorf("navigate to a group first")
}
u.apiPolicyGroupScope = true
u.apiPolicyGroupScopeW.Value = true
u.apiPolicyPath.SetText(strings.Join(path, " / "))
u.apiPolicyEntryID.SetText("")
return nil
}
func (u *ui) useSelectedEntryForPolicyAction() error {
item, ok := u.selectedEntry()
if !ok || strings.TrimSpace(item.ID) == "" {
return fmt.Errorf("select an entry first")
}
u.apiPolicyGroupScope = false
u.apiPolicyGroupScopeW.Value = false
u.apiPolicyEntryID.SetText(item.ID)
return nil
}
func (u *ui) clearAPIPolicyTargetAction() error {
u.apiPolicyPath.SetText("")
u.apiPolicyEntryID.SetText("")
return nil
}
func (u *ui) removeAPIPolicyRuleAction(index int) error { func (u *ui) removeAPIPolicyRuleAction(index int) error {
token, ok := u.selectedAPIToken() token, ok := u.selectedAPIToken()
if !ok { if !ok {
@@ -385,6 +439,11 @@ func (u *ui) removeAPIPolicyRuleAction(index int) error {
return nil return nil
} }
func (u *ui) cancelAPIPolicyEditAction() error {
u.loadSelectedAPITokenIntoEditor()
return nil
}
func (u *ui) apiAuditEvents() []apiaudit.Event { func (u *ui) apiAuditEvents() []apiaudit.Event {
if u.auditLog == nil { if u.auditLog == nil {
return nil return nil
@@ -399,7 +458,7 @@ func (u *ui) apiAuditEvents() []apiaudit.Event {
} }
filtered := make([]apiaudit.Event, 0, len(events)) filtered := make([]apiaudit.Event, 0, len(events))
for _, event := range events { for _, event := range events {
haystack := apiAuditEventSearchTerms(event) haystack := apiui.AuditEventSearchTerms(event)
if strings.Contains(haystack, query) { if strings.Contains(haystack, query) {
filtered = append(filtered, event) filtered = append(filtered, event)
} }
@@ -734,7 +793,7 @@ func (u *ui) apiAuditQuickFilterRow(gtx layout.Context, title string, filters []
click := &buttons[i] click := &buttons[i]
selected := strings.EqualFold(strings.TrimSpace(u.search.Text()), strings.TrimSpace(filter.Query)) selected := strings.EqualFold(strings.TrimSpace(u.search.Text()), strings.TrimSpace(filter.Query))
column = append(column, layout.Rigid(func(gtx layout.Context) layout.Dimensions { column = append(column, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.auditQuickFilterButton(gtx, click, compactAuditFilterLabel(filter.Label), selected, filter.Query) return u.auditQuickFilterButton(gtx, click, apiui.CompactAuditFilterLabel(filter.Label), selected, filter.Query)
})) }))
} }
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, column...) return layout.Flex{Axis: layout.Vertical}.Layout(gtx, column...)
@@ -748,7 +807,7 @@ func (u *ui) apiAuditQuickFilterRow(gtx layout.Context, title string, filters []
click := &buttons[i] click := &buttons[i]
selected := strings.EqualFold(strings.TrimSpace(u.search.Text()), strings.TrimSpace(filter.Query)) selected := strings.EqualFold(strings.TrimSpace(u.search.Text()), strings.TrimSpace(filter.Query))
flexChildren = append(flexChildren, layout.Rigid(func(gtx layout.Context) layout.Dimensions { flexChildren = append(flexChildren, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.auditQuickFilterButton(gtx, click, compactAuditFilterLabel(filter.Label), selected, filter.Query) return u.auditQuickFilterButton(gtx, click, apiui.CompactAuditFilterLabel(filter.Label), selected, filter.Query)
})) }))
} }
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, flexChildren...) return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, flexChildren...)
@@ -777,8 +836,10 @@ func (u *ui) auditQuickFilterButton(gtx layout.Context, click *widget.Clickable,
func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions { func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
token, ok := u.selectedAPIToken() token, ok := u.selectedAPIToken()
editClicks := u.ensureAPIPolicyEditClickables(0)
removeClicks := u.ensureAPIPolicyRemoveClickables(0) removeClicks := u.ensureAPIPolicyRemoveClickables(0)
if ok { if ok {
editClicks = u.ensureAPIPolicyEditClickables(len(token.Policies))
removeClicks = u.ensureAPIPolicyRemoveClickables(len(token.Policies)) removeClicks = u.ensureAPIPolicyRemoveClickables(len(token.Policies))
} }
rows := []layout.Widget{ rows := []layout.Widget{
@@ -946,6 +1007,10 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx, return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, detailLine(u.theme, "Effect", effect)), layout.Flexed(1, detailLine(u.theme, "Effect", effect)),
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout), layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &editClicks[index], "Edit")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &removeClicks[index], "Remove") return tonedButton(gtx, u.theme, &removeClicks[index], "Remove")
}), }),
@@ -979,15 +1044,23 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
rows = append(rows, rows = append(rows,
func(gtx layout.Context) layout.Dimensions { func(gtx layout.Context) layout.Dimensions {
return card(gtx, func(gtx layout.Context) layout.Dimensions { return card(gtx, func(gtx layout.Context) layout.Dimensions {
actionLabel := "Add Rule"
title := "Policy Composer"
description := "Rules are evaluated per operation. Explicit deny rules override allow rules."
if 0 <= u.selectedAPIPolicyIndex {
actionLabel = "Save Rule"
title = "Policy Editor"
description = "Editing an existing rule. Save the updated scope or cancel to return to a blank composer."
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(14), "Policy Composer") lbl := material.Label(u.theme, unit.Sp(14), title)
lbl.Color = accentColor lbl.Color = accentColor
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "Rules are evaluated per operation. Explicit deny rules override allow rules.") lbl := material.Label(u.theme, unit.Sp(12), description)
lbl.Color = mutedColor lbl.Color = mutedColor
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
@@ -1000,14 +1073,64 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
return material.CheckBox(u.theme, &u.apiPolicyGroupScopeW, "Group scope (unchecked means exact entry scope)").Layout(gtx) return material.CheckBox(u.theme, &u.apiPolicyGroupScopeW, "Group scope (unchecked means exact entry scope)").Layout(gtx)
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "Operation", "Valid operations: "+strings.Join(stringOps(apiOperations()), ", "), &u.apiPolicyOperation, false)), layout.Rigid(labeledEditorHelp(u.theme, "Operation", "Valid operations: "+strings.Join(stringOps(apiui.Operations()), ", "), &u.apiPolicyOperation, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "Group Path", "Used when group scope is enabled.", &u.apiPolicyPath, false)), layout.Rigid(func(gtx layout.Context) layout.Dimensions {
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), if u.apiPolicyGroupScopeW.Value {
layout.Rigid(labeledEditorHelp(u.theme, "Entry ID", "Used when group scope is disabled.", &u.apiPolicyEntryID, false)), return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(detailLine(u.theme, "Group Path", u.apiPolicyGroupPathSummary())),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.addAPIPolicyRule, "Add Rule") return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.useCurrentGroupForPolicy, "Use Current Group")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.clearAPIPolicyTarget, "Clear")
}),
)
}),
)
})
}
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(detailLine(u.theme, "Entry", u.apiPolicyEntrySummary())),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.useSelectedEntryForPolicy, "Use Selected Entry")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.clearAPIPolicyTarget, "Clear")
}),
)
}),
)
})
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if 0 <= u.selectedAPIPolicyIndex {
return tonedButton(gtx, u.theme, &u.saveAPIPolicyRule, actionLabel)
}
return tonedButton(gtx, u.theme, &u.addAPIPolicyRule, actionLabel)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.selectedAPIPolicyIndex < 0 {
return layout.Dimensions{}
}
return layout.Inset{Left: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.cancelAPIPolicyEdit, "Cancel Edit")
})
}),
)
}), }),
) )
}) })
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
package layout
type Mode string
const (
ModeLocked Mode = "locked"
ModeStatic Mode = "static"
ModeEmpty Mode = "empty"
ModeEditor Mode = "editor"
ModeView Mode = "view"
)
func Resolve(isLocked bool, hasStaticPanel bool, hasSelectedEntry bool, editing bool) Mode {
switch {
case isLocked:
return ModeLocked
case hasStaticPanel:
return ModeStatic
case !hasSelectedEntry && !editing:
return ModeEmpty
case editing:
return ModeEditor
default:
return ModeView
}
}
+17
View File
@@ -0,0 +1,17 @@
package detail
type EmptyState struct {
Title string
Body string
}
type VaultSummary struct {
Title string
Detail string
Context string
}
type AttachmentItem struct {
Name string
Size int
}
+64
View File
@@ -0,0 +1,64 @@
package editor
import "strings"
type Field string
const (
FieldID Field = "id"
FieldTitle Field = "title"
FieldUsername Field = "username"
FieldPassword Field = "password"
FieldURL Field = "url"
FieldPath Field = "path"
FieldTags Field = "tags"
FieldPasswordProfile Field = "password-profile"
FieldNotes Field = "notes"
FieldFields Field = "fields"
FieldHistoryIndex Field = "history-index"
)
func Label(field Field) string {
switch field {
case FieldID:
return "ID"
case FieldTitle:
return "Title"
case FieldUsername:
return "Username"
case FieldPassword:
return "Password"
case FieldURL:
return "URL"
case FieldPath:
return "Path"
case FieldTags:
return "Tags"
case FieldPasswordProfile:
return "Password Profile"
case FieldNotes:
return "Notes"
case FieldFields:
return "Custom Fields"
case FieldHistoryIndex:
return "History Index"
default:
return strings.ReplaceAll(string(field), "-", " ")
}
}
func FocusOrder() []Field {
return []Field{
FieldID,
FieldTitle,
FieldUsername,
FieldPassword,
FieldURL,
FieldPath,
FieldTags,
FieldPasswordProfile,
FieldNotes,
FieldFields,
FieldHistoryIndex,
}
}
@@ -1,4 +1,4 @@
package main package appui
import ( import (
"fmt" "fmt"
@@ -8,9 +8,9 @@ import (
"strings" "strings"
"gioui.org/widget" "gioui.org/widget"
"git.julianfamily.org/keepassgo/clipboard" "git.julianfamily.org/keepassgo/internal/clipboard"
"git.julianfamily.org/keepassgo/passwords" "git.julianfamily.org/keepassgo/internal/passwords"
"git.julianfamily.org/keepassgo/vault" "git.julianfamily.org/keepassgo/internal/vault"
) )
func (u *ui) attachmentInput() (string, []byte, error) { func (u *ui) attachmentInput() (string, []byte, error) {
File diff suppressed because it is too large Load Diff
+335
View File
@@ -0,0 +1,335 @@
package appui
import (
"fmt"
"image"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
headerview "git.julianfamily.org/keepassgo/internal/appui/header"
headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout"
)
func (u *ui) header(gtx layout.Context) layout.Dimensions {
if u.usesCompactViewport() {
if u.shouldShowLifecycleSetup() || u.isVaultLocked() {
return layout.Dimensions{}
}
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return u.headerActions(gtx)
}
if u.shouldShowDesktopWorkingHeader() {
return layout.Dimensions{}
}
return card(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.brandMark(gtx, 196, 56)
}),
layout.Flexed(1, u.headerActions),
)
})
}
func (u *ui) headerActions(gtx layout.Context) layout.Dimensions {
if u.shouldShowLifecycleSetup() || u.isVaultLocked() {
return layout.Dimensions{}
}
cluster := u.newHeaderActionCluster(gtx)
rowDims := cluster.layout(gtx, u)
if u.usesCompactViewport() {
u.maybeLogHeaderBounds(newHeaderButtonBounds(image.Pt(u.frameInsetPx, u.frameInsetPx), cluster.Metrics.Bounds()))
}
if u.usesCompactViewport() {
cluster.prepareCompactMenus(gtx, u)
return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, rowDims.Size.Y)}
}
return rowDims
}
type headerActionCluster struct {
Metrics headerlayout.ActionMetrics
SyncMenu layout.Widget
MainMenu layout.Widget
}
func (c headerActionCluster) ShowSyncMenu() bool { return c.SyncMenu != nil }
func (c headerActionCluster) ShowMainMenu() bool { return c.MainMenu != nil }
func (u *ui) newHeaderActionCluster(gtx layout.Context) headerActionCluster {
cluster := headerActionCluster{
SyncMenu: u.syncMenuWidget(),
MainMenu: u.mainMenuWidget(),
}
spacing := gtx.Dp(unit.Dp(8))
cluster.Metrics = headerlayout.ActionMetrics{Spacing: spacing, SyncInnerSpacing: gtx.Dp(unit.Dp(3))}
if !u.usesCompactViewport() {
cluster.Metrics.SyncInnerSpacing = gtx.Dp(unit.Dp(4))
}
return cluster
}
func (c *headerActionCluster) layout(gtx layout.Context, u *ui) layout.Dimensions {
rowOps := op.Record(gtx.Ops)
c.Metrics.RowDims = c.layoutRow(gtx, u)
rowCall := rowOps.Stop()
c.Metrics.RowOriginX = max(0, gtx.Constraints.Max.X-c.Metrics.RowDims.Size.X)
return layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
rowCall.Add(gtx.Ops)
return c.Metrics.RowDims
})
}
func (c headerActionCluster) activeMenu() layout.Widget {
switch {
case c.ShowSyncMenu():
return c.SyncMenu
case c.ShowMainMenu():
return c.MainMenu
default:
return nil
}
}
func (c *headerActionCluster) layoutRow(gtx layout.Context, u *ui) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
c.Metrics.SyncDims, c.Metrics.SyncPrimaryDims, c.Metrics.SyncToggleDims = u.syncButtonGroupWithMetrics(gtx)
return c.Metrics.SyncDims
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
btn := material.Button(u.theme, &u.lockVault, "Lock")
c.Metrics.LockDims = btn.Layout(gtx)
return c.Metrics.LockDims
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
c.Metrics.MainDims = u.mainMenuButtonGroup(gtx)
return c.Metrics.MainDims
}),
)
}
func (c *headerActionCluster) prepareCompactMenus(gtx layout.Context, u *ui) {
compactSurface := headerlayout.DropdownSurface{
ContainerWidth: gtx.Constraints.Max.X,
LeftInset: u.frameInsetPx,
TopInset: u.frameInsetPx,
}
if c.ShowSyncMenu() {
u.phoneSyncMenuVisible = true
u.maybeLogHeaderMenuToggle("sync-visible", true)
placement, menuCall := compactSurface.Place(gtx, c.Metrics.SyncAnchor(), c.SyncMenu)
u.phoneSyncMenuOrigin = placement.Origin
u.phoneSyncMenuSize = placement.Size
u.phoneSyncMenuCall = menuCall
u.maybeLogHeaderMenuPlacement("sync-phone", compactSurface, placement)
}
if c.ShowMainMenu() {
u.phoneMainMenuVisible = true
u.maybeLogHeaderMenuToggle("main-visible", true)
placement, menuCall := compactSurface.Place(gtx, c.Metrics.MainAnchor(), c.MainMenu)
u.phoneMainMenuOrigin = placement.Origin
u.phoneMainMenuSize = placement.Size
u.phoneMainMenuCall = menuCall
u.maybeLogHeaderMenuPlacement("main-phone", compactSurface, placement)
}
}
func (c headerActionCluster) layoutMenuRow(gtx layout.Context) layout.Dimensions {
menu := c.activeMenu()
if menu == nil {
return layout.Dimensions{}
}
fullWidthGTX := gtx
fullWidthGTX.Constraints.Min = image.Point{}
fullWidthGTX.Constraints.Min.X = fullWidthGTX.Constraints.Max.X
dims := layout.E.Layout(fullWidthGTX, menu)
return layout.Dimensions{Size: image.Pt(fullWidthGTX.Constraints.Max.X, dims.Size.Y)}
}
type headerButtonBounds struct {
SyncPrimary image.Rectangle
SyncToggle image.Rectangle
Lock image.Rectangle
MainMenu image.Rectangle
}
func newHeaderButtonBounds(origin image.Point, bounds headerlayout.ActionBounds) headerButtonBounds {
return headerButtonBounds{
SyncPrimary: bounds.SyncPrimary.Add(origin),
SyncToggle: bounds.SyncToggle.Add(origin),
Lock: bounds.Lock.Add(origin),
MainMenu: bounds.MainMenu.Add(origin),
}
}
func (b headerButtonBounds) logLine(mode string) string {
return fmt.Sprintf(
"keepassgo header-bounds mode=%s sync=%s sync_toggle=%s lock=%s menu=%s",
mode,
formatHeaderRect(b.SyncPrimary),
formatHeaderRect(b.SyncToggle),
formatHeaderRect(b.Lock),
formatHeaderRect(b.MainMenu),
)
}
func formatHeaderRect(rect image.Rectangle) string {
return fmt.Sprintf("%d,%d-%d,%d", rect.Min.X, rect.Min.Y, rect.Max.X, rect.Max.Y)
}
func (u *ui) topRightActionOrder() []string {
if u.isVaultLocked() {
return nil
}
return []string{"Sync", "Lock", "Menu"}
}
func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions {
if !u.usesCompactViewport() || (!u.syncMenuVisibleOnPhone() && !u.mainMenuVisibleOnPhone()) {
return layout.Dimensions{}
}
cluster := u.newHeaderActionCluster(gtx)
if u.syncMenuVisibleOnPhone() {
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
stack := op.Offset(image.Pt(0, max(0, u.phoneSyncMenuOrigin.Y-u.frameInsetPx))).Push(gtx.Ops)
defer stack.Pop()
dims := cluster.layoutMenuRow(gtx)
return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, max(dims.Size.Y, u.phoneSyncMenuOrigin.Y))}
})
}
if u.mainMenuVisibleOnPhone() {
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
stack := op.Offset(image.Pt(0, max(0, u.phoneMainMenuOrigin.Y-u.frameInsetPx))).Push(gtx.Ops)
defer stack.Pop()
dims := cluster.layoutMenuRow(gtx)
return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, max(dims.Size.Y, u.phoneMainMenuOrigin.Y))}
})
}
return layout.Dimensions{}
}
func (u *ui) desktopHeaderMenus(gtx layout.Context) layout.Dimensions {
if u.usesCompactViewport() || (!u.syncMenuOpen && !u.mainMenuOpen) {
return layout.Dimensions{}
}
cluster := u.newHeaderActionCluster(gtx)
dims := cluster.layoutMenuRow(gtx)
if dims.Size.Y == 0 {
return dims
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return dims }),
)
}
func (u *ui) syncMenuVisibleOnPhone() bool {
return u.usesCompactViewport() && u.phoneSyncMenuVisible && u.syncMenuOpen
}
func (u *ui) mainMenuVisibleOnPhone() bool {
return u.usesCompactViewport() && u.phoneMainMenuVisible && u.mainMenuOpen
}
func (u *ui) syncMenuDropsBelowTrigger() bool { return true }
func (u *ui) syncMenuRightAlignsToTrigger() bool { return true }
func (u *ui) headerMenusUseOverlayModel() bool { return true }
func (u *ui) mainMenuDropsBelowTrigger() bool { return true }
func (u *ui) mainMenuRightAlignsToTrigger() bool { return true }
func (u *ui) lifecycleBranding(gtx layout.Context) layout.Dimensions {
if !u.usesCompactViewport() {
return layout.Dimensions{}
}
return layout.Dimensions{}
}
func (u *ui) brandMark(gtx layout.Context, widthDP, heightDP float32) layout.Dimensions {
if u.usesCompactViewport() {
return u.brandImage(gtx, u.splashSquare, widthDP, heightDP)
}
return u.brandImage(gtx, u.logoHorizontal, widthDP, heightDP)
}
func (u *ui) brandImage(gtx layout.Context, src paint.ImageOp, widthDP, heightDP float32) layout.Dimensions {
width := gtx.Dp(unit.Dp(widthDP))
height := gtx.Dp(unit.Dp(heightDP))
if width > gtx.Constraints.Max.X {
width = gtx.Constraints.Max.X
}
if height > gtx.Constraints.Max.Y && gtx.Constraints.Max.Y > 0 {
height = gtx.Constraints.Max.Y
}
img := widget.Image{
Src: src,
Fit: widget.Contain,
Position: layout.W,
Scale: 1.0 / gtx.Metric.PxPerDp,
}
gtx.Constraints.Min = image.Point{}
gtx.Constraints.Max = image.Pt(width, height)
return img.Layout(gtx)
}
func (u *ui) mainMenuWidget() layout.Widget {
if !u.mainMenuOpen {
return nil
}
return u.mainMenu
}
func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions {
rows := []layout.Widget{
func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.showEntries, "Entries")
},
func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin")
},
func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.showAPITokens, "API Tokens")
},
func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.showAPIAudit, "API Audit")
},
func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showAbout, "About") },
func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings")
},
}
return headerview.MainMenu(gtx, u.theme, rows, compactCard, nil)
}
func (u *ui) mainMenuButtonGroup(gtx layout.Context) layout.Dimensions {
icon := u.menuIcon
if icon == nil {
icon = u.settingsIcon
}
return headerview.MainMenuButtonGroup(gtx, u.theme, &u.toggleMainMenu, icon, u.mainMenuOpen, selectedColor, accentColor)
}
func intrinsicCompactCard(gtx layout.Context, w layout.Widget) layout.Dimensions {
return headerlayout.IntrinsicCompactCard(gtx, w, compactCard, nil)
}
func menuActionWidth(gtx layout.Context, rows []layout.Widget) int {
return headerlayout.MenuActionWidth(gtx, rows)
}
func rightAlignedMenuAction(gtx layout.Context, width int, child layout.Widget) layout.Dimensions {
return headerlayout.RightAlignedAction(gtx, width, child)
}
+131
View File
@@ -0,0 +1,131 @@
package layout
import (
"image"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/unit"
)
func AnchoredMenuX(triggerWidth, menuWidth int) int {
return triggerWidth - menuWidth
}
func AnchoredMenuOriginX(containerWidth, rowOriginX, triggerRightX, menuWidth int) int {
x := rowOriginX + triggerRightX - menuWidth
if x < 0 {
return 0
}
if x+menuWidth > containerWidth {
return max(0, containerWidth-menuWidth)
}
return x
}
type DropdownAnchor struct {
TriggerRightX int
TriggerBottomY int
}
func (a DropdownAnchor) Point() image.Point {
return image.Pt(a.TriggerRightX, a.TriggerBottomY)
}
type DropdownSurface struct {
ContainerWidth int
LeftInset int
TopInset int
}
type DropdownPlacement struct {
Anchor DropdownAnchor
Origin image.Point
Size image.Point
}
func (s DropdownSurface) MenuConstraints(gtx layout.Context) layout.Context {
menuGTX := gtx
menuGTX.Constraints.Min = image.Point{}
menuGTX.Constraints.Max.X = max(0, s.ContainerWidth)
return menuGTX
}
func (s DropdownSurface) Origin(anchor DropdownAnchor, menuWidth int) image.Point {
x := s.LeftInset + AnchoredMenuOriginX(s.ContainerWidth, 0, anchor.TriggerRightX, menuWidth)
y := s.TopInset + anchor.TriggerBottomY
return image.Pt(x, y)
}
func (s DropdownSurface) Place(gtx layout.Context, anchor DropdownAnchor, menu layout.Widget) (DropdownPlacement, op.CallOp) {
menuGTX := s.MenuConstraints(gtx)
menuOps := op.Record(gtx.Ops)
menuDims := layout.Inset{Top: unit.Dp(6)}.Layout(menuGTX, menu)
menuCall := menuOps.Stop()
menuOrigin := s.Origin(anchor, menuDims.Size.X)
return DropdownPlacement{
Anchor: anchor,
Origin: menuOrigin,
Size: menuDims.Size,
}, menuCall
}
func (s DropdownSurface) Draw(gtx layout.Context, anchor DropdownAnchor, menu layout.Widget) layout.Dimensions {
placement, menuCall := s.Place(gtx, anchor, menu)
stack := op.Offset(placement.Origin).Push(gtx.Ops)
menuCall.Add(gtx.Ops)
stack.Pop()
return layout.Dimensions{Size: gtx.Constraints.Max}
}
type ActionMetrics struct {
RowOriginX int
Spacing int
SyncInnerSpacing int
RowDims layout.Dimensions
SyncDims layout.Dimensions
SyncPrimaryDims layout.Dimensions
SyncToggleDims layout.Dimensions
LockDims layout.Dimensions
MainDims layout.Dimensions
}
func (m ActionMetrics) SyncAnchor() DropdownAnchor {
return DropdownAnchor{
TriggerRightX: m.RowOriginX + m.SyncDims.Size.X,
TriggerBottomY: m.RowDims.Size.Y,
}
}
func (m ActionMetrics) MainAnchor() DropdownAnchor {
triggerRightX := m.SyncDims.Size.X + m.Spacing + m.LockDims.Size.X + m.Spacing + m.MainDims.Size.X
return DropdownAnchor{
TriggerRightX: m.RowOriginX + triggerRightX,
TriggerBottomY: m.RowDims.Size.Y,
}
}
type ActionBounds struct {
SyncPrimary image.Rectangle
SyncToggle image.Rectangle
Lock image.Rectangle
MainMenu image.Rectangle
}
func (m ActionMetrics) Bounds() ActionBounds {
top := 0
syncLeft := m.RowOriginX
syncPrimary := image.Rect(syncLeft, top, syncLeft+m.SyncPrimaryDims.Size.X, top+m.SyncPrimaryDims.Size.Y)
syncToggleLeft := syncPrimary.Max.X + m.SyncInnerSpacing
syncToggle := image.Rect(syncToggleLeft, top, syncToggleLeft+m.SyncToggleDims.Size.X, top+m.SyncToggleDims.Size.Y)
lockLeft := syncLeft + m.SyncDims.Size.X + m.Spacing
lock := image.Rect(lockLeft, top, lockLeft+m.LockDims.Size.X, top+m.LockDims.Size.Y)
mainLeft := lock.Max.X + m.Spacing
mainMenu := image.Rect(mainLeft, top, mainLeft+m.MainDims.Size.X, top+m.MainDims.Size.Y)
return ActionBounds{
SyncPrimary: syncPrimary,
SyncToggle: syncToggle,
Lock: lock,
MainMenu: mainMenu,
}
}
+59
View File
@@ -0,0 +1,59 @@
package layout
import (
"image"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/unit"
)
func IntrinsicCompactCard(gtx layout.Context, w layout.Widget, card func(layout.Context, layout.Widget) layout.Dimensions, logger func(name string, constraints layout.Constraints, dims layout.Dimensions)) layout.Dimensions {
measureGTX := gtx
measureGTX.Constraints.Min = image.Point{}
measureGTX.Constraints.Max.X = gtx.Constraints.Max.X
macro := op.Record(gtx.Ops)
contentDims := w(measureGTX)
_ = macro.Stop()
if logger != nil {
logger("intrinsic-measure", measureGTX.Constraints, contentDims)
}
width := contentDims.Size.X + gtx.Dp(unit.Dp(20))
maxWidth := gtx.Constraints.Max.X
if maxWidth > 0 && width > maxWidth {
width = maxWidth
}
if width > 0 {
gtx.Constraints.Min.X = width
gtx.Constraints.Max.X = width
}
dims := card(gtx, w)
if logger != nil {
logger("intrinsic-card", gtx.Constraints, dims)
}
return dims
}
func MenuActionWidth(gtx layout.Context, rows []layout.Widget) int {
width := 0
for _, row := range rows {
measureGTX := gtx
measureGTX.Constraints.Min = image.Point{}
macro := op.Record(gtx.Ops)
dims := row(measureGTX)
_ = macro.Stop()
if dims.Size.X > width {
width = dims.Size.X
}
}
return width
}
func RightAlignedAction(gtx layout.Context, width int, child layout.Widget) layout.Dimensions {
if width <= 0 {
return child(gtx)
}
gtx.Constraints.Min.X = width
gtx.Constraints.Max.X = width
return layout.E.Layout(gtx, child)
}
+54
View File
@@ -0,0 +1,54 @@
package header
import (
"image/color"
"image"
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout"
)
func MainMenu(gtx layout.Context, theme *material.Theme, rows []layout.Widget, card func(layout.Context, layout.Widget) layout.Dimensions, logger func(name string, constraints layout.Constraints, dims layout.Dimensions)) layout.Dimensions {
rowWidth := headerlayout.MenuActionWidth(gtx, rows)
if logger != nil {
logger("row-width", gtx.Constraints, layout.Dimensions{Size: image.Pt(rowWidth, 0)})
}
dims := headerlayout.IntrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions {
children := make([]layout.FlexChild, 0, (len(rows)*2)-1)
for i, row := range rows {
if i > 0 {
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
}
current := row
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return headerlayout.RightAlignedAction(gtx, rowWidth, current)
}))
}
dims := layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
if logger != nil {
logger("rows", gtx.Constraints, dims)
}
return dims
}, card, logger)
if logger != nil {
logger("card", gtx.Constraints, dims)
}
return dims
}
func MainMenuButtonGroup(gtx layout.Context, theme *material.Theme, click *widget.Clickable, icon *widget.Icon, open bool, selectedColor, accentColor color.NRGBA) layout.Dimensions {
btn := material.IconButton(theme, click, icon, "Menu")
if open {
btn.Background = accentColor
btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
} else {
btn.Background = selectedColor
btn.Color = accentColor
}
btn.Size = unit.Dp(18)
btn.Inset = layout.UniformInset(unit.Dp(8))
return btn.Layout(gtx)
}
+362
View File
@@ -0,0 +1,362 @@
package appui
import (
"image"
"image/color"
"runtime"
"strings"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"git.julianfamily.org/keepassgo/internal/appstate"
syncmodel "git.julianfamily.org/keepassgo/internal/appui/sync"
"git.julianfamily.org/keepassgo/internal/vault"
)
func (u *ui) syncButtonGroupWithMetrics(gtx layout.Context) (layout.Dimensions, layout.Dimensions, layout.Dimensions) {
spacing := unit.Dp(4)
if u.usesCompactViewport() {
spacing = unit.Dp(3)
}
var primaryDims layout.Dimensions
var toggleDims layout.Dimensions
groupDims := layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
primaryDims = syncPrimaryButton(gtx, u.theme, &u.synchronizeVault, "Sync", u.usesCompactViewport())
return primaryDims
}),
layout.Rigid(layout.Spacer{Width: spacing}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
toggleDims = u.syncMenuToggle(gtx)
return toggleDims
}),
)
return groupDims, primaryDims, toggleDims
}
func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions {
btn := material.IconButton(u.theme, &u.toggleSyncMenu, u.chevronDownIcon, "More synchronize actions")
if u.syncMenuOpen {
btn.Background = accentColor
btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
} else {
btn.Background = color.NRGBA{R: 231, G: 236, B: 232, A: 255}
btn.Color = accentColor
}
btn.Size = unit.Dp(18)
btn.Inset = layout.UniformInset(unit.Dp(8))
if u.usesCompactViewport() {
btn.Size = unit.Dp(16)
btn.Inset = layout.UniformInset(unit.Dp(7))
}
return btn.Layout(gtx)
}
func (u *ui) syncMenuWidget() layout.Widget {
if !u.syncMenuOpen {
return nil
}
return u.syncMenu
}
func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions {
model := u.buildSyncMenuModel()
profiles := u.availableRemoteProfiles()
credentials := u.availableRemoteCredentialEntries()
if len(u.vaultRemoteProfileClicks) < len(profiles) {
u.vaultRemoteProfileClicks = make([]widget.Clickable, len(profiles))
}
if len(u.vaultRemoteCredentialClicks) < len(credentials) {
u.vaultRemoteCredentialClicks = make([]widget.Clickable, len(credentials))
}
actionRows := u.syncMenuActionRows(model)
actionWidth := menuActionWidth(gtx, actionRows)
menu := func(gtx layout.Context) layout.Dimensions {
return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, u.syncMenuRows(model, profiles, credentials, actionWidth)...)
})
}
reserveWidth := u.syncMenuTrailingReserveWidth(gtx)
if reserveWidth <= 0 {
return menu(gtx)
}
return layout.Flex{}.Layout(gtx,
layout.Rigid(menu),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Dimensions{Size: image.Pt(reserveWidth, 0)}
}),
)
}
func (u *ui) syncMenuTrailingReserveWidth(gtx layout.Context) int {
spacing := gtx.Dp(unit.Dp(8))
if u.usesCompactViewport() {
spacing = gtx.Dp(unit.Dp(8))
}
measureGTX := gtx
measureGTX.Constraints.Min = image.Point{}
lockOps := op.Record(gtx.Ops)
lockDims := func(gtx layout.Context) layout.Dimensions {
btn := material.Button(u.theme, &u.lockVault, "Lock")
return btn.Layout(gtx)
}(measureGTX)
_ = lockOps.Stop()
menuOps := op.Record(gtx.Ops)
menuDims := u.mainMenuButtonGroup(measureGTX)
_ = menuOps.Stop()
return spacing + lockDims.Size.X + spacing + menuDims.Size.X
}
func (u *ui) syncMenuActionRows(model syncmodel.MenuModel) []layout.Widget {
rows := []layout.Widget{
func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync")
},
}
if model.ShowShare {
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.shareCurrentVault, "Share Vault")
})
}
if model.ShowRemoteSyncSetupShortcut() {
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSetupShortcutLabel())
})
}
if model.ShowDirectRemoteSyncShortcut() {
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, model.DirectRemoteSyncShortcutLabel())
})
}
if model.ShowRemoteSyncSettingsShortcut() {
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSettingsShortcutLabel())
})
}
if model.ShowRemoveRemoteSyncShortcut() {
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, model.RemoveRemoteSyncShortcutLabel())
})
}
return rows
}
func (u *ui) syncMenuRows(model syncmodel.MenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry, actionWidth int) []layout.FlexChild {
rows := u.syncMenuPrimaryRows(model, actionWidth)
rows = append(rows, u.syncMenuSavedBindingRows(model, profiles, credentials)...)
if model.ShowSaveCurrentBinding {
rows = append(rows, u.syncMenuSaveBindingRows(model)...)
}
return rows
}
func (u *ui) syncMenuPrimaryRows(model syncmodel.MenuModel, actionWidth int) []layout.FlexChild {
rows := []layout.FlexChild{}
if model.ShowShare {
rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.shareCurrentVault, "Share Vault")
})
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
)
}))
}
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.openAdvancedSync, "Open Advanced Sync"))
if model.ShowRemoteSyncSetupShortcut() {
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSetupShortcutLabel()))
}
if model.ShowDirectRemoteSyncShortcut() {
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.openSelectedVaultRemote, model.DirectRemoteSyncShortcutLabel()))
}
if model.ShowRemoteSyncSettingsShortcut() {
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.useSavedAdvancedSyncRemote, model.RemoteSyncSettingsShortcutLabel()))
}
if model.ShowRemoveRemoteSyncShortcut() {
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
rows = append(rows, u.syncMenuActionRow(actionWidth, &u.removeSelectedRemoteBinding, model.RemoveRemoteSyncShortcutLabel()))
}
return rows
}
func (u *ui) syncMenuActionRow(actionWidth int, click *widget.Clickable, label string) layout.FlexChild {
return layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return rightAlignedMenuAction(gtx, actionWidth, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, click, label)
})
})
}
func (u *ui) syncMenuSavedBindingRows(model syncmodel.MenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry) []layout.FlexChild {
if !u.hasOpenVault() || len(profiles) == 0 || len(credentials) == 0 {
return nil
}
rows := []layout.FlexChild{
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), model.SavedBindingHeading())
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
}
if !model.ShowSelectors {
rows = append(rows, layout.Rigid(u.syncMenuSavedBindingSummary(model)))
} else {
rows = append(rows, u.syncMenuSelectorRows(model, profiles, credentials)...)
}
if _, ok := u.selectedVaultRemoteProfile(); ok {
if _, ok := u.selectedVaultRemoteCredentialEntry(); ok {
rows = append(rows,
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, u.openSelectedVaultRemoteButtonLabel())
}),
)
}
}
return rows
}
func (u *ui) syncMenuSavedBindingSummary(model syncmodel.MenuModel) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
summary := model.SavedBindingSummary
if !summary.OK {
return layout.Dimensions{}
}
return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 242, G: 245, B: 240, A: 255}), func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), summary.ProfileLabel)
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "Credential: "+summary.CredentialLabel)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), summary.SyncLabel)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
})
})
}
}
func (u *ui) syncMenuSaveBindingRows(model syncmodel.MenuModel) []layout.FlexChild {
return []layout.FlexChild{
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), model.SaveCurrentRemoteBindingHeading())
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.saveCurrentRemoteBinding, model.SaveCurrentRemoteBindingButtonLabel())
}),
}
}
func (u *ui) syncMenuSelectorRows(_ syncmodel.MenuModel, profiles []vault.RemoteProfile, credentials []vault.Entry) []layout.FlexChild {
rows := make([]layout.FlexChild, 0, len(profiles)+len(credentials)+4)
for i, profile := range profiles {
i := i
profile := profile
rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
selected := u.selectedVaultRemoteProfileID == profile.ID
return layout.Inset{Bottom: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions {
return u.vaultRemoteProfileClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), profile.Name)
lbl.Color = accentColor
return lbl.Layout(gtx)
})
})
})
}))
}
rows = append(rows, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
for i, entry := range credentials {
i := i
entry := entry
rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
selected := u.selectedVaultRemoteCredentialEntryID == entry.ID
label := entry.Title
if entry.Username != "" {
label += " · " + entry.Username
}
return layout.Inset{Bottom: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions {
return u.vaultRemoteCredentialClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), label)
lbl.Color = accentColor
return lbl.Layout(gtx)
})
})
})
}))
}
return rows
}
func (u *ui) buildSyncMenuModel() syncmodel.MenuModel {
model := syncmodel.MenuModel{
HasOpenVault: u.hasOpenVault(),
ShowSelectors: u.shouldShowSavedRemoteBindingSelectors(),
ShowShare: supportsVaultShare(runtime.GOOS) && u.vaultSharer != nil && strings.TrimSpace(u.currentShareableVaultPath()) != "",
RemoteBaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
RemotePath: strings.TrimSpace(u.remotePath.Text()),
RemoteUsername: strings.TrimSpace(u.remoteUsername.Text()),
RemotePassword: u.remotePassword.Text(),
SelectedVaultSyncMode: normalizeUISyncMode(u.selectedVaultRemoteSyncMode),
}
_, model.HasSelectedBinding = u.selectedVaultRemoteBinding()
model.SavedBindingSummary = u.computeSavedRemoteBindingSummary()
model.ShowSaveCurrentBinding = model.HasOpenVault && model.RemoteBaseURL != "" && model.RemotePath != "" && model.RemoteUsername != "" && model.RemotePassword != ""
return model
}
func (u *ui) computeSavedRemoteBindingSummary() syncmodel.MenuBindingSummary {
profile, ok := u.selectedVaultRemoteProfile()
if !ok {
return syncmodel.MenuBindingSummary{}
}
entry, ok := u.selectedVaultRemoteCredentialEntry()
if !ok {
return syncmodel.MenuBindingSummary{}
}
credentialLabel := entry.Title
if strings.TrimSpace(entry.Username) != "" {
credentialLabel += " · " + strings.TrimSpace(entry.Username)
}
syncLabel := "Sync manually when you choose Use Remote Sync."
if normalizeUISyncMode(u.selectedVaultRemoteSyncMode) == appstate.SyncModeAutomaticOnOpenSave {
syncLabel = "Syncs automatically on open and save."
}
return syncmodel.MenuBindingSummary{
ProfileLabel: profile.Name,
CredentialLabel: credentialLabel,
SyncLabel: syncLabel,
OK: true,
}
}
+91 -27
View File
@@ -1,32 +1,46 @@
package main package appui
import ( import (
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
"gioui.org/io/event"
"gioui.org/io/key" "gioui.org/io/key"
"git.julianfamily.org/keepassgo/appstate" "gioui.org/layout"
"git.julianfamily.org/keepassgo/internal/appstate"
editormodel "git.julianfamily.org/keepassgo/internal/appui/editor"
"git.julianfamily.org/keepassgo/internal/clipboard"
) )
type focusID string type focusID string
type detailField string type detailField = editormodel.Field
const ( const (
focusSearch focusID = "search" focusSearch focusID = "search"
detailFieldID detailField = "id" detailFieldID = editormodel.FieldID
detailFieldTitle detailField = "title" detailFieldTitle = editormodel.FieldTitle
detailFieldUsername detailField = "username" detailFieldUsername = editormodel.FieldUsername
detailFieldPassword detailField = "password" detailFieldPassword = editormodel.FieldPassword
detailFieldURL detailField = "url" detailFieldURL = editormodel.FieldURL
detailFieldPath detailField = "path" detailFieldPath = editormodel.FieldPath
detailFieldTags detailField = "tags" detailFieldTags = editormodel.FieldTags
detailFieldPasswordProfile detailField = "password-profile" detailFieldPasswordProfile = editormodel.FieldPasswordProfile
detailFieldNotes detailField = "notes" detailFieldNotes = editormodel.FieldNotes
detailFieldFields detailField = "fields" detailFieldFields = editormodel.FieldFields
detailFieldHistoryIndex detailField = "history-index" detailFieldHistoryIndex = editormodel.FieldHistoryIndex
)
const (
shortcutSearch = "search"
shortcutSave = "save"
shortcutLock = "lock"
shortcutNewEntry = "new-entry"
shortcutCopyUser = "copy-user"
shortcutCopyPassword = "copy-password"
shortcutCopyURL = "copy-url"
) )
func breadcrumbFocusID(index int) focusID { func breadcrumbFocusID(index int) focusID {
@@ -41,6 +55,68 @@ func detailFocusID(field detailField) focusID {
return focusID("detail:" + string(field)) return focusID("detail:" + string(field))
} }
func (u *ui) processShortcuts(gtx layout.Context) {
event.Op(gtx.Ops, u)
for {
ev, ok := gtx.Event(
key.Filter{Name: "F", Required: key.ModShortcut},
key.Filter{Name: "S", Required: key.ModShortcut},
key.Filter{Name: "L", Required: key.ModShortcut},
key.Filter{Name: "N", Required: key.ModShortcut},
key.Filter{Name: "U", Required: key.ModShortcut},
key.Filter{Name: "P", Required: key.ModShortcut},
key.Filter{Name: "O", Required: key.ModShortcut},
key.Filter{Name: key.NameTab, Optional: key.ModShift},
key.Filter{Name: key.NameLeftArrow},
key.Filter{Name: key.NameRightArrow},
key.Filter{Name: key.NameUpArrow},
key.Filter{Name: key.NameDownArrow},
key.Filter{Name: key.NameReturn},
key.Filter{Name: key.NameBack},
key.Filter{Name: key.NameEscape},
)
if !ok {
break
}
ke, ok := ev.(key.Event)
if !ok || ke.State != key.Press {
continue
}
u.handleKeyPress(ke.Name, ke.Modifiers)
if ke.Name == key.NameBack || ke.Name == key.NameEscape {
_ = u.handlePhoneBack()
}
}
}
func (u *ui) performShortcut(name string) error {
switch name {
case shortcutSearch:
u.keyboardFocus = focusSearch
return nil
case shortcutSave:
return u.saveAction()
case shortcutLock:
return u.lockAction()
case shortcutNewEntry:
u.state.BeginNewEntry()
u.loadSelectedEntryIntoEditor()
u.entryPath.SetText(strings.Join(u.state.CurrentPath, " / "))
u.keyboardFocus = detailFocusID(detailFieldTitle)
return nil
case shortcutCopyUser:
return u.copySelectedFieldAction(clipboard.TargetUsername)
case shortcutCopyPassword:
return u.copySelectedFieldAction(clipboard.TargetPassword)
case shortcutCopyURL:
return u.copySelectedFieldAction(clipboard.TargetURL)
default:
return nil
}
}
func (u *ui) handleKeyPress(name key.Name, modifiers key.Modifiers) bool { func (u *ui) handleKeyPress(name key.Name, modifiers key.Modifiers) bool {
if u.handleShortcutKey(name, modifiers) { if u.handleShortcutKey(name, modifiers) {
return true return true
@@ -336,19 +412,7 @@ func (u *ui) focusedDetailIndex() int {
} }
func detailFocusOrder() []detailField { func detailFocusOrder() []detailField {
return []detailField{ return editormodel.FocusOrder()
detailFieldID,
detailFieldTitle,
detailFieldUsername,
detailFieldPassword,
detailFieldURL,
detailFieldPath,
detailFieldTags,
detailFieldPasswordProfile,
detailFieldNotes,
detailFieldFields,
detailFieldHistoryIndex,
}
} }
func canonicalFocusID(id focusID) focusID { func canonicalFocusID(id focusID) focusID {
+9
View File
@@ -0,0 +1,9 @@
package lifecycle
type OpenIntent string
const (
OpenIntentNone OpenIntent = ""
OpenIntentRemoteSyncSetup OpenIntent = "remote_sync_setup"
OpenIntentRemoteSyncSettings OpenIntent = "remote_sync_settings"
)
+778
View File
@@ -0,0 +1,778 @@
package appui
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"gioui.org/layout"
"gioui.org/x/explorer"
"git.julianfamily.org/keepassgo/internal/appstate"
"git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/internal/webdav"
)
func (u *ui) createVaultAction() error {
key, err := u.currentMasterKey()
defer u.clearMasterPassword()
if err != nil {
return err
}
if err := u.state.ConfigureSecurity(vault.SecuritySettings{
Cipher: strings.TrimSpace(u.securityCipher.Text()),
KDF: strings.TrimSpace(u.securityKDF.Text()),
}); err != nil {
return err
}
if err := u.state.CreateVault(key); err != nil {
return err
}
if u.lifecycleMode == "local" {
u.selectedVaultRemoteProfileID = ""
u.selectedVaultRemoteCredentialEntryID = ""
u.selectedVaultRemoteSyncMode = appstate.SyncModeManual
u.remoteBaseURL.SetText("")
u.remotePath.SetText("")
u.remoteUsername.SetText("")
u.remotePassword.SetText("")
if err := u.state.SaveAs(u.saveAsTargetPath()); err != nil {
return err
}
u.vaultPath.SetText(u.saveAsTargetPath())
u.noteRecentVault(u.saveAsTargetPath())
}
u.resetPasswordPeek()
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
return nil
}
func (u *ui) openVaultAction() error {
key, err := u.currentMasterKey()
defer u.clearMasterPassword()
if err != nil {
return err
}
path := strings.TrimSpace(u.vaultPath.Text())
if path == "" {
return errors.New(errVaultPathRequired)
}
if err := u.state.OpenVault(path, key); err != nil {
return err
}
u.noteRecentVault(path)
u.resetPasswordPeek()
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.restoreRecentVaultGroup(path)
u.syncSavedRemoteBindingSelection()
if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil {
u.showStatusMessage("Remote sync on open failed: " + err.Error())
}
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
u.applyPendingLifecycleOpenIntent()
return nil
}
func (u *ui) startOpenVaultAction() {
manager, ok := u.state.Session.(*session.Manager)
if !ok {
u.runAction("open vault", u.openVaultAction)
return
}
key, err := u.currentMasterKey()
u.clearMasterPassword()
if err != nil {
u.state.ErrorMessage = u.describeActionError("open vault", err)
u.requestMasterPassFocus = true
return
}
path := strings.TrimSpace(u.vaultPath.Text())
if path == "" {
u.state.ErrorMessage = u.describeActionError("open vault", errors.New(errVaultPathRequired))
u.requestMasterPassFocus = true
return
}
u.lastLifecycleAction = "open vault"
u.runBackgroundAction("open vault", func() (func() error, error) {
prepared, err := session.PrepareLocalOpen(path, key)
if err != nil {
return nil, err
}
return func() error {
manager.ApplyPreparedLocalOpen(prepared)
u.noteRecentVault(path)
u.resetPasswordPeek()
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.restoreRecentVaultGroup(path)
u.syncSavedRemoteBindingSelection()
if err := u.synchronizeSelectedRemoteBindingOnOpen(); err != nil {
u.showStatusMessage("Remote sync on open failed: " + err.Error())
}
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
u.applyPendingLifecycleOpenIntent()
return nil
}, nil
})
}
func (u *ui) shouldShowLifecycleRemoteSyncAction() bool {
return strings.TrimSpace(u.vaultPath.Text()) != ""
}
func (u *ui) lifecycleRemoteSyncActionLabel() string {
path := strings.TrimSpace(u.vaultPath.Text())
if path == "" {
return "Open Vault And Set Up Remote Sync"
}
if hasBoundRecentRemote(u.recentRemotes, path) {
return "Open Vault And Open Remote Sync Settings"
}
return "Open Vault And Set Up Remote Sync"
}
func (u *ui) beginLifecycleRemoteSyncOpen() {
path := strings.TrimSpace(u.vaultPath.Text())
switch {
case path == "":
u.pendingLifecycleOpenIntent = lifecycleOpenIntentNone
case hasBoundRecentRemote(u.recentRemotes, path):
u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSettings
default:
u.pendingLifecycleOpenIntent = lifecycleOpenIntentRemoteSyncSetup
}
u.startOpenVaultAction()
}
func (u *ui) applyPendingLifecycleOpenIntent() {
intent := u.pendingLifecycleOpenIntent
u.pendingLifecycleOpenIntent = lifecycleOpenIntentNone
switch intent {
case lifecycleOpenIntentRemoteSyncSetup, lifecycleOpenIntentRemoteSyncSettings:
u.openRemoteSyncSetupDialog()
}
}
func (u *ui) saveAction() error {
if err := u.state.Save(); err != nil {
return err
}
if err := u.synchronizeSelectedRemoteBindingOnSave(); err != nil {
return err
}
u.filter()
return nil
}
func (u *ui) saveAsAction() error {
path := u.saveAsTargetPath()
if err := u.state.SaveAs(path); err != nil {
return err
}
u.vaultPath.SetText(path)
u.noteRecentVault(path)
u.filter()
return nil
}
func (u *ui) openRemoteAction() error {
key, err := u.currentMasterKey()
defer u.clearMasterPassword()
if err != nil {
return err
}
if binding, resolved, ok, err := u.bootstrapSelectedVaultRemoteBinding(key); err != nil {
return err
} else if ok {
if err := u.state.OpenBoundRemoteVault(binding, key); err != nil {
return err
}
u.remoteBaseURL.SetText(resolved.Profile.BaseURL)
u.remotePath.SetText(resolved.Profile.Path)
u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path)
u.resetPasswordPeek()
u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path)
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
return nil
}
client := webdav.Client{
BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
Username: strings.TrimSpace(u.remoteUsername.Text()),
Password: u.remotePassword.Text(),
}
if err := u.state.OpenRemoteVault(client, strings.TrimSpace(u.remotePath.Text()), key); err != nil {
return err
}
if err := u.materializeCurrentRemoteCache(); err != nil {
return err
}
u.noteRecentRemote(
strings.TrimSpace(u.remoteBaseURL.Text()),
strings.TrimSpace(u.remotePath.Text()),
)
u.resetPasswordPeek()
u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), strings.TrimSpace(u.remotePath.Text()))
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
return nil
}
func (u *ui) startOpenRemoteAction() {
manager, ok := u.state.Session.(*session.Manager)
if !ok {
u.runAction("open remote vault", u.openRemoteAction)
return
}
key, err := u.currentMasterKey()
u.clearMasterPassword()
if err != nil {
u.state.ErrorMessage = u.describeActionError("open remote vault", err)
u.requestMasterPassFocus = true
return
}
client := webdav.Client{
BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
Username: strings.TrimSpace(u.remoteUsername.Text()),
Password: u.remotePassword.Text(),
}
remotePath := strings.TrimSpace(u.remotePath.Text())
u.lastLifecycleAction = "open remote vault"
u.runBackgroundAction("open remote vault", func() (func() error, error) {
binding, bindingOK := u.selectedVaultRemoteBinding()
if bindingOK && !u.hasOpenVault() && strings.TrimSpace(binding.LocalVaultPath) != "" {
preparedLocal, err := session.PrepareLocalOpen(binding.LocalVaultPath, key)
if err != nil {
return nil, err
}
resolved, err := binding.Resolve(preparedLocal.Model)
if err != nil {
return nil, err
}
preparedRemote, err := session.PrepareRemoteOpen(webdav.Client{
BaseURL: resolved.Profile.BaseURL,
Username: resolved.Credentials.Username,
Password: resolved.Credentials.Password,
}, resolved.Profile.Path, key)
if err != nil {
return nil, err
}
return func() error {
manager.ApplyPreparedLocalOpen(preparedLocal)
u.vaultPath.SetText(binding.LocalVaultPath)
u.noteRecentVault(binding.LocalVaultPath)
u.restoreRecentVaultGroup(binding.LocalVaultPath)
manager.ApplyPreparedRemoteOpen(preparedRemote)
u.remoteBaseURL.SetText(resolved.Profile.BaseURL)
u.remotePath.SetText(resolved.Profile.Path)
u.noteRecentRemote(resolved.Profile.BaseURL, resolved.Profile.Path)
u.resetPasswordPeek()
u.restoreRecentRemoteGroup(resolved.Profile.BaseURL, resolved.Profile.Path)
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
return nil
}, nil
}
if u.hasOpenVault() {
if _, resolved, ok, err := u.resolvedSelectedVaultRemoteBinding(); err != nil {
return nil, err
} else if ok {
client = webdav.Client{
BaseURL: resolved.Profile.BaseURL,
Username: resolved.Credentials.Username,
Password: resolved.Credentials.Password,
}
remotePath = resolved.Profile.Path
u.remoteBaseURL.SetText(resolved.Profile.BaseURL)
u.remotePath.SetText(resolved.Profile.Path)
}
}
prepared, err := session.PrepareRemoteOpen(client, remotePath, key)
if err != nil {
return nil, err
}
return func() error {
manager.ApplyPreparedRemoteOpen(prepared)
if err := u.materializeCurrentRemoteCache(); err != nil {
return err
}
u.noteRecentRemote(
strings.TrimSpace(u.remoteBaseURL.Text()),
remotePath,
)
u.resetPasswordPeek()
u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), remotePath)
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
return nil
}, nil
})
}
func (u *ui) lockAction() error {
u.clearMasterPassword()
if err := u.state.Lock(); err != nil {
return err
}
u.requestMasterPassFocus = true
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.resetPasswordPeek()
u.editingEntry = false
u.filter()
return nil
}
func (u *ui) unlockAction() error {
key, err := u.currentMasterKey()
defer u.clearMasterPassword()
if err != nil {
return err
}
if err := u.state.Unlock(key); err != nil {
return err
}
u.resetPasswordPeek()
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
return nil
}
func (u *ui) startUnlockAction() {
manager, ok := u.state.Session.(*session.Manager)
if !ok {
u.runAction("unlock vault", u.unlockAction)
return
}
key, err := u.currentMasterKey()
u.clearMasterPassword()
if err != nil {
u.state.ErrorMessage = u.describeActionError("unlock vault", err)
u.requestMasterPassFocus = true
return
}
encoded := append([]byte(nil), manager.EncodedBytes()...)
u.runBackgroundAction("unlock vault", func() (func() error, error) {
prepared, err := session.PrepareUnlock(encoded, key)
if err != nil {
return nil, err
}
return func() error {
manager.ApplyPreparedUnlock(prepared)
u.resetPasswordPeek()
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
return nil
}, nil
})
}
func (u *ui) changeMasterKeyAction() error {
key, err := u.currentMasterKey()
defer u.clearMasterPassword()
if err != nil {
return err
}
return u.state.ChangeMasterKey(key)
}
func (u *ui) loadSecuritySettingsFromSession() {
settings, err := u.state.SecuritySettings()
if err != nil {
return
}
u.securityCipher.SetText(settings.Cipher)
u.securityKDF.SetText(settings.KDF)
}
func (u *ui) clearMasterPassword() {
u.masterPassword.SetText("")
}
func (u *ui) synchronizeAction() error {
if err := u.state.Synchronize(); err != nil {
return err
}
u.syncMenuOpen = false
u.filter()
return nil
}
func (u *ui) openAdvancedSyncDialog() {
u.syncDialogOpen = true
u.syncMenuOpen = false
u.showSyncPassword = false
u.syncDialogList.Position = layout.Position{}
u.syncDialogPurpose = syncDialogPurposeAdvanced
u.syncSourceMode = u.syncDefaultSourceMode
u.syncDirection = u.syncDefaultDirection
if strings.TrimSpace(u.syncLocalPath.Text()) == "" {
u.syncLocalPath.SetText(strings.TrimSpace(u.vaultPath.Text()))
}
u.syncSavedRemoteBindingSelection()
u.prefillAdvancedSyncRemoteFromSavedBinding()
}
func (u *ui) openRemoteSyncSetupDialog() {
u.syncDialogOpen = true
u.syncMenuOpen = false
u.showSyncPassword = false
u.syncDialogList.Position = layout.Position{}
u.syncDialogPurpose = syncDialogPurposeRemoteSetup
u.syncSourceMode = syncSourceRemote
u.syncDirection = syncDirectionPush
u.syncSetupAutomatic.Value = true
if strings.TrimSpace(u.syncLocalPath.Text()) == "" {
u.syncLocalPath.SetText(strings.TrimSpace(u.vaultPath.Text()))
}
u.syncSavedRemoteBindingSelection()
u.prefillAdvancedSyncRemoteFromSavedBinding()
if _, ok := u.selectedVaultRemoteBinding(); ok && u.selectedVaultRemoteSyncMode == appstate.SyncModeManual {
u.syncSetupAutomatic.Value = false
}
}
func (u *ui) clearSyncLocalImport() {
u.syncLocalImportName = ""
u.syncLocalImportContent = nil
}
func (u *ui) selectedSyncLocalImport() (string, []byte, bool) {
name := strings.TrimSpace(u.syncLocalImportName)
if name == "" || name != strings.TrimSpace(u.syncLocalPath.Text()) || len(u.syncLocalImportContent) == 0 {
return "", nil, false
}
return name, append([]byte(nil), u.syncLocalImportContent...), true
}
func sanitizeSyncSourceMode(mode syncSourceMode) syncSourceMode {
switch mode {
case syncSourceRemote:
return syncSourceRemote
default:
return syncSourceLocal
}
}
func sanitizeSyncDirection(direction syncDirection) syncDirection {
switch direction {
case syncDirectionPush:
return syncDirectionPush
default:
return syncDirectionPull
}
}
func (u *ui) advancedSyncAction() error {
switch u.syncDirection {
case syncDirectionPush:
return u.advancedSyncToAction()
default:
return u.advancedSyncFromAction()
}
}
func (u *ui) advancedSyncFromAction() error {
switch u.syncSourceMode {
case syncSourceRemote:
client := webdav.Client{
BaseURL: strings.TrimSpace(u.syncRemoteBaseURL.Text()),
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
Password: u.syncRemotePassword.Text(),
}
if err := u.state.SynchronizeFromRemote(client, strings.TrimSpace(u.syncRemotePath.Text())); err != nil {
return err
}
default:
if name, content, ok := u.selectedSyncLocalImport(); ok {
if err := u.state.SynchronizeFromLocalBytes(name, content); err != nil {
return err
}
break
}
path := strings.TrimSpace(u.syncLocalPath.Text())
if path == "" {
return errors.New(errVaultPathRequired)
}
if err := u.state.SynchronizeFromLocal(path); err != nil {
return err
}
}
u.syncDialogOpen = false
u.showSyncPassword = false
u.filter()
return nil
}
func (u *ui) startChooseSyncLocalSourceAction() {
if runtime.GOOS != "android" || u.fileExplorer == nil {
u.runAction("choose sync path", func() error {
u.clearSyncLocalImport()
return u.chooseExistingFileAction(&u.syncLocalPath)
})
return
}
u.runBackgroundAction("choose sync file", func() (func() error, error) {
file, err := u.fileExplorer.ChooseFile(".kdbx")
if err != nil {
if errors.Is(err, explorer.ErrUserDecline) {
return func() error { return nil }, nil
}
return nil, err
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
return nil, err
}
label := "Selected Android vault"
return func() error {
u.syncLocalImportName = label
u.syncLocalImportContent = append([]byte(nil), content...)
u.syncLocalPath.SetText(label)
return nil
}, nil
})
}
func pickedDocumentName(file io.ReadCloser, fallback string) string {
if named, ok := file.(interface{ Name() string }); ok {
if base := filepath.Base(strings.TrimSpace(named.Name())); base != "" && base != "." && base != string(filepath.Separator) {
return base
}
}
fallback = filepath.Base(strings.TrimSpace(fallback))
if fallback == "" || fallback == "." || fallback == string(filepath.Separator) {
return "selected-vault.kdbx"
}
return fallback
}
func (u *ui) startChooseVaultPathAction() {
if runtime.GOOS != "android" || u.fileExplorer == nil {
u.runAction("choose vault path", func() error { return u.chooseExistingFileAction(&u.vaultPath) })
return
}
u.runBackgroundAction("choose vault file", func() (func() error, error) {
file, err := u.fileExplorer.ChooseFile(".kdbx")
if err != nil {
if errors.Is(err, explorer.ErrUserDecline) {
return func() error { return nil }, nil
}
return nil, err
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
return nil, err
}
name := pickedDocumentName(file, "selected-vault.kdbx")
return func() error {
return u.importSharedVaultBytesAction(name, content)
}, nil
})
}
func (u *ui) startImportSharedVaultAction() {
if !supportsSharedVaultImport(runtime.GOOS) || u.fileExplorer == nil {
return
}
u.runBackgroundAction("import shared vault", func() (func() error, error) {
file, err := u.fileExplorer.ChooseFile(".kdbx")
if err != nil {
if errors.Is(err, explorer.ErrUserDecline) {
return func() error { return nil }, nil
}
return nil, err
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
return nil, err
}
return func() error {
return u.importSharedVaultBytesAction("shared-vault.kdbx", content)
}, nil
})
}
func (u *ui) advancedSyncToAction() error {
switch u.syncSourceMode {
case syncSourceRemote:
baseURL := strings.TrimSpace(u.syncRemoteBaseURL.Text())
remotePath := strings.TrimSpace(u.syncRemotePath.Text())
client := webdav.Client{
BaseURL: baseURL,
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
Password: u.syncRemotePassword.Text(),
}
if err := u.state.SynchronizeToRemote(client, remotePath); err != nil {
return err
}
if u.syncDialogPurpose == syncDialogPurposeRemoteSetup {
if err := u.persistSyncDialogRemoteBinding(baseURL, remotePath); err != nil {
return err
}
u.showStatusMessage("Remote sync is set up for this vault.")
}
default:
path := strings.TrimSpace(u.syncLocalPath.Text())
if path == "" {
return errors.New(errVaultPathRequired)
}
if err := u.state.SynchronizeToLocal(path); err != nil {
return err
}
}
u.syncDialogOpen = false
u.showSyncPassword = false
u.filter()
return nil
}
func (u *ui) persistSyncDialogRemoteBinding(baseURL, remotePath string) error {
baseURL = strings.TrimSpace(baseURL)
remotePath = strings.TrimSpace(remotePath)
if baseURL == "" || remotePath == "" {
return fmt.Errorf("remote setup requires base URL and path")
}
input := appstate.RemoteBindingInput{
LocalVaultPath: strings.TrimSpace(u.vaultPath.Text()),
RemoteProfileID: "remote-profile-" + remoteBindingSuffix(baseURL, remotePath, strings.TrimSpace(u.syncRemoteUsername.Text())),
RemoteProfileName: friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: remotePath}),
BaseURL: baseURL,
RemotePath: remotePath,
CredentialEntryID: "remote-credential-" + remoteBindingSuffix(baseURL, remotePath, strings.TrimSpace(u.syncRemoteUsername.Text())),
CredentialTitle: "WebDAV Sign-In" + func() string {
if user := strings.TrimSpace(u.syncRemoteUsername.Text()); user != "" {
return " · " + user
}
return ""
}(),
Username: strings.TrimSpace(u.syncRemoteUsername.Text()),
Password: u.syncRemotePassword.Text(),
CredentialPath: append([]string(nil), u.currentPath...),
SyncMode: u.syncSetupMode(),
}
binding, err := u.state.ConfigureRemoteBinding(input)
if err != nil {
return err
}
if err := u.state.Save(); err != nil {
return err
}
u.selectedVaultRemoteProfileID = binding.RemoteProfileID
u.selectedVaultRemoteCredentialEntryID = binding.CredentialEntryID
u.selectedVaultRemoteSyncMode = binding.SyncMode
u.remoteBaseURL.SetText(baseURL)
u.remotePath.SetText(remotePath)
u.remoteUsername.SetText(strings.TrimSpace(u.syncRemoteUsername.Text()))
u.remotePassword.SetText(u.syncRemotePassword.Text())
u.noteRecentRemote(baseURL, remotePath)
return nil
}
func (u *ui) saveAsTargetPath() string {
path := strings.TrimSpace(u.saveAsPath.Text())
if path != "" {
return path
}
return u.defaultSaveAsPath
}
func (u *ui) importedVaultDestination(name string) string {
baseTarget := u.saveAsTargetPath()
baseDir := filepath.Dir(baseTarget)
baseName := filepath.Base(strings.TrimSpace(name))
switch {
case baseName == "" || baseName == "." || baseName == string(filepath.Separator):
return baseTarget
case strings.HasSuffix(strings.ToLower(baseName), ".kdbx"):
return filepath.Join(baseDir, baseName)
default:
return baseTarget
}
}
func (u *ui) consumePendingSharedVaultImport() {
path := strings.TrimSpace(u.pendingSharedVaultPath)
if path == "" {
return
}
content, err := os.ReadFile(path)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
u.state.ErrorMessage = fmt.Sprintf("import shared vault: %v", err)
}
return
}
name := "shared-vault.kdbx"
if namePath := strings.TrimSpace(u.pendingSharedVaultNamePath); namePath != "" {
if rawName, err := os.ReadFile(namePath); err == nil {
if trimmed := strings.TrimSpace(string(rawName)); trimmed != "" {
name = trimmed
}
}
}
if err := u.importSharedVaultBytesAction(name, content); err != nil {
u.state.ErrorMessage = fmt.Sprintf("import shared vault: %v", err)
return
}
_ = os.Remove(path)
if namePath := strings.TrimSpace(u.pendingSharedVaultNamePath); namePath != "" {
_ = os.Remove(namePath)
}
}
func (u *ui) importSharedVaultBytesAction(name string, content []byte) error {
target := u.importedVaultDestination(name)
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
return err
}
if err := os.WriteFile(target, append([]byte(nil), content...), 0o600); err != nil {
return err
}
u.lifecycleMode = "local"
u.vaultPath.SetText(target)
u.noteRecentVault(target)
u.state.ErrorMessage = ""
u.state.StatusMessage = ""
u.requestMasterPassFocus = true
u.filter()
return nil
}
func (u *ui) currentShareableVaultPath() string {
return strings.TrimSpace(u.vaultPath.Text())
}
func (u *ui) shareCurrentVaultAction() error {
if u.vaultSharer == nil {
return fmt.Errorf("vault sharing is not available on this platform")
}
path := u.currentShareableVaultPath()
if path == "" {
return errors.New(errVaultPathRequired)
}
if err := u.state.Save(); err != nil {
return err
}
return u.vaultSharer.ShareVault(path, friendlyRecentVaultLabel(path))
}
+348 -361
View File
@@ -1,4 +1,4 @@
package main package appui
import ( import (
"fmt" "fmt"
@@ -6,6 +6,7 @@ import (
"image/color" "image/color"
"net/url" "net/url"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"gioui.org/layout" "gioui.org/layout"
@@ -14,11 +15,30 @@ import (
"gioui.org/unit" "gioui.org/unit"
"gioui.org/widget" "gioui.org/widget"
"gioui.org/widget/material" "gioui.org/widget/material"
"git.julianfamily.org/keepassgo/appstate" "git.julianfamily.org/keepassgo/internal/appstate"
) )
func (u *ui) lifecycleScreen(gtx layout.Context) layout.Dimensions {
panel := card
if u.usesCompactViewport() {
panel = compactCard
}
return panel(gtx, func(gtx layout.Context) layout.Dimensions {
rows := []layout.Widget{
u.lifecycleBranding,
layout.Spacer{Height: unit.Dp(8)}.Layout,
u.lifecycleControls,
}
return material.List(u.theme, &u.lifecycleList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions {
return rows[i](gtx)
})
})
}
func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
busy := u.lifecycleBusy() busy := u.lifecycleBusy()
showLocalChooser := u.showLocalVaultChooser()
selectedLocalPath := strings.TrimSpace(u.vaultPath.Text())
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "OPEN A VAULT") lbl := material.Label(u.theme, unit.Sp(12), "OPEN A VAULT")
@@ -27,207 +47,14 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
message := "Choose a recent vault or pick a `.kdbx` file, then unlock it." message := "Choose a recent vault or enter a .kdbx path, then unlock it. Remote sync attaches to that local vault after it opens."
if u.lifecycleMode == "remote" {
message = "Connect to a remote vault, then unlock it with the KeePass master key."
}
lbl := material.Label(u.theme, unit.Sp(14), message) lbl := material.Label(u.theme, unit.Sp(14), message)
lbl.Color = accentColor lbl.Color = accentColor
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if busy {
return passiveSectionTab(gtx, u.theme, "Local Vault", u.lifecycleMode == "local")
}
return sectionTabButton(gtx, u.theme, &u.showLocalLifecycle, "Local Vault", u.lifecycleMode == "local")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if busy {
return passiveSectionTab(gtx, u.theme, "Remote Vault", u.lifecycleMode == "remote")
}
return sectionTabButton(gtx, u.theme, &u.showRemoteLifecycle, "Remote Vault", u.lifecycleMode == "remote")
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.lifecycleMode == "remote" { return u.lifecycleVaultChooserSection(gtx, busy, showLocalChooser, selectedLocalPath)
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "LOCATION")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "Remote Base URL", "Base WebDAV endpoint, for example https://server/remote.php/webdav.", &u.remoteBaseURL, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "Remote Path", "Path to the remote .kdbx file under the WebDAV base URL.", &u.remotePath, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if strings.TrimSpace(u.remoteBaseURL.Text()) == "" || strings.TrimSpace(u.remotePath.Text()) == "" {
return layout.Dimensions{}
}
record := u.currentRemoteRecord()
lastGroup := u.recentRemoteGroup(record.BaseURL, record.Path)
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "SELECTED CONNECTION")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(14), friendlyRecentRemoteLabel(record))
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), "Path: "+strings.TrimSpace(record.Path))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), "Server: "+strings.TrimSpace(record.BaseURL))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), "Auth: "+recentRemoteStoredAuthSummary(recentRemoteRecord{
Username: strings.TrimSpace(u.remoteUsername.Text()),
Password: u.remotePassword.Text(),
}))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if len(lastGroup) == 0 {
return layout.Dimensions{}
}
lbl := material.Label(u.theme, unit.Sp(11), "Last group: "+strings.Join(u.displayEntryPath(lastGroup), " / "))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if busy {
return layout.Dimensions{}
}
return tonedButton(gtx, u.theme, &u.clearRemoteSelection, "Change...")
}),
)
})
})
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if busy {
return layout.Dimensions{}
}
return u.recentRemoteList(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "AUTHENTICATION")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "Remote Username", "Username used to authenticate to the WebDAV server.", &u.remoteUsername, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return labeledEditorHelp(u.theme, "Remote Password", "Password or app token used to authenticate to the WebDAV server.", &u.remotePassword, true)(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
box := material.CheckBox(u.theme, &u.rememberRemoteAuth, "Remember sign-in on this device")
box.Color = accentColor
return box.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Top: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.openRemotePrefsHelp, "Settings & Help")
})
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
)
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "RECENT VAULTS")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if busy {
return layout.Dimensions{}
}
return u.recentVaultList(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "VAULT FILE")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
selectedPath := strings.TrimSpace(u.vaultPath.Text())
switch {
case busy:
return labeledEditorHelp(u.theme, "Vault Path", "Choose the existing .kdbx file to open.", &u.vaultPath, false)(gtx)
case selectedPath == "":
return selectorEditorHelp(u.theme, "Vault Path", "Choose the existing .kdbx file to open.", &u.vaultPath, &u.pickVaultPath, "Choose File", false)(gtx)
default:
lastGroup := u.recentVaultGroup(selectedPath)
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "SELECTED VAULT")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(16), friendlyRecentVaultLabel(selectedPath))
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
dir := compactPathDirectorySummary(selectedPath)
if dir == "" {
return layout.Dimensions{}
}
lbl := material.Label(u.theme, unit.Sp(11), dir)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if len(lastGroup) == 0 {
return layout.Dimensions{}
}
lbl := material.Label(u.theme, unit.Sp(11), "Last group: "+strings.Join(u.displayEntryPath(lastGroup), " / "))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.clearVaultSelection, "Change...")
}),
)
})
})
}
}),
)
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
@@ -242,33 +69,125 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if busy { if busy {
return labeledEditorHelp(u.theme, "Key File", "Optional path to a KeePass-compatible key file.", &u.keyFilePath, false)(gtx) return labeledEditorHelp(u.theme, "Key File", keyFileHelp(), &u.keyFilePath, false)(gtx)
} }
return selectorEditorHelp(u.theme, "Key File", "Optional path to a KeePass-compatible key file.", &u.keyFilePath, &u.pickKeyFile, "Choose File", false)(gtx) return keyFileSelector(u.theme, &u.keyFilePath, &u.pickKeyFile)(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.lifecycleControlsFooter(gtx, busy, selectedLocalPath)
}),
)
}
func (u *ui) lifecycleVaultChooserSection(gtx layout.Context, busy, showLocalChooser bool, selectedLocalPath string) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showLocalChooser {
return layout.Dimensions{}
}
lbl := material.Label(u.theme, unit.Sp(12), "RECENT VAULTS")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}), }),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if busy { if !showLocalChooser {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showLocalChooser || busy {
return layout.Dimensions{}
}
return u.recentVaultList(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.lifecycleImportSharedVaultButton(gtx, busy, showLocalChooser)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showLocalChooser {
return layout.Dimensions{} return layout.Dimensions{}
} }
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx) return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
}), }),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showLocalChooser {
return layout.Dimensions{}
}
lbl := material.Label(u.theme, unit.Sp(12), "VAULT FILE")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showLocalChooser {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.lifecycleVaultPathSelector(gtx, busy, selectedLocalPath)
}),
)
}
func (u *ui) lifecycleImportSharedVaultButton(gtx layout.Context, busy, showLocalChooser bool) layout.Dimensions {
if !showLocalChooser || busy || !supportsSharedVaultImport(runtime.GOOS) {
return layout.Dimensions{}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.importSharedVault, "Import Shared Vault")
}),
)
}
func (u *ui) lifecycleVaultPathSelector(gtx layout.Context, busy bool, selectedLocalPath string) layout.Dimensions {
switch {
case busy:
return labeledEditorHelp(u.theme, "Vault Path", localVaultPathHelp(), &u.vaultPath, false)(gtx)
case selectedLocalPath == "":
return localPathSelector(u.theme, &u.vaultPath, &u.pickVaultPath)(gtx)
default:
return layout.Dimensions{}
}
}
func (u *ui) lifecycleControlsFooter(gtx layout.Context, busy bool, selectedLocalPath string) layout.Dimensions {
if u.shouldPrioritizeLifecyclePrimaryActions() {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.lifecyclePrimaryActionsSection(gtx, busy) }),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.lifecycleSelectedVaultSection(gtx, busy, selectedLocalPath)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.lifecycleAdvancedSection(gtx, busy) }),
)
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.lifecycleAdvancedSection(gtx, busy) }),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.lifecyclePrimaryActionsSection(gtx, busy) }),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.lifecycleSelectedVaultSection(gtx, busy, selectedLocalPath)
}),
)
}
func (u *ui) lifecycleAdvancedSection(gtx layout.Context, busy bool) layout.Dimensions {
if busy { if busy {
return layout.Dimensions{} return layout.Dimensions{}
} }
return u.lifecycleAdvancedDisclosure(gtx) return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
}), layout.Rigid(u.lifecycleAdvancedDisclosure),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
if busy { layout.Rigid(u.lifecycleAdvancedCard),
return layout.Dimensions{} )
} }
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
}), func (u *ui) lifecycleAdvancedCard(gtx layout.Context) layout.Dimensions {
layout.Rigid(func(gtx layout.Context) layout.Dimensions { if u.lifecycleAdvancedHidden || u.lifecycleMode == "remote" {
if busy || u.lifecycleAdvancedHidden {
return layout.Dimensions{}
}
if u.lifecycleMode == "remote" {
return layout.Dimensions{} return layout.Dimensions{}
} }
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
@@ -290,27 +209,29 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
}), }),
) )
}) })
}), }
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { func (u *ui) lifecyclePrimaryActionsSection(gtx layout.Context, busy bool) layout.Dimensions {
if u.lifecycleMode == "remote" {
label := u.remoteOpenButtonLabel()
if busy {
return passiveTonedButton(gtx, u.theme, label)
}
return tonedButton(gtx, u.theme, &u.openRemote, label)
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := "Open Vault" label := "Open Vault"
if busy { if busy {
label = "Opening Vault..." return passiveTonedButton(gtx, u.theme, "Opening Vault...")
}
if busy {
return passiveTonedButton(gtx, u.theme, label)
} }
return tonedButton(gtx, u.theme, &u.openVault, label) return tonedButton(gtx, u.theme, &u.openVault, label)
}), }),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if busy || !u.shouldShowLifecycleRemoteSyncAction() {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if busy || !u.shouldShowLifecycleRemoteSyncAction() {
return layout.Dimensions{}
}
return tonedButton(gtx, u.theme, &u.lifecycleRemoteSyncAction, u.lifecycleRemoteSyncActionLabel())
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), "Need a fresh database instead?") lbl := material.Label(u.theme, unit.Sp(11), "Need a fresh database instead?")
@@ -325,10 +246,119 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
return sectionTabButton(gtx, u.theme, &u.createVault, "Create New Vault", false) return sectionTabButton(gtx, u.theme, &u.createVault, "Create New Vault", false)
}), }),
) )
}
func (u *ui) lifecycleSelectedVaultSection(gtx layout.Context, busy bool, selectedLocalPath string) layout.Dimensions {
if busy || selectedLocalPath == "" {
return layout.Dimensions{}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.selectedLocalVaultCard(gtx, selectedLocalPath)
}), }),
) )
} }
func (u *ui) shouldPrioritizeLifecyclePrimaryActions() bool {
return u.usesCompactViewport()
}
func (u *ui) selectedRemoteCardHeading() string {
if u.selectedRemoteUsesLocalCache() {
return "CACHED VAULT"
}
return "SELECTED CONNECTION"
}
func (u *ui) selectedRemoteCardPrimaryText() string {
record := u.currentRemoteRecord()
if u.selectedRemoteUsesLocalCache() {
path := strings.TrimSpace(u.vaultPath.Text())
if label := friendlyRecentVaultLabel(path); label != "" {
return label
}
}
return friendlyRecentRemoteLabel(record)
}
func (u *ui) selectedRemoteCardDetailLines() []string {
record := u.currentRemoteRecord()
lastGroup := u.recentRemoteGroup(record.BaseURL, record.Path)
lines := make([]string, 0, 3)
if u.selectedRemoteUsesLocalCache() {
if dir := compactPathDirectorySummary(strings.TrimSpace(u.vaultPath.Text())); dir != "" {
lines = append(lines, dir)
}
lines = append(lines, "Sync target: "+friendlyRecentRemoteLabel(record))
} else {
lines = append(lines, "Path: "+strings.TrimSpace(record.Path))
lines = append(lines, "Server: "+strings.TrimSpace(record.BaseURL))
}
if len(lastGroup) > 0 {
lines = append(lines, "Last group: "+strings.Join(u.displayEntryPath(lastGroup), " / "))
}
return lines
}
func (u *ui) selectedLocalVaultCard(gtx layout.Context, path string) layout.Dimensions {
lastGroup := u.recentVaultGroup(path)
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "SELECTED VAULT")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(16), friendlyRecentVaultLabel(path))
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
dir := compactPathDirectorySummary(path)
if dir == "" {
return layout.Dimensions{}
}
lbl := material.Label(u.theme, unit.Sp(11), dir)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if len(lastGroup) == 0 {
return layout.Dimensions{}
}
lbl := material.Label(u.theme, unit.Sp(11), "Last group: "+strings.Join(u.displayEntryPath(lastGroup), " / "))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), u.selectedLocalVaultRemoteSyncSummary(path))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.clearVaultSelection, "Open Different Vault")
}),
)
})
})
}
func (u *ui) selectedLocalVaultRemoteSyncSummary(path string) string {
if record, ok := u.boundRecentRemoteForLocalVault(path); ok {
summary := "Saved remote sync target: " + friendlyRecentRemoteLabel(record)
if normalizeUISyncMode(appstate.SyncMode(record.SyncMode)) == appstate.SyncModeAutomaticOnOpenSave {
return summary + " · Syncs automatically on open and save."
}
return summary + " · Sync manually when you choose Use Remote Sync."
}
return "Open this vault to set up a WebDAV sync target for it."
}
func (u *ui) lifecycleSecuritySettingsSummary() string { func (u *ui) lifecycleSecuritySettingsSummary() string {
return "Cipher and KDF now live in Vault Settings so opening and creating a vault stays focused on the file, key material, and sync choices." return "Cipher and KDF now live in Vault Settings so opening and creating a vault stays focused on the file, key material, and sync choices."
} }
@@ -385,7 +415,7 @@ func (u *ui) recentVaultList(gtx layout.Context) layout.Dimensions {
if gtx.Constraints.Min.Y > gtx.Constraints.Max.Y { if gtx.Constraints.Min.Y > gtx.Constraints.Max.Y {
gtx.Constraints.Min.Y = gtx.Constraints.Max.Y gtx.Constraints.Min.Y = gtx.Constraints.Max.Y
} }
return material.List(u.theme, &u.lifecycleList).Layout(gtx, len(u.recentVaults), func(gtx layout.Context, i int) layout.Dimensions { return material.List(u.theme, &u.recentVaultListState).Layout(gtx, len(u.recentVaults), func(gtx layout.Context, i int) layout.Dimensions {
path := u.recentVaults[i] path := u.recentVaults[i]
label := path label := path
if friendly := friendlyRecentVaultLabel(path); friendly != "" { if friendly := friendlyRecentVaultLabel(path); friendly != "" {
@@ -444,76 +474,6 @@ func (u *ui) recentVaultList(gtx layout.Context) layout.Dimensions {
) )
} }
func (u *ui) recentRemoteList(gtx layout.Context) layout.Dimensions {
if len(u.recentRemotes) == 0 {
return layout.Dimensions{}
}
if len(u.recentRemoteClicks) < len(u.recentRemotes) {
u.recentRemoteClicks = make([]widget.Clickable, len(u.recentRemotes))
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "RECENT CONNECTIONS")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
maxY := gtx.Dp(unit.Dp(180))
if gtx.Constraints.Max.Y > maxY {
gtx.Constraints.Max.Y = maxY
}
if gtx.Constraints.Min.Y > gtx.Constraints.Max.Y {
gtx.Constraints.Min.Y = gtx.Constraints.Max.Y
}
return material.List(u.theme, &u.lifecycleList).Layout(gtx, len(u.recentRemotes), func(gtx layout.Context, i int) layout.Dimensions {
record := u.recentRemotes[i]
label := friendlyRecentRemoteLabel(record)
selected := strings.TrimSpace(u.remoteBaseURL.Text()) == record.BaseURL && strings.TrimSpace(u.remotePath.Text()) == record.Path
return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions {
return u.recentRemoteClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(14), label)
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), "Path: "+strings.TrimSpace(record.Path))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), "Server: "+normalizedRemoteHost(record.BaseURL))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), "Auth: "+recentRemoteStoredAuthSummary(record))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if len(record.LastGroup) == 0 {
return layout.Dimensions{}
}
lbl := material.Label(u.theme, unit.Sp(11), "Last group: "+strings.Join(u.displayEntryPath(record.LastGroup), " / "))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
})
})
})
})
})
}),
)
}
func recentSelectionCard(gtx layout.Context, selected bool, w layout.Widget) layout.Dimensions { func recentSelectionCard(gtx layout.Context, selected bool, w layout.Widget) layout.Dimensions {
if !selected { if !selected {
return compactCard(gtx, w) return compactCard(gtx, w)
@@ -624,21 +584,6 @@ func normalizedRemoteHost(baseURL string) string {
return strings.TrimSuffix(host, "/") return strings.TrimSuffix(host, "/")
} }
func recentRemoteStoredAuthSummary(record recentRemoteRecord) string {
username := strings.TrimSpace(record.Username)
hasPassword := record.Password != ""
switch {
case username != "" && hasPassword:
return "saved username and password"
case username != "":
return "saved username"
case hasPassword:
return "saved password"
default:
return "location only"
}
}
func (u *ui) attachmentList(gtx layout.Context) layout.Dimensions { func (u *ui) attachmentList(gtx layout.Context) layout.Dimensions {
items := u.selectedAttachmentItems() items := u.selectedAttachmentItems()
if len(items) == 0 { if len(items) == 0 {
@@ -788,12 +733,6 @@ func (u *ui) groupControls(gtx layout.Context) layout.Dimensions {
lbl.Color = mutedColor lbl.Color = mutedColor
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "Create, rename, move, and delete groups without interrupting the browsing path above.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "CREATE") lbl := material.Label(u.theme, unit.Sp(12), "CREATE")
@@ -801,16 +740,6 @@ func (u *ui) groupControls(gtx layout.Context) layout.Dimensions {
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
help := "Add a new top-level group."
if len(u.displayPath()) > 0 {
help = "Add a subgroup inside " + u.currentGroupDisplayName() + "."
}
lbl := material.Label(u.theme, unit.Sp(12), help)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Create Group / Subgroup", &u.groupName, false)), layout.Rigid(labeledEditor(u.theme, "Create Group / Subgroup", &u.groupName, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
@@ -833,12 +762,6 @@ func (u *ui) groupControls(gtx layout.Context) layout.Dimensions {
lbl.Color = accentColor lbl.Color = accentColor
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "Rename or relocate only the selected group. Browsing stays in the separate group navigator.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.renameGroup, "Rename Current Group") return tonedButton(gtx, u.theme, &u.renameGroup, "Rename Current Group")
@@ -930,7 +853,7 @@ func (u *ui) groupControlsDisclosure(gtx layout.Context) layout.Dimensions {
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
icon := u.expandLessIcon icon := u.expandLessIcon
if u.groupControlsHidden { if u.groupControlsHidden {
icon = u.expandMoreIcon icon = u.chevronRightIcon
} }
if icon == nil { if icon == nil {
lbl := material.Label(u.theme, unit.Sp(16), ">") lbl := material.Label(u.theme, unit.Sp(16), ">")
@@ -944,10 +867,9 @@ func (u *ui) groupControlsDisclosure(gtx layout.Context) layout.Dimensions {
}), }),
layout.Rigid(layout.Spacer{Width: unit.Dp(4)}.Layout), layout.Rigid(layout.Spacer{Width: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := "Group management" label := "Group Tools"
size := unit.Sp(12) size := unit.Sp(12)
if u.mode == "phone" { if u.usesCompactViewport() {
label = "Tools"
size = unit.Sp(11) size = unit.Sp(11)
} }
lbl := material.Label(u.theme, size, label) lbl := material.Label(u.theme, size, label)
@@ -957,7 +879,7 @@ func (u *ui) groupControlsDisclosure(gtx layout.Context) layout.Dimensions {
) )
}) })
} }
if u.mode == "phone" { if u.usesCompactViewport() {
return content(gtx) return content(gtx)
} }
return compactCard(gtx, content) return compactCard(gtx, content)
@@ -969,15 +891,15 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return sectionCard(gtx, u.theme, "BASICS", "Core entry identity and navigation fields.", func(gtx layout.Context) layout.Dimensions { return sectionCard(gtx, u.theme, "BASICS", "Core entry identity and navigation fields.", func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(labeledEditorWithFocus(u.theme, "Title", &u.entryTitle, false, u.isFocused(detailFocusID(detailFieldTitle)))), layout.Rigid(labeledEditorWithFocus(u.theme, u.accessibilityPrefs, "Title", &u.entryTitle, false, u.isFocused(detailFocusID(detailFieldTitle)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorWithFocus(u.theme, "Username", &u.entryUsername, false, u.isFocused(detailFocusID(detailFieldUsername)))), layout.Rigid(labeledEditorWithFocus(u.theme, u.accessibilityPrefs, "Username", &u.entryUsername, false, u.isFocused(detailFocusID(detailFieldUsername)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorWithFocus(u.theme, "URL", &u.entryURL, false, u.isFocused(detailFocusID(detailFieldURL)))), layout.Rigid(labeledEditorWithFocus(u.theme, u.accessibilityPrefs, "URL", &u.entryURL, false, u.isFocused(detailFocusID(detailFieldURL)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorWithFocus(u.theme, "Path", &u.entryPath, false, u.isFocused(detailFocusID(detailFieldPath)))), layout.Rigid(labeledEditorWithFocus(u.theme, u.accessibilityPrefs, "Path", &u.entryPath, false, u.isFocused(detailFocusID(detailFieldPath)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorWithFocus(u.theme, "Tags", &u.entryTags, false, u.isFocused(detailFocusID(detailFieldTags)))), layout.Rigid(labeledEditorWithFocus(u.theme, u.accessibilityPrefs, "Tags", &u.entryTags, false, u.isFocused(detailFocusID(detailFieldTags)))),
) )
}) })
}), }),
@@ -985,9 +907,9 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return sectionCard(gtx, u.theme, "PASSWORD", "Generate, review, and keep track of password changes before you save.", func(gtx layout.Context) layout.Dimensions { return sectionCard(gtx, u.theme, "PASSWORD", "Generate, review, and keep track of password changes before you save.", func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(labeledEditorWithFocus(u.theme, "Password", &u.entryPassword, true, u.isFocused(detailFocusID(detailFieldPassword)))), layout.Rigid(labeledEditorWithFocus(u.theme, u.accessibilityPrefs, "Password", &u.entryPassword, true, u.isFocused(detailFocusID(detailFieldPassword)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorWithFocus(u.theme, "Password Profile", &u.passwordProfile, false, u.isFocused(detailFocusID(detailFieldPasswordProfile)))), layout.Rigid(labeledEditorWithFocus(u.theme, u.accessibilityPrefs, "Password Profile", &u.passwordProfile, false, u.isFocused(detailFocusID(detailFieldPasswordProfile)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), u.passwordProfileOptionsText()) lbl := material.Label(u.theme, unit.Sp(11), u.passwordProfileOptionsText())
@@ -1022,7 +944,7 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.mode == "phone" { if u.usesCompactViewport() {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.copyPass, "Copy Password") return tonedButton(gtx, u.theme, &u.copyPass, "Copy Password")
@@ -1057,7 +979,7 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return sectionCard(gtx, u.theme, "NOTES", "Long-form context for this entry.", func(gtx layout.Context) layout.Dimensions { return sectionCard(gtx, u.theme, "NOTES", "Long-form context for this entry.", func(gtx layout.Context) layout.Dimensions {
return labeledMultilineEditorWithFocus(u.theme, "Notes", &u.entryNotes, false, u.isFocused(detailFocusID(detailFieldNotes)), unit.Dp(108))(gtx) return labeledMultilineEditorWithFocus(u.theme, u.accessibilityPrefs, "Notes", &u.entryNotes, false, u.isFocused(detailFocusID(detailFieldNotes)), unit.Dp(108))(gtx)
}) })
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
@@ -1065,7 +987,7 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return sectionCard(gtx, u.theme, "HISTORY", "Pick a saved version index to restore into the current entry.", func(gtx layout.Context) layout.Dimensions { return sectionCard(gtx, u.theme, "HISTORY", "Pick a saved version index to restore into the current entry.", func(gtx layout.Context) layout.Dimensions {
return labeledEditorWithFocus(u.theme, "History Index", &u.historyIndex, false, u.isFocused(detailFocusID(detailFieldHistoryIndex)))(gtx) return labeledEditorWithFocus(u.theme, u.accessibilityPrefs, "History Index", &u.historyIndex, false, u.isFocused(detailFocusID(detailFieldHistoryIndex)))(gtx)
}) })
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
@@ -1081,7 +1003,7 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
layout.Rigid(labeledEditor(u.theme, "Export Attachment Path", &u.exportAttachmentPath, false)), layout.Rigid(labeledEditor(u.theme, "Export Attachment Path", &u.exportAttachmentPath, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.mode == "phone" { if u.usesCompactViewport() {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.addAttachment, "Add Attachment") return tonedButton(gtx, u.theme, &u.addAttachment, "Add Attachment")
@@ -1142,17 +1064,49 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
} }
func labeledEditor(th *material.Theme, label string, editor *widget.Editor, sensitive bool) layout.Widget { func labeledEditor(th *material.Theme, label string, editor *widget.Editor, sensitive bool) layout.Widget {
return labeledEditorWithFocus(th, label, editor, sensitive, false) return labeledEditorWithFocus(th, defaultAccessibilityPreferences(), label, editor, sensitive, false)
} }
func labeledEditorHelp(th *material.Theme, label, help string, editor *widget.Editor, sensitive bool) layout.Widget { func labeledEditorHelp(th *material.Theme, label, help string, editor *widget.Editor, sensitive bool) layout.Widget {
return labeledEditorHelpFocus(th, label, help, editor, sensitive, false) return labeledEditorHelpFocus(th, defaultAccessibilityPreferences(), label, help, editor, sensitive, false)
} }
func labeledEditorHelpFocus(th *material.Theme, label, help string, editor *widget.Editor, sensitive bool, focused bool) layout.Widget { func localVaultPathHelpForRuntime(goos string) string {
if supportsDesktopFilePicker(goos) || supportsSharedVaultImport(goos) {
return "Choose the existing .kdbx file to open."
}
return "Enter the shared-storage path to the existing .kdbx file, for example /sdcard/Download/vault.kdbx."
}
func localVaultPathHelp() string {
return localVaultPathHelpForRuntime(runtime.GOOS)
}
func keyFileHelp() string {
if supportsDesktopFilePicker(runtime.GOOS) {
return "Optional path to a KeePass-compatible key file."
}
return "Optional shared-storage path to a KeePass-compatible key file."
}
func localPathSelector(th *material.Theme, editor *widget.Editor, click *widget.Clickable) layout.Widget {
if supportsDesktopFilePicker(runtime.GOOS) || supportsSharedVaultImport(runtime.GOOS) {
return selectorEditorHelp(th, "Vault Path", localVaultPathHelp(), editor, click, "Choose File", false)
}
return labeledEditorHelp(th, "Vault Path", localVaultPathHelp(), editor, false)
}
func keyFileSelector(th *material.Theme, editor *widget.Editor, click *widget.Clickable) layout.Widget {
if supportsDesktopFilePicker(runtime.GOOS) {
return selectorEditorHelp(th, "Key File", keyFileHelp(), editor, click, "Choose File", false)
}
return labeledEditorHelp(th, "Key File", keyFileHelp(), editor, false)
}
func labeledEditorHelpFocus(th *material.Theme, prefs accessibilityPreferences, label, help string, editor *widget.Editor, sensitive bool, focused bool) layout.Widget {
return func(gtx layout.Context) layout.Dimensions { return func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(labeledEditorWithFocus(th, label, editor, sensitive, focused)), layout.Rigid(labeledEditorWithFocus(th, prefs, label, editor, sensitive, focused)),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(11), help) lbl := material.Label(th, unit.Sp(11), help)
@@ -1206,12 +1160,14 @@ func selectorEditorHelp(th *material.Theme, label, help string, editor *widget.E
func (u *ui) unlockPanel(gtx layout.Context) layout.Dimensions { func (u *ui) unlockPanel(gtx layout.Context) layout.Dimensions {
targetLabel := "Locked vault" targetLabel := "Locked vault"
targetValue := "Unlock the active vault to continue." targetValue := "Unlock the active vault to continue."
changeLabel := "Open Different Vault"
if u.state.Session != nil { if u.state.Session != nil {
if strings.TrimSpace(u.remoteBaseURL.Text()) != "" || strings.TrimSpace(u.remotePath.Text()) != "" { if strings.TrimSpace(u.remoteBaseURL.Text()) != "" || strings.TrimSpace(u.remotePath.Text()) != "" {
baseURL := strings.TrimSpace(u.remoteBaseURL.Text()) baseURL := strings.TrimSpace(u.remoteBaseURL.Text())
path := strings.TrimSpace(u.remotePath.Text()) path := strings.TrimSpace(u.remotePath.Text())
targetLabel = "Remote vault" targetLabel = "Remote vault"
targetValue = friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: path}) targetValue = friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: path})
changeLabel = "Open Different Connection"
if strings.TrimSpace(targetValue) == "" { if strings.TrimSpace(targetValue) == "" {
targetValue = "Remote WebDAV vault" targetValue = "Remote WebDAV vault"
} }
@@ -1227,8 +1183,7 @@ func (u *ui) unlockPanel(gtx layout.Context) layout.Dimensions {
} }
} }
} }
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, targetCard := func(gtx layout.Context) layout.Dimensions {
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
@@ -1243,20 +1198,50 @@ func (u *ui) unlockPanel(gtx layout.Context) layout.Dimensions {
lbl.Color = accentColor lbl.Color = accentColor
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !u.shouldUseLockedSinglePane() {
return layout.Dimensions{}
}
return layout.Inset{Top: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
if targetLabel == "Remote vault" {
return tonedButton(gtx, u.theme, &u.clearRemoteSelection, changeLabel)
}
return tonedButton(gtx, u.theme, &u.clearVaultSelection, changeLabel)
})
}),
) )
}) })
}) })
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.mode == "desktop" {
return layout.Dimensions{}
}
return targetCard(gtx)
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.masterPasswordField(gtx, "Used alone or together with a key file to unlock the vault.") return u.masterPasswordField(gtx, "Used alone or together with a key file to unlock the vault.")
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(selectorEditorHelp(u.theme, "Key File", "Optional path to a KeePass-compatible key file.", &u.keyFilePath, &u.pickKeyFile, "Choose File", false)), layout.Rigid(keyFileSelector(u.theme, &u.keyFilePath, &u.pickKeyFile)),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.unlockVault, "Unlock") return tonedButton(gtx, u.theme, &u.unlockVault, "Unlock")
}), }),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.mode != "desktop" {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.mode != "desktop" {
return layout.Dimensions{}
}
return targetCard(gtx)
}),
) )
} }
@@ -1276,7 +1261,7 @@ func (u *ui) masterPasswordField(gtx layout.Context, help string) layout.Dimensi
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return outlinedFieldState(gtx, false, func(gtx layout.Context) layout.Dimensions { return u.outlinedFieldState(gtx, false, func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(8)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(8)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx, return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
@@ -1313,6 +1298,7 @@ func (u *ui) masterPasswordField(gtx layout.Context, help string) layout.Dimensi
func labeledEditorWithFocus( func labeledEditorWithFocus(
th *material.Theme, th *material.Theme,
prefs accessibilityPreferences,
label string, label string,
editor *widget.Editor, editor *widget.Editor,
sensitive bool, sensitive bool,
@@ -1326,7 +1312,7 @@ func labeledEditorWithFocus(
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return outlinedFieldState(gtx, focused, func(gtx layout.Context) layout.Dimensions { return outlinedFieldStateWithPrefs(gtx, prefs, focused, func(gtx layout.Context) layout.Dimensions {
mask := editor.Mask mask := editor.Mask
if sensitive { if sensitive {
editor.Mask = '•' editor.Mask = '•'
@@ -1343,6 +1329,7 @@ func labeledEditorWithFocus(
func labeledMultilineEditorWithFocus( func labeledMultilineEditorWithFocus(
th *material.Theme, th *material.Theme,
prefs accessibilityPreferences,
label string, label string,
editor *widget.Editor, editor *widget.Editor,
sensitive bool, sensitive bool,
@@ -1357,7 +1344,7 @@ func labeledMultilineEditorWithFocus(
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return outlinedFieldState(gtx, focused, func(gtx layout.Context) layout.Dimensions { return outlinedFieldStateWithPrefs(gtx, prefs, focused, func(gtx layout.Context) layout.Dimensions {
mask := editor.Mask mask := editor.Mask
if sensitive { if sensitive {
editor.Mask = '•' editor.Mask = '•'
+12
View File
@@ -0,0 +1,12 @@
package layout
type TopSection string
const (
TopSearch TopSection = "search"
TopNavigation TopSection = "navigation"
TopPath TopSection = "path"
TopGroup TopSection = "group"
TopGroupTools TopSection = "group_tools"
TopPrimary TopSection = "primary"
)
+8
View File
@@ -0,0 +1,8 @@
package list
type EntriesSectionState struct {
Path []string
SearchQuery string
SelectedEntryID string
Editing bool
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,22 @@
//go:build android
package platform
/*
#cgo CFLAGS: -Werror
#cgo LDFLAGS: -landroid
#include <android/log.h>
#include <stdlib.h>
*/
import "C"
import "unsafe"
func LogInfo(tag, msg string) {
ctag := C.CString(tag)
defer C.free(unsafe.Pointer(ctag))
cmsg := C.CString(msg)
defer C.free(unsafe.Pointer(cmsg))
C.__android_log_write(C.ANDROID_LOG_INFO, ctag, cmsg)
}
@@ -0,0 +1,184 @@
//go:build android
package platform
/*
#cgo CFLAGS: -Werror
#cgo LDFLAGS: -landroid
#include <jni.h>
#include <stdlib.h>
static jclass jni_GetObjectClass(JNIEnv *env, jobject obj) {
return (*env)->GetObjectClass(env, obj);
}
static jmethodID jni_GetMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig) {
return (*env)->GetMethodID(env, clazz, name, sig);
}
static jmethodID jni_GetStaticMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig) {
return (*env)->GetStaticMethodID(env, clazz, name, sig);
}
static jobject jni_CallObjectMethodA(JNIEnv *env, jobject obj, jmethodID method, jvalue *args) {
return (*env)->CallObjectMethodA(env, obj, method, args);
}
static void jni_CallStaticVoidMethodA(JNIEnv *env, jclass cls, jmethodID methodID, const jvalue *args) {
(*env)->CallStaticVoidMethodA(env, cls, methodID, args);
}
static jvalue jni_ValueObject(jobject obj) {
jvalue value;
value.l = obj;
return value;
}
static jthrowable jni_ExceptionOccurred(JNIEnv *env) {
return (*env)->ExceptionOccurred(env);
}
static void jni_ExceptionClear(JNIEnv *env) {
(*env)->ExceptionClear(env);
}
static jstring jni_NewString(JNIEnv *env, const jchar *unicodeChars, jsize len) {
return (*env)->NewString(env, unicodeChars, len);
}
static int jni_IsNull(jobject obj) {
return obj == NULL;
}
*/
import "C"
import (
"fmt"
"strings"
"unicode/utf16"
"unsafe"
"gioui.org/app"
_ "unsafe"
)
type androidVaultSharer struct{}
//go:linkname gioJavaVM gioui.org/app.javaVM
func gioJavaVM() *C.JavaVM
//go:linkname gioRunInJVM gioui.org/app.runInJVM
func gioRunInJVM(jvm *C.JavaVM, f func(env *C.JNIEnv))
func NewVaultSharer(goos string) VaultSharer {
return androidVaultSharer{}
}
func (androidVaultSharer) ShareVault(path, title string) error {
if strings.TrimSpace(path) == "" {
return fmt.Errorf("vault path is required")
}
ctx := C.jobject(unsafe.Pointer(app.AppContext()))
if C.jni_IsNull(ctx) != 0 {
return fmt.Errorf("android app context is not available")
}
var callErr error
gioRunInJVM(gioJavaVM(), func(env *C.JNIEnv) {
sharerClass, err := androidLoadClass(env, ctx, "org.julianfamily.keepassgo.AndroidShare")
if err != nil {
callErr = err
return
}
methodName := cString("shareVault")
defer C.free(unsafe.Pointer(methodName))
methodSig := cString("(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;)V")
defer C.free(unsafe.Pointer(methodSig))
method := C.jni_GetStaticMethodID(env, sharerClass, methodName, methodSig)
if method == nil {
callErr = androidJNIError(env, "resolve shareVault method")
if callErr == nil {
callErr = fmt.Errorf("resolve shareVault method")
}
return
}
jPath := androidJavaString(env, path)
jTitle := androidJavaString(env, title)
args := [3]C.jvalue{}
args[0] = C.jni_ValueObject(ctx)
args[1] = C.jni_ValueObject(C.jobject(jPath))
args[2] = C.jni_ValueObject(C.jobject(jTitle))
C.jni_CallStaticVoidMethodA(env, sharerClass, method, &args[0])
callErr = androidJNIError(env, "share vault")
})
return callErr
}
func androidLoadClass(env *C.JNIEnv, ctx C.jobject, name string) (C.jclass, error) {
var zeroClass C.jclass
contextClass := C.jni_GetObjectClass(env, ctx)
getClassLoaderName := cString("getClassLoader")
defer C.free(unsafe.Pointer(getClassLoaderName))
getClassLoaderSig := cString("()Ljava/lang/ClassLoader;")
defer C.free(unsafe.Pointer(getClassLoaderSig))
getClassLoader := C.jni_GetMethodID(env, contextClass, getClassLoaderName, getClassLoaderSig)
if getClassLoader == nil {
if err := androidJNIError(env, "resolve getClassLoader"); err != nil {
return zeroClass, err
}
return zeroClass, fmt.Errorf("resolve getClassLoader")
}
classLoader := C.jni_CallObjectMethodA(env, ctx, getClassLoader, nil)
if err := androidJNIError(env, "load class loader"); err != nil {
return zeroClass, err
}
if C.jni_IsNull(classLoader) != 0 {
return zeroClass, fmt.Errorf("android class loader is nil")
}
classLoaderClass := C.jni_GetObjectClass(env, classLoader)
loadClassName := cString("loadClass")
defer C.free(unsafe.Pointer(loadClassName))
loadClassSig := cString("(Ljava/lang/String;)Ljava/lang/Class;")
defer C.free(unsafe.Pointer(loadClassSig))
loadClass := C.jni_GetMethodID(env, classLoaderClass, loadClassName, loadClassSig)
if loadClass == nil {
if err := androidJNIError(env, "resolve loadClass"); err != nil {
return zeroClass, err
}
return zeroClass, fmt.Errorf("resolve loadClass")
}
jClassName := androidJavaString(env, name)
args := [1]C.jvalue{}
args[0] = C.jni_ValueObject(C.jobject(jClassName))
loaded := C.jni_CallObjectMethodA(env, classLoader, loadClass, &args[0])
if err := androidJNIError(env, "load AndroidShare class"); err != nil {
return zeroClass, err
}
if C.jni_IsNull(loaded) != 0 {
return zeroClass, fmt.Errorf("load AndroidShare class returned nil")
}
return C.jclass(loaded), nil
}
func androidJNIError(env *C.JNIEnv, action string) error {
if thr := C.jni_ExceptionOccurred(env); C.jni_IsNull(C.jobject(thr)) == 0 {
C.jni_ExceptionClear(env)
return fmt.Errorf("%s: Java exception", action)
}
return nil
}
func androidJavaString(env *C.JNIEnv, s string) C.jstring {
chars := utf16.Encode([]rune(s))
if len(chars) == 0 {
return C.jni_NewString(env, nil, 0)
}
return C.jni_NewString(env, (*C.jchar)(unsafe.Pointer(unsafe.SliceData(chars))), C.jsize(len(chars)))
}
func cString(value string) *C.char {
return C.CString(value)
}
@@ -0,0 +1,13 @@
//go:build !android
package platform
import "log"
func NewVaultSharer(goos string) VaultSharer {
return nil
}
func LogInfo(tag, msg string) {
log.Printf("%s: %s", tag, msg)
}
+67
View File
@@ -0,0 +1,67 @@
package platform
import (
"io"
"strings"
"sync"
gioclipboard "gioui.org/io/clipboard"
"gioui.org/layout"
appclipboard "git.julianfamily.org/keepassgo/internal/clipboard"
)
type VaultSharer interface {
ShareVault(path, title string) error
}
type clipboardCommandWriter struct {
mu sync.Mutex
pending []string
invalidate func()
}
func NewClipboardWriter(goos string, invalidate func()) appclipboard.Writer {
if strings.EqualFold(goos, "android") {
return &clipboardCommandWriter{invalidate: invalidate}
}
return nil
}
func ProcessClipboardWrites(gtx layout.Context, writer appclipboard.Writer) {
commandWriter, ok := writer.(*clipboardCommandWriter)
if !ok {
return
}
commandWriter.Process(gtx)
}
func (w *clipboardCommandWriter) WriteText(text string) error {
w.mu.Lock()
w.pending = append(w.pending, text)
w.mu.Unlock()
if w.invalidate != nil {
w.invalidate()
}
return nil
}
func (w *clipboardCommandWriter) Process(gtx layout.Context) {
for _, text := range w.drain() {
gtx.Execute(gioclipboard.WriteCmd{
Type: "application/text",
Data: io.NopCloser(strings.NewReader(text)),
})
}
}
func (w *clipboardCommandWriter) drain() []string {
w.mu.Lock()
defer w.mu.Unlock()
if len(w.pending) == 0 {
return nil
}
pending := append([]string(nil), w.pending...)
w.pending = nil
return pending
}
@@ -0,0 +1,42 @@
package platform
import (
"slices"
"testing"
)
func TestNewPlatformClipboardWriterUsesCommandWriterOnAndroid(t *testing.T) {
t.Parallel()
writer := NewClipboardWriter("android", nil)
if _, ok := writer.(*clipboardCommandWriter); !ok {
t.Fatalf("NewClipboardWriter(android) = %T, want *clipboardCommandWriter", writer)
}
}
func TestNewPlatformClipboardWriterUsesSystemClipboardOffAndroid(t *testing.T) {
t.Parallel()
if writer := NewClipboardWriter("linux", nil); writer != nil {
t.Fatalf("NewClipboardWriter(linux) = %T, want nil", writer)
}
}
func TestClipboardCommandWriterDrainsQueuedWrites(t *testing.T) {
t.Parallel()
writer := &clipboardCommandWriter{}
if err := writer.WriteText("username"); err != nil {
t.Fatalf("WriteText(username) error = %v", err)
}
if err := writer.WriteText("password"); err != nil {
t.Fatalf("WriteText(password) error = %v", err)
}
if got := writer.drain(); !slices.Equal(got, []string{"username", "password"}) {
t.Fatalf("drain() = %v, want [username password]", got)
}
if got := writer.drain(); got != nil {
t.Fatalf("drain() after flush = %v, want nil", got)
}
}
File diff suppressed because it is too large Load Diff
+192
View File
@@ -0,0 +1,192 @@
package appui
import (
"flag"
"fmt"
"os"
"os/exec"
"runtime"
"strings"
"gioui.org/app"
"gioui.org/op"
"gioui.org/unit"
"gioui.org/x/explorer"
"git.julianfamily.org/keepassgo/internal/api"
"git.julianfamily.org/keepassgo/internal/apiapproval"
"git.julianfamily.org/keepassgo/internal/apitokens"
"git.julianfamily.org/keepassgo/internal/appui/platform"
"git.julianfamily.org/keepassgo/internal/passwords"
"git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/internal/vault"
)
func Main() {
mode := flag.String("mode", "", "window mode: desktop or phone")
stateDir := flag.String("state-dir", "", "directory for KeePassGO state such as recent-vault history and default save targets")
grpcAddr := flag.String("grpc-addr", "", "address for the local gRPC API listener; use 'off' to disable")
flag.Parse()
resolvedMode := resolveFlagOrEnv(*mode, "KEEPASSGO_MODE", defaultModeForRuntime(runtime.GOOS))
resolvedStateDir := resolveFlagOrEnv(*stateDir, "KEEPASSGO_STATE_DIR", "")
resolvedGRPCAddr := resolveFlagOrEnv(*grpcAddr, "KEEPASSGO_GRPC_ADDR", defaultGRPCAddr(runtime.GOOS))
width := unit.Dp(1180)
height := unit.Dp(760)
if strings.EqualFold(resolvedMode, "phone") {
width = unit.Dp(412)
height = unit.Dp(915)
}
go func() {
w := new(app.Window)
options := []app.Option{app.Title(productName)}
if shouldUsePreviewWindowSize(resolvedMode, runtime.GOOS) {
options = append(options, app.Size(width, height))
}
w.Option(options...)
if err := run(w, strings.ToLower(resolvedMode), defaultStatePaths(resolvedStateDir), resolvedGRPCAddr); err != nil {
panic(err)
}
if !strings.EqualFold(runtime.GOOS, "android") {
os.Exit(0)
}
}()
app.Main()
}
func defaultGRPCAddr(goos string) string {
if strings.EqualFold(strings.TrimSpace(goos), "android") {
return "off"
}
return "127.0.0.1:47777"
}
func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
var ops op.Ops
manager := &session.Manager{}
ui := newUIWithSession(mode, manager, paths)
ui.fileExplorer = explorer.NewExplorer(w)
ui.invalidate = w.Invalidate
ui.clipboardWriter = platform.NewClipboardWriter(runtime.GOOS, w.Invalidate)
host, err := api.StartHost(grpcAddr, manager, passwords.DefaultProfiles(), ui.clipboardWriter, func() bool { return ui.state.Dirty })
if err != nil {
ui.state.ErrorMessage = fmt.Sprintf("start gRPC API: %v", err)
} else if host != nil {
ui.apiHost = host
ui.auditLog = host.Server().AuditLog()
ui.state.AuditLog = ui.auditLog
ui.grpcAddress = host.Address()
ui.state.Approvals = &uiApprovalManager{server: host.Server()}
defer func() { _ = host.Stop() }()
}
for {
e := w.Event()
ui.fileExplorer.ListenEvents(e)
switch e := e.(type) {
case app.DestroyEvent:
return e.Err
case app.FrameEvent:
gtx := app.NewContext(&ops, e)
ui.processBackgroundActions()
ui.layout(gtx)
platform.ProcessClipboardWrites(gtx, ui.clipboardWriter)
e.Frame(gtx.Ops)
}
}
}
type uiApprovalManager struct {
server *api.Server
}
func (m *uiApprovalManager) Pending() []apiapproval.Request {
if m == nil || m.server == nil {
return nil
}
return m.server.ApprovalBroker().Pending()
}
func (m *uiApprovalManager) Resolve(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) {
if m == nil || m.server == nil {
return apiapproval.Request{}, nil, fmt.Errorf("approval manager is not configured")
}
return m.server.ResolveApproval(id, outcome)
}
type uiSession struct {
model vault.Model
locked bool
}
func (s *uiSession) HasVault() bool {
return len(s.model.Entries) > 0 || len(s.model.Templates) > 0 || len(s.model.RecycleBin) > 0 || len(s.model.Groups) > 0 || s.locked
}
func (s *uiSession) IsLocked() bool {
return s.locked
}
func (s *uiSession) IsRemote() bool {
return false
}
func (s *uiSession) Current() (vault.Model, error) {
if s.locked {
return vault.Model{}, session.ErrLocked
}
return s.model, nil
}
func (s *uiSession) Replace(model vault.Model) {
s.model = model
}
func (s *uiSession) Lock() error {
s.locked = true
return nil
}
func (s *uiSession) Unlock(vault.MasterKey) error {
if !s.locked {
return nil
}
s.locked = false
return nil
}
func pickExistingFile() (string, error) {
if path, err := runFilePicker("kdialog", "--getopenfilename", "--title", "Choose KeePass file"); err == nil {
return path, nil
}
if path, err := runFilePicker("zenity", "--file-selection", "--title=Choose KeePass file"); err == nil {
return path, nil
}
return "", fmt.Errorf("no supported file picker found; install kdialog or zenity")
}
func runFilePicker(name string, args ...string) (string, error) {
if _, err := exec.LookPath(name); err != nil {
return "", err
}
cmd := exec.Command(name, args...)
output, err := cmd.Output()
if err != nil {
return "", err
}
return parsePickedFilePath(output)
}
func parsePickedFilePath(output []byte) (string, error) {
lines := strings.Split(strings.ReplaceAll(string(output), "\r\n", "\n"), "\n")
for i := len(lines) - 1; i >= 0; i-- {
line := strings.TrimSpace(lines[i])
if line == "" {
continue
}
if strings.HasPrefix(line, "/") || strings.HasPrefix(line, "~/") {
return line, nil
}
}
return "", fmt.Errorf("file picker did not return a path")
}
+436
View File
@@ -0,0 +1,436 @@
package appui
import (
"encoding/json"
"fmt"
"image"
"image/color"
"os"
"path/filepath"
"strings"
"time"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
editormodel "git.julianfamily.org/keepassgo/internal/appui/editor"
headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout"
"git.julianfamily.org/keepassgo/internal/appui/platform"
settingsmodel "git.julianfamily.org/keepassgo/internal/appui/settings"
"git.julianfamily.org/keepassgo/internal/vault"
)
const (
displayDensityDense = settingsmodel.DisplayDensityDense
displayDensityComfortable = settingsmodel.DisplayDensityComfortable
contrastStandard = settingsmodel.ContrastStandard
contrastHigh = settingsmodel.ContrastHigh
keyboardFocusStandard = settingsmodel.KeyboardFocusStandard
keyboardFocusProminent = settingsmodel.KeyboardFocusProminent
)
type accessibilityPreferences = settingsmodel.AccessibilityPreferences
type settingsFile struct {
Sync syncSettings `json:"sync,omitempty"`
Debug debugSettings `json:"debug,omitempty"`
}
type syncSettings struct {
SourceDefault string `json:"sourceDefault,omitempty"`
DirectionDefault string `json:"directionDefault,omitempty"`
}
type debugSettings struct {
LogHeaderBounds bool `json:"logHeaderBounds,omitempty"`
}
type syncSettingsDraft struct {
SourceDefault syncSourceMode
DirectionDefault syncDirection
}
type settingsDraft struct {
Accessibility accessibilityPreferences
Sync syncSettingsDraft
Debug debugSettings
}
type legacySyncPreferences struct {
SyncSourceDefault string `json:"syncSourceDefault,omitempty"`
SyncDirectionDefault string `json:"syncDirectionDefault,omitempty"`
}
type choiceSpec struct {
Click *widget.Clickable
Label string
Active bool
}
type focusAppearance struct {
BorderColor color.NRGBA
OutlineColor color.NRGBA
OutlineWidth int
MinHeight int
}
func defaultAccessibilityPreferences() accessibilityPreferences {
return settingsmodel.DefaultAccessibilityPreferences()
}
func displayDensityForDenseLayout(dense bool) string {
return settingsmodel.DisplayDensityForDenseLayout(dense)
}
func normalizeAccessibilityPreferences(prefs accessibilityPreferences) accessibilityPreferences {
return settingsmodel.NormalizeAccessibilityPreferences(prefs)
}
func (u *ui) applyAccessibilityPreferences(prefs accessibilityPreferences) {
normalized := normalizeAccessibilityPreferences(prefs)
u.denseLayout = normalized.DisplayDensity == displayDensityDense
u.accessibilityPrefs = normalized
}
func fieldFocusAppearance(metric unit.Metric, prefs accessibilityPreferences, focused bool) focusAppearance {
prefs = normalizeAccessibilityPreferences(prefs)
appearance := focusAppearance{
BorderColor: color.NRGBA{R: 202, G: 194, B: 180, A: 255},
OutlineColor: color.NRGBA{A: 0},
OutlineWidth: max(1, metric.Dp(unit.Dp(1))),
MinHeight: metric.Dp(unit.Dp(44)),
}
if prefs.DisplayDensity == displayDensityComfortable {
appearance.MinHeight = metric.Dp(unit.Dp(52))
}
if prefs.Contrast == contrastHigh {
appearance.BorderColor = color.NRGBA{R: 108, G: 101, B: 90, A: 255}
}
if focused {
appearance.BorderColor = accentColor
appearance.OutlineColor = color.NRGBA{R: 28, G: 83, B: 63, A: 72}
appearance.OutlineWidth = max(2, metric.Dp(unit.Dp(2)))
if prefs.Contrast == contrastHigh {
appearance.BorderColor = color.NRGBA{R: 16, G: 60, B: 44, A: 255}
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 124}
}
if prefs.KeyboardFocus == keyboardFocusProminent {
appearance.OutlineWidth = max(3, metric.Dp(unit.Dp(3)))
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 148}
}
}
return appearance
}
func buttonFocusColors(prefs accessibilityPreferences, focused bool) (background color.NRGBA, text color.NRGBA) {
prefs = normalizeAccessibilityPreferences(prefs)
background = color.NRGBA{R: 231, G: 239, B: 235, A: 255}
text = accentColor
if prefs.Contrast == contrastHigh {
background = color.NRGBA{R: 225, G: 235, B: 230, A: 255}
text = color.NRGBA{R: 19, G: 57, B: 43, A: 255}
}
if focused {
background = color.NRGBA{R: 214, G: 229, B: 221, A: 255}
if prefs.Contrast == contrastHigh || prefs.KeyboardFocus == keyboardFocusProminent {
background = color.NRGBA{R: 202, G: 222, B: 212, A: 255}
}
}
return background, text
}
func (u *ui) accessibilityLabel(id focusID) string {
switch {
case id == focusSearch:
return "Search vault"
case strings.HasPrefix(string(id), "breadcrumb:"):
index := focusIndex(id)
crumbs := u.breadcrumbLabels()
if index >= 0 && index < len(crumbs) {
return fmt.Sprintf("Navigate to %s", crumbs[index])
}
case strings.HasPrefix(string(id), "list:"):
index := focusIndex(id)
if index >= 0 && index < len(u.visible) {
return fmt.Sprintf("Select entry %s", u.visible[index].Title)
}
case strings.HasPrefix(string(id), "detail:"):
name := strings.TrimPrefix(string(id), "detail:")
return fmt.Sprintf("Edit %s", detailFieldLabel(detailField(name)))
}
return ""
}
func drawFocusOutline(gtx layout.Context, appearance focusAppearance, size image.Point) layout.Dimensions {
if appearance.OutlineColor.A == 0 || appearance.OutlineWidth <= 0 {
return layout.Dimensions{Size: size}
}
width := appearance.OutlineWidth
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(size.X, width)}.Op())
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(0, size.Y-width), Max: image.Pt(size.X, size.Y)}.Op())
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(width, size.Y)}.Op())
paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(size.X-width, 0), Max: image.Pt(size.X, size.Y)}.Op())
return layout.Dimensions{Size: size}
}
func (u *ui) isFocused(id focusID) bool {
return u.keyboardFocus == id
}
func detailFieldLabel(field detailField) string {
return editormodel.Label(field)
}
func (u *ui) loadSettingsDraft() {
u.settingsDraft = settingsDraft{
Accessibility: accessibilityPreferences{
DisplayDensity: displayDensityForDenseLayout(u.denseLayout),
Contrast: u.accessibilityPrefs.Contrast,
ReducedMotion: u.accessibilityPrefs.ReducedMotion,
KeyboardFocus: u.accessibilityPrefs.KeyboardFocus,
},
Sync: syncSettingsDraft{
SourceDefault: u.syncDefaultSourceMode,
DirectionDefault: u.syncDefaultDirection,
},
Debug: debugSettings{
LogHeaderBounds: u.debugLogHeaderBounds,
},
}
u.settingsDebugHeaderBounds.Value = u.settingsDraft.Debug.LogHeaderBounds
}
func (u *ui) saveSecuritySettingsAction() error {
if err := u.applySecuritySettingsLive(); err != nil {
return err
}
u.securityDialogOpen = false
return nil
}
func (u *ui) applySecuritySettingsLive() error {
settings := vault.SecuritySettings{
Cipher: strings.TrimSpace(u.securityCipher.Text()),
KDF: strings.TrimSpace(u.securityKDF.Text()),
}
if err := u.state.ConfigureSecurity(settings); err != nil {
return err
}
if u.settingsDraft.Accessibility.DisplayDensity == displayDensityForDenseLayout(u.denseLayout) {
u.settingsDraft.Accessibility.DisplayDensity = displayDensityForDenseLayout(u.settingsDenseLayout.Value)
}
u.settingsDraft.Debug.LogHeaderBounds = u.settingsDebugHeaderBounds.Value
u.settingsDenseLayout.Value = u.settingsDraft.Accessibility.DisplayDensity == displayDensityDense
u.syncDefaultSourceMode = sanitizeSyncSourceMode(u.settingsDraft.Sync.SourceDefault)
u.syncDefaultDirection = sanitizeSyncDirection(u.settingsDraft.Sync.DirectionDefault)
u.debugLogHeaderBounds = u.settingsDraft.Debug.LogHeaderBounds
if !u.debugLogHeaderBounds {
u.lastHeaderBoundsLog = ""
}
u.applySettingsFormToPreferences()
u.applyAccessibilityPreferences(u.settingsDraft.Accessibility)
u.saveSettings()
u.saveUIPreferences()
return nil
}
func (u *ui) loadSettings() {
u.syncDefaultSourceMode = syncSourceLocal
u.syncDefaultDirection = syncDirectionPull
if strings.TrimSpace(u.settingsPath) != "" {
content, err := os.ReadFile(u.settingsPath)
if err == nil {
var settings settingsFile
if json.Unmarshal(content, &settings) == nil {
u.syncDefaultSourceMode = sanitizeSyncSourceMode(syncSourceMode(settings.Sync.SourceDefault))
u.syncDefaultDirection = sanitizeSyncDirection(syncDirection(settings.Sync.DirectionDefault))
u.debugLogHeaderBounds = settings.Debug.LogHeaderBounds
return
}
}
}
u.loadLegacySyncDefaultsFromUIPreferences()
}
func (u *ui) loadLegacySyncDefaultsFromUIPreferences() {
if strings.TrimSpace(u.uiPreferencesPath) == "" {
return
}
content, err := os.ReadFile(u.uiPreferencesPath)
if err != nil {
return
}
var prefs legacySyncPreferences
if err := json.Unmarshal(content, &prefs); err != nil {
return
}
u.syncDefaultSourceMode = sanitizeSyncSourceMode(syncSourceMode(prefs.SyncSourceDefault))
u.syncDefaultDirection = sanitizeSyncDirection(syncDirection(prefs.SyncDirectionDefault))
}
func (u *ui) saveSettings() {
if strings.TrimSpace(u.settingsPath) == "" {
return
}
if err := os.MkdirAll(filepath.Dir(u.settingsPath), 0o700); err != nil {
return
}
content, err := json.MarshalIndent(settingsFile{
Sync: syncSettings{
SourceDefault: string(u.syncDefaultSourceMode),
DirectionDefault: string(u.syncDefaultDirection),
},
Debug: debugSettings{
LogHeaderBounds: u.debugLogHeaderBounds,
},
}, "", " ")
if err != nil {
return
}
_ = os.WriteFile(u.settingsPath, content, 0o600)
}
func (u *ui) maybeLogHeaderBounds(bounds headerButtonBounds) {
if !u.debugLogHeaderBounds {
return
}
line := bounds.logLine(u.mode)
if line == u.lastHeaderBoundsLog {
return
}
platform.LogInfo("KeePassGO", line)
u.lastHeaderBoundsLog = line
}
func (u *ui) maybeLogHeaderMenuToggle(menu string, open bool) {
if !u.debugLogHeaderBounds {
return
}
platform.LogInfo("KeePassGO", fmt.Sprintf("keepassgo header-menu-toggle menu=%s open=%t", menu, open))
}
func (u *ui) maybeLogHeaderMenuPlacement(menu string, surface headerlayout.DropdownSurface, placement headerlayout.DropdownPlacement) {
if !u.debugLogHeaderBounds {
return
}
platform.LogInfo("KeePassGO", fmt.Sprintf(
"keepassgo header-menu-placement menu=%s anchor=%d,%d origin=%d,%d size=%dx%d container=%d inset=%d,%d",
menu,
placement.Anchor.TriggerRightX,
placement.Anchor.TriggerBottomY,
placement.Origin.X,
placement.Origin.Y,
placement.Size.X,
placement.Size.Y,
surface.ContainerWidth,
surface.LeftInset,
surface.TopInset,
))
}
func (u *ui) showStatusMessage(message string) {
u.state.StatusMessage = message
if u.accessibilityPrefs.ReducedMotion {
u.statusExpiresAt = time.Time{}
return
}
u.statusExpiresAt = u.now().Add(u.statusBannerTTL)
}
func (u *ui) settingsPreferenceCard(gtx layout.Context, title, detail string, body layout.Widget) layout.Dimensions {
return sectionCard(gtx, u.theme, title, detail, body)
}
func settingsSummaryCard(gtx layout.Context, th *material.Theme, title, body string) layout.Dimensions {
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(12), title)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(13), body)
lbl.Color = th.Palette.Fg
return lbl.Layout(gtx)
}),
)
})
}
func (u *ui) settingsChoiceRow(gtx layout.Context, choices ...choiceSpec) layout.Dimensions {
children := make([]layout.FlexChild, 0, len(choices)*2)
for i, choice := range choices {
if i > 0 {
children = append(children, layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout))
}
current := choice
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncChoiceButton(gtx, u.theme, current.Click, current.Label, current.Active)
}))
}
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, children...)
}
type listRowColors struct {
Title color.NRGBA
Meta color.NRGBA
Secondary color.NRGBA
Divider color.NRGBA
Fill color.NRGBA
Edge color.NRGBA
}
func (u *ui) listRowColors(selected, focused, recycleBin bool) listRowColors {
colors := listRowColors{
Title: accentColor,
Meta: color.NRGBA{R: 61, G: 60, B: 56, A: 255},
Secondary: mutedColor,
Divider: color.NRGBA{R: 225, G: 219, B: 210, A: 255},
Fill: color.NRGBA{R: 231, G: 239, B: 235, A: 255},
Edge: color.NRGBA{R: 69, G: 118, B: 97, A: 255},
}
if selected {
colors.Title = color.NRGBA{R: 19, G: 57, B: 43, A: 255}
colors.Meta = color.NRGBA{R: 31, G: 53, B: 44, A: 255}
colors.Secondary = color.NRGBA{R: 72, G: 88, B: 80, A: 255}
colors.Divider = color.NRGBA{R: 173, G: 196, B: 184, A: 255}
colors.Fill = color.NRGBA{R: 212, G: 228, B: 220, A: 255}
colors.Edge = color.NRGBA{R: 46, G: 106, B: 82, A: 255}
}
if recycleBin {
colors.Fill = color.NRGBA{R: 244, G: 229, B: 219, A: 255}
colors.Edge = color.NRGBA{R: 133, G: 65, B: 41, A: 255}
}
if focused && !selected {
colors.Meta = color.NRGBA{R: 49, G: 74, B: 63, A: 255}
colors.Secondary = color.NRGBA{R: 86, G: 102, B: 95, A: 255}
colors.Divider = color.NRGBA{R: 190, G: 208, B: 199, A: 255}
}
if u.accessibilityPrefs.Contrast == contrastHigh {
colors.Meta = color.NRGBA{R: 39, G: 39, B: 36, A: 255}
colors.Secondary = color.NRGBA{R: 58, G: 57, B: 52, A: 255}
if focused || selected {
colors.Fill = color.NRGBA{R: 211, G: 228, B: 219, A: 255}
colors.Edge = color.NRGBA{R: 16, G: 60, B: 44, A: 255}
}
}
if u.accessibilityPrefs.KeyboardFocus == keyboardFocusProminent && focused && !selected {
colors.Fill = color.NRGBA{R: 220, G: 234, B: 226, A: 255}
colors.Edge = color.NRGBA{R: 20, G: 74, B: 55, A: 255}
}
if recycleBin && (focused || selected) && u.accessibilityPrefs.Contrast == contrastHigh {
colors.Fill = color.NRGBA{R: 242, G: 223, B: 209, A: 255}
colors.Edge = color.NRGBA{R: 116, G: 43, B: 19, A: 255}
}
return colors
}
+50
View File
@@ -0,0 +1,50 @@
package settings
type AccessibilityPreferences struct {
DisplayDensity string
Contrast string
ReducedMotion bool
KeyboardFocus string
}
const (
DisplayDensityDense = "dense"
DisplayDensityComfortable = "comfortable"
ContrastStandard = "standard"
ContrastHigh = "high"
KeyboardFocusStandard = "standard"
KeyboardFocusProminent = "prominent"
)
func DefaultAccessibilityPreferences() AccessibilityPreferences {
return AccessibilityPreferences{
DisplayDensity: DisplayDensityDense,
Contrast: ContrastStandard,
KeyboardFocus: KeyboardFocusStandard,
}
}
func DisplayDensityForDenseLayout(dense bool) string {
if dense {
return DisplayDensityDense
}
return DisplayDensityComfortable
}
func NormalizeAccessibilityPreferences(prefs AccessibilityPreferences) AccessibilityPreferences {
normalized := DefaultAccessibilityPreferences()
switch prefs.DisplayDensity {
case DisplayDensityDense, DisplayDensityComfortable:
normalized.DisplayDensity = prefs.DisplayDensity
}
switch prefs.Contrast {
case ContrastStandard, ContrastHigh:
normalized.Contrast = prefs.Contrast
}
switch prefs.KeyboardFocus {
case KeyboardFocusStandard, KeyboardFocusProminent:
normalized.KeyboardFocus = prefs.KeyboardFocus
}
normalized.ReducedMotion = prefs.ReducedMotion
return normalized
}
+119
View File
@@ -0,0 +1,119 @@
package sync
import "git.julianfamily.org/keepassgo/internal/appstate"
type SourceMode string
const (
SourceLocal SourceMode = "local"
SourceRemote SourceMode = "remote"
)
type Direction string
const (
DirectionPull Direction = "pull"
DirectionPush Direction = "push"
)
type DialogPurpose string
const (
DialogPurposeAdvanced DialogPurpose = "advanced"
DialogPurposeRemoteSetup DialogPurpose = "remote-setup"
)
type MenuModel struct {
HasOpenVault bool
HasSelectedBinding bool
ShowSelectors bool
ShowShare bool
ShowSaveCurrentBinding bool
SavedBindingSummary MenuBindingSummary
RemoteBaseURL string
RemotePath string
RemoteUsername string
RemotePassword string
SelectedVaultSyncMode appstate.SyncMode
}
type MenuBindingSummary struct {
ProfileLabel string
CredentialLabel string
SyncLabel string
OK bool
}
func (m MenuModel) SavedBindingHeading() string {
if !m.ShowSelectors {
return "Use this vault's saved remote sync target"
}
return "Use a saved remote profile from this vault"
}
func (m MenuModel) OpenSelectedButtonLabel() string {
if !m.ShowSelectors {
return "Use Remote Sync"
}
return "Open Saved Remote"
}
func (m MenuModel) ShowDirectRemoteSyncShortcut() bool {
return m.HasOpenVault && m.HasSelectedBinding
}
func (m MenuModel) DirectRemoteSyncShortcutLabel() string { return "Use Remote Sync" }
func (m MenuModel) ShowRemoteSyncSettingsShortcut() bool {
return m.HasOpenVault && m.HasSelectedBinding
}
func (m MenuModel) RemoteSyncSettingsShortcutLabel() string { return "Remote Sync Settings" }
func (m MenuModel) ShowRemoveRemoteSyncShortcut() bool { return m.ShowRemoteSyncSettingsShortcut() }
func (m MenuModel) RemoveRemoteSyncShortcutLabel() string { return "Stop Using Remote Sync" }
func (m MenuModel) ShowRemoteSyncSetupShortcut() bool {
return m.HasOpenVault && !m.HasSelectedBinding
}
func (m MenuModel) RemoteSyncSetupShortcutLabel() string { return "Set Up Remote Sync" }
func (m MenuModel) ActionLabels() []string {
labels := []string{"Open Advanced Sync"}
if m.ShowRemoteSyncSetupShortcut() {
labels = append(labels, m.RemoteSyncSetupShortcutLabel())
}
if m.ShowDirectRemoteSyncShortcut() {
labels = append(labels, m.DirectRemoteSyncShortcutLabel())
}
if m.ShowRemoteSyncSettingsShortcut() {
labels = append(labels, m.RemoteSyncSettingsShortcutLabel())
}
if m.ShowRemoveRemoteSyncShortcut() {
labels = append(labels, m.RemoveRemoteSyncShortcutLabel())
}
return labels
}
func (m MenuModel) SaveCurrentRemoteBindingHeading() string {
return "Bind this local vault to the current remote target"
}
func (m MenuModel) SaveCurrentRemoteBindingButtonLabel() string { return "Save Remote In Vault" }
func SummaryText(purpose DialogPurpose, source SourceMode, direction Direction) string {
if purpose == DialogPurposeRemoteSetup {
return "Push this local vault to a WebDAV target and save that target for future sync."
}
sourceLabel := "another local vault file"
if source == SourceRemote {
sourceLabel = "another WebDAV-backed vault"
}
action := "Pull changes from"
if direction == DirectionPush {
action = "Push the current vault into"
}
return action + " " + sourceLabel + "."
}
+223
View File
@@ -0,0 +1,223 @@
package appui
import (
"image/color"
"runtime"
"strings"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
)
func (u *ui) syncDialog(gtx layout.Context) layout.Dimensions {
return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
paint.FillShape(gtx.Ops, color.NRGBA{A: 90}, clip.Rect{Max: gtx.Constraints.Max}.Op())
return layout.Dimensions{Size: gtx.Constraints.Max}
}),
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
width := gtx.Dp(unit.Dp(620))
if width > gtx.Constraints.Max.X {
width = gtx.Constraints.Max.X - gtx.Dp(unit.Dp(24))
}
if width < 1 {
width = gtx.Constraints.Max.X
}
gtx.Constraints.Min.X = width
gtx.Constraints.Max.X = width
return card(gtx, u.syncDialogContent)
})
}),
)
}
func (u *ui) syncDialogContent(gtx layout.Context) layout.Dimensions {
matchingCredentials := u.matchingAdvancedSyncRemoteCredentialEntries()
if len(u.syncRemoteCredentialClicks) < len(matchingCredentials) {
u.syncRemoteCredentialClicks = make([]widget.Clickable, len(matchingCredentials))
}
return material.List(u.theme, &u.syncDialogList).Layout(gtx, 1, func(gtx layout.Context, _ int) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(20), u.syncDialogTitle())
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(14), u.syncDialogDescription())
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !u.shouldShowSyncDirectionChoices() {
return layout.Dimensions{}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(syncDialogSectionLabel(u.theme, "Direction")),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncChoiceButton(gtx, u.theme, &u.showSyncPull, "Pull Into Current Vault", u.syncDirection == syncDirectionPull)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncChoiceButton(gtx, u.theme, &u.showSyncPush, "Push Current Vault Out", u.syncDirection == syncDirectionPush)
}),
)
}),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !u.shouldShowSyncDirectionChoices() {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(12)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !u.shouldShowSyncSourceChoices() {
return layout.Dimensions{}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(syncDialogSectionLabel(u.theme, "Other Source")),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncChoiceButton(gtx, u.theme, &u.showSyncLocal, "Local File", u.syncSourceMode == syncSourceLocal)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncChoiceButton(gtx, u.theme, &u.showSyncRemote, "Remote WebDAV", u.syncSourceMode == syncSourceRemote)
}),
)
}),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !u.shouldShowSyncSourceChoices() {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(12)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncDialogSummaryCard(gtx, u.theme, u.syncDialogPurpose, u.syncSourceMode, u.syncDirection)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.syncSourceMode == syncSourceRemote {
children := []layout.FlexChild{
layout.Rigid(labeledEditorHelp(u.theme, "Remote Base URL", "WebDAV base URL for the other source.", &u.syncRemoteBaseURL, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "Remote Path", "Path to the other remote .kdbx file.", &u.syncRemotePath, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "Remote Username", "Username for the other WebDAV source.", &u.syncRemoteUsername, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.syncPasswordField(gtx)
}),
}
if u.syncDialogPurpose == syncDialogPurposeRemoteSetup {
children = append(children,
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
check := material.CheckBox(u.theme, &u.syncSetupAutomatic, "Sync automatically on open and save")
check.Color = accentColor
return check.Layout(gtx)
}),
)
}
if len(matchingCredentials) > 0 {
children = append(children,
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), "Matching vault credentials")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
for i, entry := range matchingCredentials {
i := i
entry := entry
children = append(children,
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := entry.Title
if strings.TrimSpace(entry.Username) != "" {
label += " · " + strings.TrimSpace(entry.Username)
}
selected := strings.TrimSpace(u.selectedSyncRemoteCredentialEntryID) == entry.ID
return recentSelectionCard(gtx, selected, func(gtx layout.Context) layout.Dimensions {
return u.syncRemoteCredentialClicks[i].Layout(gtx, func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), label)
lbl.Color = accentColor
return lbl.Layout(gtx)
})
})
}),
)
}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
}
if supportsDesktopFilePicker(runtime.GOOS) {
return selectorEditorHelp(u.theme, "Local Vault Path", "Choose the other local .kdbx file to synchronize with.", &u.syncLocalPath, &u.pickSyncLocalPath, "Choose File", false)(gtx)
}
return labeledEditorHelp(u.theme, "Local Vault Path", "Enter the shared-storage path to the other local .kdbx file to synchronize with.", &u.syncLocalPath, false)(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.runAdvancedSync, u.syncDialogConfirmButtonLabel())
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.closeAdvancedSync, "Cancel")
}),
)
}),
)
})
}
func (u *ui) syncPasswordField(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "REMOTE PASSWORD")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
field := func(gtx layout.Context) layout.Dimensions {
editor := material.Editor(u.theme, &u.syncRemotePassword, "Remote Password")
editor.Color = u.theme.Palette.Fg
editor.HintColor = mutedColor
return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout)
}
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return u.outlinedFieldState(gtx, false, field)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.inlinePasswordToggle(gtx, &u.toggleSyncPassword, u.showSyncPassword)
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "Password or app token for the other WebDAV source.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
}

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -9,7 +9,7 @@ import (
"strings" "strings"
"time" "time"
"git.julianfamily.org/keepassgo/vault" "git.julianfamily.org/keepassgo/internal/vault"
) )
type Entry struct { type Entry struct {
@@ -7,7 +7,7 @@ import (
"testing" "testing"
"time" "time"
"git.julianfamily.org/keepassgo/vault" "git.julianfamily.org/keepassgo/internal/vault"
) )
func TestBuildFiltersAndNormalizesEntries(t *testing.T) { func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
@@ -22,7 +22,7 @@ func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
Username: "joe", Username: "joe",
Password: "secret", Password: "secret",
URL: "https://10.0.2.2:8443/login", URL: "https://10.0.2.2:8443/login",
Path: []string{"Joe", "Internet"}, Path: []string{"Crew", "Internet"},
}, },
{ {
ID: "two", ID: "two",
@@ -35,10 +35,10 @@ func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
Title: "Bare Host", Title: "Bare Host",
Username: "user", Username: "user",
Password: "pass", Password: "pass",
URL: "lights.julianfamily.org", URL: "surveillance.crew.example.invalid",
Fields: map[string]string{ Fields: map[string]string{
"AndroidApp1": "androidapp://com.lights.mobile", "AndroidApp1": "androidapp://com.lights.mobile",
"KP2A_URL_1": "https://lights.julianfamily.org/account", "KP2A_URL_1": "https://surveillance.crew.example.invalid/account",
}, },
}, },
}, },
@@ -50,8 +50,8 @@ func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
if got.Entries[0].Host != "10.0.2.2" { if got.Entries[0].Host != "10.0.2.2" {
t.Fatalf("first host = %q, want 10.0.2.2", got.Entries[0].Host) t.Fatalf("first host = %q, want 10.0.2.2", got.Entries[0].Host)
} }
if got.Entries[1].Host != "lights.julianfamily.org" { if got.Entries[1].Host != "surveillance.crew.example.invalid" {
t.Fatalf("second host = %q, want lights.julianfamily.org", got.Entries[1].Host) t.Fatalf("second host = %q, want surveillance.crew.example.invalid", got.Entries[1].Host)
} }
if len(got.Entries[1].Targets) != 3 { if len(got.Entries[1].Targets) != 3 {
t.Fatalf("len(second targets) = %d, want 3", len(got.Entries[1].Targets)) t.Fatalf("len(second targets) = %d, want 3", len(got.Entries[1].Targets))
@@ -137,21 +137,21 @@ func TestMatchRejectsAmbiguousSharedHost(t *testing.T) {
Title: "Host A", Title: "Host A",
Username: "first", Username: "first",
Password: "secret1", Password: "secret1",
URL: "https://lights.julianfamily.org/", URL: "https://surveillance.crew.example.invalid/",
Host: "lights.julianfamily.org", Host: "surveillance.crew.example.invalid",
}, },
{ {
ID: "two", ID: "two",
Title: "Host B", Title: "Host B",
Username: "second", Username: "second",
Password: "secret2", Password: "secret2",
URL: "https://lights.julianfamily.org/", URL: "https://surveillance.crew.example.invalid/",
Host: "lights.julianfamily.org", Host: "surveillance.crew.example.invalid",
}, },
}, },
} }
if _, ok := Match(cache, "https://lights.julianfamily.org/"); ok { if _, ok := Match(cache, "https://surveillance.crew.example.invalid/"); ok {
t.Fatalf("Match() unexpectedly resolved ambiguous shared host") t.Fatalf("Match() unexpectedly resolved ambiguous shared host")
} }
} }
@@ -5,7 +5,7 @@ import (
systemclipboard "github.com/atotto/clipboard" systemclipboard "github.com/atotto/clipboard"
"git.julianfamily.org/keepassgo/vault" "git.julianfamily.org/keepassgo/internal/vault"
) )
var ErrUnsupportedTarget = errors.New("unsupported clipboard target") var ErrUnsupportedTarget = errors.New("unsupported clipboard target")
@@ -4,7 +4,7 @@ import (
"errors" "errors"
"testing" "testing"
"git.julianfamily.org/keepassgo/vault" "git.julianfamily.org/keepassgo/internal/vault"
) )
func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) { func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
@@ -13,11 +13,11 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
}, },
}, },
} }
@@ -27,9 +27,9 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
target Target target Target
want string want string
}{ }{
{name: "username", target: TargetUsername, want: "joejulian"}, {name: "username", target: TargetUsername, want: "dannyocean"},
{name: "password", target: TargetPassword, want: "token-1"}, {name: "password", target: TargetPassword, want: "token-1"},
{name: "url", target: TargetURL, want: "https://git.julianfamily.org"}, {name: "url", target: TargetURL, want: "https://vault.crew.example.invalid"},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -37,7 +37,7 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
var writer memoryWriter var writer memoryWriter
service := Service{Writer: &writer} service := Service{Writer: &writer}
if err := service.Copy(model, "git-server", tt.target); err != nil { if err := service.Copy(model, "vault-console", tt.target); err != nil {
t.Fatalf("Copy() error = %v", err) t.Fatalf("Copy() error = %v", err)
} }
@@ -60,9 +60,9 @@ func TestServiceRejectsUnknownEntryAndUnsupportedTarget(t *testing.T) {
} }
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{{ID: "git-server", Username: "joejulian"}}, Entries: []vault.Entry{{ID: "vault-console", Username: "dannyocean"}},
} }
err = service.Copy(model, "git-server", Target("unsupported")) err = service.Copy(model, "vault-console", Target("unsupported"))
if !errors.Is(err, ErrUnsupportedTarget) { if !errors.Is(err, ErrUnsupportedTarget) {
t.Fatalf("Copy() unsupported target error = %v, want ErrUnsupportedTarget", err) t.Fatalf("Copy() unsupported target error = %v, want ErrUnsupportedTarget", err)
} }
@@ -74,11 +74,11 @@ func TestServiceSanitizesClipboardWriteErrors(t *testing.T) {
service := Service{Writer: failingWriter{err: errors.New("backend refused token-1")}} service := Service{Writer: failingWriter{err: errors.New("backend refused token-1")}}
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "git-server", Password: "token-1"}, {ID: "vault-console", Password: "token-1"},
}, },
} }
err := service.Copy(model, "git-server", TargetPassword) err := service.Copy(model, "vault-console", TargetPassword)
if !errors.Is(err, ErrWriteFailed) { if !errors.Is(err, ErrWriteFailed) {
t.Fatalf("Copy() write error = %v, want ErrWriteFailed", err) t.Fatalf("Copy() write error = %v, want ErrWriteFailed", err)
} }
@@ -4,14 +4,15 @@ import (
"bytes" "bytes"
"errors" "errors"
"fmt" "fmt"
"io"
"os" "os"
"path/filepath" "path/filepath"
"reflect" "reflect"
"slices" "slices"
"strings" "strings"
"git.julianfamily.org/keepassgo/vault" "git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/webdav" "git.julianfamily.org/keepassgo/internal/webdav"
) )
var ( var (
@@ -177,6 +178,18 @@ func (m *Manager) SynchronizeFromLocal(path string) error {
return m.persistMergedToCurrentSource(merged) return m.persistMergedToCurrentSource(merged)
} }
func (m *Manager) SynchronizeFromLocalBytes(name string, content []byte) error {
other, _, err := loadLocalSourceBytes(name, content, m.key)
if err != nil {
return err
}
merged, err := m.mergedWithPeer(other)
if err != nil {
return err
}
return m.persistMergedToCurrentSource(merged)
}
func (m *Manager) SynchronizeToLocal(path string) error { func (m *Manager) SynchronizeToLocal(path string) error {
other, config, err := loadLocalSourceOrEmpty(path, m.key) other, config, err := loadLocalSourceOrEmpty(path, m.key)
if err != nil { if err != nil {
@@ -908,6 +921,17 @@ func loadLocalSource(path string, key vault.MasterKey) (vault.Model, *vault.KDBX
return model, config, nil return model, config, nil
} }
func loadLocalSourceBytes(name string, content []byte, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
if len(content) == 0 {
return vault.Model{}, nil, fmt.Errorf("open %s for synchronize: %w", name, io.EOF)
}
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
if err != nil {
return vault.Model{}, nil, fmt.Errorf("decode %s for synchronize: %w", name, err)
}
return model, config, nil
}
func loadLocalSourceOrEmpty(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) { func loadLocalSourceOrEmpty(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
model, config, err := loadLocalSource(path, key) model, config, err := loadLocalSource(path, key)
if err == nil { if err == nil {
@@ -10,8 +10,8 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"git.julianfamily.org/keepassgo/vault" "git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/webdav" "git.julianfamily.org/keepassgo/internal/webdav"
"github.com/tobischo/gokeepasslib/v3" "github.com/tobischo/gokeepasslib/v3"
w "github.com/tobischo/gokeepasslib/v3/wrappers" w "github.com/tobischo/gokeepasslib/v3/wrappers"
) )
@@ -24,10 +24,10 @@ func TestCreateSaveAsLockAndUnlockRoundTripsVault(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -65,8 +65,8 @@ func TestCreateSaveAsLockAndUnlockRoundTripsVault(t *testing.T) {
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 1 || got[0].Title != "Git Server" || got[0].Password != "token-1" { if len(got) != 1 || got[0].Title != "Vault Console" || got[0].Password != "token-1" {
t.Fatalf("Current() entries = %#v, want persisted Git Server entry", got) t.Fatalf("Current() entries = %#v, want persisted Vault Console entry", got)
} }
} }
@@ -78,10 +78,10 @@ func TestOpenLoadsExistingKDBXFromDisk(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Home Assistant (Codex)", Title: "Surveillance Console",
Username: "codex", Username: "codex",
Password: "token-2", Password: "token-2",
URL: "https://lights.julianfamily.org", URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"}, Path: []string{"Root", "Home Assistant"},
}, },
}, },
@@ -124,10 +124,10 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -146,10 +146,10 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
updated := model updated := model
updated.UpsertEntry(vault.Entry{ updated.UpsertEntry(vault.Entry{
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-2", Password: "token-2",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}) })
sess.Replace(updated) sess.Replace(updated)
@@ -186,19 +186,19 @@ func TestSaveReparentsMixedPathsUnderSingleVaultRoot(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"keepass", "Joe", "Internet"}, Path: []string{"keepass", "Crew", "Internet"},
}, },
{ {
ID: "entry-2", ID: "entry-2",
Title: "Mail", Title: "Mail",
Username: "joejulian", Username: "dannyocean",
Password: "token-2", Password: "token-2",
URL: "https://mail.julianfamily.org", URL: "https://dispatch.crew.example.invalid",
Path: []string{"keepass", "Joe", "eMail"}, Path: []string{"keepass", "Crew", "eMail"},
}, },
}, },
}, key.Password); err != nil { }, key.Password); err != nil {
@@ -217,8 +217,8 @@ func TestSaveReparentsMixedPathsUnderSingleVaultRoot(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Current() error = %v", err) t.Fatalf("Current() error = %v", err)
} }
current.Entries[0].Path = []string{"Joe", "Internet"} current.Entries[0].Path = []string{"Crew", "Internet"}
current.Groups = append(current.Groups, []string{"Joe"}, []string{"Joe", "Internet"}, []string{"Joe", "eMail"}) current.Groups = append(current.Groups, []string{"Crew"}, []string{"Crew", "Internet"}, []string{"Crew", "eMail"})
sess.Replace(current) sess.Replace(current)
if err := sess.Save(); err != nil { if err := sess.Save(); err != nil {
@@ -244,8 +244,8 @@ func TestSaveReparentsMixedPathsUnderSingleVaultRoot(t *testing.T) {
t.Fatalf("top-level groups = %#v, want single keepass root", db.Content.Root.Groups) t.Fatalf("top-level groups = %#v, want single keepass root", db.Content.Root.Groups)
} }
rootGroups := db.Content.Root.Groups[0].Groups rootGroups := db.Content.Root.Groups[0].Groups
if len(rootGroups) != 1 || rootGroups[0].Name != "Joe" { if len(rootGroups) != 1 || rootGroups[0].Name != "Crew" {
t.Fatalf("keepass child groups = %#v, want single Joe group", rootGroups) t.Fatalf("keepass child groups = %#v, want single Crew group", rootGroups)
} }
} }
@@ -271,10 +271,10 @@ func TestOpenRemoteLoadsExistingKDBXFromWebDAV(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -309,7 +309,7 @@ func TestOpenRemoteLoadsExistingKDBXFromWebDAV(t *testing.T) {
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 1 || got[0].Password != "token-1" { if len(got) != 1 || got[0].Password != "token-1" {
t.Fatalf("Current() entries = %#v, want Git Server entry from remote vault", got) t.Fatalf("Current() entries = %#v, want Vault Console entry from remote vault", got)
} }
} }
@@ -321,10 +321,10 @@ func TestSaveRemotePersistsEditsBackToWebDAV(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Home Assistant (Codex)", Title: "Surveillance Console",
Username: "codex", Username: "codex",
Password: "token-1", Password: "token-1",
URL: "https://lights.julianfamily.org", URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"}, Path: []string{"Root", "Home Assistant"},
}, },
}, },
@@ -371,10 +371,10 @@ func TestSaveRemotePersistsEditsBackToWebDAV(t *testing.T) {
} }
current.UpsertEntry(vault.Entry{ current.UpsertEntry(vault.Entry{
ID: "entry-1", ID: "entry-1",
Title: "Home Assistant (Codex)", Title: "Surveillance Console",
Username: "codex", Username: "codex",
Password: "token-2", Password: "token-2",
URL: "https://lights.julianfamily.org", URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"}, Path: []string{"Root", "Home Assistant"},
}) })
sess.Replace(current) sess.Replace(current)
@@ -406,10 +406,10 @@ func TestSaveUsesRemoteTargetWhenVaultWasOpenedFromWebDAV(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -448,10 +448,10 @@ func TestSaveUsesRemoteTargetWhenVaultWasOpenedFromWebDAV(t *testing.T) {
} }
current.UpsertEntry(vault.Entry{ current.UpsertEntry(vault.Entry{
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-2", Password: "token-2",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}) })
sess.Replace(current) sess.Replace(current)
@@ -477,10 +477,10 @@ func TestChangeMasterKeyReencryptsSavedAndLockedVault(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -514,8 +514,8 @@ func TestChangeMasterKeyReencryptsSavedAndLockedVault(t *testing.T) {
t.Fatalf("Current() error = %v", err) t.Fatalf("Current() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 1 || got[0].Title != "Git Server" { if len(got) != 1 || got[0].Title != "Vault Console" {
t.Fatalf("Current() entries = %#v, want Git Server entry after ChangeMasterKey", got) t.Fatalf("Current() entries = %#v, want Vault Console entry after ChangeMasterKey", got)
} }
var reopened Manager var reopened Manager
@@ -540,10 +540,10 @@ func TestSavePreservesOpenedKDBXSecuritySettings(t *testing.T) {
{ {
UUID: gokeepasslib.NewUUID(), UUID: gokeepasslib.NewUUID(),
Values: []gokeepasslib.ValueData{ Values: []gokeepasslib.ValueData{
{Key: "Title", Value: gokeepasslib.V{Content: "Git Server"}}, {Key: "Title", Value: gokeepasslib.V{Content: "Vault Console"}},
{Key: "UserName", Value: gokeepasslib.V{Content: "joejulian"}}, {Key: "UserName", Value: gokeepasslib.V{Content: "dannyocean"}},
{Key: "Password", Value: gokeepasslib.V{Content: "token-1", Protected: w.NewBoolWrapper(true)}}, {Key: "Password", Value: gokeepasslib.V{Content: "token-1", Protected: w.NewBoolWrapper(true)}},
{Key: "URL", Value: gokeepasslib.V{Content: "https://git.julianfamily.org"}}, {Key: "URL", Value: gokeepasslib.V{Content: "https://vault.crew.example.invalid"}},
}, },
}, },
}, },
@@ -577,10 +577,10 @@ func TestSavePreservesOpenedKDBXSecuritySettings(t *testing.T) {
} }
current.UpsertEntry(vault.Entry{ current.UpsertEntry(vault.Entry{
ID: current.Entries[0].ID, ID: current.Entries[0].ID,
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-2", Password: "token-2",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: current.Entries[0].Path, Path: current.Entries[0].Path,
}) })
sess.Replace(current) sess.Replace(current)
@@ -620,10 +620,10 @@ func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-2", Password: "token-2",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
Attachments: map[string][]byte{ Attachments: map[string][]byte{
"token.txt": []byte("secret attachment contents"), "token.txt": []byte("secret attachment contents"),
@@ -631,10 +631,10 @@ func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
History: []vault.Entry{ History: []vault.Entry{
{ {
ID: "entry-1-history-1", ID: "entry-1-history-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -778,10 +778,10 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -841,10 +841,10 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
} }
firstCurrent.UpsertEntry(vault.Entry{ firstCurrent.UpsertEntry(vault.Entry{
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "remote-token-2", Password: "remote-token-2",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Notes: "updated remotely first", Notes: "updated remotely first",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}) })
@@ -859,10 +859,10 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
} }
secondCurrent.UpsertEntry(vault.Entry{ secondCurrent.UpsertEntry(vault.Entry{
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "local-token-2", Password: "local-token-2",
URL: "https://git.julianfamily.org/settings/tokens", URL: "https://vault.crew.example.invalid/security/badges",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}) })
second.Replace(secondCurrent) second.Replace(secondCurrent)
@@ -883,7 +883,7 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
if len(got) != 1 { if len(got) != 1 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got)) t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got))
} }
if got[0].Password != "local-token-2" || got[0].URL != "https://git.julianfamily.org/settings/tokens" { if got[0].Password != "local-token-2" || got[0].URL != "https://vault.crew.example.invalid/security/badges" {
t.Fatalf("entry after synchronize = %#v, want local winning version", got[0]) t.Fatalf("entry after synchronize = %#v, want local winning version", got[0])
} }
if len(got[0].History) == 0 { if len(got[0].History) == 0 {
@@ -905,10 +905,10 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-current", ID: "entry-current",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-current", Password: "token-current",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -917,10 +917,10 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-other", ID: "entry-other",
Title: "Dynadot", Title: "Bellagio",
Username: "jjulian", Username: "rustyryan",
Password: "token-other", Password: "token-other",
URL: "https://www.dynadot.com", URL: "https://bellagio.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -953,6 +953,63 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
} }
} }
func TestSynchronizeFromLocalBytesMergesOtherVaultIntoCurrentSource(t *testing.T) {
t.Parallel()
key := vault.MasterKey{Password: "correct horse battery staple"}
currentPath := filepath.Join(t.TempDir(), "current.kdbx")
currentModel := vault.Model{
Entries: []vault.Entry{{
ID: "entry-current",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-current",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
}},
}
otherModel := vault.Model{
Entries: []vault.Entry{{
ID: "entry-other",
Title: "Bellagio",
Username: "rustyryan",
Password: "token-other",
URL: "https://bellagio.example.invalid",
Path: []string{"Root", "Internet"},
}},
}
writeKDBXTestFile(t, currentPath, currentModel, key)
var other bytes.Buffer
if err := vault.SaveKDBX(&other, otherModel, key.Password); err != nil {
t.Fatalf("SaveKDBX(other) error = %v", err)
}
var sess Manager
if err := sess.Open(currentPath, key); err != nil {
t.Fatalf("Open(current) error = %v", err)
}
if err := sess.SynchronizeFromLocalBytes("picked-other.kdbx", other.Bytes()); err != nil {
t.Fatalf("SynchronizeFromLocalBytes() error = %v", err)
}
var reopened Manager
if err := reopened.Open(currentPath, key); err != nil {
t.Fatalf("reopen Open(current) error = %v", err)
}
current, err := reopened.Current()
if err != nil {
t.Fatalf("reopened Current() error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
}
}
func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) { func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
t.Parallel() t.Parallel()
@@ -964,10 +1021,10 @@ func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-current", ID: "entry-current",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-current", Password: "token-current",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -976,10 +1033,10 @@ func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-other", ID: "entry-other",
Title: "Dynadot", Title: "Bellagio",
Username: "jjulian", Username: "rustyryan",
Password: "token-other", Password: "token-other",
URL: "https://www.dynadot.com", URL: "https://bellagio.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -1021,10 +1078,10 @@ func TestSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-current", ID: "entry-current",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-current", Password: "token-current",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -1033,10 +1090,10 @@ func TestSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-remote", ID: "entry-remote",
Title: "Dynadot", Title: "Bellagio",
Username: "jjulian", Username: "rustyryan",
Password: "token-remote", Password: "token-remote",
URL: "https://www.dynadot.com", URL: "https://bellagio.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -8,11 +8,11 @@ func TestUpsertEntryPreservesPreviousVersionInHistory(t *testing.T) {
model := Model{ model := Model{
Entries: []Entry{ Entries: []Entry{
{ {
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "old-token", Password: "old-token",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Notes: "Original note", Notes: "Original note",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
@@ -20,11 +20,11 @@ func TestUpsertEntryPreservesPreviousVersionInHistory(t *testing.T) {
} }
model.UpsertEntry(Entry{ model.UpsertEntry(Entry{
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "new-token", Password: "new-token",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Notes: "Updated note", Notes: "Updated note",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}) })
@@ -53,17 +53,17 @@ func TestDeleteEntryMovesItToRecycleBin(t *testing.T) {
model := Model{ model := Model{
Entries: []Entry{ Entries: []Entry{
{ {
ID: "ha-codex", ID: "surveillance-console",
Title: "Home Assistant (Codex)", Title: "Surveillance Console",
Username: "codex", Username: "codex",
Password: "token-2", Password: "token-2",
URL: "https://lights.julianfamily.org", URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"}, Path: []string{"Root", "Home Assistant"},
}, },
}, },
} }
if err := model.DeleteEntry("ha-codex"); err != nil { if err := model.DeleteEntry("surveillance-console"); err != nil {
t.Fatalf("DeleteEntry() error = %v", err) t.Fatalf("DeleteEntry() error = %v", err)
} }
@@ -75,8 +75,8 @@ func TestDeleteEntryMovesItToRecycleBin(t *testing.T) {
t.Fatalf("len(RecycleBin) = %d, want 1", len(model.RecycleBin)) t.Fatalf("len(RecycleBin) = %d, want 1", len(model.RecycleBin))
} }
if model.RecycleBin[0].Title != "Home Assistant (Codex)" { if model.RecycleBin[0].Title != "Surveillance Console" {
t.Fatalf("RecycleBin[0].Title = %q, want %q", model.RecycleBin[0].Title, "Home Assistant (Codex)") t.Fatalf("RecycleBin[0].Title = %q, want %q", model.RecycleBin[0].Title, "Surveillance Console")
} }
} }
@@ -86,17 +86,17 @@ func TestRestoreEntryMovesItBackFromRecycleBin(t *testing.T) {
model := Model{ model := Model{
RecycleBin: []Entry{ RecycleBin: []Entry{
{ {
ID: "dynadot", ID: "bellagio",
Title: "Dynadot", Title: "Bellagio",
Username: "jjulian", Username: "rustyryan",
Password: "token-3", Password: "token-3",
URL: "https://www.dynadot.com", URL: "https://bellagio.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
} }
if err := model.RestoreEntry("dynadot"); err != nil { if err := model.RestoreEntry("bellagio"); err != nil {
t.Fatalf("RestoreEntry() error = %v", err) t.Fatalf("RestoreEntry() error = %v", err)
} }
@@ -105,8 +105,8 @@ func TestRestoreEntryMovesItBackFromRecycleBin(t *testing.T) {
t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got)) t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got))
} }
if got[0].Title != "Dynadot" { if got[0].Title != "Bellagio" {
t.Fatalf("EntriesInPath()[0].Title = %q, want %q", got[0].Title, "Dynadot") t.Fatalf("EntriesInPath()[0].Title = %q, want %q", got[0].Title, "Bellagio")
} }
if len(model.RecycleBin) != 0 { if len(model.RecycleBin) != 0 {
@@ -120,17 +120,17 @@ func TestRestoreEntryVersionPromotesHistoricalVersionAndRetainsCurrentInHistory(
model := Model{ model := Model{
Entries: []Entry{ Entries: []Entry{
{ {
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "new-token", Password: "new-token",
Notes: "Current note", Notes: "Current note",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
History: []Entry{ History: []Entry{
{ {
ID: "git-server-history-1", ID: "vault-console-history-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "old-token", Password: "old-token",
Notes: "Previous note", Notes: "Previous note",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
@@ -140,7 +140,7 @@ func TestRestoreEntryVersionPromotesHistoricalVersionAndRetainsCurrentInHistory(
}, },
} }
if err := model.RestoreEntryVersion("git-server", 0); err != nil { if err := model.RestoreEntryVersion("vault-console", 0); err != nil {
t.Fatalf("RestoreEntryVersion() error = %v", err) t.Fatalf("RestoreEntryVersion() error = %v", err)
} }
+37
View File
@@ -3,6 +3,7 @@ package vault
import ( import (
"crypto/rand" "crypto/rand"
"crypto/sha256" "crypto/sha256"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -26,6 +27,7 @@ const (
templatesRoot = "Templates" templatesRoot = "Templates"
recycleBinRoot = "Recycle Bin" recycleBinRoot = "Recycle Bin"
keepassGOIDField = "KeePassGO-ID" keepassGOIDField = "KeePassGO-ID"
remoteProfilesKey = "keepassgo.remoteProfiles"
) )
func LoadKDBX(r io.Reader, password string) (Model, error) { func LoadKDBX(r io.Reader, password string) (Model, error) {
@@ -49,6 +51,7 @@ func SaveKDBXWithConfigAndKey(wr io.Writer, model Model, key MasterKey, config *
db := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4()) db := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4())
db.Credentials = credentials db.Credentials = credentials
db.Content.Meta = gokeepasslib.NewMetaData() db.Content.Meta = gokeepasslib.NewMetaData()
db.Content.Meta.CustomData = customDataForModel(model)
db.Content.Root = &gokeepasslib.RootData{} db.Content.Root = &gokeepasslib.RootData{}
if config != nil && config.Header != nil { if config != nil && config.Header != nil {
db.Header = cloneHeader(config.Header) db.Header = cloneHeader(config.Header)
@@ -325,6 +328,7 @@ func LoadKDBXWithConfig(r io.Reader, key MasterKey) (Model, *KDBXConfig, error)
for _, group := range db.Content.Root.Groups { for _, group := range db.Content.Root.Groups {
appendGroupEntries(&model, db, group, nil) appendGroupEntries(&model, db, group, nil)
} }
model.RemoteProfiles = remoteProfilesFromMeta(db.Content.Meta)
return model, &KDBXConfig{ return model, &KDBXConfig{
Header: cloneHeader(db.Header), Header: cloneHeader(db.Header),
@@ -332,6 +336,39 @@ func LoadKDBXWithConfig(r io.Reader, key MasterKey) (Model, *KDBXConfig, error)
}, nil }, nil
} }
func customDataForModel(model Model) []gokeepasslib.CustomData {
if len(model.RemoteProfiles) == 0 {
return nil
}
content, err := json.Marshal(model.RemoteProfiles)
if err != nil {
return nil
}
return []gokeepasslib.CustomData{{
Key: remoteProfilesKey,
Value: string(content),
}}
}
func remoteProfilesFromMeta(meta *gokeepasslib.MetaData) []RemoteProfile {
if meta == nil {
return nil
}
for _, item := range meta.CustomData {
if item.Key != remoteProfilesKey {
continue
}
var profiles []RemoteProfile
if err := json.Unmarshal([]byte(item.Value), &profiles); err != nil {
return nil
}
return profiles
}
return nil
}
func newCredentials(key MasterKey) (*gokeepasslib.DBCredentials, error) { func newCredentials(key MasterKey) (*gokeepasslib.DBCredentials, error) {
switch { switch {
case key.Password != "" && len(key.KeyFileData) > 0: case key.Password != "" && len(key.KeyFileData) > 0:
@@ -22,11 +22,11 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
Groups: []gokeepasslib.Group{ Groups: []gokeepasslib.Group{
mustGroup("Root", mustGroup("Root",
mustGroup("Internet", mustGroup("Internet",
mustEntry("Dynadot", "jjulian", "https://www.dynadot.com", "hunter2"), mustEntry("Bellagio", "rustyryan", "https://bellagio.example.invalid", "hunter2"),
mustEntry("Git Server", "joejulian", "https://git.julianfamily.org", "token-1"), mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "bellagio-pass-1"),
), ),
mustGroup("Home Assistant", mustGroup("Security Office",
mustEntry("Home Assistant (Codex)", "codex", "https://lights.julianfamily.org", "token-2"), mustEntry("Surveillance Console", "bashertarr", "https://surveillance.crew.example.invalid", "bellagio-pass-2"),
), ),
), ),
}, },
@@ -53,24 +53,24 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
t.Fatalf("len(EntriesInPath()) = %d, want 2", len(got)) t.Fatalf("len(EntriesInPath()) = %d, want 2", len(got))
} }
if got[0].Title != "Dynadot" || got[0].Username != "jjulian" || got[0].URL != "https://www.dynadot.com" { if got[0].Title != "Bellagio" || got[0].Username != "rustyryan" || got[0].URL != "https://bellagio.example.invalid" {
t.Fatalf("unexpected first entry: %#v", got[0]) t.Fatalf("unexpected first entry: %#v", got[0])
} }
if got[1].Title != "Git Server" || got[1].Username != "joejulian" || got[1].URL != "https://git.julianfamily.org" { if got[1].Title != "Vault Console" || got[1].Username != "dannyocean" || got[1].URL != "https://vault.crew.example.invalid" {
t.Fatalf("unexpected second entry: %#v", got[1]) t.Fatalf("unexpected second entry: %#v", got[1])
} }
groups := model.ChildGroups([]string{"Root"}) groups := model.ChildGroups([]string{"Root"})
if len(groups) != 2 || groups[0] != "Home Assistant" || groups[1] != "Internet" { if len(groups) != 2 || groups[0] != "Internet" || groups[1] != "Security Office" {
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", groups) t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", groups)
} }
} }
func TestLoadKDBXPreservesEntryDetails(t *testing.T) { func TestLoadKDBXPreservesEntryDetails(t *testing.T) {
t.Parallel() t.Parallel()
entry := mustEntry("Home Assistant (Codex)", "codex", "https://lights.julianfamily.org", "token-2") entry := mustEntry("Surveillance Console", "bashertarr", "https://surveillance.crew.example.invalid", "bellagio-pass-2")
entry.Tags = "automation; home" entry.Tags = "automation; home"
entry.Values = append(entry.Values, entry.Values = append(entry.Values,
mkValue("Notes", "Long-lived token used by Codex for home automation tasks."), mkValue("Notes", "Long-lived token used by Codex for home automation tasks."),
@@ -84,7 +84,7 @@ func TestLoadKDBXPreservesEntryDetails(t *testing.T) {
Meta: gokeepasslib.NewMetaData(), Meta: gokeepasslib.NewMetaData(),
Root: &gokeepasslib.RootData{ Root: &gokeepasslib.RootData{
Groups: []gokeepasslib.Group{ Groups: []gokeepasslib.Group{
mustGroup("Root", mustGroup("Home Assistant", entry)), mustGroup("Root", mustGroup("Security Office", entry)),
}, },
}, },
}, },
@@ -104,13 +104,13 @@ func TestLoadKDBXPreservesEntryDetails(t *testing.T) {
t.Fatalf("LoadKDBX failed: %v", err) t.Fatalf("LoadKDBX failed: %v", err)
} }
got := model.EntriesInPath([]string{"Root", "Home Assistant"}) got := model.EntriesInPath([]string{"Root", "Security Office"})
if len(got) != 1 { if len(got) != 1 {
t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got)) t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got))
} }
if got[0].Password != "token-2" { if got[0].Password != "bellagio-pass-2" {
t.Fatalf("Entry.Password = %q, want %q", got[0].Password, "token-2") t.Fatalf("Entry.Password = %q, want %q", got[0].Password, "bellagio-pass-2")
} }
if got[0].Notes != "Long-lived token used by Codex for home automation tasks." { if got[0].Notes != "Long-lived token used by Codex for home automation tasks." {
@@ -133,10 +133,10 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
Entries: []Entry{ Entries: []Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "bellagio-pass-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Notes: "Personal git server token entry used for automation and CLI auth.", Notes: "Personal git server token entry used for automation and CLI auth.",
Tags: []string{"git", "infra"}, Tags: []string{"git", "infra"},
Fields: map[string]string{ Fields: map[string]string{
@@ -146,13 +146,13 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
}, },
{ {
ID: "entry-2", ID: "entry-2",
Title: "Home Assistant (Codex)", Title: "Surveillance Console",
Username: "codex", Username: "bashertarr",
Password: "token-2", Password: "bellagio-pass-2",
URL: "https://lights.julianfamily.org", URL: "https://surveillance.crew.example.invalid",
Notes: "Long-lived token used by Codex for home automation tasks.", Notes: "Long-lived token used by Codex for home automation tasks.",
Tags: []string{"automation", "home"}, Tags: []string{"automation", "home"},
Path: []string{"Root", "Home Assistant"}, Path: []string{"Root", "Security Office"},
}, },
}, },
} }
@@ -167,7 +167,7 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
t.Fatalf("LoadKDBX() error = %v", err) t.Fatalf("LoadKDBX() error = %v", err)
} }
got := loaded.Search("git") got := loaded.Search("vault")
if len(got) != 1 { if len(got) != 1 {
t.Fatalf("len(Search(\"git\")) = %d, want 1", len(got)) t.Fatalf("len(Search(\"git\")) = %d, want 1", len(got))
} }
@@ -180,13 +180,13 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
t.Fatalf("Search(\"git\") X-Role = %q, want %q", got[0].Entry.Fields["X-Role"], "automation") t.Fatalf("Search(\"git\") X-Role = %q, want %q", got[0].Entry.Fields["X-Role"], "automation")
} }
homeAssistant := loaded.EntriesInPath([]string{"Root", "Home Assistant"}) homeAssistant := loaded.EntriesInPath([]string{"Root", "Security Office"})
if len(homeAssistant) != 1 { if len(homeAssistant) != 1 {
t.Fatalf("len(EntriesInPath(Home Assistant)) = %d, want 1", len(homeAssistant)) t.Fatalf("len(EntriesInPath(Security Office)) = %d, want 1", len(homeAssistant))
} }
if homeAssistant[0].Password != "token-2" { if homeAssistant[0].Password != "bellagio-pass-2" {
t.Fatalf("Home Assistant password = %q, want %q", homeAssistant[0].Password, "token-2") t.Fatalf("Security Office password = %q, want %q", homeAssistant[0].Password, "bellagio-pass-2")
} }
} }
@@ -238,6 +238,50 @@ func TestSaveKDBXRoundTripsTemplates(t *testing.T) {
} }
} }
func TestSaveKDBXRoundTripsRemoteProfiles(t *testing.T) {
t.Parallel()
model := Model{
RemoteProfiles: []RemoteProfile{
{
ID: "bellagio-webdav",
Name: "Bellagio Vault",
Backend: RemoteBackendWebDAV,
BaseURL: "https://dav.example.invalid/remote.php/dav",
Path: "files/bellagio/keepass.kdbx",
},
},
}
var encoded bytes.Buffer
if err := SaveKDBX(&encoded, model, "correct horse battery staple"); err != nil {
t.Fatalf("SaveKDBX() error = %v", err)
}
loaded, err := LoadKDBX(bytes.NewReader(encoded.Bytes()), "correct horse battery staple")
if err != nil {
t.Fatalf("LoadKDBX() error = %v", err)
}
if len(loaded.RemoteProfiles) != 1 {
t.Fatalf("len(RemoteProfiles) = %d, want 1", len(loaded.RemoteProfiles))
}
got := loaded.RemoteProfiles[0]
if got.ID != "bellagio-webdav" || got.Name != "Bellagio Vault" {
t.Fatalf("loaded remote profile = %#v, want bellagio-webdav Bellagio Vault", got)
}
if got.Backend != RemoteBackendWebDAV {
t.Fatalf("remote backend = %q, want %q", got.Backend, RemoteBackendWebDAV)
}
if got.BaseURL != "https://dav.example.invalid/remote.php/dav" {
t.Fatalf("remote base URL = %q, want remote.php/dav URL", got.BaseURL)
}
if got.Path != "files/bellagio/keepass.kdbx" {
t.Fatalf("remote path = %q, want files/bellagio/keepass.kdbx", got.Path)
}
}
func TestSaveKDBXRoundTripsEntryHistory(t *testing.T) { func TestSaveKDBXRoundTripsEntryHistory(t *testing.T) {
t.Parallel() t.Parallel()
@@ -245,18 +289,18 @@ func TestSaveKDBXRoundTripsEntryHistory(t *testing.T) {
Entries: []Entry{ Entries: []Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "new-token", Password: "new-token",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
History: []Entry{ History: []Entry{
{ {
ID: "entry-1-old", ID: "entry-1-old",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "old-token", Password: "old-token",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
Notes: "Original version", Notes: "Original version",
}, },
@@ -296,11 +340,11 @@ func TestSaveKDBXRoundTripsRecycleBinEntries(t *testing.T) {
RecycleBin: []Entry{ RecycleBin: []Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Home Assistant (Codex)", Title: "Surveillance Console",
Username: "codex", Username: "bashertarr",
Password: "token-2", Password: "bellagio-pass-2",
URL: "https://lights.julianfamily.org", URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"}, Path: []string{"Root", "Security Office"},
}, },
}, },
} }
@@ -319,12 +363,12 @@ func TestSaveKDBXRoundTripsRecycleBinEntries(t *testing.T) {
t.Fatalf("len(RecycleBin) = %d, want 1", len(loaded.RecycleBin)) t.Fatalf("len(RecycleBin) = %d, want 1", len(loaded.RecycleBin))
} }
if loaded.RecycleBin[0].Title != "Home Assistant (Codex)" { if loaded.RecycleBin[0].Title != "Surveillance Console" {
t.Fatalf("RecycleBin[0].Title = %q, want %q", loaded.RecycleBin[0].Title, "Home Assistant (Codex)") t.Fatalf("RecycleBin[0].Title = %q, want %q", loaded.RecycleBin[0].Title, "Surveillance Console")
} }
if len(loaded.RecycleBin[0].Path) != 2 || loaded.RecycleBin[0].Path[0] != "Root" || loaded.RecycleBin[0].Path[1] != "Home Assistant" { if len(loaded.RecycleBin[0].Path) != 2 || loaded.RecycleBin[0].Path[0] != "Root" || loaded.RecycleBin[0].Path[1] != "Security Office" {
t.Fatalf("RecycleBin[0].Path = %v, want [Root Home Assistant]", loaded.RecycleBin[0].Path) t.Fatalf("RecycleBin[0].Path = %v, want [Root Security Office]", loaded.RecycleBin[0].Path)
} }
if len(loaded.Entries) != 0 { if len(loaded.Entries) != 0 {
@@ -358,7 +402,7 @@ func TestLoadKDBXWithKeyFileCredentials(t *testing.T) {
Meta: gokeepasslib.NewMetaData(), Meta: gokeepasslib.NewMetaData(),
Root: &gokeepasslib.RootData{ Root: &gokeepasslib.RootData{
Groups: []gokeepasslib.Group{ Groups: []gokeepasslib.Group{
mustGroup("Root", mustGroup("Internet", mustEntry("Git Server", "joejulian", "https://git.julianfamily.org", "token-1"))), mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "bellagio-pass-1"))),
}, },
}, },
}, },
@@ -378,9 +422,9 @@ func TestLoadKDBXWithKeyFileCredentials(t *testing.T) {
t.Fatalf("LoadKDBXWithKey() error = %v", err) t.Fatalf("LoadKDBXWithKey() error = %v", err)
} }
got := model.Search("git") got := model.Search("vault")
if len(got) != 1 || got[0].Entry.Password != "token-1" { if len(got) != 1 || got[0].Entry.Password != "bellagio-pass-1" {
t.Fatalf("LoadKDBXWithKey() = %#v, want password-preserving git entry", got) t.Fatalf("LoadKDBXWithKey() = %#v, want password-preserving vault entry", got)
} }
} }
@@ -413,7 +457,7 @@ func TestLoadKDBXWithCompositeCredentials(t *testing.T) {
Meta: gokeepasslib.NewMetaData(), Meta: gokeepasslib.NewMetaData(),
Root: &gokeepasslib.RootData{ Root: &gokeepasslib.RootData{
Groups: []gokeepasslib.Group{ Groups: []gokeepasslib.Group{
mustGroup("Root", mustGroup("Home Assistant", mustEntry("Home Assistant (Codex)", "codex", "https://lights.julianfamily.org", "token-2"))), mustGroup("Root", mustGroup("Security Office", mustEntry("Surveillance Console", "bashertarr", "https://surveillance.crew.example.invalid", "bellagio-pass-2"))),
}, },
}, },
}, },
@@ -436,9 +480,9 @@ func TestLoadKDBXWithCompositeCredentials(t *testing.T) {
t.Fatalf("LoadKDBXWithKey() error = %v", err) t.Fatalf("LoadKDBXWithKey() error = %v", err)
} }
got := model.EntriesInPath([]string{"Root", "Home Assistant"}) got := model.EntriesInPath([]string{"Root", "Security Office"})
if len(got) != 1 || got[0].Password != "token-2" { if len(got) != 1 || got[0].Password != "bellagio-pass-2" {
t.Fatalf("LoadKDBXWithKey() = %#v, want Home Assistant entry with password", got) t.Fatalf("LoadKDBXWithKey() = %#v, want Security Office entry with password", got)
} }
} }
@@ -452,7 +496,7 @@ func TestLoadKDBXReturnsInvalidCredentialsError(t *testing.T) {
Meta: gokeepasslib.NewMetaData(), Meta: gokeepasslib.NewMetaData(),
Root: &gokeepasslib.RootData{ Root: &gokeepasslib.RootData{
Groups: []gokeepasslib.Group{ Groups: []gokeepasslib.Group{
mustGroup("Root", mustGroup("Internet", mustEntry("Git Server", "joejulian", "https://git.julianfamily.org", "token-1"))), mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "bellagio-pass-1"))),
}, },
}, },
}, },
@@ -490,11 +534,11 @@ func TestSaveKDBXWithKeyRoundTripsModel(t *testing.T) {
model := Model{ model := Model{
Entries: []Entry{ Entries: []Entry{
{ {
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "bellagio-pass-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -510,9 +554,9 @@ func TestSaveKDBXWithKeyRoundTripsModel(t *testing.T) {
t.Fatalf("LoadKDBXWithKey() error = %v", err) t.Fatalf("LoadKDBXWithKey() error = %v", err)
} }
got := loaded.Search("git") got := loaded.Search("vault")
if len(got) != 1 || got[0].Entry.Password != "token-1" { if len(got) != 1 || got[0].Entry.Password != "bellagio-pass-1" {
t.Fatalf("round-trip with key file = %#v, want git entry with password", got) t.Fatalf("round-trip with key file = %#v, want vault entry with password", got)
} }
} }
@@ -533,12 +577,12 @@ func TestSaveKDBXWithCompositeKeyRoundTripsModel(t *testing.T) {
model := Model{ model := Model{
Entries: []Entry{ Entries: []Entry{
{ {
ID: "ha-codex", ID: "surveillance-console",
Title: "Home Assistant (Codex)", Title: "Surveillance Console",
Username: "codex", Username: "bashertarr",
Password: "token-2", Password: "bellagio-pass-2",
URL: "https://lights.julianfamily.org", URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"}, Path: []string{"Root", "Security Office"},
}, },
}, },
} }
@@ -558,9 +602,9 @@ func TestSaveKDBXWithCompositeKeyRoundTripsModel(t *testing.T) {
t.Fatalf("LoadKDBXWithKey() error = %v", err) t.Fatalf("LoadKDBXWithKey() error = %v", err)
} }
got := loaded.EntriesInPath([]string{"Root", "Home Assistant"}) got := loaded.EntriesInPath([]string{"Root", "Security Office"})
if len(got) != 1 || got[0].Password != "token-2" { if len(got) != 1 || got[0].Password != "bellagio-pass-2" {
t.Fatalf("composite key round-trip = %#v, want Home Assistant entry with password", got) t.Fatalf("composite key round-trip = %#v, want Security Office entry with password", got)
} }
} }
@@ -570,11 +614,11 @@ func TestKDBXRoundTripsEntryAttachments(t *testing.T) {
model := Model{ model := Model{
Entries: []Entry{ Entries: []Entry{
{ {
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "bellagio-pass-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
Attachments: map[string][]byte{ Attachments: map[string][]byte{
"token.txt": []byte("secret attachment contents"), "token.txt": []byte("secret attachment contents"),
@@ -610,10 +654,10 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
Entries: []Entry{ Entries: []Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-2", Password: "bellagio-pass-2",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Notes: "Current credential", Notes: "Current credential",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
Attachments: map[string][]byte{ Attachments: map[string][]byte{
@@ -622,10 +666,10 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
History: []Entry{ History: []Entry{
{ {
ID: "entry-1-history-1", ID: "entry-1-history-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "bellagio-pass-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Notes: "Original credential", Notes: "Original credential",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
@@ -8,6 +8,21 @@ import (
var ErrEntryNotFound = errors.New("entry not found") var ErrEntryNotFound = errors.New("entry not found")
var ErrGroupNotEmpty = errors.New("group is not empty") var ErrGroupNotEmpty = errors.New("group is not empty")
var ErrRemoteProfileNotFound = errors.New("remote profile not found")
type RemoteBackend string
const (
RemoteBackendWebDAV RemoteBackend = "webdav"
)
type RemoteProfile struct {
ID string
Name string
Backend RemoteBackend
BaseURL string
Path string
}
type Entry struct { type Entry struct {
ID string ID string
@@ -33,6 +48,7 @@ type Model struct {
Templates []Entry Templates []Entry
RecycleBin []Entry RecycleBin []Entry
Groups [][]string Groups [][]string
RemoteProfiles []RemoteProfile
} }
func (m Model) ChildGroups(path []string) []string { func (m Model) ChildGroups(path []string) []string {
@@ -168,6 +184,57 @@ func (m *Model) UpsertEntry(entry Entry) {
m.Entries = append(m.Entries, cloneEntry(entry)) m.Entries = append(m.Entries, cloneEntry(entry))
} }
func (m *Model) RemoveEntryByID(id string) bool {
for i := range m.Entries {
if m.Entries[i].ID != id {
continue
}
m.Entries = append(m.Entries[:i], m.Entries[i+1:]...)
return true
}
return false
}
func (m *Model) EntryByID(id string) (Entry, error) {
for _, entry := range m.Entries {
if entry.ID == id {
return cloneEntry(entry), nil
}
}
return Entry{}, ErrEntryNotFound
}
func (m *Model) UpsertRemoteProfile(profile RemoteProfile) {
for i := range m.RemoteProfiles {
if m.RemoteProfiles[i].ID != profile.ID {
continue
}
m.RemoteProfiles[i] = profile
return
}
m.RemoteProfiles = append(m.RemoteProfiles, profile)
}
func (m *Model) RemoveRemoteProfileByID(id string) bool {
for i := range m.RemoteProfiles {
if m.RemoteProfiles[i].ID != id {
continue
}
m.RemoteProfiles = append(m.RemoteProfiles[:i], m.RemoteProfiles[i+1:]...)
return true
}
return false
}
func (m Model) RemoteProfileByID(id string) (RemoteProfile, error) {
for _, profile := range m.RemoteProfiles {
if profile.ID == id {
return profile, nil
}
}
return RemoteProfile{}, ErrRemoteProfileNotFound
}
func (m *Model) UpsertTemplate(entry Entry) { func (m *Model) UpsertTemplate(entry Entry) {
for i := range m.Templates { for i := range m.Templates {
if m.Templates[i].ID != entry.ID { if m.Templates[i].ID != entry.ID {
@@ -9,9 +9,9 @@ import (
func testModel() Model { func testModel() Model {
return Model{ return Model{
Entries: []Entry{ Entries: []Entry{
{ID: "1", Title: "Dynadot", Username: "jjulian", URL: "https://www.dynadot.com", Path: []string{"Joe", "Internet"}}, {ID: "1", Title: "Bellagio", Username: "rustyryan", URL: "https://bellagio.example.invalid", Path: []string{"Crew", "Internet"}},
{ID: "2", Title: "Git Server", Username: "joejulian", URL: "https://git.julianfamily.org", Path: []string{"Joe", "Internet"}}, {ID: "2", Title: "Vault Console", Username: "dannyocean", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
{ID: "3", Title: "Home Assistant (Codex)", Username: "codex", URL: "https://lights.julianfamily.org", Path: []string{"Joe", "Home Assistant"}}, {ID: "3", Title: "Surveillance Console", Username: "codex", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
{ID: "4", Title: "Alma (WA Prep)", Username: "christina.julian", URL: "https://waprep.getalma.com", Path: []string{"Tricia", "School"}}, {ID: "4", Title: "Alma (WA Prep)", Username: "christina.julian", URL: "https://waprep.getalma.com", Path: []string{"Tricia", "School"}},
}, },
} }
@@ -20,7 +20,7 @@ func testModel() Model {
func TestChildGroupsReturnsImmediateGroupsOnly(t *testing.T) { func TestChildGroupsReturnsImmediateGroupsOnly(t *testing.T) {
model := testModel() model := testModel()
got := model.ChildGroups([]string{"Joe"}) got := model.ChildGroups([]string{"Crew"})
want := []string{"Home Assistant", "Internet"} want := []string{"Home Assistant", "Internet"}
if !slices.Equal(got, want) { if !slices.Equal(got, want) {
@@ -31,12 +31,12 @@ func TestChildGroupsReturnsImmediateGroupsOnly(t *testing.T) {
func TestEntriesInPathReturnsOnlyDirectEntries(t *testing.T) { func TestEntriesInPathReturnsOnlyDirectEntries(t *testing.T) {
model := testModel() model := testModel()
got := model.EntriesInPath([]string{"Joe", "Internet"}) got := model.EntriesInPath([]string{"Crew", "Internet"})
if len(got) != 2 { if len(got) != 2 {
t.Fatalf("len(EntriesInPath()) = %d, want 2", len(got)) t.Fatalf("len(EntriesInPath()) = %d, want 2", len(got))
} }
if got[0].Title != "Dynadot" || got[1].Title != "Git Server" { if got[0].Title != "Bellagio" || got[1].Title != "Vault Console" {
t.Fatalf("EntriesInPath() titles = %q, %q", got[0].Title, got[1].Title) t.Fatalf("EntriesInPath() titles = %q, %q", got[0].Title, got[1].Title)
} }
} }
@@ -45,29 +45,29 @@ func TestEntriesUnderPathReturnsDescendantEntries(t *testing.T) {
t.Parallel() t.Parallel()
model := testModel() model := testModel()
got := model.EntriesUnderPath([]string{"Joe"}) got := model.EntriesUnderPath([]string{"Crew"})
if len(got) != 3 { if len(got) != 3 {
t.Fatalf("len(EntriesUnderPath(Joe)) = %d, want 3", len(got)) t.Fatalf("len(EntriesUnderPath(Crew)) = %d, want 3", len(got))
} }
if got[0].Title != "Dynadot" || got[1].Title != "Git Server" || got[2].Title != "Home Assistant (Codex)" { if got[0].Title != "Bellagio" || got[1].Title != "Surveillance Console" || got[2].Title != "Vault Console" {
t.Fatalf("EntriesUnderPath(Joe) titles = %q, %q, %q", got[0].Title, got[1].Title, got[2].Title) t.Fatalf("EntriesUnderPath(Crew) titles = %q, %q, %q", got[0].Title, got[1].Title, got[2].Title)
} }
} }
func TestSearchReturnsMatchesWithFullPathContext(t *testing.T) { func TestSearchReturnsMatchesWithFullPathContext(t *testing.T) {
model := testModel() model := testModel()
got := model.Search("git") got := model.Search("vault")
if len(got) != 1 { if len(got) != 1 {
t.Fatalf("len(Search()) = %d, want 1", len(got)) t.Fatalf("len(Search()) = %d, want 1", len(got))
} }
if got[0].Entry.Title != "Git Server" { if got[0].Entry.Title != "Vault Console" {
t.Fatalf("Search() title = %q, want %q", got[0].Entry.Title, "Git Server") t.Fatalf("Search() title = %q, want %q", got[0].Entry.Title, "Vault Console")
} }
if got[0].Path != "Joe / Internet" { if got[0].Path != "Crew / Internet" {
t.Fatalf("Search() path = %q, want %q", got[0].Path, "Joe / Internet") t.Fatalf("Search() path = %q, want %q", got[0].Path, "Crew / Internet")
} }
} }
@@ -119,11 +119,11 @@ func TestInstantiateTemplateCreatesNormalEntryWithOverrides(t *testing.T) {
entry, err := model.InstantiateTemplate("tpl-1", Entry{ entry, err := model.InstantiateTemplate("tpl-1", Entry{
ID: "entry-1", ID: "entry-1",
Title: "Dynadot", Title: "Bellagio",
Username: "jjulian", Username: "rustyryan",
Password: "hunter2", Password: "hunter2",
URL: "https://www.dynadot.com", URL: "https://bellagio.example.invalid",
Path: []string{"Joe", "Internet"}, Path: []string{"Crew", "Internet"},
Tags: []string{"dns"}, Tags: []string{"dns"},
}) })
if err != nil { if err != nil {
@@ -134,11 +134,11 @@ func TestInstantiateTemplateCreatesNormalEntryWithOverrides(t *testing.T) {
t.Fatalf("entry.ID = %q, want %q", entry.ID, "entry-1") t.Fatalf("entry.ID = %q, want %q", entry.ID, "entry-1")
} }
if entry.Title != "Dynadot" { if entry.Title != "Bellagio" {
t.Fatalf("entry.Title = %q, want %q", entry.Title, "Dynadot") t.Fatalf("entry.Title = %q, want %q", entry.Title, "Bellagio")
} }
if entry.Username != "jjulian" || entry.Password != "hunter2" || entry.URL != "https://www.dynadot.com" { if entry.Username != "rustyryan" || entry.Password != "hunter2" || entry.URL != "https://bellagio.example.invalid" {
t.Fatalf("entry credentials = %#v, want override values", entry) t.Fatalf("entry credentials = %#v, want override values", entry)
} }
@@ -154,9 +154,9 @@ func TestInstantiateTemplateCreatesNormalEntryWithOverrides(t *testing.T) {
t.Fatalf("entry.Fields[Environment] = %q, want %q", entry.Fields["Environment"], "prod") t.Fatalf("entry.Fields[Environment] = %q, want %q", entry.Fields["Environment"], "prod")
} }
got := model.EntriesInPath([]string{"Joe", "Internet"}) got := model.EntriesInPath([]string{"Crew", "Internet"})
if len(got) != 1 || got[0].Title != "Dynadot" { if len(got) != 1 || got[0].Title != "Bellagio" {
t.Fatalf("EntriesInPath() = %#v, want instantiated Dynadot entry", got) t.Fatalf("EntriesInPath() = %#v, want instantiated Bellagio entry", got)
} }
} }
@@ -178,7 +178,7 @@ func TestDeleteTemplateRemovesTemplateWithoutTouchingEntries(t *testing.T) {
model := Model{ model := Model{
Entries: []Entry{ Entries: []Entry{
{ID: "entry-1", Title: "Git Server", Path: []string{"Root", "Internet"}}, {ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
}, },
Templates: []Entry{ Templates: []Entry{
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates"}}, {ID: "tpl-1", Title: "Website Login", Path: []string{"Templates"}},
@@ -222,8 +222,8 @@ func TestDuplicateEntryCopiesEntryWithNewIDAndTitle(t *testing.T) {
Entries: []Entry{ Entries: []Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
@@ -238,8 +238,8 @@ func TestDuplicateEntryCopiesEntryWithNewIDAndTitle(t *testing.T) {
if duplicate.ID != "entry-2" { if duplicate.ID != "entry-2" {
t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "entry-2") t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "entry-2")
} }
if duplicate.Title != "Git Server (Copy)" { if duplicate.Title != "Vault Console (Copy)" {
t.Fatalf("duplicate.Title = %q, want %q", duplicate.Title, "Git Server (Copy)") t.Fatalf("duplicate.Title = %q, want %q", duplicate.Title, "Vault Console (Copy)")
} }
got := model.EntriesInPath([]string{"Root", "Internet"}) got := model.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 2 { if len(got) != 2 {
@@ -250,9 +250,9 @@ func TestDuplicateEntryCopiesEntryWithNewIDAndTitle(t *testing.T) {
func TestCreateGroupMakesItVisibleAsChildGroup(t *testing.T) { func TestCreateGroupMakesItVisibleAsChildGroup(t *testing.T) {
model := testModel() model := testModel()
model.CreateGroup([]string{"Joe"}, "Finance") model.CreateGroup([]string{"Crew"}, "Finance")
got := model.ChildGroups([]string{"Joe"}) got := model.ChildGroups([]string{"Crew"})
want := []string{"Finance", "Home Assistant", "Internet"} want := []string{"Finance", "Home Assistant", "Internet"}
if !slices.Equal(got, want) { if !slices.Equal(got, want) {
t.Fatalf("ChildGroups() = %v, want %v", got, want) t.Fatalf("ChildGroups() = %v, want %v", got, want)
@@ -263,32 +263,32 @@ func TestCreateGroupSupportsNestedRelativePath(t *testing.T) {
t.Parallel() t.Parallel()
model := testModel() model := testModel()
model.CreateGroup([]string{"Joe"}, "Infrastructure / Prod") model.CreateGroup([]string{"Crew"}, "Infrastructure / Prod")
got := model.ChildGroups([]string{"Joe"}) got := model.ChildGroups([]string{"Crew"})
if !slices.Equal(got, []string{"Home Assistant", "Infrastructure", "Internet"}) { if !slices.Equal(got, []string{"Home Assistant", "Infrastructure", "Internet"}) {
t.Fatalf("ChildGroups(Joe) = %v, want [Home Assistant Infrastructure Internet]", got) t.Fatalf("ChildGroups(Crew) = %v, want [Home Assistant Infrastructure Internet]", got)
} }
got = model.ChildGroups([]string{"Joe", "Infrastructure"}) got = model.ChildGroups([]string{"Crew", "Infrastructure"})
if !slices.Equal(got, []string{"Prod"}) { if !slices.Equal(got, []string{"Prod"}) {
t.Fatalf("ChildGroups(Joe/Infrastructure) = %v, want [Prod]", got) t.Fatalf("ChildGroups(Crew/Infrastructure) = %v, want [Prod]", got)
} }
} }
func TestRenameGroupMovesEntriesAndKeepsHierarchy(t *testing.T) { func TestRenameGroupMovesEntriesAndKeepsHierarchy(t *testing.T) {
model := testModel() model := testModel()
if err := model.RenameGroup([]string{"Joe", "Internet"}, "Infra"); err != nil { if err := model.RenameGroup([]string{"Crew", "Internet"}, "Infra"); err != nil {
t.Fatalf("RenameGroup() error = %v", err) t.Fatalf("RenameGroup() error = %v", err)
} }
got := model.EntriesInPath([]string{"Joe", "Infra"}) got := model.EntriesInPath([]string{"Crew", "Infra"})
if len(got) != 2 { if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Joe/Infra)) = %d, want 2", len(got)) t.Fatalf("len(EntriesInPath(Crew/Infra)) = %d, want 2", len(got))
} }
if len(model.EntriesInPath([]string{"Joe", "Internet"})) != 0 { if len(model.EntriesInPath([]string{"Crew", "Internet"})) != 0 {
t.Fatal("EntriesInPath(Joe/Internet) should be empty after rename") t.Fatal("EntriesInPath(Crew/Internet) should be empty after rename")
} }
} }
@@ -309,8 +309,8 @@ func TestMoveGroupMovesEntriesAndNestedGroups(t *testing.T) {
t.Parallel() t.Parallel()
model := testModel() model := testModel()
model.CreateGroup([]string{"Joe", "Internet"}, "Infrastructure") model.CreateGroup([]string{"Crew", "Internet"}, "Infrastructure")
if err := model.MoveGroup([]string{"Joe", "Internet"}, []string{"Tricia"}); err != nil { if err := model.MoveGroup([]string{"Crew", "Internet"}, []string{"Tricia"}); err != nil {
t.Fatalf("MoveGroup() error = %v", err) t.Fatalf("MoveGroup() error = %v", err)
} }
@@ -318,8 +318,8 @@ func TestMoveGroupMovesEntriesAndNestedGroups(t *testing.T) {
if len(got) != 2 { if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Tricia/Internet)) = %d, want 2", len(got)) t.Fatalf("len(EntriesInPath(Tricia/Internet)) = %d, want 2", len(got))
} }
if len(model.EntriesInPath([]string{"Joe", "Internet"})) != 0 { if len(model.EntriesInPath([]string{"Crew", "Internet"})) != 0 {
t.Fatal("EntriesInPath(Joe/Internet) should be empty after move") t.Fatal("EntriesInPath(Crew/Internet) should be empty after move")
} }
gotGroups := model.ChildGroups([]string{"Tricia", "Internet"}) gotGroups := model.ChildGroups([]string{"Tricia", "Internet"})
if !slices.Equal(gotGroups, []string{"Infrastructure"}) { if !slices.Equal(gotGroups, []string{"Infrastructure"}) {
@@ -330,12 +330,12 @@ func TestMoveGroupMovesEntriesAndNestedGroups(t *testing.T) {
func TestDeleteEmptyGroupRemovesItFromNavigation(t *testing.T) { func TestDeleteEmptyGroupRemovesItFromNavigation(t *testing.T) {
model := testModel() model := testModel()
model.CreateGroup([]string{"Joe"}, "Finance") model.CreateGroup([]string{"Crew"}, "Finance")
if err := model.DeleteGroup([]string{"Joe", "Finance"}); err != nil { if err := model.DeleteGroup([]string{"Crew", "Finance"}); err != nil {
t.Fatalf("DeleteGroup() error = %v", err) t.Fatalf("DeleteGroup() error = %v", err)
} }
got := model.ChildGroups([]string{"Joe"}) got := model.ChildGroups([]string{"Crew"})
want := []string{"Home Assistant", "Internet"} want := []string{"Home Assistant", "Internet"}
if !slices.Equal(got, want) { if !slices.Equal(got, want) {
t.Fatalf("ChildGroups() = %v, want %v", got, want) t.Fatalf("ChildGroups() = %v, want %v", got, want)
@@ -15,8 +15,8 @@ func TestClientOpenDownloadsRemoteVault(t *testing.T) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
t.Fatalf("method = %s, want GET", r.Method) t.Fatalf("method = %s, want GET", r.Method)
} }
if user, pass, ok := r.BasicAuth(); !ok || user != "jjulian" || pass != "secret" { if user, pass, ok := r.BasicAuth(); !ok || user != "rustyryan" || pass != "secret" {
t.Fatalf("basic auth = %q/%q ok=%v, want jjulian/secret true", user, pass, ok) t.Fatalf("basic auth = %q/%q ok=%v, want rustyryan/secret true", user, pass, ok)
} }
w.Header().Set("ETag", `"etag-1"`) w.Header().Set("ETag", `"etag-1"`)
_, _ = io.WriteString(w, "vault-bytes") _, _ = io.WriteString(w, "vault-bytes")
@@ -26,7 +26,7 @@ func TestClientOpenDownloadsRemoteVault(t *testing.T) {
client := Client{ client := Client{
HTTPClient: server.Client(), HTTPClient: server.Client(),
BaseURL: server.URL, BaseURL: server.URL,
Username: "jjulian", Username: "rustyryan",
Password: "secret", Password: "secret",
} }
@@ -69,7 +69,7 @@ func TestClientSaveUploadsVaultWithIfMatch(t *testing.T) {
client := Client{ client := Client{
HTTPClient: server.Client(), HTTPClient: server.Client(),
BaseURL: server.URL, BaseURL: server.URL,
Username: "jjulian", Username: "rustyryan",
Password: "secret", Password: "secret",
} }
@@ -94,7 +94,7 @@ func TestClientSaveReturnsConflictOnVersionMismatch(t *testing.T) {
client := Client{ client := Client{
HTTPClient: server.Client(), HTTPClient: server.Client(),
BaseURL: server.URL, BaseURL: server.URL,
Username: "jjulian", Username: "rustyryan",
Password: "secret", Password: "secret",
} }
-5173
View File
File diff suppressed because it is too large Load Diff
-4664
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,26 @@
pkgbase = keepassgo-git
pkgdesc = KeePass-compatible password manager written in Go
pkgver = r165.1c72a50
pkgrel = 1
url = https://git.julianfamily.org/joejulian/keepassgo
arch = x86_64
arch = aarch64
license = custom
makedepends = git
makedepends = go
makedepends = pkgconf
depends = glibc
depends = hicolor-icon-theme
depends = libx11
depends = libxcursor
depends = libxfixes
depends = libxkbcommon
depends = libxkbcommon-x11
depends = mesa
depends = wayland
provides = keepassgo
conflicts = keepassgo
source = git+https://git.julianfamily.org/joejulian/keepassgo.git
sha256sums = SKIP
pkgname = keepassgo-git
@@ -0,0 +1,59 @@
pkgname=keepassgo-git
pkgver=@PKGVER@
pkgrel=1
pkgdesc='KeePass-compatible password manager written in Go'
arch=('x86_64' 'aarch64')
url='https://git.julianfamily.org/joejulian/keepassgo'
license=('custom')
depends=(
'glibc'
'hicolor-icon-theme'
'libx11'
'libxcursor'
'libxfixes'
'libxkbcommon'
'libxkbcommon-x11'
'mesa'
'wayland'
)
makedepends=(
'git'
'go'
'pkgconf'
)
provides=('keepassgo')
conflicts=('keepassgo')
source=('git+https://git.julianfamily.org/joejulian/keepassgo.git')
sha256sums=('SKIP')
_repo_dir() {
printf '%s\n' "@REPO_DIR@"
}
pkgver() {
cd "$(_repo_dir)"
printf 'r%s.%s' "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
}
build() {
cd "$(_repo_dir)"
export CGO_ENABLED=1
export GOFLAGS="-trimpath"
local app_version
app_version="$(git describe --tags --always --dirty)"
go build -ldflags "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=${app_version}" -o keepassgo ./cmd/keepassgo
}
package() {
cd "$(_repo_dir)"
install -Dm755 keepassgo "${pkgdir}/usr/bin/keepassgo"
install -Dm644 internal/assets/keepassgo-icon.png \
"${pkgdir}/usr/share/icons/hicolor/512x512/apps/keepassgo.png"
install -Dm644 internal/assets/keepassgo-icon.svg \
"${pkgdir}/usr/share/icons/hicolor/scalable/apps/keepassgo.svg"
install -Dm644 packaging/archlinux/keepassgo-git/keepassgo.desktop \
"${pkgdir}/usr/share/applications/keepassgo.desktop"
install -Dm644 README.md \
"${pkgdir}/usr/share/licenses/${pkgname}/README.md"
}
@@ -0,0 +1,10 @@
[Desktop Entry]
Type=Application
Name=KeePassGO
Comment=KeePass-compatible password manager
Exec=keepassgo
Icon=keepassgo
Terminal=false
Categories=Utility;Security;
Keywords=keepass;password;vault;kdbx;
StartupNotify=true
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
import argparse
import json
import mimetypes
import pathlib
import sys
import urllib.error
import urllib.parse
import urllib.request
def request_json(method: str, url: str, token: str, payload: dict | None = None) -> dict:
data = None
headers = {"Authorization": f"token {token}", "Accept": "application/json"}
if payload is not None:
data = json.dumps(payload).encode()
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode())
def request_no_content(method: str, url: str, token: str) -> None:
req = urllib.request.Request(
url,
headers={"Authorization": f"token {token}", "Accept": "application/json"},
method=method,
)
with urllib.request.urlopen(req):
return
def get_or_create_release(server: str, repo: str, token: str, tag: str) -> dict:
base = f"{server.rstrip('/')}/api/v1/repos/{repo}"
tag_url = f"{base}/releases/tags/{urllib.parse.quote(tag, safe='')}"
try:
return request_json("GET", tag_url, token)
except urllib.error.HTTPError as err:
if err.code != 404:
raise
payload = {
"tag_name": tag,
"name": tag,
"draft": False,
"prerelease": False,
}
return request_json("POST", f"{base}/releases", token, payload)
def upload_asset(server: str, repo: str, token: str, release: dict, path: pathlib.Path) -> None:
base = f"{server.rstrip('/')}/api/v1/repos/{repo}"
assets = release.get("assets", [])
for asset in assets:
if asset.get("name") == path.name:
request_no_content("DELETE", f"{base}/releases/{release['id']}/assets/{asset['id']}", token)
query = urllib.parse.urlencode({"name": path.name})
url = f"{base}/releases/{release['id']}/assets?{query}"
content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
req = urllib.request.Request(
url,
data=path.read_bytes(),
headers={
"Authorization": f"token {token}",
"Accept": "application/json",
"Content-Type": content_type,
},
method="POST",
)
with urllib.request.urlopen(req):
return
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--server", required=True)
parser.add_argument("--repo", required=True)
parser.add_argument("--token", required=True)
parser.add_argument("--tag", required=True)
parser.add_argument("artifacts", nargs="+")
args = parser.parse_args()
paths = [pathlib.Path(p) for p in args.artifacts]
missing = [str(p) for p in paths if not p.is_file()]
if missing:
print(f"missing artifacts: {', '.join(missing)}", file=sys.stderr)
return 1
release = get_or_create_release(args.server, args.repo, args.token, args.tag)
for path in paths:
upload_asset(args.server, args.repo, args.token, release, path)
return 0
if __name__ == "__main__":
raise SystemExit(main())
-67
View File
@@ -1,67 +0,0 @@
# SPDX-License-Identifier: Unlicense OR MIT
image: debian/testing
packages:
- clang
- cmake
- curl
- autoconf
- libxml2-dev
- libssl-dev
- libz-dev
- llvm-dev # for cctools
- uuid-dev ## for cctools
- libplist-utils # for gogio
sources:
- https://git.sr.ht/~eliasnaur/gio-cmd
- https://git.sr.ht/~eliasnaur/applesdks
- https://git.sr.ht/~eliasnaur/giouiorg
- https://github.com/tpoechtrager/cctools-port.git
- https://github.com/tpoechtrager/apple-libtapi.git
- https://github.com/mackyle/xar.git
environment:
APPLE_TOOLCHAIN_ROOT: /home/build/appletools
PATH: /home/build/sdk/go/bin:/home/build/go/bin:/usr/bin
tasks:
- install_go: |
mkdir -p /home/build/sdk
curl -s https://dl.google.com/go/go1.19.8.linux-amd64.tar.gz | tar -C /home/build/sdk -xzf -
- prepare_toolchain: |
mkdir -p $APPLE_TOOLCHAIN_ROOT
cd $APPLE_TOOLCHAIN_ROOT
tar xJf /home/build/applesdks/applesdks.tar.xz
mkdir bin tools
cd bin
ln -s ../toolchain/bin/x86_64-apple-darwin19-ld ld
ln -s ../toolchain/bin/x86_64-apple-darwin19-ar ar
ln -s /home/build/cctools-port/cctools/misc/lipo lipo
ln -s ../tools/appletoolchain xcrun
ln -s /usr/bin/plistutil plutil
cd ../tools
ln -s appletoolchain clang-ios
ln -s appletoolchain clang-macos
- install_appletoolchain: |
cd giouiorg
go build -o $APPLE_TOOLCHAIN_ROOT/tools ./cmd/appletoolchain
- build_xar: |
cd xar/xar
ac_cv_lib_crypto_OpenSSL_add_all_ciphers=yes CC=clang ./autogen.sh --prefix=/usr
make
sudo make install
- build_libtapi: |
cd apple-libtapi
INSTALLPREFIX=$APPLE_TOOLCHAIN_ROOT/libtapi ./build.sh
./install.sh
- build_cctools: |
cd cctools-port/cctools
./configure --prefix $APPLE_TOOLCHAIN_ROOT/toolchain --with-libtapi=$APPLE_TOOLCHAIN_ROOT/libtapi --target=x86_64-apple-darwin19
make install
- install_gogio: |
cd gio-cmd
go install ./gogio
- test_ios_gogio: |
mkdir tmp
cd tmp
go mod init example.com
go get -d gioui.org/example/kitchen
export PATH=/home/build/appletools/bin:$PATH
gogio -target ios -o app.app gioui.org/example/kitchen

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