Compare commits

..

168 Commits

Author SHA1 Message Date
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
32 changed files with 2403 additions and 906 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
+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 main.appVersion=${app_version}" -o "${out}" .
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"
+16
View File
@@ -14,6 +14,8 @@ These instructions apply to all future work in this repository.
## Skills
- 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:
`go-code-review`,
`go-concurrency`,
@@ -126,6 +128,20 @@ These features are product requirements, not “nice to have” ideas.
- Keep `golangci-lint` passing.
- Keep `go test ./...` passing.
- 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.
- 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.
- Do not assume the agent can decrypt SOPS-encrypted secrets in this repository.
+1
View File
@@ -12,6 +12,7 @@ Environment:
- `ANDROID_NDK_ROOT` defaults to `/opt/android-ndk`.
- `JAVA_HOME` defaults to `/usr/lib/jvm/java-25-openjdk`.
- `APP_ID` overrides the Android application id.
- `APP_VERSION` overrides the version shown inside KeePassGO itself.
- `APK_OUT` overrides the output path.
- `APK_VERSION` overrides the packaged app version.
- `ANDROID_MIN_SDK` overrides the minimum supported Android SDK.
+14
View File
@@ -5,8 +5,20 @@ PATH := $(JAVA_HOME)/bin:$(ANDROID_SDK_ROOT)/cmdline-tools/latest/bin:$(ANDROID_
APP_ID ?= org.julianfamily.keepassgo
APK_OUT ?= build/keepassgo.apk
APK_VERSION ?= 0.1.0.1
APP_VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
GO_LDFLAGS ?= -X main.appVersion=$(APP_VERSION)
ANDROID_MIN_SDK ?= 28
ANDROID_TARGET_SDK ?= 35
SIGNKEY ?=
SIGNPASS ?=
GOGIO_SIGN_FLAGS :=
ifneq ($(strip $(SIGNKEY)),)
GOGIO_SIGN_FLAGS += -signkey $(SIGNKEY)
endif
ifneq ($(strip $(SIGNPASS)),)
GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS)
endif
.PHONY: apk
apk: android/keepassgo-android.jar
@@ -24,6 +36,8 @@ apk: android/keepassgo-android.jar
go tool gogio -target android \
-buildmode exe \
-appid $(APP_ID) \
-ldflags "$(GO_LDFLAGS)" \
$(GOGIO_SIGN_FLAGS) \
-o $(APK_OUT) \
-version $(APK_VERSION) \
-minsdk $(ANDROID_MIN_SDK) \
+25
View File
@@ -41,6 +41,31 @@ Desktop build:
go build ./...
```
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 main.appVersion=v0.0.1" ./...
```
## 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
KeePassGO uses Gio, so Android packaging is done with `gogio`.
+1 -1
View File
@@ -20,7 +20,7 @@ func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) {
if err := lifecycle.Create(vault.Model{
Entries: []vault.Entry{
testAPITokenEntry(t),
{ID: "entry-1", Title: "Git Server", Path: []string{"Root", "Internet"}},
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
t.Fatalf("Create() error = %v", err)
+69 -69
View File
@@ -50,11 +50,11 @@ func TestVaultServiceRejectsExpiredAPITokens(t *testing.T) {
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
{
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
token.Entry([]string{"Root", "API Tokens"}),
@@ -78,22 +78,22 @@ func TestVaultServiceRejectsUnauthorizedEntryAccess(t *testing.T) {
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
{
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
testAPITokenEntry(t,
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()
_, 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 {
t.Fatalf("CopyEntryField() code = %v, want %v", status.Code(err), codes.PermissionDenied)
}
@@ -105,11 +105,11 @@ func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) {
model := vault.Model{
Entries: []vault.Entry{
{
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
testAPITokenEntry(t),
@@ -139,8 +139,8 @@ func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) {
if err := <-errCh; err != nil {
t.Fatalf("ListEntries() error = %v", err)
}
if len(resp.Entries) != 1 || resp.Entries[0].Id != "git-server" {
t.Fatalf("ListEntries().Entries = %#v, want git-server", resp.Entries)
if len(resp.Entries) != 1 || resp.Entries[0].Id != "vault-console" {
t.Fatalf("ListEntries().Entries = %#v, want vault-console", resp.Entries)
}
}
@@ -150,11 +150,11 @@ func TestVaultServicePersistsPermanentDenyApproval(t *testing.T) {
model := vault.Model{
Entries: []vault.Entry{
{
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
testAPITokenEntry(t),
@@ -196,7 +196,7 @@ func TestVaultServiceReturnsCanceledForCanceledApproval(t *testing.T) {
model := vault.Model{
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),
},
}
@@ -225,7 +225,7 @@ func TestVaultServiceTimesOutPendingApproval(t *testing.T) {
model := vault.Model{
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),
},
}
@@ -244,7 +244,7 @@ func TestVaultServiceRecordsApprovalAuditEvents(t *testing.T) {
model := vault.Model{
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),
},
}
@@ -282,11 +282,11 @@ func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) {
model: vault.Model{
Entries: []vault.Entry{
{
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
@@ -454,7 +454,7 @@ func TestVaultServiceOpensAndSavesVaultThroughLifecycleBackend(t *testing.T) {
if _, err := client.OpenRemoteVault(ctx, &keepassgov1.OpenRemoteVaultRequest{
BaseUrl: "https://dav.example.com",
Path: "vaults/main.kdbx",
Username: "jjulian",
Username: "rustyryan",
Password: "dav-token",
MasterPassword: "correct horse battery staple",
}); err != nil {
@@ -618,8 +618,8 @@ func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) {
t.Fatalf("len(ListEntries().Entries) = %d, want 1", len(resp.Entries))
}
if resp.Entries[0].Title != "Git Server" {
t.Fatalf("ListEntries().Entries[0].Title = %q, want %q", 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, "Vault Console")
}
if got := resp.Entries[0].Fields["X-Role"]; got != "automation" {
t.Fatalf("ListEntries().Entries[0].Fields[X-Role] = %q, want %q", got, "automation")
@@ -747,7 +747,7 @@ func TestVaultServiceCopiesEntryFieldsForAuthorizedClients(t *testing.T) {
ctx := tokenContext(defaultTestTokenSecret)
if _, err := client.CopyEntryField(ctx, &keepassgov1.CopyEntryFieldRequest{
Id: "git-server",
Id: "vault-console",
Target: "password",
}); err != nil {
t.Fatalf("CopyEntryField() error = %v", err)
@@ -767,11 +767,11 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
ctx := tokenContext(defaultTestTokenSecret)
upserted, err := client.UpsertEntry(ctx, &keepassgov1.UpsertEntryRequest{
Entry: &keepassgov1.Entry{
Id: "ha-codex",
Title: "Home Assistant (Codex)",
Id: "surveillance-console",
Title: "Surveillance Console",
Username: "codex",
Password: "token-2",
Url: "https://lights.julianfamily.org",
Url: "https://surveillance.crew.example.invalid",
Fields: map[string]string{
"X-Role": "lights-admin",
},
@@ -782,8 +782,8 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
t.Fatalf("UpsertEntry() error = %v", err)
}
if upserted.Entry.Title != "Home Assistant (Codex)" {
t.Fatalf("UpsertEntry().Entry.Title = %q, want %q", upserted.Entry.Title, "Home Assistant (Codex)")
if upserted.Entry.Title != "Surveillance Console" {
t.Fatalf("UpsertEntry().Entry.Title = %q, want %q", upserted.Entry.Title, "Surveillance Console")
}
if got := upserted.Entry.Fields["X-Role"]; got != "lights-admin" {
t.Fatalf("UpsertEntry().Entry.Fields[X-Role] = %q, want %q", got, "lights-admin")
@@ -809,7 +809,7 @@ func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T)
defer cleanup()
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)
}
@@ -822,13 +822,13 @@ func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T)
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 {
t.Fatalf("RestoreEntry() error = %v", err)
}
if restored.Entry.Title != "Git Server" {
t.Fatalf("RestoreEntry().Entry.Title = %q, want %q", restored.Entry.Title, "Git Server")
if restored.Entry.Title != "Vault Console" {
t.Fatalf("RestoreEntry().Entry.Title = %q, want %q", restored.Entry.Title, "Vault Console")
}
listed, err = client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
@@ -836,8 +836,8 @@ func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T)
t.Fatalf("ListEntries() error = %v", err)
}
if len(listed.Entries) != 1 || listed.Entries[0].Title != "Git Server" {
t.Fatalf("ListEntries().Entries = %#v, want restored Git Server entry", listed.Entries)
if len(listed.Entries) != 1 || listed.Entries[0].Title != "Vault Console" {
t.Fatalf("ListEntries().Entries = %#v, want restored Vault Console entry", listed.Entries)
}
}
@@ -863,11 +863,11 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
instantiated, err := client.InstantiateTemplate(ctx, &keepassgov1.InstantiateTemplateRequest{
TemplateId: "website-login",
Overrides: &keepassgov1.Entry{
Id: "dynadot",
Title: "Dynadot",
Username: "jjulian",
Id: "bellagio",
Title: "Bellagio",
Username: "rustyryan",
Password: "hunter2",
Url: "https://www.dynadot.com",
Url: "https://bellagio.example.invalid",
Fields: map[string]string{
"Environment": "staging",
},
@@ -879,8 +879,8 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
t.Fatalf("InstantiateTemplate() error = %v", err)
}
if instantiated.Entry.Title != "Dynadot" || instantiated.Entry.Notes != "Reusable template for website accounts." {
t.Fatalf("InstantiateTemplate().Entry = %#v, want Dynadot entry with template notes", instantiated.Entry)
if instantiated.Entry.Title != "Bellagio" || instantiated.Entry.Notes != "Reusable template for website accounts." {
t.Fatalf("InstantiateTemplate().Entry = %#v, want Bellagio entry with template notes", instantiated.Entry)
}
if got := instantiated.Entry.Fields["Environment"]; got != "staging" {
t.Fatalf("InstantiateTemplate().Entry.Fields[Environment] = %q, want %q", got, "staging")
@@ -956,7 +956,7 @@ func TestVaultServiceListsAndRestoresEntryHistoryForAuthorizedClients(t *testing
defer cleanup()
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 {
t.Fatalf("ListEntryHistory() error = %v", err)
}
@@ -965,7 +965,7 @@ func TestVaultServiceListsAndRestoresEntryHistoryForAuthorizedClients(t *testing
}
restored, err := client.RestoreEntryHistory(ctx, &keepassgov1.RestoreEntryHistoryRequest{
Id: "git-server",
Id: "vault-console",
HistoryIndex: 0,
})
if err != nil {
@@ -986,14 +986,14 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
uploaded := []byte("attachment-content")
if _, err := client.UploadAttachment(ctx, &keepassgov1.UploadAttachmentRequest{
EntryId: "git-server",
EntryId: "vault-console",
Name: "token.txt",
Content: uploaded,
}); err != nil {
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 {
t.Fatalf("ListAttachments() error = %v", err)
}
@@ -1002,7 +1002,7 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
}
downloaded, err := client.DownloadAttachment(ctx, &keepassgov1.DownloadAttachmentRequest{
EntryId: "git-server",
EntryId: "vault-console",
Name: "token.txt",
})
if err != nil {
@@ -1013,13 +1013,13 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
}
if _, err := client.DeleteAttachment(ctx, &keepassgov1.DeleteAttachmentRequest{
EntryId: "git-server",
EntryId: "vault-console",
Name: "token.txt",
}); err != nil {
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 {
t.Fatalf("ListAttachments() error = %v", err)
}
@@ -1055,32 +1055,32 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa
model := vault.Model{
Entries: []vault.Entry{
{
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Fields: map[string]string{
"X-Role": "automation",
},
History: []vault.Entry{
{
ID: "git-server-h1",
Title: "Git Server",
Username: "joejulian",
ID: "vault-console-h1",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-0",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
Path: []string{"Root", "Internet"},
},
{
ID: "ha-codex",
Title: "Home Assistant (Codex)",
ID: "surveillance-console",
Title: "Surveillance Console",
Username: "codex",
Password: "token-2",
URL: "https://lights.julianfamily.org",
URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"},
},
testAPITokenEntry(t,
@@ -1090,9 +1090,9 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa
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.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.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "git-server", 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.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", 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{
+15 -1
View File
@@ -26,6 +26,7 @@ const (
SectionRecycleBin Section = "recycle-bin"
SectionAPITokens Section = "api-tokens"
SectionAPIAudit Section = "api-audit"
SectionAbout Section = "about"
)
type CurrentSession interface {
@@ -61,6 +62,7 @@ type SynchronizableSession interface {
type AdvancedSynchronizableSession interface {
CurrentSession
SynchronizeFromLocal(string) error
SynchronizeFromLocalBytes(string, []byte) error
SynchronizeToLocal(string) error
SynchronizeFromRemote(webdav.Client, string) error
SynchronizeToRemote(webdav.Client, string) error
@@ -375,7 +377,7 @@ func (s *State) entriesForSection(model vault.Model) []vault.Entry {
return slices.Clone(model.Templates)
case SectionRecycleBin:
return slices.Clone(model.RecycleBin)
case SectionAPITokens, SectionAPIAudit:
case SectionAPITokens, SectionAPIAudit, SectionAbout:
return nil
default:
return slices.Clone(model.Entries)
@@ -722,6 +724,18 @@ func (s *State) SynchronizeFromLocal(path string) error {
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 {
session, ok := s.Session.(AdvancedSynchronizableSession)
if !ok {
+120 -100
View File
@@ -20,13 +20,13 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Joe", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Joe", "Home Assistant"}},
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
},
},
},
CurrentPath: []string{"Joe", "Internet"},
CurrentPath: []string{"Crew", "Internet"},
}
got, err := state.VisibleEntries()
@@ -39,8 +39,8 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
titles = append(titles, entry.Title)
}
if !slices.Equal(titles, []string{"Dynadot", "Git Server"}) {
t.Fatalf("visible titles = %v, want [Dynadot Git Server]", titles)
if !slices.Equal(titles, []string{"Bellagio", "Vault Console"}) {
t.Fatalf("visible titles = %v, want [Bellagio Vault Console]", titles)
}
}
@@ -51,14 +51,14 @@ func TestVisibleEntriesAtParentGroupOnlyShowsDirectEntries(t *testing.T) {
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "joe-note", Title: "Joe Note", Path: []string{"Joe"}},
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Joe", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Joe", "Home Assistant"}},
{ID: "joe-note", Title: "Crew Note", Path: []string{"Crew"}},
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
},
},
},
CurrentPath: []string{"Joe"},
CurrentPath: []string{"Crew"},
}
got, err := state.VisibleEntries()
@@ -70,8 +70,8 @@ func TestVisibleEntriesAtParentGroupOnlyShowsDirectEntries(t *testing.T) {
for _, entry := range got {
titles = append(titles, entry.Title)
}
if !slices.Equal(titles, []string{"Joe Note"}) {
t.Fatalf("visible titles = %v, want only direct entries from Joe", titles)
if !slices.Equal(titles, []string{"Crew Note"}) {
t.Fatalf("visible titles = %v, want only direct entries from Crew", titles)
}
}
@@ -171,14 +171,14 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
{ID: "git-server", Title: "Git Server", URL: "https://git.julianfamily.org", Path: []string{"Joe", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", URL: "https://lights.julianfamily.org", Path: []string{"Joe", "Home Assistant"}},
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
},
},
},
CurrentPath: []string{"Joe", "Internet"},
SearchQuery: "lights",
CurrentPath: []string{"Crew", "Internet"},
SearchQuery: "surveillance",
}
got, err := state.VisibleEntries()
@@ -186,7 +186,7 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
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)
}
}
@@ -198,13 +198,13 @@ func TestVisibleEntriesReturnsDescendantsAfterClearingSearch(t *testing.T) {
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
{ID: "git-server", Title: "Git Server", URL: "https://git.julianfamily.org", Path: []string{"Joe", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", URL: "https://lights.julianfamily.org", Path: []string{"Joe", "Home Assistant"}},
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
},
},
},
CurrentPath: []string{"Joe"},
CurrentPath: []string{"Crew"},
SearchQuery: "missing",
}
@@ -222,7 +222,7 @@ func TestVisibleEntriesReturnsDescendantsAfterClearingSearch(t *testing.T) {
t.Fatalf("VisibleEntries() after clearing search error = %v", err)
}
if len(got) != 0 {
t.Fatalf("len(VisibleEntries()) after clearing search = %d, want 0 direct entries at Joe", len(got))
t.Fatalf("len(VisibleEntries()) after clearing search = %d, want 0 direct entries at Crew", len(got))
}
}
@@ -349,7 +349,7 @@ func TestVisibleEntriesUsesGlobalSearchWithinRecycleBin(t *testing.T) {
Session: stubSession{
model: vault.Model{
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"}},
},
},
@@ -418,13 +418,13 @@ func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Joe", "Home Assistant"}},
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
{ID: "alma", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}},
},
},
},
CurrentPath: []string{"Joe"},
CurrentPath: []string{"Crew"},
}
got, err := state.ChildGroups()
@@ -470,19 +470,19 @@ func TestSelectVisibleEntryAndToggleSelection(t *testing.T) {
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Joe", "Internet"}},
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "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 {
t.Fatalf("SelectVisibleIndex() error = %v", err)
}
if got := state.SelectedEntryID; got != "git-server" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "git-server")
if got := state.SelectedEntryID; got != "vault-console" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "vault-console")
}
if err := state.ToggleVisibleIndex(1); err != nil {
@@ -524,14 +524,14 @@ func TestDeleteSelectedEntryUpdatesSessionAndClearsSelection(t *testing.T) {
sess := &mutableStubSession{
model: vault.Model{
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{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server",
SelectedEntryID: "vault-console",
}
if err := state.DeleteSelectedEntry(); err != nil {
@@ -546,8 +546,8 @@ func TestDeleteSelectedEntryUpdatesSessionAndClearsSelection(t *testing.T) {
t.Fatalf("len(Entries) = %d, want 0", len(sess.model.Entries))
}
if len(sess.model.RecycleBin) != 1 || sess.model.RecycleBin[0].ID != "git-server" {
t.Fatalf("RecycleBin = %#v, want git-server entry", sess.model.RecycleBin)
if len(sess.model.RecycleBin) != 1 || sess.model.RecycleBin[0].ID != "vault-console" {
t.Fatalf("RecycleBin = %#v, want vault-console entry", sess.model.RecycleBin)
}
if !state.Dirty {
@@ -561,7 +561,7 @@ func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) {
sess := &mutableStubSession{
model: vault.Model{
RecycleBin: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
},
}
@@ -570,7 +570,7 @@ func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) {
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)
}
@@ -579,8 +579,8 @@ func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].ID != "git-server" {
t.Fatalf("VisibleEntries() = %#v, want restored git-server entry", got)
if len(got) != 1 || got[0].ID != "vault-console" {
t.Fatalf("VisibleEntries() = %#v, want restored vault-console entry", got)
}
if !state.Dirty {
@@ -600,19 +600,19 @@ func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) {
}
entry := vault.Entry{
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
}
if err := state.UpsertEntry(entry); err != nil {
t.Fatalf("UpsertEntry() error = %v", err)
}
if got := state.SelectedEntryID; got != "git-server" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "git-server")
if got := state.SelectedEntryID; got != "vault-console" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "vault-console")
}
got, err := state.VisibleEntries()
@@ -620,7 +620,7 @@ func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].Password != "token-1" {
t.Fatalf("VisibleEntries() = %#v, want persisted git-server entry", got)
t.Fatalf("VisibleEntries() = %#v, want persisted vault-console entry", got)
}
if !state.Dirty {
@@ -652,11 +652,11 @@ func TestInstantiateTemplateCreatesEntryAndSelectsIt(t *testing.T) {
}
entry, err := state.InstantiateTemplate("website-login", vault.Entry{
ID: "dynadot",
Title: "Dynadot",
Username: "jjulian",
ID: "bellagio",
Title: "Bellagio",
Username: "rustyryan",
Password: "hunter2",
URL: "https://www.dynadot.com",
URL: "https://bellagio.example.invalid",
Path: []string{"Root", "Internet"},
})
if err != nil {
@@ -667,16 +667,16 @@ func TestInstantiateTemplateCreatesEntryAndSelectsIt(t *testing.T) {
t.Fatalf("entry.Notes = %q, want template notes", entry.Notes)
}
if got := state.SelectedEntryID; got != "dynadot" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "dynadot")
if got := state.SelectedEntryID; got != "bellagio" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "bellagio")
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].ID != "dynadot" {
t.Fatalf("VisibleEntries() = %#v, want instantiated dynadot entry", got)
if len(got) != 1 || got[0].ID != "bellagio" {
t.Fatalf("VisibleEntries() = %#v, want instantiated bellagio entry", got)
}
if !state.Dirty {
@@ -743,24 +743,24 @@ func TestDuplicateSelectedEntryCreatesCopyAndSelectsIt(t *testing.T) {
sess := &mutableStubSession{model: vault.Model{
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{
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 {
t.Fatalf("DuplicateSelectedEntry() error = %v", err)
}
if duplicate.ID != "git-server-copy" {
t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "git-server-copy")
if duplicate.ID != "vault-console-copy" {
t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "vault-console-copy")
}
if state.SelectedEntryID != "git-server-copy" {
t.Fatalf("SelectedEntryID = %q, want git-server-copy", state.SelectedEntryID)
if state.SelectedEntryID != "vault-console-copy" {
t.Fatalf("SelectedEntryID = %q, want vault-console-copy", state.SelectedEntryID)
}
if len(sess.model.Entries) != 2 {
t.Fatalf("len(Entries) = %d, want 2 after duplicate", len(sess.model.Entries))
@@ -772,13 +772,13 @@ func TestMoveSelectedEntryMovesEntryToNewPathAndMarksDirty(t *testing.T) {
sess := &mutableStubSession{model: vault.Model{
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{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server",
SelectedEntryID: "vault-console",
}
if err := state.MoveSelectedEntry([]string{"Root", "Infrastructure"}); err != nil {
@@ -791,8 +791,8 @@ func TestMoveSelectedEntryMovesEntryToNewPathAndMarksDirty(t *testing.T) {
}
newPath := sess.model.EntriesInPath([]string{"Root", "Infrastructure"})
if len(newPath) != 1 || newPath[0].ID != "git-server" {
t.Fatalf("EntriesInPath(Root/Infrastructure) = %#v, want moved git-server entry", newPath)
if len(newPath) != 1 || newPath[0].ID != "vault-console" {
t.Fatalf("EntriesInPath(Root/Infrastructure) = %#v, want moved vault-console entry", newPath)
}
if !state.Dirty {
@@ -806,19 +806,19 @@ func TestRestoreSelectedEntryVersionReplacesCurrentVersion(t *testing.T) {
sess := &mutableStubSession{model: vault.Model{
Entries: []vault.Entry{
{
ID: "git-server",
Title: "Git Server",
ID: "vault-console",
Title: "Vault Console",
Password: "new-token",
Path: []string{"Root", "Internet"},
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{
Session: sess,
SelectedEntryID: "git-server",
SelectedEntryID: "vault-console",
}
if err := state.RestoreSelectedEntryVersion(0); err != nil {
@@ -862,7 +862,7 @@ func TestCreateVaultResetsSelectionPathAndDirtyState(t *testing.T) {
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server",
SelectedEntryID: "vault-console",
Dirty: true,
}
@@ -891,7 +891,7 @@ func TestOpenVaultResetsSelectionPathAndDirtyState(t *testing.T) {
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server",
SelectedEntryID: "vault-console",
Dirty: true,
}
@@ -942,7 +942,7 @@ func TestOpenRemoteVaultResetsSelectionPathAndDirtyState(t *testing.T) {
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server",
SelectedEntryID: "vault-console",
Dirty: true,
}
@@ -970,14 +970,14 @@ func TestLockClearsSelectionAndMakesVaultUnavailable(t *testing.T) {
sess := &lockableStubSession{
model: vault.Model{
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{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server",
SelectedEntryID: "vault-console",
}
if err := state.Lock(); err != nil {
@@ -1000,7 +1000,7 @@ func TestUnlockRestoresVaultVisibility(t *testing.T) {
sess := &lockableStubSession{
model: vault.Model{
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,
@@ -1018,8 +1018,8 @@ func TestUnlockRestoresVaultVisibility(t *testing.T) {
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].ID != "git-server" {
t.Fatalf("VisibleEntries() = %#v, want git-server entry after unlock", got)
if len(got) != 1 || got[0].ID != "vault-console" {
t.Fatalf("VisibleEntries() = %#v, want vault-console entry after unlock", got)
}
}
@@ -1048,7 +1048,7 @@ func TestShowSectionResetsPathAndSelection(t *testing.T) {
state := State{
Section: SectionEntries,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server",
SelectedEntryID: "vault-console",
SearchQuery: "git",
}
@@ -1084,7 +1084,7 @@ func TestBeginNewEntryClearsSelectionAndStatus(t *testing.T) {
t.Parallel()
state := State{
SelectedEntryID: "git-server",
SelectedEntryID: "vault-console",
ErrorMessage: "previous error",
}
@@ -1128,7 +1128,7 @@ func TestEnterGroupAppendsPathAndClearsSelection(t *testing.T) {
state := State{
CurrentPath: []string{"Root"},
SelectedEntryID: "git-server",
SelectedEntryID: "vault-console",
}
state.EnterGroup("Internet")
@@ -1146,7 +1146,7 @@ func TestNavigateToPathReplacesPathAndClearsSelection(t *testing.T) {
state := State{
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server",
SelectedEntryID: "vault-console",
}
state.NavigateToPath([]string{"Root", "Home Assistant"})
@@ -1297,7 +1297,7 @@ func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) {
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot",
SelectedEntryID: "bellagio",
}
if err := state.MoveSelectedEntry([]string{"Root", "Home Assistant"}); err != nil {
@@ -1313,8 +1313,8 @@ func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) {
if len(got) != 2 {
t.Fatalf("len(VisibleEntries()) = %d, want 2", len(got))
}
if got[0].ID != "dynadot" && got[1].ID != "dynadot" {
t.Fatalf("VisibleEntries() = %#v, want moved dynadot entry in destination group", got)
if got[0].ID != "bellagio" && got[1].ID != "bellagio" {
t.Fatalf("VisibleEntries() = %#v, want moved bellagio entry in destination group", got)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after MoveSelectedEntry")
@@ -1326,7 +1326,7 @@ func TestMoveCurrentGroupMovesHierarchyAndMarksDirty(t *testing.T) {
model := vault.Model{
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")
@@ -1337,15 +1337,15 @@ func TestMoveCurrentGroupMovesHierarchyAndMarksDirty(t *testing.T) {
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)
}
if !slices.Equal(state.CurrentPath, []string{"Root", "Joe", "Internet"}) {
t.Fatalf("CurrentPath = %v, want [Root Joe Internet]", state.CurrentPath)
if !slices.Equal(state.CurrentPath, []string{"Root", "Crew", "Internet"}) {
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" {
t.Fatalf("EntriesInPath(Root/Joe/Internet) = %#v, want moved entry", got)
if got := session.model.EntriesInPath([]string{"Root", "Crew", "Internet"}); len(got) != 1 || got[0].ID != "vault-console" {
t.Fatalf("EntriesInPath(Root/Crew/Internet) = %#v, want moved entry", got)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after MoveCurrentGroup")
@@ -1359,7 +1359,7 @@ func TestAddAttachmentToSelectedEntryPersistsAndMarksDirty(t *testing.T) {
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot",
SelectedEntryID: "bellagio",
}
if err := state.AddAttachmentToSelectedEntry("token.txt", []byte("secret")); err != nil {
@@ -1387,7 +1387,7 @@ func TestAddAttachmentToSelectedEntryRejectsDuplicateNames(t *testing.T) {
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot",
SelectedEntryID: "bellagio",
}
err := state.AddAttachmentToSelectedEntry("token.txt", []byte("replacement"))
@@ -1413,7 +1413,7 @@ func TestReplaceAttachmentOnSelectedEntryPersistsAndMarksDirty(t *testing.T) {
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot",
SelectedEntryID: "bellagio",
}
if err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement")); err != nil {
@@ -1439,7 +1439,7 @@ func TestReplaceAttachmentOnSelectedEntryRequiresExistingAttachment(t *testing.T
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot",
SelectedEntryID: "bellagio",
}
err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement"))
@@ -1457,7 +1457,7 @@ func TestDeleteAttachmentFromSelectedEntryPersistsAndMarksDirty(t *testing.T) {
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot",
SelectedEntryID: "bellagio",
}
if err := state.DeleteAttachmentFromSelectedEntry("token.txt"); err != nil {
@@ -1483,7 +1483,7 @@ func TestDeleteAttachmentFromSelectedEntryRequiresExistingAttachment(t *testing.
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot",
SelectedEntryID: "bellagio",
}
err := state.DeleteAttachmentFromSelectedEntry("token.txt")
@@ -1511,8 +1511,8 @@ func (s *mutableStubSession) Replace(model vault.Model) {
func testVaultModel() vault.Model {
return vault.Model{
Entries: []vault.Entry{
{ID: "dynadot", Title: "Dynadot", Path: []string{"Root", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Root", "Home Assistant"}},
{ID: "bellagio", Title: "Bellagio", Path: []string{"Root", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Root", "Home Assistant"}},
},
}
}
@@ -1584,6 +1584,26 @@ func (s *lifecycleStubSession) OpenRemote(_ webdav.Client, path string, _ vault.
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 {
s.changedKey = key
return nil
+10 -10
View File
@@ -22,7 +22,7 @@ func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
Username: "joe",
Password: "secret",
URL: "https://10.0.2.2:8443/login",
Path: []string{"Joe", "Internet"},
Path: []string{"Crew", "Internet"},
},
{
ID: "two",
@@ -35,10 +35,10 @@ func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
Title: "Bare Host",
Username: "user",
Password: "pass",
URL: "lights.julianfamily.org",
URL: "surveillance.crew.example.invalid",
Fields: map[string]string{
"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" {
t.Fatalf("first host = %q, want 10.0.2.2", got.Entries[0].Host)
}
if got.Entries[1].Host != "lights.julianfamily.org" {
t.Fatalf("second host = %q, want lights.julianfamily.org", got.Entries[1].Host)
if got.Entries[1].Host != "surveillance.crew.example.invalid" {
t.Fatalf("second host = %q, want surveillance.crew.example.invalid", got.Entries[1].Host)
}
if len(got.Entries[1].Targets) != 3 {
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",
Username: "first",
Password: "secret1",
URL: "https://lights.julianfamily.org/",
Host: "lights.julianfamily.org",
URL: "https://surveillance.crew.example.invalid/",
Host: "surveillance.crew.example.invalid",
},
{
ID: "two",
Title: "Host B",
Username: "second",
Password: "secret2",
URL: "https://lights.julianfamily.org/",
Host: "lights.julianfamily.org",
URL: "https://surveillance.crew.example.invalid/",
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")
}
}
+4
View File
@@ -13,6 +13,7 @@ const (
DefaultAppID = "org.julianfamily.keepassgo"
DefaultAPKOut = "build/keepassgo.apk"
DefaultVersion = "0.1.0.1"
DefaultLdflags = "-X main.appVersion=dev"
DefaultMinSDK = "28"
DefaultTargetSDK = "35"
DefaultIconPath = "assets/keepassgo-icon.png"
@@ -25,6 +26,7 @@ type Config struct {
AppID string
APKOut string
Version string
Ldflags string
MinSDK string
TargetSDK string
IconPath string
@@ -38,6 +40,7 @@ func DefaultConfig() Config {
AppID: DefaultAppID,
APKOut: DefaultAPKOut,
Version: DefaultVersion,
Ldflags: DefaultLdflags,
MinSDK: DefaultMinSDK,
TargetSDK: DefaultTargetSDK,
IconPath: DefaultIconPath,
@@ -49,6 +52,7 @@ func (c Config) GogioArgs() []string {
"-target", "android",
"-buildmode", "exe",
"-appid", c.AppID,
"-ldflags", c.Ldflags,
"-o", c.APKOut,
"-version", c.Version,
"-minsdk", c.MinSDK,
+3
View File
@@ -15,6 +15,7 @@ func TestDefaultConfigGogioArgs(t *testing.T) {
"-target", "android",
"-buildmode", "exe",
"-appid", DefaultAppID,
"-ldflags", DefaultLdflags,
"-o", DefaultAPKOut,
"-version", DefaultVersion,
"-minsdk", DefaultMinSDK,
@@ -52,6 +53,7 @@ func TestValidateAcceptsCompleteAndroidToolchainLayout(t *testing.T) {
AppID: DefaultAppID,
APKOut: DefaultAPKOut,
Version: DefaultVersion,
Ldflags: DefaultLdflags,
MinSDK: DefaultMinSDK,
TargetSDK: DefaultTargetSDK,
IconPath: filepath.Join(root, "icon.png"),
@@ -77,6 +79,7 @@ func TestValidateRejectsMissingPrerequisites(t *testing.T) {
AppID: DefaultAppID,
APKOut: DefaultAPKOut,
Version: DefaultVersion,
Ldflags: DefaultLdflags,
MinSDK: DefaultMinSDK,
TargetSDK: DefaultTargetSDK,
}
+11 -11
View File
@@ -13,11 +13,11 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
model := vault.Model{
Entries: []vault.Entry{
{
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
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
want string
}{
{name: "username", target: TargetUsername, want: "joejulian"},
{name: "username", target: TargetUsername, want: "dannyocean"},
{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 {
@@ -37,7 +37,7 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
var writer memoryWriter
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)
}
@@ -60,9 +60,9 @@ func TestServiceRejectsUnknownEntryAndUnsupportedTarget(t *testing.T) {
}
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) {
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")}}
model := vault.Model{
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) {
t.Fatalf("Copy() write error = %v, want ErrWriteFailed", err)
}
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"io"
"strings"
"sync"
gioclipboard "gioui.org/io/clipboard"
"gioui.org/layout"
appclipboard "git.julianfamily.org/keepassgo/clipboard"
)
type clipboardCommandWriter struct {
mu sync.Mutex
pending []string
invalidate func()
}
func newPlatformClipboardWriter(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
}
+42
View File
@@ -0,0 +1,42 @@
package main
import (
"slices"
"testing"
)
func TestNewPlatformClipboardWriterUsesCommandWriterOnAndroid(t *testing.T) {
t.Parallel()
writer := newPlatformClipboardWriter("android", nil)
if _, ok := writer.(*clipboardCommandWriter); !ok {
t.Fatalf("newPlatformClipboardWriter(android) = %T, want *clipboardCommandWriter", writer)
}
}
func TestNewPlatformClipboardWriterUsesSystemClipboardOffAndroid(t *testing.T) {
t.Parallel()
if writer := newPlatformClipboardWriter("linux", nil); writer != nil {
t.Fatalf("newPlatformClipboardWriter(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)
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ KeePassGO currently targets keyboard-first desktop use on Linux and Windows.
- list navigation
- search focus
- 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
+4 -1
View File
@@ -6,6 +6,7 @@ replace gioui.org/cmd => ./third_party/gioui-cmd
require (
gioui.org v0.8.0
gioui.org/x v0.8.0
github.com/atotto/clipboard v0.1.4
github.com/tobischo/gokeepasslib/v3 v3.6.2
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90
@@ -18,6 +19,7 @@ require (
4d63.com/gochecknoglobals v0.2.2 // indirect
gioui.org/cmd v0.8.0 // 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/Abirdcfly/dupword v0.1.3 // indirect
github.com/Antonboom/errname v1.0.0 // indirect
@@ -74,6 +76,7 @@ require (
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.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/golang/protobuf v1.5.4 // indirect
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
@@ -192,7 +195,7 @@ require (
go.uber.org/multierr v1.6.0 // indirect
go.uber.org/zap v1.24.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/image v0.37.0 // indirect
golang.org/x/mod v0.33.0 // indirect
+8 -2
View File
@@ -42,6 +42,10 @@ gioui.org v0.8.0/go.mod h1:vEMmpxMOd/iwJhXvGVIzWEbxMWhnMQ9aByOGQdlQ8rc=
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/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/go.mod h1:+p4aMyFM+ra7nb41CnFG6aSDXqRxU/w1VQqScKqDARI=
github.com/Abirdcfly/dupword v0.1.3 h1:9Pa1NuAsZvpFPi9Pqkd93I7LIYRURj+A//dFd5tgBeE=
@@ -224,6 +228,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/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
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/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
@@ -669,8 +675,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-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-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
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/go.mod h1:jqkJFnLVkS8zgKKY4+MOPCZtuZGw3hONUjhapUSwZ8c=
golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
+297 -82
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"image"
"image/color"
"io"
"os"
"os/exec"
"path/filepath"
@@ -26,6 +27,7 @@ import (
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"gioui.org/x/explorer"
"git.julianfamily.org/keepassgo/api"
"git.julianfamily.org/keepassgo/apiapproval"
"git.julianfamily.org/keepassgo/apiaudit"
@@ -41,6 +43,15 @@ import (
"golang.org/x/exp/shiny/materialdesign/icons"
)
var appVersion = "dev"
func currentAppVersion() string {
if strings.TrimSpace(appVersion) == "" {
return "dev"
}
return strings.TrimSpace(appVersion)
}
type entry = vault.Entry
const (
@@ -200,6 +211,7 @@ const (
type ui struct {
mode string
theme *material.Theme
fileExplorer *explorer.Explorer
logoHorizontal paint.ImageOp
splashSquare paint.ImageOp
search widget.Editor
@@ -331,6 +343,7 @@ type ui struct {
showRecycle widget.Clickable
showAPITokens widget.Clickable
showAPIAudit widget.Clickable
showAbout widget.Clickable
showLocalLifecycle widget.Clickable
showRemoteLifecycle widget.Clickable
showSyncLocal widget.Clickable
@@ -409,6 +422,8 @@ type ui struct {
lifecycleMode string
syncSourceMode syncSourceMode
syncDirection syncDirection
syncLocalImportName string
syncLocalImportContent []byte
syncLocalPath widget.Editor
syncRemoteBaseURL widget.Editor
syncRemotePath widget.Editor
@@ -417,6 +432,7 @@ type ui struct {
syncDialogOpen bool
syncMenuOpen bool
mainMenuOpen bool
selectedRemoteConnection bool
securityDialogOpen bool
remotePrefsDialogOpen bool
showSyncPassword bool
@@ -680,6 +696,8 @@ func (u *ui) searchPlaceholder() string {
return "Search audit log"
case appstate.SectionRecycleBin:
return "Search recycle bin"
case appstate.SectionAbout:
return "Search disabled on About"
default:
return "Search vault"
}
@@ -809,6 +827,14 @@ func (u *ui) showAPIAuditSection() {
u.filter()
}
func (u *ui) showAboutSection() {
u.resetPasswordPeek()
u.rememberEntriesSectionState()
u.state.ShowSection(appstate.SectionAbout)
u.mainMenuOpen = false
u.filter()
}
func (u *ui) returnToMainEntries() {
u.clearDeleteGroupConfirmation()
u.showEntriesSection()
@@ -1216,6 +1242,19 @@ func (u *ui) openAdvancedSyncDialog() {
}
}
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:
@@ -1255,6 +1294,12 @@ func (u *ui) advancedSyncFromAction() error {
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)
@@ -1269,6 +1314,37 @@ func (u *ui) advancedSyncFromAction() error {
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 (u *ui) advancedSyncToAction() error {
switch u.syncSourceMode {
case syncSourceRemote:
@@ -1697,12 +1773,16 @@ func (u *ui) restoreStartupLifecycleTarget() {
func (u *ui) hasSelectedLifecycleTarget() bool {
switch strings.TrimSpace(u.lifecycleMode) {
case "remote":
return strings.TrimSpace(u.remoteBaseURL.Text()) != "" && strings.TrimSpace(u.remotePath.Text()) != ""
return u.hasSelectedRemoteTarget()
default:
return strings.TrimSpace(u.vaultPath.Text()) != ""
}
}
func (u *ui) hasSelectedRemoteTarget() bool {
return u.selectedRemoteConnection
}
func (u *ui) latestRecentVault() (string, time.Time) {
for _, path := range u.recentVaults {
if strings.TrimSpace(path) == "" {
@@ -1713,6 +1793,58 @@ func (u *ui) latestRecentVault() (string, time.Time) {
return "", time.Time{}
}
func (u *ui) hasSelectedVaultPath() bool {
return strings.TrimSpace(u.vaultPath.Text()) != ""
}
func (u *ui) showLocalVaultChooser() bool {
return u.lifecycleMode != "local" || !u.hasSelectedVaultPath()
}
func (u *ui) showRemoteConnectionChooser() bool {
return u.lifecycleMode != "remote" || !u.hasSelectedRemoteTarget()
}
func (u *ui) switchToLifecycleSelection(mode string) {
u.state.Session = &session.Manager{}
u.state.CurrentPath = nil
u.state.SelectedEntryID = ""
u.state.Section = appstate.SectionEntries
u.state.Dirty = false
u.state.ErrorMessage = ""
u.state.StatusMessage = ""
u.loadingMessage = ""
u.loadingActionLabel = ""
u.lastLifecycleAction = ""
u.lifecycleMode = mode
u.editingEntry = false
u.currentPath = nil
u.syncedPath = nil
u.clearMasterPassword()
u.keyFilePath.SetText("")
u.search.SetText("")
switch mode {
case "remote":
u.vaultPath.SetText("")
u.remoteBaseURL.SetText("")
u.remotePath.SetText("")
u.remoteUsername.SetText("")
u.remotePassword.SetText("")
u.rememberRemoteAuth.Value = false
u.selectedRemoteConnection = false
default:
u.vaultPath.SetText("")
u.remoteBaseURL.SetText("")
u.remotePath.SetText("")
u.remoteUsername.SetText("")
u.remotePassword.SetText("")
u.rememberRemoteAuth.Value = false
u.selectedRemoteConnection = false
}
u.requestMasterPassFocus = u.hasSelectedLifecycleTarget()
u.filter()
}
func (u *ui) latestRecentRemote() (recentRemoteRecord, bool, time.Time) {
for _, record := range u.recentRemotes {
if strings.TrimSpace(record.BaseURL) == "" || strings.TrimSpace(record.Path) == "" {
@@ -1756,6 +1888,7 @@ func (u *ui) applyRecentRemoteRecord(record recentRemoteRecord) {
u.remotePassword.SetText(record.Password)
u.remotePassword.Mask = '•'
u.rememberRemoteAuth.Value = strings.TrimSpace(record.Username) != "" || record.Password != ""
u.selectedRemoteConnection = true
}
func (u *ui) remotePreferencesCurrentSummary() string {
@@ -2643,6 +2776,11 @@ func (u *ui) listEmptyState() emptyState {
Title: "No API audit events yet",
Body: "Connect a trusted client, respond to approval prompts, or issue a token to start recording activity.",
}
case appstate.SectionAbout:
return emptyState{
Title: "About KeePassGO",
Body: "Product details, compatibility notes, and platform targets appear in the detail pane.",
}
case appstate.SectionTemplates:
return emptyState{
Title: "Templates unavailable",
@@ -2681,6 +2819,8 @@ func (u *ui) detailPlaceholderMessage() string {
return "Select an API token, issue a new one, or search to narrow the list."
case appstate.SectionAPIAudit:
return "Select an audit event to inspect it, or use Search audit log or the quick filters above."
case appstate.SectionAbout:
return "Review the product overview, platform support, and compatibility goals."
case appstate.SectionTemplates:
return "Select a template or start a reusable entry."
case appstate.SectionRecycleBin:
@@ -2898,6 +3038,10 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
u.clearDeleteGroupConfirmation()
u.showAPIAuditSection()
}
for u.showAbout.Clicked(gtx) {
u.clearDeleteGroupConfirmation()
u.showAboutSection()
}
for u.showLocalLifecycle.Clicked(gtx) {
if u.lifecycleBusy() {
continue
@@ -2910,6 +3054,7 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
continue
}
u.lifecycleMode = "remote"
u.selectedRemoteConnection = false
u.requestMasterPassFocus = true
}
for u.toggleLifecycleAdvanced.Clicked(gtx) {
@@ -3060,7 +3205,7 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
u.runAction("choose key file", func() error { return u.chooseExistingFileAction(&u.keyFilePath) })
}
for u.pickSyncLocalPath.Clicked(gtx) {
u.runAction("choose sync path", func() error { return u.chooseExistingFileAction(&u.syncLocalPath) })
u.startChooseSyncLocalSourceAction()
}
for i := range u.recentVaultClicks {
for u.recentVaultClicks[i].Clicked(gtx) {
@@ -3090,6 +3235,10 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
if u.lifecycleBusy() {
continue
}
if u.shouldUseLockedSinglePane() {
u.switchToLifecycleSelection("local")
continue
}
u.vaultPath.SetText("")
u.state.ErrorMessage = ""
u.state.StatusMessage = ""
@@ -3099,6 +3248,11 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
if u.lifecycleBusy() {
continue
}
if u.shouldUseLockedSinglePane() {
u.switchToLifecycleSelection("remote")
continue
}
u.selectedRemoteConnection = false
u.remoteBaseURL.SetText("")
u.remotePath.SetText("")
u.remoteUsername.SetText("")
@@ -4074,6 +4228,10 @@ func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.showAPIAudit, "API Audit")
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.showAbout, "About")
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings")
}),
@@ -4081,6 +4239,70 @@ func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions {
})
}
func (u *ui) aboutDetailPanel(gtx layout.Context) layout.Dimensions {
rows := []layout.Widget{
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(22), "KeePassGO")
lbl.Color = accentColor
return lbl.Layout(gtx)
},
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(15), "A KeePass-compatible password manager built in Go.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(14)}.Layout,
aboutFact(u.theme, "Compatibility", "KeePass and KDBX interoperability", "Designed to coexist with desktop KeePass and KeePass2Android workflows."),
layout.Spacer{Height: unit.Dp(10)}.Layout,
aboutFact(u.theme, "Platforms", "Windows and Linux first, Android supported", "Desktop remains the primary product surface while Android stays compatible."),
layout.Spacer{Height: unit.Dp(10)}.Layout,
aboutFact(u.theme, "Sync", "Local files and direct WebDAV", "Remote-file workflows are first-class and avoid browser-stack dependencies."),
layout.Spacer{Height: unit.Dp(10)}.Layout,
aboutFact(u.theme, "Programmatic Access", "Secure local gRPC API", "Built for trusted clients such as browser extensions and automation."),
layout.Spacer{Height: unit.Dp(14)}.Layout,
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "Version")
lbl.Color = mutedColor
return lbl.Layout(gtx)
},
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(14), currentAppVersion())
lbl.Color = u.theme.Palette.Fg
return lbl.Layout(gtx)
},
}
return material.List(u.theme, &u.detailList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions {
return rows[i](gtx)
})
}
func aboutFact(theme *material.Theme, title, primary, secondary string) layout.Widget {
return 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.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(theme, unit.Sp(12), strings.ToUpper(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(theme, unit.Sp(16), primary)
lbl.Color = theme.Palette.Fg
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(theme, unit.Sp(13), secondary)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
})
})
}
}
func (u *ui) syncButtonGroup(gtx layout.Context) layout.Dimensions {
label := "Sync"
spacing := unit.Dp(4)
@@ -4191,21 +4413,23 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
return panel(gtx, func(gtx layout.Context) layout.Dimensions {
visibleEntries, entryClicks := u.visibleEntrySnapshot()
rows := make([]layout.Widget, 0, 16+len(visibleEntries))
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return u.outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions {
editor := material.Editor(u.theme, &u.search, u.searchPlaceholder())
editor.Color = u.theme.Palette.Fg
editor.HintColor = mutedColor
return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout)
if u.state.Section != appstate.SectionAbout {
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return u.outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions {
editor := material.Editor(u.theme, &u.search, u.searchPlaceholder())
editor.Color = u.theme.Palette.Fg
editor.HintColor = mutedColor
return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout)
})
})
})
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
return layout.Spacer{Height: spacing}.Layout(gtx)
})
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
return layout.Spacer{Height: spacing}.Layout(gtx)
})
}
if !u.isVaultLocked() {
rows = append(rows, u.navigationHeader)
if u.state.Section == appstate.SectionEntries {
if u.state.Section == appstate.SectionEntries || u.state.Section == appstate.SectionAbout {
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
return layout.Spacer{Height: spacing}.Layout(gtx)
})
@@ -4235,6 +4459,8 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
return btn.Layout(gtx)
case appstate.SectionAPITokens:
return tonedButton(gtx, u.theme, &u.issueAPIToken, "Issue API Token")
case appstate.SectionAbout:
return emptyStatePanel(gtx, u.theme, u.listEmptyState())
default:
return layout.Dimensions{}
}
@@ -4248,6 +4474,7 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
rows = append(rows, u.apiTokenListPanel)
case u.state.Section == appstate.SectionAPIAudit:
rows = append(rows, u.apiAuditListPanel)
case u.state.Section == appstate.SectionAbout:
case len(visibleEntries) == 0:
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
return emptyStatePanel(gtx, u.theme, u.listEmptyState())
@@ -4274,6 +4501,18 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
return u.navigationHeader(gtx)
}),
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.state.Section == appstate.SectionAbout {
return emptyStatePanel(gtx, u.theme, u.listEmptyState())
}
return layout.Dimensions{}
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.state.Section == appstate.SectionAbout {
return layout.Dimensions{}
}
return layout.Spacer{Height: spacing}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.isVaultLocked() || (u.state.Section != appstate.SectionEntries && u.state.Section != appstate.SectionRecycleBin) {
return layout.Dimensions{}
@@ -4296,6 +4535,9 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
}),
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.state.Section == appstate.SectionAbout {
return layout.Dimensions{}
}
if u.mode == "phone" {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
}
@@ -4308,6 +4550,9 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
}),
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.state.Section == appstate.SectionAbout {
return layout.Dimensions{}
}
if u.isVaultLocked() {
return layout.Dimensions{}
}
@@ -4333,6 +4578,9 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
if u.state.Section == appstate.SectionAPIAudit {
return u.apiAuditListPanel(gtx)
}
if u.state.Section == appstate.SectionAbout {
return layout.Dimensions{}
}
if len(u.visible) == 0 {
return emptyStatePanel(gtx, u.theme, u.listEmptyState())
}
@@ -4348,13 +4596,23 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
func (u *ui) navigationHeader(gtx layout.Context) layout.Dimensions {
if u.mode == "phone" {
if u.state.Section != appstate.SectionEntries {
if u.state.Section != appstate.SectionEntries && u.state.Section != appstate.SectionAbout {
return layout.Dimensions{}
}
if u.state.Section == appstate.SectionAbout {
lbl := material.Label(u.theme, unit.Sp(18), "About")
lbl.Color = accentColor
return lbl.Layout(gtx)
}
return u.groupControlsDisclosure(gtx)
}
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
if u.state.Section == appstate.SectionAbout {
lbl := material.Label(u.theme, unit.Sp(18), "About")
lbl.Color = accentColor
return lbl.Layout(gtx)
}
return u.sectionBar(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
@@ -4629,7 +4887,6 @@ func (u *ui) detailPanelContent(gtx layout.Context) layout.Dimensions {
_ = panel
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, func() []layout.FlexChild {
if u.isVaultLocked() {
summary := u.currentVaultSummary()
return []layout.FlexChild{
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(18), "Unlock Vault")
@@ -4642,47 +4899,6 @@ func (u *ui) detailPanelContent(gtx layout.Context) layout.Dimensions {
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if strings.TrimSpace(summary.Title) == "" {
return 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(u.theme, unit.Sp(11), "UNLOCK TARGET")
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(15), summary.Title)
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if strings.TrimSpace(summary.Detail) == "" || summary.Detail == summary.Title {
return layout.Dimensions{}
}
return layout.Inset{Top: unit.Dp(2)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), summary.Detail)
lbl.Color = mutedColor
return lbl.Layout(gtx)
})
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if strings.TrimSpace(summary.Context) == "" {
return layout.Dimensions{}
}
return layout.Inset{Top: unit.Dp(2)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), summary.Context)
lbl.Color = mutedColor
return lbl.Layout(gtx)
})
}),
)
})
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(u.unlockPanel),
}
@@ -4697,6 +4913,11 @@ func (u *ui) detailPanelContent(gtx layout.Context) layout.Dimensions {
layout.Flexed(1, u.apiAuditDetailPanel),
}
}
if u.state.Section == appstate.SectionAbout {
return []layout.FlexChild{
layout.Flexed(1, u.aboutDetailPanel),
}
}
item, ok := u.selectedEntry()
if !ok && !u.editingEntry {
return []layout.FlexChild{
@@ -5409,32 +5630,22 @@ func (u *ui) groupBar(gtx layout.Context) layout.Dimensions {
if atRoot {
u.phoneGroupBrowserExpanded = true
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if len(groups) == 0 {
return layout.Dimensions{}
}
maxY := gtx.Dp(unit.Dp(168))
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.groupList).Layout(gtx, len(groups), func(gtx layout.Context, i int) layout.Dimensions {
idx := i
name := groups[i]
return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
for u.groupClicks[idx].Clicked(gtx) {
u.state.EnterGroup(name)
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.filter()
}
return tonedButton(gtx, u.theme, &u.groupClicks[idx], name)
})
children := make([]layout.FlexChild, 0, len(groups))
for i := range groups {
idx := i
name := groups[i]
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
for u.groupClicks[idx].Clicked(gtx) {
u.state.EnterGroup(name)
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.filter()
}
return tonedButton(gtx, u.theme, &u.groupClicks[idx], name)
})
}),
)
}))
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
@@ -5863,7 +6074,9 @@ 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 = newPlatformClipboardWriter(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)
@@ -5876,6 +6089,7 @@ func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
}
for {
e := w.Event()
ui.fileExplorer.ListenEvents(e)
switch e := e.(type) {
case app.DestroyEvent:
return e.Err
@@ -5883,6 +6097,7 @@ func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
gtx := app.NewContext(&ops, e)
ui.processBackgroundActions()
ui.layout(gtx)
processClipboardWrites(gtx, ui.clipboardWriter)
e.Frame(gtx.Ops)
}
}
+626 -283
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 = r160.5fa79bd
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,65 @@
pkgname=keepassgo-git
pkgver=r0.0000000
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() {
if [[ -d "${srcdir}/keepassgo/.git" ]]; then
printf '%s\n' "${srcdir}/keepassgo"
return
fi
cd "${startdir}/../../.." || exit 1
pwd
}
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 main.appVersion=${app_version}" -o keepassgo .
}
package() {
cd "$(_repo_dir)"
install -Dm755 keepassgo "${pkgdir}/usr/bin/keepassgo"
install -Dm644 assets/keepassgo-icon.png \
"${pkgdir}/usr/share/icons/hicolor/512x512/apps/keepassgo.png"
install -Dm644 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())
+24
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"reflect"
@@ -177,6 +178,18 @@ func (m *Manager) SynchronizeFromLocal(path string) error {
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 {
other, config, err := loadLocalSourceOrEmpty(path, m.key)
if err != nil {
@@ -908,6 +921,17 @@ func loadLocalSource(path string, key vault.MasterKey) (vault.Model, *vault.KDBX
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) {
model, config, err := loadLocalSource(path, key)
if err == nil {
+140 -83
View File
@@ -24,10 +24,10 @@ func TestCreateSaveAsLockAndUnlockRoundTripsVault(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
@@ -65,8 +65,8 @@ func TestCreateSaveAsLockAndUnlockRoundTripsVault(t *testing.T) {
}
got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 1 || got[0].Title != "Git Server" || got[0].Password != "token-1" {
t.Fatalf("Current() entries = %#v, want persisted Git Server entry", got)
if len(got) != 1 || got[0].Title != "Vault Console" || got[0].Password != "token-1" {
t.Fatalf("Current() entries = %#v, want persisted Vault Console entry", got)
}
}
@@ -78,10 +78,10 @@ func TestOpenLoadsExistingKDBXFromDisk(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Home Assistant (Codex)",
Title: "Surveillance Console",
Username: "codex",
Password: "token-2",
URL: "https://lights.julianfamily.org",
URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"},
},
},
@@ -124,10 +124,10 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
@@ -146,10 +146,10 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
updated := model
updated.UpsertEntry(vault.Entry{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-2",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
})
sess.Replace(updated)
@@ -186,19 +186,19 @@ func TestSaveReparentsMixedPathsUnderSingleVaultRoot(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://git.julianfamily.org",
Path: []string{"keepass", "Joe", "Internet"},
URL: "https://vault.crew.example.invalid",
Path: []string{"keepass", "Crew", "Internet"},
},
{
ID: "entry-2",
Title: "Mail",
Username: "joejulian",
Username: "dannyocean",
Password: "token-2",
URL: "https://mail.julianfamily.org",
Path: []string{"keepass", "Joe", "eMail"},
URL: "https://dispatch.crew.example.invalid",
Path: []string{"keepass", "Crew", "eMail"},
},
},
}, key.Password); err != nil {
@@ -217,8 +217,8 @@ func TestSaveReparentsMixedPathsUnderSingleVaultRoot(t *testing.T) {
if err != nil {
t.Fatalf("Current() error = %v", err)
}
current.Entries[0].Path = []string{"Joe", "Internet"}
current.Groups = append(current.Groups, []string{"Joe"}, []string{"Joe", "Internet"}, []string{"Joe", "eMail"})
current.Entries[0].Path = []string{"Crew", "Internet"}
current.Groups = append(current.Groups, []string{"Crew"}, []string{"Crew", "Internet"}, []string{"Crew", "eMail"})
sess.Replace(current)
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)
}
rootGroups := db.Content.Root.Groups[0].Groups
if len(rootGroups) != 1 || rootGroups[0].Name != "Joe" {
t.Fatalf("keepass child groups = %#v, want single Joe group", rootGroups)
if len(rootGroups) != 1 || rootGroups[0].Name != "Crew" {
t.Fatalf("keepass child groups = %#v, want single Crew group", rootGroups)
}
}
@@ -271,10 +271,10 @@ func TestOpenRemoteLoadsExistingKDBXFromWebDAV(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
@@ -309,7 +309,7 @@ func TestOpenRemoteLoadsExistingKDBXFromWebDAV(t *testing.T) {
got := current.EntriesInPath([]string{"Root", "Internet"})
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{
{
ID: "entry-1",
Title: "Home Assistant (Codex)",
Title: "Surveillance Console",
Username: "codex",
Password: "token-1",
URL: "https://lights.julianfamily.org",
URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"},
},
},
@@ -371,10 +371,10 @@ func TestSaveRemotePersistsEditsBackToWebDAV(t *testing.T) {
}
current.UpsertEntry(vault.Entry{
ID: "entry-1",
Title: "Home Assistant (Codex)",
Title: "Surveillance Console",
Username: "codex",
Password: "token-2",
URL: "https://lights.julianfamily.org",
URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"},
})
sess.Replace(current)
@@ -406,10 +406,10 @@ func TestSaveUsesRemoteTargetWhenVaultWasOpenedFromWebDAV(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
@@ -448,10 +448,10 @@ func TestSaveUsesRemoteTargetWhenVaultWasOpenedFromWebDAV(t *testing.T) {
}
current.UpsertEntry(vault.Entry{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-2",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
})
sess.Replace(current)
@@ -477,10 +477,10 @@ func TestChangeMasterKeyReencryptsSavedAndLockedVault(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
@@ -514,8 +514,8 @@ func TestChangeMasterKeyReencryptsSavedAndLockedVault(t *testing.T) {
t.Fatalf("Current() error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 1 || got[0].Title != "Git Server" {
t.Fatalf("Current() entries = %#v, want Git Server entry after ChangeMasterKey", got)
if len(got) != 1 || got[0].Title != "Vault Console" {
t.Fatalf("Current() entries = %#v, want Vault Console entry after ChangeMasterKey", got)
}
var reopened Manager
@@ -540,10 +540,10 @@ func TestSavePreservesOpenedKDBXSecuritySettings(t *testing.T) {
{
UUID: gokeepasslib.NewUUID(),
Values: []gokeepasslib.ValueData{
{Key: "Title", Value: gokeepasslib.V{Content: "Git Server"}},
{Key: "UserName", Value: gokeepasslib.V{Content: "joejulian"}},
{Key: "Title", Value: gokeepasslib.V{Content: "Vault Console"}},
{Key: "UserName", Value: gokeepasslib.V{Content: "dannyocean"}},
{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{
ID: current.Entries[0].ID,
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-2",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: current.Entries[0].Path,
})
sess.Replace(current)
@@ -620,10 +620,10 @@ func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-2",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
Attachments: map[string][]byte{
"token.txt": []byte("secret attachment contents"),
@@ -631,10 +631,10 @@ func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
History: []vault.Entry{
{
ID: "entry-1-history-1",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
@@ -778,10 +778,10 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
@@ -841,10 +841,10 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
}
firstCurrent.UpsertEntry(vault.Entry{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "remote-token-2",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Notes: "updated remotely first",
Path: []string{"Root", "Internet"},
})
@@ -859,10 +859,10 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
}
secondCurrent.UpsertEntry(vault.Entry{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "local-token-2",
URL: "https://git.julianfamily.org/settings/tokens",
URL: "https://vault.crew.example.invalid/security/badges",
Path: []string{"Root", "Internet"},
})
second.Replace(secondCurrent)
@@ -883,7 +883,7 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
if len(got) != 1 {
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])
}
if len(got[0].History) == 0 {
@@ -905,10 +905,10 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-current",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-current",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
@@ -917,10 +917,10 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-other",
Title: "Dynadot",
Username: "jjulian",
Title: "Bellagio",
Username: "rustyryan",
Password: "token-other",
URL: "https://www.dynadot.com",
URL: "https://bellagio.example.invalid",
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) {
t.Parallel()
@@ -964,10 +1021,10 @@ func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-current",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-current",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
@@ -976,10 +1033,10 @@ func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-other",
Title: "Dynadot",
Username: "jjulian",
Title: "Bellagio",
Username: "rustyryan",
Password: "token-other",
URL: "https://www.dynadot.com",
URL: "https://bellagio.example.invalid",
Path: []string{"Root", "Internet"},
},
},
@@ -1021,10 +1078,10 @@ func TestSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-current",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-current",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
@@ -1033,10 +1090,10 @@ func TestSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-remote",
Title: "Dynadot",
Username: "jjulian",
Title: "Bellagio",
Username: "rustyryan",
Password: "token-remote",
URL: "https://www.dynadot.com",
URL: "https://bellagio.example.invalid",
Path: []string{"Root", "Internet"},
},
},
+290 -134
View File
@@ -20,6 +20,9 @@ import (
func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
busy := u.lifecycleBusy()
showLocalChooser := u.showLocalVaultChooser()
showRemoteChooser := u.showRemoteConnectionChooser()
selectedLocalPath := strings.TrimSpace(u.vaultPath.Text())
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "OPEN A VAULT")
@@ -59,173 +62,172 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
if u.lifecycleMode == "remote" {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showRemoteChooser {
return 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()) == "" {
if !showRemoteChooser {
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...")
}),
)
})
})
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if busy {
if !showRemoteChooser {
return layout.Dimensions{}
}
return u.recentRemoteList(gtx)
return labeledEditorHelp(u.theme, "Remote Base URL", "Base WebDAV endpoint, for example https://server/remote.php/webdav.", &u.remoteBaseURL, false)(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showRemoteChooser {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showRemoteChooser {
return layout.Dimensions{}
}
return labeledEditorHelp(u.theme, "Remote Path", "Path to the remote .kdbx file under the WebDAV base URL.", &u.remotePath, false)(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showRemoteChooser {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if showRemoteChooser || !u.hasSelectedRemoteTarget() {
return layout.Dimensions{}
}
return layout.Dimensions{}
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if showRemoteChooser && !busy {
return u.recentRemoteList(gtx)
}
return layout.Dimensions{}
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showRemoteChooser {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(10)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showRemoteChooser {
return 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 {
if !showRemoteChooser {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showRemoteChooser {
return layout.Dimensions{}
}
return labeledEditorHelp(u.theme, "Remote Username", "Username used to authenticate to the WebDAV server.", &u.remoteUsername, false)(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showRemoteChooser {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showRemoteChooser {
return 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 {
if !showRemoteChooser {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showRemoteChooser {
return 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 {
if !showRemoteChooser {
return 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),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showRemoteChooser {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
}),
)
}
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(layout.Spacer{Height: unit.Dp(4)}.Layout),
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(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showLocalChooser {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
}),
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(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
selectedPath := strings.TrimSpace(u.vaultPath.Text())
if !showLocalChooser {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
switch {
case busy:
return labeledEditorHelp(u.theme, "Vault Path", localVaultPathHelp(), &u.vaultPath, false)(gtx)
case selectedPath == "":
case selectedLocalPath == "":
return localPathSelector(u.theme, &u.vaultPath, &u.pickVaultPath)(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...")
}),
)
})
})
return layout.Dimensions{}
}
}),
)
@@ -296,10 +298,26 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
layout.Rigid(func(gtx layout.Context) 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,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if busy {
return passiveTonedButton(gtx, u.theme, label)
}
return tonedButton(gtx, u.theme, &u.openRemote, label)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if busy || !u.hasSelectedRemoteTarget() {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if busy || !u.hasSelectedRemoteTarget() {
return layout.Dimensions{}
}
return u.selectedRemoteConnectionCard(gtx)
}),
)
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
@@ -325,11 +343,118 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
}
return sectionTabButton(gtx, u.theme, &u.createVault, "Create New Vault", false)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if busy || selectedLocalPath == "" {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if busy || selectedLocalPath == "" {
return layout.Dimensions{}
}
return u.selectedLocalVaultCard(gtx, selectedLocalPath)
}),
)
}),
)
}
func (u *ui) selectedRemoteConnectionCard(gtx layout.Context) 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(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.clearRemoteSelection, "Open Different Connection")
}),
)
})
})
}
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(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) 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."
}
@@ -1212,12 +1337,14 @@ func selectorEditorHelp(th *material.Theme, label, help string, editor *widget.E
func (u *ui) unlockPanel(gtx layout.Context) layout.Dimensions {
targetLabel := "Locked vault"
targetValue := "Unlock the active vault to continue."
changeLabel := "Open Different Vault"
if u.state.Session != nil {
if strings.TrimSpace(u.remoteBaseURL.Text()) != "" || strings.TrimSpace(u.remotePath.Text()) != "" {
baseURL := strings.TrimSpace(u.remoteBaseURL.Text())
path := strings.TrimSpace(u.remotePath.Text())
targetLabel = "Remote vault"
targetValue = friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: path})
changeLabel = "Open Different Connection"
if strings.TrimSpace(targetValue) == "" {
targetValue = "Remote WebDAV vault"
}
@@ -1233,25 +1360,42 @@ func (u *ui) unlockPanel(gtx layout.Context) layout.Dimensions {
}
}
}
targetCard := 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.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), strings.ToUpper(targetLabel))
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.Body1(u.theme, targetValue)
lbl.Color = accentColor
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 {
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), strings.ToUpper(targetLabel))
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.Body1(u.theme, targetValue)
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
)
})
})
if u.mode == "desktop" {
return layout.Dimensions{}
}
return targetCard(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
@@ -1263,6 +1407,18 @@ func (u *ui) unlockPanel(gtx layout.Context) layout.Dimensions {
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
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)
}),
)
}
+28 -28
View File
@@ -8,11 +8,11 @@ func TestUpsertEntryPreservesPreviousVersionInHistory(t *testing.T) {
model := Model{
Entries: []Entry{
{
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "old-token",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Notes: "Original note",
Path: []string{"Root", "Internet"},
},
@@ -20,11 +20,11 @@ func TestUpsertEntryPreservesPreviousVersionInHistory(t *testing.T) {
}
model.UpsertEntry(Entry{
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "new-token",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Notes: "Updated note",
Path: []string{"Root", "Internet"},
})
@@ -53,17 +53,17 @@ func TestDeleteEntryMovesItToRecycleBin(t *testing.T) {
model := Model{
Entries: []Entry{
{
ID: "ha-codex",
Title: "Home Assistant (Codex)",
ID: "surveillance-console",
Title: "Surveillance Console",
Username: "codex",
Password: "token-2",
URL: "https://lights.julianfamily.org",
URL: "https://surveillance.crew.example.invalid",
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)
}
@@ -75,8 +75,8 @@ func TestDeleteEntryMovesItToRecycleBin(t *testing.T) {
t.Fatalf("len(RecycleBin) = %d, want 1", len(model.RecycleBin))
}
if model.RecycleBin[0].Title != "Home Assistant (Codex)" {
t.Fatalf("RecycleBin[0].Title = %q, want %q", 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, "Surveillance Console")
}
}
@@ -86,17 +86,17 @@ func TestRestoreEntryMovesItBackFromRecycleBin(t *testing.T) {
model := Model{
RecycleBin: []Entry{
{
ID: "dynadot",
Title: "Dynadot",
Username: "jjulian",
ID: "bellagio",
Title: "Bellagio",
Username: "rustyryan",
Password: "token-3",
URL: "https://www.dynadot.com",
URL: "https://bellagio.example.invalid",
Path: []string{"Root", "Internet"},
},
},
}
if err := model.RestoreEntry("dynadot"); err != nil {
if err := model.RestoreEntry("bellagio"); err != nil {
t.Fatalf("RestoreEntry() error = %v", err)
}
@@ -105,8 +105,8 @@ func TestRestoreEntryMovesItBackFromRecycleBin(t *testing.T) {
t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got))
}
if got[0].Title != "Dynadot" {
t.Fatalf("EntriesInPath()[0].Title = %q, want %q", got[0].Title, "Dynadot")
if got[0].Title != "Bellagio" {
t.Fatalf("EntriesInPath()[0].Title = %q, want %q", got[0].Title, "Bellagio")
}
if len(model.RecycleBin) != 0 {
@@ -120,17 +120,17 @@ func TestRestoreEntryVersionPromotesHistoricalVersionAndRetainsCurrentInHistory(
model := Model{
Entries: []Entry{
{
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "new-token",
Notes: "Current note",
Path: []string{"Root", "Internet"},
History: []Entry{
{
ID: "git-server-history-1",
Title: "Git Server",
Username: "joejulian",
ID: "vault-console-history-1",
Title: "Vault Console",
Username: "dannyocean",
Password: "old-token",
Notes: "Previous note",
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)
}
+46 -46
View File
@@ -22,11 +22,11 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
Groups: []gokeepasslib.Group{
mustGroup("Root",
mustGroup("Internet",
mustEntry("Dynadot", "jjulian", "https://www.dynadot.com", "hunter2"),
mustEntry("Git Server", "joejulian", "https://git.julianfamily.org", "token-1"),
mustEntry("Bellagio", "rustyryan", "https://bellagio.example.invalid", "hunter2"),
mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"),
),
mustGroup("Home Assistant",
mustEntry("Home Assistant (Codex)", "codex", "https://lights.julianfamily.org", "token-2"),
mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2"),
),
),
},
@@ -53,11 +53,11 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
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])
}
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])
}
@@ -70,7 +70,7 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
func TestLoadKDBXPreservesEntryDetails(t *testing.T) {
t.Parallel()
entry := mustEntry("Home Assistant (Codex)", "codex", "https://lights.julianfamily.org", "token-2")
entry := mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2")
entry.Tags = "automation; home"
entry.Values = append(entry.Values,
mkValue("Notes", "Long-lived token used by Codex for home automation tasks."),
@@ -133,10 +133,10 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
Entries: []Entry{
{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Notes: "Personal git server token entry used for automation and CLI auth.",
Tags: []string{"git", "infra"},
Fields: map[string]string{
@@ -146,10 +146,10 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
},
{
ID: "entry-2",
Title: "Home Assistant (Codex)",
Title: "Surveillance Console",
Username: "codex",
Password: "token-2",
URL: "https://lights.julianfamily.org",
URL: "https://surveillance.crew.example.invalid",
Notes: "Long-lived token used by Codex for home automation tasks.",
Tags: []string{"automation", "home"},
Path: []string{"Root", "Home Assistant"},
@@ -167,7 +167,7 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
t.Fatalf("LoadKDBX() error = %v", err)
}
got := loaded.Search("git")
got := loaded.Search("vault")
if len(got) != 1 {
t.Fatalf("len(Search(\"git\")) = %d, want 1", len(got))
}
@@ -245,18 +245,18 @@ func TestSaveKDBXRoundTripsEntryHistory(t *testing.T) {
Entries: []Entry{
{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "new-token",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
History: []Entry{
{
ID: "entry-1-old",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "old-token",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
Notes: "Original version",
},
@@ -296,10 +296,10 @@ func TestSaveKDBXRoundTripsRecycleBinEntries(t *testing.T) {
RecycleBin: []Entry{
{
ID: "entry-1",
Title: "Home Assistant (Codex)",
Title: "Surveillance Console",
Username: "codex",
Password: "token-2",
URL: "https://lights.julianfamily.org",
URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"},
},
},
@@ -319,8 +319,8 @@ func TestSaveKDBXRoundTripsRecycleBinEntries(t *testing.T) {
t.Fatalf("len(RecycleBin) = %d, want 1", len(loaded.RecycleBin))
}
if loaded.RecycleBin[0].Title != "Home Assistant (Codex)" {
t.Fatalf("RecycleBin[0].Title = %q, want %q", 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, "Surveillance Console")
}
if len(loaded.RecycleBin[0].Path) != 2 || loaded.RecycleBin[0].Path[0] != "Root" || loaded.RecycleBin[0].Path[1] != "Home Assistant" {
@@ -358,7 +358,7 @@ func TestLoadKDBXWithKeyFileCredentials(t *testing.T) {
Meta: gokeepasslib.NewMetaData(),
Root: &gokeepasslib.RootData{
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", "token-1"))),
},
},
},
@@ -378,9 +378,9 @@ func TestLoadKDBXWithKeyFileCredentials(t *testing.T) {
t.Fatalf("LoadKDBXWithKey() error = %v", err)
}
got := model.Search("git")
got := model.Search("vault")
if len(got) != 1 || got[0].Entry.Password != "token-1" {
t.Fatalf("LoadKDBXWithKey() = %#v, want password-preserving git entry", got)
t.Fatalf("LoadKDBXWithKey() = %#v, want password-preserving vault entry", got)
}
}
@@ -413,7 +413,7 @@ func TestLoadKDBXWithCompositeCredentials(t *testing.T) {
Meta: gokeepasslib.NewMetaData(),
Root: &gokeepasslib.RootData{
Groups: []gokeepasslib.Group{
mustGroup("Root", mustGroup("Home Assistant", mustEntry("Home Assistant (Codex)", "codex", "https://lights.julianfamily.org", "token-2"))),
mustGroup("Root", mustGroup("Home Assistant", mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2"))),
},
},
},
@@ -452,7 +452,7 @@ func TestLoadKDBXReturnsInvalidCredentialsError(t *testing.T) {
Meta: gokeepasslib.NewMetaData(),
Root: &gokeepasslib.RootData{
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", "token-1"))),
},
},
},
@@ -490,11 +490,11 @@ func TestSaveKDBXWithKeyRoundTripsModel(t *testing.T) {
model := Model{
Entries: []Entry{
{
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
},
},
@@ -510,9 +510,9 @@ func TestSaveKDBXWithKeyRoundTripsModel(t *testing.T) {
t.Fatalf("LoadKDBXWithKey() error = %v", err)
}
got := loaded.Search("git")
got := loaded.Search("vault")
if len(got) != 1 || got[0].Entry.Password != "token-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,11 +533,11 @@ func TestSaveKDBXWithCompositeKeyRoundTripsModel(t *testing.T) {
model := Model{
Entries: []Entry{
{
ID: "ha-codex",
Title: "Home Assistant (Codex)",
ID: "surveillance-console",
Title: "Surveillance Console",
Username: "codex",
Password: "token-2",
URL: "https://lights.julianfamily.org",
URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"},
},
},
@@ -570,11 +570,11 @@ func TestKDBXRoundTripsEntryAttachments(t *testing.T) {
model := Model{
Entries: []Entry{
{
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
Attachments: map[string][]byte{
"token.txt": []byte("secret attachment contents"),
@@ -610,10 +610,10 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
Entries: []Entry{
{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-2",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Notes: "Current credential",
Path: []string{"Root", "Internet"},
Attachments: map[string][]byte{
@@ -622,10 +622,10 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
History: []Entry{
{
ID: "entry-1-history-1",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://git.julianfamily.org",
URL: "https://vault.crew.example.invalid",
Notes: "Original credential",
Path: []string{"Root", "Internet"},
},
+49 -49
View File
@@ -9,9 +9,9 @@ import (
func testModel() Model {
return Model{
Entries: []Entry{
{ID: "1", Title: "Dynadot", Username: "jjulian", URL: "https://www.dynadot.com", Path: []string{"Joe", "Internet"}},
{ID: "2", Title: "Git Server", Username: "joejulian", URL: "https://git.julianfamily.org", Path: []string{"Joe", "Internet"}},
{ID: "3", Title: "Home Assistant (Codex)", Username: "codex", URL: "https://lights.julianfamily.org", Path: []string{"Joe", "Home Assistant"}},
{ID: "1", Title: "Bellagio", Username: "rustyryan", URL: "https://bellagio.example.invalid", Path: []string{"Crew", "Internet"}},
{ID: "2", Title: "Vault Console", Username: "dannyocean", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
{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"}},
},
}
@@ -20,7 +20,7 @@ func testModel() Model {
func TestChildGroupsReturnsImmediateGroupsOnly(t *testing.T) {
model := testModel()
got := model.ChildGroups([]string{"Joe"})
got := model.ChildGroups([]string{"Crew"})
want := []string{"Home Assistant", "Internet"}
if !slices.Equal(got, want) {
@@ -31,12 +31,12 @@ func TestChildGroupsReturnsImmediateGroupsOnly(t *testing.T) {
func TestEntriesInPathReturnsOnlyDirectEntries(t *testing.T) {
model := testModel()
got := model.EntriesInPath([]string{"Joe", "Internet"})
got := model.EntriesInPath([]string{"Crew", "Internet"})
if len(got) != 2 {
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)
}
}
@@ -45,29 +45,29 @@ func TestEntriesUnderPathReturnsDescendantEntries(t *testing.T) {
t.Parallel()
model := testModel()
got := model.EntriesUnderPath([]string{"Joe"})
got := model.EntriesUnderPath([]string{"Crew"})
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)" {
t.Fatalf("EntriesUnderPath(Joe) titles = %q, %q, %q", got[0].Title, got[1].Title, got[2].Title)
if got[0].Title != "Bellagio" || got[1].Title != "Surveillance Console" || got[2].Title != "Vault Console" {
t.Fatalf("EntriesUnderPath(Crew) titles = %q, %q, %q", got[0].Title, got[1].Title, got[2].Title)
}
}
func TestSearchReturnsMatchesWithFullPathContext(t *testing.T) {
model := testModel()
got := model.Search("git")
got := model.Search("vault")
if len(got) != 1 {
t.Fatalf("len(Search()) = %d, want 1", len(got))
}
if got[0].Entry.Title != "Git Server" {
t.Fatalf("Search() title = %q, want %q", got[0].Entry.Title, "Git Server")
if got[0].Entry.Title != "Vault Console" {
t.Fatalf("Search() title = %q, want %q", got[0].Entry.Title, "Vault Console")
}
if got[0].Path != "Joe / Internet" {
t.Fatalf("Search() path = %q, want %q", got[0].Path, "Joe / Internet")
if got[0].Path != "Crew / 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{
ID: "entry-1",
Title: "Dynadot",
Username: "jjulian",
Title: "Bellagio",
Username: "rustyryan",
Password: "hunter2",
URL: "https://www.dynadot.com",
Path: []string{"Joe", "Internet"},
URL: "https://bellagio.example.invalid",
Path: []string{"Crew", "Internet"},
Tags: []string{"dns"},
})
if err != nil {
@@ -134,11 +134,11 @@ func TestInstantiateTemplateCreatesNormalEntryWithOverrides(t *testing.T) {
t.Fatalf("entry.ID = %q, want %q", entry.ID, "entry-1")
}
if entry.Title != "Dynadot" {
t.Fatalf("entry.Title = %q, want %q", entry.Title, "Dynadot")
if entry.Title != "Bellagio" {
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)
}
@@ -154,9 +154,9 @@ func TestInstantiateTemplateCreatesNormalEntryWithOverrides(t *testing.T) {
t.Fatalf("entry.Fields[Environment] = %q, want %q", entry.Fields["Environment"], "prod")
}
got := model.EntriesInPath([]string{"Joe", "Internet"})
if len(got) != 1 || got[0].Title != "Dynadot" {
t.Fatalf("EntriesInPath() = %#v, want instantiated Dynadot entry", got)
got := model.EntriesInPath([]string{"Crew", "Internet"})
if len(got) != 1 || got[0].Title != "Bellagio" {
t.Fatalf("EntriesInPath() = %#v, want instantiated Bellagio entry", got)
}
}
@@ -178,7 +178,7 @@ func TestDeleteTemplateRemovesTemplateWithoutTouchingEntries(t *testing.T) {
model := Model{
Entries: []Entry{
{ID: "entry-1", Title: "Git Server", Path: []string{"Root", "Internet"}},
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
Templates: []Entry{
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates"}},
@@ -222,8 +222,8 @@ func TestDuplicateEntryCopiesEntryWithNewIDAndTitle(t *testing.T) {
Entries: []Entry{
{
ID: "entry-1",
Title: "Git Server",
Username: "joejulian",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
Path: []string{"Root", "Internet"},
},
@@ -238,8 +238,8 @@ func TestDuplicateEntryCopiesEntryWithNewIDAndTitle(t *testing.T) {
if duplicate.ID != "entry-2" {
t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "entry-2")
}
if duplicate.Title != "Git Server (Copy)" {
t.Fatalf("duplicate.Title = %q, want %q", duplicate.Title, "Git Server (Copy)")
if duplicate.Title != "Vault Console (Copy)" {
t.Fatalf("duplicate.Title = %q, want %q", duplicate.Title, "Vault Console (Copy)")
}
got := model.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 2 {
@@ -250,9 +250,9 @@ func TestDuplicateEntryCopiesEntryWithNewIDAndTitle(t *testing.T) {
func TestCreateGroupMakesItVisibleAsChildGroup(t *testing.T) {
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"}
if !slices.Equal(got, want) {
t.Fatalf("ChildGroups() = %v, want %v", got, want)
@@ -263,32 +263,32 @@ func TestCreateGroupSupportsNestedRelativePath(t *testing.T) {
t.Parallel()
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"}) {
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"}) {
t.Fatalf("ChildGroups(Joe/Infrastructure) = %v, want [Prod]", got)
t.Fatalf("ChildGroups(Crew/Infrastructure) = %v, want [Prod]", got)
}
}
func TestRenameGroupMovesEntriesAndKeepsHierarchy(t *testing.T) {
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)
}
got := model.EntriesInPath([]string{"Joe", "Infra"})
got := model.EntriesInPath([]string{"Crew", "Infra"})
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 {
t.Fatal("EntriesInPath(Joe/Internet) should be empty after rename")
if len(model.EntriesInPath([]string{"Crew", "Internet"})) != 0 {
t.Fatal("EntriesInPath(Crew/Internet) should be empty after rename")
}
}
@@ -309,8 +309,8 @@ func TestMoveGroupMovesEntriesAndNestedGroups(t *testing.T) {
t.Parallel()
model := testModel()
model.CreateGroup([]string{"Joe", "Internet"}, "Infrastructure")
if err := model.MoveGroup([]string{"Joe", "Internet"}, []string{"Tricia"}); err != nil {
model.CreateGroup([]string{"Crew", "Internet"}, "Infrastructure")
if err := model.MoveGroup([]string{"Crew", "Internet"}, []string{"Tricia"}); err != nil {
t.Fatalf("MoveGroup() error = %v", err)
}
@@ -318,8 +318,8 @@ func TestMoveGroupMovesEntriesAndNestedGroups(t *testing.T) {
if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Tricia/Internet)) = %d, want 2", len(got))
}
if len(model.EntriesInPath([]string{"Joe", "Internet"})) != 0 {
t.Fatal("EntriesInPath(Joe/Internet) should be empty after move")
if len(model.EntriesInPath([]string{"Crew", "Internet"})) != 0 {
t.Fatal("EntriesInPath(Crew/Internet) should be empty after move")
}
gotGroups := model.ChildGroups([]string{"Tricia", "Internet"})
if !slices.Equal(gotGroups, []string{"Infrastructure"}) {
@@ -330,12 +330,12 @@ func TestMoveGroupMovesEntriesAndNestedGroups(t *testing.T) {
func TestDeleteEmptyGroupRemovesItFromNavigation(t *testing.T) {
model := testModel()
model.CreateGroup([]string{"Joe"}, "Finance")
if err := model.DeleteGroup([]string{"Joe", "Finance"}); err != nil {
model.CreateGroup([]string{"Crew"}, "Finance")
if err := model.DeleteGroup([]string{"Crew", "Finance"}); err != nil {
t.Fatalf("DeleteGroup() error = %v", err)
}
got := model.ChildGroups([]string{"Joe"})
got := model.ChildGroups([]string{"Crew"})
want := []string{"Home Assistant", "Internet"}
if !slices.Equal(got, want) {
t.Fatalf("ChildGroups() = %v, want %v", got, want)
+5 -5
View File
@@ -15,8 +15,8 @@ func TestClientOpenDownloadsRemoteVault(t *testing.T) {
if r.Method != http.MethodGet {
t.Fatalf("method = %s, want GET", r.Method)
}
if user, pass, ok := r.BasicAuth(); !ok || user != "jjulian" || pass != "secret" {
t.Fatalf("basic auth = %q/%q ok=%v, want jjulian/secret true", user, pass, ok)
if user, pass, ok := r.BasicAuth(); !ok || user != "rustyryan" || pass != "secret" {
t.Fatalf("basic auth = %q/%q ok=%v, want rustyryan/secret true", user, pass, ok)
}
w.Header().Set("ETag", `"etag-1"`)
_, _ = io.WriteString(w, "vault-bytes")
@@ -26,7 +26,7 @@ func TestClientOpenDownloadsRemoteVault(t *testing.T) {
client := Client{
HTTPClient: server.Client(),
BaseURL: server.URL,
Username: "jjulian",
Username: "rustyryan",
Password: "secret",
}
@@ -69,7 +69,7 @@ func TestClientSaveUploadsVaultWithIfMatch(t *testing.T) {
client := Client{
HTTPClient: server.Client(),
BaseURL: server.URL,
Username: "jjulian",
Username: "rustyryan",
Password: "secret",
}
@@ -94,7 +94,7 @@ func TestClientSaveReturnsConflictOnVersionMismatch(t *testing.T) {
client := Client{
HTTPClient: server.Client(),
BaseURL: server.URL,
Username: "jjulian",
Username: "rustyryan",
Password: "secret",
}