Compare commits

..

119 Commits

Author SHA1 Message Date
Joe Julian d8db9f14b3 Align audit UI tests with empty-state model 2026-04-01 17:21:16 -07:00
Joe Julian d1485a66c1 Refine audit UI and vault settings placement 2026-04-01 17:21:16 -07:00
Joe Julian cb1447e554 Improve local vault open usability 2026-04-01 17:17:37 -07:00
Joe Julian 315988134b Refine entry detail and edit panels 2026-04-01 17:15:58 -07:00
Joe Julian 6293eca072 Tighten mobile locked vault hierarchy 2026-04-01 17:15:20 -07:00
Joe Julian 3510bcc3b6 Improve autofill status feedback 2026-04-01 17:13:43 -07:00
Joe Julian 5c56abf31b Refine API token management UI 2026-04-01 17:12:29 -07:00
Joe Julian 139ea12317 Improve entries navigation readability 2026-04-01 17:12:17 -07:00
Joe Julian fddf864c42 Refine remote open lifecycle flow 2026-04-01 17:10:26 -07:00
Joe Julian 6eee43c1df Clarify group navigation and management 2026-04-01 17:10:04 -07:00
Joe Julian 18d57a2f8e Refine recycle bin and sync UX 2026-04-01 17:09:51 -07:00
Joe Julian b4bcceab31 Clarify lifecycle loading and target selection 2026-04-01 17:00:24 -07:00
Joe Julian 85f07ded39 Avoid blocking UI during vault open and unlock 2026-04-01 16:54:16 -07:00
Joe Julian 2fded8a403 Improve recent targets and workflow context 2026-04-01 16:43:15 -07:00
Joe Julian 23853c479a Refine group, editor, and admin workflows 2026-04-01 16:34:24 -07:00
Joe Julian 54ec195e61 Clarify start, locked, and recycle flows 2026-04-01 16:29:53 -07:00
Joe Julian 1675811aa3 Tighten navigation and admin UI 2026-04-01 16:24:11 -07:00
Joe Julian 14f4b31cf6 Refine settings and empty-state affordances 2026-04-01 15:00:46 -07:00
Joe Julian 1148eb819f Simplify lifecycle and section UI 2026-04-01 14:58:56 -07:00
Joe Julian 1ec373821d Add UI review follow-ups to TODO 2026-04-01 14:22:53 -07:00
Joe Julian 3e03d4669a Honor alternate autofill targets from entry fields 2026-04-01 10:36:08 -07:00
Joe Julian b9ecd0ae70 Fix entry and group navigation workflows 2026-04-01 10:24:57 -07:00
Joe Julian 69aa72ee3b Support Android app targets in autofill service 2026-04-01 05:56:52 -07:00
Joe Julian fbd364f01d Disambiguate same-host Android fill matches 2026-04-01 05:21:15 -07:00
Joe Julian 8be0aad14d Add Android accessibility-based Chrome form fill 2026-04-01 01:24:28 -07:00
Joe Julian eef209ae43 Add Android autofill service packaging 2026-03-31 23:13:10 -07:00
Joe Julian 322dc84917 Tighten Android lifecycle screen layout 2026-03-31 22:53:47 -07:00
Joe Julian 3d53ff8e95 Add KeePassGO branding assets 2026-03-31 22:34:36 -07:00
Joe Julian 0a6b7fe5c0 Fix Android rendering by pinning Gio v0.8 2026-03-31 22:23:45 -07:00
Joe Julian 82bfd694b6 Align Android build guidance with Gio 2026-03-31 21:37:19 -07:00
Joe Julian d9c2d48ff5 Harden API token policy button state 2026-03-31 20:23:59 -07:00
Joe Julian 8495beb8dc Test Android packaging configuration 2026-03-31 20:15:48 -07:00
Joe Julian 3e471366ce Isolate test state and paint full frame 2026-03-31 20:12:54 -07:00
Joe Julian f9b58367da Fix API token detail panel panic 2026-03-30 14:46:07 -07:00
Joe Julian 20f84ac58e Restore entries tab state and preload recent vault 2026-03-30 14:31:06 -07:00
Joe Julian 2db8a71704 Fix hidden root section restore 2026-03-30 09:24:41 -07:00
Joe Julian b7a4742ee6 Add configurable vault security settings 2026-03-30 07:58:27 -07:00
Joe Julian 9afddd7a93 Host the gRPC API and add token admin views 2026-03-30 07:50:34 -07:00
Joe Julian 84c188129e Fix Android startup defaults and window sizing 2026-03-30 07:30:06 -07:00
Joe Julian 980d30f6c2 Record audit events for API token authorization 2026-03-29 23:19:54 -07:00
Joe Julian e6b33134c9 Add token management helpers for API credentials 2026-03-29 23:17:34 -07:00
Joe Julian a269217c60 Expose API approval prompts to app state and UI 2026-03-29 23:14:39 -07:00
Joe Julian 722d2eefa0 Add API approval broker for gRPC authorization prompts 2026-03-29 23:09:36 -07:00
Joe Julian dba0bf1f2c Authorize gRPC requests with vault API tokens 2026-03-29 22:56:18 -07:00
Joe Julian 47942014d7 Add API token domain model and policy evaluation 2026-03-29 22:46:44 -07:00
Joe Julian ed2118223f Add API token authorization TODO segments 2026-03-29 22:38:09 -07:00
Joe Julian 9e30f654b2 Preserve single-root KDBX group trees 2026-03-29 22:17:40 -07:00
Joe Julian 1ecad1fae9 Set up Android SDK prerequisites for APK builds 2026-03-29 21:59:52 -07:00
Joe Julian fc34508689 Add advanced synchronize dialog and APK tooling 2026-03-29 21:38:46 -07:00
Joe Julian 9dea7a4a34 Preserve remote conflicts in entry history 2026-03-29 21:19:42 -07:00
Joe Julian e5904dd224 Tighten banner and navigation behavior 2026-03-29 21:16:38 -07:00
Joe Julian da769a9728 Keep recent remote passwords masked 2026-03-29 21:07:57 -07:00
Joe Julian f47682f3a1 Scroll lifecycle screen and restore remote groups 2026-03-29 17:46:48 -07:00
Joe Julian 9dcc5f1d6f Collapse empty phone detail pane 2026-03-29 17:41:51 -07:00
Joe Julian ee9b00bd85 Rebalance desktop open-vault layout 2026-03-29 17:36:41 -07:00
Joe Julian 8ce924e08f Reset password peek across view changes 2026-03-29 17:27:19 -07:00
Joe Julian bda28eef4b Remember recent remote vault connections 2026-03-29 17:05:42 -07:00
Joe Julian bf56e38bc5 Tighten desktop vault navigation header 2026-03-29 16:58:21 -07:00
Joe Julian d137018d3a Honor env state dir in default UI paths 2026-03-29 16:54:20 -07:00
Joe Julian fcc0291dd9 Simplify locked vault screen 2026-03-29 16:47:29 -07:00
Joe Julian cc127a013e Add collapsible group tools section 2026-03-29 16:39:27 -07:00
Joe Julian b2f1d9a66d Use structured custom fields in entry editor 2026-03-29 16:37:20 -07:00
Joe Julian b391cea295 Restore per-vault groups and scroll edit forms 2026-03-29 16:32:54 -07:00
Joe Julian fba135ff09 Require isolated state dir for test runs 2026-03-29 16:26:21 -07:00
Joe Julian 71b383114d Improve group deletion and status banner UX 2026-03-29 16:17:22 -07:00
Joe Julian 75dc6453f7 Support KeePassGO state dir via flag and env 2026-03-29 15:41:44 -07:00
Joe Julian 72b413a11a Clear KeePassGO master password after use 2026-03-29 15:38:30 -07:00
Joe Julian 75002a4c47 Fix KeePassGO open screen field and banner UX 2026-03-29 15:35:32 -07:00
Joe Julian e98256709d Default Enter to opening the selected vault 2026-03-29 15:33:41 -07:00
Joe Julian 5e15ad3265 Persist recent KeePassGO vault list 2026-03-29 15:28:20 -07:00
Joe Julian d492743eb1 Improve KeePassGO vault navigation UX 2026-03-29 15:18:14 -07:00
Joe Julian 4a629c16bd Simplify KeePassGO desktop vault workflow 2026-03-29 14:50:33 -07:00
Joe Julian 62fc343ecf Refine lifecycle setup screen 2026-03-29 14:00:20 -07:00
Joe Julian 1e51eff76e Document remaining AGENTS gaps 2026-03-29 13:53:27 -07:00
Joe Julian 4509634a15 Update lifecycle tests for controller state 2026-03-29 13:46:30 -07:00
Joe Julian 47bbbdb05e Restore search path context helper 2026-03-29 13:46:30 -07:00
Joe Julian 4fbaee3970 Align keyboard and tests with controller state 2026-03-29 13:46:30 -07:00
Joe Julian d2b49ca0ad Remove duplicate app state group deletion 2026-03-29 13:46:30 -07:00
Joe Julian fedeab2fc1 Merge branch 'seg01-appstate' into merge-main-01-seg01-appstate 2026-03-29 13:46:30 -07:00
Joe Julian b56401b5c6 Centralize app state ownership in controller 2026-03-29 13:46:30 -07:00
Joe Julian 01559a3a2b Fix rebased main path tests 2026-03-29 13:44:05 -07:00
Joe Julian 16dc1de3c8 Add password generation UI profile workflow 2026-03-29 13:44:05 -07:00
Joe Julian bd9674a1f5 Add local vault lifecycle UI coverage 2026-03-29 13:43:13 -07:00
Joe Julian e2e38a97a0 Drop obsolete UI path fallback after rebase 2026-03-29 13:41:44 -07:00
Joe Julian 34a6e0150e Sync UI path fallbacks during main landing 2026-03-29 13:40:57 -07:00
Joe Julian ec063ac81f Align moved-entry test with controller navigation 2026-03-29 13:40:36 -07:00
Joe Julian ac5b6894cf Add attachment replace workflow UI 2026-03-29 13:40:36 -07:00
Joe Julian d6bc213474 test: align rebased path coverage with controller state 2026-03-29 13:37:57 -07:00
Joe Julian 706a72e135 test: align keyboard navigation with controller paths 2026-03-29 13:37:00 -07:00
Joe Julian f8111f626f test: align template UI coverage with controller navigation 2026-03-29 13:37:00 -07:00
Joe Julian 0bfa30de91 test: cover template CRUD UI flows 2026-03-29 13:37:00 -07:00
Joe Julian 24dcf9c8ad Make group navigation controller-driven 2026-03-29 13:37:00 -07:00
Joe Julian ea5c8b3c31 Merge commit '7651b3b' into merge-main-13-seg13-copy-reveal 2026-03-29 13:36:26 -07:00
Joe Julian c355eebed5 Merge commit 'cffe05a' into merge-main-13-seg13-copy-reveal
# Conflicts:
#	main.go
2026-03-29 13:35:59 -07:00
Joe Julian 7651b3b237 Resolve remote lifecycle landing conflicts 2026-03-29 13:35:18 -07:00
Joe Julian da942b41d7 Add WebDAV lifecycle UI feedback 2026-03-29 13:35:18 -07:00
Joe Julian 1c0cd20bcc Merge commit '4fe912b' into merge-main-13-seg13-copy-reveal 2026-03-29 13:35:12 -07:00
Joe Julian cffe05af82 Add entry history browsing to UI 2026-03-29 13:35:12 -07:00
Joe Julian 8deccced9e Add keyboard-first accessibility behavior 2026-03-29 13:35:12 -07:00
Joe Julian 4fe912b41f Merge branch 'main' into merge-main-20-seg20-regression 2026-03-29 13:34:26 -07:00
Joe Julian 55ca6352b4 Add secret-safe copy and reveal UX coverage 2026-03-29 13:34:25 -07:00
Joe Julian 0cfccb58d6 Remove dead deleted-object helper 2026-03-29 13:34:12 -07:00
Joe Julian b043ecdc83 Add regression coverage for KDBX reopen cycles 2026-03-29 13:34:12 -07:00
Joe Julian d3be07f252 Add Segment 6 entry editor coverage 2026-03-29 13:34:12 -07:00
Joe Julian 0f30c5b1c5 Add gRPC vault lifecycle backend flow 2026-03-29 13:34:12 -07:00
Joe Julian 985150c36f Add gRPC group deletion and custom entry fields 2026-03-29 13:33:52 -07:00
Joe Julian f39bdcd3be Complete search section behavior 2026-03-29 13:33:52 -07:00
Joe Julian 48ffa78fa2 Add keyboard-first accessibility behavior 2026-03-29 13:33:52 -07:00
Joe Julian 1349fe6e38 Remove dead deleted-object helper 2026-03-29 13:33:30 -07:00
Joe Julian 19ccab1d8d Add regression coverage for KDBX reopen cycles 2026-03-29 13:33:30 -07:00
Joe Julian ae1921506d Add Segment 6 entry editor coverage 2026-03-29 13:33:30 -07:00
Joe Julian 1f51904567 Add gRPC vault lifecycle backend flow 2026-03-29 13:33:30 -07:00
Joe Julian ef867adfa1 Merge branch 'seg09-search' into merge-main-09-seg09-search 2026-03-29 13:33:07 -07:00
Joe Julian 5eb2068d3e Add keyboard-first accessibility behavior 2026-03-29 13:33:07 -07:00
Joe Julian 77407e0510 Complete search section behavior 2026-03-29 13:33:07 -07:00
Joe Julian dd0b0b6f6e Complete UI state and error surfaces 2026-03-29 13:32:28 -07:00
Joe Julian 566256ab79 Add UI master key setup and change flows 2026-03-29 11:23:54 -07:00
Joe Julian a4b3d5c1e1 Break TODO into parallel work segments 2026-03-29 11:10:12 -07:00
Joe Julian ce01c47256 Reconstruct KeePassGO repository 2026-03-29 11:04:38 -07:00
37 changed files with 1434 additions and 5155 deletions
-116
View File
@@ -1,116 +0,0 @@
---
name: keepassgo-apk-test
description: Build and run the KeePassGO Android APK for emulator testing. Use when work requires `make apk`, APK install/launch, emulator validation, or checking known KeePassGO Android regressions such as black screens, clipboard, open flow, or local sync behavior.
---
# KeePassGO APK Test
Use this skill together with the installed `android-emulator-debug` skill. That skill covers generic emulator and `adb` mechanics. This skill adds the KeePassGO-specific build, install, and validation requirements that have already been established in this repo.
## Use This Skill When
- Building `build/keepassgo.apk`.
- Installing or launching KeePassGO in the Android emulator.
- Verifying Android regressions such as black screen, clipboard, open flow, file picker, or Advanced Sync behavior.
- Checking whether a Gio or Android toolchain change broke runtime behavior.
## Known Working Environment
- Preferred emulator AVD: `KeepassGoAPI35`
- Package: `org.julianfamily.keepassgo`
- Activity: `org.gioui.GioActivity`
- Local APK build defaults:
`ANDROID_SDK_ROOT=/opt/android-sdk`
`ANDROID_NDK_ROOT=/opt/android-ndk`
`JAVA_HOME=/usr/lib/jvm/java-25-openjdk`
- CI APK build uses:
`ANDROID_SDK_ROOT=/opt/android-sdk`
`ANDROID_NDK_ROOT=/opt/android-sdk/ndk`
`JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64`
## Known Regression
- `gioui.org v0.9.0` caused a black screen in the `KeepassGoAPI35` emulator.
- `gioui.org v0.8.0` rendered correctly in the same environment.
- If Android rendering regresses after a Gio change, treat Gio as a primary suspect and verify against this known-bad versus known-good history before guessing about app logic.
## Safety Rules
- Do not clobber the users real KeePassGO state while testing.
- For host-side validation, use isolated state such as:
`KEEPASSGO_STATE_DIR="$(mktemp -d)" go test ./...`
- Prefer sanitized demo or test vaults when seeding emulator tests.
- If a real vault is absolutely required to reproduce a problem, do not modify it and do not overwrite the users existing recent-vault state.
## Build Workflow
1. Verify the JDK/SDK paths match the known working environment.
2. Build with `make apk`.
3. If `make apk` fails, inspect the effective `JAVA_HOME`, `ANDROID_SDK_ROOT`, and `ANDROID_NDK_ROOT` before changing code.
4. If the problem is Android-only, avoid desktop-only conclusions from `go test ./...`.
Typical local build:
```sh
JAVA_HOME=/usr/lib/jvm/java-25-openjdk make apk
```
## Emulator Workflow
1. Reuse an existing emulator session if one is already running.
2. Prefer the `KeepassGoAPI35` AVD.
3. Install with `adb install -r build/keepassgo.apk`.
4. Launch with:
```sh
adb shell monkey -p org.julianfamily.keepassgo -c android.intent.category.LAUNCHER 1
```
5. Confirm focus before drawing conclusions:
```sh
adb shell dumpsys window | rg 'mCurrentFocus|mFocusedApp'
```
6. Capture evidence before editing code:
screenshot
focused window
relevant `logcat`
## Validation Checklist
- APK builds successfully with `make apk`.
- App launches to `org.julianfamily.keepassgo/org.gioui.GioActivity`.
- Screenshot shows the expected screen, not just a black frame.
- `logcat` shows no app crash or Android runtime fatal error.
- If the change is about user interaction, perform the tap-through in the emulator instead of stopping at install success.
## Issue-Specific Checks
### Black Screen
- Confirm the app is focused.
- Capture a screenshot and `logcat` before changing anything.
- Compare the current Gio version against the known `v0.9.0` regression history.
- If needed, test whether a minimal Gio example renders in the same emulator before blaming KeePassGO layout code.
### Clipboard
- Verify behavior in the emulator, not just unit tests.
- Android clipboard writes must use the Gio-native command path, not desktop clipboard backends.
### File Picker Or Local Sync
- Verify the picker flow on Android itself.
- Prefer document-stream or content-URI based behavior over raw filesystem paths when Android permissions are involved.
## Report Back
When closing out work, state:
- the exact build command used
- whether the emulator session was reused or newly started
- whether install succeeded
- whether the app actually rendered
- what was verified manually in the emulator
- what remains unverified
-179
View File
@@ -1,179 +0,0 @@
name: ci
on:
push:
branches:
- main
tags:
- "v*"
- "release-*"
- "[0-9]+.[0-9]+.[0-9]+*"
permissions:
contents: write
env:
GO_VERSION: "1.26.1"
ANDROID_SDK_ROOT: /opt/android-sdk
ANDROID_NDK_ROOT: /opt/android-sdk/ndk
JAVA_HOME: /usr/lib/jvm/java-21-openjdk-amd64
DIST_DIR: dist
jobs:
lint-test:
runs-on: keepassgo-android
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Install native build dependencies
shell: bash
run: |
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y --no-install-recommends \
zsh \
pkg-config \
libx11-dev \
libx11-xcb-dev \
libxkbcommon-dev \
libxkbcommon-x11-dev \
libwayland-dev \
libvulkan-dev \
libegl1-mesa-dev \
libxcursor-dev \
libxfixes-dev
- name: Lint
shell: bash
run: |
set -euo pipefail
state_dir="$(mktemp -d)"
trap 'rm -rf -- "$state_dir"' EXIT
KEEPASSGO_STATE_DIR="$state_dir" go tool golangci-lint run --build-tags nox11,nowayland,novulkan ./...
- name: Test
shell: bash
run: |
set -euo pipefail
state_dir="$(mktemp -d)"
trap 'rm -rf -- "$state_dir"' EXIT
KEEPASSGO_STATE_DIR="$state_dir" go test -tags nox11,nowayland,novulkan ./...
build:
needs: lint-test
runs-on: keepassgo-android
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Install native build dependencies
shell: bash
run: |
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y --no-install-recommends \
zsh \
pkg-config \
libx11-dev \
libx11-xcb-dev \
libxkbcommon-dev \
libxkbcommon-x11-dev \
libwayland-dev \
libvulkan-dev \
libegl1-mesa-dev \
libxcursor-dev \
libxfixes-dev
- name: Prepare dist directory
shell: bash
run: |
set -euo pipefail
mkdir -p "${DIST_DIR}"
- name: Build desktop binaries
shell: bash
run: |
set -euo pipefail
app_version="$(git describe --tags --always --dirty)"
# Gio needs a Linux ARM64 cgo cross-toolchain for desktop builds.
# Keep the CI matrix to targets this runner can build reproducibly.
for target in \
"linux amd64" \
"windows amd64" \
"windows arm64"
do
set -- ${target}
goos="$1"
goarch="$2"
ext=""
cgo_enabled=0
if [[ "${goos}" == "linux" ]]; then
cgo_enabled=1
fi
if [[ "${goos}" == "windows" ]]; then
ext=".exe"
fi
out="${DIST_DIR}/keepassgo-${goos}-${goarch}${ext}"
GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED="${cgo_enabled}" \
go build -ldflags "-X main.appVersion=${app_version}" -o "${out}" .
done
- name: Build APK
shell: bash
run: |
set -euo pipefail
signkey_path="$(mktemp)"
trap 'rm -f -- "$signkey_path"' EXIT
printf '%s' '${{ secrets.APK_SIGNKEY_B64 }}' | base64 -d > "$signkey_path"
export APP_VERSION="$(git describe --tags --always --dirty)"
make apk SIGNKEY="$signkey_path" SIGNPASS='${{ secrets.APK_SIGNPASS }}'
cp build/keepassgo.apk "${DIST_DIR}/keepassgo.apk"
- name: Upload CI artifacts
uses: christopherhx/gitea-upload-artifact@v4
env:
NODE_OPTIONS: --use-system-ca
SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
with:
name: keepassgo-${{ gitea.sha }}
path: |
dist/keepassgo-linux-amd64
dist/keepassgo-windows-amd64.exe
dist/keepassgo-windows-arm64.exe
dist/keepassgo.apk
retention-days: 30
- name: Publish release artifacts
if: startsWith(gitea.ref, 'refs/tags/')
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_REF_NAME: ${{ gitea.ref_name }}
SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
REQUESTS_CA_BUNDLE: /etc/ssl/certs/ca-certificates.crt
shell: bash
run: |
set -euo pipefail
python3 scripts/gitea_release.py \
--server "${GITEA_SERVER_URL}" \
--repo "${GITEA_REPOSITORY}" \
--token "${GITEA_TOKEN}" \
--tag "${GITEA_REF_NAME}" \
"${DIST_DIR}/keepassgo-linux-amd64" \
"${DIST_DIR}/keepassgo-windows-amd64.exe" \
"${DIST_DIR}/keepassgo-windows-arm64.exe" \
"${DIST_DIR}/keepassgo.apk"
-16
View File
@@ -14,8 +14,6 @@ These instructions apply to all future work in this repository.
## Skills
- Use the installed Go skills whenever they materially apply to the current slice of work.
- Use the installed `android-emulator-debug` skill for Android emulator lifecycle, `adb`, screenshots, and log capture work.
- Use the repo-local `keepassgo-apk-test` skill for KeePassGO-specific APK build and emulator test runs.
- The available Go skills for this repository are:
`go-code-review`,
`go-concurrency`,
@@ -128,20 +126,6 @@ These features are product requirements, not “nice to have” ideas.
- Keep `golangci-lint` passing.
- Keep `go test ./...` passing.
- Track `gogio` as a Go tool and keep a reproducible `make apk` path for Android packaging.
- Keep the Android build requirements aligned with the known working setup:
`ANDROID_SDK_ROOT=/opt/android-sdk`,
local `ANDROID_NDK_ROOT=/opt/android-ndk`,
CI `ANDROID_NDK_ROOT=/opt/android-sdk/ndk`,
local `JAVA_HOME=/usr/lib/jvm/java-25-openjdk`,
CI `JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64`.
- Remember the known Android runtime regression:
`gioui.org v0.9.0` produced a black screen on the `KeepassGoAPI35` emulator, while `gioui.org v0.8.0` rendered correctly. Treat Gio upgrades on Android as regression-sensitive and verify them on-device or in the emulator.
- When validating an APK in the emulator, prefer the known KeePassGO setup:
AVD `KeepassGoAPI35`,
package `org.julianfamily.keepassgo`,
activity `org.gioui.GioActivity`.
- Do not run emulator/manual APK tests against the users real persisted app state.
Use an isolated `KEEPASSGO_STATE_DIR` for host-side validation, and when emulator testing requires seeded vault data, use sanitized test/demo vaults rather than the users real vault files whenever possible.
- When running tests or other automated validation that may touch persisted UI state, set `KEEPASSGO_STATE_DIR` to an isolated temporary directory so recent-vault history and other local state do not pollute the users real config.
- Prefer commands shaped like `KEEPASSGO_STATE_DIR=\"$(mktemp -d)\" go test ./...` for ad hoc local validation unless a test already manages its own isolated state directory.
- Do not assume the agent can decrypt SOPS-encrypted secrets in this repository.
-1
View File
@@ -12,7 +12,6 @@ Environment:
- `ANDROID_NDK_ROOT` defaults to `/opt/android-ndk`.
- `JAVA_HOME` defaults to `/usr/lib/jvm/java-25-openjdk`.
- `APP_ID` overrides the Android application id.
- `APP_VERSION` overrides the version shown inside KeePassGO itself.
- `APK_OUT` overrides the output path.
- `APK_VERSION` overrides the packaged app version.
- `ANDROID_MIN_SDK` overrides the minimum supported Android SDK.
-14
View File
@@ -5,20 +5,8 @@ PATH := $(JAVA_HOME)/bin:$(ANDROID_SDK_ROOT)/cmdline-tools/latest/bin:$(ANDROID_
APP_ID ?= org.julianfamily.keepassgo
APK_OUT ?= build/keepassgo.apk
APK_VERSION ?= 0.1.0.1
APP_VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
GO_LDFLAGS ?= -X main.appVersion=$(APP_VERSION)
ANDROID_MIN_SDK ?= 28
ANDROID_TARGET_SDK ?= 35
SIGNKEY ?=
SIGNPASS ?=
GOGIO_SIGN_FLAGS :=
ifneq ($(strip $(SIGNKEY)),)
GOGIO_SIGN_FLAGS += -signkey $(SIGNKEY)
endif
ifneq ($(strip $(SIGNPASS)),)
GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS)
endif
.PHONY: apk
apk: android/keepassgo-android.jar
@@ -36,8 +24,6 @@ apk: android/keepassgo-android.jar
go tool gogio -target android \
-buildmode exe \
-appid $(APP_ID) \
-ldflags "$(GO_LDFLAGS)" \
$(GOGIO_SIGN_FLAGS) \
-o $(APK_OUT) \
-version $(APK_VERSION) \
-minsdk $(ANDROID_MIN_SDK) \
-25
View File
@@ -41,31 +41,6 @@ Desktop build:
go build ./...
```
By default, build outputs stamp the app version from `git describe --tags --always --dirty`.
You can override the version shown in KeePassGO with:
```bash
go build -ldflags "-X main.appVersion=v0.0.1" ./...
```
## Arch Linux Package
An AUR-style package definition for the Linux desktop client lives under:
- `packaging/archlinux/keepassgo-git/`
From that directory you can build and install it with:
```bash
makepkg -si
```
The package installs:
- `/usr/bin/keepassgo`
- a desktop entry at `/usr/share/applications/keepassgo.desktop`
- application icons under the hicolor theme
## Android Packaging
KeePassGO uses Gio, so Android packaging is done with `gogio`.
+1 -1
View File
@@ -20,7 +20,7 @@ func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) {
if err := lifecycle.Create(vault.Model{
Entries: []vault.Entry{
testAPITokenEntry(t),
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
{ID: "entry-1", Title: "Git Server", Path: []string{"Root", "Internet"}},
},
}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
t.Fatalf("Create() error = %v", err)
+69 -69
View File
@@ -50,11 +50,11 @@ func TestVaultServiceRejectsExpiredAPITokens(t *testing.T) {
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
token.Entry([]string{"Root", "API Tokens"}),
@@ -78,22 +78,22 @@ func TestVaultServiceRejectsUnauthorizedEntryAccess(t *testing.T) {
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}}},
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", 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"}}},
),
},
})
defer cleanup()
_, err := client.CopyEntryField(tokenContext(defaultTestTokenSecret), &keepassgov1.CopyEntryFieldRequest{Id: "vault-console", Target: "password"})
_, err := client.CopyEntryField(tokenContext(defaultTestTokenSecret), &keepassgov1.CopyEntryFieldRequest{Id: "git-server", Target: "password"})
if status.Code(err) != codes.PermissionDenied {
t.Fatalf("CopyEntryField() code = %v, want %v", status.Code(err), codes.PermissionDenied)
}
@@ -105,11 +105,11 @@ func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) {
model := vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
testAPITokenEntry(t),
@@ -139,8 +139,8 @@ func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) {
if err := <-errCh; err != nil {
t.Fatalf("ListEntries() error = %v", err)
}
if len(resp.Entries) != 1 || resp.Entries[0].Id != "vault-console" {
t.Fatalf("ListEntries().Entries = %#v, want vault-console", resp.Entries)
if len(resp.Entries) != 1 || resp.Entries[0].Id != "git-server" {
t.Fatalf("ListEntries().Entries = %#v, want git-server", resp.Entries)
}
}
@@ -150,11 +150,11 @@ func TestVaultServicePersistsPermanentDenyApproval(t *testing.T) {
model := vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
testAPITokenEntry(t),
@@ -196,7 +196,7 @@ func TestVaultServiceReturnsCanceledForCanceledApproval(t *testing.T) {
model := vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
testAPITokenEntry(t),
},
}
@@ -225,7 +225,7 @@ func TestVaultServiceTimesOutPendingApproval(t *testing.T) {
model := vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
testAPITokenEntry(t),
},
}
@@ -244,7 +244,7 @@ func TestVaultServiceRecordsApprovalAuditEvents(t *testing.T) {
model := vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
testAPITokenEntry(t),
},
}
@@ -282,11 +282,11 @@ func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) {
model: vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
@@ -454,7 +454,7 @@ func TestVaultServiceOpensAndSavesVaultThroughLifecycleBackend(t *testing.T) {
if _, err := client.OpenRemoteVault(ctx, &keepassgov1.OpenRemoteVaultRequest{
BaseUrl: "https://dav.example.com",
Path: "vaults/main.kdbx",
Username: "rustyryan",
Username: "jjulian",
Password: "dav-token",
MasterPassword: "correct horse battery staple",
}); err != nil {
@@ -618,8 +618,8 @@ func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) {
t.Fatalf("len(ListEntries().Entries) = %d, want 1", len(resp.Entries))
}
if resp.Entries[0].Title != "Vault Console" {
t.Fatalf("ListEntries().Entries[0].Title = %q, want %q", resp.Entries[0].Title, "Vault Console")
if resp.Entries[0].Title != "Git Server" {
t.Fatalf("ListEntries().Entries[0].Title = %q, want %q", resp.Entries[0].Title, "Git Server")
}
if got := resp.Entries[0].Fields["X-Role"]; got != "automation" {
t.Fatalf("ListEntries().Entries[0].Fields[X-Role] = %q, want %q", got, "automation")
@@ -747,7 +747,7 @@ func TestVaultServiceCopiesEntryFieldsForAuthorizedClients(t *testing.T) {
ctx := tokenContext(defaultTestTokenSecret)
if _, err := client.CopyEntryField(ctx, &keepassgov1.CopyEntryFieldRequest{
Id: "vault-console",
Id: "git-server",
Target: "password",
}); err != nil {
t.Fatalf("CopyEntryField() error = %v", err)
@@ -767,11 +767,11 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
ctx := tokenContext(defaultTestTokenSecret)
upserted, err := client.UpsertEntry(ctx, &keepassgov1.UpsertEntryRequest{
Entry: &keepassgov1.Entry{
Id: "surveillance-console",
Title: "Surveillance Console",
Id: "ha-codex",
Title: "Home Assistant (Codex)",
Username: "codex",
Password: "token-2",
Url: "https://surveillance.crew.example.invalid",
Url: "https://lights.julianfamily.org",
Fields: map[string]string{
"X-Role": "lights-admin",
},
@@ -782,8 +782,8 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
t.Fatalf("UpsertEntry() error = %v", err)
}
if upserted.Entry.Title != "Surveillance Console" {
t.Fatalf("UpsertEntry().Entry.Title = %q, want %q", upserted.Entry.Title, "Surveillance Console")
if upserted.Entry.Title != "Home Assistant (Codex)" {
t.Fatalf("UpsertEntry().Entry.Title = %q, want %q", upserted.Entry.Title, "Home Assistant (Codex)")
}
if got := upserted.Entry.Fields["X-Role"]; got != "lights-admin" {
t.Fatalf("UpsertEntry().Entry.Fields[X-Role] = %q, want %q", got, "lights-admin")
@@ -809,7 +809,7 @@ func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T)
defer cleanup()
ctx := tokenContext(defaultTestTokenSecret)
if _, err := client.DeleteEntry(ctx, &keepassgov1.DeleteEntryRequest{Id: "vault-console"}); err != nil {
if _, err := client.DeleteEntry(ctx, &keepassgov1.DeleteEntryRequest{Id: "git-server"}); err != nil {
t.Fatalf("DeleteEntry() error = %v", err)
}
@@ -822,13 +822,13 @@ func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T)
t.Fatalf("len(ListEntries().Entries) = %d, want 0 after delete", len(listed.Entries))
}
restored, err := client.RestoreEntry(ctx, &keepassgov1.RestoreEntryRequest{Id: "vault-console"})
restored, err := client.RestoreEntry(ctx, &keepassgov1.RestoreEntryRequest{Id: "git-server"})
if err != nil {
t.Fatalf("RestoreEntry() error = %v", err)
}
if restored.Entry.Title != "Vault Console" {
t.Fatalf("RestoreEntry().Entry.Title = %q, want %q", restored.Entry.Title, "Vault Console")
if restored.Entry.Title != "Git Server" {
t.Fatalf("RestoreEntry().Entry.Title = %q, want %q", restored.Entry.Title, "Git Server")
}
listed, err = client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
@@ -836,8 +836,8 @@ func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T)
t.Fatalf("ListEntries() error = %v", err)
}
if len(listed.Entries) != 1 || listed.Entries[0].Title != "Vault Console" {
t.Fatalf("ListEntries().Entries = %#v, want restored Vault Console entry", listed.Entries)
if len(listed.Entries) != 1 || listed.Entries[0].Title != "Git Server" {
t.Fatalf("ListEntries().Entries = %#v, want restored Git Server entry", listed.Entries)
}
}
@@ -863,11 +863,11 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
instantiated, err := client.InstantiateTemplate(ctx, &keepassgov1.InstantiateTemplateRequest{
TemplateId: "website-login",
Overrides: &keepassgov1.Entry{
Id: "bellagio",
Title: "Bellagio",
Username: "rustyryan",
Id: "dynadot",
Title: "Dynadot",
Username: "jjulian",
Password: "hunter2",
Url: "https://bellagio.example.invalid",
Url: "https://www.dynadot.com",
Fields: map[string]string{
"Environment": "staging",
},
@@ -879,8 +879,8 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
t.Fatalf("InstantiateTemplate() error = %v", err)
}
if instantiated.Entry.Title != "Bellagio" || instantiated.Entry.Notes != "Reusable template for website accounts." {
t.Fatalf("InstantiateTemplate().Entry = %#v, want Bellagio entry with template notes", instantiated.Entry)
if instantiated.Entry.Title != "Dynadot" || instantiated.Entry.Notes != "Reusable template for website accounts." {
t.Fatalf("InstantiateTemplate().Entry = %#v, want Dynadot entry with template notes", instantiated.Entry)
}
if got := instantiated.Entry.Fields["Environment"]; got != "staging" {
t.Fatalf("InstantiateTemplate().Entry.Fields[Environment] = %q, want %q", got, "staging")
@@ -956,7 +956,7 @@ func TestVaultServiceListsAndRestoresEntryHistoryForAuthorizedClients(t *testing
defer cleanup()
ctx := tokenContext(defaultTestTokenSecret)
history, err := client.ListEntryHistory(ctx, &keepassgov1.ListEntryHistoryRequest{Id: "vault-console"})
history, err := client.ListEntryHistory(ctx, &keepassgov1.ListEntryHistoryRequest{Id: "git-server"})
if err != nil {
t.Fatalf("ListEntryHistory() error = %v", err)
}
@@ -965,7 +965,7 @@ func TestVaultServiceListsAndRestoresEntryHistoryForAuthorizedClients(t *testing
}
restored, err := client.RestoreEntryHistory(ctx, &keepassgov1.RestoreEntryHistoryRequest{
Id: "vault-console",
Id: "git-server",
HistoryIndex: 0,
})
if err != nil {
@@ -986,14 +986,14 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
uploaded := []byte("attachment-content")
if _, err := client.UploadAttachment(ctx, &keepassgov1.UploadAttachmentRequest{
EntryId: "vault-console",
EntryId: "git-server",
Name: "token.txt",
Content: uploaded,
}); err != nil {
t.Fatalf("UploadAttachment() error = %v", err)
}
listed, err := client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "vault-console"})
listed, err := client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "git-server"})
if err != nil {
t.Fatalf("ListAttachments() error = %v", err)
}
@@ -1002,7 +1002,7 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
}
downloaded, err := client.DownloadAttachment(ctx, &keepassgov1.DownloadAttachmentRequest{
EntryId: "vault-console",
EntryId: "git-server",
Name: "token.txt",
})
if err != nil {
@@ -1013,13 +1013,13 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) {
}
if _, err := client.DeleteAttachment(ctx, &keepassgov1.DeleteAttachmentRequest{
EntryId: "vault-console",
EntryId: "git-server",
Name: "token.txt",
}); err != nil {
t.Fatalf("DeleteAttachment() error = %v", err)
}
listed, err = client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "vault-console"})
listed, err = client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "git-server"})
if err != nil {
t.Fatalf("ListAttachments() error = %v", err)
}
@@ -1055,32 +1055,32 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa
model := vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Fields: map[string]string{
"X-Role": "automation",
},
History: []vault.Entry{
{
ID: "vault-console-h1",
Title: "Vault Console",
Username: "dannyocean",
ID: "git-server-h1",
Title: "Git Server",
Username: "joejulian",
Password: "token-0",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
Path: []string{"Root", "Internet"},
},
{
ID: "surveillance-console",
Title: "Surveillance Console",
ID: "ha-codex",
Title: "Home Assistant (Codex)",
Username: "codex",
Password: "token-2",
URL: "https://surveillance.crew.example.invalid",
URL: "https://lights.julianfamily.org",
Path: []string{"Root", "Home Assistant"},
},
testAPITokenEntry(t,
@@ -1090,9 +1090,9 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateGroup, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationReadEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "git-server", Path: []string{"Root", "Internet"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "git-server", Path: []string{"Root", "Internet"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "git-server", Path: []string{"Root", "Internet"}}},
),
},
Templates: []vault.Entry{
+2 -16
View File
@@ -26,7 +26,6 @@ const (
SectionRecycleBin Section = "recycle-bin"
SectionAPITokens Section = "api-tokens"
SectionAPIAudit Section = "api-audit"
SectionAbout Section = "about"
)
type CurrentSession interface {
@@ -62,7 +61,6 @@ type SynchronizableSession interface {
type AdvancedSynchronizableSession interface {
CurrentSession
SynchronizeFromLocal(string) error
SynchronizeFromLocalBytes(string, []byte) error
SynchronizeToLocal(string) error
SynchronizeFromRemote(webdav.Client, string) error
SynchronizeToRemote(webdav.Client, string) error
@@ -304,7 +302,7 @@ func (s *State) VisibleEntries() ([]vault.Entry, error) {
}
if s.Section == SectionEntries {
return entriesInPath(model.Entries, s.CurrentPath), nil
return model.EntriesUnderPath(s.CurrentPath), nil
}
if s.Section == SectionRecycleBin || len(s.CurrentPath) == 0 {
return entries, nil
@@ -377,7 +375,7 @@ func (s *State) entriesForSection(model vault.Model) []vault.Entry {
return slices.Clone(model.Templates)
case SectionRecycleBin:
return slices.Clone(model.RecycleBin)
case SectionAPITokens, SectionAPIAudit, SectionAbout:
case SectionAPITokens, SectionAPIAudit:
return nil
default:
return slices.Clone(model.Entries)
@@ -724,18 +722,6 @@ func (s *State) SynchronizeFromLocal(path string) error {
return nil
}
func (s *State) SynchronizeFromLocalBytes(name string, content []byte) error {
session, ok := s.Session.(AdvancedSynchronizableSession)
if !ok {
return fmt.Errorf("session is not advanced-synchronizable")
}
if err := session.SynchronizeFromLocalBytes(name, content); err != nil {
return err
}
s.Dirty = false
return nil
}
func (s *State) SynchronizeToLocal(path string) error {
session, ok := s.Session.(AdvancedSynchronizableSession)
if !ok {
+101 -122
View File
@@ -20,13 +20,13 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Joe", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Joe", "Home Assistant"}},
},
},
},
CurrentPath: []string{"Crew", "Internet"},
CurrentPath: []string{"Joe", "Internet"},
}
got, err := state.VisibleEntries()
@@ -39,26 +39,25 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
titles = append(titles, entry.Title)
}
if !slices.Equal(titles, []string{"Bellagio", "Vault Console"}) {
t.Fatalf("visible titles = %v, want [Bellagio Vault Console]", titles)
if !slices.Equal(titles, []string{"Dynadot", "Git Server"}) {
t.Fatalf("visible titles = %v, want [Dynadot Git Server]", titles)
}
}
func TestVisibleEntriesAtParentGroupOnlyShowsDirectEntries(t *testing.T) {
func TestVisibleEntriesIncludesDescendantEntriesAtParentGroup(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "joe-note", Title: "Crew Note", Path: []string{"Crew"}},
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Joe", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Joe", "Home Assistant"}},
},
},
},
CurrentPath: []string{"Crew"},
CurrentPath: []string{"Joe"},
}
got, err := state.VisibleEntries()
@@ -70,8 +69,8 @@ func TestVisibleEntriesAtParentGroupOnlyShowsDirectEntries(t *testing.T) {
for _, entry := range got {
titles = append(titles, entry.Title)
}
if !slices.Equal(titles, []string{"Crew Note"}) {
t.Fatalf("visible titles = %v, want only direct entries from Crew", titles)
if !slices.Equal(titles, []string{"Dynadot", "Git Server", "Home Assistant (Codex)"}) {
t.Fatalf("visible titles = %v, want descendant entries from Joe", titles)
}
}
@@ -171,14 +170,14 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
{ID: "git-server", Title: "Git Server", URL: "https://git.julianfamily.org", Path: []string{"Joe", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", URL: "https://lights.julianfamily.org", Path: []string{"Joe", "Home Assistant"}},
},
},
},
CurrentPath: []string{"Crew", "Internet"},
SearchQuery: "surveillance",
CurrentPath: []string{"Joe", "Internet"},
SearchQuery: "lights",
}
got, err := state.VisibleEntries()
@@ -186,7 +185,7 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].Title != "Surveillance Console" {
if len(got) != 1 || got[0].Title != "Home Assistant (Codex)" {
t.Fatalf("VisibleEntries() = %#v, want Home Assistant search match", got)
}
}
@@ -198,13 +197,13 @@ func TestVisibleEntriesReturnsDescendantsAfterClearingSearch(t *testing.T) {
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
{ID: "git-server", Title: "Git Server", URL: "https://git.julianfamily.org", Path: []string{"Joe", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", URL: "https://lights.julianfamily.org", Path: []string{"Joe", "Home Assistant"}},
},
},
},
CurrentPath: []string{"Crew"},
CurrentPath: []string{"Joe"},
SearchQuery: "missing",
}
@@ -221,8 +220,8 @@ func TestVisibleEntriesReturnsDescendantsAfterClearingSearch(t *testing.T) {
if err != nil {
t.Fatalf("VisibleEntries() after clearing search error = %v", err)
}
if len(got) != 0 {
t.Fatalf("len(VisibleEntries()) after clearing search = %d, want 0 direct entries at Crew", len(got))
if len(got) != 3 {
t.Fatalf("len(VisibleEntries()) after clearing search = %d, want 3 descendant entries", len(got))
}
}
@@ -349,7 +348,7 @@ func TestVisibleEntriesUsesGlobalSearchWithinRecycleBin(t *testing.T) {
Session: stubSession{
model: vault.Model{
RecycleBin: []vault.Entry{
{ID: "deleted-1", Title: "Deleted Bellagio", Path: []string{"Root", "Internet"}},
{ID: "deleted-1", Title: "Deleted Dynadot", Path: []string{"Root", "Internet"}},
{ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}},
},
},
@@ -418,13 +417,13 @@ func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Joe", "Home Assistant"}},
{ID: "alma", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}},
},
},
},
CurrentPath: []string{"Crew"},
CurrentPath: []string{"Joe"},
}
got, err := state.ChildGroups()
@@ -470,19 +469,19 @@ func TestSelectVisibleEntryAndToggleSelection(t *testing.T) {
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
{ID: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Joe", "Internet"}},
},
},
},
CurrentPath: []string{"Crew", "Internet"},
CurrentPath: []string{"Joe", "Internet"},
}
if err := state.SelectVisibleIndex(1); err != nil {
t.Fatalf("SelectVisibleIndex() error = %v", err)
}
if got := state.SelectedEntryID; got != "vault-console" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "vault-console")
if got := state.SelectedEntryID; got != "git-server" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "git-server")
}
if err := state.ToggleVisibleIndex(1); err != nil {
@@ -524,14 +523,14 @@ func TestDeleteSelectedEntryUpdatesSessionAndClearsSelection(t *testing.T) {
sess := &mutableStubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
},
},
}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console",
SelectedEntryID: "git-server",
}
if err := state.DeleteSelectedEntry(); err != nil {
@@ -546,8 +545,8 @@ func TestDeleteSelectedEntryUpdatesSessionAndClearsSelection(t *testing.T) {
t.Fatalf("len(Entries) = %d, want 0", len(sess.model.Entries))
}
if len(sess.model.RecycleBin) != 1 || sess.model.RecycleBin[0].ID != "vault-console" {
t.Fatalf("RecycleBin = %#v, want vault-console entry", sess.model.RecycleBin)
if len(sess.model.RecycleBin) != 1 || sess.model.RecycleBin[0].ID != "git-server" {
t.Fatalf("RecycleBin = %#v, want git-server entry", sess.model.RecycleBin)
}
if !state.Dirty {
@@ -561,7 +560,7 @@ func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) {
sess := &mutableStubSession{
model: vault.Model{
RecycleBin: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
},
},
}
@@ -570,7 +569,7 @@ func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) {
CurrentPath: []string{"Root", "Internet"},
}
if err := state.RestoreEntry("vault-console"); err != nil {
if err := state.RestoreEntry("git-server"); err != nil {
t.Fatalf("RestoreEntry() error = %v", err)
}
@@ -579,8 +578,8 @@ func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].ID != "vault-console" {
t.Fatalf("VisibleEntries() = %#v, want restored vault-console entry", got)
if len(got) != 1 || got[0].ID != "git-server" {
t.Fatalf("VisibleEntries() = %#v, want restored git-server entry", got)
}
if !state.Dirty {
@@ -600,19 +599,19 @@ func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) {
}
entry := vault.Entry{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
}
if err := state.UpsertEntry(entry); err != nil {
t.Fatalf("UpsertEntry() error = %v", err)
}
if got := state.SelectedEntryID; got != "vault-console" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "vault-console")
if got := state.SelectedEntryID; got != "git-server" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "git-server")
}
got, err := state.VisibleEntries()
@@ -620,7 +619,7 @@ func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].Password != "token-1" {
t.Fatalf("VisibleEntries() = %#v, want persisted vault-console entry", got)
t.Fatalf("VisibleEntries() = %#v, want persisted git-server entry", got)
}
if !state.Dirty {
@@ -652,11 +651,11 @@ func TestInstantiateTemplateCreatesEntryAndSelectsIt(t *testing.T) {
}
entry, err := state.InstantiateTemplate("website-login", vault.Entry{
ID: "bellagio",
Title: "Bellagio",
Username: "rustyryan",
ID: "dynadot",
Title: "Dynadot",
Username: "jjulian",
Password: "hunter2",
URL: "https://bellagio.example.invalid",
URL: "https://www.dynadot.com",
Path: []string{"Root", "Internet"},
})
if err != nil {
@@ -667,16 +666,16 @@ func TestInstantiateTemplateCreatesEntryAndSelectsIt(t *testing.T) {
t.Fatalf("entry.Notes = %q, want template notes", entry.Notes)
}
if got := state.SelectedEntryID; got != "bellagio" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "bellagio")
if got := state.SelectedEntryID; got != "dynadot" {
t.Fatalf("SelectedEntryID = %q, want %q", got, "dynadot")
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].ID != "bellagio" {
t.Fatalf("VisibleEntries() = %#v, want instantiated bellagio entry", got)
if len(got) != 1 || got[0].ID != "dynadot" {
t.Fatalf("VisibleEntries() = %#v, want instantiated dynadot entry", got)
}
if !state.Dirty {
@@ -743,24 +742,24 @@ func TestDuplicateSelectedEntryCreatesCopyAndSelectsIt(t *testing.T) {
sess := &mutableStubSession{model: vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
},
}}
state := State{
Session: sess,
SelectedEntryID: "vault-console",
SelectedEntryID: "git-server",
}
duplicate, err := state.DuplicateSelectedEntry("vault-console-copy")
duplicate, err := state.DuplicateSelectedEntry("git-server-copy")
if err != nil {
t.Fatalf("DuplicateSelectedEntry() error = %v", err)
}
if duplicate.ID != "vault-console-copy" {
t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "vault-console-copy")
if duplicate.ID != "git-server-copy" {
t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "git-server-copy")
}
if state.SelectedEntryID != "vault-console-copy" {
t.Fatalf("SelectedEntryID = %q, want vault-console-copy", state.SelectedEntryID)
if state.SelectedEntryID != "git-server-copy" {
t.Fatalf("SelectedEntryID = %q, want git-server-copy", state.SelectedEntryID)
}
if len(sess.model.Entries) != 2 {
t.Fatalf("len(Entries) = %d, want 2 after duplicate", len(sess.model.Entries))
@@ -772,13 +771,13 @@ func TestMoveSelectedEntryMovesEntryToNewPathAndMarksDirty(t *testing.T) {
sess := &mutableStubSession{model: vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
},
}}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console",
SelectedEntryID: "git-server",
}
if err := state.MoveSelectedEntry([]string{"Root", "Infrastructure"}); err != nil {
@@ -791,8 +790,8 @@ func TestMoveSelectedEntryMovesEntryToNewPathAndMarksDirty(t *testing.T) {
}
newPath := sess.model.EntriesInPath([]string{"Root", "Infrastructure"})
if len(newPath) != 1 || newPath[0].ID != "vault-console" {
t.Fatalf("EntriesInPath(Root/Infrastructure) = %#v, want moved vault-console entry", newPath)
if len(newPath) != 1 || newPath[0].ID != "git-server" {
t.Fatalf("EntriesInPath(Root/Infrastructure) = %#v, want moved git-server entry", newPath)
}
if !state.Dirty {
@@ -806,19 +805,19 @@ func TestRestoreSelectedEntryVersionReplacesCurrentVersion(t *testing.T) {
sess := &mutableStubSession{model: vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
ID: "git-server",
Title: "Git Server",
Password: "new-token",
Path: []string{"Root", "Internet"},
History: []vault.Entry{
{ID: "vault-console-h1", Title: "Vault Console", Password: "old-token", Path: []string{"Root", "Internet"}},
{ID: "git-server-h1", Title: "Git Server", Password: "old-token", Path: []string{"Root", "Internet"}},
},
},
},
}}
state := State{
Session: sess,
SelectedEntryID: "vault-console",
SelectedEntryID: "git-server",
}
if err := state.RestoreSelectedEntryVersion(0); err != nil {
@@ -862,7 +861,7 @@ func TestCreateVaultResetsSelectionPathAndDirtyState(t *testing.T) {
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console",
SelectedEntryID: "git-server",
Dirty: true,
}
@@ -891,7 +890,7 @@ func TestOpenVaultResetsSelectionPathAndDirtyState(t *testing.T) {
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console",
SelectedEntryID: "git-server",
Dirty: true,
}
@@ -942,7 +941,7 @@ func TestOpenRemoteVaultResetsSelectionPathAndDirtyState(t *testing.T) {
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console",
SelectedEntryID: "git-server",
Dirty: true,
}
@@ -970,14 +969,14 @@ func TestLockClearsSelectionAndMakesVaultUnavailable(t *testing.T) {
sess := &lockableStubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
},
},
}
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console",
SelectedEntryID: "git-server",
}
if err := state.Lock(); err != nil {
@@ -1000,7 +999,7 @@ func TestUnlockRestoresVaultVisibility(t *testing.T) {
sess := &lockableStubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
},
},
locked: true,
@@ -1018,8 +1017,8 @@ func TestUnlockRestoresVaultVisibility(t *testing.T) {
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
if len(got) != 1 || got[0].ID != "vault-console" {
t.Fatalf("VisibleEntries() = %#v, want vault-console entry after unlock", got)
if len(got) != 1 || got[0].ID != "git-server" {
t.Fatalf("VisibleEntries() = %#v, want git-server entry after unlock", got)
}
}
@@ -1048,7 +1047,7 @@ func TestShowSectionResetsPathAndSelection(t *testing.T) {
state := State{
Section: SectionEntries,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console",
SelectedEntryID: "git-server",
SearchQuery: "git",
}
@@ -1084,7 +1083,7 @@ func TestBeginNewEntryClearsSelectionAndStatus(t *testing.T) {
t.Parallel()
state := State{
SelectedEntryID: "vault-console",
SelectedEntryID: "git-server",
ErrorMessage: "previous error",
}
@@ -1128,7 +1127,7 @@ func TestEnterGroupAppendsPathAndClearsSelection(t *testing.T) {
state := State{
CurrentPath: []string{"Root"},
SelectedEntryID: "vault-console",
SelectedEntryID: "git-server",
}
state.EnterGroup("Internet")
@@ -1146,7 +1145,7 @@ func TestNavigateToPathReplacesPathAndClearsSelection(t *testing.T) {
state := State{
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "vault-console",
SelectedEntryID: "git-server",
}
state.NavigateToPath([]string{"Root", "Home Assistant"})
@@ -1297,7 +1296,7 @@ func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) {
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "bellagio",
SelectedEntryID: "dynadot",
}
if err := state.MoveSelectedEntry([]string{"Root", "Home Assistant"}); err != nil {
@@ -1313,8 +1312,8 @@ func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) {
if len(got) != 2 {
t.Fatalf("len(VisibleEntries()) = %d, want 2", len(got))
}
if got[0].ID != "bellagio" && got[1].ID != "bellagio" {
t.Fatalf("VisibleEntries() = %#v, want moved bellagio entry in destination group", got)
if got[0].ID != "dynadot" && got[1].ID != "dynadot" {
t.Fatalf("VisibleEntries() = %#v, want moved dynadot entry in destination group", got)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after MoveSelectedEntry")
@@ -1326,7 +1325,7 @@ func TestMoveCurrentGroupMovesHierarchyAndMarksDirty(t *testing.T) {
model := vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
},
}
model.CreateGroup([]string{"Root", "Internet"}, "Infrastructure")
@@ -1337,15 +1336,15 @@ func TestMoveCurrentGroupMovesHierarchyAndMarksDirty(t *testing.T) {
CurrentPath: []string{"Root", "Internet"},
}
if err := state.MoveCurrentGroup([]string{"Root", "Crew"}); err != nil {
if err := state.MoveCurrentGroup([]string{"Root", "Joe"}); err != nil {
t.Fatalf("MoveCurrentGroup() error = %v", err)
}
if !slices.Equal(state.CurrentPath, []string{"Root", "Crew", "Internet"}) {
t.Fatalf("CurrentPath = %v, want [Root Crew Internet]", state.CurrentPath)
if !slices.Equal(state.CurrentPath, []string{"Root", "Joe", "Internet"}) {
t.Fatalf("CurrentPath = %v, want [Root Joe Internet]", state.CurrentPath)
}
if got := session.model.EntriesInPath([]string{"Root", "Crew", "Internet"}); len(got) != 1 || got[0].ID != "vault-console" {
t.Fatalf("EntriesInPath(Root/Crew/Internet) = %#v, want moved entry", got)
if got := session.model.EntriesInPath([]string{"Root", "Joe", "Internet"}); len(got) != 1 || got[0].ID != "git-server" {
t.Fatalf("EntriesInPath(Root/Joe/Internet) = %#v, want moved entry", got)
}
if !state.Dirty {
t.Fatal("Dirty = false, want true after MoveCurrentGroup")
@@ -1359,7 +1358,7 @@ func TestAddAttachmentToSelectedEntryPersistsAndMarksDirty(t *testing.T) {
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "bellagio",
SelectedEntryID: "dynadot",
}
if err := state.AddAttachmentToSelectedEntry("token.txt", []byte("secret")); err != nil {
@@ -1387,7 +1386,7 @@ func TestAddAttachmentToSelectedEntryRejectsDuplicateNames(t *testing.T) {
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "bellagio",
SelectedEntryID: "dynadot",
}
err := state.AddAttachmentToSelectedEntry("token.txt", []byte("replacement"))
@@ -1413,7 +1412,7 @@ func TestReplaceAttachmentOnSelectedEntryPersistsAndMarksDirty(t *testing.T) {
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "bellagio",
SelectedEntryID: "dynadot",
}
if err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement")); err != nil {
@@ -1439,7 +1438,7 @@ func TestReplaceAttachmentOnSelectedEntryRequiresExistingAttachment(t *testing.T
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "bellagio",
SelectedEntryID: "dynadot",
}
err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement"))
@@ -1457,7 +1456,7 @@ func TestDeleteAttachmentFromSelectedEntryPersistsAndMarksDirty(t *testing.T) {
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "bellagio",
SelectedEntryID: "dynadot",
}
if err := state.DeleteAttachmentFromSelectedEntry("token.txt"); err != nil {
@@ -1483,7 +1482,7 @@ func TestDeleteAttachmentFromSelectedEntryRequiresExistingAttachment(t *testing.
state := State{
Session: sess,
CurrentPath: []string{"Root", "Internet"},
SelectedEntryID: "bellagio",
SelectedEntryID: "dynadot",
}
err := state.DeleteAttachmentFromSelectedEntry("token.txt")
@@ -1511,8 +1510,8 @@ func (s *mutableStubSession) Replace(model vault.Model) {
func testVaultModel() vault.Model {
return vault.Model{
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"Root", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Root", "Home Assistant"}},
{ID: "dynadot", Title: "Dynadot", Path: []string{"Root", "Internet"}},
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Root", "Home Assistant"}},
},
}
}
@@ -1584,26 +1583,6 @@ func (s *lifecycleStubSession) OpenRemote(_ webdav.Client, path string, _ vault.
return nil
}
func (s *lifecycleStubSession) SynchronizeFromLocal(string) error {
return nil
}
func (s *lifecycleStubSession) SynchronizeFromLocalBytes(string, []byte) error {
return nil
}
func (s *lifecycleStubSession) SynchronizeToLocal(string) error {
return nil
}
func (s *lifecycleStubSession) SynchronizeFromRemote(webdav.Client, string) error {
return nil
}
func (s *lifecycleStubSession) SynchronizeToRemote(webdav.Client, string) error {
return nil
}
func (s *lifecycleStubSession) ChangeMasterKey(key vault.MasterKey) error {
s.changedKey = key
return nil
+10 -10
View File
@@ -22,7 +22,7 @@ func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
Username: "joe",
Password: "secret",
URL: "https://10.0.2.2:8443/login",
Path: []string{"Crew", "Internet"},
Path: []string{"Joe", "Internet"},
},
{
ID: "two",
@@ -35,10 +35,10 @@ func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
Title: "Bare Host",
Username: "user",
Password: "pass",
URL: "surveillance.crew.example.invalid",
URL: "lights.julianfamily.org",
Fields: map[string]string{
"AndroidApp1": "androidapp://com.lights.mobile",
"KP2A_URL_1": "https://surveillance.crew.example.invalid/account",
"KP2A_URL_1": "https://lights.julianfamily.org/account",
},
},
},
@@ -50,8 +50,8 @@ func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
if got.Entries[0].Host != "10.0.2.2" {
t.Fatalf("first host = %q, want 10.0.2.2", got.Entries[0].Host)
}
if got.Entries[1].Host != "surveillance.crew.example.invalid" {
t.Fatalf("second host = %q, want surveillance.crew.example.invalid", got.Entries[1].Host)
if got.Entries[1].Host != "lights.julianfamily.org" {
t.Fatalf("second host = %q, want lights.julianfamily.org", got.Entries[1].Host)
}
if len(got.Entries[1].Targets) != 3 {
t.Fatalf("len(second targets) = %d, want 3", len(got.Entries[1].Targets))
@@ -137,21 +137,21 @@ func TestMatchRejectsAmbiguousSharedHost(t *testing.T) {
Title: "Host A",
Username: "first",
Password: "secret1",
URL: "https://surveillance.crew.example.invalid/",
Host: "surveillance.crew.example.invalid",
URL: "https://lights.julianfamily.org/",
Host: "lights.julianfamily.org",
},
{
ID: "two",
Title: "Host B",
Username: "second",
Password: "secret2",
URL: "https://surveillance.crew.example.invalid/",
Host: "surveillance.crew.example.invalid",
URL: "https://lights.julianfamily.org/",
Host: "lights.julianfamily.org",
},
},
}
if _, ok := Match(cache, "https://surveillance.crew.example.invalid/"); ok {
if _, ok := Match(cache, "https://lights.julianfamily.org/"); ok {
t.Fatalf("Match() unexpectedly resolved ambiguous shared host")
}
}
-4
View File
@@ -13,7 +13,6 @@ const (
DefaultAppID = "org.julianfamily.keepassgo"
DefaultAPKOut = "build/keepassgo.apk"
DefaultVersion = "0.1.0.1"
DefaultLdflags = "-X main.appVersion=dev"
DefaultMinSDK = "28"
DefaultTargetSDK = "35"
DefaultIconPath = "assets/keepassgo-icon.png"
@@ -26,7 +25,6 @@ type Config struct {
AppID string
APKOut string
Version string
Ldflags string
MinSDK string
TargetSDK string
IconPath string
@@ -40,7 +38,6 @@ func DefaultConfig() Config {
AppID: DefaultAppID,
APKOut: DefaultAPKOut,
Version: DefaultVersion,
Ldflags: DefaultLdflags,
MinSDK: DefaultMinSDK,
TargetSDK: DefaultTargetSDK,
IconPath: DefaultIconPath,
@@ -52,7 +49,6 @@ func (c Config) GogioArgs() []string {
"-target", "android",
"-buildmode", "exe",
"-appid", c.AppID,
"-ldflags", c.Ldflags,
"-o", c.APKOut,
"-version", c.Version,
"-minsdk", c.MinSDK,
-3
View File
@@ -15,7 +15,6 @@ func TestDefaultConfigGogioArgs(t *testing.T) {
"-target", "android",
"-buildmode", "exe",
"-appid", DefaultAppID,
"-ldflags", DefaultLdflags,
"-o", DefaultAPKOut,
"-version", DefaultVersion,
"-minsdk", DefaultMinSDK,
@@ -53,7 +52,6 @@ func TestValidateAcceptsCompleteAndroidToolchainLayout(t *testing.T) {
AppID: DefaultAppID,
APKOut: DefaultAPKOut,
Version: DefaultVersion,
Ldflags: DefaultLdflags,
MinSDK: DefaultMinSDK,
TargetSDK: DefaultTargetSDK,
IconPath: filepath.Join(root, "icon.png"),
@@ -79,7 +77,6 @@ func TestValidateRejectsMissingPrerequisites(t *testing.T) {
AppID: DefaultAppID,
APKOut: DefaultAPKOut,
Version: DefaultVersion,
Ldflags: DefaultLdflags,
MinSDK: DefaultMinSDK,
TargetSDK: DefaultTargetSDK,
}
+11 -11
View File
@@ -13,11 +13,11 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
model := vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
},
},
}
@@ -27,9 +27,9 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
target Target
want string
}{
{name: "username", target: TargetUsername, want: "dannyocean"},
{name: "username", target: TargetUsername, want: "joejulian"},
{name: "password", target: TargetPassword, want: "token-1"},
{name: "url", target: TargetURL, want: "https://vault.crew.example.invalid"},
{name: "url", target: TargetURL, want: "https://git.julianfamily.org"},
}
for _, tt := range tests {
@@ -37,7 +37,7 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
var writer memoryWriter
service := Service{Writer: &writer}
if err := service.Copy(model, "vault-console", tt.target); err != nil {
if err := service.Copy(model, "git-server", tt.target); err != nil {
t.Fatalf("Copy() error = %v", err)
}
@@ -60,9 +60,9 @@ func TestServiceRejectsUnknownEntryAndUnsupportedTarget(t *testing.T) {
}
model := vault.Model{
Entries: []vault.Entry{{ID: "vault-console", Username: "dannyocean"}},
Entries: []vault.Entry{{ID: "git-server", Username: "joejulian"}},
}
err = service.Copy(model, "vault-console", Target("unsupported"))
err = service.Copy(model, "git-server", Target("unsupported"))
if !errors.Is(err, ErrUnsupportedTarget) {
t.Fatalf("Copy() unsupported target error = %v, want ErrUnsupportedTarget", err)
}
@@ -74,11 +74,11 @@ func TestServiceSanitizesClipboardWriteErrors(t *testing.T) {
service := Service{Writer: failingWriter{err: errors.New("backend refused token-1")}}
model := vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Password: "token-1"},
{ID: "git-server", Password: "token-1"},
},
}
err := service.Copy(model, "vault-console", TargetPassword)
err := service.Copy(model, "git-server", TargetPassword)
if !errors.Is(err, ErrWriteFailed) {
t.Fatalf("Copy() write error = %v, want ErrWriteFailed", err)
}
-63
View File
@@ -1,63 +0,0 @@
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
@@ -1,42 +0,0 @@
package main
import (
"slices"
"testing"
)
func TestNewPlatformClipboardWriterUsesCommandWriterOnAndroid(t *testing.T) {
t.Parallel()
writer := newPlatformClipboardWriter("android", nil)
if _, ok := writer.(*clipboardCommandWriter); !ok {
t.Fatalf("newPlatformClipboardWriter(android) = %T, want *clipboardCommandWriter", writer)
}
}
func TestNewPlatformClipboardWriterUsesSystemClipboardOffAndroid(t *testing.T) {
t.Parallel()
if writer := newPlatformClipboardWriter("linux", nil); writer != nil {
t.Fatalf("newPlatformClipboardWriter(linux) = %T, want nil", writer)
}
}
func TestClipboardCommandWriterDrainsQueuedWrites(t *testing.T) {
t.Parallel()
writer := &clipboardCommandWriter{}
if err := writer.WriteText("username"); err != nil {
t.Fatalf("WriteText(username) error = %v", err)
}
if err := writer.WriteText("password"); err != nil {
t.Fatalf("WriteText(password) error = %v", err)
}
if got := writer.drain(); !slices.Equal(got, []string{"username", "password"}) {
t.Fatalf("drain() = %v, want [username password]", got)
}
if got := writer.drain(); got != nil {
t.Fatalf("drain() after flush = %v, want nil", got)
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ KeePassGO currently targets keyboard-first desktop use on Linux and Windows.
- list navigation
- search focus
- new-entry focus transitions
- Controls that participate in keyboard navigation have intent-revealing accessibility labels through `accessibilityLabel` in [`ui_accessibility.go`](/workspace/keepassgo/ui_accessibility.go).
- 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).
## Current screen-reader boundary
+1 -4
View File
@@ -6,7 +6,6 @@ replace gioui.org/cmd => ./third_party/gioui-cmd
require (
gioui.org v0.8.0
gioui.org/x v0.8.0
github.com/atotto/clipboard v0.1.4
github.com/tobischo/gokeepasslib/v3 v3.6.2
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90
@@ -19,7 +18,6 @@ require (
4d63.com/gochecknoglobals v0.2.2 // indirect
gioui.org/cmd v0.8.0 // indirect
gioui.org/shader v1.0.8 // indirect
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 // indirect
github.com/4meepo/tagalign v1.4.2 // indirect
github.com/Abirdcfly/dupword v0.1.3 // indirect
github.com/Antonboom/errname v1.0.0 // indirect
@@ -76,7 +74,6 @@ require (
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/godbus/dbus/v5 v5.0.6 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
@@ -195,7 +192,7 @@ require (
go.uber.org/multierr v1.6.0 // indirect
go.uber.org/zap v1.24.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect
golang.org/x/image v0.37.0 // indirect
golang.org/x/mod v0.33.0 // indirect
+2 -8
View File
@@ -42,10 +42,6 @@ gioui.org v0.8.0/go.mod h1:vEMmpxMOd/iwJhXvGVIzWEbxMWhnMQ9aByOGQdlQ8rc=
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
gioui.org/x v0.8.0 h1:RhIlQNOFKKn8D8FeaKKaXCo7vB3x+fq4VcD10HW/YpA=
gioui.org/x v0.8.0/go.mod h1:aXtQb+kyqoUOjDl5/uMqAopjzVzMkeHBbMQOGT5KnSE=
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 h1:bGG/g4ypjrCJoSvFrP5hafr9PPB5aw8SjcOWWila7ZI=
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0/go.mod h1:+axXBRUTIDlCeE73IKeD/os7LoEnTKdkp8/gQOFjqyo=
github.com/4meepo/tagalign v1.4.2 h1:0hcLHPGMjDyM1gHG58cS73aQF8J4TdVR96TZViorO9E=
github.com/4meepo/tagalign v1.4.2/go.mod h1:+p4aMyFM+ra7nb41CnFG6aSDXqRxU/w1VQqScKqDARI=
github.com/Abirdcfly/dupword v0.1.3 h1:9Pa1NuAsZvpFPi9Pqkd93I7LIYRURj+A//dFd5tgBeE=
@@ -228,8 +224,6 @@ github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8=
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
github.com/godbus/dbus/v5 v5.0.6 h1:mkgN1ofwASrYnJ5W6U/BxG15eXXXjirgZc7CLqkcaro=
github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
@@ -675,8 +669,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90 h1:kyPrwnEYXdME284bE7xgS9BPxhG7MCa5hw1/TpaTJVs=
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:jqkJFnLVkS8zgKKY4+MOPCZtuZGw3hONUjhapUSwZ8c=
golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
+552 -1646
View File
File diff suppressed because it is too large Load Diff
+280 -1546
View File
File diff suppressed because it is too large Load Diff
@@ -1,26 +0,0 @@
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
@@ -1,65 +0,0 @@
pkgname=keepassgo-git
pkgver=r0.0000000
pkgrel=1
pkgdesc='KeePass-compatible password manager written in Go'
arch=('x86_64' 'aarch64')
url='https://git.julianfamily.org/joejulian/keepassgo'
license=('custom')
depends=(
'glibc'
'hicolor-icon-theme'
'libx11'
'libxcursor'
'libxfixes'
'libxkbcommon'
'libxkbcommon-x11'
'mesa'
'wayland'
)
makedepends=(
'git'
'go'
'pkgconf'
)
provides=('keepassgo')
conflicts=('keepassgo')
source=('git+https://git.julianfamily.org/joejulian/keepassgo.git')
sha256sums=('SKIP')
_repo_dir() {
if [[ -d "${srcdir}/keepassgo/.git" ]]; then
printf '%s\n' "${srcdir}/keepassgo"
return
fi
cd "${startdir}/../../.." || exit 1
pwd
}
pkgver() {
cd "$(_repo_dir)"
printf 'r%s.%s' "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
}
build() {
cd "$(_repo_dir)"
export CGO_ENABLED=1
export GOFLAGS="-trimpath"
local app_version
app_version="$(git describe --tags --always --dirty)"
go build -ldflags "-X main.appVersion=${app_version}" -o keepassgo .
}
package() {
cd "$(_repo_dir)"
install -Dm755 keepassgo "${pkgdir}/usr/bin/keepassgo"
install -Dm644 assets/keepassgo-icon.png \
"${pkgdir}/usr/share/icons/hicolor/512x512/apps/keepassgo.png"
install -Dm644 assets/keepassgo-icon.svg \
"${pkgdir}/usr/share/icons/hicolor/scalable/apps/keepassgo.svg"
install -Dm644 packaging/archlinux/keepassgo-git/keepassgo.desktop \
"${pkgdir}/usr/share/applications/keepassgo.desktop"
install -Dm644 README.md \
"${pkgdir}/usr/share/licenses/${pkgname}/README.md"
}
@@ -1,10 +0,0 @@
[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
@@ -1,95 +0,0 @@
#!/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,7 +4,6 @@ import (
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"reflect"
@@ -178,18 +177,6 @@ func (m *Manager) SynchronizeFromLocal(path string) error {
return m.persistMergedToCurrentSource(merged)
}
func (m *Manager) SynchronizeFromLocalBytes(name string, content []byte) error {
other, _, err := loadLocalSourceBytes(name, content, m.key)
if err != nil {
return err
}
merged, err := m.mergedWithPeer(other)
if err != nil {
return err
}
return m.persistMergedToCurrentSource(merged)
}
func (m *Manager) SynchronizeToLocal(path string) error {
other, config, err := loadLocalSourceOrEmpty(path, m.key)
if err != nil {
@@ -921,17 +908,6 @@ func loadLocalSource(path string, key vault.MasterKey) (vault.Model, *vault.KDBX
return model, config, nil
}
func loadLocalSourceBytes(name string, content []byte, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
if len(content) == 0 {
return vault.Model{}, nil, fmt.Errorf("open %s for synchronize: %w", name, io.EOF)
}
model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key)
if err != nil {
return vault.Model{}, nil, fmt.Errorf("decode %s for synchronize: %w", name, err)
}
return model, config, nil
}
func loadLocalSourceOrEmpty(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
model, config, err := loadLocalSource(path, key)
if err == nil {
+83 -140
View File
@@ -24,10 +24,10 @@ func TestCreateSaveAsLockAndUnlockRoundTripsVault(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
@@ -65,8 +65,8 @@ func TestCreateSaveAsLockAndUnlockRoundTripsVault(t *testing.T) {
}
got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 1 || got[0].Title != "Vault Console" || got[0].Password != "token-1" {
t.Fatalf("Current() entries = %#v, want persisted Vault Console entry", got)
if len(got) != 1 || got[0].Title != "Git Server" || got[0].Password != "token-1" {
t.Fatalf("Current() entries = %#v, want persisted Git Server entry", got)
}
}
@@ -78,10 +78,10 @@ func TestOpenLoadsExistingKDBXFromDisk(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Surveillance Console",
Title: "Home Assistant (Codex)",
Username: "codex",
Password: "token-2",
URL: "https://surveillance.crew.example.invalid",
URL: "https://lights.julianfamily.org",
Path: []string{"Root", "Home Assistant"},
},
},
@@ -124,10 +124,10 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
@@ -146,10 +146,10 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
updated := model
updated.UpsertEntry(vault.Entry{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-2",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
})
sess.Replace(updated)
@@ -186,19 +186,19 @@ func TestSaveReparentsMixedPathsUnderSingleVaultRoot(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"keepass", "Crew", "Internet"},
URL: "https://git.julianfamily.org",
Path: []string{"keepass", "Joe", "Internet"},
},
{
ID: "entry-2",
Title: "Mail",
Username: "dannyocean",
Username: "joejulian",
Password: "token-2",
URL: "https://dispatch.crew.example.invalid",
Path: []string{"keepass", "Crew", "eMail"},
URL: "https://mail.julianfamily.org",
Path: []string{"keepass", "Joe", "eMail"},
},
},
}, key.Password); err != nil {
@@ -217,8 +217,8 @@ func TestSaveReparentsMixedPathsUnderSingleVaultRoot(t *testing.T) {
if err != nil {
t.Fatalf("Current() error = %v", err)
}
current.Entries[0].Path = []string{"Crew", "Internet"}
current.Groups = append(current.Groups, []string{"Crew"}, []string{"Crew", "Internet"}, []string{"Crew", "eMail"})
current.Entries[0].Path = []string{"Joe", "Internet"}
current.Groups = append(current.Groups, []string{"Joe"}, []string{"Joe", "Internet"}, []string{"Joe", "eMail"})
sess.Replace(current)
if err := sess.Save(); err != nil {
@@ -244,8 +244,8 @@ func TestSaveReparentsMixedPathsUnderSingleVaultRoot(t *testing.T) {
t.Fatalf("top-level groups = %#v, want single keepass root", db.Content.Root.Groups)
}
rootGroups := db.Content.Root.Groups[0].Groups
if len(rootGroups) != 1 || rootGroups[0].Name != "Crew" {
t.Fatalf("keepass child groups = %#v, want single Crew group", rootGroups)
if len(rootGroups) != 1 || rootGroups[0].Name != "Joe" {
t.Fatalf("keepass child groups = %#v, want single Joe group", rootGroups)
}
}
@@ -271,10 +271,10 @@ func TestOpenRemoteLoadsExistingKDBXFromWebDAV(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
@@ -309,7 +309,7 @@ func TestOpenRemoteLoadsExistingKDBXFromWebDAV(t *testing.T) {
got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 1 || got[0].Password != "token-1" {
t.Fatalf("Current() entries = %#v, want Vault Console entry from remote vault", got)
t.Fatalf("Current() entries = %#v, want Git Server entry from remote vault", got)
}
}
@@ -321,10 +321,10 @@ func TestSaveRemotePersistsEditsBackToWebDAV(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Surveillance Console",
Title: "Home Assistant (Codex)",
Username: "codex",
Password: "token-1",
URL: "https://surveillance.crew.example.invalid",
URL: "https://lights.julianfamily.org",
Path: []string{"Root", "Home Assistant"},
},
},
@@ -371,10 +371,10 @@ func TestSaveRemotePersistsEditsBackToWebDAV(t *testing.T) {
}
current.UpsertEntry(vault.Entry{
ID: "entry-1",
Title: "Surveillance Console",
Title: "Home Assistant (Codex)",
Username: "codex",
Password: "token-2",
URL: "https://surveillance.crew.example.invalid",
URL: "https://lights.julianfamily.org",
Path: []string{"Root", "Home Assistant"},
})
sess.Replace(current)
@@ -406,10 +406,10 @@ func TestSaveUsesRemoteTargetWhenVaultWasOpenedFromWebDAV(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
@@ -448,10 +448,10 @@ func TestSaveUsesRemoteTargetWhenVaultWasOpenedFromWebDAV(t *testing.T) {
}
current.UpsertEntry(vault.Entry{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-2",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
})
sess.Replace(current)
@@ -477,10 +477,10 @@ func TestChangeMasterKeyReencryptsSavedAndLockedVault(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
@@ -514,8 +514,8 @@ func TestChangeMasterKeyReencryptsSavedAndLockedVault(t *testing.T) {
t.Fatalf("Current() error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 1 || got[0].Title != "Vault Console" {
t.Fatalf("Current() entries = %#v, want Vault Console entry after ChangeMasterKey", got)
if len(got) != 1 || got[0].Title != "Git Server" {
t.Fatalf("Current() entries = %#v, want Git Server entry after ChangeMasterKey", got)
}
var reopened Manager
@@ -540,10 +540,10 @@ func TestSavePreservesOpenedKDBXSecuritySettings(t *testing.T) {
{
UUID: gokeepasslib.NewUUID(),
Values: []gokeepasslib.ValueData{
{Key: "Title", Value: gokeepasslib.V{Content: "Vault Console"}},
{Key: "UserName", Value: gokeepasslib.V{Content: "dannyocean"}},
{Key: "Title", Value: gokeepasslib.V{Content: "Git Server"}},
{Key: "UserName", Value: gokeepasslib.V{Content: "joejulian"}},
{Key: "Password", Value: gokeepasslib.V{Content: "token-1", Protected: w.NewBoolWrapper(true)}},
{Key: "URL", Value: gokeepasslib.V{Content: "https://vault.crew.example.invalid"}},
{Key: "URL", Value: gokeepasslib.V{Content: "https://git.julianfamily.org"}},
},
},
},
@@ -577,10 +577,10 @@ func TestSavePreservesOpenedKDBXSecuritySettings(t *testing.T) {
}
current.UpsertEntry(vault.Entry{
ID: current.Entries[0].ID,
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-2",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: current.Entries[0].Path,
})
sess.Replace(current)
@@ -620,10 +620,10 @@ func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-2",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
Attachments: map[string][]byte{
"token.txt": []byte("secret attachment contents"),
@@ -631,10 +631,10 @@ func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
History: []vault.Entry{
{
ID: "entry-1-history-1",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
@@ -778,10 +778,10 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
@@ -841,10 +841,10 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
}
firstCurrent.UpsertEntry(vault.Entry{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "remote-token-2",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Notes: "updated remotely first",
Path: []string{"Root", "Internet"},
})
@@ -859,10 +859,10 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
}
secondCurrent.UpsertEntry(vault.Entry{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "local-token-2",
URL: "https://vault.crew.example.invalid/security/badges",
URL: "https://git.julianfamily.org/settings/tokens",
Path: []string{"Root", "Internet"},
})
second.Replace(secondCurrent)
@@ -883,7 +883,7 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
if len(got) != 1 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got))
}
if got[0].Password != "local-token-2" || got[0].URL != "https://vault.crew.example.invalid/security/badges" {
if got[0].Password != "local-token-2" || got[0].URL != "https://git.julianfamily.org/settings/tokens" {
t.Fatalf("entry after synchronize = %#v, want local winning version", got[0])
}
if len(got[0].History) == 0 {
@@ -905,10 +905,10 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-current",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-current",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
@@ -917,10 +917,10 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-other",
Title: "Bellagio",
Username: "rustyryan",
Title: "Dynadot",
Username: "jjulian",
Password: "token-other",
URL: "https://bellagio.example.invalid",
URL: "https://www.dynadot.com",
Path: []string{"Root", "Internet"},
},
},
@@ -953,63 +953,6 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
}
}
func TestSynchronizeFromLocalBytesMergesOtherVaultIntoCurrentSource(t *testing.T) {
t.Parallel()
key := vault.MasterKey{Password: "correct horse battery staple"}
currentPath := filepath.Join(t.TempDir(), "current.kdbx")
currentModel := vault.Model{
Entries: []vault.Entry{{
ID: "entry-current",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-current",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
}},
}
otherModel := vault.Model{
Entries: []vault.Entry{{
ID: "entry-other",
Title: "Bellagio",
Username: "rustyryan",
Password: "token-other",
URL: "https://bellagio.example.invalid",
Path: []string{"Root", "Internet"},
}},
}
writeKDBXTestFile(t, currentPath, currentModel, key)
var other bytes.Buffer
if err := vault.SaveKDBX(&other, otherModel, key.Password); err != nil {
t.Fatalf("SaveKDBX(other) error = %v", err)
}
var sess Manager
if err := sess.Open(currentPath, key); err != nil {
t.Fatalf("Open(current) error = %v", err)
}
if err := sess.SynchronizeFromLocalBytes("picked-other.kdbx", other.Bytes()); err != nil {
t.Fatalf("SynchronizeFromLocalBytes() error = %v", err)
}
var reopened Manager
if err := reopened.Open(currentPath, key); err != nil {
t.Fatalf("reopen Open(current) error = %v", err)
}
current, err := reopened.Current()
if err != nil {
t.Fatalf("reopened Current() error = %v", err)
}
got := current.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
}
}
func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
t.Parallel()
@@ -1021,10 +964,10 @@ func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-current",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-current",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
@@ -1033,10 +976,10 @@ func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-other",
Title: "Bellagio",
Username: "rustyryan",
Title: "Dynadot",
Username: "jjulian",
Password: "token-other",
URL: "https://bellagio.example.invalid",
URL: "https://www.dynadot.com",
Path: []string{"Root", "Internet"},
},
},
@@ -1078,10 +1021,10 @@ func TestSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-current",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-current",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
@@ -1090,10 +1033,10 @@ func TestSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
Entries: []vault.Entry{
{
ID: "entry-remote",
Title: "Bellagio",
Username: "rustyryan",
Title: "Dynadot",
Username: "jjulian",
Password: "token-remote",
URL: "https://bellagio.example.invalid",
URL: "https://www.dynadot.com",
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}}" />
{{range .Permissions}} <uses-permission android:name="{{.}}"/>
{{end}}{{range .Features}} <uses-feature android:{{.}} android:required="false"/>
{{end}}{{.ManifestSnip}} <application {{.IconSnip}} android:label="{{.AppName}}" android:enableOnBackInvokedCallback="false">
{{end}}{{.ManifestSnip}} <application {{.IconSnip}} android:label="{{.AppName}}">
{{.AppSnip}}
<activity android:name="org.gioui.GioActivity"
android:label="{{.AppName}}"
+2 -25
View File
@@ -19,49 +19,26 @@ type focusAppearance struct {
MinHeight int
}
func fieldFocusAppearance(metric unit.Metric, prefs accessibilityPreferences, focused bool) focusAppearance {
prefs = normalizeAccessibilityPreferences(prefs)
func fieldFocusAppearance(metric unit.Metric, focused bool) focusAppearance {
appearance := focusAppearance{
BorderColor: color.NRGBA{R: 202, G: 194, B: 180, A: 255},
OutlineColor: color.NRGBA{A: 0},
OutlineWidth: max(1, metric.Dp(unit.Dp(1))),
MinHeight: metric.Dp(unit.Dp(44)),
}
if prefs.DisplayDensity == displayDensityComfortable {
appearance.MinHeight = metric.Dp(unit.Dp(52))
}
if prefs.Contrast == contrastHigh {
appearance.BorderColor = color.NRGBA{R: 108, G: 101, B: 90, A: 255}
}
if focused {
appearance.BorderColor = accentColor
appearance.OutlineColor = color.NRGBA{R: 28, G: 83, B: 63, A: 72}
appearance.OutlineWidth = max(2, metric.Dp(unit.Dp(2)))
if prefs.Contrast == contrastHigh {
appearance.BorderColor = color.NRGBA{R: 16, G: 60, B: 44, A: 255}
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 124}
}
if prefs.KeyboardFocus == keyboardFocusProminent {
appearance.OutlineWidth = max(3, metric.Dp(unit.Dp(3)))
appearance.OutlineColor = color.NRGBA{R: 20, G: 74, B: 55, A: 148}
}
}
return appearance
}
func buttonFocusColors(prefs accessibilityPreferences, focused bool) (background color.NRGBA, text color.NRGBA) {
prefs = normalizeAccessibilityPreferences(prefs)
func buttonFocusColors(focused bool) (background color.NRGBA, text color.NRGBA) {
background = color.NRGBA{R: 231, G: 239, B: 235, A: 255}
text = accentColor
if prefs.Contrast == contrastHigh {
background = color.NRGBA{R: 225, G: 235, B: 230, A: 255}
text = color.NRGBA{R: 19, G: 57, B: 43, A: 255}
}
if focused {
background = color.NRGBA{R: 214, G: 229, B: 221, A: 255}
if prefs.Contrast == contrastHigh || prefs.KeyboardFocus == keyboardFocusProminent {
background = color.NRGBA{R: 202, G: 222, B: 212, A: 255}
}
}
return background, text
}
+3 -89
View File
@@ -369,57 +369,6 @@ func (u *ui) addAPIPolicyRuleAction() error {
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 {
token, ok := u.selectedAPIToken()
if !ok {
@@ -1053,44 +1002,9 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
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(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.apiPolicyGroupScopeW.Value {
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(labeledEditorHelp(u.theme, "Group Path", "Used when group scope is enabled.", &u.apiPolicyPath, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "Entry ID", "Used when group scope is disabled.", &u.apiPolicyEntryID, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.addAPIPolicyRule, "Add Rule")
+187 -350
View File
@@ -6,7 +6,6 @@ import (
"image/color"
"net/url"
"path/filepath"
"runtime"
"strings"
"gioui.org/layout"
@@ -20,9 +19,6 @@ import (
func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
busy := u.lifecycleBusy()
showLocalChooser := u.showLocalVaultChooser()
showRemoteChooser := u.showRemoteConnectionChooser()
selectedLocalPath := strings.TrimSpace(u.vaultPath.Text())
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "OPEN A VAULT")
@@ -31,7 +27,7 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
message := "Choose a recent vault or enter a .kdbx path, then unlock it."
message := "Choose a recent vault or pick a `.kdbx` file, then unlock it."
if u.lifecycleMode == "remote" {
message = "Connect to a remote vault, then unlock it with the KeePass master key."
}
@@ -62,172 +58,174 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
if u.lifecycleMode == "remote" {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showRemoteChooser {
return layout.Dimensions{}
}
lbl := material.Label(u.theme, unit.Sp(12), "LOCATION")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "Remote Base URL", "Base WebDAV endpoint, for example https://server/remote.php/webdav.", &u.remoteBaseURL, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "Remote Path", "Path to the remote .kdbx file under the WebDAV base URL.", &u.remotePath, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showRemoteChooser {
if strings.TrimSpace(u.remoteBaseURL.Text()) == "" || strings.TrimSpace(u.remotePath.Text()) == "" {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
record := u.currentRemoteRecord()
lastGroup := u.recentRemoteGroup(record.BaseURL, record.Path)
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "SELECTED CONNECTION")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(14), friendlyRecentRemoteLabel(record))
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), "Path: "+strings.TrimSpace(record.Path))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), "Server: "+strings.TrimSpace(record.BaseURL))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), "Auth: "+recentRemoteStoredAuthSummary(recentRemoteRecord{
Username: strings.TrimSpace(u.remoteUsername.Text()),
Password: u.remotePassword.Text(),
}))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if len(lastGroup) == 0 {
return layout.Dimensions{}
}
lbl := material.Label(u.theme, unit.Sp(11), "Last group: "+strings.Join(u.displayEntryPath(lastGroup), " / "))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if busy {
return layout.Dimensions{}
}
return tonedButton(gtx, u.theme, &u.clearRemoteSelection, "Change...")
}),
)
})
})
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showRemoteChooser {
if busy {
return layout.Dimensions{}
}
return labeledEditorHelp(u.theme, "Remote Base URL", "Base WebDAV endpoint, for example https://server/remote.php/webdav.", &u.remoteBaseURL, false)(gtx)
return u.recentRemoteList(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showRemoteChooser {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showRemoteChooser {
return layout.Dimensions{}
}
return labeledEditorHelp(u.theme, "Remote Path", "Path to the remote .kdbx file under the WebDAV base URL.", &u.remotePath, false)(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showRemoteChooser {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if showRemoteChooser || !u.hasSelectedRemoteTarget() {
return layout.Dimensions{}
}
return layout.Dimensions{}
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if showRemoteChooser && !busy {
return u.recentRemoteList(gtx)
}
return layout.Dimensions{}
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showRemoteChooser {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(10)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showRemoteChooser {
return layout.Dimensions{}
}
lbl := material.Label(u.theme, unit.Sp(12), "AUTHENTICATION")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "Remote Username", "Username used to authenticate to the WebDAV server.", &u.remoteUsername, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showRemoteChooser {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showRemoteChooser {
return layout.Dimensions{}
}
return labeledEditorHelp(u.theme, "Remote Username", "Username used to authenticate to the WebDAV server.", &u.remoteUsername, false)(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showRemoteChooser {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showRemoteChooser {
return layout.Dimensions{}
}
return labeledEditorHelp(u.theme, "Remote Password", "Password or app token used to authenticate to the WebDAV server.", &u.remotePassword, true)(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showRemoteChooser {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showRemoteChooser {
return layout.Dimensions{}
}
box := material.CheckBox(u.theme, &u.rememberRemoteAuth, "Remember sign-in on this device")
box.Color = accentColor
return box.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showRemoteChooser {
return layout.Dimensions{}
}
return layout.Inset{Top: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.openRemotePrefsHelp, "Settings & Help")
})
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showRemoteChooser {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
lbl := material.Label(u.theme, unit.Sp(11), u.remoteAuthStatusMessage())
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
)
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showLocalChooser {
return layout.Dimensions{}
}
lbl := material.Label(u.theme, unit.Sp(12), "RECENT VAULTS")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showLocalChooser {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showLocalChooser || busy {
if busy {
return layout.Dimensions{}
}
return u.recentVaultList(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showLocalChooser {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showLocalChooser {
return layout.Dimensions{}
}
lbl := material.Label(u.theme, unit.Sp(12), "VAULT FILE")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showLocalChooser {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
selectedPath := strings.TrimSpace(u.vaultPath.Text())
switch {
case busy:
return labeledEditorHelp(u.theme, "Vault Path", localVaultPathHelp(), &u.vaultPath, false)(gtx)
case selectedLocalPath == "":
return localPathSelector(u.theme, &u.vaultPath, &u.pickVaultPath)(gtx)
return labeledEditorHelp(u.theme, "Vault Path", "Choose the existing .kdbx file to open.", &u.vaultPath, false)(gtx)
case selectedPath == "":
return selectorEditorHelp(u.theme, "Vault Path", "Choose the existing .kdbx file to open.", &u.vaultPath, &u.pickVaultPath, "Choose File", false)(gtx)
default:
return layout.Dimensions{}
lastGroup := u.recentVaultGroup(selectedPath)
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "SELECTED VAULT")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(16), friendlyRecentVaultLabel(selectedPath))
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
dir := compactPathDirectorySummary(selectedPath)
if dir == "" {
return layout.Dimensions{}
}
lbl := material.Label(u.theme, unit.Sp(11), dir)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if len(lastGroup) == 0 {
return layout.Dimensions{}
}
lbl := material.Label(u.theme, unit.Sp(11), "Last group: "+strings.Join(u.displayEntryPath(lastGroup), " / "))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.clearVaultSelection, "Change...")
}),
)
})
})
}
}),
)
@@ -245,9 +243,9 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if busy {
return labeledEditorHelp(u.theme, "Key File", keyFileHelp(), &u.keyFilePath, false)(gtx)
return labeledEditorHelp(u.theme, "Key File", "Optional path to a KeePass-compatible key file.", &u.keyFilePath, false)(gtx)
}
return keyFileSelector(u.theme, &u.keyFilePath, &u.pickKeyFile)(gtx)
return selectorEditorHelp(u.theme, "Key File", "Optional path to a KeePass-compatible key file.", &u.keyFilePath, &u.pickKeyFile, "Choose File", false)(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if busy {
@@ -298,26 +296,10 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.lifecycleMode == "remote" {
label := u.remoteOpenButtonLabel()
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if busy {
return passiveTonedButton(gtx, u.theme, label)
}
return tonedButton(gtx, u.theme, &u.openRemote, label)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if busy || !u.hasSelectedRemoteTarget() {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if busy || !u.hasSelectedRemoteTarget() {
return layout.Dimensions{}
}
return u.selectedRemoteConnectionCard(gtx)
}),
)
if busy {
return passiveTonedButton(gtx, u.theme, label)
}
return tonedButton(gtx, u.theme, &u.openRemote, label)
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
@@ -343,118 +325,11 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
}
return sectionTabButton(gtx, u.theme, &u.createVault, "Create New Vault", false)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if busy || selectedLocalPath == "" {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if busy || selectedLocalPath == "" {
return layout.Dimensions{}
}
return u.selectedLocalVaultCard(gtx, selectedLocalPath)
}),
)
}),
)
}
func (u *ui) selectedRemoteConnectionCard(gtx layout.Context) layout.Dimensions {
record := u.currentRemoteRecord()
lastGroup := u.recentRemoteGroup(record.BaseURL, record.Path)
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "SELECTED CONNECTION")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(14), friendlyRecentRemoteLabel(record))
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), "Path: "+strings.TrimSpace(record.Path))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), "Server: "+strings.TrimSpace(record.BaseURL))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), "Auth: "+recentRemoteStoredAuthSummary(recentRemoteRecord{
Username: strings.TrimSpace(u.remoteUsername.Text()),
Password: u.remotePassword.Text(),
}))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if len(lastGroup) == 0 {
return layout.Dimensions{}
}
lbl := material.Label(u.theme, unit.Sp(11), "Last group: "+strings.Join(u.displayEntryPath(lastGroup), " / "))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.clearRemoteSelection, "Open Different Connection")
}),
)
})
})
}
func (u *ui) selectedLocalVaultCard(gtx layout.Context, path string) layout.Dimensions {
lastGroup := u.recentVaultGroup(path)
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "SELECTED VAULT")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(16), friendlyRecentVaultLabel(path))
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
dir := compactPathDirectorySummary(path)
if dir == "" {
return layout.Dimensions{}
}
lbl := material.Label(u.theme, unit.Sp(11), dir)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if len(lastGroup) == 0 {
return layout.Dimensions{}
}
lbl := material.Label(u.theme, unit.Sp(11), "Last group: "+strings.Join(u.displayEntryPath(lastGroup), " / "))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.clearVaultSelection, "Open Different Vault")
}),
)
})
})
}
func (u *ui) lifecycleSecuritySettingsSummary() string {
return "Cipher and KDF now live in Vault Settings so opening and creating a vault stays focused on the file, key material, and sync choices."
}
@@ -511,7 +386,7 @@ func (u *ui) recentVaultList(gtx layout.Context) layout.Dimensions {
if gtx.Constraints.Min.Y > gtx.Constraints.Max.Y {
gtx.Constraints.Min.Y = gtx.Constraints.Max.Y
}
return material.List(u.theme, &u.recentVaultListState).Layout(gtx, len(u.recentVaults), func(gtx layout.Context, i int) layout.Dimensions {
return material.List(u.theme, &u.lifecycleList).Layout(gtx, len(u.recentVaults), func(gtx layout.Context, i int) layout.Dimensions {
path := u.recentVaults[i]
label := path
if friendly := friendlyRecentVaultLabel(path); friendly != "" {
@@ -592,7 +467,7 @@ func (u *ui) recentRemoteList(gtx layout.Context) layout.Dimensions {
if gtx.Constraints.Min.Y > gtx.Constraints.Max.Y {
gtx.Constraints.Min.Y = gtx.Constraints.Max.Y
}
return material.List(u.theme, &u.recentRemoteListState).Layout(gtx, len(u.recentRemotes), func(gtx layout.Context, i int) layout.Dimensions {
return material.List(u.theme, &u.lifecycleList).Layout(gtx, len(u.recentRemotes), func(gtx layout.Context, i int) layout.Dimensions {
record := u.recentRemotes[i]
label := friendlyRecentRemoteLabel(record)
selected := strings.TrimSpace(u.remoteBaseURL.Text()) == record.BaseURL && strings.TrimSpace(u.remotePath.Text()) == record.Path
@@ -914,6 +789,12 @@ func (u *ui) groupControls(gtx layout.Context) layout.Dimensions {
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "Create, rename, move, and delete groups without interrupting the browsing path above.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "CREATE")
@@ -921,6 +802,16 @@ func (u *ui) groupControls(gtx layout.Context) layout.Dimensions {
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
help := "Add a new top-level group."
if len(u.displayPath()) > 0 {
help = "Add a subgroup inside " + u.currentGroupDisplayName() + "."
}
lbl := material.Label(u.theme, unit.Sp(12), help)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(labeledEditor(u.theme, "Create Group / Subgroup", &u.groupName, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
@@ -943,6 +834,12 @@ func (u *ui) groupControls(gtx layout.Context) layout.Dimensions {
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "Rename or relocate only the selected group. Browsing stays in the separate group navigator.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.renameGroup, "Rename Current Group")
@@ -1034,7 +931,7 @@ func (u *ui) groupControlsDisclosure(gtx layout.Context) layout.Dimensions {
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
icon := u.expandLessIcon
if u.groupControlsHidden {
icon = u.chevronRightIcon
icon = u.expandMoreIcon
}
if icon == nil {
lbl := material.Label(u.theme, unit.Sp(16), ">")
@@ -1048,9 +945,10 @@ func (u *ui) groupControlsDisclosure(gtx layout.Context) layout.Dimensions {
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := "Group Tools"
label := "Group management"
size := unit.Sp(12)
if u.mode == "phone" {
label = "Tools"
size = unit.Sp(11)
}
lbl := material.Label(u.theme, size, label)
@@ -1072,15 +970,15 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return sectionCard(gtx, u.theme, "BASICS", "Core entry identity and navigation fields.", func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(labeledEditorWithFocus(u.theme, u.accessibilityPrefs, "Title", &u.entryTitle, false, u.isFocused(detailFocusID(detailFieldTitle)))),
layout.Rigid(labeledEditorWithFocus(u.theme, "Title", &u.entryTitle, false, u.isFocused(detailFocusID(detailFieldTitle)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorWithFocus(u.theme, u.accessibilityPrefs, "Username", &u.entryUsername, false, u.isFocused(detailFocusID(detailFieldUsername)))),
layout.Rigid(labeledEditorWithFocus(u.theme, "Username", &u.entryUsername, false, u.isFocused(detailFocusID(detailFieldUsername)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorWithFocus(u.theme, u.accessibilityPrefs, "URL", &u.entryURL, false, u.isFocused(detailFocusID(detailFieldURL)))),
layout.Rigid(labeledEditorWithFocus(u.theme, "URL", &u.entryURL, false, u.isFocused(detailFocusID(detailFieldURL)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorWithFocus(u.theme, u.accessibilityPrefs, "Path", &u.entryPath, false, u.isFocused(detailFocusID(detailFieldPath)))),
layout.Rigid(labeledEditorWithFocus(u.theme, "Path", &u.entryPath, false, u.isFocused(detailFocusID(detailFieldPath)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorWithFocus(u.theme, u.accessibilityPrefs, "Tags", &u.entryTags, false, u.isFocused(detailFocusID(detailFieldTags)))),
layout.Rigid(labeledEditorWithFocus(u.theme, "Tags", &u.entryTags, false, u.isFocused(detailFocusID(detailFieldTags)))),
)
})
}),
@@ -1088,9 +986,9 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return sectionCard(gtx, u.theme, "PASSWORD", "Generate, review, and keep track of password changes before you save.", func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(labeledEditorWithFocus(u.theme, u.accessibilityPrefs, "Password", &u.entryPassword, true, u.isFocused(detailFocusID(detailFieldPassword)))),
layout.Rigid(labeledEditorWithFocus(u.theme, "Password", &u.entryPassword, true, u.isFocused(detailFocusID(detailFieldPassword)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorWithFocus(u.theme, u.accessibilityPrefs, "Password Profile", &u.passwordProfile, false, u.isFocused(detailFocusID(detailFieldPasswordProfile)))),
layout.Rigid(labeledEditorWithFocus(u.theme, "Password Profile", &u.passwordProfile, false, u.isFocused(detailFocusID(detailFieldPasswordProfile)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), u.passwordProfileOptionsText())
@@ -1160,7 +1058,7 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return sectionCard(gtx, u.theme, "NOTES", "Long-form context for this entry.", func(gtx layout.Context) layout.Dimensions {
return labeledMultilineEditorWithFocus(u.theme, u.accessibilityPrefs, "Notes", &u.entryNotes, false, u.isFocused(detailFocusID(detailFieldNotes)), unit.Dp(108))(gtx)
return labeledMultilineEditorWithFocus(u.theme, "Notes", &u.entryNotes, false, u.isFocused(detailFocusID(detailFieldNotes)), unit.Dp(108))(gtx)
})
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
@@ -1168,7 +1066,7 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return sectionCard(gtx, u.theme, "HISTORY", "Pick a saved version index to restore into the current entry.", func(gtx layout.Context) layout.Dimensions {
return labeledEditorWithFocus(u.theme, u.accessibilityPrefs, "History Index", &u.historyIndex, false, u.isFocused(detailFocusID(detailFieldHistoryIndex)))(gtx)
return labeledEditorWithFocus(u.theme, "History Index", &u.historyIndex, false, u.isFocused(detailFocusID(detailFieldHistoryIndex)))(gtx)
})
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
@@ -1245,45 +1143,17 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
}
func labeledEditor(th *material.Theme, label string, editor *widget.Editor, sensitive bool) layout.Widget {
return labeledEditorWithFocus(th, defaultAccessibilityPreferences(), label, editor, sensitive, false)
return labeledEditorWithFocus(th, label, editor, sensitive, false)
}
func labeledEditorHelp(th *material.Theme, label, help string, editor *widget.Editor, sensitive bool) layout.Widget {
return labeledEditorHelpFocus(th, defaultAccessibilityPreferences(), label, help, editor, sensitive, false)
return labeledEditorHelpFocus(th, label, help, editor, sensitive, false)
}
func localVaultPathHelp() string {
if supportsDesktopFilePicker(runtime.GOOS) {
return "Choose the existing .kdbx file to open."
}
return "Enter the shared-storage path to the existing .kdbx file, for example /sdcard/Download/vault.kdbx."
}
func keyFileHelp() string {
if supportsDesktopFilePicker(runtime.GOOS) {
return "Optional path to a KeePass-compatible key file."
}
return "Optional shared-storage path to a KeePass-compatible key file."
}
func localPathSelector(th *material.Theme, editor *widget.Editor, click *widget.Clickable) layout.Widget {
if supportsDesktopFilePicker(runtime.GOOS) {
return selectorEditorHelp(th, "Vault Path", localVaultPathHelp(), editor, click, "Choose File", false)
}
return labeledEditorHelp(th, "Vault Path", localVaultPathHelp(), editor, false)
}
func keyFileSelector(th *material.Theme, editor *widget.Editor, click *widget.Clickable) layout.Widget {
if supportsDesktopFilePicker(runtime.GOOS) {
return selectorEditorHelp(th, "Key File", keyFileHelp(), editor, click, "Choose File", false)
}
return labeledEditorHelp(th, "Key File", keyFileHelp(), editor, false)
}
func labeledEditorHelpFocus(th *material.Theme, prefs accessibilityPreferences, label, help string, editor *widget.Editor, sensitive bool, focused bool) layout.Widget {
func labeledEditorHelpFocus(th *material.Theme, label, help string, editor *widget.Editor, sensitive bool, focused bool) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(labeledEditorWithFocus(th, prefs, label, editor, sensitive, focused)),
layout.Rigid(labeledEditorWithFocus(th, label, editor, sensitive, focused)),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(11), help)
@@ -1337,14 +1207,12 @@ func selectorEditorHelp(th *material.Theme, label, help string, editor *widget.E
func (u *ui) unlockPanel(gtx layout.Context) layout.Dimensions {
targetLabel := "Locked vault"
targetValue := "Unlock the active vault to continue."
changeLabel := "Open Different Vault"
if u.state.Session != nil {
if strings.TrimSpace(u.remoteBaseURL.Text()) != "" || strings.TrimSpace(u.remotePath.Text()) != "" {
baseURL := strings.TrimSpace(u.remoteBaseURL.Text())
path := strings.TrimSpace(u.remotePath.Text())
targetLabel = "Remote vault"
targetValue = friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: path})
changeLabel = "Open Different Connection"
if strings.TrimSpace(targetValue) == "" {
targetValue = "Remote WebDAV vault"
}
@@ -1360,65 +1228,36 @@ func (u *ui) unlockPanel(gtx layout.Context) layout.Dimensions {
}
}
}
targetCard := func(gtx layout.Context) layout.Dimensions {
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), strings.ToUpper(targetLabel))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Body1(u.theme, targetValue)
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !u.shouldUseLockedSinglePane() {
return layout.Dimensions{}
}
return layout.Inset{Top: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
if targetLabel == "Remote vault" {
return tonedButton(gtx, u.theme, &u.clearRemoteSelection, changeLabel)
}
return tonedButton(gtx, u.theme, &u.clearVaultSelection, changeLabel)
})
}),
)
})
})
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.mode == "desktop" {
return layout.Dimensions{}
}
return targetCard(gtx)
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), strings.ToUpper(targetLabel))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Body1(u.theme, targetValue)
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
)
})
})
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.masterPasswordField(gtx, "Used alone or together with a key file to unlock the vault.")
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(keyFileSelector(u.theme, &u.keyFilePath, &u.pickKeyFile)),
layout.Rigid(selectorEditorHelp(u.theme, "Key File", "Optional path to a KeePass-compatible key file.", &u.keyFilePath, &u.pickKeyFile, "Choose File", false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.unlockVault, "Unlock")
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.mode != "desktop" {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.mode != "desktop" {
return layout.Dimensions{}
}
return targetCard(gtx)
}),
)
}
@@ -1438,7 +1277,7 @@ func (u *ui) masterPasswordField(gtx layout.Context, help string) layout.Dimensi
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.outlinedFieldState(gtx, false, func(gtx layout.Context) layout.Dimensions {
return outlinedFieldState(gtx, false, func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(8)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
@@ -1475,7 +1314,6 @@ func (u *ui) masterPasswordField(gtx layout.Context, help string) layout.Dimensi
func labeledEditorWithFocus(
th *material.Theme,
prefs accessibilityPreferences,
label string,
editor *widget.Editor,
sensitive bool,
@@ -1489,7 +1327,7 @@ func labeledEditorWithFocus(
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return outlinedFieldStateWithPrefs(gtx, prefs, focused, func(gtx layout.Context) layout.Dimensions {
return outlinedFieldState(gtx, focused, func(gtx layout.Context) layout.Dimensions {
mask := editor.Mask
if sensitive {
editor.Mask = '•'
@@ -1506,7 +1344,6 @@ func labeledEditorWithFocus(
func labeledMultilineEditorWithFocus(
th *material.Theme,
prefs accessibilityPreferences,
label string,
editor *widget.Editor,
sensitive bool,
@@ -1521,7 +1358,7 @@ func labeledMultilineEditorWithFocus(
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return outlinedFieldStateWithPrefs(gtx, prefs, focused, func(gtx layout.Context) layout.Dimensions {
return outlinedFieldState(gtx, focused, func(gtx layout.Context) layout.Dimensions {
mask := editor.Mask
if sensitive {
editor.Mask = '•'
-300
View File
@@ -1,300 +0,0 @@
package main
import (
"encoding/json"
"image/color"
"os"
"path/filepath"
"strings"
"time"
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"git.julianfamily.org/keepassgo/vault"
)
const (
displayDensityDense = "dense"
displayDensityComfortable = "comfortable"
contrastStandard = "standard"
contrastHigh = "high"
keyboardFocusStandard = "standard"
keyboardFocusProminent = "prominent"
)
type accessibilityPreferences struct {
DisplayDensity string
Contrast string
ReducedMotion bool
KeyboardFocus string
}
type settingsFile struct {
Sync syncSettings `json:"sync,omitempty"`
}
type syncSettings struct {
SourceDefault string `json:"sourceDefault,omitempty"`
DirectionDefault string `json:"directionDefault,omitempty"`
}
type syncSettingsDraft struct {
SourceDefault syncSourceMode
DirectionDefault syncDirection
}
type settingsDraft struct {
Accessibility accessibilityPreferences
Sync syncSettingsDraft
}
type legacySyncPreferences struct {
SyncSourceDefault string `json:"syncSourceDefault,omitempty"`
SyncDirectionDefault string `json:"syncDirectionDefault,omitempty"`
}
type choiceSpec struct {
Click *widget.Clickable
Label string
Active bool
}
func defaultAccessibilityPreferences() accessibilityPreferences {
return accessibilityPreferences{
DisplayDensity: displayDensityForDenseLayout(true),
Contrast: contrastStandard,
KeyboardFocus: keyboardFocusStandard,
}
}
func displayDensityForDenseLayout(dense bool) string {
if dense {
return displayDensityDense
}
return displayDensityComfortable
}
func normalizeAccessibilityPreferences(prefs accessibilityPreferences) accessibilityPreferences {
normalized := defaultAccessibilityPreferences()
switch prefs.DisplayDensity {
case displayDensityDense, displayDensityComfortable:
normalized.DisplayDensity = prefs.DisplayDensity
}
switch prefs.Contrast {
case contrastStandard, contrastHigh:
normalized.Contrast = prefs.Contrast
}
switch prefs.KeyboardFocus {
case keyboardFocusStandard, keyboardFocusProminent:
normalized.KeyboardFocus = prefs.KeyboardFocus
}
normalized.ReducedMotion = prefs.ReducedMotion
return normalized
}
func (u *ui) applyAccessibilityPreferences(prefs accessibilityPreferences) {
normalized := normalizeAccessibilityPreferences(prefs)
u.denseLayout = normalized.DisplayDensity == displayDensityDense
u.accessibilityPrefs = normalized
}
func (u *ui) loadSettingsDraft() {
u.settingsDraft = settingsDraft{
Accessibility: accessibilityPreferences{
DisplayDensity: displayDensityForDenseLayout(u.denseLayout),
Contrast: u.accessibilityPrefs.Contrast,
ReducedMotion: u.accessibilityPrefs.ReducedMotion,
KeyboardFocus: u.accessibilityPrefs.KeyboardFocus,
},
Sync: syncSettingsDraft{
SourceDefault: u.syncDefaultSourceMode,
DirectionDefault: u.syncDefaultDirection,
},
}
}
func (u *ui) saveSecuritySettingsAction() error {
if err := u.applySecuritySettingsLive(); err != nil {
return err
}
u.securityDialogOpen = false
return nil
}
func (u *ui) applySecuritySettingsLive() error {
settings := vault.SecuritySettings{
Cipher: strings.TrimSpace(u.securityCipher.Text()),
KDF: strings.TrimSpace(u.securityKDF.Text()),
}
if err := u.state.ConfigureSecurity(settings); err != nil {
return err
}
if u.settingsDraft.Accessibility.DisplayDensity == displayDensityForDenseLayout(u.denseLayout) {
u.settingsDraft.Accessibility.DisplayDensity = displayDensityForDenseLayout(u.settingsDenseLayout.Value)
}
u.settingsDenseLayout.Value = u.settingsDraft.Accessibility.DisplayDensity == displayDensityDense
u.syncDefaultSourceMode = sanitizeSyncSourceMode(u.settingsDraft.Sync.SourceDefault)
u.syncDefaultDirection = sanitizeSyncDirection(u.settingsDraft.Sync.DirectionDefault)
u.applySettingsFormToPreferences()
u.applyAccessibilityPreferences(u.settingsDraft.Accessibility)
u.saveSettings()
u.saveUIPreferences()
return nil
}
func (u *ui) loadSettings() {
u.syncDefaultSourceMode = syncSourceLocal
u.syncDefaultDirection = syncDirectionPull
if strings.TrimSpace(u.settingsPath) != "" {
content, err := os.ReadFile(u.settingsPath)
if err == nil {
var settings settingsFile
if json.Unmarshal(content, &settings) == nil {
u.syncDefaultSourceMode = sanitizeSyncSourceMode(syncSourceMode(settings.Sync.SourceDefault))
u.syncDefaultDirection = sanitizeSyncDirection(syncDirection(settings.Sync.DirectionDefault))
return
}
}
}
u.loadLegacySyncDefaultsFromUIPreferences()
}
func (u *ui) loadLegacySyncDefaultsFromUIPreferences() {
if strings.TrimSpace(u.uiPreferencesPath) == "" {
return
}
content, err := os.ReadFile(u.uiPreferencesPath)
if err != nil {
return
}
var prefs legacySyncPreferences
if err := json.Unmarshal(content, &prefs); err != nil {
return
}
u.syncDefaultSourceMode = sanitizeSyncSourceMode(syncSourceMode(prefs.SyncSourceDefault))
u.syncDefaultDirection = sanitizeSyncDirection(syncDirection(prefs.SyncDirectionDefault))
}
func (u *ui) saveSettings() {
if strings.TrimSpace(u.settingsPath) == "" {
return
}
if err := os.MkdirAll(filepath.Dir(u.settingsPath), 0o700); err != nil {
return
}
content, err := json.MarshalIndent(settingsFile{
Sync: syncSettings{
SourceDefault: string(u.syncDefaultSourceMode),
DirectionDefault: string(u.syncDefaultDirection),
},
}, "", " ")
if err != nil {
return
}
_ = os.WriteFile(u.settingsPath, content, 0o600)
}
func (u *ui) showStatusMessage(message string) {
u.state.StatusMessage = message
if u.accessibilityPrefs.ReducedMotion {
u.statusExpiresAt = time.Time{}
return
}
u.statusExpiresAt = u.now().Add(u.statusBannerTTL)
}
func (u *ui) settingsPreferenceCard(gtx layout.Context, title, detail string, body layout.Widget) layout.Dimensions {
return sectionCard(gtx, u.theme, title, detail, body)
}
func settingsSummaryCard(gtx layout.Context, th *material.Theme, title, body string) layout.Dimensions {
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(12), title)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(13), body)
lbl.Color = th.Palette.Fg
return lbl.Layout(gtx)
}),
)
})
}
func (u *ui) settingsChoiceRow(gtx layout.Context, choices ...choiceSpec) layout.Dimensions {
children := make([]layout.FlexChild, 0, len(choices)*2)
for i, choice := range choices {
if i > 0 {
children = append(children, layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout))
}
current := choice
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncChoiceButton(gtx, u.theme, current.Click, current.Label, current.Active)
}))
}
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, children...)
}
type listRowColors struct {
Title color.NRGBA
Meta color.NRGBA
Secondary color.NRGBA
Divider color.NRGBA
Fill color.NRGBA
Edge color.NRGBA
}
func (u *ui) listRowColors(selected, focused, recycleBin bool) listRowColors {
colors := listRowColors{
Title: accentColor,
Meta: color.NRGBA{R: 61, G: 60, B: 56, A: 255},
Secondary: mutedColor,
Divider: color.NRGBA{R: 225, G: 219, B: 210, A: 255},
Fill: color.NRGBA{R: 231, G: 239, B: 235, A: 255},
Edge: color.NRGBA{R: 69, G: 118, B: 97, A: 255},
}
if selected {
colors.Title = color.NRGBA{R: 19, G: 57, B: 43, A: 255}
colors.Meta = color.NRGBA{R: 31, G: 53, B: 44, A: 255}
colors.Secondary = color.NRGBA{R: 72, G: 88, B: 80, A: 255}
colors.Divider = color.NRGBA{R: 173, G: 196, B: 184, A: 255}
colors.Fill = color.NRGBA{R: 212, G: 228, B: 220, A: 255}
colors.Edge = color.NRGBA{R: 46, G: 106, B: 82, A: 255}
}
if recycleBin {
colors.Fill = color.NRGBA{R: 244, G: 229, B: 219, A: 255}
colors.Edge = color.NRGBA{R: 133, G: 65, B: 41, A: 255}
}
if focused && !selected {
colors.Meta = color.NRGBA{R: 49, G: 74, B: 63, A: 255}
colors.Secondary = color.NRGBA{R: 86, G: 102, B: 95, A: 255}
colors.Divider = color.NRGBA{R: 190, G: 208, B: 199, A: 255}
}
if u.accessibilityPrefs.Contrast == contrastHigh {
colors.Meta = color.NRGBA{R: 39, G: 39, B: 36, A: 255}
colors.Secondary = color.NRGBA{R: 58, G: 57, B: 52, A: 255}
if focused || selected {
colors.Fill = color.NRGBA{R: 211, G: 228, B: 219, A: 255}
colors.Edge = color.NRGBA{R: 16, G: 60, B: 44, A: 255}
}
}
if u.accessibilityPrefs.KeyboardFocus == keyboardFocusProminent && focused && !selected {
colors.Fill = color.NRGBA{R: 220, G: 234, B: 226, A: 255}
colors.Edge = color.NRGBA{R: 20, G: 74, B: 55, A: 255}
}
if recycleBin && (focused || selected) && u.accessibilityPrefs.Contrast == contrastHigh {
colors.Fill = color.NRGBA{R: 242, G: 223, B: 209, A: 255}
colors.Edge = color.NRGBA{R: 116, G: 43, B: 19, A: 255}
}
return colors
}
-5
View File
@@ -37,8 +37,6 @@ func (u *ui) processShortcuts(gtx layout.Context) {
key.Filter{Name: key.NameUpArrow},
key.Filter{Name: key.NameDownArrow},
key.Filter{Name: key.NameReturn},
key.Filter{Name: key.NameBack},
key.Filter{Name: key.NameEscape},
)
if !ok {
break
@@ -50,9 +48,6 @@ func (u *ui) processShortcuts(gtx layout.Context) {
}
u.handleKeyPress(ke.Name, ke.Modifiers)
if ke.Name == key.NameBack || ke.Name == key.NameEscape {
_ = u.handlePhoneBack()
}
}
}
+28 -28
View File
@@ -8,11 +8,11 @@ func TestUpsertEntryPreservesPreviousVersionInHistory(t *testing.T) {
model := Model{
Entries: []Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "old-token",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Notes: "Original note",
Path: []string{"Root", "Internet"},
},
@@ -20,11 +20,11 @@ func TestUpsertEntryPreservesPreviousVersionInHistory(t *testing.T) {
}
model.UpsertEntry(Entry{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "new-token",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Notes: "Updated note",
Path: []string{"Root", "Internet"},
})
@@ -53,17 +53,17 @@ func TestDeleteEntryMovesItToRecycleBin(t *testing.T) {
model := Model{
Entries: []Entry{
{
ID: "surveillance-console",
Title: "Surveillance Console",
ID: "ha-codex",
Title: "Home Assistant (Codex)",
Username: "codex",
Password: "token-2",
URL: "https://surveillance.crew.example.invalid",
URL: "https://lights.julianfamily.org",
Path: []string{"Root", "Home Assistant"},
},
},
}
if err := model.DeleteEntry("surveillance-console"); err != nil {
if err := model.DeleteEntry("ha-codex"); err != nil {
t.Fatalf("DeleteEntry() error = %v", err)
}
@@ -75,8 +75,8 @@ func TestDeleteEntryMovesItToRecycleBin(t *testing.T) {
t.Fatalf("len(RecycleBin) = %d, want 1", len(model.RecycleBin))
}
if model.RecycleBin[0].Title != "Surveillance Console" {
t.Fatalf("RecycleBin[0].Title = %q, want %q", model.RecycleBin[0].Title, "Surveillance Console")
if model.RecycleBin[0].Title != "Home Assistant (Codex)" {
t.Fatalf("RecycleBin[0].Title = %q, want %q", model.RecycleBin[0].Title, "Home Assistant (Codex)")
}
}
@@ -86,17 +86,17 @@ func TestRestoreEntryMovesItBackFromRecycleBin(t *testing.T) {
model := Model{
RecycleBin: []Entry{
{
ID: "bellagio",
Title: "Bellagio",
Username: "rustyryan",
ID: "dynadot",
Title: "Dynadot",
Username: "jjulian",
Password: "token-3",
URL: "https://bellagio.example.invalid",
URL: "https://www.dynadot.com",
Path: []string{"Root", "Internet"},
},
},
}
if err := model.RestoreEntry("bellagio"); err != nil {
if err := model.RestoreEntry("dynadot"); err != nil {
t.Fatalf("RestoreEntry() error = %v", err)
}
@@ -105,8 +105,8 @@ func TestRestoreEntryMovesItBackFromRecycleBin(t *testing.T) {
t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got))
}
if got[0].Title != "Bellagio" {
t.Fatalf("EntriesInPath()[0].Title = %q, want %q", got[0].Title, "Bellagio")
if got[0].Title != "Dynadot" {
t.Fatalf("EntriesInPath()[0].Title = %q, want %q", got[0].Title, "Dynadot")
}
if len(model.RecycleBin) != 0 {
@@ -120,17 +120,17 @@ func TestRestoreEntryVersionPromotesHistoricalVersionAndRetainsCurrentInHistory(
model := Model{
Entries: []Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "new-token",
Notes: "Current note",
Path: []string{"Root", "Internet"},
History: []Entry{
{
ID: "vault-console-history-1",
Title: "Vault Console",
Username: "dannyocean",
ID: "git-server-history-1",
Title: "Git Server",
Username: "joejulian",
Password: "old-token",
Notes: "Previous note",
Path: []string{"Root", "Internet"},
@@ -140,7 +140,7 @@ func TestRestoreEntryVersionPromotesHistoricalVersionAndRetainsCurrentInHistory(
},
}
if err := model.RestoreEntryVersion("vault-console", 0); err != nil {
if err := model.RestoreEntryVersion("git-server", 0); err != nil {
t.Fatalf("RestoreEntryVersion() error = %v", err)
}
+46 -46
View File
@@ -22,11 +22,11 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
Groups: []gokeepasslib.Group{
mustGroup("Root",
mustGroup("Internet",
mustEntry("Bellagio", "rustyryan", "https://bellagio.example.invalid", "hunter2"),
mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"),
mustEntry("Dynadot", "jjulian", "https://www.dynadot.com", "hunter2"),
mustEntry("Git Server", "joejulian", "https://git.julianfamily.org", "token-1"),
),
mustGroup("Home Assistant",
mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2"),
mustEntry("Home Assistant (Codex)", "codex", "https://lights.julianfamily.org", "token-2"),
),
),
},
@@ -53,11 +53,11 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
t.Fatalf("len(EntriesInPath()) = %d, want 2", len(got))
}
if got[0].Title != "Bellagio" || got[0].Username != "rustyryan" || got[0].URL != "https://bellagio.example.invalid" {
if got[0].Title != "Dynadot" || got[0].Username != "jjulian" || got[0].URL != "https://www.dynadot.com" {
t.Fatalf("unexpected first entry: %#v", got[0])
}
if got[1].Title != "Vault Console" || got[1].Username != "dannyocean" || got[1].URL != "https://vault.crew.example.invalid" {
if got[1].Title != "Git Server" || got[1].Username != "joejulian" || got[1].URL != "https://git.julianfamily.org" {
t.Fatalf("unexpected second entry: %#v", got[1])
}
@@ -70,7 +70,7 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
func TestLoadKDBXPreservesEntryDetails(t *testing.T) {
t.Parallel()
entry := mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2")
entry := mustEntry("Home Assistant (Codex)", "codex", "https://lights.julianfamily.org", "token-2")
entry.Tags = "automation; home"
entry.Values = append(entry.Values,
mkValue("Notes", "Long-lived token used by Codex for home automation tasks."),
@@ -133,10 +133,10 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
Entries: []Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Notes: "Personal git server token entry used for automation and CLI auth.",
Tags: []string{"git", "infra"},
Fields: map[string]string{
@@ -146,10 +146,10 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
},
{
ID: "entry-2",
Title: "Surveillance Console",
Title: "Home Assistant (Codex)",
Username: "codex",
Password: "token-2",
URL: "https://surveillance.crew.example.invalid",
URL: "https://lights.julianfamily.org",
Notes: "Long-lived token used by Codex for home automation tasks.",
Tags: []string{"automation", "home"},
Path: []string{"Root", "Home Assistant"},
@@ -167,7 +167,7 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
t.Fatalf("LoadKDBX() error = %v", err)
}
got := loaded.Search("vault")
got := loaded.Search("git")
if len(got) != 1 {
t.Fatalf("len(Search(\"git\")) = %d, want 1", len(got))
}
@@ -245,18 +245,18 @@ func TestSaveKDBXRoundTripsEntryHistory(t *testing.T) {
Entries: []Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "new-token",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
History: []Entry{
{
ID: "entry-1-old",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "old-token",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
Notes: "Original version",
},
@@ -296,10 +296,10 @@ func TestSaveKDBXRoundTripsRecycleBinEntries(t *testing.T) {
RecycleBin: []Entry{
{
ID: "entry-1",
Title: "Surveillance Console",
Title: "Home Assistant (Codex)",
Username: "codex",
Password: "token-2",
URL: "https://surveillance.crew.example.invalid",
URL: "https://lights.julianfamily.org",
Path: []string{"Root", "Home Assistant"},
},
},
@@ -319,8 +319,8 @@ func TestSaveKDBXRoundTripsRecycleBinEntries(t *testing.T) {
t.Fatalf("len(RecycleBin) = %d, want 1", len(loaded.RecycleBin))
}
if loaded.RecycleBin[0].Title != "Surveillance Console" {
t.Fatalf("RecycleBin[0].Title = %q, want %q", loaded.RecycleBin[0].Title, "Surveillance Console")
if loaded.RecycleBin[0].Title != "Home Assistant (Codex)" {
t.Fatalf("RecycleBin[0].Title = %q, want %q", loaded.RecycleBin[0].Title, "Home Assistant (Codex)")
}
if len(loaded.RecycleBin[0].Path) != 2 || loaded.RecycleBin[0].Path[0] != "Root" || loaded.RecycleBin[0].Path[1] != "Home Assistant" {
@@ -358,7 +358,7 @@ func TestLoadKDBXWithKeyFileCredentials(t *testing.T) {
Meta: gokeepasslib.NewMetaData(),
Root: &gokeepasslib.RootData{
Groups: []gokeepasslib.Group{
mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"))),
mustGroup("Root", mustGroup("Internet", mustEntry("Git Server", "joejulian", "https://git.julianfamily.org", "token-1"))),
},
},
},
@@ -378,9 +378,9 @@ func TestLoadKDBXWithKeyFileCredentials(t *testing.T) {
t.Fatalf("LoadKDBXWithKey() error = %v", err)
}
got := model.Search("vault")
got := model.Search("git")
if len(got) != 1 || got[0].Entry.Password != "token-1" {
t.Fatalf("LoadKDBXWithKey() = %#v, want password-preserving vault entry", got)
t.Fatalf("LoadKDBXWithKey() = %#v, want password-preserving git entry", got)
}
}
@@ -413,7 +413,7 @@ func TestLoadKDBXWithCompositeCredentials(t *testing.T) {
Meta: gokeepasslib.NewMetaData(),
Root: &gokeepasslib.RootData{
Groups: []gokeepasslib.Group{
mustGroup("Root", mustGroup("Home Assistant", mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2"))),
mustGroup("Root", mustGroup("Home Assistant", mustEntry("Home Assistant (Codex)", "codex", "https://lights.julianfamily.org", "token-2"))),
},
},
},
@@ -452,7 +452,7 @@ func TestLoadKDBXReturnsInvalidCredentialsError(t *testing.T) {
Meta: gokeepasslib.NewMetaData(),
Root: &gokeepasslib.RootData{
Groups: []gokeepasslib.Group{
mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"))),
mustGroup("Root", mustGroup("Internet", mustEntry("Git Server", "joejulian", "https://git.julianfamily.org", "token-1"))),
},
},
},
@@ -490,11 +490,11 @@ func TestSaveKDBXWithKeyRoundTripsModel(t *testing.T) {
model := Model{
Entries: []Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
},
},
@@ -510,9 +510,9 @@ func TestSaveKDBXWithKeyRoundTripsModel(t *testing.T) {
t.Fatalf("LoadKDBXWithKey() error = %v", err)
}
got := loaded.Search("vault")
got := loaded.Search("git")
if len(got) != 1 || got[0].Entry.Password != "token-1" {
t.Fatalf("round-trip with key file = %#v, want vault entry with password", got)
t.Fatalf("round-trip with key file = %#v, want git entry with password", got)
}
}
@@ -533,11 +533,11 @@ func TestSaveKDBXWithCompositeKeyRoundTripsModel(t *testing.T) {
model := Model{
Entries: []Entry{
{
ID: "surveillance-console",
Title: "Surveillance Console",
ID: "ha-codex",
Title: "Home Assistant (Codex)",
Username: "codex",
Password: "token-2",
URL: "https://surveillance.crew.example.invalid",
URL: "https://lights.julianfamily.org",
Path: []string{"Root", "Home Assistant"},
},
},
@@ -570,11 +570,11 @@ func TestKDBXRoundTripsEntryAttachments(t *testing.T) {
model := Model{
Entries: []Entry{
{
ID: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
ID: "git-server",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Path: []string{"Root", "Internet"},
Attachments: map[string][]byte{
"token.txt": []byte("secret attachment contents"),
@@ -610,10 +610,10 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
Entries: []Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-2",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Notes: "Current credential",
Path: []string{"Root", "Internet"},
Attachments: map[string][]byte{
@@ -622,10 +622,10 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
History: []Entry{
{
ID: "entry-1-history-1",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
URL: "https://git.julianfamily.org",
Notes: "Original credential",
Path: []string{"Root", "Internet"},
},
+49 -49
View File
@@ -9,9 +9,9 @@ import (
func testModel() Model {
return Model{
Entries: []Entry{
{ID: "1", Title: "Bellagio", Username: "rustyryan", URL: "https://bellagio.example.invalid", Path: []string{"Crew", "Internet"}},
{ID: "2", Title: "Vault Console", Username: "dannyocean", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
{ID: "3", Title: "Surveillance Console", Username: "codex", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
{ID: "1", Title: "Dynadot", Username: "jjulian", URL: "https://www.dynadot.com", Path: []string{"Joe", "Internet"}},
{ID: "2", Title: "Git Server", Username: "joejulian", URL: "https://git.julianfamily.org", Path: []string{"Joe", "Internet"}},
{ID: "3", Title: "Home Assistant (Codex)", Username: "codex", URL: "https://lights.julianfamily.org", Path: []string{"Joe", "Home Assistant"}},
{ID: "4", Title: "Alma (WA Prep)", Username: "christina.julian", URL: "https://waprep.getalma.com", Path: []string{"Tricia", "School"}},
},
}
@@ -20,7 +20,7 @@ func testModel() Model {
func TestChildGroupsReturnsImmediateGroupsOnly(t *testing.T) {
model := testModel()
got := model.ChildGroups([]string{"Crew"})
got := model.ChildGroups([]string{"Joe"})
want := []string{"Home Assistant", "Internet"}
if !slices.Equal(got, want) {
@@ -31,12 +31,12 @@ func TestChildGroupsReturnsImmediateGroupsOnly(t *testing.T) {
func TestEntriesInPathReturnsOnlyDirectEntries(t *testing.T) {
model := testModel()
got := model.EntriesInPath([]string{"Crew", "Internet"})
got := model.EntriesInPath([]string{"Joe", "Internet"})
if len(got) != 2 {
t.Fatalf("len(EntriesInPath()) = %d, want 2", len(got))
}
if got[0].Title != "Bellagio" || got[1].Title != "Vault Console" {
if got[0].Title != "Dynadot" || got[1].Title != "Git Server" {
t.Fatalf("EntriesInPath() titles = %q, %q", got[0].Title, got[1].Title)
}
}
@@ -45,29 +45,29 @@ func TestEntriesUnderPathReturnsDescendantEntries(t *testing.T) {
t.Parallel()
model := testModel()
got := model.EntriesUnderPath([]string{"Crew"})
got := model.EntriesUnderPath([]string{"Joe"})
if len(got) != 3 {
t.Fatalf("len(EntriesUnderPath(Crew)) = %d, want 3", len(got))
t.Fatalf("len(EntriesUnderPath(Joe)) = %d, want 3", len(got))
}
if got[0].Title != "Bellagio" || got[1].Title != "Surveillance Console" || got[2].Title != "Vault Console" {
t.Fatalf("EntriesUnderPath(Crew) titles = %q, %q, %q", got[0].Title, got[1].Title, got[2].Title)
if got[0].Title != "Dynadot" || got[1].Title != "Git Server" || got[2].Title != "Home Assistant (Codex)" {
t.Fatalf("EntriesUnderPath(Joe) titles = %q, %q, %q", got[0].Title, got[1].Title, got[2].Title)
}
}
func TestSearchReturnsMatchesWithFullPathContext(t *testing.T) {
model := testModel()
got := model.Search("vault")
got := model.Search("git")
if len(got) != 1 {
t.Fatalf("len(Search()) = %d, want 1", len(got))
}
if got[0].Entry.Title != "Vault Console" {
t.Fatalf("Search() title = %q, want %q", got[0].Entry.Title, "Vault Console")
if got[0].Entry.Title != "Git Server" {
t.Fatalf("Search() title = %q, want %q", got[0].Entry.Title, "Git Server")
}
if got[0].Path != "Crew / Internet" {
t.Fatalf("Search() path = %q, want %q", got[0].Path, "Crew / Internet")
if got[0].Path != "Joe / Internet" {
t.Fatalf("Search() path = %q, want %q", got[0].Path, "Joe / Internet")
}
}
@@ -119,11 +119,11 @@ func TestInstantiateTemplateCreatesNormalEntryWithOverrides(t *testing.T) {
entry, err := model.InstantiateTemplate("tpl-1", Entry{
ID: "entry-1",
Title: "Bellagio",
Username: "rustyryan",
Title: "Dynadot",
Username: "jjulian",
Password: "hunter2",
URL: "https://bellagio.example.invalid",
Path: []string{"Crew", "Internet"},
URL: "https://www.dynadot.com",
Path: []string{"Joe", "Internet"},
Tags: []string{"dns"},
})
if err != nil {
@@ -134,11 +134,11 @@ func TestInstantiateTemplateCreatesNormalEntryWithOverrides(t *testing.T) {
t.Fatalf("entry.ID = %q, want %q", entry.ID, "entry-1")
}
if entry.Title != "Bellagio" {
t.Fatalf("entry.Title = %q, want %q", entry.Title, "Bellagio")
if entry.Title != "Dynadot" {
t.Fatalf("entry.Title = %q, want %q", entry.Title, "Dynadot")
}
if entry.Username != "rustyryan" || entry.Password != "hunter2" || entry.URL != "https://bellagio.example.invalid" {
if entry.Username != "jjulian" || entry.Password != "hunter2" || entry.URL != "https://www.dynadot.com" {
t.Fatalf("entry credentials = %#v, want override values", entry)
}
@@ -154,9 +154,9 @@ func TestInstantiateTemplateCreatesNormalEntryWithOverrides(t *testing.T) {
t.Fatalf("entry.Fields[Environment] = %q, want %q", entry.Fields["Environment"], "prod")
}
got := model.EntriesInPath([]string{"Crew", "Internet"})
if len(got) != 1 || got[0].Title != "Bellagio" {
t.Fatalf("EntriesInPath() = %#v, want instantiated Bellagio entry", got)
got := model.EntriesInPath([]string{"Joe", "Internet"})
if len(got) != 1 || got[0].Title != "Dynadot" {
t.Fatalf("EntriesInPath() = %#v, want instantiated Dynadot entry", got)
}
}
@@ -178,7 +178,7 @@ func TestDeleteTemplateRemovesTemplateWithoutTouchingEntries(t *testing.T) {
model := Model{
Entries: []Entry{
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
{ID: "entry-1", Title: "Git Server", Path: []string{"Root", "Internet"}},
},
Templates: []Entry{
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates"}},
@@ -222,8 +222,8 @@ func TestDuplicateEntryCopiesEntryWithNewIDAndTitle(t *testing.T) {
Entries: []Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Title: "Git Server",
Username: "joejulian",
Password: "token-1",
Path: []string{"Root", "Internet"},
},
@@ -238,8 +238,8 @@ func TestDuplicateEntryCopiesEntryWithNewIDAndTitle(t *testing.T) {
if duplicate.ID != "entry-2" {
t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "entry-2")
}
if duplicate.Title != "Vault Console (Copy)" {
t.Fatalf("duplicate.Title = %q, want %q", duplicate.Title, "Vault Console (Copy)")
if duplicate.Title != "Git Server (Copy)" {
t.Fatalf("duplicate.Title = %q, want %q", duplicate.Title, "Git Server (Copy)")
}
got := model.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 2 {
@@ -250,9 +250,9 @@ func TestDuplicateEntryCopiesEntryWithNewIDAndTitle(t *testing.T) {
func TestCreateGroupMakesItVisibleAsChildGroup(t *testing.T) {
model := testModel()
model.CreateGroup([]string{"Crew"}, "Finance")
model.CreateGroup([]string{"Joe"}, "Finance")
got := model.ChildGroups([]string{"Crew"})
got := model.ChildGroups([]string{"Joe"})
want := []string{"Finance", "Home Assistant", "Internet"}
if !slices.Equal(got, want) {
t.Fatalf("ChildGroups() = %v, want %v", got, want)
@@ -263,32 +263,32 @@ func TestCreateGroupSupportsNestedRelativePath(t *testing.T) {
t.Parallel()
model := testModel()
model.CreateGroup([]string{"Crew"}, "Infrastructure / Prod")
model.CreateGroup([]string{"Joe"}, "Infrastructure / Prod")
got := model.ChildGroups([]string{"Crew"})
got := model.ChildGroups([]string{"Joe"})
if !slices.Equal(got, []string{"Home Assistant", "Infrastructure", "Internet"}) {
t.Fatalf("ChildGroups(Crew) = %v, want [Home Assistant Infrastructure Internet]", got)
t.Fatalf("ChildGroups(Joe) = %v, want [Home Assistant Infrastructure Internet]", got)
}
got = model.ChildGroups([]string{"Crew", "Infrastructure"})
got = model.ChildGroups([]string{"Joe", "Infrastructure"})
if !slices.Equal(got, []string{"Prod"}) {
t.Fatalf("ChildGroups(Crew/Infrastructure) = %v, want [Prod]", got)
t.Fatalf("ChildGroups(Joe/Infrastructure) = %v, want [Prod]", got)
}
}
func TestRenameGroupMovesEntriesAndKeepsHierarchy(t *testing.T) {
model := testModel()
if err := model.RenameGroup([]string{"Crew", "Internet"}, "Infra"); err != nil {
if err := model.RenameGroup([]string{"Joe", "Internet"}, "Infra"); err != nil {
t.Fatalf("RenameGroup() error = %v", err)
}
got := model.EntriesInPath([]string{"Crew", "Infra"})
got := model.EntriesInPath([]string{"Joe", "Infra"})
if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Crew/Infra)) = %d, want 2", len(got))
t.Fatalf("len(EntriesInPath(Joe/Infra)) = %d, want 2", len(got))
}
if len(model.EntriesInPath([]string{"Crew", "Internet"})) != 0 {
t.Fatal("EntriesInPath(Crew/Internet) should be empty after rename")
if len(model.EntriesInPath([]string{"Joe", "Internet"})) != 0 {
t.Fatal("EntriesInPath(Joe/Internet) should be empty after rename")
}
}
@@ -309,8 +309,8 @@ func TestMoveGroupMovesEntriesAndNestedGroups(t *testing.T) {
t.Parallel()
model := testModel()
model.CreateGroup([]string{"Crew", "Internet"}, "Infrastructure")
if err := model.MoveGroup([]string{"Crew", "Internet"}, []string{"Tricia"}); err != nil {
model.CreateGroup([]string{"Joe", "Internet"}, "Infrastructure")
if err := model.MoveGroup([]string{"Joe", "Internet"}, []string{"Tricia"}); err != nil {
t.Fatalf("MoveGroup() error = %v", err)
}
@@ -318,8 +318,8 @@ func TestMoveGroupMovesEntriesAndNestedGroups(t *testing.T) {
if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Tricia/Internet)) = %d, want 2", len(got))
}
if len(model.EntriesInPath([]string{"Crew", "Internet"})) != 0 {
t.Fatal("EntriesInPath(Crew/Internet) should be empty after move")
if len(model.EntriesInPath([]string{"Joe", "Internet"})) != 0 {
t.Fatal("EntriesInPath(Joe/Internet) should be empty after move")
}
gotGroups := model.ChildGroups([]string{"Tricia", "Internet"})
if !slices.Equal(gotGroups, []string{"Infrastructure"}) {
@@ -330,12 +330,12 @@ func TestMoveGroupMovesEntriesAndNestedGroups(t *testing.T) {
func TestDeleteEmptyGroupRemovesItFromNavigation(t *testing.T) {
model := testModel()
model.CreateGroup([]string{"Crew"}, "Finance")
if err := model.DeleteGroup([]string{"Crew", "Finance"}); err != nil {
model.CreateGroup([]string{"Joe"}, "Finance")
if err := model.DeleteGroup([]string{"Joe", "Finance"}); err != nil {
t.Fatalf("DeleteGroup() error = %v", err)
}
got := model.ChildGroups([]string{"Crew"})
got := model.ChildGroups([]string{"Joe"})
want := []string{"Home Assistant", "Internet"}
if !slices.Equal(got, want) {
t.Fatalf("ChildGroups() = %v, want %v", got, want)
+5 -5
View File
@@ -15,8 +15,8 @@ func TestClientOpenDownloadsRemoteVault(t *testing.T) {
if r.Method != http.MethodGet {
t.Fatalf("method = %s, want GET", r.Method)
}
if user, pass, ok := r.BasicAuth(); !ok || user != "rustyryan" || pass != "secret" {
t.Fatalf("basic auth = %q/%q ok=%v, want rustyryan/secret true", user, pass, ok)
if user, pass, ok := r.BasicAuth(); !ok || user != "jjulian" || pass != "secret" {
t.Fatalf("basic auth = %q/%q ok=%v, want jjulian/secret true", user, pass, ok)
}
w.Header().Set("ETag", `"etag-1"`)
_, _ = io.WriteString(w, "vault-bytes")
@@ -26,7 +26,7 @@ func TestClientOpenDownloadsRemoteVault(t *testing.T) {
client := Client{
HTTPClient: server.Client(),
BaseURL: server.URL,
Username: "rustyryan",
Username: "jjulian",
Password: "secret",
}
@@ -69,7 +69,7 @@ func TestClientSaveUploadsVaultWithIfMatch(t *testing.T) {
client := Client{
HTTPClient: server.Client(),
BaseURL: server.URL,
Username: "rustyryan",
Username: "jjulian",
Password: "secret",
}
@@ -94,7 +94,7 @@ func TestClientSaveReturnsConflictOnVersionMismatch(t *testing.T) {
client := Client{
HTTPClient: server.Client(),
BaseURL: server.URL,
Username: "rustyryan",
Username: "jjulian",
Password: "secret",
}