Compare commits

..

163 Commits

Author SHA1 Message Date
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
30 changed files with 2310 additions and 896 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
+173
View File
@@ -0,0 +1,173 @@
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
# 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 -o "${out}" .
done
- name: Build APK
shell: bash
run: |
set -euo pipefail
make apk
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 ## Skills
- Use the installed Go skills whenever they materially apply to the current slice of work. - Use the installed Go skills whenever they materially apply to the current slice of work.
- Use the installed `android-emulator-debug` skill for Android emulator lifecycle, `adb`, screenshots, and log capture work.
- Use the repo-local `keepassgo-apk-test` skill for KeePassGO-specific APK build and emulator test runs.
- The available Go skills for this repository are: - The available Go skills for this repository are:
`go-code-review`, `go-code-review`,
`go-concurrency`, `go-concurrency`,
@@ -126,6 +128,20 @@ These features are product requirements, not “nice to have” ideas.
- Keep `golangci-lint` passing. - Keep `golangci-lint` passing.
- Keep `go test ./...` passing. - Keep `go test ./...` passing.
- Track `gogio` as a Go tool and keep a reproducible `make apk` path for Android packaging. - Track `gogio` as a Go tool and keep a reproducible `make apk` path for Android packaging.
- Keep the Android build requirements aligned with the known working setup:
`ANDROID_SDK_ROOT=/opt/android-sdk`,
local `ANDROID_NDK_ROOT=/opt/android-ndk`,
CI `ANDROID_NDK_ROOT=/opt/android-sdk/ndk`,
local `JAVA_HOME=/usr/lib/jvm/java-25-openjdk`,
CI `JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64`.
- Remember the known Android runtime regression:
`gioui.org v0.9.0` produced a black screen on the `KeepassGoAPI35` emulator, while `gioui.org v0.8.0` rendered correctly. Treat Gio upgrades on Android as regression-sensitive and verify them on-device or in the emulator.
- When validating an APK in the emulator, prefer the known KeePassGO setup:
AVD `KeepassGoAPI35`,
package `org.julianfamily.keepassgo`,
activity `org.gioui.GioActivity`.
- Do not run emulator/manual APK tests against the users real persisted app state.
Use an isolated `KEEPASSGO_STATE_DIR` for host-side validation, and when emulator testing requires seeded vault data, use sanitized test/demo vaults rather than the users real vault files whenever possible.
- When running tests or other automated validation that may touch persisted UI state, set `KEEPASSGO_STATE_DIR` to an isolated temporary directory so recent-vault history and other local state do not pollute the users real config. - When running tests or other automated validation that may touch persisted UI state, set `KEEPASSGO_STATE_DIR` to an isolated temporary directory so recent-vault history and other local state do not pollute the users real config.
- Prefer commands shaped like `KEEPASSGO_STATE_DIR=\"$(mktemp -d)\" go test ./...` for ad hoc local validation unless a test already manages its own isolated state directory. - Prefer commands shaped like `KEEPASSGO_STATE_DIR=\"$(mktemp -d)\" go test ./...` for ad hoc local validation unless a test already manages its own isolated state directory.
- Do not assume the agent can decrypt SOPS-encrypted secrets in this repository. - Do not assume the agent can decrypt SOPS-encrypted secrets in this repository.
+18
View File
@@ -41,6 +41,24 @@ Desktop build:
go build ./... go build ./...
``` ```
## Arch Linux Package
An AUR-style package definition for the Linux desktop client lives under:
- `packaging/archlinux/keepassgo-git/`
From that directory you can build and install it with:
```bash
makepkg -si
```
The package installs:
- `/usr/bin/keepassgo`
- a desktop entry at `/usr/share/applications/keepassgo.desktop`
- application icons under the hicolor theme
## Android Packaging ## Android Packaging
KeePassGO uses Gio, so Android packaging is done with `gogio`. KeePassGO uses Gio, so Android packaging is done with `gogio`.
+1 -1
View File
@@ -20,7 +20,7 @@ func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) {
if err := lifecycle.Create(vault.Model{ if err := lifecycle.Create(vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
testAPITokenEntry(t), testAPITokenEntry(t),
{ID: "entry-1", Title: "Git Server", Path: []string{"Root", "Internet"}}, {ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
}, },
}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil { }, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
t.Fatalf("Create() error = %v", err) t.Fatalf("Create() error = %v", err)
+69 -69
View File
@@ -50,11 +50,11 @@ func TestVaultServiceRejectsExpiredAPITokens(t *testing.T) {
client, _, cleanup := newTestClientForModel(t, vault.Model{ client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
token.Entry([]string{"Root", "API Tokens"}), token.Entry([]string{"Root", "API Tokens"}),
@@ -78,22 +78,22 @@ func TestVaultServiceRejectsUnauthorizedEntryAccess(t *testing.T) {
client, _, cleanup := newTestClientForModel(t, vault.Model{ client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
testAPITokenEntry(t, testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}}},
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "git-server", Path: []string{"Root", "Internet"}}}, apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
), ),
}, },
}) })
defer cleanup() defer cleanup()
_, err := client.CopyEntryField(tokenContext(defaultTestTokenSecret), &keepassgov1.CopyEntryFieldRequest{Id: "git-server", Target: "password"}) _, err := client.CopyEntryField(tokenContext(defaultTestTokenSecret), &keepassgov1.CopyEntryFieldRequest{Id: "vault-console", Target: "password"})
if status.Code(err) != codes.PermissionDenied { if status.Code(err) != codes.PermissionDenied {
t.Fatalf("CopyEntryField() code = %v, want %v", status.Code(err), codes.PermissionDenied) t.Fatalf("CopyEntryField() code = %v, want %v", status.Code(err), codes.PermissionDenied)
} }
@@ -105,11 +105,11 @@ func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) {
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
testAPITokenEntry(t), testAPITokenEntry(t),
@@ -139,8 +139,8 @@ func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) {
if err := <-errCh; err != nil { if err := <-errCh; err != nil {
t.Fatalf("ListEntries() error = %v", err) t.Fatalf("ListEntries() error = %v", err)
} }
if len(resp.Entries) != 1 || resp.Entries[0].Id != "git-server" { if len(resp.Entries) != 1 || resp.Entries[0].Id != "vault-console" {
t.Fatalf("ListEntries().Entries = %#v, want git-server", resp.Entries) t.Fatalf("ListEntries().Entries = %#v, want vault-console", resp.Entries)
} }
} }
@@ -150,11 +150,11 @@ func TestVaultServicePersistsPermanentDenyApproval(t *testing.T) {
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
testAPITokenEntry(t), testAPITokenEntry(t),
@@ -196,7 +196,7 @@ func TestVaultServiceReturnsCanceledForCanceledApproval(t *testing.T) {
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
testAPITokenEntry(t), testAPITokenEntry(t),
}, },
} }
@@ -225,7 +225,7 @@ func TestVaultServiceTimesOutPendingApproval(t *testing.T) {
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
testAPITokenEntry(t), testAPITokenEntry(t),
}, },
} }
@@ -244,7 +244,7 @@ func TestVaultServiceRecordsApprovalAuditEvents(t *testing.T) {
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
testAPITokenEntry(t), testAPITokenEntry(t),
}, },
} }
@@ -282,11 +282,11 @@ func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) {
model: vault.Model{ model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -454,7 +454,7 @@ func TestVaultServiceOpensAndSavesVaultThroughLifecycleBackend(t *testing.T) {
if _, err := client.OpenRemoteVault(ctx, &keepassgov1.OpenRemoteVaultRequest{ if _, err := client.OpenRemoteVault(ctx, &keepassgov1.OpenRemoteVaultRequest{
BaseUrl: "https://dav.example.com", BaseUrl: "https://dav.example.com",
Path: "vaults/main.kdbx", Path: "vaults/main.kdbx",
Username: "jjulian", Username: "rustyryan",
Password: "dav-token", Password: "dav-token",
MasterPassword: "correct horse battery staple", MasterPassword: "correct horse battery staple",
}); err != nil { }); err != nil {
@@ -618,8 +618,8 @@ func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) {
t.Fatalf("len(ListEntries().Entries) = %d, want 1", len(resp.Entries)) t.Fatalf("len(ListEntries().Entries) = %d, want 1", len(resp.Entries))
} }
if resp.Entries[0].Title != "Git Server" { if resp.Entries[0].Title != "Vault Console" {
t.Fatalf("ListEntries().Entries[0].Title = %q, want %q", resp.Entries[0].Title, "Git Server") t.Fatalf("ListEntries().Entries[0].Title = %q, want %q", resp.Entries[0].Title, "Vault Console")
} }
if got := resp.Entries[0].Fields["X-Role"]; got != "automation" { if got := resp.Entries[0].Fields["X-Role"]; got != "automation" {
t.Fatalf("ListEntries().Entries[0].Fields[X-Role] = %q, want %q", got, "automation") t.Fatalf("ListEntries().Entries[0].Fields[X-Role] = %q, want %q", got, "automation")
@@ -747,7 +747,7 @@ func TestVaultServiceCopiesEntryFieldsForAuthorizedClients(t *testing.T) {
ctx := tokenContext(defaultTestTokenSecret) ctx := tokenContext(defaultTestTokenSecret)
if _, err := client.CopyEntryField(ctx, &keepassgov1.CopyEntryFieldRequest{ if _, err := client.CopyEntryField(ctx, &keepassgov1.CopyEntryFieldRequest{
Id: "git-server", Id: "vault-console",
Target: "password", Target: "password",
}); err != nil { }); err != nil {
t.Fatalf("CopyEntryField() error = %v", err) t.Fatalf("CopyEntryField() error = %v", err)
@@ -767,11 +767,11 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
ctx := tokenContext(defaultTestTokenSecret) ctx := tokenContext(defaultTestTokenSecret)
upserted, err := client.UpsertEntry(ctx, &keepassgov1.UpsertEntryRequest{ upserted, err := client.UpsertEntry(ctx, &keepassgov1.UpsertEntryRequest{
Entry: &keepassgov1.Entry{ Entry: &keepassgov1.Entry{
Id: "ha-codex", Id: "surveillance-console",
Title: "Home Assistant (Codex)", Title: "Surveillance Console",
Username: "codex", Username: "codex",
Password: "token-2", Password: "token-2",
Url: "https://lights.julianfamily.org", Url: "https://surveillance.crew.example.invalid",
Fields: map[string]string{ Fields: map[string]string{
"X-Role": "lights-admin", "X-Role": "lights-admin",
}, },
@@ -782,8 +782,8 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
t.Fatalf("UpsertEntry() error = %v", err) t.Fatalf("UpsertEntry() error = %v", err)
} }
if upserted.Entry.Title != "Home Assistant (Codex)" { if upserted.Entry.Title != "Surveillance Console" {
t.Fatalf("UpsertEntry().Entry.Title = %q, want %q", upserted.Entry.Title, "Home Assistant (Codex)") t.Fatalf("UpsertEntry().Entry.Title = %q, want %q", upserted.Entry.Title, "Surveillance Console")
} }
if got := upserted.Entry.Fields["X-Role"]; got != "lights-admin" { if got := upserted.Entry.Fields["X-Role"]; got != "lights-admin" {
t.Fatalf("UpsertEntry().Entry.Fields[X-Role] = %q, want %q", got, "lights-admin") t.Fatalf("UpsertEntry().Entry.Fields[X-Role] = %q, want %q", got, "lights-admin")
@@ -809,7 +809,7 @@ func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T)
defer cleanup() defer cleanup()
ctx := tokenContext(defaultTestTokenSecret) ctx := tokenContext(defaultTestTokenSecret)
if _, err := client.DeleteEntry(ctx, &keepassgov1.DeleteEntryRequest{Id: "git-server"}); err != nil { if _, err := client.DeleteEntry(ctx, &keepassgov1.DeleteEntryRequest{Id: "vault-console"}); err != nil {
t.Fatalf("DeleteEntry() error = %v", err) t.Fatalf("DeleteEntry() error = %v", err)
} }
@@ -822,13 +822,13 @@ func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T)
t.Fatalf("len(ListEntries().Entries) = %d, want 0 after delete", len(listed.Entries)) t.Fatalf("len(ListEntries().Entries) = %d, want 0 after delete", len(listed.Entries))
} }
restored, err := client.RestoreEntry(ctx, &keepassgov1.RestoreEntryRequest{Id: "git-server"}) restored, err := client.RestoreEntry(ctx, &keepassgov1.RestoreEntryRequest{Id: "vault-console"})
if err != nil { if err != nil {
t.Fatalf("RestoreEntry() error = %v", err) t.Fatalf("RestoreEntry() error = %v", err)
} }
if restored.Entry.Title != "Git Server" { if restored.Entry.Title != "Vault Console" {
t.Fatalf("RestoreEntry().Entry.Title = %q, want %q", restored.Entry.Title, "Git Server") t.Fatalf("RestoreEntry().Entry.Title = %q, want %q", restored.Entry.Title, "Vault Console")
} }
listed, err = client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}}) listed, err = client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
@@ -836,8 +836,8 @@ func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T)
t.Fatalf("ListEntries() error = %v", err) t.Fatalf("ListEntries() error = %v", err)
} }
if len(listed.Entries) != 1 || listed.Entries[0].Title != "Git Server" { if len(listed.Entries) != 1 || listed.Entries[0].Title != "Vault Console" {
t.Fatalf("ListEntries().Entries = %#v, want restored Git Server entry", listed.Entries) t.Fatalf("ListEntries().Entries = %#v, want restored Vault Console entry", listed.Entries)
} }
} }
@@ -863,11 +863,11 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
instantiated, err := client.InstantiateTemplate(ctx, &keepassgov1.InstantiateTemplateRequest{ instantiated, err := client.InstantiateTemplate(ctx, &keepassgov1.InstantiateTemplateRequest{
TemplateId: "website-login", TemplateId: "website-login",
Overrides: &keepassgov1.Entry{ Overrides: &keepassgov1.Entry{
Id: "dynadot", Id: "bellagio",
Title: "Dynadot", Title: "Bellagio",
Username: "jjulian", Username: "rustyryan",
Password: "hunter2", Password: "hunter2",
Url: "https://www.dynadot.com", Url: "https://bellagio.example.invalid",
Fields: map[string]string{ Fields: map[string]string{
"Environment": "staging", "Environment": "staging",
}, },
@@ -879,8 +879,8 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
t.Fatalf("InstantiateTemplate() error = %v", err) t.Fatalf("InstantiateTemplate() error = %v", err)
} }
if instantiated.Entry.Title != "Dynadot" || instantiated.Entry.Notes != "Reusable template for website accounts." { if instantiated.Entry.Title != "Bellagio" || instantiated.Entry.Notes != "Reusable template for website accounts." {
t.Fatalf("InstantiateTemplate().Entry = %#v, want Dynadot entry with template notes", instantiated.Entry) t.Fatalf("InstantiateTemplate().Entry = %#v, want Bellagio entry with template notes", instantiated.Entry)
} }
if got := instantiated.Entry.Fields["Environment"]; got != "staging" { if got := instantiated.Entry.Fields["Environment"]; got != "staging" {
t.Fatalf("InstantiateTemplate().Entry.Fields[Environment] = %q, want %q", got, "staging") t.Fatalf("InstantiateTemplate().Entry.Fields[Environment] = %q, want %q", got, "staging")
@@ -956,7 +956,7 @@ func TestVaultServiceListsAndRestoresEntryHistoryForAuthorizedClients(t *testing
defer cleanup() defer cleanup()
ctx := tokenContext(defaultTestTokenSecret) ctx := tokenContext(defaultTestTokenSecret)
history, err := client.ListEntryHistory(ctx, &keepassgov1.ListEntryHistoryRequest{Id: "git-server"}) history, err := client.ListEntryHistory(ctx, &keepassgov1.ListEntryHistoryRequest{Id: "vault-console"})
if err != nil { if err != nil {
t.Fatalf("ListEntryHistory() error = %v", err) t.Fatalf("ListEntryHistory() error = %v", err)
} }
@@ -965,7 +965,7 @@ func TestVaultServiceListsAndRestoresEntryHistoryForAuthorizedClients(t *testing
} }
restored, err := client.RestoreEntryHistory(ctx, &keepassgov1.RestoreEntryHistoryRequest{ restored, err := client.RestoreEntryHistory(ctx, &keepassgov1.RestoreEntryHistoryRequest{
Id: "git-server", Id: "vault-console",
HistoryIndex: 0, HistoryIndex: 0,
}) })
if err != nil { if err != nil {
@@ -986,14 +986,14 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
uploaded := []byte("attachment-content") uploaded := []byte("attachment-content")
if _, err := client.UploadAttachment(ctx, &keepassgov1.UploadAttachmentRequest{ if _, err := client.UploadAttachment(ctx, &keepassgov1.UploadAttachmentRequest{
EntryId: "git-server", EntryId: "vault-console",
Name: "token.txt", Name: "token.txt",
Content: uploaded, Content: uploaded,
}); err != nil { }); err != nil {
t.Fatalf("UploadAttachment() error = %v", err) t.Fatalf("UploadAttachment() error = %v", err)
} }
listed, err := client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "git-server"}) listed, err := client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "vault-console"})
if err != nil { if err != nil {
t.Fatalf("ListAttachments() error = %v", err) t.Fatalf("ListAttachments() error = %v", err)
} }
@@ -1002,7 +1002,7 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
} }
downloaded, err := client.DownloadAttachment(ctx, &keepassgov1.DownloadAttachmentRequest{ downloaded, err := client.DownloadAttachment(ctx, &keepassgov1.DownloadAttachmentRequest{
EntryId: "git-server", EntryId: "vault-console",
Name: "token.txt", Name: "token.txt",
}) })
if err != nil { if err != nil {
@@ -1013,13 +1013,13 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
} }
if _, err := client.DeleteAttachment(ctx, &keepassgov1.DeleteAttachmentRequest{ if _, err := client.DeleteAttachment(ctx, &keepassgov1.DeleteAttachmentRequest{
EntryId: "git-server", EntryId: "vault-console",
Name: "token.txt", Name: "token.txt",
}); err != nil { }); err != nil {
t.Fatalf("DeleteAttachment() error = %v", err) t.Fatalf("DeleteAttachment() error = %v", err)
} }
listed, err = client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "git-server"}) listed, err = client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "vault-console"})
if err != nil { if err != nil {
t.Fatalf("ListAttachments() error = %v", err) t.Fatalf("ListAttachments() error = %v", err)
} }
@@ -1055,32 +1055,32 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Fields: map[string]string{ Fields: map[string]string{
"X-Role": "automation", "X-Role": "automation",
}, },
History: []vault.Entry{ History: []vault.Entry{
{ {
ID: "git-server-h1", ID: "vault-console-h1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-0", Password: "token-0",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
{ {
ID: "ha-codex", ID: "surveillance-console",
Title: "Home Assistant (Codex)", Title: "Surveillance Console",
Username: "codex", Username: "codex",
Password: "token-2", Password: "token-2",
URL: "https://lights.julianfamily.org", URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"}, Path: []string{"Root", "Home Assistant"},
}, },
testAPITokenEntry(t, testAPITokenEntry(t,
@@ -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.OperationMutateGroup, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationReadEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationReadEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "git-server", Path: []string{"Root", "Internet"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.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: "git-server", Path: []string{"Root", "Internet"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "git-server", Path: []string{"Root", "Internet"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
), ),
}, },
Templates: []vault.Entry{ Templates: []vault.Entry{
+13
View File
@@ -61,6 +61,7 @@ type SynchronizableSession interface {
type AdvancedSynchronizableSession interface { type AdvancedSynchronizableSession interface {
CurrentSession CurrentSession
SynchronizeFromLocal(string) error SynchronizeFromLocal(string) error
SynchronizeFromLocalBytes(string, []byte) error
SynchronizeToLocal(string) error SynchronizeToLocal(string) error
SynchronizeFromRemote(webdav.Client, string) error SynchronizeFromRemote(webdav.Client, string) error
SynchronizeToRemote(webdav.Client, string) error SynchronizeToRemote(webdav.Client, string) error
@@ -722,6 +723,18 @@ func (s *State) SynchronizeFromLocal(path string) error {
return nil return nil
} }
func (s *State) SynchronizeFromLocalBytes(name string, content []byte) error {
session, ok := s.Session.(AdvancedSynchronizableSession)
if !ok {
return fmt.Errorf("session is not advanced-synchronizable")
}
if err := session.SynchronizeFromLocalBytes(name, content); err != nil {
return err
}
s.Dirty = false
return nil
}
func (s *State) SynchronizeToLocal(path string) error { func (s *State) SynchronizeToLocal(path string) error {
session, ok := s.Session.(AdvancedSynchronizableSession) session, ok := s.Session.(AdvancedSynchronizableSession)
if !ok { if !ok {
+120 -100
View File
@@ -20,13 +20,13 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
Session: stubSession{ Session: stubSession{
model: vault.Model{ model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}}, {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Joe", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Joe", "Home Assistant"}}, {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
}, },
}, },
}, },
CurrentPath: []string{"Joe", "Internet"}, CurrentPath: []string{"Crew", "Internet"},
} }
got, err := state.VisibleEntries() got, err := state.VisibleEntries()
@@ -39,8 +39,8 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
titles = append(titles, entry.Title) titles = append(titles, entry.Title)
} }
if !slices.Equal(titles, []string{"Dynadot", "Git Server"}) { if !slices.Equal(titles, []string{"Bellagio", "Vault Console"}) {
t.Fatalf("visible titles = %v, want [Dynadot Git Server]", titles) t.Fatalf("visible titles = %v, want [Bellagio Vault Console]", titles)
} }
} }
@@ -51,14 +51,14 @@ func TestVisibleEntriesAtParentGroupOnlyShowsDirectEntries(t *testing.T) {
Session: stubSession{ Session: stubSession{
model: vault.Model{ model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "joe-note", Title: "Joe Note", Path: []string{"Joe"}}, {ID: "joe-note", Title: "Crew Note", Path: []string{"Crew"}},
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}}, {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Joe", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Joe", "Home Assistant"}}, {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
}, },
}, },
}, },
CurrentPath: []string{"Joe"}, CurrentPath: []string{"Crew"},
} }
got, err := state.VisibleEntries() got, err := state.VisibleEntries()
@@ -70,8 +70,8 @@ func TestVisibleEntriesAtParentGroupOnlyShowsDirectEntries(t *testing.T) {
for _, entry := range got { for _, entry := range got {
titles = append(titles, entry.Title) titles = append(titles, entry.Title)
} }
if !slices.Equal(titles, []string{"Joe Note"}) { if !slices.Equal(titles, []string{"Crew Note"}) {
t.Fatalf("visible titles = %v, want only direct entries from Joe", titles) t.Fatalf("visible titles = %v, want only direct entries from Crew", titles)
} }
} }
@@ -171,14 +171,14 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
Session: stubSession{ Session: stubSession{
model: vault.Model{ model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}}, {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "git-server", Title: "Git Server", URL: "https://git.julianfamily.org", Path: []string{"Joe", "Internet"}}, {ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", URL: "https://lights.julianfamily.org", Path: []string{"Joe", "Home Assistant"}}, {ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
}, },
}, },
}, },
CurrentPath: []string{"Joe", "Internet"}, CurrentPath: []string{"Crew", "Internet"},
SearchQuery: "lights", SearchQuery: "surveillance",
} }
got, err := state.VisibleEntries() got, err := state.VisibleEntries()
@@ -186,7 +186,7 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
t.Fatalf("VisibleEntries() error = %v", err) t.Fatalf("VisibleEntries() error = %v", err)
} }
if len(got) != 1 || got[0].Title != "Home Assistant (Codex)" { if len(got) != 1 || got[0].Title != "Surveillance Console" {
t.Fatalf("VisibleEntries() = %#v, want Home Assistant search match", got) t.Fatalf("VisibleEntries() = %#v, want Home Assistant search match", got)
} }
} }
@@ -198,13 +198,13 @@ func TestVisibleEntriesReturnsDescendantsAfterClearingSearch(t *testing.T) {
Session: stubSession{ Session: stubSession{
model: vault.Model{ model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}}, {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "git-server", Title: "Git Server", URL: "https://git.julianfamily.org", Path: []string{"Joe", "Internet"}}, {ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", URL: "https://lights.julianfamily.org", Path: []string{"Joe", "Home Assistant"}}, {ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
}, },
}, },
}, },
CurrentPath: []string{"Joe"}, CurrentPath: []string{"Crew"},
SearchQuery: "missing", SearchQuery: "missing",
} }
@@ -222,7 +222,7 @@ func TestVisibleEntriesReturnsDescendantsAfterClearingSearch(t *testing.T) {
t.Fatalf("VisibleEntries() after clearing search error = %v", err) t.Fatalf("VisibleEntries() after clearing search error = %v", err)
} }
if len(got) != 0 { 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{ Session: stubSession{
model: vault.Model{ model: vault.Model{
RecycleBin: []vault.Entry{ RecycleBin: []vault.Entry{
{ID: "deleted-1", Title: "Deleted Dynadot", Path: []string{"Root", "Internet"}}, {ID: "deleted-1", Title: "Deleted Bellagio", Path: []string{"Root", "Internet"}},
{ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}}, {ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}},
}, },
}, },
@@ -418,13 +418,13 @@ func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
Session: stubSession{ Session: stubSession{
model: vault.Model{ model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}}, {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Joe", "Home Assistant"}}, {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
{ID: "alma", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}}, {ID: "alma", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}},
}, },
}, },
}, },
CurrentPath: []string{"Joe"}, CurrentPath: []string{"Crew"},
} }
got, err := state.ChildGroups() got, err := state.ChildGroups()
@@ -470,19 +470,19 @@ func TestSelectVisibleEntryAndToggleSelection(t *testing.T) {
Session: stubSession{ Session: stubSession{
model: vault.Model{ model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}}, {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Joe", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
}, },
}, },
}, },
CurrentPath: []string{"Joe", "Internet"}, CurrentPath: []string{"Crew", "Internet"},
} }
if err := state.SelectVisibleIndex(1); err != nil { if err := state.SelectVisibleIndex(1); err != nil {
t.Fatalf("SelectVisibleIndex() error = %v", err) t.Fatalf("SelectVisibleIndex() error = %v", err)
} }
if got := state.SelectedEntryID; got != "git-server" { if got := state.SelectedEntryID; got != "vault-console" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "git-server") t.Fatalf("SelectedEntryID = %q, want %q", got, "vault-console")
} }
if err := state.ToggleVisibleIndex(1); err != nil { if err := state.ToggleVisibleIndex(1); err != nil {
@@ -524,14 +524,14 @@ func TestDeleteSelectedEntryUpdatesSessionAndClearsSelection(t *testing.T) {
sess := &mutableStubSession{ sess := &mutableStubSession{
model: vault.Model{ model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
}, },
}, },
} }
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server", SelectedEntryID: "vault-console",
} }
if err := state.DeleteSelectedEntry(); err != nil { if err := state.DeleteSelectedEntry(); err != nil {
@@ -546,8 +546,8 @@ func TestDeleteSelectedEntryUpdatesSessionAndClearsSelection(t *testing.T) {
t.Fatalf("len(Entries) = %d, want 0", len(sess.model.Entries)) t.Fatalf("len(Entries) = %d, want 0", len(sess.model.Entries))
} }
if len(sess.model.RecycleBin) != 1 || sess.model.RecycleBin[0].ID != "git-server" { if len(sess.model.RecycleBin) != 1 || sess.model.RecycleBin[0].ID != "vault-console" {
t.Fatalf("RecycleBin = %#v, want git-server entry", sess.model.RecycleBin) t.Fatalf("RecycleBin = %#v, want vault-console entry", sess.model.RecycleBin)
} }
if !state.Dirty { if !state.Dirty {
@@ -561,7 +561,7 @@ func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) {
sess := &mutableStubSession{ sess := &mutableStubSession{
model: vault.Model{ model: vault.Model{
RecycleBin: []vault.Entry{ RecycleBin: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
}, },
}, },
} }
@@ -570,7 +570,7 @@ func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) {
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
} }
if err := state.RestoreEntry("git-server"); err != nil { if err := state.RestoreEntry("vault-console"); err != nil {
t.Fatalf("RestoreEntry() error = %v", err) t.Fatalf("RestoreEntry() error = %v", err)
} }
@@ -579,8 +579,8 @@ func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) {
t.Fatalf("VisibleEntries() error = %v", err) t.Fatalf("VisibleEntries() error = %v", err)
} }
if len(got) != 1 || got[0].ID != "git-server" { if len(got) != 1 || got[0].ID != "vault-console" {
t.Fatalf("VisibleEntries() = %#v, want restored git-server entry", got) t.Fatalf("VisibleEntries() = %#v, want restored vault-console entry", got)
} }
if !state.Dirty { if !state.Dirty {
@@ -600,19 +600,19 @@ func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) {
} }
entry := vault.Entry{ entry := vault.Entry{
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
} }
if err := state.UpsertEntry(entry); err != nil { if err := state.UpsertEntry(entry); err != nil {
t.Fatalf("UpsertEntry() error = %v", err) t.Fatalf("UpsertEntry() error = %v", err)
} }
if got := state.SelectedEntryID; got != "git-server" { if got := state.SelectedEntryID; got != "vault-console" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "git-server") t.Fatalf("SelectedEntryID = %q, want %q", got, "vault-console")
} }
got, err := state.VisibleEntries() got, err := state.VisibleEntries()
@@ -620,7 +620,7 @@ func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) {
t.Fatalf("VisibleEntries() error = %v", err) t.Fatalf("VisibleEntries() error = %v", err)
} }
if len(got) != 1 || got[0].Password != "token-1" { if len(got) != 1 || got[0].Password != "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 { if !state.Dirty {
@@ -652,11 +652,11 @@ func TestInstantiateTemplateCreatesEntryAndSelectsIt(t *testing.T) {
} }
entry, err := state.InstantiateTemplate("website-login", vault.Entry{ entry, err := state.InstantiateTemplate("website-login", vault.Entry{
ID: "dynadot", ID: "bellagio",
Title: "Dynadot", Title: "Bellagio",
Username: "jjulian", Username: "rustyryan",
Password: "hunter2", Password: "hunter2",
URL: "https://www.dynadot.com", URL: "https://bellagio.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}) })
if err != nil { if err != nil {
@@ -667,16 +667,16 @@ func TestInstantiateTemplateCreatesEntryAndSelectsIt(t *testing.T) {
t.Fatalf("entry.Notes = %q, want template notes", entry.Notes) t.Fatalf("entry.Notes = %q, want template notes", entry.Notes)
} }
if got := state.SelectedEntryID; got != "dynadot" { if got := state.SelectedEntryID; got != "bellagio" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "dynadot") t.Fatalf("SelectedEntryID = %q, want %q", got, "bellagio")
} }
got, err := state.VisibleEntries() got, err := state.VisibleEntries()
if err != nil { if err != nil {
t.Fatalf("VisibleEntries() error = %v", err) t.Fatalf("VisibleEntries() error = %v", err)
} }
if len(got) != 1 || got[0].ID != "dynadot" { if len(got) != 1 || got[0].ID != "bellagio" {
t.Fatalf("VisibleEntries() = %#v, want instantiated dynadot entry", got) t.Fatalf("VisibleEntries() = %#v, want instantiated bellagio entry", got)
} }
if !state.Dirty { if !state.Dirty {
@@ -743,24 +743,24 @@ func TestDuplicateSelectedEntryCreatesCopyAndSelectsIt(t *testing.T) {
sess := &mutableStubSession{model: vault.Model{ sess := &mutableStubSession{model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
}, },
}} }}
state := State{ state := State{
Session: sess, Session: sess,
SelectedEntryID: "git-server", SelectedEntryID: "vault-console",
} }
duplicate, err := state.DuplicateSelectedEntry("git-server-copy") duplicate, err := state.DuplicateSelectedEntry("vault-console-copy")
if err != nil { if err != nil {
t.Fatalf("DuplicateSelectedEntry() error = %v", err) t.Fatalf("DuplicateSelectedEntry() error = %v", err)
} }
if duplicate.ID != "git-server-copy" { if duplicate.ID != "vault-console-copy" {
t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "git-server-copy") t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "vault-console-copy")
} }
if state.SelectedEntryID != "git-server-copy" { if state.SelectedEntryID != "vault-console-copy" {
t.Fatalf("SelectedEntryID = %q, want git-server-copy", state.SelectedEntryID) t.Fatalf("SelectedEntryID = %q, want vault-console-copy", state.SelectedEntryID)
} }
if len(sess.model.Entries) != 2 { if len(sess.model.Entries) != 2 {
t.Fatalf("len(Entries) = %d, want 2 after duplicate", len(sess.model.Entries)) t.Fatalf("len(Entries) = %d, want 2 after duplicate", len(sess.model.Entries))
@@ -772,13 +772,13 @@ func TestMoveSelectedEntryMovesEntryToNewPathAndMarksDirty(t *testing.T) {
sess := &mutableStubSession{model: vault.Model{ sess := &mutableStubSession{model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
}, },
}} }}
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server", SelectedEntryID: "vault-console",
} }
if err := state.MoveSelectedEntry([]string{"Root", "Infrastructure"}); err != nil { if err := state.MoveSelectedEntry([]string{"Root", "Infrastructure"}); err != nil {
@@ -791,8 +791,8 @@ func TestMoveSelectedEntryMovesEntryToNewPathAndMarksDirty(t *testing.T) {
} }
newPath := sess.model.EntriesInPath([]string{"Root", "Infrastructure"}) newPath := sess.model.EntriesInPath([]string{"Root", "Infrastructure"})
if len(newPath) != 1 || newPath[0].ID != "git-server" { if len(newPath) != 1 || newPath[0].ID != "vault-console" {
t.Fatalf("EntriesInPath(Root/Infrastructure) = %#v, want moved git-server entry", newPath) t.Fatalf("EntriesInPath(Root/Infrastructure) = %#v, want moved vault-console entry", newPath)
} }
if !state.Dirty { if !state.Dirty {
@@ -806,19 +806,19 @@ func TestRestoreSelectedEntryVersionReplacesCurrentVersion(t *testing.T) {
sess := &mutableStubSession{model: vault.Model{ sess := &mutableStubSession{model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Password: "new-token", Password: "new-token",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
History: []vault.Entry{ History: []vault.Entry{
{ID: "git-server-h1", Title: "Git Server", Password: "old-token", Path: []string{"Root", "Internet"}}, {ID: "vault-console-h1", Title: "Vault Console", Password: "old-token", Path: []string{"Root", "Internet"}},
}, },
}, },
}, },
}} }}
state := State{ state := State{
Session: sess, Session: sess,
SelectedEntryID: "git-server", SelectedEntryID: "vault-console",
} }
if err := state.RestoreSelectedEntryVersion(0); err != nil { if err := state.RestoreSelectedEntryVersion(0); err != nil {
@@ -862,7 +862,7 @@ func TestCreateVaultResetsSelectionPathAndDirtyState(t *testing.T) {
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server", SelectedEntryID: "vault-console",
Dirty: true, Dirty: true,
} }
@@ -891,7 +891,7 @@ func TestOpenVaultResetsSelectionPathAndDirtyState(t *testing.T) {
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server", SelectedEntryID: "vault-console",
Dirty: true, Dirty: true,
} }
@@ -942,7 +942,7 @@ func TestOpenRemoteVaultResetsSelectionPathAndDirtyState(t *testing.T) {
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server", SelectedEntryID: "vault-console",
Dirty: true, Dirty: true,
} }
@@ -970,14 +970,14 @@ func TestLockClearsSelectionAndMakesVaultUnavailable(t *testing.T) {
sess := &lockableStubSession{ sess := &lockableStubSession{
model: vault.Model{ model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
}, },
}, },
} }
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server", SelectedEntryID: "vault-console",
} }
if err := state.Lock(); err != nil { if err := state.Lock(); err != nil {
@@ -1000,7 +1000,7 @@ func TestUnlockRestoresVaultVisibility(t *testing.T) {
sess := &lockableStubSession{ sess := &lockableStubSession{
model: vault.Model{ model: vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
}, },
}, },
locked: true, locked: true,
@@ -1018,8 +1018,8 @@ func TestUnlockRestoresVaultVisibility(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("VisibleEntries() error = %v", err) t.Fatalf("VisibleEntries() error = %v", err)
} }
if len(got) != 1 || got[0].ID != "git-server" { if len(got) != 1 || got[0].ID != "vault-console" {
t.Fatalf("VisibleEntries() = %#v, want git-server entry after unlock", got) t.Fatalf("VisibleEntries() = %#v, want vault-console entry after unlock", got)
} }
} }
@@ -1048,7 +1048,7 @@ func TestShowSectionResetsPathAndSelection(t *testing.T) {
state := State{ state := State{
Section: SectionEntries, Section: SectionEntries,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server", SelectedEntryID: "vault-console",
SearchQuery: "git", SearchQuery: "git",
} }
@@ -1084,7 +1084,7 @@ func TestBeginNewEntryClearsSelectionAndStatus(t *testing.T) {
t.Parallel() t.Parallel()
state := State{ state := State{
SelectedEntryID: "git-server", SelectedEntryID: "vault-console",
ErrorMessage: "previous error", ErrorMessage: "previous error",
} }
@@ -1128,7 +1128,7 @@ func TestEnterGroupAppendsPathAndClearsSelection(t *testing.T) {
state := State{ state := State{
CurrentPath: []string{"Root"}, CurrentPath: []string{"Root"},
SelectedEntryID: "git-server", SelectedEntryID: "vault-console",
} }
state.EnterGroup("Internet") state.EnterGroup("Internet")
@@ -1146,7 +1146,7 @@ func TestNavigateToPathReplacesPathAndClearsSelection(t *testing.T) {
state := State{ state := State{
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "git-server", SelectedEntryID: "vault-console",
} }
state.NavigateToPath([]string{"Root", "Home Assistant"}) state.NavigateToPath([]string{"Root", "Home Assistant"})
@@ -1297,7 +1297,7 @@ func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) {
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot", SelectedEntryID: "bellagio",
} }
if err := state.MoveSelectedEntry([]string{"Root", "Home Assistant"}); err != nil { if err := state.MoveSelectedEntry([]string{"Root", "Home Assistant"}); err != nil {
@@ -1313,8 +1313,8 @@ func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) {
if len(got) != 2 { if len(got) != 2 {
t.Fatalf("len(VisibleEntries()) = %d, want 2", len(got)) t.Fatalf("len(VisibleEntries()) = %d, want 2", len(got))
} }
if got[0].ID != "dynadot" && got[1].ID != "dynadot" { if got[0].ID != "bellagio" && got[1].ID != "bellagio" {
t.Fatalf("VisibleEntries() = %#v, want moved dynadot entry in destination group", got) t.Fatalf("VisibleEntries() = %#v, want moved bellagio entry in destination group", got)
} }
if !state.Dirty { if !state.Dirty {
t.Fatal("Dirty = false, want true after MoveSelectedEntry") t.Fatal("Dirty = false, want true after MoveSelectedEntry")
@@ -1326,7 +1326,7 @@ func TestMoveCurrentGroupMovesHierarchyAndMarksDirty(t *testing.T) {
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}}, {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
}, },
} }
model.CreateGroup([]string{"Root", "Internet"}, "Infrastructure") model.CreateGroup([]string{"Root", "Internet"}, "Infrastructure")
@@ -1337,15 +1337,15 @@ func TestMoveCurrentGroupMovesHierarchyAndMarksDirty(t *testing.T) {
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
} }
if err := state.MoveCurrentGroup([]string{"Root", "Joe"}); err != nil { if err := state.MoveCurrentGroup([]string{"Root", "Crew"}); err != nil {
t.Fatalf("MoveCurrentGroup() error = %v", err) t.Fatalf("MoveCurrentGroup() error = %v", err)
} }
if !slices.Equal(state.CurrentPath, []string{"Root", "Joe", "Internet"}) { if !slices.Equal(state.CurrentPath, []string{"Root", "Crew", "Internet"}) {
t.Fatalf("CurrentPath = %v, want [Root Joe Internet]", state.CurrentPath) t.Fatalf("CurrentPath = %v, want [Root Crew Internet]", state.CurrentPath)
} }
if got := session.model.EntriesInPath([]string{"Root", "Joe", "Internet"}); len(got) != 1 || got[0].ID != "git-server" { if got := session.model.EntriesInPath([]string{"Root", "Crew", "Internet"}); len(got) != 1 || got[0].ID != "vault-console" {
t.Fatalf("EntriesInPath(Root/Joe/Internet) = %#v, want moved entry", got) t.Fatalf("EntriesInPath(Root/Crew/Internet) = %#v, want moved entry", got)
} }
if !state.Dirty { if !state.Dirty {
t.Fatal("Dirty = false, want true after MoveCurrentGroup") t.Fatal("Dirty = false, want true after MoveCurrentGroup")
@@ -1359,7 +1359,7 @@ func TestAddAttachmentToSelectedEntryPersistsAndMarksDirty(t *testing.T) {
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot", SelectedEntryID: "bellagio",
} }
if err := state.AddAttachmentToSelectedEntry("token.txt", []byte("secret")); err != nil { if err := state.AddAttachmentToSelectedEntry("token.txt", []byte("secret")); err != nil {
@@ -1387,7 +1387,7 @@ func TestAddAttachmentToSelectedEntryRejectsDuplicateNames(t *testing.T) {
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot", SelectedEntryID: "bellagio",
} }
err := state.AddAttachmentToSelectedEntry("token.txt", []byte("replacement")) err := state.AddAttachmentToSelectedEntry("token.txt", []byte("replacement"))
@@ -1413,7 +1413,7 @@ func TestReplaceAttachmentOnSelectedEntryPersistsAndMarksDirty(t *testing.T) {
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot", SelectedEntryID: "bellagio",
} }
if err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement")); err != nil { if err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement")); err != nil {
@@ -1439,7 +1439,7 @@ func TestReplaceAttachmentOnSelectedEntryRequiresExistingAttachment(t *testing.T
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot", SelectedEntryID: "bellagio",
} }
err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement")) err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement"))
@@ -1457,7 +1457,7 @@ func TestDeleteAttachmentFromSelectedEntryPersistsAndMarksDirty(t *testing.T) {
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot", SelectedEntryID: "bellagio",
} }
if err := state.DeleteAttachmentFromSelectedEntry("token.txt"); err != nil { if err := state.DeleteAttachmentFromSelectedEntry("token.txt"); err != nil {
@@ -1483,7 +1483,7 @@ func TestDeleteAttachmentFromSelectedEntryRequiresExistingAttachment(t *testing.
state := State{ state := State{
Session: sess, Session: sess,
CurrentPath: []string{"Root", "Internet"}, CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "dynadot", SelectedEntryID: "bellagio",
} }
err := state.DeleteAttachmentFromSelectedEntry("token.txt") err := state.DeleteAttachmentFromSelectedEntry("token.txt")
@@ -1511,8 +1511,8 @@ func (s *mutableStubSession) Replace(model vault.Model) {
func testVaultModel() vault.Model { func testVaultModel() vault.Model {
return vault.Model{ return vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "dynadot", Title: "Dynadot", Path: []string{"Root", "Internet"}}, {ID: "bellagio", Title: "Bellagio", Path: []string{"Root", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Root", "Home Assistant"}}, {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Root", "Home Assistant"}},
}, },
} }
} }
@@ -1584,6 +1584,26 @@ func (s *lifecycleStubSession) OpenRemote(_ webdav.Client, path string, _ vault.
return nil return nil
} }
func (s *lifecycleStubSession) SynchronizeFromLocal(string) error {
return nil
}
func (s *lifecycleStubSession) SynchronizeFromLocalBytes(string, []byte) error {
return nil
}
func (s *lifecycleStubSession) SynchronizeToLocal(string) error {
return nil
}
func (s *lifecycleStubSession) SynchronizeFromRemote(webdav.Client, string) error {
return nil
}
func (s *lifecycleStubSession) SynchronizeToRemote(webdav.Client, string) error {
return nil
}
func (s *lifecycleStubSession) ChangeMasterKey(key vault.MasterKey) error { func (s *lifecycleStubSession) ChangeMasterKey(key vault.MasterKey) error {
s.changedKey = key s.changedKey = key
return nil return nil
+10 -10
View File
@@ -22,7 +22,7 @@ func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
Username: "joe", Username: "joe",
Password: "secret", Password: "secret",
URL: "https://10.0.2.2:8443/login", URL: "https://10.0.2.2:8443/login",
Path: []string{"Joe", "Internet"}, Path: []string{"Crew", "Internet"},
}, },
{ {
ID: "two", ID: "two",
@@ -35,10 +35,10 @@ func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
Title: "Bare Host", Title: "Bare Host",
Username: "user", Username: "user",
Password: "pass", Password: "pass",
URL: "lights.julianfamily.org", URL: "surveillance.crew.example.invalid",
Fields: map[string]string{ Fields: map[string]string{
"AndroidApp1": "androidapp://com.lights.mobile", "AndroidApp1": "androidapp://com.lights.mobile",
"KP2A_URL_1": "https://lights.julianfamily.org/account", "KP2A_URL_1": "https://surveillance.crew.example.invalid/account",
}, },
}, },
}, },
@@ -50,8 +50,8 @@ func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
if got.Entries[0].Host != "10.0.2.2" { if got.Entries[0].Host != "10.0.2.2" {
t.Fatalf("first host = %q, want 10.0.2.2", got.Entries[0].Host) t.Fatalf("first host = %q, want 10.0.2.2", got.Entries[0].Host)
} }
if got.Entries[1].Host != "lights.julianfamily.org" { if got.Entries[1].Host != "surveillance.crew.example.invalid" {
t.Fatalf("second host = %q, want lights.julianfamily.org", got.Entries[1].Host) t.Fatalf("second host = %q, want surveillance.crew.example.invalid", got.Entries[1].Host)
} }
if len(got.Entries[1].Targets) != 3 { if len(got.Entries[1].Targets) != 3 {
t.Fatalf("len(second targets) = %d, want 3", len(got.Entries[1].Targets)) t.Fatalf("len(second targets) = %d, want 3", len(got.Entries[1].Targets))
@@ -137,21 +137,21 @@ func TestMatchRejectsAmbiguousSharedHost(t *testing.T) {
Title: "Host A", Title: "Host A",
Username: "first", Username: "first",
Password: "secret1", Password: "secret1",
URL: "https://lights.julianfamily.org/", URL: "https://surveillance.crew.example.invalid/",
Host: "lights.julianfamily.org", Host: "surveillance.crew.example.invalid",
}, },
{ {
ID: "two", ID: "two",
Title: "Host B", Title: "Host B",
Username: "second", Username: "second",
Password: "secret2", Password: "secret2",
URL: "https://lights.julianfamily.org/", URL: "https://surveillance.crew.example.invalid/",
Host: "lights.julianfamily.org", Host: "surveillance.crew.example.invalid",
}, },
}, },
} }
if _, ok := Match(cache, "https://lights.julianfamily.org/"); ok { if _, ok := Match(cache, "https://surveillance.crew.example.invalid/"); ok {
t.Fatalf("Match() unexpectedly resolved ambiguous shared host") t.Fatalf("Match() unexpectedly resolved ambiguous shared host")
} }
} }
+11 -11
View File
@@ -13,11 +13,11 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
}, },
}, },
} }
@@ -27,9 +27,9 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
target Target target Target
want string want string
}{ }{
{name: "username", target: TargetUsername, want: "joejulian"}, {name: "username", target: TargetUsername, want: "dannyocean"},
{name: "password", target: TargetPassword, want: "token-1"}, {name: "password", target: TargetPassword, want: "token-1"},
{name: "url", target: TargetURL, want: "https://git.julianfamily.org"}, {name: "url", target: TargetURL, want: "https://vault.crew.example.invalid"},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -37,7 +37,7 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
var writer memoryWriter var writer memoryWriter
service := Service{Writer: &writer} service := Service{Writer: &writer}
if err := service.Copy(model, "git-server", tt.target); err != nil { if err := service.Copy(model, "vault-console", tt.target); err != nil {
t.Fatalf("Copy() error = %v", err) t.Fatalf("Copy() error = %v", err)
} }
@@ -60,9 +60,9 @@ func TestServiceRejectsUnknownEntryAndUnsupportedTarget(t *testing.T) {
} }
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{{ID: "git-server", Username: "joejulian"}}, Entries: []vault.Entry{{ID: "vault-console", Username: "dannyocean"}},
} }
err = service.Copy(model, "git-server", Target("unsupported")) err = service.Copy(model, "vault-console", Target("unsupported"))
if !errors.Is(err, ErrUnsupportedTarget) { if !errors.Is(err, ErrUnsupportedTarget) {
t.Fatalf("Copy() unsupported target error = %v, want ErrUnsupportedTarget", err) t.Fatalf("Copy() unsupported target error = %v, want ErrUnsupportedTarget", err)
} }
@@ -74,11 +74,11 @@ func TestServiceSanitizesClipboardWriteErrors(t *testing.T) {
service := Service{Writer: failingWriter{err: errors.New("backend refused token-1")}} service := Service{Writer: failingWriter{err: errors.New("backend refused token-1")}}
model := vault.Model{ model := vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
{ID: "git-server", Password: "token-1"}, {ID: "vault-console", Password: "token-1"},
}, },
} }
err := service.Copy(model, "git-server", TargetPassword) err := service.Copy(model, "vault-console", TargetPassword)
if !errors.Is(err, ErrWriteFailed) { if !errors.Is(err, ErrWriteFailed) {
t.Fatalf("Copy() write error = %v, want ErrWriteFailed", err) t.Fatalf("Copy() write error = %v, want ErrWriteFailed", err)
} }
+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 - list navigation
- search focus - search focus
- new-entry focus transitions - new-entry focus transitions
- Controls that participate in keyboard navigation have intent-revealing accessibility labels through `accessibilityLabel` in [`ui_accessibility.go`](/home/jjulian/dev/go/src/git.julianfamily.org/keepassgo/ui_accessibility.go). - Controls that participate in keyboard navigation have intent-revealing accessibility labels through `accessibilityLabel` in [`ui_accessibility.go`](/workspace/keepassgo/ui_accessibility.go).
## Current screen-reader boundary ## Current screen-reader boundary
+4 -1
View File
@@ -6,6 +6,7 @@ replace gioui.org/cmd => ./third_party/gioui-cmd
require ( require (
gioui.org v0.8.0 gioui.org v0.8.0
gioui.org/x v0.8.0
github.com/atotto/clipboard v0.1.4 github.com/atotto/clipboard v0.1.4
github.com/tobischo/gokeepasslib/v3 v3.6.2 github.com/tobischo/gokeepasslib/v3 v3.6.2
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90 golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90
@@ -18,6 +19,7 @@ require (
4d63.com/gochecknoglobals v0.2.2 // indirect 4d63.com/gochecknoglobals v0.2.2 // indirect
gioui.org/cmd v0.8.0 // indirect gioui.org/cmd v0.8.0 // indirect
gioui.org/shader v1.0.8 // indirect gioui.org/shader v1.0.8 // indirect
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 // indirect
github.com/4meepo/tagalign v1.4.2 // indirect github.com/4meepo/tagalign v1.4.2 // indirect
github.com/Abirdcfly/dupword v0.1.3 // indirect github.com/Abirdcfly/dupword v0.1.3 // indirect
github.com/Antonboom/errname v1.0.0 // indirect github.com/Antonboom/errname v1.0.0 // indirect
@@ -74,6 +76,7 @@ require (
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect github.com/gobwas/glob v0.2.3 // indirect
github.com/godbus/dbus/v5 v5.0.6 // indirect
github.com/gofrs/flock v0.12.1 // indirect github.com/gofrs/flock v0.12.1 // indirect
github.com/golang/protobuf v1.5.4 // indirect github.com/golang/protobuf v1.5.4 // indirect
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
@@ -192,7 +195,7 @@ require (
go.uber.org/multierr v1.6.0 // indirect go.uber.org/multierr v1.6.0 // indirect
go.uber.org/zap v1.24.0 // indirect go.uber.org/zap v1.24.0 // indirect
golang.org/x/crypto v0.48.0 // indirect golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect
golang.org/x/image v0.37.0 // indirect golang.org/x/image v0.37.0 // indirect
golang.org/x/mod v0.33.0 // indirect golang.org/x/mod v0.33.0 // indirect
+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/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA= gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM= gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
gioui.org/x v0.8.0 h1:RhIlQNOFKKn8D8FeaKKaXCo7vB3x+fq4VcD10HW/YpA=
gioui.org/x v0.8.0/go.mod h1:aXtQb+kyqoUOjDl5/uMqAopjzVzMkeHBbMQOGT5KnSE=
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 h1:bGG/g4ypjrCJoSvFrP5hafr9PPB5aw8SjcOWWila7ZI=
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0/go.mod h1:+axXBRUTIDlCeE73IKeD/os7LoEnTKdkp8/gQOFjqyo=
github.com/4meepo/tagalign v1.4.2 h1:0hcLHPGMjDyM1gHG58cS73aQF8J4TdVR96TZViorO9E= github.com/4meepo/tagalign v1.4.2 h1:0hcLHPGMjDyM1gHG58cS73aQF8J4TdVR96TZViorO9E=
github.com/4meepo/tagalign v1.4.2/go.mod h1:+p4aMyFM+ra7nb41CnFG6aSDXqRxU/w1VQqScKqDARI= github.com/4meepo/tagalign v1.4.2/go.mod h1:+p4aMyFM+ra7nb41CnFG6aSDXqRxU/w1VQqScKqDARI=
github.com/Abirdcfly/dupword v0.1.3 h1:9Pa1NuAsZvpFPi9Pqkd93I7LIYRURj+A//dFd5tgBeE= github.com/Abirdcfly/dupword v0.1.3 h1:9Pa1NuAsZvpFPi9Pqkd93I7LIYRURj+A//dFd5tgBeE=
@@ -224,6 +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/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo= github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
github.com/godbus/dbus/v5 v5.0.6 h1:mkgN1ofwASrYnJ5W6U/BxG15eXXXjirgZc7CLqkcaro=
github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
@@ -669,8 +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-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90 h1:kyPrwnEYXdME284bE7xgS9BPxhG7MCa5hw1/TpaTJVs= golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90 h1:kyPrwnEYXdME284bE7xgS9BPxhG7MCa5hw1/TpaTJVs=
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:jqkJFnLVkS8zgKKY4+MOPCZtuZGw3hONUjhapUSwZ8c= golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:jqkJFnLVkS8zgKKY4+MOPCZtuZGw3hONUjhapUSwZ8c=
golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
+164 -72
View File
@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"image" "image"
"image/color" "image/color"
"io"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -26,6 +27,7 @@ import (
"gioui.org/unit" "gioui.org/unit"
"gioui.org/widget" "gioui.org/widget"
"gioui.org/widget/material" "gioui.org/widget/material"
"gioui.org/x/explorer"
"git.julianfamily.org/keepassgo/api" "git.julianfamily.org/keepassgo/api"
"git.julianfamily.org/keepassgo/apiapproval" "git.julianfamily.org/keepassgo/apiapproval"
"git.julianfamily.org/keepassgo/apiaudit" "git.julianfamily.org/keepassgo/apiaudit"
@@ -200,6 +202,7 @@ const (
type ui struct { type ui struct {
mode string mode string
theme *material.Theme theme *material.Theme
fileExplorer *explorer.Explorer
logoHorizontal paint.ImageOp logoHorizontal paint.ImageOp
splashSquare paint.ImageOp splashSquare paint.ImageOp
search widget.Editor search widget.Editor
@@ -384,6 +387,9 @@ type ui struct {
disableAPIToken widget.Clickable disableAPIToken widget.Clickable
revokeAPIToken widget.Clickable revokeAPIToken widget.Clickable
deleteAPIToken widget.Clickable deleteAPIToken widget.Clickable
useCurrentGroupForPolicy widget.Clickable
useSelectedEntryForPolicy widget.Clickable
clearAPIPolicyTarget widget.Clickable
addAPIPolicyRule widget.Clickable addAPIPolicyRule widget.Clickable
phoneSplit widget.Float phoneSplit widget.Float
splitDrag gesture.Drag splitDrag gesture.Drag
@@ -396,6 +402,7 @@ type ui struct {
copyIcon *widget.Icon copyIcon *widget.Icon
expandMoreIcon *widget.Icon expandMoreIcon *widget.Icon
expandLessIcon *widget.Icon expandLessIcon *widget.Icon
chevronRightIcon *widget.Icon
chevronDownIcon *widget.Icon chevronDownIcon *widget.Icon
settingsIcon *widget.Icon settingsIcon *widget.Icon
menuIcon *widget.Icon menuIcon *widget.Icon
@@ -405,6 +412,8 @@ type ui struct {
lifecycleMode string lifecycleMode string
syncSourceMode syncSourceMode syncSourceMode syncSourceMode
syncDirection syncDirection syncDirection syncDirection
syncLocalImportName string
syncLocalImportContent []byte
syncLocalPath widget.Editor syncLocalPath widget.Editor
syncRemoteBaseURL widget.Editor syncRemoteBaseURL widget.Editor
syncRemotePath widget.Editor syncRemotePath widget.Editor
@@ -615,6 +624,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
u.copyIcon, _ = widget.NewIcon(icons.ContentContentCopy) u.copyIcon, _ = widget.NewIcon(icons.ContentContentCopy)
u.expandMoreIcon, _ = widget.NewIcon(icons.NavigationExpandMore) u.expandMoreIcon, _ = widget.NewIcon(icons.NavigationExpandMore)
u.expandLessIcon, _ = widget.NewIcon(icons.NavigationExpandLess) u.expandLessIcon, _ = widget.NewIcon(icons.NavigationExpandLess)
u.chevronRightIcon, _ = widget.NewIcon(icons.NavigationChevronRight)
u.chevronDownIcon, _ = widget.NewIcon(icons.NavigationArrowDropDown) u.chevronDownIcon, _ = widget.NewIcon(icons.NavigationArrowDropDown)
u.settingsIcon, _ = widget.NewIcon(icons.ActionSettings) u.settingsIcon, _ = widget.NewIcon(icons.ActionSettings)
u.menuIcon, _ = widget.NewIcon(icons.NavigationMenu) u.menuIcon, _ = widget.NewIcon(icons.NavigationMenu)
@@ -645,13 +655,21 @@ func (u *ui) filter() {
return return
} }
u.visible = visible u.visible = visible
if len(u.entryClicks) < len(u.visible) { u.ensureEntryClicks()
u.entryClicks = make([]widget.Clickable, len(u.visible)) }
func (u *ui) ensureEntryClicks() {
if len(u.entryClicks) >= len(u.visible) {
return
} }
next := make([]widget.Clickable, len(u.visible))
copy(next, u.entryClicks)
u.entryClicks = next
} }
func (u *ui) visibleEntrySnapshot() ([]entry, []*widget.Clickable) { func (u *ui) visibleEntrySnapshot() ([]entry, []*widget.Clickable) {
visible := append([]entry(nil), u.visible...) visible := append([]entry(nil), u.visible...)
u.ensureEntryClicks()
clicks := make([]*widget.Clickable, len(visible)) clicks := make([]*widget.Clickable, len(visible))
for i := range visible { for i := range visible {
clicks[i] = &u.entryClicks[i] clicks[i] = &u.entryClicks[i]
@@ -659,6 +677,19 @@ func (u *ui) visibleEntrySnapshot() ([]entry, []*widget.Clickable) {
return visible, clicks return visible, clicks
} }
func (u *ui) searchPlaceholder() string {
switch u.state.Section {
case appstate.SectionAPITokens:
return "Search API tokens"
case appstate.SectionAPIAudit:
return "Search audit log"
case appstate.SectionRecycleBin:
return "Search recycle bin"
default:
return "Search vault"
}
}
func defaultStatePaths(stateDir string) statePaths { func defaultStatePaths(stateDir string) statePaths {
baseDir := strings.TrimSpace(stateDir) baseDir := strings.TrimSpace(stateDir)
if baseDir == "" { if baseDir == "" {
@@ -1190,6 +1221,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 { func sanitizeSyncSourceMode(mode syncSourceMode) syncSourceMode {
switch mode { switch mode {
case syncSourceRemote: case syncSourceRemote:
@@ -1229,6 +1273,12 @@ func (u *ui) advancedSyncFromAction() error {
return err return err
} }
default: 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()) path := strings.TrimSpace(u.syncLocalPath.Text())
if path == "" { if path == "" {
return errors.New(errVaultPathRequired) return errors.New(errVaultPathRequired)
@@ -1243,6 +1293,37 @@ func (u *ui) advancedSyncFromAction() error {
return nil 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 { func (u *ui) advancedSyncToAction() error {
switch u.syncSourceMode { switch u.syncSourceMode {
case syncSourceRemote: case syncSourceRemote:
@@ -1671,12 +1752,16 @@ func (u *ui) restoreStartupLifecycleTarget() {
func (u *ui) hasSelectedLifecycleTarget() bool { func (u *ui) hasSelectedLifecycleTarget() bool {
switch strings.TrimSpace(u.lifecycleMode) { switch strings.TrimSpace(u.lifecycleMode) {
case "remote": case "remote":
return strings.TrimSpace(u.remoteBaseURL.Text()) != "" && strings.TrimSpace(u.remotePath.Text()) != "" return u.hasSelectedRemoteTarget()
default: default:
return strings.TrimSpace(u.vaultPath.Text()) != "" return strings.TrimSpace(u.vaultPath.Text()) != ""
} }
} }
func (u *ui) hasSelectedRemoteTarget() bool {
return strings.TrimSpace(u.remoteBaseURL.Text()) != "" && strings.TrimSpace(u.remotePath.Text()) != ""
}
func (u *ui) latestRecentVault() (string, time.Time) { func (u *ui) latestRecentVault() (string, time.Time) {
for _, path := range u.recentVaults { for _, path := range u.recentVaults {
if strings.TrimSpace(path) == "" { if strings.TrimSpace(path) == "" {
@@ -1687,6 +1772,56 @@ func (u *ui) latestRecentVault() (string, time.Time) {
return "", 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
default:
u.vaultPath.SetText("")
u.remoteBaseURL.SetText("")
u.remotePath.SetText("")
u.remoteUsername.SetText("")
u.remotePassword.SetText("")
u.rememberRemoteAuth.Value = false
}
u.requestMasterPassFocus = u.hasSelectedLifecycleTarget()
u.filter()
}
func (u *ui) latestRecentRemote() (recentRemoteRecord, bool, time.Time) { func (u *ui) latestRecentRemote() (recentRemoteRecord, bool, time.Time) {
for _, record := range u.recentRemotes { for _, record := range u.recentRemotes {
if strings.TrimSpace(record.BaseURL) == "" || strings.TrimSpace(record.Path) == "" { if strings.TrimSpace(record.BaseURL) == "" || strings.TrimSpace(record.Path) == "" {
@@ -2582,7 +2717,7 @@ func (u *ui) listEmptyState() emptyState {
case appstate.SectionAPITokens: case appstate.SectionAPITokens:
return emptyState{ return emptyState{
Title: "No matching API tokens", Title: "No matching API tokens",
Body: fmt.Sprintf("No API tokens match %q. Clear or refine Search vault to find a token by name, client, or expiration.", query), Body: fmt.Sprintf("No API tokens match %q. Clear or refine Search API tokens to find a token by name, client, or expiration.", query),
} }
case appstate.SectionAPIAudit: case appstate.SectionAPIAudit:
return emptyState{ return emptyState{
@@ -2654,7 +2789,7 @@ func (u *ui) detailPlaceholderMessage() string {
case appstate.SectionAPITokens: case appstate.SectionAPITokens:
return "Select an API token, issue a new one, or search to narrow the list." return "Select an API token, issue a new one, or search to narrow the list."
case appstate.SectionAPIAudit: case appstate.SectionAPIAudit:
return "Select an audit event to inspect it, or use Search vault or the quick filters above." return "Select an audit event to inspect it, or use Search audit log or the quick filters above."
case appstate.SectionTemplates: case appstate.SectionTemplates:
return "Select a template or start a reusable entry." return "Select a template or start a reusable entry."
case appstate.SectionRecycleBin: case appstate.SectionRecycleBin:
@@ -2986,6 +3121,15 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
for u.addAPIPolicyRule.Clicked(gtx) { for u.addAPIPolicyRule.Clicked(gtx) {
u.runAction("add API policy rule", u.addAPIPolicyRuleAction) u.runAction("add API policy rule", u.addAPIPolicyRuleAction)
} }
for u.useCurrentGroupForPolicy.Clicked(gtx) {
u.runAction("use current group for API policy", u.useCurrentGroupForPolicyAction)
}
for u.useSelectedEntryForPolicy.Clicked(gtx) {
u.runAction("use selected entry for API policy", u.useSelectedEntryForPolicyAction)
}
for u.clearAPIPolicyTarget.Clicked(gtx) {
u.runAction("clear API policy target", u.clearAPIPolicyTargetAction)
}
for i := range u.apiPolicyRemoves { for i := range u.apiPolicyRemoves {
for u.apiPolicyRemoves[i].Clicked(gtx) { for u.apiPolicyRemoves[i].Clicked(gtx) {
index := i index := i
@@ -3025,7 +3169,7 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
u.runAction("choose key file", func() error { return u.chooseExistingFileAction(&u.keyFilePath) }) u.runAction("choose key file", func() error { return u.chooseExistingFileAction(&u.keyFilePath) })
} }
for u.pickSyncLocalPath.Clicked(gtx) { 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 i := range u.recentVaultClicks {
for u.recentVaultClicks[i].Clicked(gtx) { for u.recentVaultClicks[i].Clicked(gtx) {
@@ -3055,6 +3199,10 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
if u.lifecycleBusy() { if u.lifecycleBusy() {
continue continue
} }
if u.shouldUseLockedSinglePane() {
u.switchToLifecycleSelection("local")
continue
}
u.vaultPath.SetText("") u.vaultPath.SetText("")
u.state.ErrorMessage = "" u.state.ErrorMessage = ""
u.state.StatusMessage = "" u.state.StatusMessage = ""
@@ -3064,6 +3212,10 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
if u.lifecycleBusy() { if u.lifecycleBusy() {
continue continue
} }
if u.shouldUseLockedSinglePane() {
u.switchToLifecycleSelection("remote")
continue
}
u.remoteBaseURL.SetText("") u.remoteBaseURL.SetText("")
u.remotePath.SetText("") u.remotePath.SetText("")
u.remoteUsername.SetText("") u.remoteUsername.SetText("")
@@ -4159,7 +4311,7 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
rows = append(rows, func(gtx layout.Context) layout.Dimensions { rows = append(rows, func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Min.X = gtx.Constraints.Max.X gtx.Constraints.Min.X = gtx.Constraints.Max.X
return u.outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions { return u.outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions {
editor := material.Editor(u.theme, &u.search, "Search vault") editor := material.Editor(u.theme, &u.search, u.searchPlaceholder())
editor.Color = u.theme.Palette.Fg editor.Color = u.theme.Palette.Fg
editor.HintColor = mutedColor editor.HintColor = mutedColor
return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout) return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout)
@@ -4176,12 +4328,6 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
}) })
} }
} }
if !u.isVaultLocked() && u.state.Section == appstate.SectionRecycleBin {
rows = append(rows, u.recycleBinSectionNotice)
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
return layout.Spacer{Height: spacing}.Layout(gtx)
})
}
if !u.isVaultLocked() && (u.state.Section == appstate.SectionEntries || u.state.Section == appstate.SectionRecycleBin) { if !u.isVaultLocked() && (u.state.Section == appstate.SectionEntries || u.state.Section == appstate.SectionRecycleBin) {
rows = append(rows, u.pathBar) rows = append(rows, u.pathBar)
rows = append(rows, func(gtx layout.Context) layout.Dimensions { rows = append(rows, func(gtx layout.Context) layout.Dimensions {
@@ -4245,18 +4391,6 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
return u.navigationHeader(gtx) return u.navigationHeader(gtx)
}), }),
layout.Rigid(layout.Spacer{Height: spacing}.Layout), layout.Rigid(layout.Spacer{Height: spacing}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.isVaultLocked() || u.state.Section != appstate.SectionRecycleBin {
return layout.Dimensions{}
}
return u.recycleBinSectionNotice(gtx)
}),
layout.Rigid(layout.Spacer{Height: func() unit.Dp {
if u.isVaultLocked() || u.state.Section != appstate.SectionRecycleBin {
return 0
}
return spacing
}()}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.isVaultLocked() || (u.state.Section != appstate.SectionEntries && u.state.Section != appstate.SectionRecycleBin) { if u.isVaultLocked() || (u.state.Section != appstate.SectionEntries && u.state.Section != appstate.SectionRecycleBin) {
return layout.Dimensions{} return layout.Dimensions{}
@@ -4283,7 +4417,7 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Min.X = gtx.Constraints.Max.X gtx.Constraints.Min.X = gtx.Constraints.Max.X
} }
return u.outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions { return u.outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions {
editor := material.Editor(u.theme, &u.search, "Search vault") editor := material.Editor(u.theme, &u.search, u.searchPlaceholder())
editor.Color = u.theme.Palette.Fg editor.Color = u.theme.Palette.Fg
editor.HintColor = mutedColor editor.HintColor = mutedColor
return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout) return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout)
@@ -4612,7 +4746,6 @@ func (u *ui) detailPanelContent(gtx layout.Context) layout.Dimensions {
_ = panel _ = panel
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, func() []layout.FlexChild { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, func() []layout.FlexChild {
if u.isVaultLocked() { if u.isVaultLocked() {
summary := u.currentVaultSummary()
return []layout.FlexChild{ return []layout.FlexChild{
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(18), "Unlock Vault") lbl := material.Label(u.theme, unit.Sp(18), "Unlock Vault")
@@ -4625,47 +4758,6 @@ func (u *ui) detailPanelContent(gtx layout.Context) layout.Dimensions {
lbl.Color = mutedColor lbl.Color = mutedColor
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(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(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(u.unlockPanel), layout.Rigid(u.unlockPanel),
} }
@@ -5741,10 +5833,6 @@ func recyclePathCard(gtx layout.Context, th *material.Theme, title, body string)
}) })
} }
func (u *ui) recycleBinSectionNotice(gtx layout.Context) layout.Dimensions {
return recyclePathCard(gtx, u.theme, "Recycle Bin", "Deleted entries are separated from normal browsing so you can review or restore them safely.")
}
func recycleDetailTitle(gtx layout.Context, th *material.Theme, title string) layout.Dimensions { func recycleDetailTitle(gtx layout.Context, th *material.Theme, title string) layout.Dimensions {
return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 247, G: 239, B: 231, A: 255}), func(gtx layout.Context) layout.Dimensions { return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 247, G: 239, B: 231, A: 255}), func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
@@ -5850,7 +5938,9 @@ func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
var ops op.Ops var ops op.Ops
manager := &session.Manager{} manager := &session.Manager{}
ui := newUIWithSession(mode, manager, paths) ui := newUIWithSession(mode, manager, paths)
ui.fileExplorer = explorer.NewExplorer(w)
ui.invalidate = w.Invalidate 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 }) host, err := api.StartHost(grpcAddr, manager, passwords.DefaultProfiles(), ui.clipboardWriter, func() bool { return ui.state.Dirty })
if err != nil { if err != nil {
ui.state.ErrorMessage = fmt.Sprintf("start gRPC API: %v", err) ui.state.ErrorMessage = fmt.Sprintf("start gRPC API: %v", err)
@@ -5863,6 +5953,7 @@ func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
} }
for { for {
e := w.Event() e := w.Event()
ui.fileExplorer.ListenEvents(e)
switch e := e.(type) { switch e := e.(type) {
case app.DestroyEvent: case app.DestroyEvent:
return e.Err return e.Err
@@ -5870,6 +5961,7 @@ func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
gtx := app.NewContext(&ops, e) gtx := app.NewContext(&ops, e)
ui.processBackgroundActions() ui.processBackgroundActions()
ui.layout(gtx) ui.layout(gtx)
processClipboardWrites(gtx, ui.clipboardWriter)
e.Frame(gtx.Ops) e.Frame(gtx.Ops)
} }
} }
+614 -279
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,63 @@
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"
go build -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" "bytes"
"errors" "errors"
"fmt" "fmt"
"io"
"os" "os"
"path/filepath" "path/filepath"
"reflect" "reflect"
@@ -177,6 +178,18 @@ func (m *Manager) SynchronizeFromLocal(path string) error {
return m.persistMergedToCurrentSource(merged) return m.persistMergedToCurrentSource(merged)
} }
func (m *Manager) SynchronizeFromLocalBytes(name string, content []byte) error {
other, _, err := loadLocalSourceBytes(name, content, m.key)
if err != nil {
return err
}
merged, err := m.mergedWithPeer(other)
if err != nil {
return err
}
return m.persistMergedToCurrentSource(merged)
}
func (m *Manager) SynchronizeToLocal(path string) error { func (m *Manager) SynchronizeToLocal(path string) error {
other, config, err := loadLocalSourceOrEmpty(path, m.key) other, config, err := loadLocalSourceOrEmpty(path, m.key)
if err != nil { if err != nil {
@@ -908,6 +921,17 @@ func loadLocalSource(path string, key vault.MasterKey) (vault.Model, *vault.KDBX
return model, config, nil return model, config, nil
} }
func loadLocalSourceBytes(name string, content []byte, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
if len(content) == 0 {
return vault.Model{}, nil, fmt.Errorf("open %s for synchronize: %w", name, io.EOF)
}
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
if err != nil {
return vault.Model{}, nil, fmt.Errorf("decode %s for synchronize: %w", name, err)
}
return model, config, nil
}
func loadLocalSourceOrEmpty(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) { func loadLocalSourceOrEmpty(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
model, config, err := loadLocalSource(path, key) model, config, err := loadLocalSource(path, key)
if err == nil { if err == nil {
+140 -83
View File
@@ -24,10 +24,10 @@ func TestCreateSaveAsLockAndUnlockRoundTripsVault(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -65,8 +65,8 @@ func TestCreateSaveAsLockAndUnlockRoundTripsVault(t *testing.T) {
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 1 || got[0].Title != "Git Server" || got[0].Password != "token-1" { if len(got) != 1 || got[0].Title != "Vault Console" || got[0].Password != "token-1" {
t.Fatalf("Current() entries = %#v, want persisted Git Server entry", got) t.Fatalf("Current() entries = %#v, want persisted Vault Console entry", got)
} }
} }
@@ -78,10 +78,10 @@ func TestOpenLoadsExistingKDBXFromDisk(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Home Assistant (Codex)", Title: "Surveillance Console",
Username: "codex", Username: "codex",
Password: "token-2", Password: "token-2",
URL: "https://lights.julianfamily.org", URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"}, Path: []string{"Root", "Home Assistant"},
}, },
}, },
@@ -124,10 +124,10 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -146,10 +146,10 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
updated := model updated := model
updated.UpsertEntry(vault.Entry{ updated.UpsertEntry(vault.Entry{
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-2", Password: "token-2",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}) })
sess.Replace(updated) sess.Replace(updated)
@@ -186,19 +186,19 @@ func TestSaveReparentsMixedPathsUnderSingleVaultRoot(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"keepass", "Joe", "Internet"}, Path: []string{"keepass", "Crew", "Internet"},
}, },
{ {
ID: "entry-2", ID: "entry-2",
Title: "Mail", Title: "Mail",
Username: "joejulian", Username: "dannyocean",
Password: "token-2", Password: "token-2",
URL: "https://mail.julianfamily.org", URL: "https://dispatch.crew.example.invalid",
Path: []string{"keepass", "Joe", "eMail"}, Path: []string{"keepass", "Crew", "eMail"},
}, },
}, },
}, key.Password); err != nil { }, key.Password); err != nil {
@@ -217,8 +217,8 @@ func TestSaveReparentsMixedPathsUnderSingleVaultRoot(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Current() error = %v", err) t.Fatalf("Current() error = %v", err)
} }
current.Entries[0].Path = []string{"Joe", "Internet"} current.Entries[0].Path = []string{"Crew", "Internet"}
current.Groups = append(current.Groups, []string{"Joe"}, []string{"Joe", "Internet"}, []string{"Joe", "eMail"}) current.Groups = append(current.Groups, []string{"Crew"}, []string{"Crew", "Internet"}, []string{"Crew", "eMail"})
sess.Replace(current) sess.Replace(current)
if err := sess.Save(); err != nil { if err := sess.Save(); err != nil {
@@ -244,8 +244,8 @@ func TestSaveReparentsMixedPathsUnderSingleVaultRoot(t *testing.T) {
t.Fatalf("top-level groups = %#v, want single keepass root", db.Content.Root.Groups) t.Fatalf("top-level groups = %#v, want single keepass root", db.Content.Root.Groups)
} }
rootGroups := db.Content.Root.Groups[0].Groups rootGroups := db.Content.Root.Groups[0].Groups
if len(rootGroups) != 1 || rootGroups[0].Name != "Joe" { if len(rootGroups) != 1 || rootGroups[0].Name != "Crew" {
t.Fatalf("keepass child groups = %#v, want single Joe group", rootGroups) t.Fatalf("keepass child groups = %#v, want single Crew group", rootGroups)
} }
} }
@@ -271,10 +271,10 @@ func TestOpenRemoteLoadsExistingKDBXFromWebDAV(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -309,7 +309,7 @@ func TestOpenRemoteLoadsExistingKDBXFromWebDAV(t *testing.T) {
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 1 || got[0].Password != "token-1" { if len(got) != 1 || got[0].Password != "token-1" {
t.Fatalf("Current() entries = %#v, want Git Server entry from remote vault", got) t.Fatalf("Current() entries = %#v, want Vault Console entry from remote vault", got)
} }
} }
@@ -321,10 +321,10 @@ func TestSaveRemotePersistsEditsBackToWebDAV(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Home Assistant (Codex)", Title: "Surveillance Console",
Username: "codex", Username: "codex",
Password: "token-1", Password: "token-1",
URL: "https://lights.julianfamily.org", URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"}, Path: []string{"Root", "Home Assistant"},
}, },
}, },
@@ -371,10 +371,10 @@ func TestSaveRemotePersistsEditsBackToWebDAV(t *testing.T) {
} }
current.UpsertEntry(vault.Entry{ current.UpsertEntry(vault.Entry{
ID: "entry-1", ID: "entry-1",
Title: "Home Assistant (Codex)", Title: "Surveillance Console",
Username: "codex", Username: "codex",
Password: "token-2", Password: "token-2",
URL: "https://lights.julianfamily.org", URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"}, Path: []string{"Root", "Home Assistant"},
}) })
sess.Replace(current) sess.Replace(current)
@@ -406,10 +406,10 @@ func TestSaveUsesRemoteTargetWhenVaultWasOpenedFromWebDAV(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -448,10 +448,10 @@ func TestSaveUsesRemoteTargetWhenVaultWasOpenedFromWebDAV(t *testing.T) {
} }
current.UpsertEntry(vault.Entry{ current.UpsertEntry(vault.Entry{
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-2", Password: "token-2",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}) })
sess.Replace(current) sess.Replace(current)
@@ -477,10 +477,10 @@ func TestChangeMasterKeyReencryptsSavedAndLockedVault(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -514,8 +514,8 @@ func TestChangeMasterKeyReencryptsSavedAndLockedVault(t *testing.T) {
t.Fatalf("Current() error = %v", err) t.Fatalf("Current() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 1 || got[0].Title != "Git Server" { if len(got) != 1 || got[0].Title != "Vault Console" {
t.Fatalf("Current() entries = %#v, want Git Server entry after ChangeMasterKey", got) t.Fatalf("Current() entries = %#v, want Vault Console entry after ChangeMasterKey", got)
} }
var reopened Manager var reopened Manager
@@ -540,10 +540,10 @@ func TestSavePreservesOpenedKDBXSecuritySettings(t *testing.T) {
{ {
UUID: gokeepasslib.NewUUID(), UUID: gokeepasslib.NewUUID(),
Values: []gokeepasslib.ValueData{ Values: []gokeepasslib.ValueData{
{Key: "Title", Value: gokeepasslib.V{Content: "Git Server"}}, {Key: "Title", Value: gokeepasslib.V{Content: "Vault Console"}},
{Key: "UserName", Value: gokeepasslib.V{Content: "joejulian"}}, {Key: "UserName", Value: gokeepasslib.V{Content: "dannyocean"}},
{Key: "Password", Value: gokeepasslib.V{Content: "token-1", Protected: w.NewBoolWrapper(true)}}, {Key: "Password", Value: gokeepasslib.V{Content: "token-1", Protected: w.NewBoolWrapper(true)}},
{Key: "URL", Value: gokeepasslib.V{Content: "https://git.julianfamily.org"}}, {Key: "URL", Value: gokeepasslib.V{Content: "https://vault.crew.example.invalid"}},
}, },
}, },
}, },
@@ -577,10 +577,10 @@ func TestSavePreservesOpenedKDBXSecuritySettings(t *testing.T) {
} }
current.UpsertEntry(vault.Entry{ current.UpsertEntry(vault.Entry{
ID: current.Entries[0].ID, ID: current.Entries[0].ID,
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-2", Password: "token-2",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: current.Entries[0].Path, Path: current.Entries[0].Path,
}) })
sess.Replace(current) sess.Replace(current)
@@ -620,10 +620,10 @@ func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-2", Password: "token-2",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
Attachments: map[string][]byte{ Attachments: map[string][]byte{
"token.txt": []byte("secret attachment contents"), "token.txt": []byte("secret attachment contents"),
@@ -631,10 +631,10 @@ func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
History: []vault.Entry{ History: []vault.Entry{
{ {
ID: "entry-1-history-1", ID: "entry-1-history-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -778,10 +778,10 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -841,10 +841,10 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
} }
firstCurrent.UpsertEntry(vault.Entry{ firstCurrent.UpsertEntry(vault.Entry{
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "remote-token-2", Password: "remote-token-2",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Notes: "updated remotely first", Notes: "updated remotely first",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}) })
@@ -859,10 +859,10 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
} }
secondCurrent.UpsertEntry(vault.Entry{ secondCurrent.UpsertEntry(vault.Entry{
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "local-token-2", Password: "local-token-2",
URL: "https://git.julianfamily.org/settings/tokens", URL: "https://vault.crew.example.invalid/security/badges",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}) })
second.Replace(secondCurrent) second.Replace(secondCurrent)
@@ -883,7 +883,7 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
if len(got) != 1 { if len(got) != 1 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got)) t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got))
} }
if got[0].Password != "local-token-2" || got[0].URL != "https://git.julianfamily.org/settings/tokens" { if got[0].Password != "local-token-2" || got[0].URL != "https://vault.crew.example.invalid/security/badges" {
t.Fatalf("entry after synchronize = %#v, want local winning version", got[0]) t.Fatalf("entry after synchronize = %#v, want local winning version", got[0])
} }
if len(got[0].History) == 0 { if len(got[0].History) == 0 {
@@ -905,10 +905,10 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-current", ID: "entry-current",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-current", Password: "token-current",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -917,10 +917,10 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-other", ID: "entry-other",
Title: "Dynadot", Title: "Bellagio",
Username: "jjulian", Username: "rustyryan",
Password: "token-other", Password: "token-other",
URL: "https://www.dynadot.com", URL: "https://bellagio.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -953,6 +953,63 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
} }
} }
func TestSynchronizeFromLocalBytesMergesOtherVaultIntoCurrentSource(t *testing.T) {
t.Parallel()
key := vault.MasterKey{Password: "correct horse battery staple"}
currentPath := filepath.Join(t.TempDir(), "current.kdbx")
currentModel := vault.Model{
Entries: []vault.Entry{{
ID: "entry-current",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-current",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
}},
}
otherModel := vault.Model{
Entries: []vault.Entry{{
ID: "entry-other",
Title: "Bellagio",
Username: "rustyryan",
Password: "token-other",
URL: "https://bellagio.example.invalid",
Path: []string{"Root", "Internet"},
}},
}
writeKDBXTestFile(t, currentPath, currentModel, key)
var other bytes.Buffer
if err := vault.SaveKDBX(&other, otherModel, key.Password); err != nil {
t.Fatalf("SaveKDBX(other) error = %v", err)
}
var sess Manager
if err := sess.Open(currentPath, key); err != nil {
t.Fatalf("Open(current) error = %v", err)
}
if err := sess.SynchronizeFromLocalBytes("picked-other.kdbx", other.Bytes()); err != nil {
t.Fatalf("SynchronizeFromLocalBytes() error = %v", err)
}
var reopened Manager
if err := reopened.Open(currentPath, key); err != nil {
t.Fatalf("reopen Open(current) error = %v", err)
}
current, err := reopened.Current()
if err != nil {
t.Fatalf("reopened Current() error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
}
}
func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) { func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
t.Parallel() t.Parallel()
@@ -964,10 +1021,10 @@ func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-current", ID: "entry-current",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-current", Password: "token-current",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -976,10 +1033,10 @@ func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-other", ID: "entry-other",
Title: "Dynadot", Title: "Bellagio",
Username: "jjulian", Username: "rustyryan",
Password: "token-other", Password: "token-other",
URL: "https://www.dynadot.com", URL: "https://bellagio.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -1021,10 +1078,10 @@ func TestSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-current", ID: "entry-current",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-current", Password: "token-current",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -1033,10 +1090,10 @@ func TestSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
Entries: []vault.Entry{ Entries: []vault.Entry{
{ {
ID: "entry-remote", ID: "entry-remote",
Title: "Dynadot", Title: "Bellagio",
Username: "jjulian", Username: "rustyryan",
Password: "token-remote", Password: "token-remote",
URL: "https://www.dynadot.com", URL: "https://bellagio.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
+1 -1
View File
@@ -471,7 +471,7 @@ func exeAndroid(tmpDir string, tools *androidTools, bi *buildInfo, extraJars, pe
<uses-sdk android:minSdkVersion="{{.MinSDK}}" android:targetSdkVersion="{{.TargetSDK}}" /> <uses-sdk android:minSdkVersion="{{.MinSDK}}" android:targetSdkVersion="{{.TargetSDK}}" />
{{range .Permissions}} <uses-permission android:name="{{.}}"/> {{range .Permissions}} <uses-permission android:name="{{.}}"/>
{{end}}{{range .Features}} <uses-feature android:{{.}} android:required="false"/> {{end}}{{range .Features}} <uses-feature android:{{.}} android:required="false"/>
{{end}}{{.ManifestSnip}} <application {{.IconSnip}} android:label="{{.AppName}}"> {{end}}{{.ManifestSnip}} <application {{.IconSnip}} android:label="{{.AppName}}" android:enableOnBackInvokedCallback="false">
{{.AppSnip}} {{.AppSnip}}
<activity android:name="org.gioui.GioActivity" <activity android:name="org.gioui.GioActivity"
android:label="{{.AppName}}" android:label="{{.AppName}}"
+89 -3
View File
@@ -369,6 +369,57 @@ func (u *ui) addAPIPolicyRuleAction() error {
return nil return nil
} }
func (u *ui) apiPolicyGroupPathSummary() string {
path := parsePath(u.apiPolicyPath.Text())
if len(path) == 0 {
return "No group selected"
}
return strings.Join(path, " / ")
}
func (u *ui) apiPolicyEntrySummary() string {
id := strings.TrimSpace(u.apiPolicyEntryID.Text())
if id == "" {
return "No entry selected"
}
if item, ok := u.selectedEntry(); ok && item.ID == id {
if strings.TrimSpace(item.Title) != "" {
return item.Title + " (" + id + ")"
}
}
return id
}
func (u *ui) useCurrentGroupForPolicyAction() error {
u.syncCurrentPath()
path := u.displayPath()
if len(path) == 0 {
return fmt.Errorf("navigate to a group first")
}
u.apiPolicyGroupScope = true
u.apiPolicyGroupScopeW.Value = true
u.apiPolicyPath.SetText(strings.Join(path, " / "))
u.apiPolicyEntryID.SetText("")
return nil
}
func (u *ui) useSelectedEntryForPolicyAction() error {
item, ok := u.selectedEntry()
if !ok || strings.TrimSpace(item.ID) == "" {
return fmt.Errorf("select an entry first")
}
u.apiPolicyGroupScope = false
u.apiPolicyGroupScopeW.Value = false
u.apiPolicyEntryID.SetText(item.ID)
return nil
}
func (u *ui) clearAPIPolicyTargetAction() error {
u.apiPolicyPath.SetText("")
u.apiPolicyEntryID.SetText("")
return nil
}
func (u *ui) removeAPIPolicyRuleAction(index int) error { func (u *ui) removeAPIPolicyRuleAction(index int) error {
token, ok := u.selectedAPIToken() token, ok := u.selectedAPIToken()
if !ok { if !ok {
@@ -1002,9 +1053,44 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "Operation", "Valid operations: "+strings.Join(stringOps(apiOperations()), ", "), &u.apiPolicyOperation, false)), layout.Rigid(labeledEditorHelp(u.theme, "Operation", "Valid operations: "+strings.Join(stringOps(apiOperations()), ", "), &u.apiPolicyOperation, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "Group Path", "Used when group scope is enabled.", &u.apiPolicyPath, false)), layout.Rigid(func(gtx layout.Context) layout.Dimensions {
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), if u.apiPolicyGroupScopeW.Value {
layout.Rigid(labeledEditorHelp(u.theme, "Entry ID", "Used when group scope is disabled.", &u.apiPolicyEntryID, false)), return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(detailLine(u.theme, "Group Path", u.apiPolicyGroupPathSummary())),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.useCurrentGroupForPolicy, "Use Current Group")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.clearAPIPolicyTarget, "Clear")
}),
)
}),
)
})
}
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(detailLine(u.theme, "Entry", u.apiPolicyEntrySummary())),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.useSelectedEntryForPolicy, "Use Selected Entry")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.clearAPIPolicyTarget, "Clear")
}),
)
}),
)
})
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.addAPIPolicyRule, "Add Rule") return tonedButton(gtx, u.theme, &u.addAPIPolicyRule, "Add Rule")
+262 -106
View File
@@ -20,6 +20,9 @@ import (
func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
busy := u.lifecycleBusy() busy := u.lifecycleBusy()
showLocalChooser := u.showLocalVaultChooser()
showRemoteChooser := u.showRemoteConnectionChooser()
selectedLocalPath := strings.TrimSpace(u.vaultPath.Text())
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "OPEN A VAULT") lbl := material.Label(u.theme, unit.Sp(12), "OPEN A VAULT")
@@ -59,174 +62,173 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
if u.lifecycleMode == "remote" { if u.lifecycleMode == "remote" {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showRemoteChooser {
return layout.Dimensions{}
}
lbl := material.Label(u.theme, unit.Sp(12), "LOCATION") lbl := material.Label(u.theme, unit.Sp(12), "LOCATION")
lbl.Color = mutedColor lbl.Color = mutedColor
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(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 { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if strings.TrimSpace(u.remoteBaseURL.Text()) == "" || strings.TrimSpace(u.remotePath.Text()) == "" { if !showRemoteChooser {
return layout.Dimensions{} return layout.Dimensions{}
} }
record := u.currentRemoteRecord() return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
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 { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), "Server: "+strings.TrimSpace(record.BaseURL)) if !showRemoteChooser {
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{} return layout.Dimensions{}
} }
lbl := material.Label(u.theme, unit.Sp(11), "Last group: "+strings.Join(u.displayEntryPath(lastGroup), " / ")) return labeledEditorHelp(u.theme, "Remote Base URL", "Base WebDAV endpoint, for example https://server/remote.php/webdav.", &u.remoteBaseURL, false)(gtx)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if busy { if !showRemoteChooser {
return layout.Dimensions{} return layout.Dimensions{}
} }
return tonedButton(gtx, u.theme, &u.clearRemoteSelection, "Change...") return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
}), }),
)
})
})
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if busy { if !showRemoteChooser {
return layout.Dimensions{} 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 u.recentRemoteList(gtx)
}
return layout.Dimensions{}
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
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 := material.Label(u.theme, unit.Sp(12), "AUTHENTICATION")
lbl.Color = mutedColor lbl.Color = mutedColor
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(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 { 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) 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 { 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 := material.CheckBox(u.theme, &u.rememberRemoteAuth, "Remember sign-in on this device")
box.Color = accentColor box.Color = accentColor
return box.Layout(gtx) return box.Layout(gtx)
}), }),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { 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 layout.Inset{Top: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.openRemotePrefsHelp, "Settings & Help") 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, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showLocalChooser {
return layout.Dimensions{}
}
lbl := material.Label(u.theme, unit.Sp(12), "RECENT VAULTS") lbl := material.Label(u.theme, unit.Sp(12), "RECENT VAULTS")
lbl.Color = mutedColor lbl.Color = mutedColor
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { 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 layout.Dimensions{}
} }
return u.recentVaultList(gtx) return u.recentVaultList(gtx)
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !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 := material.Label(u.theme, unit.Sp(12), "VAULT FILE")
lbl.Color = mutedColor lbl.Color = mutedColor
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { 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 { switch {
case busy: case busy:
return labeledEditorHelp(u.theme, "Vault Path", localVaultPathHelp(), &u.vaultPath, false)(gtx) return labeledEditorHelp(u.theme, "Vault Path", localVaultPathHelp(), &u.vaultPath, false)(gtx)
case selectedPath == "": case selectedLocalPath == "":
return localPathSelector(u.theme, &u.vaultPath, &u.pickVaultPath)(gtx) return localPathSelector(u.theme, &u.vaultPath, &u.pickVaultPath)(gtx)
default: 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{} 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...")
}),
)
})
})
}
}), }),
) )
}), }),
@@ -296,10 +298,26 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.lifecycleMode == "remote" { if u.lifecycleMode == "remote" {
label := u.remoteOpenButtonLabel() label := u.remoteOpenButtonLabel()
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if busy { if busy {
return passiveTonedButton(gtx, u.theme, label) return passiveTonedButton(gtx, u.theme, label)
} }
return tonedButton(gtx, u.theme, &u.openRemote, 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, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions { 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) 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 { func (u *ui) lifecycleSecuritySettingsSummary() string {
return "Cipher and KDF now live in Vault Settings so opening and creating a vault stays focused on the file, key material, and sync choices." return "Cipher and KDF now live in Vault Settings so opening and creating a vault stays focused on the file, key material, and sync choices."
} }
@@ -909,7 +1034,7 @@ func (u *ui) groupControlsDisclosure(gtx layout.Context) layout.Dimensions {
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
icon := u.expandLessIcon icon := u.expandLessIcon
if u.groupControlsHidden { if u.groupControlsHidden {
icon = u.expandMoreIcon icon = u.chevronRightIcon
} }
if icon == nil { if icon == nil {
lbl := material.Label(u.theme, unit.Sp(16), ">") lbl := material.Label(u.theme, unit.Sp(16), ">")
@@ -1212,12 +1337,14 @@ func selectorEditorHelp(th *material.Theme, label, help string, editor *widget.E
func (u *ui) unlockPanel(gtx layout.Context) layout.Dimensions { func (u *ui) unlockPanel(gtx layout.Context) layout.Dimensions {
targetLabel := "Locked vault" targetLabel := "Locked vault"
targetValue := "Unlock the active vault to continue." targetValue := "Unlock the active vault to continue."
changeLabel := "Open Different Vault"
if u.state.Session != nil { if u.state.Session != nil {
if strings.TrimSpace(u.remoteBaseURL.Text()) != "" || strings.TrimSpace(u.remotePath.Text()) != "" { if strings.TrimSpace(u.remoteBaseURL.Text()) != "" || strings.TrimSpace(u.remotePath.Text()) != "" {
baseURL := strings.TrimSpace(u.remoteBaseURL.Text()) baseURL := strings.TrimSpace(u.remoteBaseURL.Text())
path := strings.TrimSpace(u.remotePath.Text()) path := strings.TrimSpace(u.remotePath.Text())
targetLabel = "Remote vault" targetLabel = "Remote vault"
targetValue = friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: path}) targetValue = friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: path})
changeLabel = "Open Different Connection"
if strings.TrimSpace(targetValue) == "" { if strings.TrimSpace(targetValue) == "" {
targetValue = "Remote WebDAV vault" targetValue = "Remote WebDAV vault"
} }
@@ -1233,8 +1360,7 @@ func (u *ui) unlockPanel(gtx layout.Context) layout.Dimensions {
} }
} }
} }
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, targetCard := func(gtx layout.Context) layout.Dimensions {
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
@@ -1249,9 +1375,27 @@ func (u *ui) unlockPanel(gtx layout.Context) layout.Dimensions {
lbl.Color = accentColor lbl.Color = accentColor
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !u.shouldUseLockedSinglePane() {
return layout.Dimensions{}
}
return layout.Inset{Top: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
if targetLabel == "Remote vault" {
return tonedButton(gtx, u.theme, &u.clearRemoteSelection, changeLabel)
}
return tonedButton(gtx, u.theme, &u.clearVaultSelection, changeLabel)
})
}),
) )
}) })
}) })
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.mode == "desktop" {
return layout.Dimensions{}
}
return targetCard(gtx)
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
@@ -1263,6 +1407,18 @@ func (u *ui) unlockPanel(gtx layout.Context) layout.Dimensions {
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.unlockVault, "Unlock") return tonedButton(gtx, u.theme, &u.unlockVault, "Unlock")
}), }),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.mode != "desktop" {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.mode != "desktop" {
return layout.Dimensions{}
}
return targetCard(gtx)
}),
) )
} }
+28 -28
View File
@@ -8,11 +8,11 @@ func TestUpsertEntryPreservesPreviousVersionInHistory(t *testing.T) {
model := Model{ model := Model{
Entries: []Entry{ Entries: []Entry{
{ {
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "old-token", Password: "old-token",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Notes: "Original note", Notes: "Original note",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
@@ -20,11 +20,11 @@ func TestUpsertEntryPreservesPreviousVersionInHistory(t *testing.T) {
} }
model.UpsertEntry(Entry{ model.UpsertEntry(Entry{
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "new-token", Password: "new-token",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Notes: "Updated note", Notes: "Updated note",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}) })
@@ -53,17 +53,17 @@ func TestDeleteEntryMovesItToRecycleBin(t *testing.T) {
model := Model{ model := Model{
Entries: []Entry{ Entries: []Entry{
{ {
ID: "ha-codex", ID: "surveillance-console",
Title: "Home Assistant (Codex)", Title: "Surveillance Console",
Username: "codex", Username: "codex",
Password: "token-2", Password: "token-2",
URL: "https://lights.julianfamily.org", URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"}, Path: []string{"Root", "Home Assistant"},
}, },
}, },
} }
if err := model.DeleteEntry("ha-codex"); err != nil { if err := model.DeleteEntry("surveillance-console"); err != nil {
t.Fatalf("DeleteEntry() error = %v", err) t.Fatalf("DeleteEntry() error = %v", err)
} }
@@ -75,8 +75,8 @@ func TestDeleteEntryMovesItToRecycleBin(t *testing.T) {
t.Fatalf("len(RecycleBin) = %d, want 1", len(model.RecycleBin)) t.Fatalf("len(RecycleBin) = %d, want 1", len(model.RecycleBin))
} }
if model.RecycleBin[0].Title != "Home Assistant (Codex)" { if model.RecycleBin[0].Title != "Surveillance Console" {
t.Fatalf("RecycleBin[0].Title = %q, want %q", model.RecycleBin[0].Title, "Home Assistant (Codex)") t.Fatalf("RecycleBin[0].Title = %q, want %q", model.RecycleBin[0].Title, "Surveillance Console")
} }
} }
@@ -86,17 +86,17 @@ func TestRestoreEntryMovesItBackFromRecycleBin(t *testing.T) {
model := Model{ model := Model{
RecycleBin: []Entry{ RecycleBin: []Entry{
{ {
ID: "dynadot", ID: "bellagio",
Title: "Dynadot", Title: "Bellagio",
Username: "jjulian", Username: "rustyryan",
Password: "token-3", Password: "token-3",
URL: "https://www.dynadot.com", URL: "https://bellagio.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
} }
if err := model.RestoreEntry("dynadot"); err != nil { if err := model.RestoreEntry("bellagio"); err != nil {
t.Fatalf("RestoreEntry() error = %v", err) t.Fatalf("RestoreEntry() error = %v", err)
} }
@@ -105,8 +105,8 @@ func TestRestoreEntryMovesItBackFromRecycleBin(t *testing.T) {
t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got)) t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got))
} }
if got[0].Title != "Dynadot" { if got[0].Title != "Bellagio" {
t.Fatalf("EntriesInPath()[0].Title = %q, want %q", got[0].Title, "Dynadot") t.Fatalf("EntriesInPath()[0].Title = %q, want %q", got[0].Title, "Bellagio")
} }
if len(model.RecycleBin) != 0 { if len(model.RecycleBin) != 0 {
@@ -120,17 +120,17 @@ func TestRestoreEntryVersionPromotesHistoricalVersionAndRetainsCurrentInHistory(
model := Model{ model := Model{
Entries: []Entry{ Entries: []Entry{
{ {
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "new-token", Password: "new-token",
Notes: "Current note", Notes: "Current note",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
History: []Entry{ History: []Entry{
{ {
ID: "git-server-history-1", ID: "vault-console-history-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "old-token", Password: "old-token",
Notes: "Previous note", Notes: "Previous note",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
@@ -140,7 +140,7 @@ func TestRestoreEntryVersionPromotesHistoricalVersionAndRetainsCurrentInHistory(
}, },
} }
if err := model.RestoreEntryVersion("git-server", 0); err != nil { if err := model.RestoreEntryVersion("vault-console", 0); err != nil {
t.Fatalf("RestoreEntryVersion() error = %v", err) t.Fatalf("RestoreEntryVersion() error = %v", err)
} }
+46 -46
View File
@@ -22,11 +22,11 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
Groups: []gokeepasslib.Group{ Groups: []gokeepasslib.Group{
mustGroup("Root", mustGroup("Root",
mustGroup("Internet", mustGroup("Internet",
mustEntry("Dynadot", "jjulian", "https://www.dynadot.com", "hunter2"), mustEntry("Bellagio", "rustyryan", "https://bellagio.example.invalid", "hunter2"),
mustEntry("Git Server", "joejulian", "https://git.julianfamily.org", "token-1"), mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"),
), ),
mustGroup("Home Assistant", 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)) t.Fatalf("len(EntriesInPath()) = %d, want 2", len(got))
} }
if got[0].Title != "Dynadot" || got[0].Username != "jjulian" || got[0].URL != "https://www.dynadot.com" { if got[0].Title != "Bellagio" || got[0].Username != "rustyryan" || got[0].URL != "https://bellagio.example.invalid" {
t.Fatalf("unexpected first entry: %#v", got[0]) t.Fatalf("unexpected first entry: %#v", got[0])
} }
if got[1].Title != "Git Server" || got[1].Username != "joejulian" || got[1].URL != "https://git.julianfamily.org" { if got[1].Title != "Vault Console" || got[1].Username != "dannyocean" || got[1].URL != "https://vault.crew.example.invalid" {
t.Fatalf("unexpected second entry: %#v", got[1]) t.Fatalf("unexpected second entry: %#v", got[1])
} }
@@ -70,7 +70,7 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
func TestLoadKDBXPreservesEntryDetails(t *testing.T) { func TestLoadKDBXPreservesEntryDetails(t *testing.T) {
t.Parallel() t.Parallel()
entry := mustEntry("Home Assistant (Codex)", "codex", "https://lights.julianfamily.org", "token-2") entry := mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2")
entry.Tags = "automation; home" entry.Tags = "automation; home"
entry.Values = append(entry.Values, entry.Values = append(entry.Values,
mkValue("Notes", "Long-lived token used by Codex for home automation tasks."), mkValue("Notes", "Long-lived token used by Codex for home automation tasks."),
@@ -133,10 +133,10 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
Entries: []Entry{ Entries: []Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "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.", Notes: "Personal git server token entry used for automation and CLI auth.",
Tags: []string{"git", "infra"}, Tags: []string{"git", "infra"},
Fields: map[string]string{ Fields: map[string]string{
@@ -146,10 +146,10 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
}, },
{ {
ID: "entry-2", ID: "entry-2",
Title: "Home Assistant (Codex)", Title: "Surveillance Console",
Username: "codex", Username: "codex",
Password: "token-2", Password: "token-2",
URL: "https://lights.julianfamily.org", URL: "https://surveillance.crew.example.invalid",
Notes: "Long-lived token used by Codex for home automation tasks.", Notes: "Long-lived token used by Codex for home automation tasks.",
Tags: []string{"automation", "home"}, Tags: []string{"automation", "home"},
Path: []string{"Root", "Home Assistant"}, Path: []string{"Root", "Home Assistant"},
@@ -167,7 +167,7 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
t.Fatalf("LoadKDBX() error = %v", err) t.Fatalf("LoadKDBX() error = %v", err)
} }
got := loaded.Search("git") got := loaded.Search("vault")
if len(got) != 1 { if len(got) != 1 {
t.Fatalf("len(Search(\"git\")) = %d, want 1", len(got)) t.Fatalf("len(Search(\"git\")) = %d, want 1", len(got))
} }
@@ -245,18 +245,18 @@ func TestSaveKDBXRoundTripsEntryHistory(t *testing.T) {
Entries: []Entry{ Entries: []Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "new-token", Password: "new-token",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
History: []Entry{ History: []Entry{
{ {
ID: "entry-1-old", ID: "entry-1-old",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "old-token", Password: "old-token",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
Notes: "Original version", Notes: "Original version",
}, },
@@ -296,10 +296,10 @@ func TestSaveKDBXRoundTripsRecycleBinEntries(t *testing.T) {
RecycleBin: []Entry{ RecycleBin: []Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Home Assistant (Codex)", Title: "Surveillance Console",
Username: "codex", Username: "codex",
Password: "token-2", Password: "token-2",
URL: "https://lights.julianfamily.org", URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"}, Path: []string{"Root", "Home Assistant"},
}, },
}, },
@@ -319,8 +319,8 @@ func TestSaveKDBXRoundTripsRecycleBinEntries(t *testing.T) {
t.Fatalf("len(RecycleBin) = %d, want 1", len(loaded.RecycleBin)) t.Fatalf("len(RecycleBin) = %d, want 1", len(loaded.RecycleBin))
} }
if loaded.RecycleBin[0].Title != "Home Assistant (Codex)" { if loaded.RecycleBin[0].Title != "Surveillance Console" {
t.Fatalf("RecycleBin[0].Title = %q, want %q", loaded.RecycleBin[0].Title, "Home Assistant (Codex)") t.Fatalf("RecycleBin[0].Title = %q, want %q", loaded.RecycleBin[0].Title, "Surveillance Console")
} }
if len(loaded.RecycleBin[0].Path) != 2 || loaded.RecycleBin[0].Path[0] != "Root" || loaded.RecycleBin[0].Path[1] != "Home Assistant" { if len(loaded.RecycleBin[0].Path) != 2 || loaded.RecycleBin[0].Path[0] != "Root" || loaded.RecycleBin[0].Path[1] != "Home Assistant" {
@@ -358,7 +358,7 @@ func TestLoadKDBXWithKeyFileCredentials(t *testing.T) {
Meta: gokeepasslib.NewMetaData(), Meta: gokeepasslib.NewMetaData(),
Root: &gokeepasslib.RootData{ Root: &gokeepasslib.RootData{
Groups: []gokeepasslib.Group{ Groups: []gokeepasslib.Group{
mustGroup("Root", mustGroup("Internet", mustEntry("Git Server", "joejulian", "https://git.julianfamily.org", "token-1"))), mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"))),
}, },
}, },
}, },
@@ -378,9 +378,9 @@ func TestLoadKDBXWithKeyFileCredentials(t *testing.T) {
t.Fatalf("LoadKDBXWithKey() error = %v", err) t.Fatalf("LoadKDBXWithKey() error = %v", err)
} }
got := model.Search("git") got := model.Search("vault")
if len(got) != 1 || got[0].Entry.Password != "token-1" { if len(got) != 1 || got[0].Entry.Password != "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(), Meta: gokeepasslib.NewMetaData(),
Root: &gokeepasslib.RootData{ Root: &gokeepasslib.RootData{
Groups: []gokeepasslib.Group{ Groups: []gokeepasslib.Group{
mustGroup("Root", mustGroup("Home Assistant", mustEntry("Home Assistant (Codex)", "codex", "https://lights.julianfamily.org", "token-2"))), mustGroup("Root", mustGroup("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(), Meta: gokeepasslib.NewMetaData(),
Root: &gokeepasslib.RootData{ Root: &gokeepasslib.RootData{
Groups: []gokeepasslib.Group{ Groups: []gokeepasslib.Group{
mustGroup("Root", mustGroup("Internet", mustEntry("Git Server", "joejulian", "https://git.julianfamily.org", "token-1"))), mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"))),
}, },
}, },
}, },
@@ -490,11 +490,11 @@ func TestSaveKDBXWithKeyRoundTripsModel(t *testing.T) {
model := Model{ model := Model{
Entries: []Entry{ Entries: []Entry{
{ {
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
}, },
@@ -510,9 +510,9 @@ func TestSaveKDBXWithKeyRoundTripsModel(t *testing.T) {
t.Fatalf("LoadKDBXWithKey() error = %v", err) t.Fatalf("LoadKDBXWithKey() error = %v", err)
} }
got := loaded.Search("git") got := loaded.Search("vault")
if len(got) != 1 || got[0].Entry.Password != "token-1" { if len(got) != 1 || got[0].Entry.Password != "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{ model := Model{
Entries: []Entry{ Entries: []Entry{
{ {
ID: "ha-codex", ID: "surveillance-console",
Title: "Home Assistant (Codex)", Title: "Surveillance Console",
Username: "codex", Username: "codex",
Password: "token-2", Password: "token-2",
URL: "https://lights.julianfamily.org", URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"}, Path: []string{"Root", "Home Assistant"},
}, },
}, },
@@ -570,11 +570,11 @@ func TestKDBXRoundTripsEntryAttachments(t *testing.T) {
model := Model{ model := Model{
Entries: []Entry{ Entries: []Entry{
{ {
ID: "git-server", ID: "vault-console",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
Attachments: map[string][]byte{ Attachments: map[string][]byte{
"token.txt": []byte("secret attachment contents"), "token.txt": []byte("secret attachment contents"),
@@ -610,10 +610,10 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
Entries: []Entry{ Entries: []Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-2", Password: "token-2",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Notes: "Current credential", Notes: "Current credential",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
Attachments: map[string][]byte{ Attachments: map[string][]byte{
@@ -622,10 +622,10 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
History: []Entry{ History: []Entry{
{ {
ID: "entry-1-history-1", ID: "entry-1-history-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
URL: "https://git.julianfamily.org", URL: "https://vault.crew.example.invalid",
Notes: "Original credential", Notes: "Original credential",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
+49 -49
View File
@@ -9,9 +9,9 @@ import (
func testModel() Model { func testModel() Model {
return Model{ return Model{
Entries: []Entry{ Entries: []Entry{
{ID: "1", Title: "Dynadot", Username: "jjulian", URL: "https://www.dynadot.com", Path: []string{"Joe", "Internet"}}, {ID: "1", Title: "Bellagio", Username: "rustyryan", URL: "https://bellagio.example.invalid", Path: []string{"Crew", "Internet"}},
{ID: "2", Title: "Git Server", Username: "joejulian", URL: "https://git.julianfamily.org", Path: []string{"Joe", "Internet"}}, {ID: "2", Title: "Vault Console", Username: "dannyocean", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
{ID: "3", Title: "Home Assistant (Codex)", Username: "codex", URL: "https://lights.julianfamily.org", Path: []string{"Joe", "Home Assistant"}}, {ID: "3", Title: "Surveillance Console", Username: "codex", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
{ID: "4", Title: "Alma (WA Prep)", Username: "christina.julian", URL: "https://waprep.getalma.com", Path: []string{"Tricia", "School"}}, {ID: "4", Title: "Alma (WA Prep)", Username: "christina.julian", URL: "https://waprep.getalma.com", Path: []string{"Tricia", "School"}},
}, },
} }
@@ -20,7 +20,7 @@ func testModel() Model {
func TestChildGroupsReturnsImmediateGroupsOnly(t *testing.T) { func TestChildGroupsReturnsImmediateGroupsOnly(t *testing.T) {
model := testModel() model := testModel()
got := model.ChildGroups([]string{"Joe"}) got := model.ChildGroups([]string{"Crew"})
want := []string{"Home Assistant", "Internet"} want := []string{"Home Assistant", "Internet"}
if !slices.Equal(got, want) { if !slices.Equal(got, want) {
@@ -31,12 +31,12 @@ func TestChildGroupsReturnsImmediateGroupsOnly(t *testing.T) {
func TestEntriesInPathReturnsOnlyDirectEntries(t *testing.T) { func TestEntriesInPathReturnsOnlyDirectEntries(t *testing.T) {
model := testModel() model := testModel()
got := model.EntriesInPath([]string{"Joe", "Internet"}) got := model.EntriesInPath([]string{"Crew", "Internet"})
if len(got) != 2 { if len(got) != 2 {
t.Fatalf("len(EntriesInPath()) = %d, want 2", len(got)) t.Fatalf("len(EntriesInPath()) = %d, want 2", len(got))
} }
if got[0].Title != "Dynadot" || got[1].Title != "Git Server" { if got[0].Title != "Bellagio" || got[1].Title != "Vault Console" {
t.Fatalf("EntriesInPath() titles = %q, %q", got[0].Title, got[1].Title) t.Fatalf("EntriesInPath() titles = %q, %q", got[0].Title, got[1].Title)
} }
} }
@@ -45,29 +45,29 @@ func TestEntriesUnderPathReturnsDescendantEntries(t *testing.T) {
t.Parallel() t.Parallel()
model := testModel() model := testModel()
got := model.EntriesUnderPath([]string{"Joe"}) got := model.EntriesUnderPath([]string{"Crew"})
if len(got) != 3 { if len(got) != 3 {
t.Fatalf("len(EntriesUnderPath(Joe)) = %d, want 3", len(got)) t.Fatalf("len(EntriesUnderPath(Crew)) = %d, want 3", len(got))
} }
if got[0].Title != "Dynadot" || got[1].Title != "Git Server" || got[2].Title != "Home Assistant (Codex)" { if got[0].Title != "Bellagio" || got[1].Title != "Surveillance Console" || got[2].Title != "Vault Console" {
t.Fatalf("EntriesUnderPath(Joe) titles = %q, %q, %q", got[0].Title, got[1].Title, got[2].Title) t.Fatalf("EntriesUnderPath(Crew) titles = %q, %q, %q", got[0].Title, got[1].Title, got[2].Title)
} }
} }
func TestSearchReturnsMatchesWithFullPathContext(t *testing.T) { func TestSearchReturnsMatchesWithFullPathContext(t *testing.T) {
model := testModel() model := testModel()
got := model.Search("git") got := model.Search("vault")
if len(got) != 1 { if len(got) != 1 {
t.Fatalf("len(Search()) = %d, want 1", len(got)) t.Fatalf("len(Search()) = %d, want 1", len(got))
} }
if got[0].Entry.Title != "Git Server" { if got[0].Entry.Title != "Vault Console" {
t.Fatalf("Search() title = %q, want %q", got[0].Entry.Title, "Git Server") t.Fatalf("Search() title = %q, want %q", got[0].Entry.Title, "Vault Console")
} }
if got[0].Path != "Joe / Internet" { if got[0].Path != "Crew / Internet" {
t.Fatalf("Search() path = %q, want %q", got[0].Path, "Joe / Internet") t.Fatalf("Search() path = %q, want %q", got[0].Path, "Crew / Internet")
} }
} }
@@ -119,11 +119,11 @@ func TestInstantiateTemplateCreatesNormalEntryWithOverrides(t *testing.T) {
entry, err := model.InstantiateTemplate("tpl-1", Entry{ entry, err := model.InstantiateTemplate("tpl-1", Entry{
ID: "entry-1", ID: "entry-1",
Title: "Dynadot", Title: "Bellagio",
Username: "jjulian", Username: "rustyryan",
Password: "hunter2", Password: "hunter2",
URL: "https://www.dynadot.com", URL: "https://bellagio.example.invalid",
Path: []string{"Joe", "Internet"}, Path: []string{"Crew", "Internet"},
Tags: []string{"dns"}, Tags: []string{"dns"},
}) })
if err != nil { if err != nil {
@@ -134,11 +134,11 @@ func TestInstantiateTemplateCreatesNormalEntryWithOverrides(t *testing.T) {
t.Fatalf("entry.ID = %q, want %q", entry.ID, "entry-1") t.Fatalf("entry.ID = %q, want %q", entry.ID, "entry-1")
} }
if entry.Title != "Dynadot" { if entry.Title != "Bellagio" {
t.Fatalf("entry.Title = %q, want %q", entry.Title, "Dynadot") t.Fatalf("entry.Title = %q, want %q", entry.Title, "Bellagio")
} }
if entry.Username != "jjulian" || entry.Password != "hunter2" || entry.URL != "https://www.dynadot.com" { if entry.Username != "rustyryan" || entry.Password != "hunter2" || entry.URL != "https://bellagio.example.invalid" {
t.Fatalf("entry credentials = %#v, want override values", entry) t.Fatalf("entry credentials = %#v, want override values", entry)
} }
@@ -154,9 +154,9 @@ func TestInstantiateTemplateCreatesNormalEntryWithOverrides(t *testing.T) {
t.Fatalf("entry.Fields[Environment] = %q, want %q", entry.Fields["Environment"], "prod") t.Fatalf("entry.Fields[Environment] = %q, want %q", entry.Fields["Environment"], "prod")
} }
got := model.EntriesInPath([]string{"Joe", "Internet"}) got := model.EntriesInPath([]string{"Crew", "Internet"})
if len(got) != 1 || got[0].Title != "Dynadot" { if len(got) != 1 || got[0].Title != "Bellagio" {
t.Fatalf("EntriesInPath() = %#v, want instantiated Dynadot entry", got) t.Fatalf("EntriesInPath() = %#v, want instantiated Bellagio entry", got)
} }
} }
@@ -178,7 +178,7 @@ func TestDeleteTemplateRemovesTemplateWithoutTouchingEntries(t *testing.T) {
model := Model{ model := Model{
Entries: []Entry{ Entries: []Entry{
{ID: "entry-1", Title: "Git Server", Path: []string{"Root", "Internet"}}, {ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
}, },
Templates: []Entry{ Templates: []Entry{
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates"}}, {ID: "tpl-1", Title: "Website Login", Path: []string{"Templates"}},
@@ -222,8 +222,8 @@ func TestDuplicateEntryCopiesEntryWithNewIDAndTitle(t *testing.T) {
Entries: []Entry{ Entries: []Entry{
{ {
ID: "entry-1", ID: "entry-1",
Title: "Git Server", Title: "Vault Console",
Username: "joejulian", Username: "dannyocean",
Password: "token-1", Password: "token-1",
Path: []string{"Root", "Internet"}, Path: []string{"Root", "Internet"},
}, },
@@ -238,8 +238,8 @@ func TestDuplicateEntryCopiesEntryWithNewIDAndTitle(t *testing.T) {
if duplicate.ID != "entry-2" { if duplicate.ID != "entry-2" {
t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "entry-2") t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "entry-2")
} }
if duplicate.Title != "Git Server (Copy)" { if duplicate.Title != "Vault Console (Copy)" {
t.Fatalf("duplicate.Title = %q, want %q", duplicate.Title, "Git Server (Copy)") t.Fatalf("duplicate.Title = %q, want %q", duplicate.Title, "Vault Console (Copy)")
} }
got := model.EntriesInPath([]string{"Root", "Internet"}) got := model.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 2 { if len(got) != 2 {
@@ -250,9 +250,9 @@ func TestDuplicateEntryCopiesEntryWithNewIDAndTitle(t *testing.T) {
func TestCreateGroupMakesItVisibleAsChildGroup(t *testing.T) { func TestCreateGroupMakesItVisibleAsChildGroup(t *testing.T) {
model := testModel() model := testModel()
model.CreateGroup([]string{"Joe"}, "Finance") model.CreateGroup([]string{"Crew"}, "Finance")
got := model.ChildGroups([]string{"Joe"}) got := model.ChildGroups([]string{"Crew"})
want := []string{"Finance", "Home Assistant", "Internet"} want := []string{"Finance", "Home Assistant", "Internet"}
if !slices.Equal(got, want) { if !slices.Equal(got, want) {
t.Fatalf("ChildGroups() = %v, want %v", got, want) t.Fatalf("ChildGroups() = %v, want %v", got, want)
@@ -263,32 +263,32 @@ func TestCreateGroupSupportsNestedRelativePath(t *testing.T) {
t.Parallel() t.Parallel()
model := testModel() model := testModel()
model.CreateGroup([]string{"Joe"}, "Infrastructure / Prod") model.CreateGroup([]string{"Crew"}, "Infrastructure / Prod")
got := model.ChildGroups([]string{"Joe"}) got := model.ChildGroups([]string{"Crew"})
if !slices.Equal(got, []string{"Home Assistant", "Infrastructure", "Internet"}) { if !slices.Equal(got, []string{"Home Assistant", "Infrastructure", "Internet"}) {
t.Fatalf("ChildGroups(Joe) = %v, want [Home Assistant Infrastructure Internet]", got) t.Fatalf("ChildGroups(Crew) = %v, want [Home Assistant Infrastructure Internet]", got)
} }
got = model.ChildGroups([]string{"Joe", "Infrastructure"}) got = model.ChildGroups([]string{"Crew", "Infrastructure"})
if !slices.Equal(got, []string{"Prod"}) { if !slices.Equal(got, []string{"Prod"}) {
t.Fatalf("ChildGroups(Joe/Infrastructure) = %v, want [Prod]", got) t.Fatalf("ChildGroups(Crew/Infrastructure) = %v, want [Prod]", got)
} }
} }
func TestRenameGroupMovesEntriesAndKeepsHierarchy(t *testing.T) { func TestRenameGroupMovesEntriesAndKeepsHierarchy(t *testing.T) {
model := testModel() model := testModel()
if err := model.RenameGroup([]string{"Joe", "Internet"}, "Infra"); err != nil { if err := model.RenameGroup([]string{"Crew", "Internet"}, "Infra"); err != nil {
t.Fatalf("RenameGroup() error = %v", err) t.Fatalf("RenameGroup() error = %v", err)
} }
got := model.EntriesInPath([]string{"Joe", "Infra"}) got := model.EntriesInPath([]string{"Crew", "Infra"})
if len(got) != 2 { if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Joe/Infra)) = %d, want 2", len(got)) t.Fatalf("len(EntriesInPath(Crew/Infra)) = %d, want 2", len(got))
} }
if len(model.EntriesInPath([]string{"Joe", "Internet"})) != 0 { if len(model.EntriesInPath([]string{"Crew", "Internet"})) != 0 {
t.Fatal("EntriesInPath(Joe/Internet) should be empty after rename") t.Fatal("EntriesInPath(Crew/Internet) should be empty after rename")
} }
} }
@@ -309,8 +309,8 @@ func TestMoveGroupMovesEntriesAndNestedGroups(t *testing.T) {
t.Parallel() t.Parallel()
model := testModel() model := testModel()
model.CreateGroup([]string{"Joe", "Internet"}, "Infrastructure") model.CreateGroup([]string{"Crew", "Internet"}, "Infrastructure")
if err := model.MoveGroup([]string{"Joe", "Internet"}, []string{"Tricia"}); err != nil { if err := model.MoveGroup([]string{"Crew", "Internet"}, []string{"Tricia"}); err != nil {
t.Fatalf("MoveGroup() error = %v", err) t.Fatalf("MoveGroup() error = %v", err)
} }
@@ -318,8 +318,8 @@ func TestMoveGroupMovesEntriesAndNestedGroups(t *testing.T) {
if len(got) != 2 { if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Tricia/Internet)) = %d, want 2", len(got)) t.Fatalf("len(EntriesInPath(Tricia/Internet)) = %d, want 2", len(got))
} }
if len(model.EntriesInPath([]string{"Joe", "Internet"})) != 0 { if len(model.EntriesInPath([]string{"Crew", "Internet"})) != 0 {
t.Fatal("EntriesInPath(Joe/Internet) should be empty after move") t.Fatal("EntriesInPath(Crew/Internet) should be empty after move")
} }
gotGroups := model.ChildGroups([]string{"Tricia", "Internet"}) gotGroups := model.ChildGroups([]string{"Tricia", "Internet"})
if !slices.Equal(gotGroups, []string{"Infrastructure"}) { if !slices.Equal(gotGroups, []string{"Infrastructure"}) {
@@ -330,12 +330,12 @@ func TestMoveGroupMovesEntriesAndNestedGroups(t *testing.T) {
func TestDeleteEmptyGroupRemovesItFromNavigation(t *testing.T) { func TestDeleteEmptyGroupRemovesItFromNavigation(t *testing.T) {
model := testModel() model := testModel()
model.CreateGroup([]string{"Joe"}, "Finance") model.CreateGroup([]string{"Crew"}, "Finance")
if err := model.DeleteGroup([]string{"Joe", "Finance"}); err != nil { if err := model.DeleteGroup([]string{"Crew", "Finance"}); err != nil {
t.Fatalf("DeleteGroup() error = %v", err) t.Fatalf("DeleteGroup() error = %v", err)
} }
got := model.ChildGroups([]string{"Joe"}) got := model.ChildGroups([]string{"Crew"})
want := []string{"Home Assistant", "Internet"} want := []string{"Home Assistant", "Internet"}
if !slices.Equal(got, want) { if !slices.Equal(got, want) {
t.Fatalf("ChildGroups() = %v, want %v", got, want) t.Fatalf("ChildGroups() = %v, want %v", got, want)
+5 -5
View File
@@ -15,8 +15,8 @@ func TestClientOpenDownloadsRemoteVault(t *testing.T) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
t.Fatalf("method = %s, want GET", r.Method) t.Fatalf("method = %s, want GET", r.Method)
} }
if user, pass, ok := r.BasicAuth(); !ok || user != "jjulian" || pass != "secret" { if user, pass, ok := r.BasicAuth(); !ok || user != "rustyryan" || pass != "secret" {
t.Fatalf("basic auth = %q/%q ok=%v, want jjulian/secret true", user, pass, ok) t.Fatalf("basic auth = %q/%q ok=%v, want rustyryan/secret true", user, pass, ok)
} }
w.Header().Set("ETag", `"etag-1"`) w.Header().Set("ETag", `"etag-1"`)
_, _ = io.WriteString(w, "vault-bytes") _, _ = io.WriteString(w, "vault-bytes")
@@ -26,7 +26,7 @@ func TestClientOpenDownloadsRemoteVault(t *testing.T) {
client := Client{ client := Client{
HTTPClient: server.Client(), HTTPClient: server.Client(),
BaseURL: server.URL, BaseURL: server.URL,
Username: "jjulian", Username: "rustyryan",
Password: "secret", Password: "secret",
} }
@@ -69,7 +69,7 @@ func TestClientSaveUploadsVaultWithIfMatch(t *testing.T) {
client := Client{ client := Client{
HTTPClient: server.Client(), HTTPClient: server.Client(),
BaseURL: server.URL, BaseURL: server.URL,
Username: "jjulian", Username: "rustyryan",
Password: "secret", Password: "secret",
} }
@@ -94,7 +94,7 @@ func TestClientSaveReturnsConflictOnVersionMismatch(t *testing.T) {
client := Client{ client := Client{
HTTPClient: server.Client(), HTTPClient: server.Client(),
BaseURL: server.URL, BaseURL: server.URL,
Username: "jjulian", Username: "rustyryan",
Password: "secret", Password: "secret",
} }